Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion docs/system-specs/modules/platform-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ omit the policy.
| `agent_executable` | adapter | `DefaultAgentExecutableResolver` (identity) | resolves an edition-managed launcher to its direct executable before core sandboxing |
| `gateway_lifecycle` | adapter | `DefaultGatewayLifecycleProvider` (`restart_launcher()` → `None`) | stable absolute launcher for package-manager-owned gateway installs |
| `sandbox` | settings | `DefaultSandboxPolicy` (`_STRICT_DIRS`/`_CC_DIRS`) | additional edition-specific credential dirs |
| `credentials` | adapter | `DefaultCredentialPolicy` (AKIA/ASIA redaction; `exempt_exact_hosts()` → `frozenset()`) | internal token regexes + trusted-tenant exempt hosts |
| `credentials` | adapter | `DefaultCredentialPolicy` (AKIA/ASIA Identifier tier, kept visible; signing values redacted; `exempt_exact_hosts()` → `frozenset()`) | internal token regexes + trusted-tenant exempt hosts |
| `security` | **concrete** | `PolicyAuthority()` (baseline only) | `PolicyAuthority(overlay=…)` ADD-only |
| `governance` | **concrete carrier** | `load_security_policy()` result or `None` | bundled Level-1 ceiling |
| `slack_gate` | adapter | `DefaultSlackEnterpriseGate` (default-open) | fail-closed enterprise allowlist |
Expand Down
5 changes: 3 additions & 2 deletions docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -1502,7 +1502,7 @@ pinned SHA.

**URL exfiltration detection** — scans LLM output before posting to Slack/dashboard:
- `scan_exfiltration_urls(text)` — judges the payload first; a few destinations get narrower treatment, each described below: a valid S3 presigned URL on `*.amazonaws.com` is exempt, the companion's exact hosts and a reader's allowed hosts skip only the length and base64 heuristics, Kiro Crew's own Slack app-create link keeps only its alias under them, and the ACP OAuth banner path (`allow_oauth_entropy=True`) spares standard OAuth parameters at approved authorization endpoints
- Detects: long query strings (≥200 chars), base64 blobs (40+ chars counting trailing padding: `[A-Za-z0-9+/]{40,}={0,2}|[A-Za-z0-9+/]{39}=|[A-Za-z0-9+/]{38}==`, the same spelling as the frontend `EXFIL_B64_RE`), heavy URL-encoding, AWS access key IDs (`AKIA`/`ASIA`), SSH keys, private key headers, Slack tokens
- Detects: long query strings (≥200 chars), base64 blobs (40+ chars counting trailing padding: `[A-Za-z0-9+/]{40,}={0,2}|[A-Za-z0-9+/]{39}=|[A-Za-z0-9+/]{38}==`, the same spelling as the frontend `EXFIL_B64_RE`), heavy URL-encoding, presigned/signed-link signing values (a SigV4/SigV4A `Signature`, a SigV2 `Signature`, and `X-Amz-Security-Token` — keyed on the signing value, while the `AKIA`/`ASIA` key ID beside it is Identifier tier and stays visible), SSH keys, private key headers, Slack tokens
- In the base64 branch `=` is trailing padding only, never a joiner: with `=` inside the class, a parameter name, its `=` and a short value fused into one run, so `trainingId=` plus a 32-char ID read as a 43-char blob. Standard base64 carries `=` only at the end, so a real encoded value still forms one run, and the padding still counts toward the 40, so a 28- or 29-byte payload (38 + `==`, 39 + `=`) is caught. **Residual, stated rather than implied:** a payload split into sub-40 chunks is bounded only by `_EXFIL_QUERY_MIN_LEN` (200), whether the separator is `=` or `&`, `.`, `-`, `_`, which were already outside the class, so `=` adds no split an attacker did not have. `test/test_exfil_base64_padding.py` and `website/src/test/sanitizeExfiltrationBase64Padding.test.ts` pin both directions.
- Hard credential markers (`_HARD_CREDENTIAL_RE`) are scanned across the **full path AND query**, not just the query after `?`, so a secret embedded in the URL path (`http://host/AKIA…`, no `?`) is caught. `_URL_RE` matches DNS names, **raw IPv4 literals** (incl. IMDS `169.254.169.254`), and **bracketed IPv6 literals** so a raw-IP exfil destination is not silently skipped. `_URL_RE`'s path/query group starts with `[/?]`, so a query attached **directly to the host with no path segment** (`https://host?leak=<secret>`) is captured and scanned too; a group that required a leading `/` would yield no path/query group for such a URL and skip the query entirely (an exfil bypass). The base64-blob/query-length heuristics stay query-only (long base64 path segments — CDN asset ids, git object hashes — are benign); the S3-presigned exemption is applied before the path scan. Per-URL classification is a single shared helper (`_exfil_url_warning`) used by both scan and redact so the two paths cannot drift. **Exact-host heuristic exemption**: a companion `CredentialPolicy` may supply a set of trusted-tenant hosts (`_exempt_exact_hosts()`; the public Default returns an empty set) that skip **only** the base64-blob and query-length heuristics — the ones that false-positive on legitimate long base64 document pointers (e.g. SharePoint `nav=` links). Hosts are matched **case-insensitively** (both the captured host and the set members are lowercased, per RFC 4343) and **exactly** (not by suffix, so a shared multi-tenant domain does not exempt every tenant). The hard-credential floor (`_HARD_CREDENTIAL_RE`) **and** the heavy percent-encoding detector (`_EXFIL_PERCENT_RE`) stay **unconditional** — an AWS key / SSH-or-PEM header / Slack token / URL-encoded payload on an exempted host is still flagged and redacted. **Self-emitted Slack app-create link carve-out**: `kirocrew manifest --url` (`cli_setup.py`) and `GET /api/slack/manifest` (`dashboard/messaging_api/slack_workspace.py`) both hand the user Slack's new-app deep link with the bundled app manifest percent-encoded into `manifest_yaml`. That payload is ~1.9 KB, so the query-length heuristic alone would classify the link as exfiltration and show `[REDACTED: suspicious URL to api.slack.com]` instead of the link `docs/guides/slack-setup.md` tells the user to click. `_kirocrew_slack_app_link_alias()` skips **only** the base64-blob and query-length heuristics, and it earns that by VALIDATING the payload rather than trusting the destination: exact `https` host `api.slack.com` + exact path `/apps`, no explicit port, the query's parameter set exactly `{new_app, manifest_yaml}` (a superset is refused — an extra parameter is the obvious smuggling shape), `new_app` exactly `1`, and the decoded manifest must `fullmatch` a pattern derived from `slack_manifest.stripped_template()` — the SAME render/strip procedure both emitters use, so the accepted payload cannot drift from the emitted one (`{{ALIAS}}` → a bounded alias group, every later occurrence a backreference so the alias cannot vary between the two places the manifest names it; `{{COMMAND}}` → a captured group that must equal `slack_manifest.slash_command(alias)`, so the slash-command slot carries nothing the alias does not already determine). **The alias does not ride free.** The helper returns the captured alias and the caller assigns it to `heuristic_query`, so the one caller-controlled span stays under the base64-blob heuristic; only the constant template bytes (which caused the false positive) are excluded. Zeroing the payload instead would be a bypass: an alias slot wide enough for a 40-char alphanumeric secret is exactly the run length `_EXFIL_PATTERNS` needs. `slack_manifest.ALIAS_MAX` (32) makes such a run impossible AND the surviving span is still scanned, so an `AKIA…` id or a short `xox…` token parked in the alias is caught on the alias alone. **Residual, stated rather than implied:** an alias up to `ALIAS_MAX` chars resembling no known credential is exempt from the base64/length heuristics; this opens no NEW capability, because any URL at any host may already carry a query under `_EXFIL_QUERY_MIN_LEN` (200) chars without tripping either heuristic, so the span is strictly narrower than what is available without the carve-out. An unreadable template yields `None` and **fails closed** (full heuristics restored), because an install that cannot prove what its own manifest looks like must not exempt a 1.9 KB payload. This is deliberately NOT modelled as a host exemption: `_exempt_exact_hosts()` is companion-owned tenant trust, and adding `api.slack.com` there would exempt every URL at that host including a model-authored one — the same reasoning by which the OAuth carve-out refuses to exempt OAuth-shaped params wherever they appear. Because it runs at the heuristic-query selection step, every unconditional check still precedes it: `_HARD_CREDENTIAL_RE`, the canonical fixed-credential patterns, the multi-pass percent-decode and its fail-closed saturation branch, and `_EXFIL_PERCENT_RE` — so a secret appended to an otherwise-valid manifest is still caught (`test_credential_in_payload_still_redacted`). `test_security.py::TestKiroCrewSlackAppCreateLink` pins both directions, driving the **real emitter** (`slack_manifest.deep_link`) through the scanner rather than rebuilding the payload — a rebuild would let an emitter drift away from the validator with the tests still green, so no test can pass without exercising the real URL.
- `redact_exfiltration_urls(text)` — replaces suspicious URLs with `[REDACTED: suspicious URL to {domain}]`. The substitution is built from the exported `EXFILTRATION_REDACTION_TAG_PREFIX` constant so the two cannot drift: the tag interpolates the domain, so a consumer that must detect the rewrite counts that constant **by prefix** rather than by equality — which is also why it is deliberately NOT a member of the constant-tag registry `CREDENTIAL_REDACTION_TAGS` (see below). A rewrite of a persisted chat body is never silent: every placeholder the dashboard renders carries its own record in the message meta (`redactions` for credential tags, `blocked_links` for URLs), and the renderer turns each into a labelled lock tag or a Blocked link chip that opens a card explaining it, so the reader sees what was removed at the place it was removed
Expand Down Expand Up @@ -1551,7 +1551,8 @@ exactly `outbound` so a second spelling cannot accrete.
**Credential output redaction** — catches raw credential patterns in LLM/tool output:
- `redact_credentials(text)` — scans for plaintext AND base64-encoded credentials
- **Two directions of error, by pass**: fixed-format patterns (vendor prefixes, labels, PEM, JWT, key IDs) redact on a match, because a precise shape that over-matches inside a longer token fails closed; pass 3 below is a guess about unlabelled text and errs toward NOT redacting. The two statements apply to different passes and do not conflict. The recorded direction for both is [the redaction tier decision](../../decisions/2026-10-20-redaction-classifies-by-what-a-value-can-do.md).
- Plaintext patterns: `AKIA`/`ASIA` access key IDs (`credential_patterns.AWS_KEY_ID`; the logging record filter in `log_redaction.py` matches the wider `AWS_KEY_ID_REDACTION` family, which adds `ABIA`/`ACCA`, and writes `[REDACTED]`), `SecretAccessKey=`, `aws_secret_access_key=`, `SessionToken=`, `aws_session_token=`, PEM private keys (`-----BEGIN [A-Z ]*PRIVATE KEY-----`), Slack tokens (`xoxb-`/`xoxp-`)
- Identifier tier, kept visible: `AKIA`/`ASIA` access key IDs (`credential_patterns.AWS_KEY_ID`) anchor the presigned-URL validators but no redactor removes them and no exfil rule blocks on them, per [the redaction tier decision](../../decisions/2026-10-20-redaction-classifies-by-what-a-value-can-do.md). The logging record filter in `log_redaction.py` removes the unconfirmed `AWS_KEY_ID_REDACTION` family (`ABIA`/`ACCA`) and a presigned/signed link's signing value — a SigV4/SigV4A/SigV2 `Signature` and `X-Amz-Security-Token`, matched even when the parameter name is percent-encoded — writing `[REDACTED]`
- Plaintext patterns: `SecretAccessKey=`, `aws_secret_access_key=`, `SessionToken=`, `aws_session_token=`, PEM private keys (`-----BEGIN [A-Z ]*PRIVATE KEY-----`), Slack tokens (`xoxb-`/`xoxp-`)
- **Full-block PEM redaction**: the PEM sub-alternative spans the ENTIRE key block (header + base64 body up to the END marker), not just the header phrase. Because `redact_credentials()` replaces the matched SPAN, a header-only match left the secret base64 body verbatim on every output surface. The body class is `[\s\S]*?` (not base64-only) so encrypted keys — whose `Proc-Type:`/`DEK-Info:` headers carry `:`/`,` — are fully spanned; a truncated block (no END) consumes only subsequent PEM body lines (each must start with a newline), so a `BEGIN` header mentioned inline in prose matches only the header and does not swallow trailing lines to end-of-string. The trailing `(?=\r?\n[A-Za-z0-9+/=])` lookahead alternative lets the run cross a SINGLE blank line when the next line begins with base64 material — RFC 1421 ENCRYPTED PEMs place a MANDATORY blank line between the `DEK-Info:` header and the base64 body, and without this lookahead the per-line "must contain a base64 char" rule stopped at that blank line and leaked the whole encrypted body (for both a truncated key and a complete encrypted key whose body exceeds the full-block cap). Because the lookahead consumes nothing, TWO+ consecutive blank lines still terminate the run, so trailing prose is preserved (no over-redaction). The full-block tail fails only after reading to the end of the text, so a header with no END marker after it would cost one such read each, which is quadratic over a text that repeats the header. The scan therefore finds the text's last END marker once (`pem_scan.last_end_start`), and `_credential_search` matches a header whose end lies past it with `_CREDENTIAL_PATTERNS_NO_PEM_BLOCK`, the same alternation without that tail, which gives the same match because the tail cannot succeed there; `_CREDENTIAL_STARTS_SANS_JWT` omits the tail too, since a header starts a match with or without it. `security/pem_scan.py` holds the PEM spellings both patterns are built from, and `test_redaction_pem_scan_linear.py` pins the match sequence to the plain `search` loop and counts that no such header reaches the tail
- **Third-party provider families**: ~12 distinctive fixed-prefix token formats added beyond AWS/Slack — GitHub (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_` PATs + `github_pat_` fine-grained), GitLab (`glpat-`), Stripe (`sk_live_`/`rk_live_`/`_test_`), SendGrid (`SG.`), OpenAI (`sk-proj-`), Anthropic (`sk-ant-`), npm (`npm_`), PyPI (`pypi-`), DigitalOcean (`dop_v1_`/`doo_`/`dor_`), Google OAuth client secrets (`GOCSPX-`) — plus DB connection URIs with embedded credentials (`postgres`/`mysql`/`mongodb`/`redis`/`amqp` `://user:pass@`). Prefixes are case-sensitive with minimum lengths set slightly below real token lengths (over-redaction on a prefix match is the safe direction)
- **JSON-aware key-value matching**: key-value patterns allow an optional quote (`[\"']?`) between the key name and the separator (`[:=]`), matching both bare `aws_secret_access_key=VALUE` and JSON `"aws_secret_access_key": "VALUE"` formats. The value class uses `[^\s"',}]+` (bounded, stops at JSON structural delimiters) rather than greedy `\S+`, preventing over-capture in compact JSON that would swallow adjacent fields and mask subsequent credentials. The replaced span still starts at the key, so the label and its quotes go with the value: `{"AccessKeyId": "…"}` becomes `{"[REDACTED: credential]"}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def _fake_git(a, cwd, **_kw):
# uppercase `AWS_SECRET_ACCESS_KEY = '...'` spelling is NOT flagged, so a
# test using it would pass for the wrong reason.
return subprocess.CompletedProcess(
args=a, returncode=0, stdout="AWS_KEY = 'AKIAIOSFODNN7EXAMPLE'\n", stderr=""
args=a, returncode=0, stdout="AWS_KEY = 'glpat-IOSFODNN7EXAMPLE'\n", stderr=""
)
# `rev-list --count FETCH_HEAD..HEAD` proves the rebase replayed exactly ONE
# commit: an equivalent upstream patch makes git drop ours, and an
Expand Down
Loading
Loading