fix(security): stop a long command from taking the gateway down, and name the job - #8282
Conversation
7ef7496 to
d7fd4e2
Compare
861d29d to
e6715bf
Compare
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS A measured, root-cause fix — linear patterns plus a hard ceiling carry the liveness guarantee, and the breaker acts only on unambiguous fenced evidence, never inference. [DESIGN-REVIEWED] a57f462 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All evidence is read: the contract, the full 3,729-line patch, the intent file, and the base tree checks (no pre-existing in-flight-run record; First-Principles-Verdict: CONCERNS Nearly every item is derived from one reported field crash; the one heavy piece is the attribution-record file, which re-persists the very markers it deletes. What this change shipsIntent: stop a long agent command from crashing the gateway hourly, and let the operator see which cron job did it — a FIX (three previously separate PRs merged).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] a57f462 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've traced the single candidate against the actual PR-head code. The candidate is a self-rated "low confidence" double-cancel race. Falsifying it:
The candidate fails (a) and (c). Nothing else in the diff grounds a new finding to the 80+ bar — this is careful defensive hardening with fail-closed reads, fenced evidence, and idempotent claims. No findings. [OPUS-REVIEWED] a57f462 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/dashboard/crash_dump_store.py:691 -- [GPT-REVIEWED] a57f462 |
e6715bf to
8953ed8
Compare
|
Addressed the review findings in
First Principles "watch" (12 label-only surfaces) left as is: each row only changes wording, and a recognised non-cron label is exactly what prevents the misattribution this PR fixes. |
|
Both GPT blocks on
Also unblocked the Fork workflow-change guard: the black-baseline prune that touched |
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. |
|
Pushed
GPT BLOCK — "auto-pause trusts agent-writable crash dumps": not fixing, with reasons. A forged dump alone can pause nothing: the breaker acts only on a cron-surface dump plus exactly one abandoned marker whose First Principles' subtraction (fold |
|
Both GPT blocks on
The forged-dump block from the previous round is no longer raised; the trust-boundary reasoning above stands as the record. |
|
Both GPT blocks on
|
|
On
"Incomplete marker evidence can pause the wrong job" — not changing, with reasons. The scenario needs two jobs in flight during one crash and the marker write for exactly one of them failing. Both markers are written by the same process into the same directory with the same |
|
Both GPT blocks on
|
|
Pushed Correction, and a retraction. The Design lane's "watch" item was right and I was wrong: CPython's
Consequences, now stated everywhere the old claim was: the gil_probe_v2.pyimport random, re, sys, threading, time, zlib
def probe(label, fn):
done = threading.Event(); out = {}
def worker():
t = time.perf_counter(); fn(); out["s"] = round(time.perf_counter() - t, 2); done.set()
th = threading.Thread(target=worker, daemon=True)
t0 = time.perf_counter(); ticks = 0 # clock starts BEFORE the worker
th.start()
while not done.is_set() and time.perf_counter() - t0 < 120:
time.sleep(0.02); ticks += 1
print(sys.version.split()[0], label, "worker", out.get("s"), "s main ticks", ticks,
f"(~{int(out.get('s', 0) / 0.02)} if released)")
data = [random.random() for _ in range(20_000_000)]
probe("sorted", lambda: sorted(data))
blob = ("x" * 1_000_000).encode()
probe("zlib x40", lambda: [zlib.compress(blob, 9) for _ in range(40)])
pat = re.compile(r"(?:cat\s.*|.*[<>|]\s*)/home/[^/\s]+/\.aws", re.I)
subject = "ls " + " ".join("/opt//" + "a" * 60 for _ in range(700))
probe("re.search", lambda: pat.search(subject))GPT block on Design's suggestion (a source guard that every |
|
GPT block on |
|
GPT block on The finding asks for an HMAC "gateway-only key" over the marker and dump files. That is the same class as the forged-dump finding on the earlier head (dropped after the fence went in), now moved to a pre-upgrade window. Three facts make it a non-issue:
Given the shipped invariants (fenced marker dir, identity-joined attribution, operator-visible and reversible outcome), a signing scheme here is over-engineering for a threat whose payoff is a capability the agent already has by design. |
|
Design Review CONCERNS — accepted-and-deferred The Watch item is correct: This is not disputed. The suggestion — keep the old compiled Deferred to: #8485
Issue #8485 carries |
|
First Principles Review CONCERNS — needs-a-decision (maintainer) The Watch item: The suggested subtraction (~150 lines: delete The observation is architecturally sound. However:
Maintainer question: should this be addressed in-PR (requires extending scope to the sweep-ordering protocol) or deferred to a follow-up? If deferred, is the current shape acceptable for merging? [operator: bolichen] |
|
Correcting my own rebuttal of the GPT block — argument 1 does not hold, and this needs a maintainer decision. I re-checked the marker-forgery finding against the tree instead of re-asserting the earlier reply, and two of the three legs I posted in the rebuttal above are wrong. Recording that rather than leaving it to stand. What is wrong with the rebuttal.
What is actually left of the finding. The window is narrow and post-upgrade is closed, which is the part of the rebuttal that does hold:
Why I am not just pushing a fix. There are two credible remedies and choosing between them is a design call on the security surface, not a cleanup:
I do not think I should pick one unilaterally on a security-labelled PR, so I am leaving the GPT lane red and flagging it. Happy to implement either promptly once a maintainer says which. For the record on the rest of the PR: rebased onto |
|
GPT blocks on 1. Reproduced before fixing, on a temp
Two notes on attribution and on my own comment. The truncation predates this PR — The fix is GPT's: read one character past the cap ( Tests: 2. The mechanism is as described: What I checked, and where I end up differing on severity:
GPT's remedy — process every unclaimed dump by process identity, with claims retained per dump — is a redesign of the breaker's central mechanism: multi-dump attribution plus a per-dump claim file, where the claim is currently one dump name. On a change this size, at the end of a rebase round, I do not think I should swap that in unilaterally against the documented choice already in the spec ("a marker no dump explains is swept"). The alternative small fixes I considered all collapse back into needing multi-dump knowledge, so there is no cheap version of this. So I am recording it as a named residual with the bound above rather than either silently carrying it or rebuilding the claim file. Happy to implement the per-dump claim promptly if you would rather have it in this PR than as a follow-up. Where the rest of CI stands. On |
…name the job An hourly cron whose agent emitted a ~9 KB bash command full of https:// URLs took a user's gateway down every hour: is_sensitive_bash_command ran for 25+ seconds on the event loop and the loop-stall watchdog hard-exited the process. The killed run left no trace in the cron store, so the job was due again on the next boot and re-ran the crash, and `kirocrew doctor` could show the stack but not the job. Three changes, each with its own bound, all needed: 1. The gate is linear and bounded (security.py). Three constructs in the pattern tier were quadratic on their own and their costs multiply, so each was measured alone (pattern tier, 10 KB / 40 KB, before -> after): the redirect alternative `.*[<>|]\s*<path>` (0.30 s / 4.8 s -> 5 / 19 ms, dropped to `[<>|]\s*<path>`, redundant under re.search); the UNC anchor followed by the generalized separator (0.06 / 0.8 s on one UNC token -> 11 / 43 ms, it takes a plain separator, same language because the UNC run already absorbs every character a no-op chain contains); the verb-anchored branch `verb.*<path>` (12 / 130 ms verb-dense -> 4 / 18 ms, moved out of the regex into a per-line "earliest verb end + path search from there" walk, two linear searches, same language). Whole gate at the crash size on every adversarial shape: 20-80 ms against 15-36 s on the shipped build. MAX_SCANNABLE_COMMAND_CHARS (20 KiB) is a hard ceiling: longer is refused with a reason, never scanned partially or let through; llm_helpers' tool_input ceiling aliases it. Zero verdict change on a 381 474-command differential corpus against origin/main and the tree before kirodotdev#7941. 2. The title tier scans off the loop (llm_helpers.py). kirodotdev#7941 offloaded the tool_input scan but the title checks -- for a shell tool the title IS the command -- still ran inline, and that was the crash frame. Title and tool_input now share one asyncio.to_thread hop, title first, keeping every reason string and mechanism label. Measured: CPython's re HOLDS the GIL for one whole match call (a 5-8 s worker search leaves the main thread a single tick on 3.10 and 3.12), so the hop does not keep the loop live inside one scan; the linear patterns and the size ceiling do, and the hop buys the realpath I/O in is_sensitive_path plus a yield between tool_input strings. hooks.on_tool_call still runs inline and relies on the gate's own bound. 3. A run leaves an in-flight marker, and the breaker names the job (cron.py, cron_inflight.py, stall_attribution.py, cli_doctor.py). A run writes <data home>/cron-running/<job id>.json when it starts executing and clears it on every finally path; a marker whose PID is dead is exactly "in flight when that gateway died". stall_attribution names the surface from the outermost recognised frame of the wedged thread and joins abandoned markers to the dump by PID -- one match names the job, several name candidates, none says so, never a guess. CronService.start() runs the breaker before the timer arms: a cron-surface dump plus exactly one matching marker parks that job auto_paused (last_error names the dump and the resume command), persisted under the store lock, SEL-audited, claimed once per dump so a resumed job is not re-paused. `kirocrew doctor` prints the attribution and `recommended: kirocrew cron pause <id>` with no gateway running; the boot notification carries the same lines. Tests: verdict pins for every rewritten construct and its negatives, source guards, the ceiling, both trigger paths at the crash size and doubling-ratio linearity per construct (seven mutants each turn a test red); title-tier reasons/mechanisms and same-hop thread identity; the breaker on an OVERDUE strict job (with the breaker removed the job fires on the first tick, which is the crash loop), markers present during a run and gone after, attribution across single/multiple/no marker, PID mismatch, live owner, chat/slack/unknown surfaces. test_cron.py::test_cron_schedule fails on untouched origin/main (timezone-dependent) and is unrelated.
Problem / Motivation
An hourly cron whose agent emitted a ~9 KB bash command full of
https://URLs took a user's gateway down every hour. The crash dump's main thread sat insecurity.pyinsideis_sensitive_bash_commandfor the full 25 s watchdog budget (dashboard.loop_stall_exit_after_secs=25), the loop-stall watchdog hard-exited the process, and the run in flight left no trace in the cron store — so the job was due again on the next boot and re-ran the crash.kirocrew doctorshowed the stack but could not name the job, so the user paused the wrong one (ascriptcron that never touches the model). The user's own benchmark of the installed gate: 1.2 KB 0.6 s, 2.3 KB 2.3 s, 4.6 KB 9.1 s, 9.1 KB 35.8 s.Why it matters
Every surface that streams a permission request (cron, Slack, dashboard side panel, workflows) and every caller of
hooks.on_tool_callruns this gate inline on the event loop, so one long command from the model kills the gateway and every in-flight turn with it; for a cron it does so again on the next boot. #7941 removed the eleven.*token anchors and cut the constant ~20x, but the growth was still quadratic on every shape measured (10 KB 0.3 s, 40 KB 5 s, pattern tier alone), the title tier still ran inline, and nothing on disk said which job was running.Supersedes #8277, #8278 and #8279 (the same three changes as one PR; each was independently mergeable and CI-clean on its own).
What changed (motivation → approach → change)
1. The gate is linear and bounded (
security.py)Three constructs were each quadratic on their own and their costs multiply, so each was measured and fixed separately (harness in a subprocess under
timeout; pattern tier alone, 10 KB / 40 KB, before → after):\\\\[^\s'"]++ generalized separator\X\..hopsverb.*<path>,interp … open( … <path>.never crosses a newline, so per line it decomposes into "earliest verb end" + "path search from there" (_verb_anchored_sensitive_hit, two linear searches, same language)._sensitive_pattern_hitruns both halves and is the only entry to the pattern tierFixing the first alone leaves the UNC shape quadratic (0.8 s at 40 KB); fixing the second alone changes nothing visible because the first dominates; fixing both leaves the verb term (×3.5 per doubling). All three are needed.
Whole gate at the crash size (10–12 KB) on every adversarial shape tried — doubled-separator paths, URL-dense JSON, UNC chains, verb-dense lines,
~-runs, 24 000-backslash runs: 20–80 ms, against 15–36 s on the shipped build. With the ceiling lifted for measurement, 20 → 40 → 80 KB scales ×2 per doubling on every shape.MAX_SCANNABLE_COMMAND_CHARS(20 KiB) is a hard ceiling in the gate itself: a longer command is refused with a reason, never scanned partially and never let through unscanned.llm_helpers._MAX_SCANNABLE_TOOL_INPUT_CHARSalready refused at that size and now aliases the same constant. Two later passes (_ENV_CRED_PATTERNS, the normalizer) remain O(k·n) in verb tokens and are bounded by the ceiling (≤ 60 ms at 20 KB); they were not on the crash path.Zero verdict change. A 381 474-command generated differential corpus (verbs × preceding characters × paths × terminators, script-open shapes, multi-line, UNC and
%VAR%spellings, plus every golden from the existing tests) produced no difference againstorigin/mainand against the tree before #7941.2. The title tier scans off the loop (
llm_helpers.py)#7941 moved the tool_input scan onto a worker but the title checks —
is_sensitive_path,is_sensitive_bash_command,is_deniedonevent.title; for a shell tool the title is the command — still ran inline, and that is the frame in the crash dump. Title and tool_input now go through oneasyncio.to_threadhop, title first, keeping every reason string and mechanism label (always_deny/always_deny_input). The empty-title refusal stays on the loop.CPython's
reholds the GIL for the whole of one match call — confirmed with a tick-counting probe whose clock starts before the worker does (a 5–8 sre.searchon a worker leaves the main thread a single tick on 3.10 and 3.12, the same shape assorted()on a large list, whilezlib.compress, which does release, leaves it ticking). An earlier revision of this description claimed the opposite from a probe that started its clock after the worker already held the GIL; that claim is withdrawn and the comments and spec now state the measured behaviour. So the hop does not keep the loop live inside one scan — the linear patterns and the 20 KiB ceiling are the liveness guarantee — and what it buys is the realpath I/O insideis_sensitive_path(which releases the GIL) plus a yield between tool_input strings.hooks.on_tool_call(HOOK_BASED policy and the channel dispatchers that call it synchronously) still runs inline and relies on the gate's own bound, stated in the spec.3. A run leaves an in-flight marker, and the breaker names the job (
cron.py,cron_inflight.py,stall_attribution.py,cli_doctor.py,dashboard/server.py)cron_inflight— a run writes<data home>/cron-running/<job id>.json(job_id,name,started_at,pid) when it starts executing and clears it on everyfinallypath. A marker whose PID is dead is exactly "this job was in flight when that gateway died": no schedule inference. Writes are off-loop and best-effort; reads are size-bounded; job ids that would leave the directory are refused.stall_attribution— reads the newest stack-bearing dump, names the surface from the outermost recognised frame of the wedged thread (a cron turn passes throughslack/gateway.py, so a top-down match would call it Slack), names the permission-gate frame, and joins abandoned markers to the dump by the writing process's identity — PID plus the PID domain and start id the dump header already records (RunningMarker.same_process), so a replacement container's PID 1 is not the one that died and a recycled PID reads as dead. One match names the job; several name candidates and no job; none says so; a chat/channel surface is named and implicates no job. It never guesses.crash_dump_storegainsdump_owner_identity,current_process_identity,pid_identity_aliveanddump_wedged_frames.CronService.start()— runs on a worker before_arm_timer(). Only when the surface is cron and exactly one abandoned marker carries the dump's PID does it park that jobauto_paused(enabled=False,last_status="error", alast_errornaming the dump andkirocrew cron resume <id>), persisted under the store lock and SEL-audited ascron_auto_pause/auto_paused_loop_stall. The dump name is claimed incron-running/.loop-stall-breakerso one crash pauses its job once. Abandoned markers are swept after being read.kirocrew doctor— prints anattribution:block under the dump's stack: stuck-in frame, surface, job,recommended: kirocrew cron pause <id>, and the job's current pause state fromcrons.json(no gateway needed). The boot notification carries the same lines.Sample doctor output on a synthesized data home shaped like the field host:
Trust boundary: the markers are fenced evidence. The breaker's pause RESTS on a marker, so
cron-runningis onsecurity._CREW_SECRET_LEAVESbesidecrons.jsonandcron-history(and masked by the OS sandbox alongsidecron-history): a marker the agent could write would be an unauthorized "pause this job" that routes around the MCP cron tools, and one it could delete would disable the breaker for a crash loop about to recur. Under that fencecron_inflightstill treats its own directory as hostile, for a leaf planted before the fence existed — reads openO_NOFOLLOWand take only single-linked regular files under_MARKER_MAX_BYTES; writes go throughatomic_write(restrict_to_owner=True), whosemkstempname cannot be pre-planted and whose linked-parent refusal is what stops a redirected marker write from landing on a keystone file. Nothing in the sandbox reads a marker: they are written by the run task in the gateway process and the breaker runs inCronService.start(), which only the gateway calls.The verdict outlives the evidence, and evidence is consumed only once the verdict survives a restart. Before the markers are swept the breaker writes what they said to
cron-running/.loop-stall-attribution(dump name plus the candidate and unrelated markers, no.jsonsuffix soread_markerscannot mistake it for a marker) andattribute_dumpmerges it back for that dump — sokirocrew doctorand the restart notification, which both run after an automatic restart has already swept, still name the candidates the breaker declined to choose between. And a pause the store refused to persist is NOT claimed and its evidence is NOT swept, so the boot whose store is readable again reaches the same verdict instead of skipping a job that is still enabled and still due. No failure inside the breaker can failstart(): it is a safety net, and losing the scheduler would be worse than losing the net.Specs:
docs/system-specs/modules/security.md,learn-cron-dashboard.md,cli.md.Tests
test/test_security_gate_liveness.py— verdict pins for the cases where each rewritten construct was the only matching branch, plus negatives (path before the verb, verb on another line,open(before the interpreter); the pattern tier is the union of both halves; source guards; ceiling refused-above / scanned-at / tiers agree; both trigger paths at the crash size (Pass 1b double-separator 10 KB, URL payload 12 KB) and doubling-ratio linearity for the backslash run (Pass 1) and one shape per construct. Seven mutants each turn a test red (drop the ceiling; drop the verb half; accept a path before the verb;.*back on the redirect; generalized separator back on UNC; no line split;open(before the interpreter); the two spelling mutants are also caught by the timing pins alone. The per-construct linearity pin takes the BEST of three samples (a contended runner can only make a scan look slower) and its absolute backstop is 12 s, not 3 s: 80–100 KB is aboveMAX_SCANNABLE_COMMAND_CHARSso the gate refuses a command that long and nothing waits for that scan — the cost the loop actually pays is pinned at the crash size, under the ceiling, by the two tests beside it. A four-core runner with three sibling xdist workers read 4.0 s (Linux) and 5.2 s (Windows) for ~0.3 s of dev-box work, so 3 s false-red there; a reintroduced quadratic term still costs minutes at that size, so the ratio and the backstop both catch it.test/test_llm_helpers_tool_input_offload.py::TestTitleTierOffLoop— title bash/path/regex denials keep reason andalways_deny; title decides before tool_input; title predicates run on a non-loop thread in the same hop as the tool_input predicates; the empty-title refusal never enters the scan.test/test_cron_loop_stall_breaker.py— the breaker test seeds an overdue stricteveryjob and asserts it did not fire afterstart(); with the breaker call removed the job fires on the first tick (['<id>'] == []), which is the crash loop. A resumed job is not re-paused on a second boot; chat-surface and two-candidate dumps pause nothing; the pause is SEL-audited once; markers are present during a run and gone after normal and raising completion.test/test_cron_loop_stall_breaker.py::TestMarkerIoRefusesWhatItDidNotWrite— a symlinked marker and a symlinked claim are not followed; a FIFO marker is refused instead of blocking the workerstart()awaits (asserted underwait_for, so a regression times out rather than hanging the shard); a redirecting parent link leaves the target directory empty; an oversized marker is refused. Plus: an unpersisted pause writes no claim, keeps its evidence, and is retried and paused on the next boot; the non-cron and two-candidate cases sweep their markers but the recorded verdict still names the candidates to a laterattribute_dump; a marker no dump explains is swept; a breaker that raises leaves the scheduler running; the rule table's module and function names are pinned against the package, so a rename cannot silently degradeclassify_surfaceto "unknown".test_security.py::TestCronStoreProtectionpins the new fence in both home prefixes, on the read gate, the write gate and the shell forms (forge, read andrm), andtest_sandbox_governance_mask.py's reconciliation ratchet pins the OS mask.test/test_stall_attribution.py+test/stall_dump_helpers.py— marker round trip and validation; surface classification (cron through the Slack module, dashboard chat, Slack, unknown, Windows paths); attribution for single/multiple/no marker, PID mismatch, marker newer than the dump, live owner;describe()lines; newest-dump selection past a header-only file.test_security_regex_linearity.py::test_long_nonshell_line_does_not_blow_upresized under the ceiling (it fed 22.5 KB and asserted "allowed"; that is now refused by design).test_cron.py::TestComputeNextRunTs::test_cron_schedulefails on untouchedorigin/main(timezone-dependent) and is unrelated.Manual verification
Reproduced the field curve on the shipped build (10.7 KB double-separator command: 14.8 s). GIL probe (tick-counting, clock started before the worker) on 3.10.20 / 3.12.13. Ran
kirocrew doctoragainst a temp data home with acrons.json, acron-runningmarker and a dump in the store's format (output above).Related Issues
Supersedes #8277, #8278, #8279. Follows #7941 (anchor rewrite + tool_input offload) and #8022 (bounded realpath in the same gate).
Pattern harvest
Rule candidate: review-prompt
Pattern: a leading
.*(or a greedy run followed by a starred group) inside an alternation evaluated withre.searchon agent-supplied text — redundant for existence, quadratic in the subject; measure each.*-bearing branch alone, because their costs multiply and fixing one hides the rest. And: an offload that moves a loop over N inputs to a worker while the same predicates still run inline on the request's primary field.Checklist