Skip to content

fix(gateway): stop three on-loop stalls, keep crash logs, scrub PTY env - #7941

Merged
buluoray merged 1 commit into
mainfrom
fix/gateway-loop-liveness-and-stall-diagnostics
Sep 3, 2026
Merged

fix(gateway): stop three on-loop stalls, keep crash logs, scrub PTY env#7941
buluoray merged 1 commit into
mainfrom
fix/gateway-loop-liveness-and-stall-diagnostics

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

The 25s loop watchdog hard-exits the gateway, so any synchronous work on the asyncio event loop is a liveness bug: the process dies and in-flight work is lost. Four such sites were still live on main, plus a credential leak that was hiding inside one of them.

Observed symptoms from the reports behind this:

  • The gateway repeatedly stalled and restarted, six times on one host, always inside a cron turn — killing the active cron and losing its work. Every retained dump had a byte-identical main-thread stack through the permission gate's regex scan.
  • A user's Python 3.13 venv silently imported Kiro Crew's 3.12 site-packages from an interactive terminal, and its C extensions failed to load (libpython3.12.so.1.0: cannot open shared object file).

Why it matters

A watchdog exit is not a degraded mode — it drops the turn, and cron status can still read healthy because the process dies before its failure bookkeeping runs.

One fix here also closes a credential leak: the old redaction shape could emit a live credential in plaintext while redacting an innocent bystander (details below).

What changed (motivation → approach → change)

Five changes in two groups. Each is independently revertable.

Event-loop liveness

1. redact_credentials pass 1 was O(n²)finditer plus result.replace(matched, tag, 1) rebuilt the entire string per match. Now a single sub() pass.

While converting it: replace() targets the first occurrence of the matched text, not the span the regex matched. When an earlier non-matching lookalike contains that text as a substring — xM<jwt> before a boundary-anchored M<jwt> — the old code redacted the innocent lookalike and emitted the real credential in plaintext. sub() splices the matched span, so the credential is redacted. This was in no report; it fell out of the differential corpus, and it is the most important change here.

Passes 2 and 3 are untouched: they scan the original text and select spans by value, and a comment in the function deliberately forbids fusing them. Their latent equivalent is tracked separately (see Pattern harvest).

2. The sensitive-path regex had 11 branches anchored (?:^|.*[\s'"=:,;]). Under .search, which already retries at every offset, the leading .* matched nothing extra while making the scan quadratic in the longest line — ~27s on a 20 KB newline-free line, well past the watchdog. Rewritten to (?:^|[\s'"=:,;]); the same input now takes ~1.8s. \n is in the character class, so a path at the start of a later line still matches, and . never crossed a newline anyway.

3. _resolve_permission ran that regex inline on the loop for every string in the tool input. Its extractor recurses over the whole parsed payload, so document bodies were scanned as shell commands. The scan loop is now one asyncio.to_thread hop, preserving check order, short-circuit on first denial, and the returned reason exactly.

Precision on what this does and does not buy, since it is easy to overread: CPython's re holds the GIL for a whole match call, so the hop yields between per-string scans, not within one. It removes the observed killer (the quadratic behaviour, which made 20 KB enough) and moves the threshold out by more than an order of magnitude, to roughly a single 300 KB string. It does not make the scan unconditionally safe at any size. Design Review raised this on the pushed head; the residual is tracked with a chunked-scan approach in #8053.

Deliberately not done: scoping the scan to shell/path fields. That narrows a deny surface and is a security-policy decision, not a liveness fix; it is also unnecessary for liveness once the scan is off-loop.

4. api_models called _resolve_ssh_auth_sock inline, which globs /tmp/ssh-*/agent.* and stats every hit — two lines below an existing executor hop, and against its own sibling wrapper's docstring ("must never run on the event loop — call this via asyncio.to_thread"). Wrapped to match the idiom already used in that function.

Environment hygiene

5. The terminal PTY handed children the raw parent environment, so any PYTHONPATH/PYTHONHOME the gateway held reached interactive shells — searched before a venv's own site-packages, which is the 3.13-venv failure above. Stripped via the shared sandbox._PYTHON_ENV_PREFIXES, aligning the terminal with the ACP spawn policy. No default is flipped and no other caller changes.

Note on scope: neither scrub_agent_subprocess_env nor wrap_argv's scrub was reused, because both also strip SSH_AUTH_SOCK / AWS_* / GNUPGHOME / GIT_ASKPASS. This is the user's own explicitly unsandboxed shell, so removing those would break git over SSH and the AWS CLI in it. Only the Python vars are stripped, using the same prefix-loop pattern mcp_gateway/gatewayd.py already uses. sandbox.py is untouched.

Removed from this PR after review

An earlier revision also replaced the single gateway.log.prev slot with an N-deep .prev.1..N ring, to keep crash-moment logs across a watchdog restart loop. That is reverted here. GPT flagged it against the blocking: true AUTOSDE rule no-new-work-on-gateway-boot-path (rotation runs before the dashboard socket accepts), and Design Review and First Principles independently found that the rename orphaned its one consumer: diagnostics.py:506 collects the literal name gateway.log.prev, so support bundles would have shipped without the prior-boot log — losing exactly the evidence the change existed to keep. Reverting restores that name, removes the legacy-fold branch and the unused depth parameter (both First Principles subtractions), and drops the boot-path cost to zero. Multi-incident log retention will land separately, done off the boot path.

Tests

2063 passed, 2 skipped across the touched suites and their pre-existing neighbours; flake8, isort, mypy and the baselined black gate all clean.

  • Regex differential (the review-critical one): positives, negatives, every separator-boundary case the character class exists for (space, quote, =, :, ,, ;, string start), and a mid-token case that must not match — proving no verdict became more permissive. Backed at scale by test_security.py + test_trust_reads.py.
  • Credential leak regression — asserts the matched span is redacted and an earlier lookalike is not, so the replace() shape cannot return.
  • Redaction differential — byte-identical output and identical warning content and order.
  • Complexity guards on both quadratic paths, with generous ceilings (6s against ~1.8s actual) so they catch a regression without benchmarking CI.
  • Off-loop probes for the permission scan and _resolve_ssh_auth_sock, asserting the work lands on a non-loop thread; plus a liveness test that a 20 KB non-shell body does not stop a concurrent task from ticking.
  • PTY envPYTHONPATH/PYTHONHOME absent from the child env while KIROCREW_TERMINAL/TERM and the credential vars survive, on both the POSIX and ConPTY branches.

Every new test was mutation-verified (revert the change → test fails; restore → passes).

One test edit worth calling out rather than burying: the credential reference oracle in test_credential_prefilter.py was a verbatim copy of the old body, so it reproduced the leak. Its pass 1 is corrected, with the divergence documented in its docstring and the leak captured as its own test.

Manual verification

N/A — unit coverage sufficient. Every change is a synchronous-call-site or environment-construction change fully observable from tests; the off-loop and liveness assertions reproduce the watchdog condition directly rather than needing a wedged gateway.

Screenshots / video

Why no screenshot: backend-only change; no component, layout, theme, or user-visible string is touched (the sole frontend-adjacent file is the terminal PTY's child-environment construction, which renders nothing).

Related Issues

no linked issue: reported through internal triage (Mesh-3693, Mesh-3654, Mesh-3639, Mesh-3656), which has no public GitHub issue to close.

Pattern harvest

Rule candidate: semgrep
Pattern: regex match redacted by value instead of by span — for m in RE.finditer(s) followed by s.replace(m.group(), repl, 1), which rewrites the first textual occurrence rather than the matched span and can leave the real match in place. Two live siblings remain in security.py (passes 2 and 3 of redact_credentials), which is why this is worth a rule rather than a one-off fix.

Rule candidate: semgrep
Pattern: redundant leading .* in a pattern only ever used with re.search — matches nothing extra and makes the scan quadratic in the longest line.

The third defect class here (a blocking call inside an async def) already has a build gate: test/test_no_blocking_call_on_loop.py.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|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

@iamwhatever
iamwhatever requested a review from a team as a code owner September 2, 2026 17:28
@iamwhatever
iamwhatever requested a review from pepmach September 2, 2026 17:28
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

A new hard 20 KiB fail-closed denial on every permission-gated tool_input string is the diff's biggest behavior change — and the PR description never states it.

Watch

  • Undocumented functional ceiling. The description claims the offload "preserv[es] check order, short-circuit on first denial, and the returned reason exactly" and defers the large-string residual to tool_input scan: stop running the shell-command matcher over non-command fields #8053 — but the diff adds _MAX_SCANNABLE_TOOL_INPUT_CHARS = 20 * 1024 that hard-rejects the call ("refused rather than left unscanned"). The code's own comment concedes "a permission-gated write of a benign file larger than this … is now refused." Fail-closed is the right direction for a deny surface, but 20 KiB is smaller than many ordinary source files, is sized to the slowest CI runner (order-of-magnitude margin) rather than the 25s watchdog on real hosts, and converts a rare liveness crash into a routine hard denial for gated write workflows. Say it in the description, and justify the constant against production hosts, not CI.
  • Half the leak class remains live. Pass 1's replace-by-value leak is fixed, but the PR's own harvest names "two live siblings … (passes 2 and 3 of redact_credentials)" with only a semgrep-rule aspiration. A known credential-leak-shaped defect deferred without a tracking issue deserves one before merge.
  • Stale title. "keep crash logs" claims a change the description says was reverted; retitle so history matches the diff.

[DESIGN-REVIEWED] 2ae0c20

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552 — this comment is updated in place on each push.

Review details

The candidate — oversize tool_input denied even for approved calls — is a deliberate, documented, fail-closed tradeoff (_MAX_SCANNABLE_TOOL_INPUT_CHARS, tracked as an interim in #8053) pinned by tests as intended behavior; the discovery pass itself scored it "low." It is not a defect in the changed lines. Dropped.

Verifying the diff's load-bearing claims:

  • _PYTHON_ENV_PREFIXES is exactly [PYTHONPATH, PYTHONHOME, PYTHONPYCACHEPREFIX]; _pty_child_env's startswith strip matches only those and mirrors the agent surface — no over-stripping.
  • _resolve_ssh_auth_sock mutates env in place, and the call is awaited before env is used — offloading to a thread is safe.
  • The regex anchor rewrite ((?:^|.*[\s'"=:,;])(?:^|[\s'"=:,;])) is behavior-preserving under .search, which already retries at every offset; \n remains in the class so later-line paths still match. Differential tests pin verdicts in both directions.
  • redact_credentials sub() replaces the matched span rather than the first textual occurrence — an improvement, with byte-identical differential tests.

Nothing survives falsification, and no new grounded defect emerged.

No findings.

[OPUS-REVIEWED] 2ae0c20

Verdict parsed from the review's SHA-scoped output markers for commit 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552 — 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/CONCERNS are advisory.

I have everything needed. All items verified: the reuse constraint on scrub_env is real (it unconditionally strips credential prefixes, so it couldn't serve the terminal), the four on-loop fixes match the described sites, and I found two things worth reporting — the fail-closed 20 KiB cap ships undeclared (the description describes the pre-cap state), and the title-tier scan two screens above the fixed loop is a counted unfixed sibling.

First-Principles-Verdict: CONCERNS

A hard 20 KiB deny-cap on tool_input ships undeclared — the description still describes the uncapped build and defers that residual to #8053.

What this change ships

Intent: stop the gateway watchdog from killing live turns during on-loop security scans — a FIX (plus one leak fix found en route).

  1. Credential redaction now redacts the matched credential, not an earlier lookalike — justified (leak fix)
  2. Credential redaction is linear on credential-dense text — justified
  3. Sensitive-path regex ~15× faster on long lines, verdicts unchanged — justified
  4. tool_input permission scan runs off the event loop — justified
  5. Any tool_input string over 20 KiB is now denied outright — undeclared
  6. Model-list endpoint no longer stalls on /tmp ssh-agent globbing — justified
  7. Terminal shells no longer inherit gateway PYTHON* vars — justified (no reusable mechanism: scrub_env always strips credentials too)

Watch

  • Item 5 contradicts the description. "the residual is tracked with a chunked-scan approach in …/8053" and "moves the threshold out … to roughly a single 300 KB string" describe the diff without _MAX_SCANNABLE_TOOL_INPUT_CHARS; the diff ships a fail-closed 20 KiB denial whose own comment admits "a permission-gated write of a benign file larger than this … is now refused." The cap is well-derived (GIL + 25s watchdog, fail-closed on a deny surface) — the risk is that a human approves this PR believing large benign writes still work.
  • One counted unfixed sibling of the same root cause, in the same function: the title-tier is_sensitive_path/is_sensitive_bash_command(event.title) at src/kiro_crew/llm_helpers.py:2063/2070 still runs on the loop, uncapped (grepped is_sensitive_bash_command( under src/, 7 non-test callers; this is the one on the watchdogged loop). A model-emitted 300 KB single-line command title reproduces the stall the PR fixes. The new test file names this gap in a comment; the description does not.
  • Stale title: "stop three on-loop stalls, keep crash logs" — the diff fixes four and ships no log change (the ring was reverted, declared in the body).

[FIRST-PRINCIPLES-REVIEWED] 2ae0c20

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 2ae0c20

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 2ae0c20a7a7109f3ef9dcd9e2b7f58f1af5ba552: <one-sentence reason>

@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 Sep 2, 2026
@iamwhatever
iamwhatever force-pushed the fix/gateway-loop-liveness-and-stall-diagnostics branch from a3052a9 to ecc972b Compare September 3, 2026 01:24
@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 Sep 3, 2026
@iamwhatever

iamwhatever commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=6f272d5efd83 BLOCKING — src/kiro_crew/cli.py:1036 — Gateway boot performs expanded filesystem rotation before readiness — disposition: fixed in ecc972b58 (took the first of your two options).

Gateway launch -> _setup_cli_logging before asyncio.run() -> dashboard import plus ring filesystem operations -> socket readiness is delayed.
Anchor: no-new-work-on-gateway-boot-path
Fix: revert the ring expansion or defer it until after readiness.

Legitimate, and the anchor is blocking: true with src/kiro_crew/cli.py in its file-patterns, so it is in scope by the repo's own rule rather than by judgement. Reverted the ring expansion entirelycli.py and test/test_cli_logging.py are now byte-identical to origin/main, and test/test_cli_gateway_log_rotation.py is deleted. Boot-path cost returns to exactly the pre-PR single log_file.replace(prev_log): net new work on the boot path is now zero, not merely bounded.

Reverting rather than deferring was the better of your two options here, because the ring had a second, independent defect: it orphaned its only consumer (diagnostics.py:506), which is your own separate FINDING on cli.py:800 and was raised independently by Design Review and First Principles. Deferring the ring past readiness would have kept that consumer broken; reverting fixes both at once and additionally lands the two subtractions First Principles asked for (the unused depth parameter and the legacy-fold branch both go away with it).

Multi-incident log retention — the motivating ask — is now tracked to land on its own, off the boot path, so it cannot regress this anchor.

Diff is 12 files -> 9. The four liveness fixes and the credential-leak fix in this PR are untouched by the revert.

@iamwhatever

iamwhatever commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=d8527ee72ab5 FINDING — src/kiro_crew/cli.py:800"gateway.log.prev.1" no longer matches collect_bundle()'s gateway.log.prev — disposition: fixed in ecc972b58.

"gateway.log.prev.1" no longer matches collect_bundle()'s gateway.log.prev, so post-restart diagnostic bundles omit the previous boot log -> Fix: retain slot 1 as gateway.log.prev and number only older generations.

Correct, and verified: src/kiro_crew/diagnostics.py:506 collects the literal name —

text_sources.append(("gateway.log.prev", home / "gateway.log.prev", True))

— so on a fresh install the ring never wrote that name at all, and on an upgraded one the legacy-fold step deleted it. Support bundles would have carried no prior-boot log: strictly worse than before, at exactly the point the evidence reaches a human, and self-defeating for a change whose stated purpose was keeping that evidence.

Fixed by reverting the rotation change rather than by your suggested narrowing (retain slot 1 as gateway.log.prev, number only older generations). Your fix is sound and I would have taken it, but the sibling blocking finding on this same code (the no-new-work-on-gateway-boot-path anchor) is not addressed by renaming slots — a ring shift is still N filesystem operations before readiness however the slots are named. Reverting is the one change that closes both, and it restores the consumer's name without diagnostics.py needing to change at all.

Worth recording for whoever lands retention later: the .prev name is load-bearing and has exactly one consumer, diagnostics.py:506 (grepped \.prev across src/). Any future scheme must either keep the newest generation at that exact name or update that call site in the same change.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • Design Review CONCERNS, watch 1 — the .prev -> .prev.1 rename misses its one consumer (diagnostics.py:506) — disposition: fixed in ecc972b58.

You identified the sharpest problem in the revision, and framed it correctly: the rename undid "Keeping the evidence" at the exact point evidence reaches a human. Confirmed by reading the call site — diagnostics.py:506 collects the literal home / "gateway.log.prev", a name a fresh install would never have written under the ring and which the legacy-fold step deleted on upgraded ones.

Rather than adding the .prev.N ring to text_sources as you suggested, I reverted the rotation change altogether. Two reasons that option was better than the in-place fix:

  1. GPT raised a blocking: true AUTOSDE finding on the same code (no-new-work-on-gateway-boot-path) — the ring performs N filesystem operations before the dashboard socket accepts. Extending text_sources fixes the consumer but leaves the boot-path cost, so it would have closed one finding and not the other.
  2. Reverting also lands both of First Principles' subtractions for free (the unused depth parameter, the legacy-fold branch), which is a strictly smaller surface than the ring plus a widened diagnostics collector.

cli.py and test/test_cli_logging.py are now byte-identical to origin/main; diagnostics.py needs no change because the name it reads is back. Multi-incident retention is tracked to land separately, off the boot path, and the note that .prev has exactly one consumer is recorded on the GPT disposition so the next attempt cannot repeat this.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Agreed on both halves: the class is genuinely half-closed, and a code comment is not tracking. Issue #8049 carries the deferred-finding and security labels, an assignee, and Due: 2026-10-03.

Your point prompted me to check reachability rather than assume the siblings were benign, and they are not. Both remaining passes iterate b64_chunks, the maximal base64 runs of the original text. A later chunk C can occur earlier as a substring of a longer run R:

  • R decodable -> pass 2 redacts R first, so R is gone from result and replace(C, …) lands on standalone C. Safe.
  • R not decodable -> pass 2 skips it, R survives in result, and when decodable C is processed replace(C, tag, 1) lands on C's occurrence inside R — mangling R and leaving the real credential in the output.

Pass 3's if run not in result guard tests presence, not position, so it does not close this.

Why it is deferred rather than fixed here: the offsets from _B64_CHUNK_RE.finditer(text) index text, but each pass mutates result, so span offsets stop being valid once pass 1 substitutes spans of a different length. A correct fix collects all three passes' spans against the immutable text and performs one ordered splice — which changes the warnings ordering contract (the passes are deliberately kept separate and ordered) and the meaning of pass 3's guard. That is a rework of the redaction pipeline wanting its own differential corpus, materially riskier than this PR's one-line-per-site changes. #8049 records the mechanism, the approach, and acceptance criteria including a test for the substring-hosting shape above.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • First Principles CONCERNS, watch 1 — the log rename ships with its one real consumer unfixed (diagnostics.py:506) — disposition: fixed in ecc972b58.

Your count was right and so was the conclusion: one consumer, grepped across src/, reading the literal gateway.log.prev — a name fresh installs never write under the ring and the fold deletes on upgraded ones. Post-merge, support bundles would have carried no prior-boot log at all, which as you put it is worse than before for the diagnosing human.

Fixed by reverting the rotation change entirely rather than by widening the diagnostics collector, because GPT independently raised a blocking: true AUTOSDE finding on the same code (no-new-work-on-gateway-boot-path — the ring runs N filesystem operations before the socket accepts). Reverting is the single change that closes the consumer break and the boot-path rule together, and it happens to implement both of your subtractions as a side effect. cli.py and test/test_cli_logging.py are byte-identical to origin/main again.

Your framing of item 5 as "justified, but renames surface with 1 unfixed consumer" is the accurate reading: the retention goal was sound, the shipped mechanism was not. Retention is now tracked to land on its own, off the boot path, keeping the newest generation at the exact name diagnostics.py:506 reads.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • First Principles Subtractions — drop the depth: int | None parameter and its clamp validation; shrink the legacy-fold branch to nothing — disposition: fixed in ecc972b58.

Both accepted, and both are now gone, because reverting the rotation change removes the code they targeted.

On the first: you were right that depth had zero production consumers — the sole caller passed nothing, so the parameter plus its int(depth) coercion and clamp existed only to let tests vary it, which patching _gateway_log_rotation_depth already allowed. That is machinery guarding a caller that did not exist, and your item 8 ("zero production consumers") named it exactly.

On the second: your reasoning is the sharper of the two and generalises past this PR. An unnumbered .prev left in place stays readable forever and stays visible to diagnostics.py:506; folding it bought one pruned file while deleting the diagnostics bundle's only surviving source. So the fold was not merely unnecessary, it was the step that made the consumer break unrecoverable on upgraded installs — a subtraction that fixes a defect rather than just trimming surface.

Recorded for the follow-up that lands retention properly: do not reintroduce a fold, and keep the newest generation at the literal name gateway.log.prev.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Your sibling count is right (two: the pass-2 and pass-3 replace sites in redact_credentials) and "half-closed" is the honest description.

Prompted by this and Design Review's matching note, I checked whether the siblings are actually reachable instead of assuming the boundary assertions made them safe. They are reachable. Both passes iterate b64_chunks, the maximal base64 runs of the original text, and a later chunk C can occur earlier as a substring of a longer run R. When R is not decodable, pass 2 skips it, so R survives in result; a decodable C processed afterwards has replace(C, tag, 1) land on C's occurrence inside R, mangling R and leaving the real credential in the output. Pass 3's if run not in result guard checks presence, not position, so it does not close it.

Why deferring is still the right call rather than fixing it in this PR: the finding is pre-existing on main and not introduced here, and the fix is not the one-line-per-site shape the rest of this PR uses. _B64_CHUNK_RE.finditer(text) offsets index text, while each pass mutates result, so span offsets stop being valid the moment pass 1 substitutes a span of different length. Doing it correctly means collecting all three passes' spans against the immutable text and splicing once — which changes the warnings ordering contract that the in-code comment deliberately protects, and changes the meaning of pass 3's guard. That belongs in a change with its own differential corpus, not bolted onto four liveness fixes.

#8049 carries deferred-finding + security, an assignee, Due: 2026-10-03, and records the mechanism, the suggested span-collection approach, and acceptance criteria including a regression for the substring-hosting shape.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

You are right on the mechanism and it is the sharpest remaining point on this PR. CPython's re does not release the GIL for the duration of a single match call, so the hop yields between per-string scans and not within one. Your arithmetic follows from this PR's own numbers: at ~1.8s per 20 KB of linear scan, a single ~300 KB string reaches ~25s inside one .search and trips the same watchdog. And _extract_tool_input_strings does still hand over document bodies, so that string is plausible rather than theoretical.

Description fixed. The claim you quoted — that the loop keeps servicing its watchdog while the scan proceeds — was in the body revision live when your lane ran; it is an overclaim and it is gone. The body now states the bound explicitly: the hop yields between strings, not within one; it removes the observed killer (the quadratic behaviour, which made 20 KB enough) and moves the threshold out by more than an order of magnitude, to roughly one 300 KB string; it does not make the scan unconditionally safe at any size.

Fix deferred, not declined. Your chunked-scan suggestion is the right shape, and #8053 records it with the part that actually needs care: the overlap must be derived from the pattern's longest possible match (including the Windows-native and %APPDATA% branches plus the separator anchor), because an overlap below that silently narrows the deny surface — which is worse than the stall it fixes. The issue carries deferred-finding + security, an assignee, Due: 2026-10-03, and acceptance criteria requiring a ~300 KB liveness test that asserts a concurrent task ticks throughout, plus a differential with matches placed astride chunk boundaries.

Not folded into this PR because deriving and proving that overlap bound is a deny-surface change wanting its own differential corpus, and this PR is four one-line-per-site liveness fixes plus a leak fix. You also correctly note it "narrows the hole substantially" — shipping that now and the bound separately is strictly better than holding the narrowing hostage to the harder half.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Accepted: the duplication is real and this PR added the second copy, so it is fair to charge it here. Your framing also names the thing precisely — "Python vars only, preserve credentials" is a distinct policy from scrub_agent_subprocess_env (which additionally strips SSH_AUTH_SOCK / AWS_* / GNUPGHOME / GIT_ASKPASS, correct for a sandboxed agent child and wrong for the user's own unsandboxed shell), and a policy with two call sites and no name is exactly what earns a helper.

Deferred rather than done here for one reason, stated plainly: this PR had converged with all 65 checks green, and the change touches sandbox.py — a security-sensitive module this PR otherwise leaves untouched on purpose. Re-opening a green security-adjacent PR to rename a three-line loop trades a real regression risk and a full ~40-minute CI round for a naming improvement. On its own the diff is reviewable as exactly what it is.

#8054 carries deferred-finding, an assignee, Due: 2026-10-17, and acceptance criteria that pin the behaviour boundary: both call sites routed through the helper, no open-coded loop left for this policy, the terminal tests still asserting PYTHONPATH/PYTHONHOME absent while SSH_AUTH_SOCK/AWS_*/TERM/KIROCREW_TERMINAL survive, and no change to scrub_agent_subprocess_env or any agent-spawn call site.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Sep 3, 2026
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 3, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 3, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 3, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This 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

  • PR #8282 is PARTIALLY_COVERED relative to this PR. Coverage is explicitly incomplete; this finding is not a completion or closure claim. Recommended action for PR #8282: KEEP. The merged predecessor covers only the constant-factor part of the same problem; the growth, the ceiling, the title tier and the whole cron attribution/breaker are absent from current main. Files: src/kiro_crew/security.py, src/kiro_crew/llm_helpers.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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 releases the GIL while matching (a 16-23 s
   worker search left the main thread's 20 ms tick gaps at 20 ms on
   3.10/3.11/3.12), so the loop stays live within a scan and a caller's
   wait_for can cancel the await; the comments that claimed otherwise
   are corrected. 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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 4, 2026
…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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 5, 2026
…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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 5, 2026
…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.
bolichen97 added a commit to bolichen97/KiroCrew that referenced this pull request Sep 5, 2026
…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.
bolichen97 added a commit that referenced this pull request Sep 5, 2026
…name the job (#8282)

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 #7941.

2. The title tier scans off the loop (llm_helpers.py). #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.

Co-authored-by: Bolin Chen <bolichen97@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants