Skip to content

fix(security): rm floor reads reserved words, empty substitutions and ${...} spans - #23840

Open
iamwhatever wants to merge 1 commit into
mainfrom
fix/rm-floor-keyword-shapes
Open

iamwhatever wants to merge 1 commit into
mainfrom
fix/rm-floor-keyword-shapes

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem / Motivation

Goal: The recursive-force rm floor refuses a wipe of / or ~ written behind a shell keyword, an empty quoted substitution, or a ${…} / $[…] span, the same as the plain spelling.

is_denied allowed shapes like if :; then rm -fr ~; fi, ! rm -fr ~, time rm -fr ~ and echo ${x:- #}; rm -fr /, while rm -fr ~ alone is refused.

Why it matters

The floor exists so that a respelling of rm -rf ~ cannot slip past the deny rows. Each shape here is a one-word change that wipes the home or root directory and was allowed.

Not a goal

  • Exec wrappers (sudo rm, command rm) and interpreter bodies: still left to the catalog rows and the OS sandbox, as before.
  • Reading heredoc bodies as commands: a heredoc body stays data.
  • An unquoted $(…) or backtick inside the rm argv (rm -fr $(printf build) ~), and a heredoc opened inside one: left open on security: rm argv floor misses keyword, comment and in-argument substitution spellings #18464. Reading them as their own frame refused common heredoc commit messages and needs a heredoc-aware paren walk first.
  • Narrowing the catalog row's over-refusals (cleanup=( rm -rf / ), rm -rf --help /): not touched.

What changed (motivation → approach → change)

Decision: deny-only. Each fix makes src/kiro_crew/security/rm_floor.py read more of the command as live shell. Every command the base refuses is still refused, and every existing allow case in TestRecursiveForceRmFloor still passes.

Shape Before After
then / do / else / { / ! / time before rm the keyword was read as the program the keyword is stepped over, like VAR=val
"$(true)"rm -fr ~ or rm -fr "$HOME$(true)" the quoted substitution hid rm or ~ refused: a substitution may expand to nothing, so it is dropped from the program word and the target
# or << inside ${…} / $[…] read as a comment or heredoc, so the rest was skipped read as data until the expansion closes; $${ is the PID and opens nothing
a ${ that never closes n/a the command is also read as if the span were not open

The keyword list is the shared shell_normalizer._KEEPS_COMMAND_POSITION set. A substitution gets its own marker, apart from the one for a literal quoted ~ or $, so rm -fr "~/" stays allowed. A leading redirection is stepped over before the substitution is dropped, so >"$(mktemp)" rm -fr ~ keeps rm as the program.

Some odd commands are now refused that bash would not wipe with: a quoted keyword ("then" rm -fr ~) and an rm -fr ~ written inside a ${x:-…} default. Each is an over-refusal, which is the safe side.

The security package line budget in test/test_security_gate_liveness.py rises by 19 lines for this.

Two shapes from the issue were already refused on main, so they get tests only: an apostrophe in a comment, and case x in x) rm -fr /;; esac.

Backwards compatibility

Compatible: only commands that wipe / or ~ with recursive force start being refused; no other command changes.

Removes nothing: no rule, key, or allow case is removed; the floor only refuses more.

Tests

TestRecursiveForceRmFloor gains 28 deny cases and 4 allow cases (echo then rm -fr ~, rm -fr $(mktemp -d), a comment after ${a[1]}, a comment after $((1 << 2))). The deny cases cover the keyword rows, case, the ${…} / $[…] rows, empty quoted and backtick substitutions, a substitution as a leading redirection target (>$(mktemp) rm -fr ~), $${, and an unclosed ${.

Deny-only check: 113 commands (every case in the class plus every reviewer probe) were run through is_denied on base c24c7bc and on this head. None is refused on base and allowed here.

Mutants checked, each fails its own cases: the keyword skip (9), the ${…} tracking (4), the $$ pairing (1), the unclosed-span fallback (1), and the empty-substitution step in the program word (1) and in a target (1).

Proof: PROVEN test/test_denied_commands_security_rows.py::TestRecursiveForceRmFloor::test_a_recursive_force_wipe_of_root_or_home_is_denied[if :; then RM -fr ~; fi], test/test_denied_commands_security_rows.py::TestRecursiveForceRmFloor::test_a_recursive_force_wipe_of_root_or_home_is_denied[if false; then :; else RM -fr ~; fi], test/test_denied_commands_security_rows.py::TestRecursiveForceRmFloor::test_a_recursive_force_wipe_of_root_or_home_is_denied[for i in 1; do RM -fr ~; done], test/test_denied_commands_security_rows.py::TestRecursiveForceRmFloor::test_a_recursive_force_wipe_of_root_or_home_is_denied[while :; do RM -fr /; done], test/test_denied_commands_security_rows.py::TestRecursiveForceRmFloor::test_a_recursive_force_wipe_of_root_or_home_is_denied[{ RM -fr ~; }] (head 94abebc)

Local GPT and Opus review lanes were skipped: the host had under 3 GB of free memory. CI's review lanes are the review for this head.

Manual verification

Every keyword, case and ${…} probe in the issue and its comments was run through kiro_crew.security.is_denied on this head: all are refused. rm -fr build, echo rm -fr ~, a cat <<EOF with an rm -fr ~ body, and heredoc commit messages with a) list items stay allowed.

Related Issues

Part of #18464

Sibling sweep

Swept: git grep -n "_KEEPS_COMMAND_POSITION|_matching_close_paren" -- src/kiro_crew/security/
Sibling: src/kiro_crew/security/shell_normalizer_walk.py -- not affected -- the shared walk already keeps command position after these keywords
Sibling: src/kiro_crew/security/rm_floor.py -- changed here

Pattern harvest

Not generalizable: the floor keeps its own small word split by design, and the shared reader already handles these keywords and spans.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Sensitive Change Review

Sensitive files changed:

  • src/kiro_crew/security/rm_floor.py

  • @iamwhatever posted a complete reasoning template for 94abebc.

@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

UX Review (Opus 5.5) — ⏭️ skipped

Revision 94abebcde738886b08d43880a2a2f5db6fe61fea touches no user-facing surface (no changes under website/ or committed screenshots), so the UX review was skipped. Does not block merge.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Design Review (Opus 5.5) — ✅ PASS

Design-level review of 94abebcde738886b08d43880a2a2f5db6fe61fea — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

Design-Verdict: PASS

Nothing blocks: the change only adds refusals, it is reversible, and its subject is a real shell command line whose / or ~ target the OS sandbox does not protect.

Notes

[DESIGN-REVIEWED] 94abebc

Description read

[DESCRIPTION-READ] 2ba053c08852d0a12534a73d4c73ae3a0fea54d32a9ac7ddb083280f6b0e31fb

That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with .github/scripts/pr-description-capture.sh over the same inputs; if it differs, the description or that evidence changed after this verdict was formed and any finding drawn from either is unproven.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

First Principles Review (Opus 5.5) — ✅ PASS

Premise-level review of 94abebcde738886b08d43880a2a2f5db6fe61fea — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

First-Principles-Verdict: PASS

Nothing blocks. Every item closes a refused-spelling gap linked to #18464, and each one has a deny test that fails on base. The over-refusals are declared and land on the safe side.

What this change ships

Inventory (6 items) — 6 justified

Intent: stop a wipe of / or ~ from getting past the rm floor when the user respells it with a shell keyword, an empty substitution or a ${…} span. FIX.

  1. rm -fr ~ / rm -fr / after then/do/else/{/!/time is now refused — justified
  2. An empty quoted or backtick substitution stuck to rm or to the target no longer hides the wipe — justified
  3. A # or << inside ${…} / $[…] no longer starts a comment or heredoc that skips the rest — justified
  4. An unclosed ${ is read both with and without the open span, and refuses if either reading hits — justified
  5. New over-refusals ("then" rm -fr ~, rm inside a ${x:-…} default), declared — justified
  6. Security package line budget raised by 19 lines — justified

[FIRST-PRINCIPLES-REVIEWED] 94abebc

Description read

[DESCRIPTION-READ] 70f40b4d55c72b22f5084b4d1a2e098e4f45474b240cb213eb489ff8d88ebf3f

That sha256 names the PR description this verdict read. Recompute it with .github/scripts/pr-description-capture.sh; if it differs, the description changed after this verdict was formed and any finding drawn from the description is unproven.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Opus 5.5 Review — 🔴 changes requested (blocking)

Reviewed 94abebcde738886b08d43880a2a2f5db6fe61fea — this comment is updated in place on each push.

The rm-floor change in _segments takes exponential time on nested unclosed expansions, so a command of a couple hundred bytes stalls the synchronous PreToolUse deny gate for seconds to minutes.

BLOCKING — src/kiro_crew/security/rm_floor.py:147
if expansion and track: # never closed: also read it as if no span were open / return [*out, "".join(seg), *_segments(command, track=False)]
Trigger: the agent runs a shell command, which may contain external content such as a page or a channel message, that nests "$( ${ … )" N times around rm -fr /tmp/x. The path is deny_gate.is_denied → _rm._can_fire (true) → _rm._rm_wipe_targets → _segments. Each level leaves ${ open, so the whole command is read a second time with track=False. Both readings recurse into the "$( body through _segments(body…) with the default track=True, and that body does the same, so the work doubles at every level.
Outcome: the time doubles per level, as measured on this head. N=14 (153 chars) took 1.3 s in is_denied. N=16 (173 chars) took 5.2 s, against 0.0017 s for the base rm_floor on the same input. No exception is raised, so the except Exception fail-closed branch never runs, and the gate thread hangs for minutes to hours on commands a few hundred bytes long.
Anchor: defect
Fix: re-read without tracking only at the top level, by passing a flag so nested _segments calls never re-run the whole body. Or build the tracked and untracked readings in one pass that shares the recursive body results. Add a liveness test with N≈20 nesting.

[BLOCK-MERGE] 94abebc
[OPUS-REVIEWED] 94abebc

Verdict parsed from the review's SHA-scoped output markers for commit 94abebcde738886b08d43880a2a2f5db6fe61fea.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 94abebcde738886b08d43880a2a2f5db6fe61fea false-positive: <reason, 20 to 500 characters>

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

GPT 6.1 Review — 🔴 changes requested (blocking)

GPT 6.1 found at least one blocking issue that must be resolved before merging 94abebcde738886b08d43880a2a2f5db6fe61fea. 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands.

This comment is updated in place on each push.

BLOCKING -- src/kiro_crew/security/rm_floor.py:93 -- Expansion tracking lets comment quotes conceal a home wipe

live_syntax = step.active and not expansion
Trigger: An agent shell call containing unset x; echo ${x:-$( # don't\nrm -fr ~\n# '\n)} (actual newlines) reaches _tier_deny_rules → is_denied → _segments; the backtick equivalent also reproduces.
Outcome: Base refuses both commands; HEAD allows them. Comment recognition is suppressed inside the expansion, so the apostrophes falsely quote away the wipe, and the closing } prevents the fallback walk. Bash executes rm -fr ~, deleting writable home contents outside the sandbox’s selected-leaf protections.
Anchor: residual/security
Fix: Preserve the untracked walk’s refusals alongside the tracked walk even when the expansion closes, rather than only for unclosed expansions.

[BLOCK-MERGE] 94abebc
[GPT-REVIEWED] 94abebc

Adjudication (Opus 5.5) — is blocking on each finding proportionate?

F1 (fenced): unbounded harm, a recursive-force wipe of the home directory. I reproduced it in this run: _segments (src/kiro_crew/security/rm_floor.py:93) never recognises the comment while the ${…} expansion is open, so the apostrophes quote rm -fr ~ into a \x00 word. The expansion closes, so the fallback walk at :146 never runs, _rm_wipe_targets returns set(), and is_denied returns None, which means allowed (deny_gate.py:777). A FLAG is not justified: the only condition is a string the agent writes, and a prompt-injected agent can write any string. No contradictory timing or unreachable input is involved, so the risk is plausible in real use.

[ADJUDICATION] 94abebcde738886b08d43880a2a2f5db6fe61fea total=0 uphold=0 downgrade=0
[GPT-ADJUDICATED] 94abebcde738886b08d43880a2a2f5db6fe61fea

[ADJUDICATION-FENCED] 94abebcde738886b08d43880a2a2f5db6fe61fea fenced=1 flagged=0
UPHOLD-FENCED F1 src/kiro_crew/security/rm_floor.py:93 -- Reproduced: is_denied returns None for the crafted command because comment detection is off inside a closed ${…} span, and an injected agent can write that string, so the home-wipe risk is plausible.
[GPT-ADJUDICATED-FENCED] 94abebcde738886b08d43880a2a2f5db6fe61fea

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 94abebcde738886b08d43880a2a2f5db6fe61fea false-positive: <reason, 20 to 500 characters>

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Security Scope Review -- ✅ PASS

Which legitimate operations does 94abebcde738886b08d43880a2a2f5db6fe61fea newly refuse? Updated in place on each push. A script-confirmed regression blocks; the model proposes candidates and never decides a refusal.

Verdict basis: adjudicated, zero confirmed regressions.

Security scope review -- adjudicated candidates

  • macos-latest (platform posix): 18 classified, 0 newly refused, 11 still allowed.
  • ubuntu-latest (platform posix): 18 classified, 0 newly refused, 11 still allowed.
  • windows-latest (platform windows): 4 classified, 0 newly refused, 4 still allowed.

No candidate flipped from allowed to refused.

Scope-Verdict: PASS

Reason: The rm floor's segment walk was tightened along three axes: it now steps over reserved words, it drops a substitution that sits in command position or inside a target operand, and it stops treating # or << as a comment or heredoc while a ${…} or $[…] is open. The 22 candidates probe the edge of each axis with non-root, non-home targets, and none is confirmed newly refused yet. One overlap with legitimate use can be argued but was deliberately not written as a row, because it has the same shape as the attack. It is named under UNADJUDICATED.

TIGHTENING: the recursive-force rm floor's segment and argv walk (_segments / _rm_wipe_targets in src/kiro_crew/security/rm_floor.py, which runs after the rule-catalog regex passes in deny_gate.py). The threat it refuses: a root or home wipe hidden behind a keyword, a substitution that expands to nothing, or a fake comment or heredoc inside an expansion. The hunks are if ENV_ASSIGNMENT_RE.match(words[0]) or words[0] in _shell._KEEPS_COMMAND_POSITION:, if _SUBST in words[0]: # a substitution in command position may expand to nothing, kinds |= {k for arg in operands if (k := _target_kind(arg.replace(_SUBST, "")))} and live_syntax = step.active and not expansion.

ADJUDICATED: 22 candidates, 0 confirmed newly-refused

PLATFORM COVERAGE: I wrote 18 posix rows and 4 windows rows. The posix rows cover each new path:

  • Reserved words: then, do, {, !, time, time -p.
  • Quoted substitution in command position: a repo-relative script, and "$(command -v rm)".
  • Substitution erased inside an operand: under $HOME, under /tmp, and a bare "$(mktemp -d)".
  • # inside a closed ${…}: ${PWD##*/} and ${#files[@]}.
  • A double-quoted, inactive ${: checked so it opens no span.
  • A heredoc body containing ${…}.

The diff assumes bash quoting and reserved-word syntax, not any path or executable spelling. The windows rows check that this posix-only reading does not spill onto Windows spellings:

  • a Git-Bash C:/ drive path;
  • cmd's if exist … reaching the new if skip;
  • rm.exe with %USERPROFILE% and \ separators;
  • PowerShell Remove-Item … $env:TEMP\….

I wrote no UNC-path row; the floor only refuses /, ~, $HOME and ${HOME}, and the diff adds no path assumption a UNC path would reach.

UNADJUDICATED: This is a narrowing argument, not a row, because writing it as a candidate would encode the attack. Since _target_kind(arg.replace(_SUBST, "")) erases a quoted substitution before comparing, a legitimate per-item cleanup like rm -rf "$HOME/$(basename "$repo")" or rm -rf "${HOME}/$(cat .tool-dir)" now reads as $HOME/, so the floor refuses it. Before this change the literal \0 kept it a descendant. Call path: Bash tool call → deny_gate → after the regex passes, _rm._rm_wipe_targets → _segments puts _SUBST in the double-quoted $(…) → the operand is stripped to $HOME/ → _target_kind returns home → refused with rule local-destructive-rm-rf-home, component argv-floor. This is the same shape the PR's own test 'RM -fr "$HOME$(true)"' targets, and if the substitution comes back empty it really does wipe home, so I'm not claiming it as a regression. If that loss is unwanted, the narrow fix is to treat a substitution glued directly after $HOME/ as a descendant only when it is followed by a literal suffix. A human should decide that.

[SCOPE-REVIEWED] 94abebc

@iamwhatever
iamwhatever force-pushed the fix/rm-floor-keyword-shapes branch from df1e137 to f140c9f Compare October 24, 2026 21:37
@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Empty substitution glued before the program word hides the wipe (span=f6adfbd55f89)

fixed in f140c9f: a leading placeholder from a substitution is stripped from the program word, and a word that was only a substitution is dropped, so $(true)rm -fr ~, $(true) rm -fr ~ and echo hi | \true`rm -fr /` are refused again.
Tests: four new deny cases in TestRecursiveForceRmFloor; removing the step fails all four.
self-added: yes
mechanism: substitution placeholder in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Empty substitutions hide a previously denied home wipe (span=e8aa6e56f6f9)

fixed in f140c9f: a substitution in command position may expand to nothing, so its placeholder is stripped from the program word (or the word dropped); $(true) rm -fr ~ and `true` rm -fr ~ are refused again.
Placeholders inside argv and quoted words are unchanged. Tests: four new deny cases.
self-added: yes
mechanism: substitution placeholder in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Literal brackets hide subsequent executable commands (span=e8aa6e56f6f9)

fixed in f140c9f: a bare [ no longer pushes a closer; only ${ and $[ open a tracked span, and each closes only on its own closer, so ${x:-[} ends at its } and the later comment is skipped.
Test: echo ${x:-[} # don't remove this marker + newline + rm -fr ~ is a new deny case; ${a[1]:- #} stays covered.
self-added: yes
mechanism: expansion closer stack in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Parameter-expansion data becomes a fictitious command (span=e8aa6e56f6f9)

Rebutted: this is an over-refusal, not a bypass. echo ${x:- #; rm -fr ~;} is refused because the floor reads an rm -fr ~ it cannot prove is data; nothing is run or lost, and rewording clears it.
The PR's goal is deny-only; making separators expansion-aware adds parser state to a floor that is a seatbelt, not a parser. Adjudication also downgraded this as disproportionate.
self-added: yes
mechanism: expansion closer stack in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Heredoc parentheses expose document text as executable shell (span=e8aa6e56f6f9)

Rebutted: this is an over-refusal, not a bypass. A heredoc inside $(...) whose body has a bare ) and then rm -fr ~ is refused; nothing is run or lost, and rewording clears it.
Making the shared _matching_close_paren heredoc-aware is out of this deny-only PR's scope. Adjudication also downgraded this as disproportionate.
self-added: yes
mechanism: unquoted substitution branch in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author

Sensitive change review

  • Head commit: f140c9f
  • What rule changed: the recursive-force rm floor in src/kiro_crew/security/rm_floor.py (behind local-destructive-rm-rf-root / -home) reads more shell shapes as live commands.
  • Before -> after (what was allowed/blocked, now): allowed before, refused now: rm -fr ~ / rm -fr / behind then, do, else, {, !, time; with $(...) or a backtick in the argv or glued before rm; after a # or << inside ${...} / $[...]; after a heredoc opened inside $(...). Nothing that was refused is now allowed.
  • Worst case if wrong (breaks commands / leaks secret / locks user out): breaks commands — an over-refusal of an unusual command that names rm -fr ~ or rm -fr / as data (for example inside a ${x:-...} default). The user rewords it. No secret or lockout path.
  • Evidence checked (which sandbox result proves it): every probe from issue security: rm argv floor misses keyword, comment and in-argument substitution spellings #18464 and its comments run through kiro_crew.security.is_denied on this head: all refused; rm -fr build, echo rm -fr ~, rm -fr $(mktemp -d) and a heredoc body containing rm -fr ~ stay allowed. prove.py reports PROVEN for the new tests (head f140c9f).
  • No regression:
    • Tools still work (which tools/commands were run, result): the allow list in TestRecursiveForceRmFloor (plain descendants, quoted text, heredoc bodies, redirect targets) passes unchanged.
    • Backward compatible (old config / old data / old callers still work, how checked): no config, key or caller changes; deny_gate.py calls _rm_wipe_targets the same way; a disabled row still disables its floor (test_a_disabled_row_disables_its_floor).
    • Existing tests (which suites passed on this head): test/test_denied_commands_security_rows.py, test/test_denied_commands_security_quoting.py, test/test_security_gate_liveness.py, test/test_flake_pattern_ratchet.py (304 passed).
  • How to undo: revert this PR's single commit.

🤖 AI-assisted comment 🤖

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not ready: two inputs that the base floor refuses are allowed on this head, so the change is not deny-only as described.

Head reviewed: f140c9f. Bots read: Design PASS, First Principles PASS, UX skipped, Opus no findings, GPT 2 blocking, readiness checking.

I reproduced both GPT findings with kiro_crew.security.is_denied on the base commit (c24c7bc) and on this head:

Command Base Head
echo $${x:- # don't remove this marker + newline + rm -fr ~ refused allowed
rm -fr ~/`true` refused allowed

The new deny cases also pass on this head. For example, if :; then rm -fr ~; fi is refused now and was allowed on the base.

🔴 2 must-fix. Details are in the inline comments. Please add each command above as a deny case, so a test goes red if the regression comes back.

Not repeated here: the Design and First Principles note that the floor keeps a second shell reader beside the shared walk. That is advisory, and it is outside this PR's goal.

Overlap: #22390, #22719 and #21900 also change the rm floor or its tests. Whichever merges second needs a rebase and a rerun of the full TestRecursiveForceRmFloor suite.

Comment thread src/kiro_crew/security/rm_floor.py Outdated
resume, resume_state = pos + end, 2
resume, resume_state = pos + end, step.state
break
if step.active and text.startswith(("${", "$["), step.offset):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A $$ before { opens a tracked expansion that bash never opens, which hides a later rm -fr ~

This check opens a tracked ${ span at any active $, including the second $ of $$. Bash reads $$ as the PID and {x:- ... as literal text. With the span open, live_syntax is false, so the real comment that follows is not skipped. The apostrophe in that comment then opens a single quote that swallows the next line.

if step.active and text.startswith(("${", "$["), step.offset):

Reproduced: echo $${x:- # don't remove this marker + newline + rm -fr ~ is refused on the base and allowed on this head (is_denied returns None).

Why it matters: The floor exists to refuse a respelled home wipe, and this change makes one respelling pass that was refused before.

Suggestion: Open a tracked span only at a $ that starts a parameter expansion. Do not open one at the second $ of a $$ pair or at an escaped \$. Add the command above as a deny case. I will withdraw this if the deny case passes on a later head.
Rule: removed guard with no replacement (a deny-only fix must not allow what the base refused)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efe5150 and kept in the current head 94abebc. This command is a deny case in TestRecursiveForceRmFloor: it fails on f140c9f and passes on base c24c7bc and on the fix.

🤖 AI-assisted comment 🤖

Comment thread src/kiro_crew/security/rm_floor.py Outdated
break
if step.state == 2 and step.char == "`" and len(step.text) == 1:
# a backtick body in double quotes is read on its own, then the quote resumes
if (step.active or step.state == 2) and step.char == "`" and len(step.text) == 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 An unquoted backtick inside a target operand now hides a home wipe that the base refused

Before this change, an unquoted backtick was not a substitution here, so ~/ followed by a backtick span was not cut, and the base refused it. Now the span becomes the \0 placeholder inside the same word. The operand reads as ~/\0, _target_kind sees no root or home target, and the command is allowed. Bash expands an empty substitution to nothing, so the command runs rm -fr ~/.

if (step.active or step.state == 2) and step.char == "`" and len(step.text) == 1:

Reproduced: rm -fr ~/`true` is refused on the base and allowed on this head (is_denied returns None).

Why it matters: This is a one-word respelling of rm -fr ~ that the base refused and this head allows.

Suggestion: When an operand contains a substitution placeholder, also check it with the placeholder removed (the empty expansion), the same way the new command-position step already does. Add rm -fr ~/`true` as a deny case. I will withdraw this if that case passes on a later head.
Rule: removed guard with no replacement (a deny-only fix must not allow what the base refused)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efe5150 and kept in the current head 94abebc. This command is a deny case in TestRecursiveForceRmFloor: it fails on f140c9f and passes on base c24c7bc and on the fix.

🤖 AI-assisted comment 🤖

@iamwhatever
iamwhatever force-pushed the fix/rm-floor-keyword-shapes branch from f140c9f to efe5150 Compare October 24, 2026 22:18
@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Paired dollars create fictitious expansions that hide executable commands (span=e8aa6e56f6f9)

fixed in efe5150: a ${ / $[ opens a tracked expansion only after an odd run of live $, so $${ (the PID, then a brace) opens nothing and the real comment after it is skipped again.
Test: echo $${x:- # don't remove this marker + newline + rm -fr ~ is a new deny case; dropping the pairing check fails it.
self-added: yes
mechanism: expansion closer stack in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Empty backtick expansions conceal destructive target operands (span=e8aa6e56f6f9)

fixed in efe5150: a substitution now leaves its own marker, apart from the literal-character one, and target operands are checked with substitutions treated as empty, so rm -fr ~/\true`, rm -fr /$(true)andrm -fr "$HOME$(true)"are refused;rm -fr "/"` stays allowed.
Tests: three new deny cases; checking the raw operand fails all three.
self-added: yes
mechanism: substitution placeholder in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author

Sensitive change review

  • Head commit: efe5150
  • What rule changed: the recursive-force rm floor in src/kiro_crew/security/rm_floor.py (behind local-destructive-rm-rf-root / -home) reads more shell shapes as live commands.
  • Before -> after (what was allowed/blocked, now): allowed before, refused now: rm -fr ~ / rm -fr / behind then, do, else, {, !, time; with $(...) or a backtick in the argv, in a target such as ~/$(true), or glued before rm; after a # or << inside ${...} / $[...]; after a heredoc opened inside $(...). Nothing that was refused is now allowed.
  • Worst case if wrong (breaks commands / leaks secret / locks user out): breaks commands — an over-refusal of an unusual command that names rm -fr ~ or rm -fr / as data (for example inside a ${x:-...} default). The user rewords it. No secret or lockout path.
  • Evidence checked (which sandbox result proves it): every probe from issue security: rm argv floor misses keyword, comment and in-argument substitution spellings #18464 and its comments run through kiro_crew.security.is_denied on this head: all refused; rm -fr build, echo rm -fr ~, rm -fr $(mktemp -d) and a heredoc body containing rm -fr ~ stay allowed. prove.py reports PROVEN for the new tests (head efe5150).
  • No regression:
    • Tools still work (which tools/commands were run, result): the allow list in TestRecursiveForceRmFloor (plain descendants, quoted text, heredoc bodies, redirect targets) passes unchanged.
    • Backward compatible (old config / old data / old callers still work, how checked): no config, key or caller changes; deny_gate.py calls _rm_wipe_targets the same way; a disabled row still disables its floor (test_a_disabled_row_disables_its_floor).
    • Existing tests (which suites passed on this head): test/test_denied_commands_security_rows.py, test/test_denied_commands_security_quoting.py, test/test_security_gate_liveness.py, test/test_flake_pattern_ratchet.py (308 passed).
  • How to undo: revert this PR's single commit.

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author

@bolichen97 Both inputs from your review are fixed on head efe5150:

Command Base f140c9f efe5150
echo $${x:- # don't remove this marker + newline + rm -fr ~ refused allowed refused
rm -fr ~/`true` refused allowed refused
  • $${ is now read as the PID: a ${ / $[ opens a tracked expansion only after an odd run of live $.
  • A substitution leaves its own marker, apart from the literal-character one, and target operands are checked with it removed. So rm -fr ~/$(true) and rm -fr "$HOME$(true)" are refused too, while rm -fr "~/" stays allowed.

Each has a new deny case in TestRecursiveForceRmFloor, and a mutant that removes the check fails it. The per-finding dispositions are posted above. Could you take another look on this head when CI is done?

🤖 AI-assisted comment 🤖

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Oct 24, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed on head efe5150 (delta from f140c9f).

Both inputs from the previous review are fixed. I ran them through kiro_crew.security.is_denied on base 1c57aca and on this head, and ran each in real bash with printf in place of rm to confirm bash executes the wipe:

Command Base f140c9f efe5150
echo $${x:- # don't remove this marker + newline + rm -fr ~ refused allowed refused
rm -fr ~/`true` refused allowed refused

A wider probe of 68 home and root wipe forms (keywords, time -p, subshells, groups, pipelines, substitutions in the program word and in targets, ${...} and $[...] spans, $$ runs, heredocs, escaped and quoted rm) found no regression among them. The PR is still not deny-only, though: a redirection whose target is a substitution, placed before rm, now hides the wipe.

Command Base efe5150
>$(mktemp) rm -fr ~ refused allowed
>"$(mktemp)" rm -fr ~ refused allowed
2>`mktemp` rm -fr / refused allowed
<$(x) rm -fr ~ refused allowed
2>$(printf /dev/null) rm -fr ~ refused allowed

Real bash runs the program after such a redirection (2>mktemp printf ... -fr / prints its operands), so this is a home or root wipe the floor refused before and allows now. The Opus and GPT lanes report the same regression on this head. Details and a fix direction are inline.

One non-blocking note: Security Scope Review is red on this head for two newly refused commit and PR body drafts. A ) in a heredoc body inside $(...) (a 1) or a) list item) closes the substitution early, and the rest of the body is read as commands. That over-refusal keeps the gate red, so it needs a fix or a maintainer decision before merge.

Also, the description says the line budget rises by 12, but the diff raises it by 19.

Verified locally on this head: test/test_denied_commands_security_rows.py and test/test_security_gate_liveness.py pass (189 tests). Backend test shards 5 to 8 and Windows shard 7 were still pending when I checked.

Overlaps on the same file: #22390, #22719 and #21900 also touch the rm floor or its tests. Whichever merges second needs a rebase, and the new segment walk should be rechecked against the nested-shell changes in #22390 and #22719.

This touches a security path, so approval stays with a maintainer.

Comment thread src/kiro_crew/security/rm_floor.py Outdated
while words:
if ENV_ASSIGNMENT_RE.match(words[0]):
words = words[1:]
if _SUBST in words[0]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A redirection glued to a substitution, placed before rm, now hides the wipe

The new unquoted $( and backtick branch in _segments leaves the substitution marker glued to the redirection word, so >$(mktemp) becomes >\x01. This step then removes the marker, the word becomes a bare >, and _push_token_redirection(">") reports that it still needs a target. That takes rm as the redirection target, so the program word becomes -fr.

if _SUBST in words[0]:
    bare = words[0].replace(_SUBST, "")
    words = [bare, *words[1:]] if bare else words[1:]

is_denied returns None on this head for >$(mktemp) rm -fr ~, >"$(mktemp)" rm -fr ~, 2>`mktemp` rm -fr /, <$(x) rm -fr ~ and 2>$(printf /dev/null) rm -fr ~. Base refuses all five. Bash runs the program after such a redirection, so these are home and root wipes that are allowed now.

Suggestion: classify the redirection on the original word before stripping. Strip _SUBST only when the word is not a redirection, or replace it with a non-empty placeholder in a redirection word so the target stays attached. Add the five commands above as deny cases.

Rule: a deny-only change must refuse every input the base refuses.

# the body is its own frame and one opaque word here; the outer
# quote state (unquoted or double) resumes after the close
body = text[step.offset + 2 :]
close, proven = _shell._matching_close_paren(body, 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A ) in a heredoc body inside $(...) closes the substitution early

_matching_close_paren does not know about heredocs, so a list item like 1) or a) in a commit message ends the body. The rest of the heredoc is then read as commands. On this head, M=$(cat <<'EOF' + newline + b) `rm -fr ~` is refused + newline + EOF + newline + ) is refused, while the same body without the ) is allowed. Base allows both.

This is an over-refusal, not a bypass, but it is the case Security Scope Review confirms as newly refused on two POSIX legs. Drafting a commit or PR body into a variable through a heredoc is a common agent flow, especially for security fixes that quote the refused command. As long as it stays, that gate stays red.

Suggestion: stop at a heredoc start inside the substitution body when looking for the close, or leave the body to the sandbox when a heredoc opens inside it. Add the gate's two commands as allow cases.

Rule: a floor change should not newly refuse a legitimate, common workflow without a maintainer decision.

… ${...} spans

The recursive-force rm floor now refuses a wipe of / or ~ behind then, do,
else, {, !, or time; with a quoted substitution that expands to nothing in
the program word or a target; and after a # or << inside an unpaired ${...}
or $[...]. An unclosed span is also read as if it were not there. Deny-only.
@iamwhatever iamwhatever changed the title fix(security): rm floor reads reserved words, argv substitutions and ${...} spans fix(security): rm floor reads reserved words, empty substitutions and ${...} spans Oct 24, 2026
@iamwhatever
iamwhatever force-pushed the fix/rm-floor-keyword-shapes branch from efe5150 to 94abebc Compare October 24, 2026 23:14
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Oct 24, 2026
@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Substitution strip turns a redirection into a bare operator that swallows rm (span=f6adfbd55f89)

fixed in 94abebc: a leading redirection is now stepped over before any substitution marker is dropped from the program word, so >$(mktemp) rm -fr ~, >"$(mktemp)" rm -fr ~ and 2>\mktemp` rm -fr /keeprmas the program and are refused, as on base. Recurring seam: the unquoted$(…)` / backtick frame this round's defect lived in is removed (option A), so that mechanism no longer exists; a 113-command corpus shows nothing base refuses is allowed on this head.
resolution: removed unquoted substitution frame; redirection order fixed
self-added: yes
mechanism: substitution placeholder in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author
  • Stripping substitutions from leading redirections hides the rm command (span=e8aa6e56f6f9)

fixed in 94abebc: leading redirections are consumed before the substitution marker is stripped, so 2>$(printf /dev/null) rm -fr ~ and the backtick form are refused, as on base.
Recurring seam (third fix on this span): the unquoted $(…) / backtick frame is removed instead of patched again; a 113-command corpus run on base c24c7bc and this head finds nothing base refuses that this head allows.
resolution: removed unquoted substitution frame; redirection order fixed
self-added: yes
mechanism: substitution placeholder in the rm floor's segment walk

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author

Security Scope Review on efe5150 found 2 newly refused commands: heredoc commit and PR messages inside $(cat <<'EOF' ... EOF) with an a) or 1) list item. Both are allowed again on 94abebc. The unquoted $(…) / backtick frame that read them is removed, and this PR is now Part of #18464. Those shapes stay open on the issue until the paren walk can skip heredoc bodies.

🤖 AI-assisted comment 🤖

@iamwhatever

Copy link
Copy Markdown
Contributor Author

Sensitive change review

  • Head commit: 94abebc
  • What rule changed: the recursive-force rm floor in src/kiro_crew/security/rm_floor.py (behind local-destructive-rm-rf-root / -home) reads more shell shapes as live commands.
  • Before -> after (what was allowed/blocked, now): allowed before, refused now: rm -fr ~ / rm -fr / behind then, do, else, {, !, time; with a quoted substitution that expands to nothing in the program word or a target; after a # or << inside an unpaired ${...} / $[...]. Nothing that was refused is now allowed: a 113-command corpus was run through is_denied on base c24c7bc and on this head.
  • Worst case if wrong (breaks commands / leaks secret / locks user out): breaks commands — an over-refusal of an unusual command that names rm -fr ~ or rm -fr / as data (for example inside a ${x:-...} default). The user rewords it. No secret or lockout path.
  • Evidence checked (which sandbox result proves it): the keyword, case and ${...} probes from issue security: rm argv floor misses keyword, comment and in-argument substitution spellings #18464 run through kiro_crew.security.is_denied on this head: all refused; rm -fr build, echo rm -fr ~, rm -fr $(mktemp -d), a heredoc body containing rm -fr ~, and heredoc commit messages with a) list items stay allowed. prove.py reports PROVEN for the new tests (head 94abebc).
  • No regression:
    • Tools still work (which tools/commands were run, result): the allow list in TestRecursiveForceRmFloor (plain descendants, quoted text, heredoc bodies, redirect targets) passes unchanged.
    • Backward compatible (old config / old data / old callers still work, how checked): no config, key or caller changes; deny_gate.py calls _rm_wipe_targets the same way; a disabled row still disables its floor (test_a_disabled_row_disables_its_floor).
    • Existing tests (which suites passed on this head): test/test_denied_commands_security_rows.py, test/test_denied_commands_security_quoting.py, test/test_security_gate_liveness.py, test/test_flake_pattern_ratchet.py (308 passed).
  • How to undo: revert this PR's single commit.

🤖 AI-assisted comment 🤖

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: checking Automated validation is still running labels Oct 24, 2026
@iamwhatever

Copy link
Copy Markdown
Contributor Author

Paused for a maintainer decision. The rm floor's own shell reader has blocked review for four rounds, so this PR gets no more code until a maintainer picks a direction.

Rounds:

  1. df1e137: an empty substitution before rm ($(true)rm -fr ~) hid the wipe, and a literal [ in ${x:-[} hid a later command. Fixed.
  2. f140c9f: $${ opened a fake expansion, and rm -fr ~/`true` hid the home target (also bolichen97's review). Fixed.
  3. efe5150: a substitution as a leading redirect target (>$(mktemp) rm -fr ~) swallowed rm, and Security Scope found heredoc commit messages with a) refused. Fixed by removing the unquoted $(...) / backtick frame; this PR became Part of #18464.
  4. 94abebc: two new findings in the ${...} tracking (below).

Current head 94abebc is deny-only against base c24c7bc on a 113-command corpus: nothing base refuses is allowed. It still carries the two round-4 findings:

  • GPT: echo ${x:-$( # don't + newline + rm -fr ~ + newline + # ' + newline + )} is allowed (base refuses it). A comment inside the tracked ${...} is not read as one, so its apostrophe hides the wipe.
  • Opus: the fallback for an unclosed ${ re-reads the whole command at every nesting level, so nested "$( ${ takes 5.2 s at 173 characters and doubles per level.

Please pick one:

  • (a) Read every command both ways, with and without ${...} tracking, and union the targets. The untracked reading is the base behavior, so this is deny-only by construction. About 5 lines plus a nesting liveness test.
  • (b) Drop the ${...} / $[...] tracking. Keep only the keyword fixes (then, do, else, {, !, time, case) and the empty quoted substitution fix. The ${x:- #} rows stay open on security: rm argv floor misses keyword, comment and in-argument substitution spellings #18464.
  • (c) Close this PR.

🤖 AI-assisted comment 🤖

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed on head 94abebc (delta from efe5150).

The red from the previous review is fixed. Moving the substitution strip after the redirection step means a leading redirection keeps its substitution target, and all 14 redirect-to-substitution forms I tried are refused again, as on base:

Command Base efe5150 94abebc
>$(mktemp) rm -fr ~ refused allowed refused
>"$(mktemp)" rm -fr ~ refused allowed refused
2>`mktemp` rm -fr / refused allowed refused
<$(echo /dev/null) rm -fr / refused allowed refused
2>&1 >$(mktemp) rm -fr ~ refused allowed refused
if :; then >$(mktemp) rm -fr ~; fi refused allowed refused

The heredoc over-refusal is gone too: Security Scope Review is green on this head, and commit and PR body drafts with ) list items inside "$(cat <<'EOF' ... )" are allowed again.

The PR is still not deny-only, though. The delta stopped reading an unquoted $(...) as its own frame, so inside an open ${...} a # no longer starts a comment and an apostrophe in that comment quotes away the wipe:

Command (real newlines) Base efe5150 94abebc
unset x; echo ${x:-$( # don't / rm -fr ~ / # ' / )} refused refused allowed
Same with backticks in place of $( ) refused refused allowed
echo ${x:-$( # it's / rm -fr / / # ' / )} refused refused allowed

Real bash runs the inner command in both forms (I used a printf stand-in for rm; it printed the home operand). The GPT lane reports the same case.

The unclosed-span fallback added in this delta is also exponential: each nested "$( ${ ... )" level re-reads the whole command with track=False, and the nested bodies do the same. On this head is_denied takes 0.05 s at 8 levels and 1.3 s at 14 levels (153 characters), against 0.07 s on base. The Opus lane measured 5.2 s at 16 levels. That stalls the synchronous deny gate on a short, agent-written string.

One change fixes both. Read the command twice at the top of _rm_wipe_targets (once with tracking, once without), pass track through to the nested _segments calls, and drop the in-function fallback. I tried that on a scratch copy of this head:

  • it refuses all 5 comment-quote forms above;
  • 14 levels run in 0.07 s;
  • it refuses every form that base or this head refuses in my probe;
  • the 13 benign commands I tried (commit and PR body heredocs, descendant targets, keyword arguments) are unchanged;
  • the 109 TestRecursiveForceRmFloor tests pass.

Details are inline.

Verified locally on this head: a probe of 86 home and root wipe forms (redirect-to-substitution, heredocs, arithmetic, nested substitutions, assignment prefixes, env and wrapper prefixes, reserved words, ${...} and $[...] spans). Apart from the comment-quote forms above, this head refuses every form base refuses, and 73 of the 86 against 61 on base. test/test_denied_commands_security_rows.py and test/test_security_gate_liveness.py pass (189 tests). CI on this head: Backend Tests shard 7 failed and shard 8 was still running when I checked. I did not read the shard 7 log because the run had not finished.

The branch now conflicts with main: main raised _PACKAGE_LINE_BUDGET in test/test_security_gate_liveness.py to 32,527, so the budget line needs a rebase.

Overlaps on the same file: #22390, #22719 and #21900 are still open and touch the rm floor or its tests.

This touches a security path, so approval stays with a maintainer.

for step in _shell._iter_shell_chars(text, state):
if step.active and text.startswith("<<", step.offset):
dollars = dollars + 1 if step.active and step.char == "$" else 0
live_syntax = step.active and not expansion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A comment inside ${x:-$( ... )} no longer hides its quotes, so a home wipe gets through

Cause: this delta handles $(...) and backticks as their own frame only inside double quotes (step.state == 2). An unquoted $( inside an open ${...} is now walked inline, and live_syntax is false there. Mechanism: # don't is not read as a comment, so its apostrophe opens a single quote that runs over rm -fr ~ up to the ' on the next comment line. The span closes at }, so the unclosed-span fallback never runs. Consequence: base and efe5150 refuse the command below, this head allows it, and bash runs the inner rm -fr ~ (confirmed with a printf stand-in):

unset x; echo ${x:-$( # don't
rm -fr ~
# '
)}

The backtick form and rm -fr / behave the same way.

Suggestion: read the command twice at the top of _rm_wipe_targets, with and without span tracking, and pass track into the nested _segments calls:

for segment in [*_segments(command), *_segments(command, track=False)]:

The untracked reading keeps every refusal base had, so the result is deny-only by construction. Add the three forms above as deny rows.

Rule: a change to a deny floor may only add refusals.

in_redirect = False # past the operator, into the (glued) target or argv
pos, state = resume, resume_state
if expansion and track: # never closed: also read it as if no span were open
return [*out, "".join(seg), *_segments(command, track=False)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The unclosed-span re-read is exponential in nesting depth

Cause: when a ${ stays open, _segments re-reads the whole command with track=False. Both readings recurse into each $( body through _segments(body) with the default track=True, and each body that also leaves ${ open re-reads itself. Mechanism: the work doubles at every "$( ${ ... )" level. Consequence: on this head is_denied takes 0.05 s at 8 levels, 0.36 s at 12 and 1.3 s at 14 (153 characters), against 0.07 s on base at 14. The gate is synchronous, so a short string the agent writes stalls it, and no exception is raised to reach the fail-closed branch.

Suggestion: drop this fallback and do the two readings once, at the top of _rm_wipe_targets (see the other comment), with track passed through to the nested calls. On a scratch copy that took 14 levels back to 0.07 s. Add a liveness test at about 20 levels with a time bound.

Rule: a security gate must run in bounded time on any input.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants