fix(security): follow a custom KIROCREW_HOME on the bash gate too - #4291
fix(security): follow a custom KIROCREW_HOME on the bash gate too#4291leonlaiyc wants to merge 1 commit into
Conversation
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
2 similar comments
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/security.py:9935 -- Raw cache key leaves a repointed custom home unfenced BLOCKING -- src/kiro_crew/security.py:9200 -- Empty shell quotes bypass the custom-home fence [BLOCK-MERGE] 2e0eafa Adjudication (Opus 4.8) — is blocking on each finding proportionate?Both adjudicable findings are absent (0). Two FENCED findings remain; ruling annotate-only. F1 — Cache keyed on the raw F2 — 🏷️ Fenced finding(s) machine-flagged as likely edge caseThe security fence keeps these findings blocking regardless of adjudication; the only clearance path is a human override recorded by a repository writer, who must independently verify a rationale before recording it — it is machine-authored, and a wrong override on a security-class finding ships exactly the class the fence exists to stop. (This lane's comment deliberately carries no override command.)
|
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Real gate-parity hole, closed at the right seam — the shell branch derives from the same resolver and leaf lists the tool gate uses, so the gates can't silently drift. Suggestions
[DESIGN-REVIEWED] 2e0eafa |
0e95bdb to
5f69323
Compare
|
The finding is valid and reproducible. With A path with a space cannot appear bare in a command — the shell requires it quoted or backslash-escaped. The quoted forms keep the raw space, so the literal anchor matched them; Fixed by making the anchor accept shell-escaped whitespace rather than by reverting the hunk: each segment now goes through
|
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All verification is done. The base confirms: First-Principles-Verdict: CONCERNS Two of the new tolerances fix gate-wide root causes at one branch only: attached-option paths and resolved-override anchoring each leave counted siblings open. What this change shipsIntent: make the shell gate refuse the same files the tool gate refuses under a custom
Watch
[FIRST-PRINCIPLES-REVIEWED] 2e0eafa |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsBoth symbols and the resolver exist; no NameError is introduced by the new branch, and the branch is empty ( Assessing the two candidates: Candidate 1 (concurrent Candidate 2 (security.md not updated): Doc-freshness against an AGENTS.md convention, not an AUTOSDE rule and not a code behavior. It has no observable runtime wrong outcome — (c) cannot be re-derived from what the code does when executed. The candidate rates it low and calls it "a judgment call." It does not meet the (a)/(b)/(c) bar. Dropped. No grounded Step-2 finding surfaced: the cache is strictly finer-grained than the prior single-slot form (keyed on raw No findings. [OPUS-REVIEWED] 2e0eafa |
5f69323 to
c9e3f46
Compare
|
Valid, and reproduced before fixing. With The root is as spellable as the remainder, and the tool gate fences every one of those because it resolves the path — so an exact-literal root was a one-character bypass of the whole branch. Fixed by joining the root segments with the same generalized separator the fenced-dir branches above already use, plus repeated-separator tolerance: crew_gsep = rf"{win_gsep}{win_sep}*"
crew_anchor = crew_gsep.join(
_shell_whitespace_tolerant(part) for part in re.split(r"[\\/]", crew_home)
)
Security suites re-run at 1033 passed / 18 skipped, with one failure that is not from this diff: |
c9e3f46 to
4bc50ac
Compare
|
Valid, and reproduced before fixing. A symlinked (or junctioned)
The lexical path is the one an operator actually types, because it is the one they configured — so anchoring on the resolved path alone left the whole branch open to the spelling most likely to be used. The tool gate does not have this gap: Fixed exactly as the finding asks — the anchor now matches both roots: crew_roots = [crew_home]
crew_override = os.environ.get("KIROCREW_HOME")
if crew_override:
crew_lexical = os.path.abspath(os.path.expanduser(crew_override))
if crew_lexical and crew_lexical not in crew_roots:
crew_roots.append(crew_lexical)
crew_anchor = "|".join(
crew_gsep.join(
_shell_whitespace_tolerant(part) for part in re.split(r"[\\/]", root)
)
for root in crew_roots
)
No real symlink privilege was needed for the red-before. Two negative controls keep the widened anchor honest: an ordinary file under the lexical home stays writable, and a sibling root that merely shares its prefix ( Fail-before/pass-after: on Security suites re-run at 1185 passed / 18 skipped, with one failure that is not from this diff: (Edited: the quoted snippet above had lost one backslash in the character class - the code on the branch is and always was [\/], matching the existing spelling at security.py:4928. Correcting the quote so it matches what is actually pushed at 4bc50ac; no code changed.) |
48d6b03 to
37cf631
Compare
37cf631 to
a2a3872
Compare
Same-span stall on
|
| Round | Finding | Outcome |
|---|---|---|
| R1–R2 | Earlier custom-home fence gaps | Patched per-instance |
| R3 (Aug 26) | Env-var-spelling bypass — $KIROCREW_HOME/... spellings reached the protected tree while the gate matched only the resolved literal path |
Fixed on head a2a3872cb: added crew_var_anchor (POSIX $VAR/${VAR}, cmd %VAR%/!VAR!, pwsh $env:VAR spellings) to resolved_crew_branch, mirroring the $KIRO_HOME precedent; 2 parity tests added |
| R4 (this round) | Shell operators bypass the custom-home fence — the crew_var_anchor terminator `(?:{win_sep} |
\s |
Four-plus blocking rounds on one span is the same-span stall trigger: per protocol I am stopping instance patches on this span and asking for a ruling instead of pushing a 5th fix.
Options
- Unify terminators (recommended). Delete the bespoke terminator in
crew_var_anchorand reuse the sharedpath_endclass for every anchor branch (resolved literal,$KIRO_HOME, and allKIROCREW_HOMEvar-spellings). This removes the divergence axis entirely — there is no per-operator whack-a-mole left, because there is only one terminator definition. ~5-line change; red-before-green verifiable with the same parity harness used in R3. - Spot-patch. Add the missing operator class to the bespoke terminator. Smallest diff, but it is the 5th instance patch on this span and leaves the two-terminator divergence in place for a future round.
- Rule the residual out of scope. Treat operator-glued spellings as beyond this PR (which targets security: the bash gate does not follow a custom KIROCREW_HOME while the tool gate does #4082's basic custom-home parity), document the residual, and dispose the finding with that rationale.
Recommendation: option 1 — it is the restructure the stall rule asks for, small, and it makes the R4 class of finding structurally impossible rather than individually patched.
Awaiting decision; not patching further on this span until then.
|
span=3c5b15a2d41e R4 fix (nrb-ruled restructure, head The finding was real: Per the escalation ruling (option 1 in the span ledger), this is the restructure, not a 5th spot-patch: the bespoke terminator is deleted and the branch reuses the shared Proven red-before-green: |
|
span=c042398cee96 Fixed at head The advisory was correct: |
Audit note — part of this has already landed; the rest has notThis PR is not a duplicate and is not finished by anything on Already landedTwo of the six references actually landed and both cover ZERO of this PR's behaviour: e536451 (#6993, the landed-commit index for Which parts
|
|
Escalation: CI perf guard is systemically flaky on CI runners (not a code regression)
All other lanes on head 4aedd38 are green. Options for the maintainer:
Halting the automated drive here per the agreed flake-escalation threshold; not patching the test without a ruling. |
|
Escalation resolved per maintainer ruling — no budget change. Root cause of the perf flake was in this PR, not the runners' speed alone: the compiled-pattern cache had become single-slot keyed on |
|
Escalation: timing tests now failing for reasons that implicate the CI runner, not this PR's code. Context from scratch: this PR (fork, author @leonlaiyc, fixes #4082) makes the bash gate fence a custom On this head's CI round, Linux shard 3 failed two independent timing tests:
Local evidence that the code is not the cause: best-of-3 match time on the guard's 20KB line is identical between origin/main and this head (1.498s vs 1.496s), and the suite-induced pattern rebuilds are gone (build-count regression test passes). A runner ~5x slower than baseline fails both tests — including main's own guard at main's own cost. Also on this round, One finding WAS ours and is already fixed locally awaiting push: main's new anchor-count pin (11) vs our added fence branch (12) — pin bumped, full suite (1363) green. Maintainer's call requested — options in the Slack summary. |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Maintainer decision requested: the 1.0s perf test is now demonstrably measuring cold compile + runner load, not this PR's matching cost. Context from scratch: this fork PR (author @leonlaiyc, fixes #4082) extends the bash gate to fence a custom On head
Options (maintainer's call):
Recommendation: option 1. The 6s guard already covers the pathological-input case with a load-tolerant ceiling. |
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
The two gates that fence a write-protected file under the data home disagreed about a non-default `KIROCREW_HOME`. `is_sensitive_write_path` resolves through `config_dir()` and follows the override. `is_sensitive_bash_command` is a string matcher over the home SPELLINGS a command can carry (`~`, `$HOME`, `/home/<user>`, `%USERPROFILE%`) followed by a crew prefix (`.kiro/crew`, `.kirocrew`), and a resolved override path carries neither. So on such an install a write-protected file was fenced against the agent's file tools and reachable by a bash redirect naming its real path. Protected on one path only is not protected. The worked example is `apps/ops-mission-control/data/rotation.yaml`, which is an INPUT TO AN AUTHORIZATION DECISION -- an agent that writes its own login there makes `rotation.authorize_action` accept a forged shift -- but the gap is generic to every entry on the write-protected bash floor and to the crew-prefixed entries of `_SENSITIVE_HOME_DIRS` (`.env` and the other crew secret leaves were equally reachable). A custom home is a normal single-instance install: `kirocrew pod` and `dev-backend.sh` both use one. `_build_sensitive_regex` gains one more alternative, anchored on the resolved crew home, carrying the same remainders the tool gate re-anchors there. It is derived by stripping whichever crew prefix an entry carries -- the same derivation `_home_dir_targets_uncached` performs -- so a leaf added to either list is covered on both paths without a second edit, and the two gates cannot drift apart again. The resolver is `_resolved_root_key`, which the tool gate already keys its target set on. It only reads and resolves the env var: none of `config_dir()`'s start-of-process maintenance (mkdir, legacy migration, breadcrumb refresh, archive sweep) runs on the gate path. It answers `None` for a default home, where the branch is omitted entirely and the pattern is byte-identical to before. The compiled pattern is cached, and it now embeds a resolved path, so the cache is re-keyed on the raw `KIROCREW_HOME` -- a stale pattern built for a previous home fails OPEN on the current one, which is this same bug reintroduced through the cache. The raw value is used rather than the resolved root because this is read on every gate call; the residual (a symlink UNDER the override repointed mid-process) can only leave the resolved-literal branch naming the old location and removes no existing branch, so the gate cannot fall below what it matched before. Both separators are accepted in the anchor: the resolved literal is all-backslash on Windows while git-bash and msys tooling render the same root with `/`. `ops_mission_control/tests/test_security.py::test_the_shell_path_is_closed_too` is the test that surfaced this. It recorded the asymmetry in its docstring rather than pinning it, because handing it the resolved path asserted nothing while the suite read the operator's real home; pinning `KIROCREW_HOME` per test in kirodotdev#4065 made that visible. It now asserts the resolved form alongside the home spellings. The anchor accepts shell-escaped whitespace, not only the raw literal. A path containing a space cannot appear bare in a command -- the shell requires it quoted or backslash-escaped -- and the quoted spellings keep the raw space while `.../my\ home/...` carries a backslash a literal does not have. That is the ordinary case rather than a corner: `C:\Users\First Last` is a normal profile directory, so a literal anchor would have fenced three spellings out of four and left the fourth open. Tabs fold in with spaces, over-matching in the fail-safe direction for a gate that blocks on naming alone. The root anchor is joined with the generalized separator, not a bare one. A root is as spellable as the remainder -- `/x/./home`, `/x//home` and `/x/zz/../home` all name the same directory -- and the tool gate fences every one because it RESOLVES the path. An exact-literal root fenced none of them, which was a one-character bypass of the whole branch; the fenced-dir branches above already carry this tolerance, so the root now uses the same chain plus repeated-separator tolerance. Over-matching a path that ends elsewhere is the safe direction, the trade ``win_gsep`` already documents. Fixes kirodotdev#4082 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Originally authored by Leon (leonlaiyc). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
|
Closing as superseded on Those two PRs deleted the text-layer path fence in The underlying report (issue #4082, custom |
Problem / Motivation
The two gates that fence a write-protected file under the data home disagree
about a non-default
KIROCREW_HOME:security.is_sensitive_write_path()— resolves throughconfig_dir(), so it follows the override.security.is_sensitive_bash_command()— is a stringmatcher over the home SPELLINGS a command can carry (
~,$HOME,/home/<user>,%USERPROFILE%) followed by a crew prefix (.kiro/crew,.kirocrew). A resolved override path carries neither.So on such an install a write-protected file is fenced against the agent's file
tools and reachable by a bash redirect naming its real path. Protected on one
path only is not protected.
Reproduction from #4082, run against
mainand against this branch:Why it matters
rotation.yamlis the worked example because it is an INPUT TO ANAUTHORIZATION DECISION — an agent that names its own login there makes
rotation.authorize_actionaccept a forged shift — but the gap is generic toevery entry on the write-protected bash floor and to raw-text, embedded-script,
and Windows-native spellings of crew-prefixed
_SENSITIVE_HOME_DIRSentries.Ordinary path-like custom-home
.envforms may already be caught by the baseis_sensitive_path()normalizer; this PR does not claim that all such forms werepreviously unprotected.
A custom
KIROCREW_HOMEis a normal single-instance install, not an exoticconfiguration —
kirocrew podanddev-backend.shboth use one routinely.What changed (motivation → approach → change)
Motivation — make the shell gate fence the same files the tool gate already
fences, without widening what either one blocks on a default install.
Approach — reuse the tool gate's own resolution rather than inventing a
second one.
_resolved_root_keyis what_home_dir_targetsalready keys itstarget set on, and
_home_dir_targets_uncachedalready re-anchors everycrew-prefixed entry under the resolved home for exactly this reason. Deriving
the shell branch from those same two inputs is what keeps the gates from
drifting apart again.
Change —
_build_sensitive_regexgains one more alternative, anchored onthe resolved crew home and carrying the same remainders the tool gate
re-anchors there.
The remainders are derived by stripping whichever crew prefix an entry carries,
so a leaf added to
_SENSITIVE_HOME_DIRSor_WRITE_PROTECTED_BASH_LEAVESiscovered on both paths without a second edit.
_resolved_root_keyonly reads and resolves the env var: none ofconfig_dir()'s start-of-process maintenance (mkdir, legacy migration,breadcrumb refresh, archive sweep) runs on this gate path. It answers
Nonefora default home, where the branch is omitted entirely and the compiled pattern is
byte-identical to before.
Both separators are accepted in the anchor: the resolved literal is
all-backslash on Windows while git-bash and msys tooling render the same root
with
/.The root segments are joined with the generalized separator the fenced-dir
branches above already use (canonical
\.and\X\..no-op chains), plusrepeated-separator tolerance. A root is as spellable as the remainder —
/x/./home,/x//homeand/x/zz/../homeall name the same directory, andthe tool gate fences every one because it resolves — so an exact-literal root
would fence none of them.
The anchor tolerates shell backslash escapes
The generated path fragments accept an optional backslash before every
character, not only before whitespace. An unquoted POSIX shell removes a
backslash escape before the path is resolved, whatever character follows it.
Measured, not assumed:
So a command can spell a protected path with a backslash anywhere and still
reach that exact file. Tolerating it only before whitespace left every other
position open — an authorization bypass, since the tool gate resolves the path
and refuses it while the shell gate did not. Whitespace additionally folds
space and tab into one class: a path containing a space cannot appear bare in a
command at all, and
C:\Users\First Lastis an ordinary profile directory.This over-matches, deliberately and in the fail-safe direction. The quoted
spellings above keep the backslash and therefore name a different path, but
this gate matches raw command text and cannot see quoting, so it fences them
too. Refusing a path that would not have resolved here is safe; missing one
that would is the bypass being closed. Segments never contain a separator (the
caller splits on
[\\/]first), so no separator ambiguity is introduced.Widening a security pattern was measured rather than argued — branch vs
main,same process shape,
KIROCREW_HOMEset:\\?Xis near-deterministic because\andXare disjoint for everycharacter in these paths, so the optional group does not create the ambiguity
that drives backtracking.
The pattern cache had to be re-keyed
The compiled pattern is cached in
_SENSITIVE_REand now embeds a resolvedpath. A stale pattern built for a PREVIOUS home fails open on the current
one — this same bug, reintroduced through the cache.
_get_sensitive_renowre-keys on
KIROCREW_HOME.Keyed on the RAW env var rather than the resolved root because this is read on
every gate call and resolving would put a
Path.resolve()on that hot path. Theresidual — a symlink UNDER the override repointed mid-process — can only leave
the resolved-literal branch naming the old location; it removes no existing
branch, so the home-spelling and bare-leaf strategies are unaffected and the
gate cannot fall below what it matched before.
Tests
New:
test/test_security.py::TestBashGateFollowsACustomKirocrewHome(11tests). Each one pins parity rather than the branch: the assertion states
what the tool gate already answers and requires the shell gate to answer the
same, so the class keeps its meaning if the implementation is rewritten.
test_the_two_gates_agree_on_a_write_protected_leaftest_every_write_form_is_refused_not_just_a_redirectcp/tee/python -c open/sed -i/mv— a narrow verb allowlist is bypassabletest_a_crew_secret_leaf_under_the_override_is_blocked_too.envspellings follow the override too; ordinary path-like forms may already be covered by base normalizationtest_either_separator_spells_the_same_root/spelling a shell actually carries names the same roottest_an_ordinary_file_under_the_override_stays_writabletest_the_home_spellings_are_unchanged~/$HOME//home/<user>//Users/<user>still match, asserted with an override set (the configuration whose pattern is rebuilt)test_changing_the_override_re_keys_the_compiled_patterntest_a_home_with_a_space_is_fenced_in_every_shell_spellingtest_a_canonical_no_op_spelling_of_the_root_still_anchors/./,//,/x/../, backslash) — plus two negative controls: a sibling root sharing a prefix is not fenced, and an ordinary file under the real root stays writabletest_both_spellings_of_a_relocated_root_are_fencedtest_dropping_the_override_returns_to_the_default_home|
test_an_escaped_ordinary_character_does_not_bypass_the_fence| A shell backslash before an ordinary character — pinned at four positions (mid-root, first character of a root segment, mid-remainder, mid-intermediate-dir), each via both a redirect and an embeddedpython -c, with the tool gate asserted first as the reference answer; plus two controls so the widening cannot fence more than the root |Fail-before / pass-after. Run against pristine
origin/mainthe class is9 failed / 2 passed — and the 2 that pass are exactly the controls that must
not change (
test_an_ordinary_file_under_the_override_stays_writable,test_the_home_spellings_are_unchanged). Against this branch, 12 passed.The escaped-character regression was added in response to a blocking review on
cf6fc0d9and verified the same way, with onlysecurity.pyreverted: 1failed / 11 passed before, 12 passed after. The other 11 pass on both
sides, so that change is additive rather than a rewrite of this branch.
Updated:
ops_mission_control/tests/test_security.py::test_the_shell_path_is_closed_too.This is the test that surfaced the bug. It recorded the asymmetry in its
docstring rather than pinning it, because handing it the resolved path asserted
nothing while the suite read the operator's real home; pinning
KIROCREW_HOMEper test in #4065 made that visible. It now asserts the resolved form alongside
the home spellings — 4 failed on
main, 43 passed here.Regression suites, run with
-p no:randomly:test_security.py,test_governance_self_protection.py,test_connections_tool_aliases.py,test_browser_cli_launch.py,test_mcp_cron_security.py,ops_mission_control/tests/test_security.py— 1033 passed, 18 skipped, plus one failure that is not from this diff:
TestHomeDirTargetsCache::test_second_call_does_not_rebuild(assert 8 == 1) reproduces identically on pristinemainunder the same load andpasses when its class runs alone — a wall-clock-vs-TTL flake in that test
test_hooks.py,test_config_loader.py,test_computer_use_api.py,test_computer_use_enable_state.py,test_spawn_audit.py— 575 passed, 7 skipped, plus one Windows-local symlink failure
(
TestSafeReadFile::test_allows_benign_symlink,WinError 1314, needselevation) that reproduces identically on pristine
mainon this host.flake8clean on both changed source files.mypy src/kiro_crew/security.pyreports 3 errors —
resource.getrlimit/RLIM_INFINITY/setrlimitat lines8695-8703, POSIX-only attributes that are pre-existing on Windows and untouched
by this diff.
Manual verification
The reproduction table under Problem / Motivation was run as a standalone
probe against both trees (a pristine
origin/mainworktree vs this branch),outside pytest, so the result does not depend on any fixture. It exercises the
issue's own scenario end to end: set
KIROCREW_HOME, resolveschedule_file.schedule_path(), and hand the resolved path to both gates.Cross-tree baselines were taken by copying the changed test files into a clean
origin/mainworktree and running pytest there, rather than by pointingPYTHONPATHat it — the repo's conftest inserts the invoking worktree'ssrcahead of
PYTHONPATH, so the latter silently tests the branch and reports green.Scope
security.pyis the keystone gate andbackend-security-controlsis ablocking: trueAUTOSDE rule, so this is deliberately the only productionchange in the diff: one added alternative, one cache key. The read gate's
_SENSITIVE_HOME_DIRSquestion raised at the end of #4082 is answered by thesame branch — the crew-prefixed entries of that list are part of the remainder
set, and
test_a_crew_secret_leaf_under_the_override_is_blocked_toopins it.Related Issues
Fixes #4082
Context: #4065, which pinned
KIROCREW_HOMEper test and made the asymmetryvisible.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)docs/architecture/security-deep-dive.md, Layer 2Contribution License Agreement
The repository template currently contains an OSPO placeholder, so no CLA wording is reproduced.
Pattern harvest
Rule candidate: lint/AST check
Pattern: a regex path-fence branch defining its own bespoke right-boundary (terminator) class instead of reusing the module's shared
path_end— each private terminator that omits the shell-operator class reopens the operator-glued bypass this PR's R4 round fixed. Candidate check: flag any new string-literal terminator alternation insecurity.pyfence branches that is not composed frompath_end/win_sep.