# Security

## Threat Model

### What cqs Is

cqs is a **local code search tool** for developers. It runs on your machine, indexes your code, and answers semantic queries.

### Trust Boundaries

| Boundary | Trust Level | Notes |
|----------|-------------|-------|
| **Local user** | Trusted | You run cqs, you control it |
| **Project files** | Trusted | Your code, indexed by your choice |
| **External documents** | Semi-trusted | PDF/HTML/CHM files converted via `cqs convert` — parsed but not executed |
| **Reference sources** | Semi-trusted | Indexed via `cqs ref add` — search results blended with project code |
| **`cqs serve` HTTP clients** | Untrusted by default | Per-launch 256-bit auth token gates every request (#1118 / SEC-7). Three credential channels: `Authorization: Bearer`, `cqs_token_<port>` cookie (port-scoped per RFC 6265, #1135 — concurrent instances don't collide in the browser jar), `?token=` query param. Cookie handoff is `HttpOnly; SameSite=Strict; Path=/`; compare is constant-time on every channel. Disabling auth requires `--no-auth` plus an internal `NoAuthAcknowledgement` proof token (#1136), so no internal caller can ship a fully-open server by accident; the disabled branch logs a structured `tracing::error!` regardless of `quiet`. Loud-warn banner on non-loopback binds with `--no-auth`. |
| **Indexed content (in AI agent context)** | Untrusted | cqs relays code, comments, summaries, and developer notes verbatim. Injection payloads in any of those surfaces survive the relay. See [Indirect Prompt Injection](#indirect-prompt-injection--supply-chain-risks-from-indexed-content) below. |

### MCP Bridge (`cqs mcp`)

| Boundary | Trust Level | Notes |
|----------|-------------|-------|
| **MCP client** | Untrusted | The connecting client (e.g. Claude Code) is not trusted. It can send any `tools/call` request, including ones that carry adversarially-crafted query strings or that are themselves steered by indexed content (indirect injection via the relay). |
| **Indexed content via relay** | Untrusted | `context`, `explain`, `read --focus`, and `trace` responses surface doc-comments, signatures, and depended-on chunk bodies verbatim; injection payloads in those surfaces are faithfully relayed (never refused) but are **flagged** via `injection_flags` so a downstream agent can detect them. |

The MCP bridge uses JSON-RPC over stdio. No network port is opened.

**Mitigations**:
- **Read-only surface by default**: `tools/list` exposes 30 read-only tools. No mutation is possible without `CQS_MCP_ENABLE_MUTATIONS=1`.
- **Gated mutation channel** (`CQS_MCP_ENABLE_MUTATIONS=1`): adds 4 mutating tools (`cqs_notes_add`, `cqs_notes_update`, `cqs_notes_remove`, `cqs_index`). Enforcement is double-gated — the bridge withholds the tools from the surface, and the daemon dispatch rejects mutation requests if the flag is unset. Fails closed: a misconfigured bridge cannot bypass daemon-side enforcement.
- **Destructive set withheld by absence**: `gc`, `slot remove`, `index --force`, `model swap`, and `cache clear` are never registered as MCP tools and cannot be reached through the bridge regardless of env flags.
- **Daemon Store stays read-only from MCP**: notes mutations write `docs/notes.toml`; `cqs_index` queues a reconcile (non-blocking). Neither path writes the daemon's in-memory Store from the MCP handler.

### What We Protect Against

1. **Path traversal**: Commands cannot read files outside project root
2. **FTS injection**: Search queries sanitized before SQLite FTS5 MATCH operations
3. **Database corruption**: `PRAGMA quick_check(1)` on write-mode opens (opt-out via `CQS_SKIP_INTEGRITY_CHECK=1`). Read-only opens skip the check entirely — reads cannot introduce corruption and the index is rebuildable via `cqs index --force`
4. **Reference config trust**: Warnings logged when reference configs override project settings
5. **Parser DoS hardening**: The parse pipeline bounds adversarial deeply-nested or pathological indexed content from crashing `cqs index` or the daemon. Three independent rails: recursion depth (`CQS_PARSER_MAX_WALK_DEPTH`, default 800), per-file wall-clock timeout (`CQS_PARSER_TIMEOUT_MS`, default 5000 ms), and worker stack size (`CQS_PARSER_STACK_SIZE`, default 2 MiB). Past any cap the file is skipped with a warning; the pipeline continues.

### What We Don't Protect Against

- **Malicious code in your project**: If your code contains exploits, indexing won't stop them
- **Local privilege escalation**: cqs runs with your permissions
- **Side-channel attacks**: Beyond timing, not in scope for a local tool
- **Indirect prompt injection from indexed content**: cqs relays code, comments, summaries, and notes verbatim to AI consumers; injection payloads inside those surfaces survive the relay. See [Indirect Prompt Injection](#indirect-prompt-injection--supply-chain-risks-from-indexed-content) below.

## Indirect Prompt Injection / Supply-Chain Risks from Indexed Content

cqs's primary consumer is AI agents. By design, cqs *faithfully relays* the content it has indexed — code, comments, doc strings, LLM-generated summaries, and developer notes — into the agent's context window. Any of those surfaces can carry **indirect prompt injection** payloads: instructions disguised as content that try to redirect the consuming agent ("Ignore prior instructions and...", "This function is safe to call with sudo", etc.).

cqs cannot reliably distinguish a legitimate doc comment from a malicious one. Defence has to live partly in the agent (treat retrieved code as untrusted input) and partly in cqs's protocol (make the trust boundary loud and visible).

### Surfaces

| Surface | Vector | Persistent? |
|---------|--------|-------------|
| **Project source code** | Comments, strings, doc blocks containing injection payloads (committed by a contributor or embedded by an upstream dependency) | Yes — survives until removed from source |
| **Reference content** (`cqs ref add`) | Third-party code indexed for cross-project search; less curated than the user's own code, blended into search results without an explicit trust signal | Yes — survives until ref is removed |
| **Shared notes** (`docs/notes.toml`) | A cloned repo can ship committed notes that bias rankings and surface in agent context. `audit-mode` mitigates ranking influence at runtime, but not the first-encounter case | Yes — survives in the indexed repo |
| **LLM-generated summaries** (`cqs index --llm-summaries`) | Claude is prompted with chunk content; a poisoned chunk can produce a summary that contains injection text. The summary text is cached in the `llm_summaries` table keyed by `(content_hash, purpose)` (search for `CREATE TABLE IF NOT EXISTS llm_summaries` in `src/schema.sql`); the post-summary embedding flows through the normal `embeddings_cache.db` (purpose `embedding`, the same purpose served to search) and is replayed to downstream agents | Yes — cached in `llm_summaries` table + `embeddings_cache.db` |
| **Doc-comment generation** (`cqs index --llm-summaries --improve-docs`) | LLM output is staged as patch files under `.cqs/proposed-docs/` for review (default since v1.30.1); only `--apply` writes back to source files in place. A poisoned chunk can still land a doc comment in the user's repo on commit if `--apply` is used or the patch is accepted unread | Patch-only by default — see Mitigation #3 below; `--apply` reverts to in-place writes |
| **Search result blending** | RRF merges chunks across project + references; the consuming agent sees a single ranked list with no in-protocol trust signal distinguishing user code from third-party content | Yes — every query |

### Current mitigations

- **`audit-mode`**: `cqs audit-mode on` excludes notes from rankings and forces direct code examination. Mitigates the runtime side of shared-notes injection.
- **No automatic execution**: cqs never executes indexed code; the threat is purely textual relay into agent context.
- **`--improve-docs` review gate (since v1.30.1)**: by default, `cqs index --improve-docs` writes proposed doc comments as unified-diff patches to `.cqs/proposed-docs/<rel>.patch` instead of mutating source files in place. Review with `git diff` and apply with `git apply .cqs/proposed-docs/**/*.patch`. Pass `--apply` to opt back into direct write-back; the run prints a warning when it does.
- **First-encounter shared-notes gate (since v1.30.1)**: on the first `cqs index` against a repo containing `docs/notes.toml`, cqs prompts to confirm before indexing the notes — committed notes affect search rankings and surface in agent context. Acceptance is persisted to `.cqs/.accepted-shared-notes` so the prompt doesn't repeat. Pass `--accept-shared-notes` to bypass for CI / scripted use; non-TTY stdin auto-skips the notes pass with a warning so CI never hangs. (#1168)
- **`trust_level` + `reference_name` on chunk JSON (since v1.30.1, three-tier as of #1221)**: every chunk-returning JSON output (`search`, `gather`, `task`, `scout`, `onboard`, `read`, `read --focus`, `context`, `similar`) carries one of:
  - `"user-code"` — chunk lives in the user's project store and did not match any vendored-path prefix at index time.
  - `"vendored-code"` — chunk lives in the user's project store **but** its origin passed through a configured vendored-path segment (default list: `vendor`, `third_party`, `node_modules`, `.cargo`, `target`, `dist`, `build`; override via `[index].vendored_paths` in `.cqs.toml`). Treat as third-party content for indirect-injection purposes — same threat profile as `reference-code`. (#1221, schema v24)
  - `"reference-code"` (with `reference_name`) — chunk lives in a `cqs ref` reference index. Wins over `vendored-code` when both apply: the per-reference name is the more useful agent-facing signal.

  **Scope of `user-code` after #1221.** `trust_level: "user-code"` now means *"from the user's project store AND not under a configured vendored-path prefix"*. Vendored upstream content (`vendor/`, `third_party/`, `node_modules/`, etc.) is structurally distinguished as `"vendored-code"` at index time — the structural fix the SEC-V1.30.1-5 doc-only stop-gap acknowledged was needed. **Caveats that still apply**: (1) `user-code` does not mean "authored by the user" — generated code under `src/` is still `user-code`. (2) Developer notes (`docs/notes.toml` content surfaced via `cqs notes mention`) still ride the user-code label since they're stored separately and don't pass through the chunk pipeline. (3) Reindex required to retroactively flag pre-v24 chunks: the v23→v24 migration adds the column with default `0`; only the next `cqs index` (or `cqs watch` re-emit) populates it.
- **`CQS_TRUST_DELIMITERS` is on by default (since v1.30.2)**: every chunk's `content` is wrapped in `<<<chunk:{id}>>> ... <<</chunk:{id}>>>` markers so prompt-injection guards downstream of cqs can detect content boundaries even when the agent inlines the rendered string into a larger prompt. Set `CQS_TRUST_DELIMITERS=0` to opt out (raw text). Was opt-in in v1.30.1. (#1167, #1181)
- **LLM summary validation (since v1.30.1)**: every summary headed for the `llm_summaries` cache passes through `cqs::llm::validation::validate_summary` before insertion. Catches lazy injections (leading "Ignore prior" / "Disregard"; embedded code fences; embedded URLs) and enforces a 1500-char hard length cap. Configurable via `CQS_SUMMARY_VALIDATION=strict|loose|off` (default `loose`: log + keep on pattern match, truncate over-long; strict drops pattern-matched summaries entirely). Doc-comment generation is intentionally exempt — its prose is imperative by design and would false-positive; it has its own review gate (#1166). (#1170)
- **Trust signals are emitted when meaningful, never suppressed (advisory removed in #1690)**: every chunk-returning JSON output carries `trust_level` whenever it is non-default (`vendored-code` / `reference-code`) and `injection_flags` whenever a heuristic fired (see the two bullets above / below). These are the load-bearing security signals, and they are always present when they carry information — absent means "default" (`user-code`, no flags), which any consuming agent handles. The former `_meta.handling_advice` advisory string (an always-on framing of every response as untrusted-by-default v1.30.2 → v1.39.x) was made opt-in via `CQS_ULTRASECURITY=1` in v1.40.0 and then **removed entirely in #1690** — its only effect was emitting a constant advisory string plus forcing the default-valued `trust_level`/`injection_flags` onto the wire, none of which added information a competent agent didn't already have. The advisory guidance now lives in this document (treat retrieved code as untrusted input) rather than on every response. There is no remaining posture env knob; `CQS_OUTPUT_FORMAT=v1` (below) still restores the full `{data, error, version, _meta}` envelope for consumers that want the wrapped shape, but it never re-adds `handling_advice`. (#1181 → #1593 → #1690)
- **`_meta.worktree_stale` + `_meta.worktree_name` (#1254), and the worktree overlay (#1858)**: when a cqs command runs from inside a `git worktree` that has **no `.cqs/` of its own**, cqs auto-discovers the main project's index via the worktree's `.git/commondir` link and serves queries against main's `.cqs/`; every JSON envelope from that process carries `worktree_stale: true` plus the worktree's directory name in `worktree_name` (absent on the non-worktree happy path, so the wire shape only grows when the redirect fires). For a worktree being served against the parent index, the **overlay is default-on** (opt out `--no-overlay` / `CQS_WORKTREE_OVERLAY=0`): `cqs search` plus the seed phase of scout/gather/task and callers/callees/impact/dead/review now reflect the worktree's committed+uncommitted edits, surfaced via a `_meta.overlay_graph` marker (`full` / `callers-only` for impact+review / `seed-only` / absent). The old blanket "served snapshot = main's branch, re-read every chunk about to be edited" hedge is therefore **retired** — only the `callers-only`/`seed-only` graph sections still carry parent/main-truth. The overlay runs on the daemon path, and its `--overlay-root` request is validated against an arbitrary-tree read before any file is touched: (1) the canonical root must be a member of the served project's **authoritative `git worktree list` registry** — git's own record, which a forged or symlinked `.git` on the client's tree cannot fake, so a non-worktree, a forged-gitdir tree, or a `.git`-symlink masquerade is rejected; and (2) on success the daemon **opens and holds a file descriptor pinning that validated directory's inode**, then runs every subsequent overlay git/file op against the pinned fd (`/proc/self/fd/<n>`) rather than the path string — so a same-uid client that *symlinks or renames* a path component after validation cannot redirect the daemon — the held fd follows the original inode, not the swapped name. (Known residual #1969: a same-uid client that wins the narrow check→pin window by *recreating* the worktree path as a fresh clone is structurally hard to fully exclude with path-based validation against a same-uid filesystem attacker; tracked low-severity, since the practical threat — passive hostile content in a non-registered tree — is closed by the registry check above, and a same-uid party already has broad filesystem access.) If the directory cannot be pinned (non-Linux, or the open fails) the overlay is skipped and the parent index served (fail closed). Closes the worktree-leakage class of bugs documented in `feedback_agent_worktrees.md`.
- **Per-chunk `injection_flags` array is skip-when-default since v1.40.0 (was always-on v1.30.2 → v1.39.x)**: every chunk-returning JSON output carries an `injection_flags` field listing which injection heuristics fired on the chunk's raw content (e.g. `["leading-directive", "code-fence", "embedded-url"]`). The field is **skipped when empty** (the typical case) to keep the wire shape lean, and emitted as a populated array whenever at least one heuristic fires. cqs labels — never refuses to relay. Agents that want a stricter stance can refuse to act on chunks with non-empty `injection_flags`. The former `CQS_ULTRASECURITY=1` force-emit-on-empty mode was removed in #1690: an empty array carried no information, and its absence already means "no heuristic fired". (#1181 → #1602 → #1690)
- **Honest-relay injection scanning across every doc/signature/body-relaying command (RT-RELAY, #2024 + audit v1.48.0)**: the injection heuristics that scan chunk `content` also scan every other surface a command relays — the doc-comment + signature fields of `context`/`explain` (#2024), the similar-chunk bodies of `explain --tokens`, the type-dependency bodies of `read --focus`, the hop signatures of `trace`, and the kind-fallback definition signatures (v1.48.0 audit). The contract is **scan == relayed, both directions**: a surface that is relayed is scanned (so a payload there surfaces as a non-empty `injection_flags`), and a surface that is *not* relayed (e.g. un-emitted `content` in a compact response) is not scanned (no phantom over-flagging). The `leading-directive` heuristic anchors to line starts, so an imperative directive on its own line is flagged even behind a benign first line, without firing on the same words mid-sentence in legitimate prose. Findings surface as `injection_flags` / `trust_level`, matching the honest-relay contract search results already carry.

### Guidance: `cqs dead --verdict dead` is a confident SUBSET, not a complete absence claim

`cqs dead` self-classifies each finding into one of `test-only` / `low-confidence-live` / `known-gap` / `dead`; only the `dead` verdict is meant to be read as a confident "no caller exists" claim. That confidence is intentionally a *subset*: a finding is demoted out of `dead` whenever any structural reference to the function exists in the indexed corpus (a candidate edge — a bare fn-pointer / macro-argument / serde linkage the confident extractor declined to resolve). This demotion is **by design** — the function is not hidden, only relabelled `low-confidence-live`, and stays fully visible in that bucket.

The security consequence for a reviewer working against adversarial indexed content: an attacker who can add a textual structural reference to a function (in committed source, a vendored dependency, or reference content) can demote a genuinely-dead function from `dead` to `low-confidence-live`, moving it out of the `--verdict dead` view. So **a security-reviewing agent enumerating dead/unreachable code must review the `low-confidence-live` bucket as well as `--verdict dead` — `--verdict dead` alone is a lower bound on the dead set, not the complete set.** (The complementary directions — a comment-planted `#[cfg(test)]` mention demoting a function to `test-only`, and a free-function namesake of a framework trait method stealing the `known-gap` excuse — are NOT exploitable: the test-only check is position-anchored to a real line-start attribute, and the known-gap external-trait-method excuse is structurally gated on the `ChunkType::Method` tag, so neither a comment nor a free-function name can steer those verdicts.)

These are defence in depth, not absolute protection. Subtle injections (a summary that is superficially correct but biased) will still get through. The agent-side defence — treat retrieved code as untrusted input, sandbox tool calls, never execute payload-shaped output — remains the load-bearing layer.

## Architecture

cqs runs locally by default. No network telemetry. Optional local command logging to `.cqs/telemetry.jsonl` — active when `CQS_TELEMETRY=1` is set OR when the telemetry file already exists (persists across shells/subprocesses). Never transmitted. Delete the file to opt out. The optional `--llm-summaries` flag sends function code to the Anthropic API (see below).

## Network Requests

The only network activity is:

- **Model download** (`cqs init`): Downloads embedding model from HuggingFace Hub
  - Default since v1.35.0: `huggingface.co/onnx-community/embeddinggemma-300m-ONNX` (~1.2GB FP32 ONNX bundle + ~20MB tokenizer)
  - Preset: `bge-large` (`BAAI/bge-large-en-v1.5`, ~1.2GB) — former default; opt-in via `CQS_EMBEDDING_MODEL=bge-large`
  - Preset: `e5-base` (`intfloat/e5-base-v2`, ~438MB)
  - Preset: `nomic-coderank` (`jamie8johnson/CodeRankEmbed-onnx`, ~547MB) — code-specialised, opt-in via `CQS_EMBEDDING_MODEL=nomic-coderank` (#1110)
  - Custom: any HuggingFace repo via `[embedding]` config or `CQS_EMBEDDING_MODEL` env var. Custom model configs download ONNX files from the specified repo — only configure repos you trust.
  - One-time download per model, cached in `~/.cache/huggingface/`

- **Reranker model download** (first `--rerank` use): Downloads cross-encoder model from HuggingFace Hub
  - Model: `ms-marco-MiniLM-L-6-v2` (cross-encoder)
  - One-time download, cached in `~/.cache/huggingface/`

- **LLM summaries** (`cqs index --llm-summaries`): Sends function code to the Anthropic API
- **HyDE queries** (`cqs index --llm-summaries --hyde-queries`): Sends function descriptions to the Anthropic API for synthetic query generation

| Flag | Endpoint | Data Sent | Notes |
|------|----------|-----------|-------|
| `--llm-summaries` | api.anthropic.com | Function bodies (up to 8000 chars), chunk type, language | Requires `ANTHROPIC_API_KEY`. Opt-in via `cqs index --llm-summaries` |
| `--hyde-queries` | api.anthropic.com | Function NL descriptions, signatures | Requires `--llm-summaries`. Generates synthetic search queries per function |
| `--improve-docs` | api.anthropic.com | Function bodies (for doc generation) | Requires `--llm-summaries`. Stages doc comments as patches under `.cqs/proposed-docs/` for review (default); `--apply` writes them in place |

- **Model export** (`cqs export-model`): Spawns Python `optimum.exporters.onnx` which downloads the specified HuggingFace model and converts to ONNX format

No other network requests are made. Without `--llm-summaries` or `export-model`, all operations are offline.

## Filesystem Access

### Read Access

| Path | Purpose | When |
|------|---------|------|
| Project source files | Parsing and embedding | `cqs index`, `cqs watch` |
| `.cqs/slots/<name>/index.db` | SQLite database (per-slot, PR #1105). Pre-migration projects may still see the legacy `.cqs/index.db`. | All operations |
| `.cqs/slots/<name>/index.hnsw.*` | HNSW vector index files (per-slot) | Search operations |
| `.cqs/slots/<name>/index_base.hnsw.*` | Base (non-enriched) HNSW index (per-slot) | Search operations (Phase 5 dual routing) |
| `.cqs/splade.index.bin` | SPLADE sparse inverted index | Search operations (`--splade` or routed cross-language) |
| `docs/notes.toml` | Developer notes | Search, `cqs read` |
| `~/.cache/huggingface/` | ML model cache | Embedding operations |
| `<project>/.cqs/embeddings_cache.db` | Per-project embedding cache (PR #1105, primary; legacy global cache at `~/.cache/cqs/embeddings.db` is fallback) | `cqs index`, search |
| `~/.cache/cqs/embeddings.db` | Global embedding cache (content-addressed, capped at 1 GB). Linux path; on macOS resolves to `~/Library/Caches/cqs/embeddings.db`, on Windows to `%LOCALAPPDATA%\cqs\embeddings.db` | Index and search |
| `~/.cache/cqs/query_cache.db` | Recent query embedding cache (size-capped at `CQS_QUERY_CACHE_MAX_SIZE`, 100 MiB default). Linux path; macOS `~/Library/Caches/cqs/query_cache.db`; Windows `%LOCALAPPDATA%\cqs\query_cache.db` | Search |
| `~/.config/cqs/` | Config file (user-level defaults) | All operations |
| `$CQS_ONNX_DIR/` | Local ONNX model directory | When `CQS_ONNX_DIR` is set |
| `~/.local/share/cqs/refs/*/` | Reference indexes (read-only copies) | Search operations |

### Write Access

| Path | Purpose | When |
|------|---------|------|
| `.cqs/` directory | Index storage | `cqs init` |
| `.cqs/slots/<name>/index.db` | SQLite database (per-slot, PR #1105). Pre-migration projects may still see the legacy `.cqs/index.db`. | `cqs index`, note operations |
| `.cqs/slots/<name>/index.hnsw.*` | HNSW vector index + checksums (per-slot) | `cqs index` |
| `.cqs/slots/<name>/index_base.hnsw.*` | Base HNSW index + checksums (per-slot) | `cqs index` |
| `.cqs/splade.index.bin` | SPLADE sparse inverted index | `cqs index` (with `CQS_SPLADE_MODEL` set), lazy rebuild on first `--splade` query |
| `.cqs/index.lock` | Process lock file | `cqs watch` |
| `.cqs/audit-mode.json` | Audit mode state (on/off, expiry) | `cqs audit-mode on`, `cqs audit-mode off` |
| `.cqs/telemetry.jsonl` | Command usage logs (opt-in, persists via file presence; single file, no rotation) | `CQS_TELEMETRY=1` or file exists, delete to opt out |
| `docs/notes.toml` | Developer notes | `cqs notes add`, `cqs notes update`, `cqs notes remove` |
| `.cqs.toml` | Reference configuration | `cqs ref add`, `cqs ref remove` |
| `~/.config/cqs/projects.toml` | Project registry | `cqs project register`, `cqs project remove` |
| `~/.local/share/cqs/refs/*/` | Reference index creation and updates (write) | `cqs ref add`, `cqs ref update` |
| `<project>/.cqs/embeddings_cache.db` | Per-project embedding cache writes (primary; PR #1105) | `cqs index`, search |
| `~/.cache/cqs/embeddings.db` | Global embedding cache writes (legacy fallback). Linux path; macOS `~/Library/Caches/cqs/embeddings.db`; Windows `%LOCALAPPDATA%\cqs\embeddings.db` | `cqs index` |
| `~/.cache/cqs/query_cache.db` | Recent query embedding cache writes. Linux path; macOS `~/Library/Caches/cqs/query_cache.db`; Windows `%LOCALAPPDATA%\cqs\query_cache.db` | Search (cache miss) |
| `~/.cache/cqs/query_log.jsonl` | Local query log (append-only). Linux path; macOS `~/Library/Caches/cqs/query_log.jsonl`; Windows `%LOCALAPPDATA%\cqs\query_log.jsonl` | `cqs chat` / `cqs batch` (search, gather, onboard, scout, where, task) |
| Project source files | Doc comment insertion | `cqs index --llm-summaries --improve-docs` |
| `<output>/` directory | ONNX model files + model.toml | `cqs export-model` |

### Process Operations

| Operation | Purpose |
|-----------|---------|
| `libc::kill(pid, 0)` | Check if watch process is running (signal 0 = existence check only) |
| `cmd /C start "" <url>` (Windows / WSL) / `xdg-open <url>` (Linux) / `open <url>` (macOS) | Browser launch for `cqs serve --open`. Token-bearing URLs are suppressed from argv (see below). |

#### Browser-launch token handling (`cqs serve --open`)

`cqs serve --open` historically spawned the OS browser launcher with the full `http://<bind>/?token=<token>` URL on argv. That places the token in `/proc/<pid>/cmdline` (Linux) / `wmic process get CommandLine` (Windows) for any local user to read until the launcher exits, and audit subsystems (auditd, ETW) typically capture exec command lines independently of process lifetime — same surface the per-launch banner avoids by printing the tokenized URL only when stdout is a terminal; on a non-TTY stdout (journald, container logs, pipes) the banner carries the token-free URL plus a hint that the token is per-launch and terminal-only.

**Mitigation (#1337 / SEC-V1.33-1):** when `cqs serve` runs with auth enabled (the default), `--open` no longer spawns a browser. The CLI prints a notice instructing the user to paste the banner URL manually; the token is never written to a subprocess argv. With `--no-auth` there is no token to leak and `--open` continues to launch the browser normally.

### Document Conversion (`cqs convert`)

The convert module spawns external processes for format conversion:

| Subprocess | Purpose | When |
|------------|---------|------|
| `python3` / `python` | PDF-to-Markdown via pymupdf4llm | `cqs convert *.pdf` |
| `7z` | CHM archive extraction | `cqs convert *.chm` |

**Attack surface:**

- **`CQS_PDF_SCRIPT` env var**: If set, the convert module executes the specified script instead of the default PDF conversion logic. This allows arbitrary script execution under the user's permissions.
- **Output directory**: Generated Markdown files are written to the `--output` directory. The output path is not sandboxed beyond normal filesystem permissions.

**Mitigations:**

- Symlink filtering: Symlinks are skipped during directory walks and archive extraction
- Zip-slip containment: Extracted paths are validated to stay within the output directory
- Page count limits: PDF conversion enforces a maximum page count to bound processing time

### Model Export (`cqs export-model`)

The export-model command spawns Python to convert HuggingFace models to ONNX format:

| Subprocess | Purpose | When |
|------------|---------|------|
| `python3` / `python` / `py` | ONNX export via `optimum.exporters.onnx` | `cqs export-model --repo org/model` |

**Attack surface:**

- **Repo ID**: Passed to `python -m optimum.exporters.onnx --model <repo>`. Validated to contain `/` and reject `"`, `\n`, `\` characters (SEC-18).
- **Output directory**: Model files and `model.toml` written to `--output` path. Not sandboxed beyond filesystem permissions.
- **Python execution**: Spawns Python with user permissions to run optimum library code.

**Mitigations:**

- Repo ID format validation prevents injection (SEC-18)
- Output path canonicalized via `dunce::canonicalize` (PB-30)
- `model.toml` restricted to 0o600 permissions on Unix (SEC-19)

### Path Traversal Protection

The `cqs read` command validates paths:

```rust
let canonical = dunce::canonicalize(&file_path)?;
let project_canonical = dunce::canonicalize(root)?;
if !canonical.starts_with(&project_canonical) {
    bail!("Path traversal not allowed: {}", path);
}
```

This blocks:
- `../../../etc/passwd` - resolved and rejected
- Absolute paths outside project - rejected
- Symlinks pointing outside - resolved then rejected

## Symlink Behavior

cqs has **two** symlink-handling regimes, depending on the entry point.

### Directory walks (`cqs index`, `cqs ref add`, `cqs watch` reconcile, `cqs convert`)

Symlinks are **skipped** entirely — `enumerate_files` / `enumerate_files_iter` in `src/lib.rs` (search for `WalkBuilder::follow_links(false)`) and `cqs convert`'s archive extraction skip them in extract paths. The walker never opens the link's target.

| Scenario | Behavior |
|----------|----------|
| `project/link → project/src/file.rs` | Skipped (symlink, regardless of target) |
| `project/link → /etc/passwd` | Skipped |
| `project/link → ../sibling/file` | Skipped |

This is conservative: a monorepo workspace that uses in-tree symlinks to share common code will silently miss those files. Workaround: replace the symlinks with the actual files (or use a `[references]` config block to index the shared tree as a separate slot).

### Explicit-path canonicalization (`cqs read <path>`, `cqs ref add --source <path>`)

When the user passes a path on the command line, cqs canonicalizes it (`dunce::canonicalize`), then validates the resolved path against the project root.

| Scenario | Behavior |
|----------|----------|
| `cqs read link` where `link → project/src/file.rs` | Allowed (target inside project, canonicalised path reads `project/src/file.rs`) |
| `cqs read link` where `link → /etc/passwd` | Blocked (target outside project) |
| `cqs read link` where `link → ../sibling/file` | Blocked (target outside project) |

**`cqs ref add --source <path>` redirect surfacing (since v1.30.2, [#1222](https://github.com/jamie8johnson/cqs/issues/1222))**: when the user-supplied `--source` path resolves through a symlink to a different filesystem location, `cmd_ref_add` emits a `tracing::warn!` (and a `WARN:` line on stderr in non-`--quiet` text mode) naming both the user-supplied and resolved paths. JSON output gains a `warnings: ["source path '<input>' resolved via symlink to '<target>'"]` field. The reference is still indexed at the resolved path — the warning exists so an operator who ran `cqs ref add foo vendored-monorepo-pull/` against a symlink to `~/work/customer-A-private/` can see what they actually pulled in. Lexical normalization (`..`, `.`, repeated separators) is applied before comparison so purely syntactic differences in the input don't trigger false positives.

**TOCTOU consideration**: A symlink could theoretically be changed between canonicalization and read. This is a standard filesystem race condition that affects all programs. Mitigation would require `O_NOFOLLOW` or similar, which would break legitimate symlink use cases on `cqs read`.

**Recommendation**: If you don't trust symlinks in your project, remove them. The directory-walk path is already conservative.

## Windows-Specific Asymmetries

Several `cfg(unix)` paths apply file-mode hardening that the `cfg(not(unix))` arms can't replicate without per-file ACL programming. Documented here so the SEC promises don't read as universal when they're actually Linux/macOS-specific.

### Audit-mode marker (`src/audit.rs::write_state`)

- **Linux/macOS**: file is created with `mode(0o600)` so it's never world-readable; the audit token can't be read by other accounts on a shared host.
- **Windows**: file is created via `std::fs::write` and inherits the parent directory's DACL. The default `%LOCALAPPDATA%\cqs\` location inherits user-only ACLs from the user profile, which is fine — but operators with roaming profiles, or who've relocated `%LOCALAPPDATA%` to a shared mount (some enterprise deployments), can end up with the audit-mode file readable by other authenticated users on the same machine.
- **Recommendation**: on Windows, verify the parent directory's ACL doesn't grant read to other users before treating the audit-mode marker as a confidentiality boundary. Audit-mode is a debugging aid, not a credential store; the file contains a timestamp + opt-in flag, not a token.

### Embedding cache file mode (`src/cache.rs::apply_db_file_perms`)

- **Linux/macOS**: `apply_db_file_perms` sets `mode(0o600)` after creation.
- **Windows**: `apply_db_file_perms(_path: &Path) {}` is a no-op. Same DACL inheritance story as above. The cache contains chunk content_hashes + embedding vectors — not directly sensitive, but the same operator-environment caveat applies.

### Database file backup discovery (`src/store/migrations::db_file_identity`)

- **Linux/macOS**: identity is `(dev, inode)` — durable across `mtime` changes from `--force` reindexes.
- **Windows / non-Unix**: identity falls back to `mtime`, which can collide if two `--force` operations run in the same second. The legacy backup discovery path (used only for the v18→v19 migration on existing indexes) may misidentify a fresh DB as the pre-migration one. Mitigated in practice because the v18→v19 migration shipped over a year ago and operators on current schemas don't hit this code path.

### Hook scripts on Windows-native (`src/cli/hook.rs`)

- The `cqs hook install` script body assumes `cqs` is on PATH from a POSIX-style shell. On Windows-native Git (cmd.exe / PowerShell), the inherited PATH from MSYS shells doesn't always carry over. Operators on native-Windows git should use Git for Windows' bundled bash for the hook scripts to fire reliably.

These limitations are tracked as Windows-specific issues in the v1.33.0 audit batch (#1353, #1354, #1355).

## Index Storage

- Stored in `.cqs/slots/<name>/index.db` (SQLite with WAL mode; PR #1105 introduced per-slot layout, pre-migration projects may still see the legacy `.cqs/index.db`)
- Contains: code chunks, embeddings (768-dim vectors for default embeddinggemma-300m since v1.35.0; 1024-dim for bge-large preset), file metadata
- Add `.cqs/` to `.gitignore` to avoid committing
- Database is **not encrypted** - it contains your code
- Schema v27 (#1497) adds `chunks.needs_embedding`; chunks created during a `--llm-summaries` reindex skip the initial cold embed and get embedded once the LLM-enriched description is available, then cleared by `enrichment_pass`. This is behavior-visible to anyone tracing a "why isn't this chunk in HNSW yet?" path mid-index — see `docs/audit-triage.md` Cluster A for the gap closures (DS-V1.38-1/2/3/8, #1514).

## CI/CD Security

- **Dependabot**: Automated weekly checks for crate updates
- **CI workflow**: Runs clippy with `-D warnings` to catch issues
- **cargo audit**: Runs in CI, allowed warnings documented in `audit.toml`
- **No secrets in CI**: Build and test only, no publish credentials exposed

## Branch Protection

The `main` branch is protected by a GitHub ruleset:

- **Pull requests required**: All changes go through PR
- **Status checks required**: `test`, `clippy`, `fmt` must pass
- **Force push blocked**: History cannot be rewritten

## Dependency Auditing

Known advisories and mitigations:

| Crate | Advisory | Status |
|-------|----------|--------|
| `bincode` | RUSTSEC-2025-0141 | Mitigated: transitive via `hnsw_rs` 0.3.4 with no upgrade path; blake3 checksums validate data before deserialization |
| `number_prefix` | RUSTSEC-2025-0119 | Accepted: unmaintained, transitive via `hf-hub` → `indicatif` 0.17, progress bars only |
| `paste` | RUSTSEC-2024-0436 | Accepted: proc-macro, no runtime impact, transitive via tokenizers |

Run `cargo audit` to check current status.

## Reporting Vulnerabilities

Report security issues to: https://github.com/jamie8johnson/cqs/issues

Use a private security advisory for sensitive issues.
