fix(autonomy): add deny-by-default --credential-roots allowlist - #947
Conversation
The security-binding checker recognized a probed host-credential path by static structural shape (exact home-anchored / fixed-system forms). A static checker cannot know an org's real credential locations, so for any such recognizer an adversarial reviewer can craft a plausible-but-invented path (an invented home user, a mount that need not exist) that passes while real host credentials go unrecognized — the non-convergence #549 documents. Replace the structural recognizer with a deny-by-default allowlist, per the ratified Option A. A filesystem credential entry is credential-absence evidence only when its recorded host-side expansion resolves (lexically, ".."-safe, filesystem-independent) under one of the operator-configured trusted roots passed via the new --credential-roots flag, mirroring the --egress-hosts seam; with no roots configured every filesystem credential entry is untrusted and the level fails closed. Membership under a configured root is the sole test, dissolving the open-ended enumeration. A cloud-metadata-endpoint route and a well-known credential env token stay bounded closed sets; the expansion-coherence guard is retained. The egress-side seam is untouched. Prune the fixtures that tested the removed structural discrimination; keep invented-home-user, host-home-mount, and dot-traversal as not-under-root regressions and add an unconfigured-roots fail-closed fixture. Bump autonomy 0.8.0 -> 0.9.0. Closes #549 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…expansions
Independent security review of the deny-by-default credential-roots change
surfaced two coherence gaps in credentialEntryProblem:
- A UNC / double-separator credential path ("//etc/credentials", or a Windows
"\etc\credentials" that folds to "//") collapsed its leading "//" onto the
POSIX root in normalizeHostPath and could resolve under a configured root
(e.g. "/etc") — a share on an invented server misread as the host's own path.
The deleted structural recognizer rejected all UNC forms and the home-anchored
coherence guard still does; normalizeHostPath now refuses a leading "//" so
containment can never accept it, consistent with that guard.
- The metadata-URL and env-token branches returned before the expansion-
coherence check, so a concrete entry's recorded host_expanded was no longer
verified verbatim (a URL/token entry could carry an arbitrary path in its
expansion field). Both branches now run the coherence guard after the
closed-set validity check, restoring the verbatim requirement.
Add regression fixtures: a UNC credential path fails closed against a
configured /etc root, and a metadata-URL entry with a mismatched expansion is
rejected as needing no expansion.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 44s —— View job PR #947 Review:
|
|
Claude finished @kyle-sexton's task in 8m 28s —— View job Security Review — PR #947:
|
| Area | Verdict |
|---|---|
UNC collapse onto POSIX root (//etc/...) |
Blocked in normalizeHostPath (returns null) and independently in old coherence guard |
.. escaping the configured root |
Blocked: normalizeHostPath collapses traversal; post-normalization path falls outside root → pathUnderConfiguredRoot returns false |
| Env-token bypass of credential roots check | Bounded closed set ($GITHUB_TOKEN, $GH_TOKEN); org-specific tokens rejected by name; verbatim expansion required |
| Metadata URL bypass of credential roots check | Bounded closed set (169.254.169.254 / metadata.google.internal, plain HTTP, known routes only); file://, HTTPS, off-port all rejected |
Segment-boundary prefix attack (/home/runner-x accepted by root /home/runner) |
Blocked: containment checks startsWith(normRoot + "/") not bare startsWith(normRoot) |
Empty --credential-roots list (e.g. ",") |
Produces [] after filter; roots.some(...) on empty array → false → all paths denied (fail-closed) |
| Relative-path configured root | Relative roots produce relative normRoot; absolute candidate paths don't start with a relative prefix → denied |
| Argument injection / shell concerns | None: value is split/compared as plain strings in Node.js; never passed to a shell |
| GitHub Actions patterns | No workflow files changed |
…ic-paths The autonomy security-binding fixtures directory is already excluded from the machine-specific-paths hygiene scan because its transcripts carry adversarial host paths on purpose. The graded-fixture manifest is the same corpus: it pins each fixture's trusted --credential-roots (a real runner home, /home/runner) and the expected not-under-root rejections, so it necessarily carries those same host paths. Exclude it too, consistent with the fixtures-dir exemption. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 16s —— View job PR #947 Review:
|
|
Claude finished @kyle-sexton's task in 3m 30s —— View job Security Review — PR #947:
|
| Area | Verdict |
|---|---|
UNC collapse onto POSIX root (//etc/...) |
Blocked: normalizeHostPath returns null; pathUnderConfiguredRoot treats null candidate as no-match |
.. escaping a configured root |
Blocked: normalizeHostPath collapses traversal; normalized form falls outside root |
Empty-roots after parse (--credential-roots "," → []) |
Behavior is fail-closed (every path denied); error message has empty parens but the security posture is correct |
Segment-boundary prefix attack (/home/runner-x vs root /home/runner) |
Blocked: startsWith(normRoot + "/") not bare startsWith(normRoot) |
| Env-token bypass of roots check | Bounded closed set ($GITHUB_TOKEN, $GH_TOKEN); unrecognized names always rejected |
| Metadata URL bypass of roots check | Bounded closed set (known providers + routes; plain HTTP + default port only) |
GitHub Actions workflow change (ci.yml) |
Uses pull_request (not pull_request_target); all action refs SHA-pinned; no run: steps interpolating untrusted context; permissions: contents: read; the exclusion only widens the machine-paths hygiene skip for the manifest file — no security boundary affected |
Argument injection in --credential-roots parsing |
None: split/compare in Node.js, never passed to a shell |
Two prior SUGGESTION-level findings remain open. Neither is a bypass without operator misconfiguration; neither introduces a new attack surface from outside. The /-root guard is the higher-priority fix since its consequence — total silent disable of deny-by-default — is severe relative to the ease of the mistake.
Classification — review + security-review passes (commit 279042d)
Findings 1-3 routed back to the branch for a fix; finding 4 gets a tracked follow-up (not blocking this PR). |
Post-green review of the deny-by-default credential-roots change flagged three
issues, all addressed here:
- A configured root that normalizes to "/" made containment `startsWith("/")`,
accepting every absolute path and silently defeating deny-by-default —
a fail-open in the exact gate this change exists to close. pathUnderConfiguredRoot
now refuses a "/"-normalizing root (fails closed like an unconfigured root),
and the now-dead "/" containment branch is removed. Added a fixture proving
`--credential-roots /` does not bypass the gate.
- An arg that parses to zero roots (e.g. "," or whitespace) yielded [] rather
than null, so the operator saw a confusing empty-allowlist rejection instead
of the "no --credential-roots configured" guidance. Empty-after-parse is now
treated as unconfigured.
- The unrecognized-env-token guidance (comment + error message) wrongly told
operators to put an org-specific token name on --credential-roots, which holds
filesystem roots, not token names. It now points to probing the host path
where the injected secret is stored and configuring its parent directory.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 45s —— View job PR #947 Review:
|
|
Claude finished @kyle-sexton's task in 3m 0s —— View job Security Review — PR #947:
|
| Case | Before fix | After fix |
|---|---|---|
normRoot === "/" (configured root /) |
Accepted every absolute path | Returned false — denied |
normRoot === "/home/runner" |
startsWith("/home/runner/") (correct) |
Same — no change |
normCandidate === normRoot equality |
Checked after ternary | Same — checked after guard, so "/" equality never fires |
credentialRoots = [] (empty-after-parse) |
Reached roots.some(...) → false; confusing message |
Now null — routes to "no --credential-roots configured" |
credentialRoots = null (no arg) |
"no --credential-roots configured" |
Unchanged |
The Windows drive-letter root case (e.g., --credential-roots C:/) is not caught by the normRoot === "/" guard, but it cannot accept anything via startsWith ("c:/foo" does not start with "c://") so it is effectively inert rather than a silent bypass — consistent with the existing behavior and not a regression.
No new security issues found
The changes in fd3b5c7 are narrowly scoped to the three prior findings. No new injection surfaces, access-control gaps, or path-normalization bypasses are introduced. The /-root guard is strictly more restrictive. The empty-list normalization is strictly fail-closed.
The deferred PLAUSIBLE finding (concrete paths with ../. segments in the non-home-anchored coherence branch) remains unaddressed by design per the classification comment — normalization at containment time still correctly denies any traversal that would escape a configured root, and the follow-up issue will track the diagnostic-quality gap. This is consistent with the stated defer decision and is not a security regression.
Summary: All three security findings from prior reviews are correctly fixed. No new issues introduced. Secure to merge.
|
Claude finished @kyle-sexton's task in 2m 55s —— View job PR #947 Final Review:
|
| Area | Verdict |
|---|---|
normalizeHostPath is sound |
UNC rejection (// → null), drive-letter anchoring, ..-safe via null-on-root-escape. normalizeHostPath("/") → "/", caught by guard. |
pathUnderConfiguredRoot enforces segment boundaries |
Guards null/""/"/", then normCandidate === normRoot (exact match) or startsWith(normRoot + "/"). /home/runner-other never matches root /home/runner. |
| Coherence guard runs for all entry types | credentialExpansionProblem fires inside credentialEntryProblem for metadata URLs, env tokens, and filesystem paths alike. |
| Env-token and metadata-URL bypass is correct by design | Bounded closed sets; unrecognized names/routes always rejected; well-known tokens require verbatim host_expanded. |
| Empty-relative-root is inert, not a bypass | A root normalizing to "" (e.g. .) hits the normRoot === "" guard → false. Fail-closed. |
| UNC bypass is closed | normalizeHostPath("//etc/...") → null; pathUnderConfiguredRoot treats null candidate as no-match. |
Test harness correctly wires credential_roots |
Line 92 of the test MJS: if (entry.credential_roots) args.push("--credential-roots", entry.credential_roots). |
| Documentation is accurate | SKILL.md and isolation-probe.md both correctly describe credential validation as deny-by-default with explicit "fails closed" language. |
| Version bump and CHANGELOG entry are accurate | 0.8.0 → 0.9.0 reflects a behavioral change (not additive); CHANGELOG entry is precise. |
All prior findings are resolved. No new issues identified. Ready to merge.
|
Claude finished @kyle-sexton's task in 5m 6s —— View job Security Review — PR #947:
|
| Area | Verdict |
|---|---|
UNC collapse (//etc/... onto POSIX root /etc) |
Blocked: normalizeHostPath returns null at the lowered.startsWith("//") guard; pathUnderConfiguredRoot treats null candidate as no-match |
.. escaping the configured root |
Blocked: normalizeHostPath collapses traversal arithmetically; post-normalization path falls outside root → pathUnderConfiguredRoot returns false |
Multi-hop .. traversal (e.g. /root/a/../../etc/passwd) |
Blocked: the out.pop() loop correctly unwinds each hop; returning null if the stack underflows before the root anchor |
/./ and embedded double-slash paths |
Correctly collapsed: /./home/runner → /home/runner; /home//runner → /home/runner |
Segment-boundary prefix attack (/home/runner-x vs root /home/runner) |
Blocked: startsWith(\${normRoot}/`)— suffix/` is required; sibling never matches |
normRoot === "/" (configured root /) |
Blocked by the new explicit guard at line 221 |
Windows drive-letter root (C:/) |
normalizeHostPath("C:/") returns "c:/" — NOT caught by the normRoot === "/" guard, but normCandidate.startsWith("c://") never fires for real Windows paths, so behavior is fail-closed (denies everything), not fail-open. Documented in prior review as existing behavior, not a regression. |
Empty string root after normalization (e.g. .) |
Caught by normRoot === "" guard → false |
| Env-token single-segment bypass of roots check | Bounded closed set ($GITHUB_TOKEN, $GH_TOKEN); unrecognized bare names always rejected |
| Metadata URL bypass of roots check | Bounded closed set (169.254.169.254 / metadata.google.internal, plain HTTP, known routes); file://, HTTPS, off-port, arbitrary hostnames all rejected |
host_expanded comma-injection from transcript |
Each positionally-paired entry independently runs the coherence guard (credentialExpansionProblem); concrete entries require verbatim equality, so injecting a second comma-delimited path that aliases a legitimate root requires the second entry to also independently pass containment. Length mismatch on any split is caught and rejected. |
host_expanded null byte or control characters |
normalizeHostPath doesn't strip control characters, but a null byte in position len(normRoot) prevents startsWith(normRoot + "/") from matching → fail-closed |
| Coherence guard scope (all entry kinds) | credentialExpansionProblem now runs for metadata URL, env token, and filesystem path alike — concrete entries (all three) must repeat host_expanded verbatim |
| Relative path as configured root | Relative normRoot never starts with /, so an absolute candidate path never matches → fail-closed |
Missing --credential-roots argument value |
args[i + 1] = undefined; !isNonEmptyString(undefined) = true → exits with usage error (exit 2) |
Argument injection in --credential-roots parsing |
Split/compare in Node.js as plain strings; never passed to a shell |
GitHub Actions ci.yml workflow change |
Only change: added fixture manifest to machine-specific-paths exclusion list. Trigger is pull_request (not pull_request_target); all action refs SHA-pinned; no run: step interpolates untrusted context; permissions: contents: read; exclusion pattern is safe pathspec syntax |
Deferred finding (from prior review, no change expected)
The prior security review classified one PLAUSIBLE finding as DEFER: concrete paths with .. or . segments in the non-home-anchored branch of credentialExpansionProblem pass the verbatim-equality coherence check (since host_expanded must repeat the entry exactly, .. segments satisfy the equality), then are correctly handled at containment time by normalizeHostPath collapsing them. No bypass: a traversal that escapes the root after normalization falls outside containment and is denied. The gap is diagnostic only — the error says "does not resolve under any configured --credential-roots" rather than "contains a dot-traversal segment." This remains appropriately deferred.
Summary: No CRITICAL or IMPORTANT findings. All three prior security findings are correctly fixed and verified. The deny-by-default posture is correctly implemented and the /-root bypass regression is locked in by the new fixture. Secure to merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd3b5c7005
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… roots The --credential-roots containment test lowercased both root and candidate before comparing. On a case-sensitive host that widens the allowlist: a root /var/Creds would contain /var/creds/not-real, a different file. normalizeHostPath now preserves POSIX case and folds only the case-insensitive drive-letter form, so a case mismatch fails closed. The checker cannot observe the probing host's platform, so case-sensitive comparison is the safe default for POSIX paths. The same normalizer kept an unanchored (relative) path as a bare comparable string, so a root `secrets` would contain a candidate `secrets/not-real`. Relative paths resolve against an arbitrary working directory and name no stable host credential store, so they now yield null (denied) for both roots and candidates. Adds graded fixtures for both edges so a future refactor cannot silently reintroduce the fail-open.
|
Claude finished @kyle-sexton's task in 3m 13s —— View job PR #947 Review:
|
| Area | Verdict |
|---|---|
normalizeHostPath("/") still returns "/", caught by normRoot === "/" guard |
✅ unchanged |
normalizeHostPath("//etc/...") → null (UNC rejection) |
✅ unchanged |
normalizeHostPath("/home/runner/.ssh/../id_rsa") → /home/runner/id_rsa (dot-collapse) |
✅ unchanged |
normalizeHostPath("C:/Users/Alice") → c:/users/alice (drive-letter fold) |
✅ unchanged, regex widened to [a-zA-Z] to accept uppercase driver letter before fold |
normalizeHostPath("/home/runner/.ssh/id_rsa") → /home/runner/.ssh/id_rsa (POSIX case preserved) |
✅ new behavior, correct |
/-root guard in pathUnderConfiguredRoot (from prior fd3b5c7 fix) |
✅ unaffected — still normRoot === "/" |
Segment-boundary prefix (/home/runner-x vs root /home/runner) |
✅ unaffected — still startsWith(normRoot + "/") |
Empty-after-parse credentialRoots → null (from prior fd3b5c7 fix) |
✅ unaffected |
One subtle point confirmed: The coherence guard (credentialExpansionProblem, lines 298–299) still lowercases internally via entry.toLowerCase() and expanded.toLowerCase(). This is consistent and correct for its purpose — it only checks structural properties (tail-consistency, UNC rejection, ephemeral segment detection), not containment under a root. Containment is gated separately in pathUnderConfiguredRoot with the now-case-preserving normalizeHostPath. The two functions compose correctly.
Status of all findings across the full review history
| # | Finding | Status at HEAD e9f7abf |
|---|---|---|
| 1 (review) | Misleading env-token guidance (L255 + comment) | ✅ Fixed in fd3b5c7 |
| 2 (review) | Empty-roots edge case → confusing empty-parens error | ✅ Fixed in fd3b5c7 |
| 3 (security-review, CONFIRMED) | --credential-roots / silently accepts every absolute path |
✅ Fixed in fd3b5c7 with regression fixture |
| 4 (security-review, PLAUSIBLE) | Concrete paths with ../. segments — diagnostic quality only |
Deferred by design (prior triage), follow-up issue planned |
| Codex P2 | POSIX credential roots widen on case-sensitive hosts | ✅ Fixed in e9f7abf with regression fixture |
| Codex P2 | Relative credential root secrets contains secrets/not-real |
✅ Fixed in e9f7abf with regression fixture |
All open findings are resolved. No new issues introduced by this commit. Ready to merge.
|
Claude finished @kyle-sexton's task in 4m 38s —— View job Security Review — PR #947:
|
| Area | Verdict |
|---|---|
Relative candidate with absolute root (e.g. host_expanded: "secrets/not-real", root /home/runner) |
normCandidate = null → denied. Was already safe under old code because a relative string never starts with an absolute root; now explicitly null. |
Drive-letter root C:/ (Windows drive root) — equivalent of POSIX / bypass |
normalizeHostPath("C:/") → "c:/". normRoot === "/" guard doesn't fire, but startsWith("c://") is never true for real paths like "c:/users/...", so the root is inert rather than a bypass — fail-closed, not fail-open. |
| Interaction between case-insensitive coherence guard and case-sensitive containment | credentialExpansionProblem still calls .toLowerCase() on both entry and expanded for its internal checks (tail-consistency, dot-segment, UNC, rootedness). The coherence guard is strictly a pre-filter; any path it passes then faces the stricter case-preserving containment check. A case-mismatched host_expanded that passes coherence (e.g. /home/runner/.SSH/ID_RSA with tail /.ssh/id_rsa in lowercased form) is still accepted only if the case-preserved expanded value starts with the case-preserved configured root. No bypass is introduced; the pipeline is fail-closed for any root/candidate case mismatch. |
UNC bypass, .. traversal, /-root bypass, empty-roots-after-parse, segment-boundary prefix |
All previously verified and unchanged in this commit. |
| GitHub Actions CI | No workflow changes in e9f7abf. |
Both Codex P2 findings are correctly fixed and regression-locked with new fixtures. No new security issues introduced. Secure to merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e9f7abf768
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A Windows --credential-roots C:/ normalized to c:/ and slipped past the whole-filesystem-root guard, which only rejected POSIX "/". A transcript could then set path and host_expanded to C:/ and satisfy containment via the equality case, accepting a failed read of the entire drive as L2 credential-absence evidence. Extend the guard to fail closed on a bare drive root (c:/), matching the "/" rejection; a narrower root such as c:/creds is unaffected. Adds probe-evidence-credroots-drive-root regression (fails pre-fix as accepted evidence, passes post-fix as an unproven not-under-root level).
|
Claude finished @kyle-sexton's task in 2m 23s —— View job PR #947 Review:
|
| # | Source | Finding | Status at HEAD f4cf324 |
|---|---|---|---|
| 1 | review | Misleading env-token guidance in error + comment | ✅ Fixed in fd3b5c7 |
| 2 | review | --credential-roots "," → [] → confusing empty-parens error |
✅ Fixed in fd3b5c7 |
| 3 | security-review, CONFIRMED | --credential-roots / silently accepts every absolute path |
✅ Fixed in fd3b5c7 + regression fixture |
| 4 | security-review, PLAUSIBLE | Concrete paths with ../. segments — diagnostic quality only |
Deferred by design, follow-up issue planned |
| Codex P2 | e9f7abf |
POSIX case folding widens the allowlist on case-sensitive hosts | ✅ Fixed in e9f7abf + regression fixture |
| Codex P2 | e9f7abf |
Relative credential root secrets accepts secrets/not-real |
✅ Fixed in e9f7abf + regression fixture |
| Codex P2 | f4cf324 |
--credential-roots C:/ accepted via equality case |
✅ Fixed in f4cf324 + regression fixture |
What's correct at HEAD
The combined guard now closes all three "root that accepts everything" shapes in a single line:
normRoot === null || normRoot === "" || normRoot === "/" || /^[a-z]:\/$/.test(normRoot)null— UNC, relative paths, root-escape traversals""— relative paths that normalize to empty (already covered bynull, belt-and-suspenders)"/"— POSIX filesystem root/^[a-z]:\/$/.test(...)— Windows drive root (c:/,d:/, etc.)
The equality branch (normCandidate === normRoot) can only be reached by a non-root, non-UNC, non-relative, segment-bounded path — exactly the correct set of paths that are legitimate narrower roots.
All prior security verifications from the review history remain valid. No new issues introduced. Ready to merge.
|
Claude finished @kyle-sexton's task in 3m 47s —— View job Security Review — PR #947:
|
| Finding | Status |
|---|---|
| Misleading env-token guidance (error message + comment) | ✅ Fixed in fd3b5c7 |
| Empty-roots edge case → confusing empty-parens message | ✅ Fixed in fd3b5c7 |
--credential-roots / silently accepts every absolute path |
✅ Fixed in fd3b5c7 |
| POSIX case folding widens the allowlist on case-sensitive hosts | ✅ Fixed in e9f7abf |
Relative root secrets contains relative candidate secrets/not-real |
✅ Fixed in e9f7abf |
Drive root C:/ accepted every path on the whole drive |
✅ Fixed in f4cf324 |
No new security issues found
The single code change in f4cf324 extends the existing root-rejection guard in pathUnderConfiguredRoot:
if (normRoot === null || normRoot === "" || normRoot === "/" || /^[a-z]:\/$/.test(normRoot)) return false;Exhaustive trace of the Windows-root surface at HEAD:
Input to normalizeHostPath as root |
Normalized form | Guard result |
|---|---|---|
C:/ |
c:/ |
/^[a-z]:\/$/.test("c:/") → rejected ✅ |
C:\\ (backslash) |
c:/ |
Same ✅ |
C:/./ (dot-collapse) |
c:/ |
Same ✅ |
C:// (double slash body) |
c:/ |
Same ✅ |
C: (no slash) |
null (no drive-regex match → no anchor) |
normRoot === null → rejected ✅ |
C:/../ (traversal at root) |
null (underflows stack) |
normRoot === null → rejected ✅ |
\\server\share → //server/share |
null (UNC prefix) |
normRoot === null → rejected ✅ |
\\?\C:\ → //?/C:/ |
null (UNC prefix) |
normRoot === null → rejected ✅ |
C:/creds (valid narrower root) |
c:/creds |
Regex doesn't match → accepted as a valid root ✅ |
Z:/ (other drive letter) |
z:/ |
Regex matches → rejected ✅ |
The compound-root case (mix of valid and invalid roots in --credential-roots) also behaves correctly: someRoot.some(...) iterates all roots; an invalid root returns false for its iteration and doesn't block the valid one.
Coherence guard / containment interaction is still correct. credentialExpansionProblem lowercases for its structural checks (tail-consistency, dot-segments, ephemeral-segments, UNC rejection) — this is format validation only. Containment in pathUnderConfiguredRoot runs separately with normalizeHostPath preserving POSIX case, so case-preserving containment is unaffected by the lowercased coherence check.
probe-evidence-credroots-drive-root.json fixture is load-bearing. The manifest entry { "exit": 1, "credential_roots": "C:/", "findings_substrings": ["does not resolve under any configured --credential-roots"] } correctly locks in the denial. Trace: transcript has path: "C:/", host_expanded: "C:/" → coherence passes (verbatim equality holds on lowercased entry) → pathUnderConfiguredRoot("C:/", ["C:/"]) → normRoot = "c:/" → regex fires → returns false → finding reported → exit 1 ✅. The fixture exits 0 (fail-open, accepted as evidence) against the pre-fix code and exit 1 (denied) against the fix — it is a genuine regression test.
CI workflow change (f4cf324 is not in this commit — it appeared in 279042d): adding the fixture manifest to the existing machine-specific-paths exclusion list is narrowly scoped. The workflow uses pull_request (not pull_request_target); all action refs are SHA-pinned; no run: step interpolates untrusted context; permissions: contents: read is unchanged. The exclusion only widens the hygiene scanner's skip list — no security boundary is affected.
Summary: No CRITICAL, IMPORTANT, or SUGGESTION-level security findings. All six prior security findings across the review history are verified fixed. The drive-root guard is correct, complete, and regression-locked. Secure to merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f4cf324a72
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Summary
The security-binding checker (
plugins/autonomy/skills/setup/scripts/check-security-binding.mjs) recognized a probed host-credential path by static structural shape — exact home-anchored / fixed-system forms. A static checker cannot know an org's real credential locations, so for any such recognizer an adversarial reviewer can always craft a plausible-but-invented path (an invented home user, a mount that need not exist) whose failing read proves nothing while real host credentials go unrecognized. That is the non-convergence #549 documents.This implements the operator-ratified Option A (deny-by-default; issue comment): a new
--credential-roots <path,path,...>flag mirroring the already-landed--egress-hostsseam, with unconfigured default = deny-all. Credential side only — the egress seam (--egress-hosts,isNonExternalEgressHost, special-use ranges) is untouched.Fix
--credential-rootsflag, parsed exactly like--egress-hosts(comma-separated, trimmed; roots are not case-folded at parse time —normalizeHostPathfolds case consistently at containment time, since the checker never touches the probing host's filesystem and POSIX case-sensitivity is not observable here)...-safe, filesystem-independent (normalizeHostPath/pathUnderConfiguredRoot) — under one of the configured trusted roots. With no roots configured, every filesystem credential entry is untrusted and the level fails closed. Membership under a configured root is the sole test, dissolving the open-ended enumeration.isMetadataEndpoint) and single-segment well-known credential env tokens ($GITHUB_TOKEN,$GH_TOKEN). The expansion-coherence guard (credentialExpansionProblem: rooted, non-UNC, ephemeral-free, tail-consistent) is retained and now runs for every entry kind, so a concrete entry'shost_expandedmust repeat it verbatim./home/rundoes not accept/home/runner-x); UNC ///-prefixed forms are refused so//etc/credentialscannot collapse onto a configured/etcroot.SKILL.mdandtemplates/isolation-probe.mddescribe credential = deny-by-default while leaving the egress seam's accurate allowlist-with-fallback description intact;CHANGELOG.md+plugin.jsonbumped0.8.0 → 0.9.0.invented-home-user,host-home-mount, anddot-traversalas not-under-configured-root regressions; added unconfigured-roots (fail-closed), UNC-rejection, and metadata-expansion-mismatch regressions.An independent security review (fresh-context subagent, rationale withheld) surfaced a UNC-collapse containment bypass and a concrete-entry coherence gap; both are fixed in the second commit with regression fixtures.
Verification
plugins/autonomy/skills/setup/scripts/check-security-binding.fixtures.test.sh(the repo's graded-fixture harness):Baseline before the change was
All 394 checks passed (109 fixtures graded); the delta is the pruned structural-discrimination corpus (−12 fixtures + transcripts) plus 3 new deny-by-default / hardening fixtures. North-star behavior verified directly against the checker:$HOME/.ssh/id_rsa→/home/runner/.ssh/id_rsawith no--credential-roots⇒ UNPROVEN, level fails closed (no --credential-roots configured).--credential-roots /home/runner⇒Security binding OK(C1–C4 eligible)./home/definitely-not-a-host-user/.ssh/id_rsawith--credential-roots /home/runner⇒does not resolve under any configured --credential-roots.No egress-side fixture changed behavior.
Closes #549
Related
--egress-hostsseam this mirrors