fix(gateway): stop three on-loop stalls, keep crash logs, scrub PTY env - #7941
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of 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
[DESIGN-REVIEWED] 2ae0c20 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate — oversize Verifying the diff's load-bearing claims:
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 False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I have everything needed. All items verified: the reuse constraint on 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 shipsIntent: stop the gateway watchdog from killing live turns during on-loop security scans — a FIX (plus one leak fix found en route).
Watch
[FIRST-PRINCIPLES-REVIEWED] 2ae0c20 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
a3052a9 to
ecc972b
Compare
Legitimate, and the anchor is 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 ( 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. |
Correct, and verified: 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 Worth recording for whoever lands retention later: the |
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 — Rather than adding the
|
Agreed on both halves: the class is genuinely half-closed, and a code comment is not tracking. Issue #8049 carries the Your point prompted me to check reachability rather than assume the siblings were benign, and they are not. Both remaining passes iterate
Pass 3's Why it is deferred rather than fixed here: the offsets from |
Your count was right and so was the conclusion: one consumer, grepped across Fixed by reverting the rotation change entirely rather than by widening the diagnostics collector, because GPT independently raised a 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 |
Both accepted, and both are now gone, because reverting the rotation change removes the code they targeted. On the first: you were right that On the second: your reasoning is the sharper of the two and generalises past this PR. An unnumbered Recorded for the follow-up that lands retention properly: do not reintroduce a fold, and keep the newest generation at the literal name |
Your sibling count is right (two: the pass-2 and pass-3 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 Why deferring is still the right call rather than fixing it in this PR: the finding is pre-existing on #8049 carries |
You are right on the mechanism and it is the sharpest remaining point on this PR. CPython's 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 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. |
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 Deferred rather than done here for one reason, stated plainly: this PR had converged with all 65 checks green, and the change touches #8054 carries |
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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.
…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>
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:
site-packagesfrom 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_credentialspass 1 was O(n²) —finditerplusresult.replace(matched, tag, 1)rebuilt the entire string per match. Now a singlesub()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-anchoredM<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.\nis in the character class, so a path at the start of a later line still matches, and.never crossed a newline anyway.3.
_resolve_permissionran 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 oneasyncio.to_threadhop, 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
reholds 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_modelscalled_resolve_ssh_auth_sockinline, which globs/tmp/ssh-*/agent.*andstats 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 viaasyncio.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/PYTHONHOMEthe gateway held reached interactive shells — searched before a venv's ownsite-packages, which is the 3.13-venv failure above. Stripped via the sharedsandbox._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_envnorwrap_argv's scrub was reused, because both also stripSSH_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 patternmcp_gateway/gatewayd.pyalready uses.sandbox.pyis untouched.Removed from this PR after review
An earlier revision also replaced the single
gateway.log.prevslot with an N-deep.prev.1..Nring, to keep crash-moment logs across a watchdog restart loop. That is reverted here. GPT flagged it against theblocking: trueAUTOSDE ruleno-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:506collects the literal namegateway.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 unuseddepthparameter (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.
=,:,,,;, string start), and a mid-token case that must not match — proving no verdict became more permissive. Backed at scale bytest_security.py+test_trust_reads.py.replace()shape cannot return._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.PYTHONPATH/PYTHONHOMEabsent from the child env whileKIROCREW_TERMINAL/TERMand 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.pywas 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 bys.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 insecurity.py(passes 2 and 3 ofredact_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 withre.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
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)