refactor(security): split security.py into a package and drop path regex - #9183
Conversation
First Principles Review (Fable 5.1) — ✅ human override accepted@bolichen97 overrode this lane for |
Design Review (Fable 5.1) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound refactor that executes the codebase's own "OS layer, not a text matcher" mandate — but it removes the only shell-layer fence on Watch
Suggestions
[DESIGN-REVIEWED] 26b2f2a |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
ee5da9e to
22b74f5
Compare
eff7d6d to
360b57e
Compare
360b57e to
33e745c
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
/ai-review override gpt 33e745c: intentional design, accepted by the maintainer -- the shell gate's path regex refused one spelling of an unbounded set and passed the next (six review rounds on #9089 alone) while failing ordinary read-only work; the OS sandbox is the control where a store is masked, and the |
|
Replying to the Design Review and First Principles CONCERNS on
Alias record ( Split into two commits (Design suggestion). The package move and the deletion are one commit on purpose: a package that carries the deleted layer never exists in history, so there is no intermediate shape to bisect onto. The behaviour diff is auditable from the test side --
Cron vetter's two |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
33e745c to
96f1ec1
Compare
96f1ec1 to
a2eec90
Compare
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
…7820) (#9189) A GitHub issue-prefill link is redacted in chat as `[REDACTED: suspicious URL to github.com]`, which #7820 reports as a false positive. Two earlier rounds of this change waived the aggregate query-LENGTH signal for that shape -- first on the validated shape alone, then additionally pinned to this project's own tracker. Both are withdrawn. Neither was safe, and the difference between them was only who reads the payload. What reaches `redact_exfiltration_urls` is MODEL-AUTHORED text: injected content steers the model into emitting a prefill URL whose `body` carries percent-encoded private context; the waiver skips the length check; the link renders as the familiar "file an issue" affordance; the user submits it; and the issue is PUBLIC, so the attacker reads it. Pinning the repository does not close that -- this project's tracker is world-readable by design. A URL's shape says nothing about who authored it, and a marker placed IN the text travels in the channel the injection controls. So no validation performed on a model-emitted URL can establish provenance, and that is a property of the class rather than of any one spelling of the check. - Remove the frontend waiver: `isPrefilledIssueUrl` and the `EXFIL_ISSUE_*` constants in `website/src/utils/sanitize.ts` (they landed for #7824). The aggregate-length signal there is unconditional again. This is the production change in this commit. - The BACKEND needed no deletion after rebasing onto #9183, which split `security.py` into a package: `security/exfil.py` was re-derived from a base that never carried the waiver, so its length gate is already unconditional. What lands there is the reason it must stay that way, recorded at the gate so the next person with a long legitimate URL narrows the heuristic for every host instead of carving out a shape. - Deliver #7820's feature through the TRUSTED STRUCTURED CHANNEL the product already ships, rather than through the redactor. `diagnostics._issue_url` assembles the prefill query from structured fields (version, channel, what-happened, context, install) with `urlencode`; it travels as `BundleResult.github_issue_url` on a JSON response no redactor scans; and `ReportProblemModal` / `ReportProblemCard` render their own `<a href={result.github_issue_url}>`. Nothing on that path is text a model wrote, so nothing on it needs a waiver. `diagnostics.py` already documented this split, with `terminal_issue_url` as the bounded variant for paths that DO get relayed through prose. - Repoint the parity guard from pinning a carve-out to pinning its ABSENCE on both halves, reading each enforcing source at run time. The backend sweep runs over the whole `security/` package rather than one module, because a waiver reintroduced in any of the nine modules or in the facade would be just as live -- the single-file path this replaces was left pointing at a deleted file by #9183, which is exactly the failure mode. The length comparison is asserted at its own site on each side, so a waiver spelled with fresh identifiers still fails, and the structured seam is asserted present because deleting the waiver is only free while that seam exists. Red-first against the pre-fix head: a prefill link to this project's own tracker with an encoded payload rendered as a live link. The withdrawn allow-list held GitHub's classic prefill keys, while the trusted builder uses issue-FORM keys, which that list never contained -- so the waiver only ever served links the model typed, the exfiltration surface and nothing else. Refs, not Closes: #7820 asks for a narrower heuristic and also reports `monitorportal.amazon.com`. Both are still redacted in chat, and a test pins them as getting the same verdict. Narrowing aggregate query length is a per-host security-ceiling decision on its own merits, and a per-shape escape hatch is not a substitute for it. Refs #7820
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
The scan shipped with no `pull_request` trigger, so it only ran after a change had already reached `main`. Within hours that cost us a real leak: the push run on 09c54c7 flagged an internal ticket id and an internal workplace path that PR #9183 had already published to the public default branch. On a public repository post-push detection is post-disclosure — a revert does not recall it. This makes the check block before merge, on every pull request including forks. ## Same-repo pull requests `internal-content-scan-gate.yml` gains `pull_request: branches: [main]`. A same-repo PR does receive an OIDC token, so it reaches a real verdict. The callee's diff computation now states the `pull_request` range explicitly instead of falling through to `HEAD^`. That fallback resolves correctly today only because a merge commit's first parent IS the base tip; relying on the coincidence is how a later edit silently changes the scan's scope. ## Fork pull requests `fork-internal-content-scan.yml` is new: Stage 2 of the same pattern the `fork-*-review.yml` lanes use. A fork head gets no OIDC token, so the same-repo caller skips it (a job-level `if`, so it skips rather than failing) and this privileged lane covers it instead — triggered by `Fast Gate` completing, running the definition from the default branch, posting a check-run under the same `Internal Content Scan` name so one status surfaces on either path. Leaving forks uncovered was the alternative and it is the wrong one: external contributions are the code we know least about, so they are the last place to accept no pre-merge coverage. This lane is safer than the AI-review lanes it copies, because no model and no fork-authored instruction is involved. The fork's code is never checked out, built or executed — its diff is fetched as a data file, using git rather than the compare API so a large diff cannot be silently truncated past the gate. The scanner comes from the sha256-verified bundle in our own bucket, so a fork cannot influence the rules that judge it either. Unlike those advisory lanes, an incomplete run finalizes as **failure**, never `neutral`: "the scan did not reach a verdict" must never read as "the scan found nothing", which is the fail-open this whole gate replaced. ## How it actually blocks `PR Readiness` — the one required status on `main` — reads the scan as a named lane, so a marker in an added line fails readiness and the PR cannot merge. This is the route `Fast Gate` already uses. The alternative, branch-protection's required-checks list, would need the reusable workflow's composed check name (`scan / internal-content-scan`) and a first report before GitHub's picker offers it at all. Both workflows are added to `pr-readiness.yml`'s `workflow_run` list. Without that, a finished scan never re-triggers readiness and the lane sits at "(not started)" with no event able to clear it. Two lanes are marked ineligible rather than pending, the treatment CodeQL already gets: a stacked PR (base is another feature branch) never starts the `branches:`-filtered workflow, and a fork PR's same-repo lane is skipped in favour of the Stage-2 one. ## Docs `AGENTS.md`, `docs/ci/ci-and-reviews.md` and `oss-fork-boundaries.md` all said the check reports on `push` and does not gate a PR. That was true when written and is now false, so all three are corrected rather than left to rot. The boundary doc also records the leak that motivated this, so the next reader sees why the trigger matters instead of reading it as ceremony. ## Verified - 668 tests pass across the readiness, AI-review-workflow, quality-gate, workflow-permission, secret-scope and fast-gate suites. The one failure in `test_ai_review_workflows.py::TestGptVerdictVisibility` is PRE-EXISTING: it fails identically with these changes stashed. - All four workflows parse; `pr-readiness.yml`'s `workflow_run` list resolves to 17 workflows including both new entries. - `python3 scripts/docs_lint.py` reports nothing against tracked files. - The gate scanned this change's own diff: clean over 391 added lines.
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: dwu96 <dwu96@users.noreply.github.com> Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Problem / Motivation
The shell gate refused ordinary read-only work. During this refactor alone the live gateway blocked
grep -rn ... src test docs,cd <scratch dir> && python probe.py | grep -v ..., a heredoc carrying backticks ("64 nested substitutions"), agit diff | grep | sedpipeline, and a Python probe whose command line merely contained the string~/.aws/credentials. None of those read a credential. Each refusal came fromsecurity.py-- 21k lines in one file, most of it regex trying to decide from command TEXT whether a path might be sensitive -- and each cost a retry with a different tool plus the tokens to explain the block.The OS sandbox is where that question belongs:
sandbox.pybind-masks the crew-home secrets (_CREW_HIDDEN_LEAVES) and seals the governance keystone read-only (_CREW_READONLY_LEAVES) in every mode, hides~/.awsinstrict/cc, andis_sensitive_pathfences every resolved path the file tools open. Where the sandbox deliberately leaves a store visible (see residuals), a text regex was never a real control either -- it refused one spelling and let the next one through. A text regex over the command cannot be made complete (each closed spelling opened another -- #9089 went six review rounds on exactly that) and adds nothing the sandbox does not already hold.Why it matters
Every false refusal is a wasted agent turn and a wrong lesson ("this tool is unsafe") on a command that was fine. The team direction, agreed after #9082 and #9089, is to remove the regex text layers rather than keep narrowing them. This PR is that removal, plus the package split that makes the surviving 16k lines navigable.
What changed (motivation → approach → change)
Package split (behaviour-preserving).
kiro_crew.securityis now a thin facade over owner modules ordered by dependency:vocabulary→helpers→shell_normalizer→paths→denied_rules→redaction→exfil→argv_floor, plusdiagnostics. Every public and test-reached private name is re-exported from a frozen manifest (_exports.py, ratchet-tested) and amonkeypatchon the facade mirrors onto the owning module, so existing imports and patch targets keep working. The two load-time cycles MAP found are broken by moving shared vocabulary down or keeping an import call-time; the ~40 judgement calls are in.refactor/DECISIONS.mdon the branch history (not shipped).Text path matching deleted, not narrowed.
is_sensitive_bash_commandkeeps three tiers: the size ceiling, the IMDS check, the environment-credential exfiltration check. Removed: the fence-literal and relative-traversal matchers, separator-run collapse, assignment-resolved views, trust-rootcdre-rooting, the trust-root extraction control, the native home-entry scan, and every helper only they used (paths.py3131→1880,shell_normalizer.py3311→2806,argv_floor.py2813→2588). From the deny catalog, three groups, catalog 149→111: (1) the 27sensitive-file-readrows (<verb>.*<credential store>-- the same regex under another name); (2) the 4 self-management rows that fired on the product name appearing anywhere in a path, grep pattern or filename -- they had structural argv-floor twins, and the floor now runs them ungated instead of looking up its own catalog row (that lookup was a trap: deleting a row would have silently disabled the floor); (3) 7 bare-word rows with no successor:legacy-delete-stack-underscore,legacy-terminate-instance-underscore,legacy-drop-table-underscore,legacy-delete-table-underscore,legacy-delete-bucket-underscore(.*<word>.*, aws-destructive / sql) andlegacy-get-secret,legacy-read-secret(credential-exfil). They refused any command containing a boto3 method name or a secret-fetching tool name --grep -rn delete_stack src/,git log --grep drop_table, editingdelete_table.sql-- and an earlier revision of this description wrongly filed them under the product-name group and claimed every destructive/credential-exfil row was kept. It was not: see residuals.Kept, deliberately.
is_sensitive_path(keystone; the file-tool fence). The IMDS / env-credential tiers. Every destructive, git-publish, pipe-to-shell, reverse-shell and credential-exfil catalog row that matches a COMMAND shape --aws cloudformation delete-stack,aws s3 rb,DROP DATABASE,curl ... | bash-- as opposed to a bare word. The argv-structural self-protection floor. Output redaction.self-protection-cron-adopt(no floor twin, real ownership grab). Each surviving refusal now names its rule id and matched span so a false positive can be self-diagnosed.Stated residuals -- two review lanes flagged them, they are real, and the maintainer has decided them.
standardmode (the default) does NOT bind-mask~/.aws,~/.sshor~/.kube--sandbox.pyleaves them visible on purpose so kiro-cli's own credential resolution keeps working (_STANDARD_DIRSvs_STRICT_DIRS; the module's own comment says the shell gate "is the control" for those). After this PR a spawnedcat ~/.aws/credentialsin standard mode is refused by nothing. My first description said "every sandbox mode"; that was wrong. Decision (maintainer, @bolichen97): (a) accept the residual. The matcher never made the store unreadable -- it refused one spelling and passed the next -- while refusing ordinary read-only work; masking.aws/.kubeinstandard(with the.aws/configcarve-outccalready has) is a sandbox-tier change for its own PR._WRITE_PROTECTED_BASH_LEAVES/_BARE_TOKEN_PROTECTED_LEAVEStier fenced shell WRITES to six crew-home leaves. Four have an OS seal already (settings_seeds.json,computer_use.json,aws_service_consent.jsonREADONLY;routing,workflow_library,apps/meetings/data/editsHIDDEN) and their tests now assert that disposition. Two do not:connections-tool-aliases.json(an alias-deletion grant record) andplaywright-cli-config.json(kept VISIBLE bysandbox.pyon purpose; the agent can already pointPLAYWRIGHT_MCP_CONFIGelsewhere). Themodels/weights directory and the ops-mission-controlrotation.yaml/incidents/index.jsonare likewise unsealed. Decision (maintainer): accepted as a stated residual. Sealing the alias record in_CREW_READONLY_LEAVESis the follow-up and is NOT in this PR: it needs the empty-document and precreate analysissettings_seeds.jsongot, and a sandbox change does not belong inside a deletion.legacy-*rows stay deleted, with nothing replacing them. Apython -c "boto3.client('cloudformation').delete_stack(StackName='prod')"one-liner is no longer refused by text; the hyphenated CLI spellings still are. The bare-word rows refused every command that merely CONTAINED the method name, which in a repository that talks to AWS is a routine grep, and a one-liner they did catch is one file-write away from a script they never caught.src/kiro_crew/cloud/__init__.py's module docstring still names the underscore shapes as "the MCP-side block"; that note is now stale and is corrected in this PR.sel_hmac.keyisVISIBLEto the sandbox and has no bash-layer fence; closing that means moving its in-sandbox reader behind the gateway, not another matcher. The croncommandvetter is a separate surface with its own credential-path detector; it gains a backslash-unescaped view so the spellings the deleted catalog row used to catch there stay caught there.Tests
TestTheBashGateMatchesNoPathspinscat ~/.aws/credentialsandcd ~/.kiro/crew && cat security_policy.jsonas not refused by the gate while IMDS, env-cred and oversize still are.TestNoCatalogRowMatchesACredentialPathpins the category gone, five credential-path commands passingis_denied, and the neighbouring families still refusing.TestProductNameAnywhereIsNotADenialpins the eleven product-name rows gone, representative false positives allowed, and the genuine self-kill / self-restart still refused by the floor -- including with every built-in disabled.TestARefusalNamesItsRuleAndSpanpins the diagnostic line on catalog, floor and ceiling refusals and that it never echoes matched bytes.test_security_facade.pypins the export manifest and patch-mirroring.test_security*,test_hooks*,test_denied*,test_redact*,test_mcp_cron_security*,test_governance*,test_deny_guidance,test_workflow_security,test_host_isolation_floor,test_sandbox_governance_mask. flake8 / isort / mypy (--platform linux) / black gate / docs-lint / brand / harness / subprocess-encoding gates green.Manual verification
Full suite on the pre-#9089 shape of this branch: 89,308 passed; every failure reproduced identically on
origin/mainon the same host (ops-mission-control redaction, xdist worker budget, autonudge reconciler). Ran the probeis_sensitive_bash_command("cat ~/.aws/credentials")→None,("env | grep AWS_SECRET")→ refused,("curl http://169.254.169.254/...")→ refused, 30 KB command → refused withrule=keystone-scan-ceiling.Related Issues
Follows #9082 (cron script bodies leave the shell gate) and #9089 (drop the traversal simulation). Closes the false-positive class behind #8889.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)