fix: per-root hardlink-scan budgets with loud truncation (#646) - #2387
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've analyzed the diff and the surrounding code. Let me verify my reasoning on the key semantic change (the The core change narrows The budget change (shared 10000 → per-root 100000) widens coverage and fixes the starvation of the No findings. [OPUS-REVIEWED] ca83eb5 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/sandbox.py:1499 -- False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Root-cause fix with the right trade-offs: arm-on-alias gate, per-root budgets, and an explicit fail-open rationale; the name-prune bypass was correctly rejected. Suggestions
[DESIGN-REVIEWED] ca83eb5 |
The pre-exec hardlink scan shared one 10000-file budget across the CWD and /tmp walks, so a large worktree starved /tmp (the world-writable root the check exists for) of any scanning, and an exhausted budget fell through to exec silently -- indistinguishable from a clean scan. Only credential inodes with st_nlink > 1 enter the match set now: a single-link inode has no alias anywhere, so healthy-host spawns skip the walk entirely. When a walk does run, each root gets its own generous budget (100k) and emits one stderr warning per truncated root. Deliberate fail-open: failing closed would break every sandbox spawn on busy hosts. No directory pruning: /tmp is world-writable, so any name-based skip list is a deterministic bypass; a regression test pins its absence. Closes #646
16bfd87 to
ca83eb5
Compare
|
Design Review dispositions (advisory CONCERNS on 16bfd87 — both suggestions adopted, amended into
The attacker-filler scenario (planting files to push a hardlink past the budget) is mitigated but not eliminated — an attacker must now stage ~100k files per root instead of relying on ambient churn, and the truncation warning fires loudly when they do. Fully closing that requires the per-session private /tmp namespace the issue lists as follow-up work. |
) (kirodotdev#2387) The pre-exec hardlink scan shared one 10000-file budget across the CWD and /tmp walks, so a large worktree starved /tmp (the world-writable root the check exists for) of any scanning, and an exhausted budget fell through to exec silently -- indistinguishable from a clean scan. Only credential inodes with st_nlink > 1 enter the match set now: a single-link inode has no alias anywhere, so healthy-host spawns skip the walk entirely. When a walk does run, each root gets its own generous budget (100k) and emits one stderr warning per truncated root. Deliberate fail-open: failing closed would break every sandbox spawn on busy hosts. No directory pruning: /tmp is world-writable, so any name-based skip list is a deterministic bypass; a regression test pins its absence. Closes kirodotdev#646
Problem
The Linux namespace sandbox's pre-exec hardlink scan (Step 7 of the launcher script generated by
_build_launcher_script) walks the agent CWD and/tmplooking for hardlinks (st_nlink > 1) aliasing protected credential inodes. The walk was capped by a single counter (_MAX_SCAN = 10000) shared across BOTH roots, and on exhaustion the loops broke silently and fell through toos.execvpexactly as if the scan had passed clean.Why it matters
Two failure modes, both live in normal operation (issue evidence:
/tmpalone held ~11.8k files from routine telemetry/cache churn — no attacker action needed):/tmp— the world-writable root the check exists for — of any scanning at all.Fix (symptoms → root cause → change)
Silent degradation → one shared budget with a silent
break, spent unconditionally on every spawn → three changes confined to the Step-7 scan block of the f-string launcher template insrc/kiro_crew/sandbox.py:st_nlink > 1. A single-link inode has no alias anywhere on the filesystem, so on healthy hosts the whole CWD+/tmpwalk is skipped and the spawn pays nothing.st_nlinkis an inode property (any hardlink raises it on the shared inode), so the gate cannot miss a pre-existing alias; the BLOCKED-exit input set is unchanged for any filesystem state stable across the scan (the walk side already requiredst_nlink > 1to match)._MAX_SCAN_PER_ROOT = 100000with_root_scannedreset per_scan_root, so a large CWD can never zero out the/tmpscan, and the budget (only ever paid when a credential is actually aliased) covers the busiest observed/tmpwith an order of magnitude to spare. Worst case ≤200klstats in the child pre-exec, off the gateway loop.sandbox: WARNING — pre-exec hardlink scan truncated at N files in <root>; scan incomplete (control degrades open)), captured by the parent's stderr drain. Deliberately fail-OPEN: exiting would break sandbox spawns on any host whose/tmpoutgrows the budget; rationale documented in the block comment.Deliberately not included: pruning "known-noise"
/tmpsubtrees by name prefix./tmpis world-writable and the sandboxed agent shares the uid, so any name-based skip list is a deterministic bypass (plant the hardlink under/tmp/node-compile-cache-evil/). Both pre-push reviewers flagged this independently; a regression test pins the absence of any prune list.Tests
test/test_sandbox_argv.py::TestHardlinkScanBudget(8 tests, same generated-script text/compile-assertion pattern as the other launcher tests — the launcher needsunshareand cannot run end-to-end in CI). Red-checked against unfixed code._scan_count/_MAX_SCANcounter is gonest_nlink > 1gate (exactly 2 occurrences)_MAX_SCAN_PER_ROOT = 100000present_root_scanned = 0reset is a DIRECT child of thefor _scan_rootloop body (a byte-offset check could not distinguish a per-root reset from a per-directory one nested in theos.walkloop)sys.exiton that pathsandbox: BLOCKED — found hardlinkexit path unchanged_SKIP_TMP_DIR_PREFIXESabsent from the generated scriptcompile(script, "<launcher>", "exec")at all three sandbox levels proves the f-string brace escaping produced valid PythonManual verification
N/A — unit coverage sufficient: the changed logic is fully exercised through the generated-script assertions plus
compile(); the walk itself runs in the sandbox child pre-execvpand needsunshare, which CI lacks. Full backend gates (isort/flake8/mypy/pytest) green locally; pytest failures on the dev host are identical to a cleanorigin/mainbaseline (pre-existing environment failures, zero overlap with this change).Screenshots
N/A — no UI change.
Out of scope (per issue)
Per-session private
/tmpnamespace; periodic/tmpjunk sweep in session maintenance.Closes #646