cqs is a local code search tool for developers. It runs on your machine, indexes your code, and answers semantic queries.
| 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 below. |
| 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 (full-file and --focus), and trace responses surface file contents, 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. |
cqs mcp has two transports. The default is a JSON-RPC bridge over stdio — it opens no network port. The opt-in cqs mcp --http <port> (behind the non-default mcp-http build feature, unix-only) instead opens a Streamable HTTP listener; it is loopback-bound by default and token-gated (see the MCP over HTTP subsection below). Both transports are transport-only clients of the warm cqs watch --serve daemon: every tools/call relays over the daemon's unix socket, there is no in-process fallback, and a daemon-absent call is a clean JSON-RPC error (never a panic or a 5xx).
Mitigations:
- Read-only surface by default:
tools/listexposes 31 read-only tools. No mutation is possible withoutCQS_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, andcache clearare 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_indexqueues a reconcile (non-blocking). Neither path writes the daemon's in-memory Store from the MCP handler.
cqs mcp --http <port> opens a Streamable HTTP listener behind the opt-in, non-default mcp-http build feature (unix-only — it relays to the unix daemon socket, so it has no meaning off-unix). Like the stdio bridge it holds only session state and relays every tools/call through the same daemon socket; there is no in-process fallback, and a daemon-absent call is a clean JSON-RPC -32603 error, never a panic or an HTTP 500. Any TCP listener widens the daemon's same-UID 0o600-socket boundary to every local UID (and, under WSL, Windows-side processes), so the posture below deliberately mirrors the cqs serve HTTP-clients row above — bearer token, constant-time compare, loopback default, non-loopback loud-warn:
- Auth is unconditional. A bearer token is always required — there is no
--no-auth— and the listener refuses to start without one. Token source is--token-file(preferred: the file must be owned by the current user and not group/world-readable, or the listener refuses to start; the mode check is skipped only on a genuine WSL drvfs mount, where unix modes are synthetic0777— that skip is path-scoped, not host-global, and the ownership check still applies) or the discouragedCQS_MCP_HTTP_TOKENenv var (whose value leaks through unit files / exec-capture / child env). The compare iscqs serve's timing-safect_eq; the token value is never logged (only its source/path). Bearer auth gates POST, GET, and DELETE uniformly. - Loopback default + loopback-only mutations. The bind defaults to
127.0.0.1. A non-loopback bind fires its own loud warning (the token and any mutation would cross the LAN in cleartext — there is no TLS in this stack) and withholds every mutating tool from bothtools/listandtools/call: a withheld mutator resolves to JSON-RPC-32601(method not found) before any relay. A mutating tool is thus visible-and-callable only under the triple gateCQS_MCP_ENABLE_MUTATIONS=1∧ a valid token ∧ a loopback bind; visibility and reachability read the same gate. - Sessions are server-issued only.
MCP-Session-Idis minted by the server; a client-proposed id oninitializeis ignored (session fixation closed). Ids are crypto-random visible-ASCII. Sessions are in-memory and non-durable, capped in count, and idle-expire; because auth gates DELETE, an unauthenticated request cannot tear down a victim session. - Origin / Host. A present-and-invalid
Origin→ 403;Origin: null→ reject (sandboxed-iframe class); an absentOriginis allowed (headless agents send none). The Host allowlist (reused fromcqs serve'senforce_host_allowlist) rejects DNS-rebinding. - Concurrency cap. A semaphore bounds in-flight requests, installed as the outermost layer: a saturated listener returns
503immediately viatry_acquire— before any body buffer is allocated or any token compare runs — so one steered-but-authenticated client cannot fan out and exhaust the shared daemon forserve/ CLI / other consumers. It reusescqs serve's concurrency budget. - Body & protocol limits. A POST body is capped at 1 MiB (a
413before auth) and must be a single JSON-RPC message (arrays / batches →400). AGETwithAccept: text/event-streamon a live session opens the SSE progress stream (see SSE progress stream below); any otherGET→405. An invalidMCP-Protocol-Version→400; an absent one assumes2025-03-26. - SSE progress stream (P3a-2). A
GETbearingAccept: text/event-streamon a live session opens a notifications-only Server-Sent Events stream: it carriesnotifications/progressframes (and an optional terminalnotifications/tasks/status) for a task the client augmented with aprogressToken— never a JSON-RPC response and never a server→client request. The listener's trust boundary is unchanged: Origin / Host / bearer-auth are router-wide layers, so they gateGETexactly asPOST(a present-and-invalidOrigin→403, a disallowedHost→400, a missing/wrong token →401), and the protocol-version gate runs onGETtoo (invalid →400). Under the shared token there is no per-task ACL, so — as withtasks/get/tasks/result— any holder of the bearer token who also holds ataskIdand its clientprogressTokencan watch that task's progress; isolation rests on thetaskId/progressTokenentropy plus the no-log posture. Streams ride a dedicated 16-stream budget (separate from the 256 one-shot request permits, which it cannot starve; a saturated budget →503); a secondGETon a session already streaming transfers its budget slot rather than acquiring a second; and every stream ends on graceful shutdown. NoprogressTokenon the augmented call ⇒ no frames for that task. - Tasks surface (async
cqs_eval). The MCP Tasks capability (2025-11-25) is advertised (tasks.requests.tools.call;listandcancelare omitted). Exactly one tool —cqs_eval— is task-augmentable (execution.taskSupport:"optional"); ataskaugmentation on any other tool is-32601. AtaskIdis a bearer capability: it is minted from the same 256-bit crypto-secure RNG as the session id and is never logged (itsDebugis redacted, and no worker thread name or tracing field carries it — the thread name uses a non-secret job counter). Shared-token limitation (documented per the spec): under the shared-token, single-principal model there is no per-task ACL — any holder of the listener's bearer token who also possesses ataskIdcantasks/get/tasks/resultit. Isolation rests solely on thetaskId's entropy plus the no-log posture. This is the spec-endorsed no-authorization-context posture (the spec binds task isolation to an authorization context, which cqs does not have under a single shared token), and this document records the limitation as the spec directs. A task that is unknown, expired, or garbage-collected resolves to JSON-RPC-32602, never an HTTP404(404 stays session-expiry only). The blockingtasks/result(spec-MUST block-until-terminal) runs on an isolated small blocking budget that never holds one of the outermost 256 request permits and is fan-out-capped pertaskId, so a client mounting long blocks cannot starve one-shot requests (serve/ CLI / other MCP calls) for the shared daemon (RT-DOS-2).
- Path traversal: Commands cannot read files outside project root
- FTS injection: Search queries sanitized before SQLite FTS5 MATCH operations
- Database corruption:
PRAGMA quick_check(1)on write-mode opens (opt-out viaCQS_SKIP_INTEGRITY_CHECK=1). Read-only opens skip the check entirely — reads cannot introduce corruption and the index is rebuildable viacqs index --force - Reference config trust: Warnings logged when reference configs override project settings
- Parser DoS hardening: The parse pipeline bounds adversarial deeply-nested or pathological indexed content from crashing
cqs indexor 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.
- 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 below.
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).
| 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 |
-
audit-mode:cqs audit-mode onexcludes 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-docsreview gate (since v1.30.1): by default,cqs index --improve-docswrites proposed doc comments as unified-diff patches to.cqs/proposed-docs/<rel>.patchinstead of mutating source files in place. Review withgit diffand apply withgit apply .cqs/proposed-docs/**/*.patch. Pass--applyto 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 indexagainst a repo containingdocs/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-notesso the prompt doesn't repeat. Pass--accept-shared-notesto bypass for CI / scripted use; non-TTY stdin auto-skips the notes pass with a warning so CI never hangs. (#1168) -
trust_level+reference_nameon 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_pathsin.cqs.toml). Treat as third-party content for indirect-injection purposes — same threat profile asreference-code. (#1221, schema v24)"reference-code"(withreference_name) — chunk lives in acqs refreference index. Wins overvendored-codewhen both apply: the per-reference name is the more useful agent-facing signal.
Scope of
user-codeafter #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-codedoes not mean "authored by the user" — generated code undersrc/is stilluser-code. (2) Developer notes (docs/notes.tomlcontent surfaced viacqs 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 default0; only the nextcqs index(orcqs watchre-emit) populates it. -
CQS_TRUST_DELIMITERSis on by default (since v1.30.2): every chunk'scontentis 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. SetCQS_TRUST_DELIMITERS=0to 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_summariescache passes throughcqs::llm::validation::validate_summarybefore insertion. Catches lazy injections (leading "Ignore prior" / "Disregard"; embedded code fences; embedded URLs) and enforces a 1500-char hard length cap. Configurable viaCQS_SUMMARY_VALIDATION=strict|loose|off(defaultloose: 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_levelwhenever it is non-default (vendored-code/reference-code) andinjection_flagswhenever 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_adviceadvisory string (an always-on framing of every response as untrusted-by-default v1.30.2 → v1.39.x) was made opt-in viaCQS_ULTRASECURITY=1in v1.40.0 and then removed entirely in #1690 — its only effect was emitting a constant advisory string plus forcing the default-valuedtrust_level/injection_flagsonto 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-addshandling_advice. (#1181 → #1593 → #1690) -
_meta.worktree_stale+_meta.worktree_name(#1254), and the worktree overlay (#1858): when a cqs command runs from inside agit worktreethat has no.cqs/of its own, cqs auto-discovers the main project's index via the worktree's.git/commondirlink and serves queries against main's.cqs/; every JSON envelope from that process carriesworktree_stale: trueplus the worktree's directory name inworktree_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 searchplus 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_graphmarker (full/callers-onlyfor 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 thecallers-only/seed-onlygraph sections still carry parent/main-truth. The overlay runs on the daemon path, and its--overlay-rootrequest 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 authoritativegit worktree listregistry — git's own record, which a forged or symlinked.giton 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 — accepted, wont-fix under the same-uid threat model: a same-uid client that wins the narrow check→pin window by recreating a registered directory at the validated path defeats any path-based re-check, including areadlinkof the pinned fd, so path re-validation is fundamentally unfixable against a same-uid filesystem attacker. The only airtight fix — dropping per-request roots and deriving the worktree from the caller'sSO_PEERCREDpid via/proc/<pid>/cwd— would break per-request--overlay-rooton the batch/MCP surface (the MCP server's cwd is not the worktree) and explicit--overlay-root, a functionality cost disproportionate to a same-uid-only threat behind a0o600socket where there are no external users and a same-uid party can already replace~/.cargo/bin/cqsoutright. The practical threat — passive hostile content in a non-registered tree — is already closed by the registry check above.) 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 infeedback_agent_worktrees.md. -
Per-chunk
injection_flagsarray is skip-when-default since v1.40.0 (was always-on v1.30.2 → v1.39.x): every chunk-returning JSON output carries aninjection_flagsfield 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-emptyinjection_flags. The formerCQS_ULTRASECURITY=1force-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
contentalso scan every other surface a command relays — the doc-comment + signature fields ofcontext/explain(#2024), the similar-chunk bodies ofexplain --tokens, the type-dependency bodies ofread --focus, the full relayed file content of a whole-fileread, the hop signatures oftrace, 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-emptyinjection_flags), and a surface that is not relayed (e.g. un-emittedcontentin a compact response) is not scanned (no phantom over-flagging). Theleading-directiveheuristic 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 asinjection_flags/trust_level, matching the honest-relay contract search results already carry.
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.
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). The opt-in cqs mcp --http listener (behind the mcp-http feature) accepts inbound MCP requests on a loopback port but makes no outbound requests and transmits nothing (its posture is documented in the MCP over HTTP subsection above); the outbound list under Network Requests below is therefore still complete.
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 viaCQS_EMBEDDING_MODEL=bge-large - Preset:
e5-base(intfloat/e5-base-v2, ~438MB) - Preset:
nomic-coderank(jamie8johnson/CodeRankEmbed-onnx, ~547MB) — code-specialised, opt-in viaCQS_EMBEDDING_MODEL=nomic-coderank(#1110) - Custom: any HuggingFace repo via
[embedding]config orCQS_EMBEDDING_MODELenv 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/
- Default since v1.35.0:
-
Reranker model download (first
--rerankuse): Downloads cross-encoder model from HuggingFace Hub- Model:
ms-marco-MiniLM-L-6-v2(cross-encoder) - One-time download, cached in
~/.cache/huggingface/
- Model:
-
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 Pythonoptimum.exporters.onnxwhich 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.
| 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 |
| 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.gen<N>.hnsw.* + index.hnsw.manifest |
HNSW vector index: generation-suffixed graph/data/ids artifacts plus the manifest (generation pointer, stamp, inline checksums) and the index.hnsw.lock advisory lock (per-slot) |
cqs index |
.cqs/slots/<name>/index_base.gen<N>.hnsw.* + index_base.hnsw.manifest |
Base HNSW index, same generation+manifest layout (per-slot). Pre-manifest layouts (*.hnsw.{graph,data,ids,meta,checksum}) are migrated (rebuilt + legacy files removed) on the first save after upgrade. |
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 |
| 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). |
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.
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_SCRIPTenv var: By default the PDF converter script is embedded in the binary (include_str!) and materialized to a temp file at exec time — there is no CWD or binary-relative script search (that TOCTOU surface was removed). IfCQS_PDF_SCRIPTis set, the named script is executed instead — arbitrary script execution under the user's permissions — gated by a.py-extension check and, on unix, an ownership + permissions check (owned by the current effective user, not group/world-writable). A nonexistent override path falls back to the embedded script rather than executing anything.- Output directory: Generated Markdown files are written to the
--outputdirectory. 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
- Output size limits: PDF conversion caps converter stdout at
CQS_PDF_MAX_BYTES(100 MiB default); CHM / web-help conversion caps processed pages atCQS_CONVERT_MAX_PAGES(1000 default)
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.tomlwritten to--outputpath. 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.tomlrestricted to 0o600 permissions on Unix (SEC-19)
The cqs read command validates paths:
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
cqs has two symlink-handling regimes, depending on the entry point.
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).
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): 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.
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.
- 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::writeand 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.
- Linux/macOS:
apply_db_file_permssetsmode(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.
- Linux/macOS: identity is
(dev, inode)— durable acrossmtimechanges from--forcereindexes. - Windows / non-Unix: identity falls back to
mtime, which can collide if two--forceoperations 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.
- The
cqs hook installscript body assumescqsis 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).
- 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.gitignoreto avoid committing - Database is not encrypted - it contains your code
- Schema v27 (#1497) adds
chunks.needs_embedding; chunks created during a--llm-summariesreindex skip the initial cold embed and get embedded once the LLM-enriched description is available, then cleared byenrichment_pass. This is behavior-visible to anyone tracing a "why isn't this chunk in HNSW yet?" path mid-index — seedocs/audit-triage.mdCluster A for the gap closures (DS-V1.38-1/2/3/8, #1514).
- Dependabot: Automated weekly checks for crate updates
- CI workflow: Runs clippy with
-D warningsto 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
The main branch is protected by a GitHub ruleset:
- Pull requests required: All changes go through PR
- Status checks required:
test,clippy,fmtmust pass - Force push blocked: History cannot be rewritten
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.
Report security issues to: https://github.com/jamie8johnson/cqs/issues
Use a private security advisory for sensitive issues.