Skip to content

fix(sql): bind paths as parameters, validate session ids at the boundary, prove constant-only SQL text - #8

Merged
laithalsaadoon merged 1 commit into
mainfrom
fix/sql-text-boundaries
Sep 12, 2026
Merged

laithalsaadoon merged 1 commit into
mainfrom
fix/sql-text-boundaries

Conversation

@laithalsaadoon

Copy link
Copy Markdown
Owner

What changed

Bandit reported 29 B608 string-built-query sites; the code carried # noqa: S608, which ruff honors and Bandit does not. Most sites interpolated constants. The ones that mattered spliced corpus paths into statement text through sql_literal, and the one outside influence on those paths was the session id derived from a transcript filename.

Boundary (atif-corpus, atif-duck). domain.session_id in each package pins the same rule, ^[A-Za-z0-9][A-Za-z0-9._-]*$ and at most 255 characters (twinned the way AgentSource is, each side's test reads the other's source). Every id Claude Code (UUID v4) and Codex (UUID v7) produce passes. The scanner skips a failing name with a logged reason; SourceScan and MaterializationReport gain rejected_session_ids; the CLI report adds rejected and rejected_session_ids beside the existing fields. A corpus dir an older version wrote under such a name is kept, never ghosted. register_raw applies the twin before building any per-session path: a rejected dir registers nothing, is logged once, and is reported in RawSources.rejected_session_ids; columnar_coverage skips it the same way.

Bound paths (atif-duck, atif-embed). The read_json readers (meta, edges, loss, trajectory list) take their glob or file list as one bound parameter. The parquet readers are con.read_parquet(files) relations registered as views, since CREATE VIEW ... read_parquet(?) is refused ("this type of statement can't be prepared"); the con.sql("... read_parquet(?)", params=...) shape was tried and measured about 3x the memory, so it's documented as rejected. corpus_text_rows binds its trajectory batch as a list parameter. ATTACH (Lance) and the producer's one-row session projection are the two statements DuckDB won't prepare and keep sql_literal.

Types and the audit. SqlFragment (a NewType over str) marks the text sql_literal, the catalog and the projections produce; every SQL-building helper takes and returns it. packages/atif-duck/tests/sql_text_audit.py walks registry.py, columnar.py, analytics.py and atif-embed's corpus_text_rows.py and fails on any f-string placeholder that isn't a module constant, a catalog constant, a projection call or sql_literal(...). Proven against a planted f"SELECT * FROM {user_input}" in registry.py (caught, then removed).

Adversarial fixtures. A corpus under a root named o'brien ?; --$1 whose session content carries '); DROP TABLE x; --, $1 and ? and $2, backslashes, newlines and 20-deep nesting registers as data on both the JSON and columnar paths, row for row equal; six bad directory names register nothing and are reported once each. The producer with a quoted session id and the Lance store under a quoted path each fail when the sql_literal wrapping is removed (proven, then restored).

Markers. Every remaining interpolation carries # noqa: S608 # nosec B608 - <what it interpolates>. No blanket skip in [tool.bandit]. mise run security:bandit: B608 29 -> 0.

Cooldown. exclude-newer = "7 days" closes Semgrep uv-missing-dependency-cooldown (the rule accepts only a relative window of at least seven days; an absolute instant is flagged too). Re-resolving under the window today would have moved 21 locked packages (ruff, ty, litellm, numpy, boto3, ...) down to week-old releases, and ty 0.0.78 rejects a # ty: ignore rule name main already uses, so those 21 are exempted under [tool.uv.exclude-newer-package] up to the instant the window landed. The uv.lock diff is options-only; every pin stays. mise run security:semgrep: 1 -> 0.

Docs. docs/CONTRACT.md, packages/atif-duck/README.md, AGENTS.md, docs/reference/cli.md.

Equivalence and timing

Corpus: 300 Claude Code sessions materialized from atif-sql-frontier/corpus/raw/claude (all columnar). Reference: an untouched worktree of origin/main (31b61d5). Three runs each, /usr/bin/time -f '%e %M' (seconds, peak KB), --format json on a pipe. Output byte-identical for all three statements (sha256 equal across every run of both binaries).

statement this branch (s / KB) origin/main (s / KB)
(a) sessions count 0.95 / 482260, 1.00 / 488960, 0.93 / 498552 0.98 / 484684, 1.07 / 492200, 0.97 / 493184
(b) tool_calls top 40 0.91 / 484340, 0.98 / 488820, 0.95 / 485680 0.98 / 488532, 1.19 / 490140, 1.02 / 476280
(c) steps token sums 0.93 / 479196, 0.98 / 490164, 0.96 / 487640 1.08 / 491092, 1.02 / 488048, 0.96 / 480284

Two shapes were measured and rejected on the way: con.sql("... read_parquet(?)", params=...) relation views (about 3.0 s / 1.85 GB) and explicit file lists in place of globs for the meta/edges/loss readers (about 1.22 s; DuckDB reads 300 small files roughly twice as fast through a glob).

Gates

mise run check green (1399 passed, 1 skipped; was 1329 passed, 1 skipped). mise run docs:gate green (64 tests). uv run cz check passes. atif-duck's equivalence tests (test_columnar.py::TestEquivalence) pass unchanged.

Code-scanning alerts this addresses

Bandit B608: #6, #7, #8, #9, #10, #11, #12, #13, #15, #16, #17, #18, #19, #20, #23, #24, #25, #26, #27, #28, #29, #30, #31, #32, #33, #34, #35, #36, #37. Semgrep uv-missing-dependency-cooldown: #21. (Closes doesn't apply to code-scanning alerts; they close on the next scan of main.)

Not done

test_parity_live.py::TestLiveParity::test_claude_code fails on this machine when run outside mise run check, on this branch and on the untouched reference worktree alike: the diverging session is the live transcript of the Claude Code session that produced this PR, still being written while harbor and our converter read it at different instants. It passed inside the check run recorded above.

…ary, prove constant-only SQL text

Bandit reported 29 B608 string-built-query sites that ruff's `# noqa: S608` never
silenced. Most interpolated constants; the ones that mattered spliced corpus paths
into statement text through `sql_literal`, and the one outside influence on those
paths was the session id derived from a transcript filename.

atif-corpus: `domain.session_id` is the boundary a derived id must pass before it
becomes a corpus path (`^[A-Za-z0-9][A-Za-z0-9._-]*$`, at most 255 chars). The
scanner skips a failing name with a logged reason, `SourceScan` and
`MaterializationReport` carry `rejected_session_ids`, the CLI report adds
`rejected` / `rejected_session_ids` (additive), and a corpus dir an older version
wrote under such a name is kept rather than ghosted.

atif-duck: the twin `domain.session_id` is applied in `register_raw` before any
per-session path is built; a rejected dir registers nothing, is logged once, and
is reported in `RawSources.rejected_session_ids`; `columnar_coverage` skips it the
same way. The read_json readers take their glob or file list as ONE bound
parameter. The parquet readers are `con.read_parquet(files)` relations registered
as views (CREATE VIEW cannot be prepared); the `sql(..., params=)` shape measured
3x the memory and is documented as rejected. `SqlFragment` (NewType over str)
marks the text `sql_literal`, the catalog and the projections produce, and every
SQL-building helper takes and returns it. ATTACH and the producer's one-row
session projection are the two statements DuckDB will not prepare and keep
`sql_literal`; a test each fails when the wrapping is removed.

atif-embed: `corpus_text_rows` binds its trajectory batch as a list parameter.

Tests: adversarial corpus under a root named `o'brien ?; --$1` whose content
carries SQL text registers as data on both paths and row-for-row equal; bad dir
names register nothing; an AST audit (`tests/sql_text_audit.py`) over
registry.py, columnar.py, analytics.py and corpus_text_rows.py fails on any
placeholder that is not a constant, a projection call or `sql_literal(...)`, and
was proven against a planted `{user_input}`. Twin pins on both sides.

Every remaining interpolation carries `# noqa: S608  # nosec B608 - <reason>`;
`mise run security:bandit` reports 0 B608 (was 29). Equivalence on the 300
session panel corpus: byte-identical output for the three panel statements,
wall time and peak RSS at parity with origin/main.

pyproject: `exclude-newer = "7 days"` closes Semgrep uv-missing-dependency-cooldown;
the 21 packages the window would have moved today are exempted up to the instant
it landed so uv.lock keeps every pin (options-only lock diff).
@laithalsaadoon
laithalsaadoon merged commit 4571e14 into main Sep 12, 2026
17 checks passed
@laithalsaadoon
laithalsaadoon deleted the fix/sql-text-boundaries branch September 12, 2026 21:44
@laithalsaadoon

Copy link
Copy Markdown
Owner Author

MicroVM defensive review of atif-sql main 31b61d5

Reviewed 2026-09-12 inside AWS Lambda MicroVMs (Firecracker, aarch64) with the microvm CLI v0.8.0, account 392583147479, us-east-1. Subject: origin/main 31b61d5 (worktree worktrees/atif-sql-review). PR #8 (fix/sql-text-boundaries, merged 21:44Z as 4571e14) landed while this ran; the last section says which findings it already closes, checked against that commit.

Environment

item value
guest images atif-review-a09b78c39591 (built at the 2048 MiB class, guest sees 8 GiB / 4 vCPU) and atif-review-8g (8192 MiB class, guest sees 32 GiB / 16 vCPU); same Dockerfile, ~5.2 GB each
base public.ecr.aws/amazonlinux/amazonlinux:2023-minimal@sha256:c439fb49...11de, AL2023.12.20260817, kernel 6.1.166-24.303.amzn2023.aarch64
arch aarch64
python CPython 3.13.15 (uv-managed; AL2023's own python3 is 3.9)
duckdb 1.5.5 (host: 1.5.5 on CPython 3.13.12 x86_64)
atif-sql 0.1.0 at 31b61d5, tree hash of packages/ pyproject.toml uv.lock identical in /workspace (packed upload) and /opt/atif-sql (git clone in the image): eaec506a...b3e9
scanners bandit 1.9.4, semgrep 1.176.1, ruff 0.16.7, uv 0.12.12 on both sides
egress every static/dynamic run launched without --egress; the guest still answered curl https://extensions.duckdb.org/ with 301 and https://pypi.org/ with 200, which matches the microvms-agentd README's note that omitting --egress doesn't seal a VM today

Image route: own Dockerfile derived from examples/coding-agents-on-bedrock/Dockerfile (FROM line and its comments kept), plus uv 0.12.12, uv python install 3.13, a git clone of the repo at 31b61d5 into /opt/atif-sql, and uv sync --locked --all-packages --no-build --no-install-package hdbscan. hdbscan 0.8.44 is the one locked dependency with no aarch64 wheel and only atif_analytics.domain.structure.cluster imports it, lazily, so skipping that one package keeps the image wheel-only with no compiler. microvm build --project was rejected because it zips only pyproject.toml + uv.lock, runs uv sync --locked on AL2023's python3.12, and cannot see the seven packages/* workspace members. Local docker buildx build --platform linux/arm64 caught one error before any server-side build (npm is the separate nodejs22-npm package on AL2023; the example's npm name exits 127). One platform fact worth recording: microvm build --memory fixes the size class in the image, so run --memory 8192 against the 2048-class image still gave a guest with 8 GiB; a second image was built at 8192 to get 32 GiB.

Two exec-environment facts cost a run each: the daemon spawns execs with no PATH and no HOME (env shows only PWD, SHLVL, _), so semgrep died on uname -s and Python's subprocess couldn't find atif-sql; and run <dir>'s artifact collection tars all of /workspace first, which failed on the wire once the corpus was in it, so artifacts came back through microvm cp --tar from kept VMs.

Sample corpus

review-input/ (gitignored via .git/info/exclude, 131 MB, 268 files, under the 512 MiB pack budget): 12 Claude Code sessions from atif-sql-frontier/corpus/raw/claude (2 smallest at 9.8 and 10.2 KB, 8 around the 106 KB median, 2 largest under 20 MB at 11.8 and 12.6 MB with their 39 and 49 MB subagent side directories), 3 Codex rollouts (31 KB, 75 KB, 18.8 MB), plus adversarial fixtures under -adversarial-project/: session files named it's, say"hi, semi;colon, dash--dash, dollar$1, with space, ünïcødé-日本, a 200-char aaaa... name (each a copy of the smallest real session), and injected-trajectory.jsonl whose user text, assistant text, tool_use input and tool_result carry '); DROP TABLE sessions; -- ${HOME} and a 200-deep nested JSON value. Credential grep over the tree (AKIA/ASIA keys, Bearer <token>, ghp_/github_pat_, xox*, sk-, PEM blocks, 40-char AWS secrets) found nothing; the word "token" appears only as usage fields and shell variable names in transcripts.

Static counts, local vs VM

Same commands both sides (bandit -c pyproject.toml -r packages -f json, bandit -r packages/*/src, ruff check --select S packages, and mise's security:semgrep packs p/auto + p/owasp-top-ten + p/python fetched once and packed as files so the egress-off guest ran the identical 209 rules). Identical across three VM runs.

scanner local (x86_64) VM (aarch64) match
bandit -r packages 30 (B608 MEDIUM x29, B105 LOW x1) 30 (same) yes
bandit -r packages/*/src 30 (same) 30 (same) yes
ruff --select S 0 0 yes
semgrep (209 rules) 1 (uv-missing-dependency-cooldown) 1 (same) yes

Dynamic pass

atif-sql materialize --agent claude-code ... --quiesce-seconds 0 --force --format json into /workspace/corpus: 21 materialized, 0 failed, 0 unreadable, workers 4 (8 GiB guest) or 16. Codex half into /workspace/corpus-codex: 3 materialized. atif-sql status reported query_path: columnar, 21/21 columnar sessions, for the sample. All 21 adversarial and real session ids came out as directory names (it's, say"hi, semi;colon, dash--dash, dollar$1, with space, ünïcødé-日本, the 200-char name, injected-trajectory).

The shipped CLI could not run a single query in either guest

Every atif-sql query on 31b61d5, including SELECT count(*) FROM sessions, exited 70 runtime_error with Out of Memory Error: failed to allocate data of size 4.0 KiB (8.0 GiB/6.2 GiB used) on the 8 GiB / 4 vCPU guest, and (26.0 GiB/25.0 GiB used) on the 32 GiB / 16 vCPU guest: 62 of 62 probe runs on each. The error is raised inside register_raw (the read_json TEMP TABLE over edges.jsonl, registry.py:569), before _harden_query_connection runs and before the caller's SQL is parsed. The reservation scales with DuckDB's thread count (defaults to the core count) at roughly 1.5 GiB per thread, driven by maximum_object_size=1 GiB on the two eager readers, while DuckDB's default memory_limit is 80% of RAM. So any host with under about 1.9 GiB of RAM per core can't register at all; reproduced on the x86_64 review host too: SET threads=4; SET memory_limit='6GB' then register() fails with (6.0 GiB/5.5 GiB used), threads=16 under 25GB fails with (24.0 GiB/23.2 GiB used), and threads=1 under 6GB succeeds. The same reproduction fails on merged main 4571e14. Details under finding 1.

Probe table (one-thread control)

To exercise the sandbox at all, the probes were re-run through a 12-line shim that calls the shipped atif_cli.app.main after duckdb.connect is wrapped to run SET threads=1 first; everything else (registration, _harden_query_connection, error classification) is the 31b61d5 code. Corpus freshly materialized, spill dir /workspace/corpus/.duckdb_tmp pre-created so a write into allowed_directories tests the allowlist rather than mkdir. Each statement ran twice: as uid 1000 first, then as root (uid 0). Expected for b through k: exit 70, kind runtime_error.

id statement uid 1000 uid 0 (root) note
a1 SELECT count(*) AS n, count(DISTINCT model_name) AS models FROM sessions 0, [{"n": 21, "models": 2}] same panel
a2 SELECT tool_name, count(*) FROM tool_calls WHERE ts >= TIMESTAMP '2025-01-01' AND ts < TIMESTAMP '2026-09-12' AND tool_name IS NOT NULL GROUP BY 1 ORDER BY n DESC, tool_name LIMIT 20 0, Bash 3966, Read 174, StructuredOutput 174, ... same panel
a3 SELECT source, sum(prompt_tokens), sum(completion_tokens), sum(cached_tokens), count(*) FROM steps WHERE <window> GROUP BY 1 ORDER BY 1 0, agent 454298249 / 5569635 / 421415444 / 3462 steps; user 334 steps same panel
b COPY (SELECT 1) TO '/workspace/escape.csv' 70 runtime_error, Permission Error same
c COPY (SELECT 1) TO '<corpus>/sessions/<id>/trajectory.json' 70 runtime_error, Permission Error same
d same as c with (USE_TMP_FILE false) 70 runtime_error, Permission Error same
d2 COPY (SELECT 1) TO '<corpus>/sessions/<id>/steps.parquet' (USE_TMP_FILE false) (a granted path, file mode 0444) 70 runtime_error, IO Error: Cannot open file ... Permission denied 0, [{"Count": 1}], the parquet was overwritten with a one-row file finding 2
d3 COPY (SELECT 1) TO '<corpus>/.duckdb_tmp/probe.csv' 0, [{"Count": 1}] same finding 3
d4 COPY (SELECT 1) TO '<corpus>/.duckdb_tmp/../../escape-traversal.csv' 70 runtime_error, Permission Error same .. doesn't escape
e SELECT read_text('/etc/passwd') (as given) 70 runtime_error, Binder Error: read_text is a table function same not a sandbox refusal
e2 SELECT * FROM read_text('/etc/passwd') 70 runtime_error, Permission Error same
e3 SELECT * FROM read_text('<spill>/../../../../../../etc/passwd') 70 runtime_error, Permission Error same
e4 SELECT * FROM read_text('<spill>/probe.csv') 0, returns the file d3 wrote same finding 3
f SELECT * FROM read_csv('/etc/passwd') 70 runtime_error, Permission Error same
g ATTACH '/workspace/other.db' 70 runtime_error, Permission Error same
h INSTALL httpfs 70 runtime_error, Permission Error on $HOME/.duckdb/extensions/... same
h2 LOAD httpfs 70 runtime_error, "Loading external extensions is disabled through configuration" same
i SET threads=1 70 runtime_error, "configuration has been locked" same
i2 SET TimeZone='UTC' 0 same the one unlocked config
j SELECT * FROM read_json('/workspace/home/anything.json') 70 runtime_error, Permission Error same
k SELECT getenv('HOME') 65 catalog_error, function does not exist in this build same not 70; no disclosure
k2 SELECT count(*) FROM glob('/**') 70 runtime_error, Permission Error same
k3 SELECT length(content) FROM read_blob('/etc/passwd') 70 runtime_error, Permission Error same
k4 SELECT count(*) FROM read_parquet('<corpus>/sessions/<id>/steps.parquet') 0, 2 rows same granted path, expected
k5 SELECT name, value FROM duckdb_settings() WHERE name IN (...) 0: allowed_directories=[<spill>/], enable_external_access=false, lock_configuration=true, memory_limit=15.6 GiB, temp_directory=<spill> same finding 5
k6 EXPORT DATABASE '/workspace/exported' 70 runtime_error, Permission Error same
l1 SELECT session_id FROM sessions ORDER BY 1 0, all 21 ids including every adversarial name same data, not SQL
l2 SELECT count(*) FROM steps WHERE message LIKE '%DROP TABLE%' 0, 2 same
l3 SELECT count(*) FROM tool_calls WHERE CAST(tool_input AS VARCHAR) LIKE '%DROP TABLE%' 0, 6 same
l4 SELECT max(length(CAST(tool_input AS VARCHAR))) ... 0, 2857 (the 200-deep value survived intact) same
l5 SELECT count(*) FROM steps WHERE message LIKE '%${HOME}%' 0, 2 (${HOME} stays literal) same

Nothing in b through k succeeded except the three rows marked in bold, and each of those is a finding below. The panel statements answer identically to the host.

Filesystem diff

Baseline: sha256 of every file under /workspace excluding corpus, corpus-codex, home and the review output dir, plus a find / -xdev -type f listing, taken after materialize and before the probes; re-taken after.

run /workspace hash diff new or newer files outside the excluded dirs files changed inside /workspace/corpus
shipped CLI, 8 GiB guest (all queries OOM) 0 lines 5 __pycache__/*.pyc under /opt/atif-sql (import-time bytecode) 0
shipped CLI, 32 GiB guest (all queries OOM) 0 lines same 5 .pyc 0
one-thread control, 32 GiB guest 0 lines /root/.duckdb/extensions/v1.5.5/linux_arm64/lance.duckdb_extension (242,170,198 bytes) and its .info, and the same pair under /workspace/home/.duckdb/ for the uid 1000 pass 1: sessions/042823d8-.../steps.parquet, rewritten by probe d2 as root

The lance extension files are register_vss doing INSTALL lance on first use: a 242 MB download from the network, written under $HOME, during the unsandboxed registration phase of query (finding 4). The overwritten parquet is finding 2.

Adversarial data assertions

All nine adversarial session ids materialized and appear as rows of sessions, byte-identical to their filenames; the injected '); DROP TABLE sessions; -- ${HOME} text is present as data in 2 steps.message rows and 6 tool_calls.tool_input rows; the 200-deep nested value is stored whole (2857-char JSON); ${HOME} isn't expanded. No statement was altered by transcript filenames or contents on 31b61d5 in the sense of changed SQL text. Two filename-driven problems that don't go through SQL text are findings 6 and 7 (found by the reviewer agent, reproduced here, and closed by PR #8).

Reviewer agent pass

microvm agent-up --vm-name atif-review --agent claude-code --project <worktree> --memory 2048 (Bedrock, global.anthropic.claude-opus-5, image agent-vm-claude-code-0bd9917f5a4f, 141.6 MB / 800 members uploaded), then one agent-prompt pointing at review-vm/agent-prompt.md. Ran 21:10Z to 21:32Z, exit 0, wrote a 699-line /workspace/review/agent-review.md (kept locally as review/agent-review.md). It bootstrapped uv, synced the workspace, materialized the corpus, hit the same registration OOM (about one run in three on its 8 GiB guest; every run on a --no-columnar corpus), worked around it with a retry wrapper, and ranked six findings by reach. Its top two (reach 1, filenames only) were independently reproduced on the host before being accepted:

  1. A transcript named ..jsonl has stem ., so CorpusLayout.session_dir resolves to sessions/ itself and replace_dir_atomic renames the whole sessions/ tree aside and deletes it. Reproduced: 3 sessions materialized, add ..jsonl, plain incremental pass exits 0 with {"materialized": 1, "up_to_date": 3, "failed": 0, "removed": 0}, and sessions/ now holds loose artifact files and no session directories; status says materialized_sessions: 0, query_path: empty.
  2. A transcript named *.jsonl (or ???.jsonl) becomes a session id that is spliced, correctly quoted, into the read_parquet([...]) / read_json([...]) path lists, and DuckDB expands each list element as a glob. Reproduced: sessions aaa bbb ccc * with 2 steps each; SELECT session_id, count(*) FROM steps GROUP BY 1 returns * 2, aaa 4, bbb 4, ccc 4; sessions has 7 rows for 4 distinct ids; the glob also lands verbatim in allowed_paths.

The rest of its list (registration OOM before the cap, spill-dir writes, settings enumeration, connection-local catalog shadowing, multi-statement SQL accepted) agrees with the probe table above. It confirmed the SET ordering inside _harden_query_connection is right and that every SET/RESET after the lock is refused except TimeZone.

Cost

microvm cost figures (estimates from pinned us-east-1 rates dated 2026-08-07; the image build phase is unpriced by the tool):

VM class running estimate
run 1, egress off, torn down 2048 200 s $0.021
atif-dyn (kept, inspected, terminated) 2048 ~840 s $0.043
atif-dyn8 (2048-class image, --memory 8192 flag) 2048 ~1140 s $0.054
atif-dyn8g (8192-class image) 8192 ~660 s $0.148
atif-review (agent VM) 2048 ~2280 s $0.094
image snapshots: 2 review images at ~5 GB, 1 agent image at ~2 GB, one-week minimum retention each $0.112 + $0.112 + $0.051

Total priced: about $0.64, plus three unpriced server-side image builds (the two review images and the agent image, roughly 5 to 6 minutes each) and the Bedrock token spend of one Opus 5 session of 22 minutes, which microvm doesn't meter. Retention caveat: deleting the images now saves nothing; atif-review-a09b78c39591 and atif-review-8g are keyed to this Dockerfile and reusable for a re-run. Every VM is terminated: aws lambda-microvms list-microvms shows no non-TERMINATED VM and microvm ls --remote reports nothing live.

Findings, ranked by reach

Reach classes: (1) attacker controls transcript filenames; (2) attacker controls transcript contents or size; (3) the person typing SQL, against what the sandbox claims to prevent; (0) no attacker needed.

1. query cannot register the corpus on a host with under ~1.9 GiB RAM per core (reach 2/0, availability)

register_raw builds two eager read_json TEMP TABLEs with maximum_object_size=1 GiB (registry.py _MAX_OBJECT_SIZE, lines 527 and 569) under DuckDB's defaults, and only afterwards does query() apply _harden_query_connection's memory_limit. DuckDB reserves about 1.5 GiB per thread for those readers; with threads defaulting to the core count and memory_limit defaulting to 80% of RAM, an 8 GiB / 4 vCPU guest fails at (8.0 GiB/6.2 GiB used) and a 32 GiB / 16 vCPU guest fails at (26.0 GiB/25.0 GiB used), on a 131 MB corpus whose edges.jsonl files total under 7 MB. Every query fails, including SELECT 1, with an opaque OOM. _QUERY_MEMORY_FLOOR_BYTES = 8 GiB also sets a "limit" above physical RAM on the 8 GiB guest (the computed cap was 6.26 GiB only because the 80% clamp won). The _MAX_OBJECT_SIZE docstring's claim that lowering it doesn't reduce registration memory measured RSS at a fixed thread count; the DuckDB accounting that enforces the limit scales with threads. Still present on merged main 4571e14.

Fix: in query() (app.py), set threads, memory_limit and temp_directory before register(...), leaving only the allowlists, enable_external_access and lock_configuration after it; clamp _query_memory_limit_bytes to physical RAM instead of flooring at 8 GiB; in registry.py, lower maximum_object_size for edges.jsonl (newline-delimited, small objects) to a few MiB and size the trajectory reader's limit from the largest trajectory.json present, or cap the reader threads. A regression test: SET threads=4; SET memory_limit='6GB' then register() over the fixture corpus must succeed.

2. Running query as root, COPY ... TO <granted parquet> (USE_TMP_FILE false) overwrites the corpus (reach 3)

Probe d2 as uid 1000 is refused at the filesystem (IO Error: Permission denied, the 0444 mode doing its job), but as root it exits 0 and steps.parquet becomes a one-row file; every later query touching that session fails with schema mismatch. The _harden_query_connection docstring says the 0o444 mode closes this "for any non-root user", which is accurate, but containers and CI runners commonly run as root, and the corpus is then writable from SQL. Not addressed by PR #8.

Fix: drop the granted parquets from allowed_paths by reading them at registration the way the JSON path does (a con.read_parquet(...) relation or a TEMP TABLE, which PR #8's bound-parameter shape already moves toward), or grant a read-only bind mount / copy; failing that, refuse to run query as uid 0 with an explicit error, and amend the docstring.

3. Caller SQL can create and read arbitrary files inside the spill directory (reach 3)

allowed_directories=[<corpus>/.duckdb_tmp] is documented as the spill area DuckDB needs; it also permits COPY (SELECT ...) TO '<spill>/anything' (probe d3, exit 0) and read_text/read_csv/glob back over it (e4, exit 0), for any uid. .. traversal out of it is refused (d4, e3). The directory doesn't exist on a fresh corpus (the grant is then unusable and the CLI's hint "is the corpus materialized?" misleads), and once it exists it sits inside corpus_root, where materialize's scan and ghost-removal operate.

Fix: create the spill directory in _harden_query_connection before granting it, place it outside corpus_root (a per-process tempfile.mkdtemp under the system temp dir, removed on exit), and say in the docstring that the grant allows arbitrary COPY into it, not only spilling.

4. query downloads and installs a 242 MB extension over the network during the unsandboxed registration phase (reach 0)

register_vss runs INSTALL lance; LOAD lance before the sandbox arms. On a machine without the extension cached, the first atif-sql query reaches extensions.duckdb.org, writes $HOME/.duckdb/extensions/v1.5.5/<platform>/lance.duckdb_extension (242,170,198 bytes) and its .info, then proceeds; it did so in a VM launched without egress, because the platform doesn't seal it. The docstring positions INSTALL httpfs as the network hole the sandbox closes, while the tool's own startup performs an INSTALL. Also a supply-chain surface: the extension is fetched by name from the default repository at query time.

Fix: install lance explicitly at materialize/embed time (or ship it via the duckdb-extensions mechanism the wheel already uses for core extensions), and in query use LOAD only, failing with a clear exit-65-style message when the extension isn't present; or make register_vss skip the ATTACH entirely when no embeddings store exists, which is the state of every corpus that hasn't run embed.

5. Sandbox settings and the full session inventory are readable from caller SQL (reach 3, low)

duckdb_settings() / current_setting('allowed_paths') return the corpus root, spill dir, memory limit and one granted path per session, so a query caller learns every session id (Claude Code ids embed project directory names) without reading a session. Consistent with the current threat model (the query caller is the local user); document it, or, if query is ever exposed to a less trusted caller, deny those functions too.

6. ..jsonl deletes the materialized corpus with exit 0 (reach 1) — closed by PR #8

Reproduced on 31b61d5 as described in the agent pass section. Merged main 4571e14 rejects it: the same pass reports rejected: 2, rejected_session_ids: ['*', '.'] and the three victim sessions survive. Suggested follow-up: replace_dir_atomic should still refuse a dst_dir that is an ancestor of its own tmp_dir, as defence in depth independent of the id allowlist.

7. Glob metacharacters in a session id multiply other sessions' rows (reach 1) — closed by PR #8

Reproduced on 31b61d5 (sessions 7 rows for 4 ids). 4571e14's ^[A-Za-z0-9][A-Za-z0-9._-]*$ allowlist rejects * and ?, and its bound-parameter readers no longer splice ids into statement text. A post-registration assertion that the number of files read equals the number of session ids would catch any future regression in either layer.

Things that behaved as documented

sql_literal and _sql_str quote every path and id they wrap, and every adversarial name and payload round-tripped as data; enable_external_access=false refused every read, write, ATTACH, INSTALL, LOAD, EXPORT and glob outside the two grants; lock_configuration refused every SET/RESET but TimeZone; .. never escaped the spill dir; getenv doesn't exist in this build (exit 65, not 70); the three panel queries returned the same rows in the guest as on the host; bandit/ruff/semgrep counts matched exactly between x86_64 and aarch64.

laithalsaadoon added a commit that referenced this pull request Sep 13, 2026
…, no extension installs at query time (#10)

* fix(query): size registration to the host, private spill dir, no root, no extension installs at query time

Closes the five open findings of the MicroVM defensive review of main
4571e14 and pins findings 6 and 7 (closed by #8) with their exact
reproductions.

1. Registration OOM under ~1.9 GiB RAM per core. The eager read_json
   readers reserve about 2x maximum_object_size per thread, so the 1 GiB
   constant cost 2 GiB a thread and `SET threads=4; SET memory_limit='6GB'`
   failed to register any corpus with two or more edges.jsonl files. The
   bound is now sized from the largest file the reader opens (plus a
   quarter and 1 MiB, floored at DuckDB's 16 MiB default, capped at 1 GiB),
   and query applies memory_limit, threads and temp_directory BEFORE
   register(). The cap derives from available RAM (MemAvailable, 80%
   ceilings, 8 GiB target, 512 MiB floor) and the thread count from the
   cap (one per 2 GiB, capped at sched_getaffinity). ATIF_SQL_QUERY_MEMORY_LIMIT
   and ATIF_SQL_QUERY_THREADS override both; malformed values exit 64.
   The old _query_memory_limit_bytes is the same function, re-derived.
   Panel on the 300-session corpus: byte-identical output unlimited and
   under 6GB/4 threads; unlimited wall 0.88-0.98 s -> 0.73-0.79 s.

2. Root could overwrite the corpus through a granted parquet with
   COPY ... (USE_TMP_FILE false). DuckDB 1.5.5 has no read-only grant
   (measured: no write switch in duckdb_settings(), and a read_parquet
   relation bound before enable_external_access=false is refused without
   a grant), so two layers: query, search and analyze refuse uid 0 with
   exit 77 root_refused unless ATIF_SQL_ALLOW_ROOT=1 (logged warning), and
   query refuses every file-facing statement kind before executing anything
   (COPY, COPY_DATABASE, EXPORT, ATTACH, DETACH, INSTALL, LOAD, PREPARE,
   EXECUTE) via DuckDB's own extract_statements on the hardened connection,
   exit 70 sandbox_refused, for any uid. Probe d2 now fails as any uid.

3. The spill directory was <corpus_root>/.duckdb_tmp and writable by
   caller SQL, persisting files inside the corpus. It is now a per-process
   tempfile.mkdtemp (0700) under the system temp dir, the sole
   allowed_directories entry, removed on every exit path after the
   connection closes. Nothing under the corpus is writable.

4. register_vss ran INSTALL lance (a 242 MB download) during every
   registration. It now LOADs the extension only when duckdb_extensions()
   says it is installed, skips the load entirely when no store directory
   exists, and binds message_embeddings empty with a warning otherwise.
   query sets autoinstall_known_extensions=false and
   autoload_known_extensions=false before registration. Installing is an
   explicit act: `atif-sql embed --install-extension`, or a real embed
   run. `atif-sql status` reports vector_search ready | no_store |
   extension_missing; `search` exits 78 extension_missing rather than
   pretending the store is empty.

5. duckdb_settings() / current_setting() expose the grants and so every
   session id. DuckDB cannot hide a setting and the grants must be per
   file, so this is documented as accepted: the caller is the local user
   and query is not a privilege boundary.

Findings 6 and 7: a transcript named `..jsonl` (stem `.`) and one named
`*.jsonl`, materialized over three victims, are rejected and the tree is
untouched; a session dir named `*` or `???` counts every session once on
both read paths and grants no glob.

Tests that fail on main 4571e14 and pass here (verified on a scratch
checkout): registration under 4 threads/6GB and 16 threads/8GB (CLI and
registry), COPY into the spill dir persists nothing, USE_TMP_FILE false
over a granted parquet is refused, root is refused, and a corpus with a
store and no extension installs nothing.

* test: register the lance install fixture under a public name

conftest.py re-exports the fixture modules with `import *`, which skips
underscore names, so `_lance_extension_present` never registered and a
runner with no cached extension (CI) ran the store-binding tests without
lance. Reproduced locally with an empty HOME: 6 failures, then 407 passed
once the fixture had a public name and installed the extension itself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant