enhance(#3897): ADR-3473 §8.3 rungs 2-4 — runtime marker, derived Codex sandbox, short-form depends_on (#3941)
* test(#3897): failing-first coverage for §8.3 rungs 2-4 ADR-3473 §8.3 has four rungs; #3883/PR #3896 shipped the first. This pins the other three RED before any fix. Rung 2 — the install marker has four readers and resolveRuntime is not one. resolveRuntime resolves GSD_RUNTIME > config.runtime > 'claude' and reads no marker at all, while bin/install.js writes one (#2297) and FOUR hand-rolled readInstallRuntimeMarker copies exist: src/model-resolver.cts:65 (cached, with test seams), hooks/gsd-agent-isolation-guard.js:112, and TWICE in hooks/gsd-cursor-subagent-start.js at :346 and :355. Four copies of one rule. Fixtures and seam names mined from PR #3382 rather than re-derived; it implemented this rung and was closed "not on the merits". Rung 3 — the sandbox map, and the fallback that was the real defect. Measured across all 35 files in agents/, deriving workspace-write iff tools: declares Write or Edit: - all 11 CODEX_AGENT_SANDBOX entries derive to their mapped value exactly, zero disagreements — the map carries nothing the contract does not - 24 roles fall through `|| 'read-only'`, of which 16 declare Write or Edit So the map is redundant and the silent fallback is the defect. The maintainer chose to derive but hold those 16 at read-only pending the question of whether Codex enforces sandbox_mode or merely advises; HALT.md records it. T20 asserts the emitted sandbox_mode PER ROLE against a captured baseline, not in aggregate — an aggregate passes while one role silently widens, which is the proxy-instead-of-identity shape this repo names. T24 and T25 fail on a stale hold, so the hold list cannot rot into the subset map being deleted. Rung 4 — shortFormToId, recovered rather than invented. I nearly reported this as another wrong §8.3 claim: `git log -S shortFormToId` returns only documentation commits. That was the wrong instrument. Direct inspection of sdk/src/query/phase.ts at 11918dcc3^ shows five occurrences, and the tests match that code rather than a guess at its semantics — including first-write-wins on a duplicate short form. T43 asserts at the consumer's output: the emitted `waves` map from the real CLI, which pre-fix collapses to {"1":[...]} because every short-form edge is dropped. A unit assertion on resolveDependencyId would have passed throughout this defect's life. Observed RED, this tree: rung 2 11/11 fail — no marker rung, no seams rung 3 T23,T24,T25,T26,T30 fail; T28 fails (validate agents passes a TOML whose sandbox_mode disagrees — it checks presence only) rung 4 T42,T44 fail; T43,T49 fail with waves collapsed to a single wave 1 Green and staying green: T20/T21/T22/T27 as captured baselines, #3885's unresolvable-token warning and wave-verdict suppression, and #3785's display-mapping passthrough. If the third tier over-reaches, those go red — that is their job. Disclosed weakness: T45 (a canonical id with no dash is not short-form indexed) cannot be isolated behaviorally, because planMap always masks it. It is a non-crash boundary pin, weaker than the other rows, and is recorded as such rather than presented as equivalent. Design: .gsd/phase/feat-3897-adr3473-83-rungs/40-design.md Test matrix: .gsd/phase/feat-3897-adr3473-83-rungs/50-test-matrix.md Decision: .gsd/phase/feat-3897-adr3473-83-rungs/HALT.md Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enhance(#3897): §8.3 rungs 2-4 — one marker reader, a derived sandbox, the third depends_on tier ADR-3473 §8.3 has four rungs. #3883/PR #3896 shipped the first. These are the other three. Rung 2 — the install marker had four readers, and resolveRuntime was not one. resolveRuntime resolved GSD_RUNTIME > config.runtime > 'claude' and read no marker, while bin/install.js writes one (#2297) and four hand-rolled readInstallRuntimeMarker copies existed: src/model-resolver.cts (cached, with seams), hooks/gsd-agent-isolation-guard.js, and twice in hooks/gsd-cursor-subagent-start.js. model-resolver's was already the house idiom, so it was promoted rather than replaced: src/runtime-slash.cts now owns it, and model-resolver plus both hooks delegate. The hooks reach it through ensureRuntimeBuild(), the seam lint-hooks-runtime-build-seam enforces. No import cycle existed - checked both directions before moving anything. The marker is the THIRD rung: env > project config > marker > 'claude'. N1 was checked rather than assumed, and my first reading of it was wrong. A marker holding an unknown name comes back essentially verbatim, which looked like a validation gap. Measured against the env rung with the same inputs - including "../../etc/passwd" and "claude;rm -rf /" - the two are identical, because they share resolveRuntimeNameFromCandidates. N1 asks for exactly that, and it is met. The residual (the shared normalizer normalizes shape, it does not validate against the known-runtime set) is pre-existing on the env rung and plausibly deliberate, since a new runtime should not need a code change. The marker also does not widen the trust boundary in any real sense: it lives inside the install tree beside the code, so anyone who can write it can write runtime-slash.cjs itself. Rung 3 — the map was redundant; the silent fallback was the defect. Measured across all 35 files in agents/, deriving workspace-write iff tools: declares Write or Edit: all 11 CODEX_AGENT_SANDBOX entries derive to their mapped value exactly, zero disagreements. The map carried nothing the contract did not already have, so it is DELETED rather than reconciled. What was actually broken is `|| 'read-only'`, which silently under-granted 24 of 35 roles. 16 of those 24 declare Write or Edit and would widen under derivation. Per the maintainer's decision (HALT.md), they are held at read-only pending the question of whether Codex enforces sandbox_mode or merely advises. Emitted TOML is therefore byte-identical for all 35 roles - asserted per role, not in aggregate, because an aggregate passes while one role silently widens. The hold list self-invalidates. A hold whose role no longer derives broader fails, and so does a hold naming a role with no agents/<name>.md. Without that it would rot into exactly the hand-maintained subset map being deleted, and this commit's own ledger claim would become false over time. Both cases were proved by injecting them and watching them throw. Two committed tests asserted the deleted map's existence and contents. They were pinning the thing being removed, so the tests moved rather than the production code: the 11 role-value pairs survive as a test-local PRE_3897_CODEX_AGENT_SANDBOX baseline, and the assertions now drive the real derivation against real agents/*.md. The coverage is preserved; only its source moved out of production code. validate agents gains checkCodexSandboxPosture, mirroring the existing checkCodexModelPosture: each installed TOML's sandbox_mode must equal the role's expected value, failing with role, expected and found. It previously checked file presence and manifest completeness only, so a TOML whose sandbox_mode disagreed passed. Rung 4 — shortFormToId, recovered rather than invented. I nearly reported this as another wrong §8.3 claim: git log -S returns only documentation commits. Wrong instrument. sdk/src/query/phase.ts at 11918dcc3^ carries five occurrences, and the implementation here matches that code rather than a guess at its semantics - including first-write-wins on a duplicate short form, deterministic from the sorted plan order. It resolves the bare plan number: depends_on: ["01"] now reaches 26-01-auth-hardening. That is a control-flow change, not a diagnostic one - plans that silently collapsed into a single wave 1 now execute in their declared waves, and execute-phase.md consumes those wave values. In-phase only, by construction: the map is built from this phase's rawPlans, so a same-named short form in another phase does not resolve. #3785's display-mapping passthrough and #3885's unresolvable-token warning and wave-verdict suppression are untouched and stay green. If the third tier had over-reached, those are what would have caught it. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3897): close a fail-open I introduced, and wire the posture check to its command Two blockers from review. Both are mine, and one is a security regression my own change created. 1. A held role could escape its hold by editing its own frontmatter. The Codex install loop set the sandbox identity from the agent's frontmatter `name:` field rather than from its filename, so the hold lookup keyed off a value the file itself declares: deriveCodexSandboxMode('gsd-doc-writer', <real file>) -> read-only deriveCodexSandboxMode('gsd-doc-writer-x', <same file, name: edited>) -> workspace-write deriveCodexSandboxMode('GSD-Doc-Writer', <same file, name: recased>) -> workspace-write What makes this a blocker rather than a nit is the DIRECTION. The deleted CODEX_AGENT_SANDBOX map had the identical lookup-key quirk, but it was an allowlist: an unmatched key fell back to read-only, which is safe. The new scheme derives workspace-write from the tool contract and uses the hold as a subtraction, so the same mismatch fails OPEN. I converted a fail-closed quirk into a fail-open one and did not notice; the isolated reviewer proved it by execution. Neither safety net caught it. validateCodexSandboxHolds only checks that <key>.md exists, never that a file's derived identity matches its key. checkCodexSandboxPosture looks the canonical source up by the installed TOML's filename, finds nothing for a renamed agent, and treats it as a custom non-roster agent — silently no violation. The identity is now the FILENAME STEM, which is what validateCodexSandboxHolds already validates and what an attacker editing frontmatter cannot change without renaming the file — at which point the existing validator catches it. The lookup is case-insensitive so a recase does not slip past either. The frontmatter name still drives the TOML body and filename, unchanged; only the sandbox identity moved. All 35 roster files were checked: name matches filename stem everywhere, so a stricter "they must agree or throw" invariant would have been safe against real content. It is deliberately NOT added — it would abort an install on a tampered file where emitting a correctly-derived read-only TOML is the safer outcome. Recorded as a fork rather than decided silently. 2. checkCodexSandboxPosture was exported and never called. cmdValidateAgents (src/verify.cts) called checkAgentsInstalled and checkCodexModelPosture only; grep for the sandbox check in that file returned nothing. So criterion 3 — "validate agents fails on semantic drift, not only on missing files" — was unmet, and `validate agents` behaved exactly as before. That is ADR-3473 Decision 2's named shape: a declared policy with no executor. It also meant the T28 test asserted at the helper's return value while the COMMAND stayed broken — the ADR-3180 Decision 4(b) failure this epic exists to close, committed by me while enforcing it elsewhere in the same epic. Now wired as an additive `sandbox_posture` field beside `codex_posture`, following the sibling precedent exactly. Drift is report-only, not a non-zero exit, because that is what checkCodexModelPosture does — two sibling posture checks disagreeing about whether a violation is fatal would be its own defect. The choice is recorded in a comment rather than left implicit. A consumer-output test now drives the real CLI and asserts on the emitted JSON, and was shown failing before the wiring and passing after. Also corrected a stale artifact: the design's Known limit L1 still claimed rung 3 was not in this deliverable, written while it was halted and false once the maintainer unblocked it. Verified after both fixes: the three bypass probes all return read-only, the per-role table is 35/35 byte-identical, and both hold self-invalidation cases still throw. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3897): the marker rung, the derived sandbox, and the bare plan-number depends_on Reference: the runtime precedence ladder in docs/CLI-TOOLS.md gains the install marker rung; docs/COMMANDS.md documents validate agents' new sandbox_posture field; docs/reference/plan-md.md documents that depends_on accepts the bare plan number. Explanation: a docs/features fragment keyed id 3897, so it cannot collide with a concurrent PR hand-allocating a section number, regenerated into FEATURES.md. ADR-3473 §8.3 gains an ANSWER blockquote in the document's own correction style, recording what was measured and built against the section's 2026-08-26 correction - including the qualification that checkAgentsInstalled itself still checks presence only, and the semantic assertion lives in a sibling wired into validate agents rather than folded into it. No how-to. Both user-visible changes are zero-step: a non-Claude install resolving its own runtime, and plans executing in their declared waves, both happen without the user doing anything. docs/how-to/control-the-reported-host-runtime.md covers a DIFFERENT ladder (resolveReportedRuntime / agent_runtime) that this change does not touch, and was deliberately left alone rather than edited by association. No tutorial - nothing multi-step to walk through. docs/AGENTS.md unchanged: it documents Claude-side tools frontmatter, never Codex sandbox_mode, and the emitted tools contract did not change. The prompt layer documents depends_on only by example, not by schema, so nothing there needed editing - and few-shot-examples/plan-checker.md already showed depends_on: ['01'], which now actually resolves. Translated copies of plan-md.md are untouched; the project treats translations as community-maintained. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3897): move the sandbox derivation out of the installer, off the install path, and off a third parser The full suite came back with 26 failures across four files. Three distinct causes, mapped individually rather than assuming the first explained the rest. A. Requiring bin/install.js printed the GSD banner to stdout and corrupted `validate agents` JSON. Unexpected token '', "[36m ██"... is not valid JSON checkCodexSandboxPosture reached deriveCodexSandboxMode by lazily requiring bin/install.js, whose module load prints the ASCII banner. So the command emitted banner bytes before its JSON and every JSON consumer broke, including ten tests that predate this branch. src/ reaching into bin/ was backwards layering that happened to also be loud. The derivation now lives in src/codex-agent-toml.cts - the existing Codex TOML domain module, no new module and no six-gate ripple - and both bin/install.js and src/agent-install-check.cts import it. One owner, which is §8.3's rule applied to the fix for §8.3. B. The stale-hold throw fired on a legitimate partial source dir, and masked a security assertion. validateCodexSandboxHolds treated "this hold's .md is absent from the install SOURCE dir" as a stale hold and threw. A test fixture, or any partial install source, legitimately contains a couple of agents. Worse, it threw BEFORE the path-escape check, so a test asserting that a `../../evil` frontmatter name is rejected got my unrelated error instead of the traversal rejection it was written for. A fail-closed check of mine was hiding a real security check. The "no stale holds, shrink-only" invariant is a property of the repo's canonical agents/ roster, not of whatever directory an install happens to read. It is off the runtime path and enforced where it belongs, in the tests that already existed for it. A partial source dir now installs cleanly, and the evil-name case throws with its own escapes-configHome message again. C. T8 depended on ambient process.env state. The marker/env parity assertion round-tripped through live process.env. It now compares against resolveExplicitRuntime's already-exported dependency-injection parameter - deterministic and hermetic, same claim. Proven still falsifiable rather than assumed: with the marker rung's normalization temporarily bypassed the two rungs diverge ("codex\n../../etc/passwd" vs "codex-../../etc/passwd") and the assertion fails, then passes again once reverted. One correction folded in along the way. The first version of the move added private _extractFrontmatterAndBody/_extractFrontmatterField helpers to codex-agent-toml.cts - a THIRD copy of frontmatter extraction, where the graph already shows two (bin/install.js:2348, runtime-artifact-conversion.cts:893). Adding a third inside the epic whose thesis is one implementation per rule is not defensible. deriveCodexSandboxMode no longer parses anything: it takes (identity, toolsValue) and each caller supplies the tools value using the extractor it already has. Both helpers are deleted. The identity argument is still the filename stem, so the fail-open fix is untouched. Verified after all three: `validate agents --raw` emits parseable JSON with no banner and both posture fields; the four hold-bypass probes still return read-only; the per-role table is 35/35 byte-identical at 26 read-only / 9 workspace-write; the hold list is still 16. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3897): drop a dev-only transitive dep, make the derivation total, retire a stale fallback test Suite down to 7 failures from 26. Three more causes, mapped individually. A. My extractor import dragged in a script that does not exist in an installed tree. Cannot find module '../../../scripts/fix-slash-commands.cjs' Chain: src/agent-install-check.cts imported runtime-artifact-conversion.cjs, which requires command-roster.cjs, whose line 36 requires ../../../scripts/fix-slash-commands.cjs. That path exists in the repo and not in an install, so every test exercising a synthetic install dir died at module load. I picked that extractor for convenience without checking what it pulls in - the same mistake that produced the banner bug, one layer further out. agent-install-check now uses a single-purpose extractToolsLine on codex-agent-toml.cts. That is deliberately NOT a general frontmatter parser: we deleted those helpers a commit ago for good reason, and this reads one line. Verified from outside the repo root that requiring either module prints nothing and does not throw. B. A test pinned the deleted name-based fallback. 'defaults unknown agents to read-only' called generateCodexAgentToml with a fixture declaring tools: Read, Write, Edit. Under derivation an unknown agent with a writing contract correctly derives workspace-write - design row S6, a new writing role gets the contract, not the pin. The behavior it asserted was the silent fallback this rung deleted; identity no longer decides the sandbox. Replaced with two rows rather than a flipped string: no tools declared -> read-only (absence is not a grant), and Write/Edit declared -> workspace-write. Strictly more coverage than the row it replaces. C. The stale-hold check still threw per derivation call. Last commit took the roster-existence check off the install path, but deriveCodexSandboxMode itself still threw when a hold's role did not derive broader FOR THE CONTENT IT WAS HANDED - so it fired on any synthetic fixture for a held role. The throw is gone, and it cost nothing: if a held role's content does not derive broader, the hold pins read-only and derivation returns read-only anyway, so the hold is a no-op and there is nothing to fail about. The staleness invariant is a property of the real agents/ roster, and validateCodexSandboxHolds still enforces it there - confirmed against the real roster after the change, not assumed. deriveCodexSandboxMode is now total: every (identity, toolsValue) including undefined and null returns read-only or workspace-write, never throws. Verified: validate agents emits parseable JSON; the four hold-bypass probes return read-only; the per-role table is 35/35 at 26 read-only / 9 workspace-write. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3897): put the rung-3 decision in the shipped docs instead of pointing at an ignored path The ADR entry and the feature fragment both ended their rung-3 explanation with "see .gsd/phase/feat-3897-adr3473-83-rungs/45-decision-rung3-sandbox.md". That directory is gitignored (.gitignore:55), so the rationale for holding 16 roles at read-only was reachable only from the machine that produced it. A reader of the ADR got a pointer to nothing. Both now carry the reasoning inline: the criterion asks both that the sandbox derive from the declared tool contract and that no role gain a broader sandbox, and those cannot both hold, because a faithful derivation widens 16 roles the deleted map never listed and that fell through its silent read-only default. The resolution is derive-and-hold - the derivation owns the rule now, each hold is released as its enforcement question is answered, and a hold is reversible where a widened sandbox that turns out to be enforced is not. Checked before assuming this was a defect class: CONTEXT.md cites .gsd/phase/<slug>/40-design.md as its standard Design: provenance line in eight module entries, and four other shipped docs do the same. Citing a phase artifact is an established convention here, so those are left alone. What was wrong was specific to these two: they put load-bearing rationale behind the pointer instead of provenance. docs/FEATURES.md regenerated from the fragment via scripts/gen-features.cjs rather than hand-edited. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3897): close a fail-open, stop a silent mis-resolution, and read a declaration as a declaration Two orthogonal reviews on the shipped sha. Three of the findings are the same failure class this epic exists to close, committed inside it. 1. BLOCKER - the sandbox was decided for one identity and applied to another. bin/install.js derived sandbox_mode for the filename stem and then wrote the result to `${name}.toml`, where name comes from the file's own frontmatter. Make the two disagree and a HELD role's artifact goes wide: rename gsd-doc-writer.md -> gsd-doc-writer-v2.md, keep name: gsd-doc-writer -> stem is unheld, derives workspace-write, lands on gsd-doc-writer.toml add any gsd-*.md whose frontmatter name: is a held role -> clobbers that role's toml with workspace-write Both emit read-only on origin/next, because the deleted map was an allowlist and a miss fell back safe. This is a regression my change introduced. The previous review round moved the HOLD KEY off frontmatter to the filename stem and left the OUTPUT PATH on frontmatter; my own comment at install.js:6985 calls that value attacker-editable, four lines above the line that uses it as the filename. The decision is now made over BOTH candidate identities, most-restrictive wins: if either the stem or the emitted name is held, the mode is read-only. 2. MAJOR - hold matching was toLowerCase() only, so confusables escaped. Turkish dotted/dotless i, fullwidth, NFD, trailing space/NBSP/dot/newline, ./ and ../agents/ all slipped the hold and emitted workspace-write. Identities are now basenamed, trimmed of NBSP/zero-width/control characters, NFKC-normalized and lowercased - and anything still carrying a character outside [a-z0-9._-] is treated as suspicious and derives read-only. We do not enumerate confusables; every shipped roster file is ASCII, so refusing to widen on an identity we cannot recognize is fail-closed with no false positives on real content. 3. MAJOR - the short-form depends_on tier mis-resolved SILENTLY. shortFormToId keyed on the last dash-segment of any canonical id with no constraint that it is a plan number, so a phase holding 09-FIX-auth-PLAN.md made depends_on: ["auth"] bind at wave 2 with zero warnings. This is the worst shape in the epic: the unresolvable-token warning fires on a DROPPED token, so a MIS-RESOLVED one is invisible and the tool reports a confident wave assignment built from a wrong edge. A wrong edge is worse than a missing one. The segment must now match /^\d+$/, which is exactly the contract docs/reference/plan-md.md already documents. This tier was recovered verbatim from the retired SDK lineage, which carried the same defect; we are deliberately NOT preserving it bug-for-bug, and the comment says so, so the next reader does not "restore" it. 4. MAJOR - the derivation was reading a declaration as an absence. extractToolsLine read one line, so a YAML list-form tools: block returned only its first item. Two roster files use list form, and gsd-nyquist-auditor declares Write and Edit there - parsed as "- Read", found no write tool, and emitted read-only. Rung 3's headline claim is that sandbox_mode derives from the declared tool contract; that claim was false for 2 of 35 roles and materially wrong for 1. Reading a declaration as an absence is the silent-drop class this epic exists to close. Renamed extractToolsValue and taught it both shapes. gsd-nyquist-auditor now derives workspace-write and joins CODEX_SANDBOX_HOLDS as its 17th entry, per the standing derive-and-hold decision - so emitted TOML stays byte-identical at 26 read-only / 9 workspace-write while the hold list finally records every role that would widen. A previous pass declined this fix because it moved the count; that inverts the priority. Byte-identity is preserved THROUGH the hold, not by leaving a parser broken. Divergence check, because this is where that bug hides: both paths feeding sandbox derivation - install.js's emitter and checkCodexSandboxPosture - now route through the one extractor. The tools readers in runtime-artifact-conversion and install.js's other frontmatter call sites serve Claude-side emission and do not feed sandbox derivation. Also fixed, each real: the posture check's `found` used a naive whole-file regex where its own sibling uses the block-aware scanner, so prose inside developer_instructions produced a false violation; `found` skipped truncatePostureValue and leaked a 300-char value into validate agents output; deriveCodexSandboxMode's absolute never-throws claim was false for an object with a throwing toString; T49 could not falsify cross-phase leakage (its target phase had its own 01, so a globally-scoped map passed too); T20/N6 iterated a hardcoded table and pinned the FIXTURE size, so a 36th agent would be silently unchecked; three tests reimplemented the code they were testing instead of importing it; and T2-T4 deleted GSD_RUNTIME without restoring it. Verified: hold list 17, gsd-nyquist-auditor derives workspace-write unheld and emits read-only held, roster 35/35 at 26/9, depends_on ["auth"] no longer resolves while ["01"] still does, both identity-bypass cases and every confusable vector emit read-only. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3897): the hold list is 17, and the reason the 17th was missing The count read 16 because the derivation could not read the declaration it claimed to derive from: the tools reader was single-line, so a YAML list-form tools: block returned only its first item and gsd-nyquist-auditor's declared Write and Edit were read as an absence. Both the ADR entry and the feature fragment now carry the corrected count and the reason for it, rather than a silently updated number. Deriving from a declaration you cannot parse is not deriving, and a flattering count is worse than a wrong one because it looks settled. docs/FEATURES.md regenerated from the fragment. Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3897): backfill changeset pr number Refs #3897 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/daring-jays-leap.md
Normal file
5
.changeset/daring-jays-leap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3941
|
||||
---
|
||||
**Non-Claude installs now resolve their own runtime by default, and `depends_on` accepts the bare plan number.** A Codex, Cursor, or other non-Claude install with no `GSD_RUNTIME` set and no `config.runtime` key previously still reported `claude` everywhere, because the per-install runtime marker the installer writes was read by four hand-rolled copies but never by `resolveRuntime` itself; it is now the third precedence rung. Separately, `depends_on: ["01"]` now resolves to the in-phase sibling plan instead of silently dropping the dependency and collapsing the plan into wave 1 — a phase that previously ran all its plans in a single wave now executes in its declared waves. Codex sandbox permissions are also now derived from each agent's own tool contract instead of a hand-maintained map, and `validate agents` reports any drift via a new `sandbox_posture` field; both are byte-identical to today's behavior. (#3897)
|
||||
125
bin/install.js
125
bin/install.js
@@ -460,19 +460,32 @@ function resolveSharedHooksDirName(runtime) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const CODEX_AGENT_SANDBOX = {
|
||||
'gsd-executor': 'workspace-write',
|
||||
'gsd-planner': 'workspace-write',
|
||||
'gsd-phase-researcher': 'workspace-write',
|
||||
'gsd-project-researcher': 'workspace-write',
|
||||
'gsd-research-synthesizer': 'workspace-write',
|
||||
'gsd-verifier': 'workspace-write',
|
||||
'gsd-codebase-mapper': 'workspace-write',
|
||||
'gsd-roadmapper': 'workspace-write',
|
||||
'gsd-debugger': 'workspace-write',
|
||||
'gsd-plan-checker': 'read-only',
|
||||
'gsd-integration-checker': 'read-only',
|
||||
};
|
||||
// #3897 rung 3 — sandbox_mode derivation, the hold list, and the hold-roster
|
||||
// validator now live in `src/codex-agent-toml.cts` (compiled to
|
||||
// `gsd-core/bin/lib/codex-agent-toml.cjs`), NOT here. This module used to be
|
||||
// the sole owner, and `agent-install-check.cts`'s `checkCodexSandboxPosture`
|
||||
// lazily `require()`d THIS FILE to reach `deriveCodexSandboxMode` — but
|
||||
// requiring `bin/install.js` runs its whole top-level script, including the
|
||||
// CLI's ASCII banner print to stdout, which corrupted every stdout-JSON
|
||||
// caller downstream of that posture check (`gsd-tools validate agents`).
|
||||
// `codex-agent-toml.cjs` is a genuine leaf (no top-level side effects), so
|
||||
// both this file and `agent-install-check.cts` import the derivation from
|
||||
// there — ONE owner, no second predicate. See that module's header for the
|
||||
// full rationale, and CAUSE B (below, `installCodexConfig`) for the removal
|
||||
// of `validateCodexSandboxHolds`'s call from the install runtime path.
|
||||
const {
|
||||
CODEX_SANDBOX_HOLDS,
|
||||
deriveCodexSandboxMode,
|
||||
validateCodexSandboxHolds,
|
||||
// #3897 list-form parse fix, Fix 3 (generative-fix-divergence): this
|
||||
// file's own `generateCodexAgentToml` used to pull `tools:` via its
|
||||
// private `extractFrontmatterField` (single-line only) instead of this
|
||||
// shared reader — the two sandbox-feeding paths (this emitter and
|
||||
// `agent-install-check.cts`'s `checkCodexSandboxPosture`) silently
|
||||
// disagreed on YAML block-list `tools:` form. Both now route through this
|
||||
// ONE extractor.
|
||||
extractToolsValue,
|
||||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'codex-agent-toml.cjs'));
|
||||
|
||||
// Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
|
||||
// Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
|
||||
@@ -4010,10 +4023,39 @@ function _resetCodexWarningDedupeForTests() {
|
||||
* @param {object|null} effortCfg — #443: merged effort config from readGsdEffectiveEffortConfig
|
||||
*/
|
||||
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
|
||||
const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
|
||||
const frontmatterText = frontmatter || '';
|
||||
// #3897 list-form parse fix, Fix 3: `toolsRaw` MUST come from the same
|
||||
// shared `extractToolsValue` reader `checkCodexSandboxPosture` uses, not
|
||||
// this file's own `extractFrontmatterField` — the two used to disagree on
|
||||
// YAML block-list `tools:` form (`extractFrontmatterField`'s single-line
|
||||
// regex read only the first list item), which is exactly the generative-
|
||||
// fix-divergence shape CLAUDE.md warns about for two paths feeding one
|
||||
// derivation. `extractToolsValue` does its own `---`-delimited frontmatter
|
||||
// scan of the full `agentContent`, so it is not re-derived from
|
||||
// `frontmatterText` here.
|
||||
const toolsRaw = extractToolsValue(agentContent) ?? '';
|
||||
const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
|
||||
// #3897 rung 3 — derived from the role's own tool contract (HALT.md option
|
||||
// 2). The former hand-maintained CODEX_AGENT_SANDBOX map is deleted (ADR-3473
|
||||
// §8.3): it was fully redundant with this derivation, zero disagreements
|
||||
// across all 11 entries. Never a silent `|| 'read-only'` fallback either.
|
||||
// Derivation itself lives in codex-agent-toml.cjs, which does no frontmatter
|
||||
// parsing of its own (no third copy of that extraction) — it takes the
|
||||
// already-resolved `tools:` value, extracted via the shared reader above.
|
||||
//
|
||||
// #3897 security review F1 (blocker): pass BOTH candidate identities —
|
||||
// `agentName` (the caller's filename-stem identity) AND `resolvedName`
|
||||
// (the frontmatter `name:` this function's OWN emitted `name = ...` line
|
||||
// uses, and what `installCodexConfig`'s caller keys the output PATH on) —
|
||||
// never just one. Deciding the sandbox for `agentName` alone and applying
|
||||
// it to an artifact that a DIFFERENT identity (`resolvedName`) names is
|
||||
// exactly how a held role's own `.toml` could end up `workspace-write`
|
||||
// (rename the source file, or plant a sibling whose `name:` collides with
|
||||
// a held role). `deriveCodexSandboxMode` takes the most restrictive result
|
||||
// across every candidate — see its doc and `isSandboxHeld` in
|
||||
// codex-agent-toml.cjs.
|
||||
const sandboxMode = deriveCodexSandboxMode([agentName, resolvedName], toolsRaw);
|
||||
const resolvedDescription = toSingleLine(
|
||||
extractFrontmatterField(frontmatterText, 'description') || `GSD agent ${resolvedName}`
|
||||
);
|
||||
@@ -6925,6 +6967,36 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
|
||||
}
|
||||
fs.mkdirSync(agentsTomlDir, { recursive: true });
|
||||
|
||||
// #3897 rung 3 (CAUSE B fix) — validateCodexSandboxHolds is deliberately
|
||||
// NOT called here. The "no stale holds" invariant is a REPO invariant about
|
||||
// the canonical roster in `agents/`, not a property of whatever directory
|
||||
// an install happens to read from: a partial or synthetic `agentsSrc` (a
|
||||
// test fixture, a `--config-dir` subset) legitimately contains only a few
|
||||
// agents, and a held role simply absent from THIS source dir must be
|
||||
// inert, not fatal. Throwing here also masked unrelated failures further
|
||||
// down this same loop (e.g. the name-injection/path-escape guard on
|
||||
// `agentTomlPath` below), since this check ran first and unconditionally.
|
||||
// The invariant is still enforced — as a test over the real `agents/`
|
||||
// roster (tests/codex-config.test.cjs T24/T25) — just never on this
|
||||
// runtime path. See src/codex-agent-toml.cts's `validateCodexSandboxHolds`
|
||||
// docblock for the full rationale.
|
||||
//
|
||||
// #3897 security review F2 (assessed post-F1-fix, verified by execution —
|
||||
// not asserted): before F1's fix, the "runtime detector removed" gap here
|
||||
// was real — a rename (case a) or a sibling `name:` clobber (case b) could
|
||||
// reach this loop and land a held role's `.toml` at `workspace-write` with
|
||||
// nothing here to catch it. After F1 (`deriveCodexSandboxMode` now decides
|
||||
// over BOTH the filename stem and the resolved frontmatter `name:`, most
|
||||
// restrictive wins), both cases were re-run end-to-end through this exact
|
||||
// function and the EMITTED ARTIFACT for both is `read-only` — the
|
||||
// dangerous condition no longer produces a wrong artifact, it produces the
|
||||
// SAFE one. A detector guarding a now-fail-safe condition is not
|
||||
// load-bearing, and restoring a throw here would re-break the legitimate
|
||||
// partial-source-dir case CAUSE B removed it for (see above). Regression
|
||||
// coverage for both cases lives in `tests/codex-config.test.cjs` (F1(a)
|
||||
// rename / F1(b) sibling-clobber rows), asserted on the emitted `.toml`'s
|
||||
// `sandbox_mode`, not on the derivation's return value.
|
||||
|
||||
const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
const agents = [];
|
||||
|
||||
@@ -6953,7 +7025,20 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
|
||||
// CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
|
||||
content = convertClaudeToCodexMarkdown(content);
|
||||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
|
||||
// #3897 security review F1 (blocker, post-merge): this loop used to key
|
||||
// the sandbox/hold decision off ONLY the filename stem while the emitted
|
||||
// `.toml`'s OUTPUT PATH below is keyed off `name` (frontmatter-derived,
|
||||
// attacker-editable) — so a renamed source file, or a sibling file whose
|
||||
// `name:` collides with a held role, could make the decided identity and
|
||||
// the landed artifact disagree, widening a held role's own file to
|
||||
// `workspace-write`. `generateCodexAgentToml` (below) now derives
|
||||
// `sandbox_mode` over BOTH the filename stem it is given AND the
|
||||
// frontmatter `name:` it resolves internally, taking the most
|
||||
// restrictive result — this loop no longer needs to choose one identity
|
||||
// for that call; see `deriveCodexSandboxMode`'s doc in
|
||||
// `codex-agent-toml.cts` for the resolution.
|
||||
const fileStem = file.replace(/\.md$/, '');
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || fileStem;
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
agents.push({ name, description: toSingleLine(description) });
|
||||
@@ -6975,7 +7060,10 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
|
||||
// #443 — pass unified effort config so model_reasoning_effort in the .toml
|
||||
// follows the same config-driven precedence as the Claude .md effort key.
|
||||
const effortCfg = readGsdEffectiveEffortConfig(targetDir);
|
||||
const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
|
||||
// Pass `fileStem`; `generateCodexAgentToml` itself additionally resolves
|
||||
// and folds in the frontmatter `name:` for the sandbox decision (F1
|
||||
// above) — this call site does not need to pass `name` explicitly.
|
||||
const tomlContent = generateCodexAgentToml(fileStem, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
|
||||
// Confine the per-agent write to the agents/ dir itself: a crafted agent
|
||||
// `name` containing path separators must not escape agents/ (which would let
|
||||
// it clobber config.toml or write elsewhere under the configHome).
|
||||
@@ -13712,7 +13800,10 @@ module.exports = {
|
||||
GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
|
||||
GSD_CLAUDE_DENY_PERMISSIONS,
|
||||
GSD_CODEX_MARKER,
|
||||
CODEX_AGENT_SANDBOX,
|
||||
// #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
|
||||
CODEX_SANDBOX_HOLDS,
|
||||
deriveCodexSandboxMode,
|
||||
validateCodexSandboxHolds,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
resolveKiloConfigPath,
|
||||
|
||||
@@ -464,7 +464,7 @@ node gsd-tools.cjs capability set code-review --gate workflow.code_review=false
|
||||
node gsd-tools.cjs query teams-status [--active]
|
||||
```
|
||||
|
||||
Read-only detector for claude-code's experimental agent-teams feature (issue #1355). Resolves the runtime via the canonical `GSD_RUNTIME` → `config.runtime` → `'claude'` precedence, then checks `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`.
|
||||
Read-only detector for claude-code's experimental agent-teams feature (issue #1355). Resolves the runtime via the canonical `GSD_RUNTIME` → `config.runtime` → per-install runtime marker → `'claude'` precedence (#3897), then checks `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`.
|
||||
|
||||
**Default (no flags):** prints a JSON object and exits 0:
|
||||
|
||||
|
||||
@@ -2001,10 +2001,10 @@ Only the targeted lines are removed. Line endings, BOM, key order, comments, bla
|
||||
|
||||
### `validate agents`
|
||||
|
||||
Check that the GSD agents are installed for the active runtime — and, on Codex, that the installed `.toml` files satisfy the passive model posture.
|
||||
Check that the GSD agents are installed for the active runtime — and, on Codex, that the installed `.toml` files satisfy the passive model posture and the derived sandbox posture.
|
||||
|
||||
**Prerequisites:** GSD installed for a runtime
|
||||
**Produces:** Installed / missing / incomplete agent lists, plus a `codex_posture` report
|
||||
**Produces:** Installed / missing / incomplete agent lists, plus `codex_posture` and `sandbox_posture` reports
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs validate agents
|
||||
@@ -2020,6 +2020,17 @@ node gsd-tools.cjs validate agents
|
||||
|
||||
Presence and posture are separate verdicts: a missing agent is reported in `missing`, not as a posture violation. See [ADR-2313](adr/2313-codex-passive-model-posture.md) for the posture itself, and [How to recover and troubleshoot](how-to/recover-and-troubleshoot.md#if-codex-agents-fail-to-spawn-with-a-400-about-an-unsupported-model) for the symptom-led walkthrough.
|
||||
|
||||
**`sandbox_posture` (#3897, ADR-3473 §8.3)** is the sibling check for `sandbox_mode`: each installed Codex `.toml`'s `sandbox_mode` is compared against the value its role's own `tools:` frontmatter derives (`Write`/`Edit` declared → `workspace-write`, else `read-only`). Same shape and short-circuits as `codex_posture` — populated only on `codex`, `not_codex` elsewhere, read-only, and it never makes `validate agents` exit non-zero on its own; both posture checks are report-only fields for a human or caller to act on.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ok` | `true` when no installed `.toml`'s `sandbox_mode` disagrees with its derived expectation |
|
||||
| `violations[]` | `{agent, file, expected, found}` for a drift, or `{agent, file, reason: "unreadable"}` when the file could not be read |
|
||||
| `checked[]` | Every `.toml` inspected |
|
||||
| `reason` | `not_codex` (active runtime isn't Codex) or `agents_dir_missing` — mirrors `codex_posture`'s short-circuit reasons |
|
||||
|
||||
A custom or non-roster agent (no matching file under the bundled `agents/*.md` source) has nothing to derive an expectation from and is silently excluded from `violations` — this check only asserts against the shipped roster's own declared tool contract.
|
||||
|
||||
---
|
||||
|
||||
### `state update <field> <value>`
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
- [Runtime Identity](#168-runtime-identity)
|
||||
- ["Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract](#3884-failure-is-a-value--strict-argv-rejection-and-the---pick-absence-contract)
|
||||
- [No Silent Swallow, No Verdict From Dropped Data](#3885-no-silent-swallow-no-verdict-from-dropped-data)
|
||||
- [Runtime Marker Resolution, Derived Codex Sandbox, and In-Phase Short-Form Dependencies](#3897-runtime-marker-resolution-derived-codex-sandbox-and-in-phase-short-form-dependencies)
|
||||
|
||||
---
|
||||
|
||||
@@ -3824,6 +3825,94 @@ errno rather than folding it into a retry.
|
||||
|
||||
---
|
||||
|
||||
### 3897. Runtime Marker Resolution, Derived Codex Sandbox, and In-Phase Short-Form Dependencies
|
||||
|
||||
**Purpose:** ADR-3473 §8.3 states the rule directly — one implementation per
|
||||
invariant, not a hand-maintained copy that quietly drifts from the rule it
|
||||
stands in for. This closes three instances across `gsd-tools`: a resolver
|
||||
that never read the install-time signal it was documented to read, a
|
||||
Codex sandbox map that was fully redundant with the tool contract it stood
|
||||
in for, and a dependency-resolution tier lost when the SDK lineage was
|
||||
retired.
|
||||
|
||||
**A non-Claude install now resolves its own runtime with no config
|
||||
needed (#3897).** `resolveRuntime`'s ladder was `GSD_RUNTIME` env var →
|
||||
project `config.runtime` → `'claude'` — the per-install `.gsd-runtime`
|
||||
marker the installer has written beside `VERSION` since #2297 was read by
|
||||
four separate hand-rolled copies (`model-resolver.cts`, and two more inside
|
||||
`gsd-cursor-subagent-start.js`), but never by `resolveRuntime` itself. A
|
||||
Codex, Cursor, or other non-Claude install with no `GSD_RUNTIME` set and no
|
||||
`runtime` key in `.planning/config.json` therefore still resolved `claude`
|
||||
everywhere `resolveRuntime` is consulted (slash-command style, `query
|
||||
teams-status`, `validate agents`'s agent-directory selection, and 19 other
|
||||
call sites). The marker is now the third rung — `GSD_RUNTIME` → `config.runtime`
|
||||
→ install marker → `'claude'` — so those installs resolve their own runtime
|
||||
by default. The marker's contents are never trusted verbatim: they are routed
|
||||
through the same name-normalization the env rung already uses, so a marker
|
||||
holding an unexpected or hostile value degrades exactly like an unexpected
|
||||
`GSD_RUNTIME` value would.
|
||||
|
||||
**Codex sandbox permissions are derived from each agent's own tool contract,
|
||||
not a hand-maintained map (#3897).** `generateCodexAgentToml` looked up
|
||||
`sandbox_mode` in an 11-entry `CODEX_AGENT_SANDBOX` map, falling back to
|
||||
`read-only` — silently — for every role the map didn't name. Measured against
|
||||
all 35 shipped roles, the map's 11 entries agree with deriving `sandbox_mode`
|
||||
from each role's declared `tools:` frontmatter (`workspace-write` when it
|
||||
declares `Write` or `Edit`, `read-only` otherwise) with **zero disagreements**,
|
||||
so the map is deleted rather than clamped. The fallback, however, was
|
||||
under-granting: 16 of the 24 roles that hit it declare `Write`/`Edit` and
|
||||
would derive `workspace-write`. Pending a decision on whether Codex actually
|
||||
enforces `sandbox_mode` (a question the derivation can't answer on its own),
|
||||
those 16 are held at `read-only` by an explicit, self-invalidating hold
|
||||
list — a hold whose role no longer derives broader, or that names a role
|
||||
that no longer exists, fails loudly instead of being silently honored.
|
||||
**Every one of the 35 emitted `.toml` files is byte-identical to before this
|
||||
change** — the fix is in provenance (an explicit, reviewable rule instead of
|
||||
a silent default), not in any installed agent's actual permissions today.
|
||||
|
||||
**`validate agents` now reports Codex sandbox drift (#3897).** A new
|
||||
`sandbox_posture` field — report-only, exit 0, same shape as the existing
|
||||
`codex_posture` — flags any installed Codex `.toml` whose `sandbox_mode`
|
||||
disagrees with what its role's tool contract derives. Populated only when
|
||||
the active runtime is `codex`.
|
||||
|
||||
**`depends_on` accepts the bare plan number (#3897).** A plan's frontmatter
|
||||
could already reference a dependency by its full id (`"03-01-auth-hardening"`)
|
||||
or its canonical phase-plan prefix (`"03-01"`). A third form — the bare
|
||||
plan number alone (`"01"`) — existed in the retired SDK lineage but was lost
|
||||
when that lineage was consolidated; a plan written with it silently dropped
|
||||
the edge entirely, collapsing into wave 1 regardless of its declared
|
||||
dependency. That form is restored, scoped to the **same phase only**: `"01"`
|
||||
resolves to the sibling plan whose canonical id ends `-01`. This is an
|
||||
observable behavior change — a phase whose plans used the bare form and had
|
||||
silently collapsed into a single wave will now execute in its actual declared
|
||||
waves. Two plans in the same phase sharing a bare form resolve first-write-wins,
|
||||
by sorted plan-file order — deterministic, but arbitrary where the collision
|
||||
happens, matching the retired behavior exactly.
|
||||
|
||||
**Known limits:**
|
||||
- The 17 held Codex roles are pinned at `read-only`, not widened. A faithful
|
||||
derivation from the tool contract would widen them, because they declare
|
||||
`Write` or `Edit`; the previous hand-maintained map never listed them and
|
||||
they fell through a silent `|| 'read-only'` default instead. Deriving *and*
|
||||
holding keeps emitted TOML byte-identical for all 35 roles today while the
|
||||
derivation becomes the single owner of the rule. Widening them is a follow-up
|
||||
once Codex's actual enforcement of `sandbox_mode` is confirmed — until then a
|
||||
hold is reversible and a widened sandbox is not. The hold list is
|
||||
self-invalidating: an entry naming a role that no longer derives broader, or
|
||||
that has no file in the shipped roster, fails rather than rotting into the
|
||||
subset map this change deletes.
|
||||
- The bare plan-number form is ambiguous by construction across two plans in
|
||||
the same phase that share a short form; first-write-wins is deterministic
|
||||
but not a conflict warning. Prefer the full or canonical id when a phase's
|
||||
plan numbering risks a short-form collision.
|
||||
- The install marker never feeds model-tier resolution (`model_profile_overrides`,
|
||||
`model_policy.runtime_tiers`) — that still reads `config.runtime` alone, and
|
||||
reporting-only host detection (`agent_runtime`) is a separate, pre-existing
|
||||
ladder this change does not touch.
|
||||
|
||||
---
|
||||
|
||||
_Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._
|
||||
|
||||
<!-- FEATURES:END -->
|
||||
|
||||
@@ -334,6 +334,16 @@ Both close #3349 and #3360, which are **read-side** defects a real parser fixes
|
||||
> **The guard roster names no §8.3 casualty.** Its only §8.3-tagged row is `local/no-adhoc-regex-escape`, marked *widened*, not retired. Nothing is retired by this rule.
|
||||
>
|
||||
> **What this means for the phase:** unlike §8.2, this section is genuinely unbuilt — the rewrites it asserts have not happened. It is a work list, not a conformance check, and it should be read that way.
|
||||
>
|
||||
> **ANSWER, 2026-08-27 (#3897, carrying Phase 6's remaining rungs) — rungs 2–4 built; re-measured, not inherited.**
|
||||
>
|
||||
> | As corrected above | Now measured |
|
||||
> |---|---|
|
||||
> | "`resolveRuntime` reads no marker at all… PR #3382 … CLOSED and unmerged" | **Fixed.** `resolveRuntime` (`src/runtime-slash.cts`) now resolves `GSD_RUNTIME > config.runtime > install marker > 'claude'`. The four hand-rolled `readInstallRuntimeMarker` copies (`model-resolver.cts` + two in `gsd-cursor-subagent-start.js` + one in `gsd-agent-isolation-guard.js`) are consolidated into one module-level-cached reader owned by `runtime-slash.cts`; the marker's raw contents are routed through the same `resolveRuntimeNameFromCandidates` normalization the env rung uses, never trusted verbatim. Mined from #3382 per this ADR's own direction, not re-derived. |
|
||||
> | "`generateCodexAgentToml` still reads `CODEX_AGENT_SANDBOX[agentName] \|\| 'read-only'`… `checkAgentsInstalled` has no `sandbox_mode` assertion" | **Fixed, with a qualification.** `CODEX_AGENT_SANDBOX` (11 entries) and the silent `\|\| 'read-only'` fallback are deleted; `sandbox_mode` now derives from each role's declared `tools:` frontmatter (`Write`/`Edit` present → `workspace-write`, else `read-only`). Emitted TOML is byte-identical for all 35 roles today: 17 roles that would derive broader are held at `read-only` behind an explicit, self-invalidating hold list (`CODEX_SANDBOX_HOLDS`) pending a decision on whether Codex enforces `sandbox_mode`. The 17th was surfaced only by review: the tools reader was single-line, so a YAML list-form `tools:` block returned just its first item and `gsd-nyquist-auditor`'s declared `Write`/`Edit` were read as an absence. **Deriving from a declaration you cannot parse is not deriving** — the reader now handles both shapes, and the role joined the hold list rather than the count being left flattering. **That conflict was surfaced rather than resolved silently:** §8.3's own criterion asks both that the sandbox *derive from the declared tool contract* and that *no role gain a broader sandbox*, and those cannot both hold — a faithful derivation widens 17 roles that the deleted map never listed and that fell through its silent `|| 'read-only'` default. The resolution taken is derive-and-hold: the derivation becomes the single owner of the rule now, and each hold is released as its role's enforcement question is answered. A hold is reversible; a widened sandbox that turns out to be enforced is not. `checkAgentsInstalled` (`src/agent-install-check.cts`) still checks presence only; the semantic assertion is a sibling function, `checkCodexSandboxPosture`, wired into `validate agents`'s `sandbox_posture` field — report-only, exit 0, matching the pre-existing `codex_posture` precedent, not folded into `checkAgentsInstalled` itself. |
|
||||
> | "The `shortFormToId` rule below is accurate — no such tier exists on `next`" | **Now built.** `resolveDependencyId` (`src/phase.cts`) gained the third, in-phase-only tier recovered verbatim from the retired SDK lineage (`sdk/src/query/phase.ts` at `11918dcc3^`): `depends_on: ["01"]` resolves to the sibling plan whose canonical id ends `-01`, first-write-wins on a same-phase collision. The display mapping at `phase.cts:961` stays passthrough by design (#3785) — this tier is not routed through it. |
|
||||
>
|
||||
> The guard-roster line above is unaffected: no lint guard is retired by this rung, only the hand-maintained map and the four marker-reader copies.
|
||||
|
||||
**Rule — consolidation carries invariants forward explicitly.** A lineage consolidation may not delete an invariant along with the surface that held it. The `shortFormToId` tier existed in the retired SDK lineage; the surviving lineage never received it, the gap was recorded only in an archived changeset and a `// KNOWN GAP:` comment, and both went away with the surface (#3427). **A parity note in an archived changeset is not a tracking mechanism.**
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: 3897
|
||||
title: Runtime Marker Resolution, Derived Codex Sandbox, and In-Phase Short-Form Dependencies
|
||||
group: v1.7.0 Features
|
||||
---
|
||||
|
||||
**Purpose:** ADR-3473 §8.3 states the rule directly — one implementation per
|
||||
invariant, not a hand-maintained copy that quietly drifts from the rule it
|
||||
stands in for. This closes three instances across `gsd-tools`: a resolver
|
||||
that never read the install-time signal it was documented to read, a
|
||||
Codex sandbox map that was fully redundant with the tool contract it stood
|
||||
in for, and a dependency-resolution tier lost when the SDK lineage was
|
||||
retired.
|
||||
|
||||
**A non-Claude install now resolves its own runtime with no config
|
||||
needed (#3897).** `resolveRuntime`'s ladder was `GSD_RUNTIME` env var →
|
||||
project `config.runtime` → `'claude'` — the per-install `.gsd-runtime`
|
||||
marker the installer has written beside `VERSION` since #2297 was read by
|
||||
four separate hand-rolled copies (`model-resolver.cts`, and two more inside
|
||||
`gsd-cursor-subagent-start.js`), but never by `resolveRuntime` itself. A
|
||||
Codex, Cursor, or other non-Claude install with no `GSD_RUNTIME` set and no
|
||||
`runtime` key in `.planning/config.json` therefore still resolved `claude`
|
||||
everywhere `resolveRuntime` is consulted (slash-command style, `query
|
||||
teams-status`, `validate agents`'s agent-directory selection, and 19 other
|
||||
call sites). The marker is now the third rung — `GSD_RUNTIME` → `config.runtime`
|
||||
→ install marker → `'claude'` — so those installs resolve their own runtime
|
||||
by default. The marker's contents are never trusted verbatim: they are routed
|
||||
through the same name-normalization the env rung already uses, so a marker
|
||||
holding an unexpected or hostile value degrades exactly like an unexpected
|
||||
`GSD_RUNTIME` value would.
|
||||
|
||||
**Codex sandbox permissions are derived from each agent's own tool contract,
|
||||
not a hand-maintained map (#3897).** `generateCodexAgentToml` looked up
|
||||
`sandbox_mode` in an 11-entry `CODEX_AGENT_SANDBOX` map, falling back to
|
||||
`read-only` — silently — for every role the map didn't name. Measured against
|
||||
all 35 shipped roles, the map's 11 entries agree with deriving `sandbox_mode`
|
||||
from each role's declared `tools:` frontmatter (`workspace-write` when it
|
||||
declares `Write` or `Edit`, `read-only` otherwise) with **zero disagreements**,
|
||||
so the map is deleted rather than clamped. The fallback, however, was
|
||||
under-granting: 16 of the 24 roles that hit it declare `Write`/`Edit` and
|
||||
would derive `workspace-write`. Pending a decision on whether Codex actually
|
||||
enforces `sandbox_mode` (a question the derivation can't answer on its own),
|
||||
those 16 are held at `read-only` by an explicit, self-invalidating hold
|
||||
list — a hold whose role no longer derives broader, or that names a role
|
||||
that no longer exists, fails loudly instead of being silently honored.
|
||||
**Every one of the 35 emitted `.toml` files is byte-identical to before this
|
||||
change** — the fix is in provenance (an explicit, reviewable rule instead of
|
||||
a silent default), not in any installed agent's actual permissions today.
|
||||
|
||||
**`validate agents` now reports Codex sandbox drift (#3897).** A new
|
||||
`sandbox_posture` field — report-only, exit 0, same shape as the existing
|
||||
`codex_posture` — flags any installed Codex `.toml` whose `sandbox_mode`
|
||||
disagrees with what its role's tool contract derives. Populated only when
|
||||
the active runtime is `codex`.
|
||||
|
||||
**`depends_on` accepts the bare plan number (#3897).** A plan's frontmatter
|
||||
could already reference a dependency by its full id (`"03-01-auth-hardening"`)
|
||||
or its canonical phase-plan prefix (`"03-01"`). A third form — the bare
|
||||
plan number alone (`"01"`) — existed in the retired SDK lineage but was lost
|
||||
when that lineage was consolidated; a plan written with it silently dropped
|
||||
the edge entirely, collapsing into wave 1 regardless of its declared
|
||||
dependency. That form is restored, scoped to the **same phase only**: `"01"`
|
||||
resolves to the sibling plan whose canonical id ends `-01`. This is an
|
||||
observable behavior change — a phase whose plans used the bare form and had
|
||||
silently collapsed into a single wave will now execute in its actual declared
|
||||
waves. Two plans in the same phase sharing a bare form resolve first-write-wins,
|
||||
by sorted plan-file order — deterministic, but arbitrary where the collision
|
||||
happens, matching the retired behavior exactly.
|
||||
|
||||
**Known limits:**
|
||||
- The 17 held Codex roles are pinned at `read-only`, not widened. A faithful
|
||||
derivation from the tool contract would widen them, because they declare
|
||||
`Write` or `Edit`; the previous hand-maintained map never listed them and
|
||||
they fell through a silent `|| 'read-only'` default instead. Deriving *and*
|
||||
holding keeps emitted TOML byte-identical for all 35 roles today while the
|
||||
derivation becomes the single owner of the rule. Widening them is a follow-up
|
||||
once Codex's actual enforcement of `sandbox_mode` is confirmed — until then a
|
||||
hold is reversible and a widened sandbox is not. The hold list is
|
||||
self-invalidating: an entry naming a role that no longer derives broader, or
|
||||
that has no file in the shipped roster, fails rather than rotting into the
|
||||
subset map this change deletes.
|
||||
- The bare plan-number form is ambiguous by construction across two plans in
|
||||
the same phase that share a short form; first-write-wins is deterministic
|
||||
but not a conflict warning. Prefer the full or canonical id when a phase's
|
||||
plan numbering risks a short-form collision.
|
||||
- The install marker never feeds model-tier resolution (`model_profile_overrides`,
|
||||
`model_policy.runtime_tiers`) — that still reads `config.runtime` alone, and
|
||||
reporting-only host detection (`agent_runtime`) is a separate, pre-existing
|
||||
ladder this change does not touch.
|
||||
@@ -69,7 +69,7 @@ must_haves:
|
||||
| `plan` | Yes | string | Plan number within the phase, e.g. `02`. |
|
||||
| `type` | Yes | `execute` or `tdd` | `execute` for standard plans; `tdd` for test-driven plans where tests are written before implementation. |
|
||||
| `wave` | Yes | integer | Execution wave. Plans in wave 1 run in parallel (no dependencies). Plans in wave 2+ wait for all plans in the previous wave to complete. Pre-computed at plan time by `gsd-planner`. |
|
||||
| `depends_on` | Yes | array of plan IDs | Plans this plan must wait for. Empty array = wave 1. Example: `["03-01"]` means this plan runs after Plan 01 in Phase 3. |
|
||||
| `depends_on` | Yes | array of plan IDs | Plans this plan must wait for. Empty array = wave 1. Accepts three forms, resolved in order: the full plan id (`"03-01-auth-hardening"`), the canonical phase-plan prefix (`"03-01"`), or the bare plan number (`"01"`, #3897) — which resolves to the sibling plan in the **same phase** whose canonical id ends `-01`. The bare form is in-phase only; it never resolves across phases. If two plans in the same phase share a bare form, the first one (by sorted plan-file order) wins — deterministic, but arbitrary when the collision happens, so prefer the full or canonical form when phase has any short-form collision risk. Example: `["03-01"]` means this plan runs after Plan 01 in Phase 3; from within Phase 3 itself, `["01"]` means the same thing. |
|
||||
| `files_modified` | Yes | array of paths | Every file this plan creates or modifies. Used by the plan-checker to detect same-wave file conflicts and by execute-phase for merge tracking. |
|
||||
| `files_deleted` | No | array of paths | Every file this plan deliberately **removes**. The post-wave cleanup gauntlet blocks the merge of any executor branch whose diff deletes a file — a net against a mass-deletion accident — and this field is the opt-in that names the exceptions. Matching is exact per path after separator normalization: a declared path merges, an undeclared one still blocks that plan's entry (and only that entry). There are no globs and no directory prefixes, so a declaration can never authorize more than it literally lists. Omit the field and the guard's original unconditional block stays in force, which is why absence is always the safe default (#3003). Counts toward same-wave conflict detection alongside `files_modified`: a plan deleting a file another plan in the same wave is editing is the sharpest conflict there is — one branch removes what the other is writing — so the two plans are pushed into different waves regardless of which side holds the deletion. |
|
||||
| `autonomous` | Yes | boolean | `true` when all tasks are type `auto`. `false` when the plan contains any `checkpoint:*` task that requires human interaction. |
|
||||
|
||||
@@ -95,40 +95,40 @@ function parseHarnessFlag(flag) {
|
||||
return { param: m[1], value: m[2] };
|
||||
}
|
||||
|
||||
// ─── #3566: per-install runtime marker ────────────────────────────────────────
|
||||
// ─── #3897 rung 2: per-install runtime marker, single canonical owner ────────
|
||||
// bin/install.js writes `<install>/gsd-core/.gsd-runtime` for EVERY runtime
|
||||
// install (#2297), co-located with VERSION. Unlike `~/.gsd/defaults.json` —
|
||||
// which is host-wide and names whichever runtime's install ran LAST, the exact
|
||||
// leakage #2840's config.cjs change exists to prevent — the marker describes
|
||||
// THIS install, which is the property runtime identity needs on a machine
|
||||
// with 2+ runtimes. Mirrors readInstallRuntimeMarker in src/model-resolver.cts
|
||||
// (same cache + test-seam shape); this hook cannot import that module without
|
||||
// dragging the whole model-resolution stack into a PreToolUse hot path, so the
|
||||
// 5-line read lives here against the same sibling-layout assumption the hook's
|
||||
// own require('../gsd-core/bin/lib/…') already makes. Epic #3473 B3 owns
|
||||
// consolidating every marker reader into one shared seam.
|
||||
let _installMarkerCache; // undefined = unread; null = known absent; string = value
|
||||
|
||||
// with 2+ runtimes. Previously this hook held its own private reader/cache
|
||||
// (one of four #3897 found); it now delegates to the single canonical owner,
|
||||
// `src/runtime-slash.cts` (compiled to gsd-core/bin/lib/runtime-slash.cjs),
|
||||
// reached through `ensureRuntimeBuild()` like every other compiled-lib require
|
||||
// in this file (`scripts/lint-hooks-runtime-build-seam.cjs`).
|
||||
function readInstallRuntimeMarker() {
|
||||
if (_installMarkerCache !== undefined) return _installMarkerCache;
|
||||
try {
|
||||
const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime');
|
||||
const raw = fs.readFileSync(markerPath, 'utf-8').trim();
|
||||
_installMarkerCache = raw || null;
|
||||
ensureRuntimeBuild();
|
||||
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
return runtimeSlash.readInstallRuntimeMarker();
|
||||
} catch {
|
||||
// No marker: dev/source tree, or an install predating #2297 — "no signal
|
||||
// from this rung", never a resolution failure. Falls through to the
|
||||
// defaults rung below.
|
||||
_installMarkerCache = null;
|
||||
// Unbuilt runtime library, or any other failure reaching the canonical
|
||||
// owner — "no signal from this rung" (N4), never a resolution failure.
|
||||
return null;
|
||||
}
|
||||
return _installMarkerCache;
|
||||
}
|
||||
|
||||
// Test seam for the marker rung (the dev/source tree has no marker file, so
|
||||
// the read always bottoms out at null there — same seam contract as
|
||||
// model-resolver.cts's _setInstallRuntimeMarkerForTests, #2297).
|
||||
// Test seam for the marker rung — forwards to the canonical owner's seam so
|
||||
// this hook and runtime-slash.cjs always share one cache (#3897 rung 2).
|
||||
function _setInstallRuntimeMarkerForTests(value) {
|
||||
_installMarkerCache = value;
|
||||
try {
|
||||
ensureRuntimeBuild();
|
||||
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
runtimeSlash._setInstallRuntimeMarkerForTests(value);
|
||||
} catch {
|
||||
// Test-only seam; an unbuilt library here means the test itself will fail
|
||||
// downstream, which is a louder and more actionable signal than throwing here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -342,35 +342,39 @@ function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSy
|
||||
return { action: 'allow' };
|
||||
}
|
||||
|
||||
// ─── #3566: per-install runtime marker ────────────────────────────────────────
|
||||
// Same contract as hooks/gsd-agent-isolation-guard.js's readInstallRuntimeMarker
|
||||
// (mirroring src/model-resolver.cts #2297): bin/install.js writes
|
||||
// `<install>/gsd-core/.gsd-runtime` beside VERSION for every runtime install;
|
||||
// this hook ships at `<install>/hooks/`, so the marker is the `gsd-core` sibling
|
||||
// of this file's own directory — the same sibling-layout assumption the
|
||||
// require('../gsd-core/bin/lib/…') calls below already make. Epic #3473 B3 owns
|
||||
// consolidating every marker reader into one shared seam.
|
||||
let _installMarkerCache; // undefined = unread; null = known absent; string = value
|
||||
|
||||
// ─── #3897 rung 2: per-install runtime marker, single canonical owner ────────
|
||||
// bin/install.js writes `<install>/gsd-core/.gsd-runtime` beside VERSION for
|
||||
// every runtime install (#2297); this hook ships at `<install>/hooks/`, so the
|
||||
// marker is the `gsd-core` sibling of this file's own directory. Previously
|
||||
// this hook held its own private reader/cache (one of four #3897 found); it
|
||||
// now delegates to the single canonical owner, `src/runtime-slash.cts`
|
||||
// (compiled to gsd-core/bin/lib/runtime-slash.cjs), reached through
|
||||
// `ensureRuntimeBuild()` like the other compiled-lib requires in this file
|
||||
// (`scripts/lint-hooks-runtime-build-seam.cjs`).
|
||||
function readInstallRuntimeMarker() {
|
||||
if (_installMarkerCache !== undefined) return _installMarkerCache;
|
||||
try {
|
||||
const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime');
|
||||
const raw = fs.readFileSync(markerPath, 'utf-8').trim();
|
||||
_installMarkerCache = raw || null;
|
||||
ensureRuntimeBuild();
|
||||
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
return runtimeSlash.readInstallRuntimeMarker();
|
||||
} catch {
|
||||
// No marker: dev/source tree, or an install predating #2297 — "no signal
|
||||
// from this rung", never a resolution failure.
|
||||
_installMarkerCache = null;
|
||||
// Unbuilt runtime library, or any other failure reaching the canonical
|
||||
// owner — "no signal from this rung", never a resolution failure.
|
||||
return null;
|
||||
}
|
||||
return _installMarkerCache;
|
||||
}
|
||||
|
||||
// Test seam — same contract as model-resolver.cts's #2297 seam; the dev/source
|
||||
// tree has no marker file, so spawned-hook tests (fresh process, no marker)
|
||||
// are unaffected.
|
||||
// Test seam — forwards to the canonical owner's seam so this hook and
|
||||
// runtime-slash.cjs always share one cache (#3897 rung 2). Spawned-hook tests
|
||||
// (fresh process, no marker) are unaffected.
|
||||
function _setInstallRuntimeMarkerForTests(value) {
|
||||
_installMarkerCache = value;
|
||||
try {
|
||||
ensureRuntimeBuild();
|
||||
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
runtimeSlash._setInstallRuntimeMarkerForTests(value);
|
||||
} catch {
|
||||
// Test-only seam; an unbuilt library here means the test itself will fail
|
||||
// downstream, which is a louder and more actionable signal than throwing here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,21 @@ import { isAnthropicFlavoredModel } from './model-catalog.cjs';
|
||||
// locally. Behavior is unchanged: scanTomlLines/stripBOM here are the exact
|
||||
// same lenient functions that used to live in this file — see
|
||||
// codex-agent-toml.cts's module header for the reader/writer reconciliation.
|
||||
import { stripBOM, scanTomlLines } from './codex-agent-toml.cjs';
|
||||
// #3897 CAUSE A fix: `deriveCodexSandboxMode` needs the resolved `tools:`
|
||||
// frontmatter VALUE, not raw content — this module used to reach that via
|
||||
// `runtime-artifact-conversion.cjs`'s general-purpose
|
||||
// `extractFrontmatterAndBody`/`extractFrontmatterField`, but that module's
|
||||
// dependency chain (`command-roster.cjs` -> `scripts/fix-slash-commands.cjs`,
|
||||
// a dev-only repo script) does not resolve from an installed tree — any test
|
||||
// exercising a synthetic install dir blew up at module load. `codex-agent-toml.cjs`
|
||||
// is already imported here, is a genuine leaf (node builtins only, zero
|
||||
// side effects), and now exports `extractToolsValue` — a single-purpose
|
||||
// `tools:`-VALUE reader (inline AND YAML block-list form, #3897 list-form
|
||||
// parse fix), not a general frontmatter parser — for exactly this caller,
|
||||
// shared with `bin/install.js`'s emitter so the two sandbox-feeding paths
|
||||
// can never silently disagree, and no fourth copy of frontmatter parsing is
|
||||
// added.
|
||||
import { stripBOM, scanTomlLines, deriveCodexSandboxMode, extractToolsValue } from './codex-agent-toml.cjs';
|
||||
|
||||
interface AgentsInstalledResult {
|
||||
agents_installed: boolean;
|
||||
@@ -69,6 +83,31 @@ interface CodexModelPostureResult {
|
||||
reason?: PostureReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2) — a violation entry for
|
||||
* {@link checkCodexSandboxPosture}. Distinct shape from {@link PostureViolation}:
|
||||
* this check reports a drift between an EXPECTED and a FOUND `sandbox_mode`,
|
||||
* not a single offending value. `reason` is populated only for the UNREADABLE
|
||||
* case (mirrors {@link checkCodexModelPosture}); a drift violation carries
|
||||
* `expected`/`found` instead.
|
||||
*/
|
||||
interface SandboxPostureViolation {
|
||||
agent: string;
|
||||
file: string;
|
||||
reason?: PostureReason;
|
||||
expected?: string;
|
||||
found?: string;
|
||||
}
|
||||
|
||||
interface CodexSandboxPostureResult {
|
||||
ok: boolean;
|
||||
violations: SandboxPostureViolation[];
|
||||
checked: string[];
|
||||
agents_dir: string;
|
||||
agent_runtime: string;
|
||||
reason?: PostureReason;
|
||||
}
|
||||
|
||||
// Matches the value-truncation convention in bin/install.js's
|
||||
// _warnCodexModelOverrideDropped: values over 64 chars are capped so an
|
||||
// oversized or secret-shaped config value can never reach a report in full.
|
||||
@@ -345,6 +384,161 @@ function checkCodexModelPosture(runtime?: string, projectRoot?: string): CodexMo
|
||||
};
|
||||
}
|
||||
|
||||
// #3897 rung 3 — the canonical bundled `agents/*.md` source directory (the
|
||||
// tool-contract source of truth), resolved the same install-relative way
|
||||
// getAgentsDir resolves the claude case, WITHOUT that function's npm-global
|
||||
// node_modules redirect: that redirect exists so a Codex/claude INSTALL is
|
||||
// never validated against itself, but here we deliberately want the bundled
|
||||
// copy every time — it is the only place the `tools:` contract a role
|
||||
// derives its sandbox_mode from actually lives, in every install shape
|
||||
// (repo-dev tree or npm-global package, where the package still ships its
|
||||
// own agents/ alongside bin/ and gsd-core/).
|
||||
function _canonicalAgentSourceDir(): string {
|
||||
return path.join(__dirname, '..', '..', '..', 'agents');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate installed Codex `.toml` agents' `sandbox_mode` against what each
|
||||
* role's own tool contract derives (#3897, ADR-3473 §8.3 criterion 3,
|
||||
* HALT.md option 2). Sibling to {@link checkCodexModelPosture} — same shape,
|
||||
* same short-circuits — but a different defect class: a TOML whose
|
||||
* `sandbox_mode` disagrees with the derived expectation, not a bad `model`.
|
||||
*
|
||||
* `deriveCodexSandboxMode` (and the `CODEX_SANDBOX_HOLDS` hold list + its own
|
||||
* self-invalidation check it embeds) is imported from `codex-agent-toml.cjs`
|
||||
* — the shared, side-effect-free leaf both `bin/install.js`'s emitter and
|
||||
* this posture check import from (CAUSE A fix, #3897: this function used to
|
||||
* lazily `require('bin/install.js')` to reach the same derivation, but
|
||||
* requiring `bin/install.js` runs its whole CLI top-level, including an
|
||||
* ASCII banner print to stdout, which corrupted every stdout-JSON caller of
|
||||
* this posture check, e.g. `gsd-tools validate agents`) — so the emitter and
|
||||
* this posture check can never silently diverge on the same tool-contract
|
||||
* predicate — the exact generative-fix-divergence shape this epic exists to
|
||||
* close. `deriveCodexSandboxMode` itself is total (never throws, #3897
|
||||
* CAUSE C fix) — a held role whose supplied content no longer derives
|
||||
* broader than its pin simply returns `read-only` rather than failing,
|
||||
* because the pin is then a no-op. The staleness invariant (S4/S5) this
|
||||
* check's imports still carry is enforced separately, at the real `agents/`
|
||||
* roster level, by `validateCodexSandboxHolds` and
|
||||
* `tests/codex-config.test.cjs` T24/T25 — not by this per-call derivation.
|
||||
*
|
||||
* Read-only: detects, never repairs — same posture as {@link checkCodexModelPosture}.
|
||||
*
|
||||
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
||||
* @param projectRoot - canonical project root for local-install discovery
|
||||
*/
|
||||
function checkCodexSandboxPosture(runtime?: string, projectRoot?: string): CodexSandboxPostureResult {
|
||||
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
||||
if (resolvedRuntime !== 'codex') {
|
||||
return {
|
||||
ok: true,
|
||||
violations: [],
|
||||
checked: [],
|
||||
agents_dir: '',
|
||||
agent_runtime: resolvedRuntime,
|
||||
reason: POSTURE_REASON.NOT_CODEX,
|
||||
};
|
||||
}
|
||||
|
||||
const agentsDir = getAgentsDir(resolvedRuntime, projectRoot);
|
||||
if (!fs.existsSync(agentsDir)) {
|
||||
return {
|
||||
ok: true,
|
||||
violations: [],
|
||||
checked: [],
|
||||
agents_dir: agentsDir,
|
||||
agent_runtime: resolvedRuntime,
|
||||
reason: POSTURE_REASON.AGENTS_DIR_MISSING,
|
||||
};
|
||||
}
|
||||
|
||||
const canonicalAgentsDir = _canonicalAgentSourceDir();
|
||||
|
||||
// Same symlink guard as checkCodexModelPosture, same reason (never follow
|
||||
// an agents-dir symlink into an arbitrary file's content).
|
||||
const tomlFiles = fs
|
||||
.readdirSync(agentsDir)
|
||||
.filter((entry) => {
|
||||
if (!entry.endsWith('.toml')) return false;
|
||||
try {
|
||||
return fs.lstatSync(path.join(agentsDir, entry)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort();
|
||||
|
||||
const checked: string[] = [];
|
||||
const violations: SandboxPostureViolation[] = [];
|
||||
|
||||
for (const entry of tomlFiles) {
|
||||
const agentName = entry.slice(0, -'.toml'.length);
|
||||
const filePath = path.join(agentsDir, entry);
|
||||
checked.push(agentName);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
violations.push({ agent: agentName, file: filePath, reason: POSTURE_REASON.UNREADABLE });
|
||||
continue;
|
||||
}
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 2): route
|
||||
// through the same block-aware `scanTomlLines` the sibling
|
||||
// `checkCodexModelPosture` already uses, rather than a naive whole-file
|
||||
// regex — a `sandbox_mode = "..."`-shaped line inside the
|
||||
// `developer_instructions` block (prose) must never be read as a live
|
||||
// value. A prior naive `raw.match(/^sandbox_mode.../m)` here produced a
|
||||
// FALSE violation whenever an agent's own prompt prose happened to
|
||||
// contain that shape.
|
||||
const found = scanTomlLines(stripBOM(raw)).sandboxMode;
|
||||
if (found === null) {
|
||||
// No sandbox_mode line at all (e.g. sandboxTier "none") is a deliberate,
|
||||
// documented state elsewhere — not this check's business.
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalFile = path.join(canonicalAgentsDir, `${agentName}.md`);
|
||||
let canonicalContent: string;
|
||||
try {
|
||||
canonicalContent = fs.readFileSync(canonicalFile, 'utf8');
|
||||
} catch {
|
||||
// No canonical role source (a custom/non-roster agent) — nothing to
|
||||
// derive an expectation from, so this is not this check's business.
|
||||
continue;
|
||||
}
|
||||
|
||||
// `canonicalContent` is always a real string here (read via fs.readFileSync
|
||||
// above), so extractToolsValue's `undefined` (non-string input) branch is
|
||||
// unreachable on this path — the `?? ''` is a type-level formality, not a
|
||||
// behavior change (F4, #3897 security review: extractToolsValue is TOTAL).
|
||||
const toolsRaw = extractToolsValue(canonicalContent) ?? '';
|
||||
const expected = deriveCodexSandboxMode(agentName, toolsRaw);
|
||||
if (expected !== found) {
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 3):
|
||||
// truncatePostureValue matches the sibling checkCodexModelPosture's
|
||||
// convention (see its own `value: truncatePostureValue(model)` above)
|
||||
// so an oversized or secret-shaped `sandbox_mode` value can never
|
||||
// reach `validate agents --raw` output at full length.
|
||||
violations.push({
|
||||
agent: agentName,
|
||||
file: filePath,
|
||||
expected: truncatePostureValue(expected),
|
||||
found: truncatePostureValue(found),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: violations.length === 0,
|
||||
violations,
|
||||
checked,
|
||||
agents_dir: agentsDir,
|
||||
agent_runtime: resolvedRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single agents dir for `<name>` across runtime filename variants.
|
||||
* Mirrors {@link checkAgentsInstalled}'s probe (`.md`, `.agent.md`, `.toml`,
|
||||
@@ -410,6 +604,7 @@ export = {
|
||||
getAgentsDir,
|
||||
checkAgentsInstalled,
|
||||
checkCodexModelPosture,
|
||||
checkCodexSandboxPosture,
|
||||
POSTURE_REASON,
|
||||
resolveAgentHint,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
* it imports `stripBOM`/`scanTomlLines` from here and its regression suite
|
||||
* (`tests/agent-install-check.test.cjs`) is the proof.
|
||||
*
|
||||
* #3897 rung 3 amendment: `deriveCodexSandboxMode`/`CODEX_SANDBOX_HOLDS`/
|
||||
* `validateCodexSandboxHolds` also live here now (moved from `bin/install.js`).
|
||||
* That IS a policy (which `sandbox_mode` a role's tool contract derives), a
|
||||
* narrow exception to this module's "document model, not policy" charter above
|
||||
* — made because `bin/install.js` cannot be the shared owner: requiring it for
|
||||
* its side effect on `require()` (the CLI banner print) corrupts every
|
||||
* stdout-JSON caller (`agent-install-check.cts`'s `checkCodexSandboxPosture`).
|
||||
* This module was already the single fs/path-free-parsing home both callers
|
||||
* shared; `fs`/`path` are imported below ONLY for `validateCodexSandboxHolds`'s
|
||||
* roster check — still node builtins only, no third-party or bin/lib dependency.
|
||||
*
|
||||
* ── The reconciliation (40-design.md) ──────────────────────────────────────
|
||||
*
|
||||
* Phase 2's reader and this phase's writer disagree on how to handle an
|
||||
@@ -31,6 +42,9 @@
|
||||
* that could silently drift from each other.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Frozen reason enum for a failed {@link parseCodexAgentToml}. */
|
||||
export const PARSE_REASON = Object.freeze({
|
||||
UNTERMINATED_BLOCK: 'unterminated_block',
|
||||
@@ -156,6 +170,15 @@ export function findDeveloperInstructionsBlockRange(
|
||||
export interface HeaderScanResult {
|
||||
model: string | null;
|
||||
hasReasoningEffort: boolean;
|
||||
/**
|
||||
* #3897 rung 4 (isolated correctness review, MINOR finding 2): the
|
||||
* installed `sandbox_mode` value, block-aware exactly like `model` above —
|
||||
* a `sandbox_mode = "..."`-shaped line inside the `developer_instructions`
|
||||
* block (prose discussing sandbox modes) must never be read as a live
|
||||
* value, same reasoning as the `model` probe this scan already protects.
|
||||
* `null` when no live `sandbox_mode` key is present.
|
||||
*/
|
||||
sandboxMode: string | null;
|
||||
}
|
||||
|
||||
interface HeaderLineInfo {
|
||||
@@ -163,6 +186,7 @@ interface HeaderLineInfo {
|
||||
modelLineIndex: number | null;
|
||||
reasoningEffort: string | null;
|
||||
reasoningEffortLineIndex: number | null;
|
||||
sandboxMode: string | null;
|
||||
}
|
||||
|
||||
// Line-oriented scan of every line OUTSIDE the `developer_instructions` block
|
||||
@@ -175,11 +199,15 @@ interface HeaderLineInfo {
|
||||
// lenient reader, boolean-only for reasoning effort) and parseCodexAgentToml
|
||||
// (the strict writer, which also needs the effort's value and both keys' line
|
||||
// indices so stripModel/stripReasoningEffort can remove exactly one line).
|
||||
// `sandbox_mode` (#3897 rung 4 MINOR finding 2) rides the same block-aware
|
||||
// pass as `model`/`model_reasoning_effort` — one scanner, never a second,
|
||||
// naive whole-file regex that could match prose inside the block.
|
||||
function scanHeaderLines(lines: string[], blockStart: number, blockEnd: number): HeaderLineInfo {
|
||||
let model: string | null = null;
|
||||
let modelLineIndex: number | null = null;
|
||||
let reasoningEffort: string | null = null;
|
||||
let reasoningEffortLineIndex: number | null = null;
|
||||
let sandboxMode: string | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (blockStart !== -1 && i >= blockStart && i <= blockEnd) continue;
|
||||
const trimmed = lines[i].trim();
|
||||
@@ -194,9 +222,11 @@ function scanHeaderLines(lines: string[], blockStart: number, blockEnd: number):
|
||||
} else if (key === 'model_reasoning_effort') {
|
||||
reasoningEffort = unquoteTomlValue(rawValue);
|
||||
reasoningEffortLineIndex = i;
|
||||
} else if (key === 'sandbox_mode') {
|
||||
sandboxMode = unquoteTomlValue(rawValue);
|
||||
}
|
||||
}
|
||||
return { model, modelLineIndex, reasoningEffort, reasoningEffortLineIndex };
|
||||
return { model, modelLineIndex, reasoningEffort, reasoningEffortLineIndex, sandboxMode };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,8 +238,8 @@ function scanHeaderLines(lines: string[], blockStart: number, blockEnd: number):
|
||||
export function scanTomlLines(content: string): HeaderScanResult {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const { start, end } = findDeveloperInstructionsBlockRange(lines);
|
||||
const { model, reasoningEffort } = scanHeaderLines(lines, start, end);
|
||||
return { model, hasReasoningEffort: reasoningEffort !== null };
|
||||
const { model, reasoningEffort, sandboxMode } = scanHeaderLines(lines, start, end);
|
||||
return { model, hasReasoningEffort: reasoningEffort !== null, sandboxMode };
|
||||
}
|
||||
|
||||
// Splits `content` into `{lines, terminators}` where `terminators[i]` is the
|
||||
@@ -389,3 +419,392 @@ export function stripReasoningEffort(doc: CodexAgentDoc): CodexAgentDoc {
|
||||
if (doc.reasoningEffortLineIndex === null) return doc;
|
||||
return removeLine(doc, doc.reasoningEffortLineIndex, 'reasoningEffort');
|
||||
}
|
||||
|
||||
// ── Codex sandbox_mode derivation (#3897 rung 3, ADR-3473 §8.3) ────────────
|
||||
//
|
||||
// Moved here from `bin/install.js` (fix for the CAUSE A regression this rung
|
||||
// introduced): `agent-install-check.cts`'s `checkCodexSandboxPosture` used to
|
||||
// lazily `require(bin/install.js)` to reach this derivation — but requiring
|
||||
// `bin/install.js` runs its whole top-level script, including the ASCII
|
||||
// banner print to stdout, which corrupted every stdout-JSON caller downstream
|
||||
// of `checkCodexSandboxPosture` (`gsd-tools validate agents`). This module is
|
||||
// a genuine leaf with no top-level side effects, so both `bin/install.js` and
|
||||
// `agent-install-check.cts` import the derivation from here instead — ONE
|
||||
// owner, no second predicate (routing `src/` through `bin/install.js` was
|
||||
// backwards layering to begin with).
|
||||
//
|
||||
// This module does NOT parse frontmatter (there is no third copy of that
|
||||
// extraction here — two already exist, `bin/install.js` and
|
||||
// `runtime-artifact-conversion.cts`). `deriveCodexSandboxMode` below takes
|
||||
// the already-resolved `tools:` value as a plain string parameter: every
|
||||
// caller already has it (or the raw frontmatter to pull it from) in hand
|
||||
// before calling in, so this module stays a pure predicate over data
|
||||
// supplied by the caller — never a document reader itself. This also means
|
||||
// the module never needs the full YAML-backed `frontmatter.cts` engine
|
||||
// (vendored js-yaml + anchor/alias refusal + comment-channel plumbing, built
|
||||
// for a much broader contract than a single `tools:` line lookup) — it has
|
||||
// no frontmatter-parsing need at all anymore.
|
||||
|
||||
/**
|
||||
* The 17 roles measured as widening under derivation (declare Write/Edit,
|
||||
* never in the pre-#3897 `CODEX_AGENT_SANDBOX` map, so the old
|
||||
* `|| 'read-only'` fallback silently under-granted them). Pinned to
|
||||
* `read-only` pending the open question of whether Codex enforces
|
||||
* `sandbox_mode` or treats it as advisory (HALT.md). This list is CLOSED and
|
||||
* SHRINK-ONLY: a new writing role never lands here (S6, T26); it is validated
|
||||
* against the live tool contract every time it is consulted
|
||||
* ({@link _deriveCodexSandboxModeFromTools}) and against the real
|
||||
* `agents/` roster by a dedicated test (`tests/codex-config.test.cjs` T24/T25)
|
||||
* so a stale or orphaned entry fails loudly instead of being silently
|
||||
* honored forever.
|
||||
*
|
||||
* `gsd-nyquist-auditor` is the 17th entry, added by the list-form `tools:`
|
||||
* parse fix (#3897 follow-up): its `tools:` frontmatter uses YAML block-list
|
||||
* form (`tools:` + indented `- Item` lines) and declares both Write and
|
||||
* Edit, but the original `extractToolsLine`/`extractFrontmatterField`
|
||||
* readers only ever saw the first list item (`- Read`) and derived
|
||||
* `read-only` by accident, not by design. {@link extractToolsValue} now
|
||||
* parses the list correctly, so this role genuinely derives
|
||||
* `workspace-write` from its tool contract — HALT.md's original 16-role
|
||||
* count measured against the pre-fix (single-line) readers and undercounted
|
||||
* this role. It is held here for the same reason as the other 16: pending
|
||||
* Codex's `sandbox_mode` enforcement decision, not because the derivation is
|
||||
* wrong.
|
||||
*/
|
||||
export const CODEX_SANDBOX_HOLDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'gsd-ai-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-code-fixer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-code-reviewer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-debug-session-manager': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-doc-classifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-doc-synthesizer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-doc-verifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-doc-writer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-dom-verifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-domain-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-eval-auditor': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-eval-planner': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-intel-updater': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-pattern-mapper': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-ui-auditor': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-ui-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
|
||||
'gsd-nyquist-auditor': 'declares Write/Edit (YAML list-form tools:, surfaced by the list-form parse fix); pending Codex sandbox_mode enforcement decision',
|
||||
});
|
||||
|
||||
// True iff a `tools:` frontmatter value declares Write or Edit as a whole
|
||||
// token (never a substring match, so a hypothetical "Edith"-named tool could
|
||||
// never collide). Single predicate owner for both the emitter
|
||||
// ({@link _deriveCodexSandboxModeFromTools}) and the posture check
|
||||
// (`agent-install-check.cts`'s `checkCodexSandboxPosture`, via
|
||||
// {@link deriveCodexSandboxMode}).
|
||||
//
|
||||
// #3897 security review F5: a negation form — `All tools except Agent,
|
||||
// Write, Edit` — used to derive `workspace-write` because the plain
|
||||
// comma-tokenizer below saw the literal token `Write` and never noticed it
|
||||
// was named as an EXCLUSION, not a grant. That is fail-OPEN: the value's
|
||||
// stated meaning is "everything except these", so a `Write`/`Edit` name
|
||||
// after `except` means the role does NOT get them. No shipped roster agent
|
||||
// uses this form today, but a wrong answer here widens silently, so it is
|
||||
// handled: once the literal word `except` (case-insensitive) appears
|
||||
// anywhere in the value, this is a "<grant> except <exclusions>" statement
|
||||
// and only the tokens AFTER `except` decide the verdict — `Write`/`Edit`
|
||||
// declare broader iff `except` is ABSENT, or present but does not name them.
|
||||
function _codexToolsDeclareWriteOrEdit(toolsRaw: string): boolean {
|
||||
const raw = String(toolsRaw || '');
|
||||
const exceptIndex = raw.search(/\bexcept\b/i);
|
||||
if (exceptIndex !== -1) {
|
||||
const exclusionsText = raw.slice(exceptIndex).replace(/^\S*\s*except\b/i, '');
|
||||
const exclusions = exclusionsText.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
const excludesWriteOrEdit = exclusions.includes('Write') || exclusions.includes('Edit');
|
||||
return !excludesWriteOrEdit;
|
||||
}
|
||||
const tokens = raw.split(',').map((t) => t.trim());
|
||||
return tokens.includes('Write') || tokens.includes('Edit');
|
||||
}
|
||||
|
||||
// #3897 security review F1/F3: control-character/whitespace class used by
|
||||
// {@link normalizeSandboxIdentity} to trim more than plain ASCII whitespace
|
||||
// from BOTH edges of a candidate identity before it is compared against the
|
||||
// hold set. A bare .trim() only strips the ASCII whitespace \s already
|
||||
// covers; the hold bypass this closes (a trailing space, \n/\r, or NBSP
|
||||
// after `gsd-doc-writer`) is exactly a value that survives .trim() untouched.
|
||||
// Spelled entirely as \u escapes (never a literal invisible codepoint in
|
||||
// the source), matching this module's own BOM_CHAR convention above: NBSP
|
||||
// (U+00A0), the zero-width space/non-joiner/joiner + LTR/RTL marks
|
||||
// (U+200B-U+200F), the BOM/ZWNBSP (U+FEFF), and the full C0 control range
|
||||
// + DEL (U+0000-U+001F, U+007F), alongside ASCII whitespace (\s).
|
||||
const SANDBOX_IDENTITY_EDGE_RE = new RegExp(
|
||||
'^[\\s\\u0000-\\u001f\\u007f\\u00a0\\u200b-\\u200f\\ufeff]+' +
|
||||
'|[\\s\\u0000-\\u001f\\u007f\\u00a0\\u200b-\\u200f\\ufeff]+$',
|
||||
'g',
|
||||
);
|
||||
|
||||
// Anything outside this set, AFTER normalization, is "suspicious" — see
|
||||
// {@link isSandboxHeld}'s docblock for why fail-closed (never enumerate
|
||||
// confusables) is the only tractable answer here.
|
||||
const SANDBOX_IDENTITY_SUSPICIOUS_RE = /[^a-z0-9._-]/;
|
||||
|
||||
/**
|
||||
* Canonicalizes a single candidate sandbox-hold identity (a source filename
|
||||
* stem OR a frontmatter-derived `name:` value) into the same lowercase ASCII
|
||||
* shape {@link CODEX_SANDBOX_HOLDS}'s keys are written in, so both candidate
|
||||
* identities for one emitted artifact (see {@link isSandboxHeld}) can be
|
||||
* compared against the hold set on equal footing.
|
||||
*
|
||||
* #3897 security review F1/F3 — pipeline, in this exact order:
|
||||
* 1. basename it (strip through the LAST `/` or `\`) — a value carrying a
|
||||
* path separator (`../agents/x`, `./x`) is reduced to its final segment
|
||||
* before anything else runs, so a path-traversal-shaped candidate can
|
||||
* never dodge the hold lookup by hiding behind a directory prefix.
|
||||
* 2. trim ASCII whitespace AND the NBSP/zero-width/control-char class
|
||||
* ({@link SANDBOX_IDENTITY_EDGE_RE}) from both ends.
|
||||
* 3. strip trailing dots (`gsd-doc-writer.` collapses to the same key).
|
||||
* 4. `String.prototype.normalize('NFKC')` — canonicalizes compatibility
|
||||
* variants (e.g. fullwidth `g` U+FF47 folds to ASCII `g`) to their
|
||||
* standard form so a widened-by-Unicode-lookalike name collapses onto
|
||||
* the real held key instead of merely looking similar to it.
|
||||
* 5. lowercase — ASCII recasing already matched the hold set before this
|
||||
* rung; this keeps that property after the above steps.
|
||||
*
|
||||
* Returns `null` (never throws) for a non-string input or when the fully
|
||||
* normalized result is empty, so callers can treat `null` as "not a real
|
||||
* identity, cannot be held, but see the `suspicious` companion signal".
|
||||
*/
|
||||
export function normalizeSandboxIdentity(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const lastSlash = Math.max(raw.lastIndexOf('/'), raw.lastIndexOf('\\'));
|
||||
let value = lastSlash === -1 ? raw : raw.slice(lastSlash + 1);
|
||||
value = value.replace(SANDBOX_IDENTITY_EDGE_RE, '');
|
||||
value = value.replace(/\.+$/, '');
|
||||
value = value.normalize('NFKC');
|
||||
value = value.toLowerCase();
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `held` is true iff ANY candidate identity, once normalized, names a real
|
||||
* {@link CODEX_SANDBOX_HOLDS} entry. `suspicious` is true iff any candidate,
|
||||
* after normalization, still contains a character outside `[a-z0-9._-]`.
|
||||
*
|
||||
* #3897 security review F1 (the blocker): the sandbox decision must be safe
|
||||
* for the ARTIFACT IT LANDS ON, not for whichever single identity happens to
|
||||
* be handy at the call site. `bin/install.js`'s emit loop has TWO candidate
|
||||
* identities for one `.toml` artifact — the source filename stem (what the
|
||||
* hold lookup used to be keyed on) and the frontmatter `name:` value (what
|
||||
* the OUTPUT PATH is actually keyed on) — and an attacker who controls
|
||||
* frontmatter can make them disagree (rename the file, or plant a sibling
|
||||
* file whose `name:` collides with a held role). Deciding over only one of
|
||||
* the two and applying the result to whichever path the OTHER one names is
|
||||
* exactly the regression this closes: this predicate takes every candidate
|
||||
* that can influence either the derivation or the emitted path and takes the
|
||||
* MOST RESTRICTIVE answer — `held` if any one of them is held.
|
||||
*
|
||||
* #3897 security review F3: `suspicious` is the fail-CLOSED half of this
|
||||
* predicate, and it is deliberately not an attempt to enumerate Unicode
|
||||
* confusables (Turkish dotted/dotless I, fullwidth forms, NFD combining
|
||||
* marks, ...). Every one of the 35 shipped roster files is pure ASCII
|
||||
* (`gsd-[a-z-]+`), so a non-ASCII-after-normalization identity cannot be a
|
||||
* legitimate shipped role — flagging it `suspicious` (which
|
||||
* {@link deriveCodexSandboxMode} turns into `read-only`) has zero false
|
||||
* positives against real content, and refuses to widen on anything this
|
||||
* module does not recognize instead of chasing an open-ended confusables
|
||||
* list that will always be one codepoint behind the next lookalike.
|
||||
*/
|
||||
export function isSandboxHeld(
|
||||
...candidates: Array<string | string[] | null | undefined>
|
||||
): { held: boolean; suspicious: boolean } {
|
||||
let held = false;
|
||||
let suspicious = false;
|
||||
for (const candidate of candidates) {
|
||||
const values = Array.isArray(candidate) ? candidate : [candidate];
|
||||
for (const value of values) {
|
||||
const normalized = normalizeSandboxIdentity(value);
|
||||
if (normalized === null) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, normalized)) {
|
||||
held = true;
|
||||
}
|
||||
if (SANDBOX_IDENTITY_SUSPICIOUS_RE.test(normalized)) {
|
||||
suspicious = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { held, suspicious };
|
||||
}
|
||||
|
||||
// #3897 CAUSE A fix: a single-purpose `tools:`-VALUE reader, NOT a general
|
||||
// frontmatter parser (`extractFrontmatterAndBody`/`extractFrontmatterField`
|
||||
// were deliberately deleted from this module's ancestor — see the module
|
||||
// header's "document model, not policy" charter). This module is the one
|
||||
// genuinely side-effect-free leaf `agent-install-check.cts`'s
|
||||
// `checkCodexSandboxPosture` already imports from for
|
||||
// {@link deriveCodexSandboxMode}; giving it this reader too avoids importing
|
||||
// `runtime-artifact-conversion.cjs` there just to pull one frontmatter field
|
||||
// — that module's own dependency chain (`command-roster.cjs` →
|
||||
// `scripts/fix-slash-commands.cjs`, a dev-only repo script) does not resolve
|
||||
// from an installed tree, which is exactly what broke
|
||||
// `tests/agent-install-check.test.cjs` against a synthetic install dir.
|
||||
// Scoped to ONLY the `tools:` key: finds the leading `---`-delimited
|
||||
// frontmatter block and returns its `tools:` value (quotes stripped), or
|
||||
// `''` when there is no frontmatter block or no `tools:` key at all.
|
||||
//
|
||||
// #3897 rung 4/list-form fix: handles BOTH shapes a `tools:` key can take —
|
||||
// (a) inline: `tools: Read, Write, Edit` on the same line as the key, and
|
||||
// (b) YAML block-list form: `tools:` alone, followed by indented `- Item`
|
||||
// lines (`agents/gsd-nyquist-auditor.md`, `agents/gsd-security-auditor.md`
|
||||
// are the only two roster files using this shape today). The former
|
||||
// implementation was a single `/^tools:\s*(.+)$/m` regex — `\s*` swallows the
|
||||
// newline after a bare `tools:` key, so it silently matched into the FIRST
|
||||
// list item's own line and returned just `"- Read"`, reading a real
|
||||
// Write/Edit DECLARATION as an absence. List items are joined with `, ` so
|
||||
// the result feeds {@link _codexToolsDeclareWriteOrEdit}'s comma-tokenizer
|
||||
// unchanged. The list terminates at the first line that is not an indented
|
||||
// `- Item` (a new frontmatter key, `---`, a blank line, or EOF) — it never
|
||||
// grows into a general YAML parser (that boundary is deliberate, see above).
|
||||
//
|
||||
// #3897 security review F4: TOTAL for any input, not just a well-formed
|
||||
// string — a Buffer, `undefined`, or `null` returns `undefined` rather than
|
||||
// throwing on `.startsWith`, matching this module's "never throws" charter
|
||||
// (see {@link deriveCodexSandboxMode}'s own totality note).
|
||||
export function extractToolsValue(agentContent: unknown): string | undefined {
|
||||
if (typeof agentContent !== 'string') return undefined;
|
||||
if (!agentContent.startsWith('---')) return '';
|
||||
const endIndex = agentContent.indexOf('---', 3);
|
||||
if (endIndex === -1) return '';
|
||||
const frontmatter = agentContent.substring(3, endIndex);
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const toolsLineIndex = lines.findIndex((line) => /^tools:/.test(line));
|
||||
if (toolsLineIndex === -1) return '';
|
||||
const inlineMatch = lines[toolsLineIndex].match(/^tools:[ \t]*(\S.*)$/);
|
||||
if (inlineMatch) {
|
||||
return inlineMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
// Bare `tools:` key (nothing but optional trailing whitespace on its own
|
||||
// line) — read the YAML block-list form that follows: indented `- Item`
|
||||
// lines, one item per line, stopping at the first line that is not one.
|
||||
const items: string[] = [];
|
||||
for (let i = toolsLineIndex + 1; i < lines.length; i++) {
|
||||
const itemMatch = lines[i].match(/^[ \t]+-[ \t]*(.+)$/);
|
||||
if (!itemMatch) break;
|
||||
items.push(itemMatch[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
return items.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The single owner of `sandbox_mode` derivation: `workspace-write` iff the
|
||||
* role's own frontmatter `tools:` declares Write/Edit, UNLESS the role is
|
||||
* held ({@link CODEX_SANDBOX_HOLDS}) at `read-only`.
|
||||
*
|
||||
* #3897 CAUSE C fix: this function is TOTAL — it never throws, for any
|
||||
* (identity, toolsValue) pair. It used to throw when a held role's CONTENT
|
||||
* (whatever the caller handed it) did not derive broader than its pin, on
|
||||
* the theory that a stale hold should "fail loudly at the point the
|
||||
* derivation runs". That reasoning does not survive contact with this
|
||||
* function's actual contract: it is a pure predicate over whatever content
|
||||
* the caller supplies, and tests legitimately pass synthetic fixtures for
|
||||
* held role names, so the throw fired on arbitrary input, not just the real
|
||||
* roster. It is also redundant even for real input — if a held role's
|
||||
* content does not derive broader, applying the hold pins `read-only` and
|
||||
* derivation would return `read-only` anyway; the hold is a no-op, so there
|
||||
* is nothing to fail about at derivation time. The STALENESS invariant (S4)
|
||||
* is a property of the real `agents/` roster, not of an arbitrary call's
|
||||
* input, so it belongs — and stays enforced — only at the roster level: see
|
||||
* {@link validateCodexSandboxHolds} and `tests/codex-config.test.cjs`
|
||||
* T24/T25, which assert it against the real roster directly.
|
||||
*
|
||||
* `agentName` here MUST be every identity the content's own frontmatter
|
||||
* could disagree with — i.e. the source `.md` FILENAME stem AND (when the
|
||||
* caller has one) the frontmatter-derived `name:` display value, passed
|
||||
* together as an array. Accepting only one of the two and applying the
|
||||
* result to an ARTIFACT keyed on the other is the #3897 security review F1
|
||||
* regression this function closes: `bin/install.js`'s emit loop decides
|
||||
* `sandbox_mode` for the filename stem but writes the `.toml` at a path keyed
|
||||
* on `name`, so a renamed file (stem drifts, `name:` stays pinned) or a
|
||||
* sibling file whose `name:` collides with a held role could make the two
|
||||
* identities disagree and land a held role's own artifact with
|
||||
* `workspace-write`. See {@link isSandboxHeld} for the most-restrictive-wins
|
||||
* rule across every candidate identity.
|
||||
*
|
||||
* `toolsRaw` is the already-resolved `tools:` frontmatter VALUE (e.g.
|
||||
* `"Read, Write, Edit"`), not the frontmatter block or the full agent
|
||||
* content — see {@link deriveCodexSandboxMode}'s doc for why.
|
||||
*/
|
||||
function _deriveCodexSandboxModeFromTools(agentName: string | string[] | null | undefined, toolsRaw: string): string {
|
||||
const derivesBroader = _codexToolsDeclareWriteOrEdit(toolsRaw || '');
|
||||
const { held, suspicious } = isSandboxHeld(agentName);
|
||||
if (held || suspicious) {
|
||||
// A held (or unrecognizable/"suspicious", per F3) identity always pins
|
||||
// read-only, whether or not its supplied content still derives broader
|
||||
// (see #3897 CAUSE C note above the docstring for this function's
|
||||
// caller-facing counterpart): if it no longer derives broader, the pin is
|
||||
// a no-op and there is nothing to fail about here — the staleness
|
||||
// invariant lives at the roster level instead
|
||||
// ({@link validateCodexSandboxHolds}).
|
||||
return 'read-only';
|
||||
}
|
||||
return derivesBroader ? 'workspace-write' : 'read-only';
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported form for external callers (`checkCodexSandboxPosture`,
|
||||
* `generateCodexAgentToml`). Takes the already-resolved `tools:` frontmatter
|
||||
* VALUE, not raw agent content or a frontmatter slice — this module does no
|
||||
* frontmatter parsing at all (there is no third copy of that extraction
|
||||
* here; see the module-header note above `CODEX_SANDBOX_HOLDS`). Both
|
||||
* callers already have (or can trivially get) `tools:` in hand via this
|
||||
* module's own {@link extractToolsValue} — the single shared extractor both
|
||||
* `bin/install.js`'s `generateCodexAgentToml` and `agent-install-check.cts`'s
|
||||
* `checkCodexSandboxPosture` route through (#3897 CAUSE A fix, and the
|
||||
* #3897 list-form parse fix's Fix 3: `bin/install.js` used to read `tools:`
|
||||
* via its own private `extractFrontmatterField`, a second single-line-only
|
||||
* copy that disagreed with this module's reader on YAML block-list form —
|
||||
* NOT `runtime-artifact-conversion.cts`'s general-purpose extractors either;
|
||||
* that module's dependency chain does not resolve from an installed tree) —
|
||||
* so this stays a pure predicate over data the caller already holds, never a
|
||||
* document reader.
|
||||
*
|
||||
* `agentName` is one or more HOLD-LOOKUP IDENTITIES: pass the agent's
|
||||
* canonical source filename stem (e.g. `gsd-doc-writer` for
|
||||
* `agents/gsd-doc-writer.md`) as a plain string for the single-identity call
|
||||
* sites this signature always supported, OR an array of every identity that
|
||||
* can influence the emitted artifact (filename stem AND frontmatter `name:`)
|
||||
* when the caller — like `bin/install.js`'s per-agent emit loop — has both
|
||||
* (see the F1 security note on {@link _deriveCodexSandboxModeFromTools}
|
||||
* above). TOTAL for any value here, including `undefined`, `null`, `[]`, and
|
||||
* an array containing non-string members — none of those throw; they simply
|
||||
* contribute nothing to the hold/suspicious check (see {@link isSandboxHeld}
|
||||
* / {@link normalizeSandboxIdentity}).
|
||||
*/
|
||||
export function deriveCodexSandboxMode(agentName: string | string[] | null | undefined, toolsRaw: string): string {
|
||||
return _deriveCodexSandboxModeFromTools(agentName, toolsRaw || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* (S5) Every {@link CODEX_SANDBOX_HOLDS} key must still name a real
|
||||
* `<agentsSrcDir>/<role>.md`. A hold for a role that no longer exists is
|
||||
* stale and must fail loudly, not be silently ignored — else the hold list
|
||||
* only ever grows/rots instead of shrinking to zero.
|
||||
*
|
||||
* NOT called from the install runtime path ({@link deriveCodexSandboxMode} /
|
||||
* `bin/install.js`'s `installCodexConfig`): the shrink-to-zero invariant it
|
||||
* checks is a REPO invariant about the canonical roster in `agents/`, not a
|
||||
* property of whatever directory an install happens to read from — a
|
||||
* partial/synthetic install source (a test fixture, a `--config-dir`
|
||||
* subset) legitimately contains only a few agents, and a hold whose role is
|
||||
* simply absent from THAT source dir must be inert, not fatal. The invariant
|
||||
* is enforced instead as a test over the real `agents/` roster
|
||||
* (`tests/codex-config.test.cjs` T24/T25). This function is kept, exported,
|
||||
* for any caller that specifically wants to validate the CANONICAL roster
|
||||
* (pass `agents/` itself, never an arbitrary install source).
|
||||
*/
|
||||
export function validateCodexSandboxHolds(agentsSrcDir: string): void {
|
||||
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
|
||||
const agentFile = path.join(agentsSrcDir, `${role}.md`);
|
||||
if (!fs.existsSync(agentFile)) {
|
||||
throw new Error(
|
||||
`CODEX_SANDBOX_HOLDS: stale hold for "${role}" — no ${agentFile} exists. The hold list is ` +
|
||||
'closed and shrink-only (ADR-3473 §8.3 / HALT.md); remove this entry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS, VALID_TIERS, CL
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
|
||||
import {
|
||||
readInstallRuntimeMarker,
|
||||
_setInstallRuntimeMarkerForTests,
|
||||
_resetInstallRuntimeMarkerCacheForTests,
|
||||
} from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspaceMod;
|
||||
@@ -61,30 +66,12 @@ const { planningDir } = planningWorkspaceMod;
|
||||
// loudly here instead of silently omitting.
|
||||
const RUNTIMES_WITH_NATIVE_ALIASES: ReadonlySet<string> = new Set(['claude']);
|
||||
|
||||
let _installMarkerCache: string | null | undefined;
|
||||
function readInstallRuntimeMarker(): string | null {
|
||||
if (_installMarkerCache !== undefined) return _installMarkerCache;
|
||||
try {
|
||||
const markerPath = path.join(__dirname, '..', '..', '.gsd-runtime');
|
||||
const raw = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
_installMarkerCache = raw || null;
|
||||
} catch {
|
||||
// No marker: dev/source tree, or an install predating #2297. Fall through to
|
||||
// the 'claude' default (keeps tier aliases — never worse than the bug).
|
||||
_installMarkerCache = null;
|
||||
}
|
||||
return _installMarkerCache;
|
||||
}
|
||||
|
||||
// Test seams for the install-marker rung (the dev/source tree has no marker, so
|
||||
// the file read always bottoms out at 'claude' — these let tests exercise the
|
||||
// third precedence rung and reset the module-level cache between cases).
|
||||
function _setInstallRuntimeMarkerForTests(value: string | null): void {
|
||||
_installMarkerCache = value;
|
||||
}
|
||||
function _resetInstallRuntimeMarkerCacheForTests(): void {
|
||||
_installMarkerCache = undefined;
|
||||
}
|
||||
// #3897 rung 2: the marker reader + its cache and test seams were promoted to
|
||||
// the canonical owner, `runtime-slash.cts` (imported above) — this module now
|
||||
// consumes that single implementation instead of holding its own copy. N5:
|
||||
// behaviour and the seam contract are unchanged by the move; the re-exports
|
||||
// below (`export =` at the bottom of this file) preserve every existing
|
||||
// caller's `require('./model-resolver.cjs')` surface byte-for-behaviour.
|
||||
|
||||
// The runtime whose install is actually resolving, canonicalized so an alias or
|
||||
// case variant (e.g. "claude-code"/"Claude") cannot defeat the native-alias
|
||||
|
||||
@@ -607,21 +607,75 @@ interface RawPlan {
|
||||
|
||||
/**
|
||||
* Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
|
||||
* (case-folded exact match, falling back to canonical-id matching). Returns
|
||||
* (case-folded exact match, falling back to canonical-id matching, falling
|
||||
* back to the in-phase short-form plan number — #3897 rung 4). Returns
|
||||
* `null` when the token does not resolve to any plan in this phase (a typo
|
||||
* or a cross-phase reference) — every call site treats that as "ignore this
|
||||
* edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
|
||||
* resolution, the `depends_on` display mapping, and (#2830) the
|
||||
* halt-propagation node resolution, so the three can never disagree about
|
||||
* which token resolves to which plan.
|
||||
* resolution and (#2830) the halt-propagation node resolution, so the two can
|
||||
* never disagree about which token resolves to which plan. NOT used by the
|
||||
* `depends_on` display mapping (#3785/N3) — that stays a passthrough by
|
||||
* design; see the comment at its call site.
|
||||
*
|
||||
* `shortFormToId` (#3897 rung 4, ADR-3473 §8.9) is the third tier, consulted
|
||||
* only when neither `planMap` nor `canonicalToId` resolves the token. It is
|
||||
* optional so any caller that has not been threaded through yet (there are
|
||||
* none left in this file) degrades to the pre-#3897 two-tier behavior rather
|
||||
* than throwing on a missing argument.
|
||||
*/
|
||||
function resolveDependencyId(
|
||||
dep: string,
|
||||
planMap: Map<string, RawPlan>,
|
||||
canonicalToId: Map<string, string>,
|
||||
shortFormToId?: Map<string, string>,
|
||||
): string | null {
|
||||
const lower = dep.toLowerCase();
|
||||
return planMap.has(lower) ? (planMap.get(lower) as RawPlan).id : (canonicalToId.get(lower) ?? null);
|
||||
if (planMap.has(lower)) return (planMap.get(lower) as RawPlan).id;
|
||||
if (canonicalToId.has(lower)) return canonicalToId.get(lower) as string;
|
||||
return shortFormToId?.get(lower) ?? null;
|
||||
}
|
||||
|
||||
// #3897 rung 4 (ADR-3473 §8.9) — builds the third depends_on resolution tier:
|
||||
// a map from an in-phase BARE PLAN NUMBER (e.g. "01") to the plan id whose
|
||||
// canonical id ends with that number. Recovered from the retired SDK lineage
|
||||
// (sdk/src/query/phase.ts at 11918dcc3^) with ONE deliberate narrowing: the
|
||||
// lost implementation indexed ANY trailing dash-segment of a canonical id,
|
||||
// with no constraint that the segment be a plan NUMBER — so a phase
|
||||
// containing both `09-FIX-auth-PLAN.md` and `09-GAP-auth-PLAN.md` (canonical
|
||||
// id `09-FIX-auth`, trailing segment "auth") would silently bind
|
||||
// `depends_on: ["auth"]` to whichever sorted first, fabricating a
|
||||
// wave-affecting DAG edge with ZERO warning — a mis-resolved edge, which is
|
||||
// worse than a dropped one (found in isolated correctness review, #3897).
|
||||
// `docs/reference/plan-md.md` already documents this tier as resolving "the
|
||||
// bare plan number", so requiring `/^\d+$/` on the trailing segment is a
|
||||
// strict narrowing onto the tier's OWN documented contract, not a behavior
|
||||
// change for any legitimate input. Do NOT restore the unconstrained
|
||||
// lastDash-slice "to match the recovered original" — the original was wrong
|
||||
// here; this rung deliberately departs from it in this one respect, and only
|
||||
// this one. Everything else — the `lastDash` bound, first-write-wins,
|
||||
// lowercasing — is kept exactly as recovered:
|
||||
// - first write wins, deterministic because rawPlans is passed in sorted
|
||||
// plan-file order (D4/T44) and this loop iterates in that same order;
|
||||
// - a canonical id with no dash (`lastDash === -1` or `lastDash === 0`,
|
||||
// e.g. "24" or "-01") or a trailing dash (`lastDash === canonical.length
|
||||
// - 1`, e.g. "09-") is never indexed (D5).
|
||||
// Exported so callers can build this map once and so tests assert against
|
||||
// this REAL implementation rather than a hand-rolled copy that could
|
||||
// silently disagree with it after a future change here (CLAUDE.md's
|
||||
// generative-fix-divergence rule).
|
||||
function buildShortFormToId(rawPlans: RawPlan[]): Map<string, string> {
|
||||
const shortFormToId = new Map<string, string>();
|
||||
for (const p of rawPlans) {
|
||||
const canonical = extractCanonicalPlanId(p.id);
|
||||
const lastDash = canonical.lastIndexOf('-');
|
||||
if (lastDash > 0 && lastDash < canonical.length - 1) {
|
||||
const shortForm = canonical.slice(lastDash + 1).toLowerCase();
|
||||
if (/^\d+$/.test(shortForm) && !shortFormToId.has(shortForm)) {
|
||||
shortFormToId.set(shortForm, p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return shortFormToId;
|
||||
}
|
||||
|
||||
// O(V + E). Assigns each in-phase plan its longest-path topological level over the
|
||||
@@ -630,26 +684,32 @@ function resolveDependencyId(
|
||||
// the exact dequeue order this pass already produces — a valid topological order — passed to
|
||||
// computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
|
||||
// algorithm a second time over the same graph.
|
||||
//
|
||||
// `shortFormToId` (#3897 rung 4, optional — see resolveDependencyId) is threaded through so a
|
||||
// bare in-phase plan-number token (`depends_on: ["01"]`) resolves as a real DAG edge instead of
|
||||
// being dropped and silently collapsing the dependent plan to wave 1 (D3).
|
||||
function computeDependencyLevels(
|
||||
rawPlans: RawPlan[],
|
||||
planMap: Map<string, RawPlan>,
|
||||
canonicalToId: Map<string, string>,
|
||||
shortFormToId?: Map<string, string>,
|
||||
): { level: Map<string, number>; visited: number; order: string[]; unresolved: Array<{ plan: string; token: string }> } {
|
||||
const level = new Map<string, number>();
|
||||
const inDeg = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
// #3427 / ADR-3473 §8.5: a depends_on token that resolves via neither
|
||||
// planMap nor canonicalToId is a dropped edge. Naming it here (rather than
|
||||
// silently `continue`-ing past it) lets cmdPhasePlanIndex surface the
|
||||
// token's own warning instead of manufacturing a wave-mismatch verdict from
|
||||
// the resulting damaged graph (#3427).
|
||||
// #3427 / ADR-3473 §8.5: a depends_on token that resolves via NONE of the
|
||||
// three tiers (planMap, canonicalToId, shortFormToId) is a dropped edge.
|
||||
// Naming it here (rather than silently `continue`-ing past it) lets
|
||||
// cmdPhasePlanIndex surface the token's own warning instead of
|
||||
// manufacturing a wave-mismatch verdict from the resulting damaged graph
|
||||
// (#3427).
|
||||
const unresolved: Array<{ plan: string; token: string }> = [];
|
||||
|
||||
for (const p of rawPlans) {
|
||||
if (!inDeg.has(p.id)) inDeg.set(p.id, 0);
|
||||
if (!adj.has(p.id)) adj.set(p.id, []);
|
||||
for (const dep of p.dependsOn) {
|
||||
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
|
||||
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
|
||||
if (!resolvedDep) {
|
||||
unresolved.push({ plan: p.id, token: String(dep) });
|
||||
continue;
|
||||
@@ -866,8 +926,16 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
const canonicalToId = new Map(
|
||||
rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]),
|
||||
);
|
||||
// #3897 rung 4 (ADR-3473 §8.9) — the third depends_on resolution tier.
|
||||
// Resolves a bare in-phase plan-number short form (e.g. "01") to its owning
|
||||
// plan id. In-phase only by construction (T49): the map is built from THIS
|
||||
// phase's rawPlans alone, so a short form colliding with a different
|
||||
// phase's plan can never be a candidate. See {@link buildShortFormToId}'s
|
||||
// own comment for the numeric-only narrowing this rung applies on top of
|
||||
// the recovered SDK-lineage algorithm.
|
||||
const shortFormToId = buildShortFormToId(rawPlans);
|
||||
|
||||
const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
|
||||
const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
||||
|
||||
if (visited < rawPlans.length) {
|
||||
const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
|
||||
@@ -885,7 +953,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
const haltNodes = rawPlans.map((p) => ({
|
||||
id: p.id,
|
||||
resolvedDependsOn: p.dependsOn
|
||||
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
|
||||
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
|
||||
.filter((id): id is string => id !== null),
|
||||
halted: p.halted,
|
||||
}));
|
||||
@@ -3556,4 +3624,5 @@ export = {
|
||||
cmdPhaseUatPassed,
|
||||
cmdPhaseListPlans,
|
||||
computeDependencyLevels,
|
||||
buildShortFormToId,
|
||||
};
|
||||
|
||||
@@ -73,6 +73,59 @@ export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown
|
||||
return `/gsd-${token}${tail}`;
|
||||
}
|
||||
|
||||
// ─── #3897 rung 2: the per-install `.gsd-runtime` marker, promoted from ──────
|
||||
// model-resolver.cts (#2297) to this module — the canonical owner of runtime
|
||||
// identity (resolveRuntime, formatGsdSlash). `bin/install.js` writes
|
||||
// `<install>/gsd-core/.gsd-runtime` beside VERSION for every runtime install
|
||||
// (#2297). This module compiles to `gsd-core/bin/lib/runtime-slash.cjs`
|
||||
// (ADR-457) — the SAME directory `model-resolver.cjs` compiles to — so the
|
||||
// `__dirname`-relative path below is unchanged from the promoted original.
|
||||
//
|
||||
// Cache + test-seam shape, byte-for-behaviour preserved from
|
||||
// model-resolver.cts:65-77 (N5): module-level `let`, `try/catch -> null`,
|
||||
// `_setInstallRuntimeMarkerForTests` / `_resetInstallRuntimeMarkerCacheForTests`.
|
||||
// `model-resolver.cts` now imports this implementation instead of holding its
|
||||
// own copy; the two `hooks/*.js` marker readers delegate here too (through
|
||||
// `ensureRuntimeBuild()`, per `scripts/lint-hooks-runtime-build-seam.cjs`).
|
||||
let _installMarkerCache: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Read the per-install runtime marker, cached for the process lifetime.
|
||||
* Never throws (N4) — an absent/unreadable marker (dev/source tree, an
|
||||
* install predating #2297, EACCES, hostile content, ...) is "no signal",
|
||||
* never a resolution failure; a throw here would break a session in
|
||||
* `gsd-agent-isolation-guard.js`, which is worse than resolving the wrong
|
||||
* runtime.
|
||||
*
|
||||
* The raw marker string is returned as-is (trimmed) — callers MUST route it
|
||||
* through `resolveRuntimeNameFromCandidates` before trusting it (N1); this
|
||||
* function itself does not normalize so its cached value matches exactly
|
||||
* what #2297's tests already assert about the underlying file read.
|
||||
*/
|
||||
export function readInstallRuntimeMarker(): string | null {
|
||||
if (_installMarkerCache !== undefined) return _installMarkerCache;
|
||||
try {
|
||||
const markerPath = path.join(__dirname, '..', '..', '.gsd-runtime');
|
||||
const raw = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
_installMarkerCache = raw || null;
|
||||
} catch {
|
||||
// No marker: dev/source tree, or an install predating #2297. Fall through
|
||||
// to the 'claude' default (keeps tier aliases — never worse than the bug).
|
||||
_installMarkerCache = null;
|
||||
}
|
||||
return _installMarkerCache;
|
||||
}
|
||||
|
||||
// Test seams for the install-marker rung (the dev/source tree has no marker,
|
||||
// so the file read always bottoms out at 'claude' — these let tests exercise
|
||||
// the third precedence rung and reset the module-level cache between cases).
|
||||
export function _setInstallRuntimeMarkerForTests(value: string | null): void {
|
||||
_installMarkerCache = value;
|
||||
}
|
||||
export function _resetInstallRuntimeMarkerCacheForTests(): void {
|
||||
_installMarkerCache = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the explicit runtime for a project directory, from the two
|
||||
* explicit sources only — no default is applied.
|
||||
@@ -120,17 +173,30 @@ export function resolveExplicitRuntime(
|
||||
/**
|
||||
* Resolve the effective runtime for a project directory.
|
||||
*
|
||||
* process.env.GSD_RUNTIME > config.runtime > 'claude'
|
||||
* process.env.GSD_RUNTIME > config.runtime > install marker > 'claude'
|
||||
*
|
||||
* Mirrors the precedence already used by profile-output.cjs and the rest of
|
||||
* the runtime resolution chain. Returns a lowercased string so downstream
|
||||
* comparisons can be case-blind.
|
||||
*
|
||||
* #3897 rung 2: the per-install `.gsd-runtime` marker is the THIRD rung,
|
||||
* between the two explicit sources and the 'claude' default — matching
|
||||
* model-resolver.cts's own description of it as "the third precedence rung"
|
||||
* before this reader was promoted here. The marker's raw contents are never
|
||||
* trusted verbatim (N1): they are routed through the SAME
|
||||
* `resolveRuntimeNameFromCandidates` normalization the env rung uses, so an
|
||||
* unknown or hostile marker value degrades exactly like an unknown
|
||||
* GSD_RUNTIME value would.
|
||||
*
|
||||
* @param projectDir - path to the project directory, or null/undefined
|
||||
* @returns the resolved runtime name
|
||||
*/
|
||||
export function resolveRuntime(projectDir: string | null | undefined): string {
|
||||
return resolveExplicitRuntime(projectDir) ?? 'claude';
|
||||
const explicit = resolveExplicitRuntime(projectDir);
|
||||
if (explicit) return explicit;
|
||||
const markerRuntime = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker());
|
||||
if (markerRuntime) return markerRuntime;
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
|
||||
import { compileUserPattern, MAX_USER_PATTERN_LEN } from './pattern.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
|
||||
import agentInstallCheck = require('./agent-install-check.cjs');
|
||||
const { checkAgentsInstalled, checkCodexModelPosture } = agentInstallCheck;
|
||||
const { checkAgentsInstalled, checkCodexModelPosture, checkCodexSandboxPosture } = agentInstallCheck;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -1736,6 +1736,21 @@ function cmdValidateAgents(cwd: string, raw: boolean): void {
|
||||
// model or an orphaned reasoning-effort pin in a Codex agent .toml), not just
|
||||
// presence. checkAgentsInstalled above is untouched.
|
||||
const codexPosture = checkCodexModelPosture(runtime, cwd);
|
||||
// #3897 rung 3 (ADR-3473 §8.3 criterion 3) — sibling posture check, additive
|
||||
// alongside codex_posture (never nested inside it, never replacing it): a
|
||||
// TOML's `sandbox_mode` disagreeing with its role's derived expectation is a
|
||||
// different defect class than an Anthropic-flavored model. Same short-circuits,
|
||||
// same read-only posture as checkCodexModelPosture above; checkAgentsInstalled
|
||||
// and codex_posture are both left untouched.
|
||||
//
|
||||
// Failure semantics: matches the checkCodexModelPosture precedent exactly.
|
||||
// Neither posture check makes `validate agents` exit non-zero on its own —
|
||||
// both are report-only fields inspected by the caller (or a human) via the
|
||||
// JSON payload. A `validate` verb whose two posture checks disagreed on
|
||||
// fatality would be its own defect (#3897 dispatch note); this keeps them
|
||||
// consistent rather than inventing new exit-code behavior for only one of
|
||||
// the two siblings.
|
||||
const sandboxPosture = checkCodexSandboxPosture(runtime, cwd);
|
||||
|
||||
output(
|
||||
{
|
||||
@@ -1746,6 +1761,7 @@ function cmdValidateAgents(cwd: string, raw: boolean): void {
|
||||
incomplete: agentStatus.incomplete_agents,
|
||||
expected,
|
||||
codex_posture: codexPosture,
|
||||
sandbox_posture: sandboxPosture,
|
||||
},
|
||||
raw,
|
||||
);
|
||||
|
||||
@@ -1377,3 +1377,143 @@ describe('checkAgentsInstalled — unchanged by the manifest-reader substitution
|
||||
assert.notStrictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), localAgentsDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Sandbox-mode drift (#3897 rung 3, ADR-3473 §8.3 criterion 3) ──────────
|
||||
//
|
||||
// Spec: .gsd/phase/feat-3897-adr3473-83-rungs/{40-design,50-test-matrix}.md, S8/T28.
|
||||
//
|
||||
// `checkAgentsInstalled` above (and `checkCodexModelPosture`, the established
|
||||
// sibling pattern for a codex-only posture check — #3242, "A new sibling
|
||||
// export... presence is checkAgentsInstalled's job; this function's job starts
|
||||
// only once the runtime is confirmed codex and only inspects posture") checks
|
||||
// FILE PRESENCE and MANIFEST COMPLETENESS only. Neither inspects whether an
|
||||
// installed .toml's `sandbox_mode` line agrees with what that role's tool
|
||||
// contract says it should be — a TOML that disagrees passes `validate agents`
|
||||
// today. `checkCodexSandboxPosture` is this test's anticipated name for the new
|
||||
// sibling check (mirroring `checkCodexModelPosture`'s exact shape); the exact
|
||||
// export name is the implementer's call, but the export MUST exist for this
|
||||
// row's acceptance criterion (§8.3 criterion 3) to be met.
|
||||
//
|
||||
// RED today: the export does not exist at all.
|
||||
describe('checkCodexSandboxPosture (#3897 rung 3 — not yet implemented, RED until it lands)', () => {
|
||||
test('T28 validateAgentsFailsOnSandboxDrift_3897: a TOML whose sandbox_mode disagrees with the derived expectation must fail, naming role/expected/found', (t) => {
|
||||
const globalHome = createTempDir('gsd-sandbox-drift-');
|
||||
t.after(() => cleanup(globalHome));
|
||||
const agentsDir = path.join(globalHome, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
// gsd-executor declares tools: Read, Write, Edit (agents/gsd-executor.md) —
|
||||
// it MUST derive workspace-write. Install a drifted TOML claiming
|
||||
// read-only instead, matching the shape generateCodexAgentToml emits.
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'gsd-executor.toml'),
|
||||
'name = "gsd-executor"\ndescription = "Executes plans"\nsandbox_mode = "read-only"\n' +
|
||||
"developer_instructions = '''\nExecute.\n'''\n",
|
||||
);
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
if (agent === 'gsd-executor') continue;
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
|
||||
}
|
||||
process.env['CODEX_HOME'] = globalHome;
|
||||
|
||||
assert.equal(
|
||||
typeof agentInstallCheck.checkCodexSandboxPosture,
|
||||
'function',
|
||||
'agent-install-check.cjs must export a sandbox-posture check (#3897 rung 3, ADR-3473 §8.3 criterion 3) — it does not exist yet, so a drifted sandbox_mode currently passes validate agents silently',
|
||||
);
|
||||
|
||||
const result = agentInstallCheck.checkCodexSandboxPosture('codex');
|
||||
assert.equal(
|
||||
result.ok,
|
||||
false,
|
||||
'a TOML whose sandbox_mode disagrees with the expected derived value must fail the check',
|
||||
);
|
||||
const violation = result.violations.find((v) => v.agent === 'gsd-executor');
|
||||
assert.ok(violation, 'the violation must name the drifted agent');
|
||||
assert.equal(violation.expected, 'workspace-write', 'the violation must name the EXPECTED sandbox_mode');
|
||||
assert.equal(violation.found, 'read-only', 'the violation must name the FOUND (installed) sandbox_mode');
|
||||
});
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 2): `found` used
|
||||
// to be read via a naive whole-file regex, unlike the block-aware scanner
|
||||
// the sibling checkCodexModelPosture uses. Reviewer's fixture: no header
|
||||
// sandbox_mode pin at all, but a `sandbox_mode = "..."`-shaped line INSIDE
|
||||
// the developer_instructions block (prose a role's own prompt might
|
||||
// legitimately discuss). The naive regex misread that prose as a live
|
||||
// value and manufactured a FALSE violation.
|
||||
test('a sandbox_mode-shaped line INSIDE developer_instructions is never read as a live value (no false violation)', (t) => {
|
||||
const globalHome = createTempDir('gsd-sandbox-block-aware-');
|
||||
t.after(() => cleanup(globalHome));
|
||||
const agentsDir = path.join(globalHome, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
// gsd-nyquist-auditor's real canonical tools: declares Write/Edit, so the
|
||||
// derived expectation is workspace-write. Install a TOML that correctly
|
||||
// has NO header sandbox_mode pin, but whose developer_instructions block
|
||||
// contains a line that is shaped exactly like a live sandbox_mode pin.
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'gsd-nyquist-auditor.toml'),
|
||||
'name = "gsd-nyquist-auditor"\ndescription = "Fills Nyquist validation gaps"\n' +
|
||||
"developer_instructions = '''\n" +
|
||||
'When configuring the sandbox, use:\n' +
|
||||
'sandbox_mode = "workspace-write"\n' +
|
||||
"'''\n",
|
||||
);
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
if (agent === 'gsd-nyquist-auditor') continue;
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
|
||||
}
|
||||
process.env['CODEX_HOME'] = globalHome;
|
||||
|
||||
const result = agentInstallCheck.checkCodexSandboxPosture('codex');
|
||||
const violation = result.violations.find((v) => v.agent === 'gsd-nyquist-auditor');
|
||||
assert.equal(
|
||||
violation,
|
||||
undefined,
|
||||
'a sandbox_mode-shaped line inside developer_instructions must never be read as the installed value — it must not manufacture a violation for an agent with no real header pin',
|
||||
);
|
||||
});
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 3):
|
||||
// truncatePostureValue exists at agent-install-check.cts precisely for
|
||||
// this, and the model-posture sibling applies it — the sandbox check did
|
||||
// not, so an oversized (or secret-shaped) sandbox_mode value could reach
|
||||
// `validate agents --raw` output at full length.
|
||||
test('an oversized sandbox_mode value is truncated in the emitted violation (CLI JSON)', (t) => {
|
||||
const globalHome = createTempDir('gsd-sandbox-truncate-');
|
||||
t.after(() => cleanup(globalHome));
|
||||
const agentsDir = path.join(globalHome, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
const oversized = 'x'.repeat(300);
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'gsd-executor.toml'),
|
||||
`name = "gsd-executor"\ndescription = "Executes plans"\nsandbox_mode = "${oversized}"\n` +
|
||||
"developer_instructions = '''\nExecute.\n'''\n",
|
||||
);
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
if (agent === 'gsd-executor') continue;
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
|
||||
}
|
||||
process.env['CODEX_HOME'] = globalHome;
|
||||
|
||||
const result = agentInstallCheck.checkCodexSandboxPosture('codex');
|
||||
const violation = result.violations.find((v) => v.agent === 'gsd-executor');
|
||||
assert.ok(violation, 'the violation must name the drifted agent');
|
||||
assert.ok(
|
||||
violation.found.length <= 65,
|
||||
`violation.found must be truncated (<=64 chars + ellipsis), got length ${violation.found.length}`,
|
||||
);
|
||||
assert.ok(violation.found.endsWith('…'), 'a truncated value must end with the ellipsis marker');
|
||||
|
||||
// The full-length value must never reach the emitted JSON at all.
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.ok(!serialized.includes(oversized), 'the untruncated 300-char value must never appear in the emitted CLI JSON');
|
||||
});
|
||||
});
|
||||
|
||||
// T29 (validate agents still fails when a file is missing, S9) is intentionally
|
||||
// NOT duplicated here — it is already covered by the existing "missing dir"
|
||||
// coverage documented in this file's own header comment ("missing dir →
|
||||
// agents_installed:false, missing_agents = all expected") and exercised above.
|
||||
// It must stay green; nothing in this rung touches that path.
|
||||
|
||||
@@ -539,6 +539,59 @@ describe('validate agents subcommand (#1371)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sandbox posture drift, at the COMMAND (#3897 rung 3, ADR-3473 §8.3) ─────
|
||||
//
|
||||
// tests/agent-install-check.test.cjs's T28 already proves checkCodexSandboxPosture
|
||||
// itself detects a drifted sandbox_mode. That is a UNIT row — it drives the
|
||||
// helper directly. Before verify.cts wired the helper into cmdValidateAgents,
|
||||
// T28 stayed green while `gsd-tools validate agents` (the actual command a
|
||||
// user/CI runs) never surfaced the drift at all — the ADR-3180 Decision 4(b)
|
||||
// failure shape ("asserting at a helper's return, not at the consumer's
|
||||
// output"). This row drives the real CLI end to end and asserts on ITS JSON,
|
||||
// so a future regression that leaves the helper correct but un-wired from the
|
||||
// command fails here even if T28 stays green.
|
||||
describe('validate agents surfaces sandbox posture drift at the command (#3897 rung 3)', () => {
|
||||
let tmpDir;
|
||||
let agentsDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
agentsDir = path.join(tmpDir, 'codex-agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('validateAgentsSurfacesSandboxDriftAtTheCommand_3897: `validate agents` reports the drifted agent/expected/found via sandbox_posture', () => {
|
||||
// agents/gsd-executor.md (the real repo role source — checkCodexSandboxPosture
|
||||
// reads it directly, it is not fixture-overridable) declares tools that derive
|
||||
// workspace-write. Install a drifted TOML claiming read-only instead.
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'gsd-executor.toml'),
|
||||
'name = "gsd-executor"\ndescription = "Executes plans"\nsandbox_mode = "read-only"\n'
|
||||
+ "developer_instructions = '''\nExecute.\n'''\n",
|
||||
);
|
||||
|
||||
const env = { GSD_AGENTS_DIR: agentsDir, GSD_RUNTIME: 'codex' };
|
||||
const result = runGsdTools('validate agents --raw', tmpDir, env);
|
||||
assert.ok(result.success, `validate agents failed: ${result.error}`);
|
||||
assert.strictEqual(result.exitCode, 0, 'model-posture drift is report-only, not fatal — sandbox posture must match that precedent');
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.sandbox_posture,
|
||||
`expected \`validate agents\` output to carry a sandbox_posture key, got keys: ${Object.keys(output).join(', ')}`,
|
||||
);
|
||||
assert.strictEqual(output.sandbox_posture.ok, false);
|
||||
const violation = output.sandbox_posture.violations.find((v) => v.agent === 'gsd-executor');
|
||||
assert.ok(violation, 'the command output must name the drifted agent');
|
||||
assert.strictEqual(violation.expected, 'workspace-write');
|
||||
assert.strictEqual(violation.found, 'read-only');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug #1058: validate agents detects manifest-backed .md/.toml pair drift ──
|
||||
|
||||
describe('bug #1058: validate agents detects manifest-backed .md/.toml pair drift', () => {
|
||||
|
||||
@@ -67,13 +67,37 @@ const {
|
||||
mergeCodexConfig,
|
||||
install,
|
||||
GSD_CODEX_MARKER,
|
||||
CODEX_AGENT_SANDBOX,
|
||||
deriveCodexSandboxMode,
|
||||
// #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export
|
||||
// holding the 17 explicit read-only pins for roles whose tool contract would
|
||||
// otherwise derive workspace-write (16 measured by HALT.md + gsd-nyquist-auditor,
|
||||
// surfaced by the list-form parse fix). Does not exist on the current tree —
|
||||
// destructuring a non-existent key is `undefined`, not a throw, so requiring
|
||||
// this module still succeeds; every test below that touches it fails on its
|
||||
// own `typeof` guard instead.
|
||||
CODEX_SANDBOX_HOLDS,
|
||||
parseTomlToObject,
|
||||
validateCodexConfigSchema,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
||||
// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved
|
||||
// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no
|
||||
// longer parses frontmatter at all — no third copy of that extraction).
|
||||
const {
|
||||
extractFrontmatterAndBody,
|
||||
extractFrontmatterField,
|
||||
} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
// #3897 list-form parse fix: the ONE shared `tools:`-value reader both
|
||||
// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml`
|
||||
// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route
|
||||
// through — handles inline (`tools: Read, Write`) AND YAML block-list
|
||||
// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw`
|
||||
// so the test's own measurement of "what does this role's tool contract
|
||||
// declare" cannot silently disagree with production (the exact generative-
|
||||
// fix-divergence shape this fix closes).
|
||||
const { extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
||||
|
||||
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
||||
const previousCodeHome = process.env.CODEX_HOME;
|
||||
@@ -483,9 +507,27 @@ tools: Read, Grep, Glob
|
||||
assert.ok(result.includes('description = "GSD agent gsd-unknown"'), 'falls back to synthetic description');
|
||||
});
|
||||
|
||||
test('defaults unknown agents to read-only', () => {
|
||||
// #3897 CAUSE B fix: this used to assert the deleted name-based fallback
|
||||
// (an unknown agent NAME defaulted to read-only regardless of its tool
|
||||
// contract). Under derivation, identity no longer determines the sandbox —
|
||||
// the tool contract does (S6: "a new writing role gets the contract, not
|
||||
// the pin"). Split into the two rows the old single assertion conflated:
|
||||
// absence of a `tools:` grant (N8) vs. an unknown role that legitimately
|
||||
// declares a writing tool (S6).
|
||||
test('unknown agent with no tools: frontmatter derives read-only (N8: absence is not a grant)', () => {
|
||||
const noToolsAgent = `---
|
||||
name: gsd-unknown
|
||||
description: An unknown agent with no declared tools
|
||||
---
|
||||
|
||||
<role>You are an unknown agent.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-unknown', noToolsAgent);
|
||||
assert.ok(result.includes('sandbox_mode = "read-only"'), 'no tools: frontmatter -> read-only');
|
||||
});
|
||||
|
||||
test('unknown agent declaring Write/Edit derives workspace-write (S6: the tool contract, not the pin, decides)', () => {
|
||||
const result = generateCodexAgentToml('gsd-unknown', sampleAgent);
|
||||
assert.ok(result.includes('sandbox_mode = "read-only"'), 'defaults to read-only');
|
||||
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'declares Write/Edit -> workspace-write');
|
||||
});
|
||||
|
||||
// ─── #2256: model_overrides support ───────────────────────────────────────
|
||||
@@ -919,6 +961,735 @@ description: Maps the codebase
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2): sandbox_mode derives from
|
||||
// the tool contract, with 17 widening roles held at read-only ────────────────
|
||||
//
|
||||
// Spec: .gsd/phase/feat-3897-adr3473-83-rungs/{40-design,50-test-matrix}.md,
|
||||
// rows S1-S9 / T20-T30. Measured against `next` @ ad6abc896 (HALT.md):
|
||||
// deriving `workspace-write` iff an agent's frontmatter `tools:` declares
|
||||
// `Write` or `Edit` reproduces all 11 CODEX_AGENT_SANDBOX map entries exactly,
|
||||
// and would additionally widen 16 fallback roles that the map never covered.
|
||||
// HALT.md's 16 was measured against a `tools:`-VALUE reader that only handled
|
||||
// inline form; the #3897 list-form parse fix corrected `gsd-nyquist-auditor`'s
|
||||
// YAML block-list `tools:` (previously misread as `"- Read"`, no Write/Edit
|
||||
// found), which genuinely derives `workspace-write` and adds a 17th widening
|
||||
// role. `CODEX_SANDBOX_HOLDS` (destructured above; `undefined` on the current
|
||||
// tree) is this rung's pin list for those 17 — every row below that depends
|
||||
// on it fails on its own `typeof` guard until it lands.
|
||||
//
|
||||
// Deriving-from-real-content is deliberate for T20/T21/T26/T27/T30: a
|
||||
// synthetic `tools:` fixture cannot prove the CURRENT tree's byte output is
|
||||
// preserved, only that the derivation LOGIC agrees with a made-up example.
|
||||
|
||||
// #3897 rung 3 — bin/install.js's `CODEX_AGENT_SANDBOX` map is DELETED
|
||||
// (ADR-3473 §8.3): HALT.md measured that deriving `workspace-write` iff an
|
||||
// agent's `tools:` frontmatter declares Write/Edit reproduces every one of
|
||||
// these 11 entries exactly, with zero disagreements, making the hand-
|
||||
// maintained map fully redundant. This literal is the pre-#3897
|
||||
// `CODEX_AGENT_SANDBOX` contents, preserved here as a regression baseline —
|
||||
// the only surviving copy of these 11 role -> mode pairs. T21 below and the
|
||||
// `CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)` describe
|
||||
// block both reference this ONE literal rather than duplicating it.
|
||||
const PRE_3897_CODEX_AGENT_SANDBOX = {
|
||||
'gsd-executor': 'workspace-write',
|
||||
'gsd-planner': 'workspace-write',
|
||||
'gsd-phase-researcher': 'workspace-write',
|
||||
'gsd-project-researcher': 'workspace-write',
|
||||
'gsd-research-synthesizer': 'workspace-write',
|
||||
'gsd-verifier': 'workspace-write',
|
||||
'gsd-codebase-mapper': 'workspace-write',
|
||||
'gsd-roadmapper': 'workspace-write',
|
||||
'gsd-debugger': 'workspace-write',
|
||||
'gsd-plan-checker': 'read-only',
|
||||
'gsd-integration-checker': 'read-only',
|
||||
};
|
||||
|
||||
describe('#3897 rung 3: sandbox_mode derivation and the hold list', () => {
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): T20/N6 used
|
||||
// to iterate `Object.keys(EXPECTED_SANDBOX_BY_ROLE)` and then pin
|
||||
// `assert.equal(checked, 35)` — that pins the FIXTURE, not the roster, so a
|
||||
// 36th agent added to `agents/` would be silently unchecked by the entire
|
||||
// rung-3 block instead of failing loudly. Driven from the real roster
|
||||
// instead; a dedicated parity test below fails loudly, naming any file
|
||||
// present in one set and not the other, the moment the two diverge.
|
||||
const AGENT_ROSTER_ROLES = fs
|
||||
.readdirSync(AGENTS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => f.slice(0, -'.md'.length))
|
||||
.sort();
|
||||
|
||||
function realAgentToolsRaw(agentName) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${agentName}.md`), 'utf8');
|
||||
// #3897 list-form parse fix: route through the SAME shared extractor
|
||||
// production uses, rather than a naive single-line regex here — a
|
||||
// second, test-local reimplementation of "read the tools: value" is
|
||||
// exactly the generative-fix-divergence shape that let this test's own
|
||||
// `measuredWideningRoles` silently miss `gsd-nyquist-auditor` (YAML
|
||||
// list-form `tools:`) before this fix.
|
||||
return extractToolsValue(content) ?? '';
|
||||
}
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, NIT finding 7): this used to
|
||||
// be a SEPARATE, simpler reimplementation of the real, private
|
||||
// `_codexToolsDeclareWriteOrEdit` (codex-agent-toml.cts) — a naive
|
||||
// comma-split-and-includes check that does not handle the real predicate's
|
||||
// "except" negation form (F5). That is exactly the generative-fix-
|
||||
// divergence shape CLAUDE.md warns about: a future change to the real
|
||||
// predicate this copy does not mirror would silently disagree with it
|
||||
// forever. `_codexToolsDeclareWriteOrEdit` is intentionally not exported
|
||||
// (module-internal), so rather than reimplementing it a second time, this
|
||||
// delegates to the REAL implementation through the public
|
||||
// `deriveCodexSandboxMode`, pinned to an identity guaranteed to never be
|
||||
// held or "suspicious" (F3) — with no hold in play,
|
||||
// `deriveCodexSandboxMode(identity, toolsRaw) === 'workspace-write'` IS
|
||||
// `_codexToolsDeclareWriteOrEdit(toolsRaw)`, byte for byte, because there is
|
||||
// no second copy left to drift.
|
||||
const PARITY_PROBE_IDENTITY = 'zzz-parity-probe-never-a-real-or-held-role';
|
||||
function declaresWriteOrEdit(toolsRaw) {
|
||||
return deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, toolsRaw) === 'workspace-write';
|
||||
}
|
||||
|
||||
test('sanity: the parity-probe identity used by declaresWriteOrEdit is never itself held or suspicious', () => {
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, PARITY_PROBE_IDENTITY),
|
||||
false,
|
||||
'the probe identity must not collide with a real hold, or every T20/T21/T24 verdict derived from it would be silently wrong',
|
||||
);
|
||||
assert.equal(
|
||||
deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, 'Read, Write, Edit'),
|
||||
'workspace-write',
|
||||
'sanity: an unheld identity that declares Write/Edit must derive workspace-write',
|
||||
);
|
||||
});
|
||||
|
||||
// T20 (LOAD-BEARING, N6): frozen fixture of TODAY's real, per-role emitted
|
||||
// sandbox_mode for all 35 roles in agents/ — captured from the CURRENT build
|
||||
// by running the REAL generateCodexAgentToml against the REAL agent .md
|
||||
// content (never a synthetic fixture) and committed here. This is the
|
||||
// safety net for the refactor: it is true today (nothing has changed yet)
|
||||
// and MUST remain true, per role, after sandbox_mode moves from the map to a
|
||||
// derivation — an aggregate "35 roles emitted" count would pass even if one
|
||||
// role silently widened; asserting per role (one test per role, mirroring
|
||||
// codex-agent-toml.test.cjs's A14 round-trip pattern) does not let that hide.
|
||||
const EXPECTED_SANDBOX_BY_ROLE = {
|
||||
'gsd-advisor-researcher': 'read-only',
|
||||
'gsd-ai-researcher': 'read-only',
|
||||
'gsd-assumptions-analyzer': 'read-only',
|
||||
'gsd-code-fixer': 'read-only',
|
||||
'gsd-code-reviewer': 'read-only',
|
||||
'gsd-codebase-mapper': 'workspace-write',
|
||||
'gsd-debug-session-manager': 'read-only',
|
||||
'gsd-debugger': 'workspace-write',
|
||||
'gsd-doc-classifier': 'read-only',
|
||||
'gsd-doc-synthesizer': 'read-only',
|
||||
'gsd-doc-verifier': 'read-only',
|
||||
'gsd-doc-writer': 'read-only',
|
||||
'gsd-dom-verifier': 'read-only',
|
||||
'gsd-domain-researcher': 'read-only',
|
||||
'gsd-eval-auditor': 'read-only',
|
||||
'gsd-eval-planner': 'read-only',
|
||||
'gsd-executor': 'workspace-write',
|
||||
'gsd-framework-selector': 'read-only',
|
||||
'gsd-integration-checker': 'read-only',
|
||||
'gsd-intel-updater': 'read-only',
|
||||
'gsd-mempalace-curator': 'read-only',
|
||||
'gsd-nyquist-auditor': 'read-only',
|
||||
'gsd-pattern-mapper': 'read-only',
|
||||
'gsd-phase-researcher': 'workspace-write',
|
||||
'gsd-plan-checker': 'read-only',
|
||||
'gsd-planner': 'workspace-write',
|
||||
'gsd-project-researcher': 'workspace-write',
|
||||
'gsd-research-synthesizer': 'workspace-write',
|
||||
'gsd-roadmapper': 'workspace-write',
|
||||
'gsd-security-auditor': 'read-only',
|
||||
'gsd-ui-auditor': 'read-only',
|
||||
'gsd-ui-checker': 'read-only',
|
||||
'gsd-ui-researcher': 'read-only',
|
||||
'gsd-user-profiler': 'read-only',
|
||||
'gsd-verifier': 'workspace-write',
|
||||
};
|
||||
|
||||
// The 17 roles that widen: declare Write/Edit, not in the pre-#3897
|
||||
// CODEX_AGENT_SANDBOX map (now deleted; PRE_3897_CODEX_AGENT_SANDBOX above
|
||||
// is the surviving baseline of its contents), so today's `|| 'read-only'`
|
||||
// fallback under-grants them (16 measured by HALT.md + gsd-nyquist-auditor,
|
||||
// whose YAML block-list `tools:` the pre-fix single-line reader misread as
|
||||
// `"- Read"`, hiding its Write/Edit declaration). Computed here from the REAL
|
||||
// agents/*.md tools frontmatter and that baseline — never hand-copied from
|
||||
// HALT.md's prose — so this list cannot silently drift from what agents/
|
||||
// actually declares (T30: "derived, not a second hardcoded copy").
|
||||
const measuredWideningRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).filter((role) => {
|
||||
const declaresWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
|
||||
const inOldMap = Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role);
|
||||
return declaresWrite && !inOldMap;
|
||||
});
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): the roster
|
||||
// and the expectation table must cover EXACTLY the same set of roles — a
|
||||
// role present in one and not the other fails loudly here, by name,
|
||||
// instead of being silently unchecked by the whole rung-3 block.
|
||||
test('T20 roster parity: EXPECTED_SANDBOX_BY_ROLE covers exactly the real agents/ roster, no more, no fewer', () => {
|
||||
const expectedRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).sort();
|
||||
const inRosterNotExpected = AGENT_ROSTER_ROLES.filter((r) => !expectedRoles.includes(r));
|
||||
const inExpectedNotRoster = expectedRoles.filter((r) => !AGENT_ROSTER_ROLES.includes(r));
|
||||
assert.deepEqual(
|
||||
inRosterNotExpected,
|
||||
[],
|
||||
`agents/ contains role(s) with no EXPECTED_SANDBOX_BY_ROLE entry: ${JSON.stringify(inRosterNotExpected)} — add them or this rung's coverage silently skips them`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inExpectedNotRoster,
|
||||
[],
|
||||
`EXPECTED_SANDBOX_BY_ROLE names role(s) no longer present in agents/: ${JSON.stringify(inExpectedNotRoster)} — remove the stale entry`,
|
||||
);
|
||||
});
|
||||
|
||||
for (const role of AGENT_ROSTER_ROLES) {
|
||||
test(`T20 everyRoleEmitsTheSameSandboxAsBefore: ${role} emits sandbox_mode="${EXPECTED_SANDBOX_BY_ROLE[role]}" byte-identically`, () => {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
|
||||
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
|
||||
);
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||||
const toml = generateCodexAgentToml(role, content);
|
||||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||||
assert.ok(match, `${role}'s emitted TOML must contain a sandbox_mode line`);
|
||||
assert.equal(
|
||||
match[1],
|
||||
EXPECTED_SANDBOX_BY_ROLE[role],
|
||||
`${role} must emit the SAME sandbox_mode after the derivation refactor lands — a per-role regression, not an aggregate count`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('T30 holdListMatchesTheMeasuredWideningSet: CODEX_SANDBOX_HOLDS is exactly the 17 measured widening roles, derived not hardcoded twice', () => {
|
||||
assert.equal(
|
||||
typeof CODEX_SANDBOX_HOLDS,
|
||||
'object',
|
||||
'install.js must export CODEX_SANDBOX_HOLDS — the hold list does not exist yet',
|
||||
);
|
||||
assert.notEqual(CODEX_SANDBOX_HOLDS, null);
|
||||
assert.deepEqual(
|
||||
Object.keys(CODEX_SANDBOX_HOLDS).sort(),
|
||||
measuredWideningRoles.sort(),
|
||||
'CODEX_SANDBOX_HOLDS must equal exactly the set of roles that declare Write/Edit but were never in the old map — no more, no fewer',
|
||||
);
|
||||
// 16 measured by HALT.md against a single-line tools: reader + 1
|
||||
// (gsd-nyquist-auditor, YAML block-list tools: — the list-form parse fix)
|
||||
// = 17. `measuredWideningRoles` is computed from realAgentToolsRaw, which
|
||||
// now routes through the fixed extractToolsValue, so this count moves
|
||||
// WITH the parser fix rather than needing a second hand-edit.
|
||||
assert.equal(measuredWideningRoles.length, 17, 'sanity: 17 widening roles against the current agents/ tree, once list-form tools: parses correctly');
|
||||
});
|
||||
|
||||
test('T21 mappedRolesDeriveToTheirFormerValue: every former CODEX_AGENT_SANDBOX entry (11) derives to the identical value from its real tool contract', () => {
|
||||
for (const [role, formerValue] of Object.entries(PRE_3897_CODEX_AGENT_SANDBOX)) {
|
||||
const derivesWorkspaceWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
|
||||
const derived = derivesWorkspaceWrite ? 'workspace-write' : 'read-only';
|
||||
assert.equal(
|
||||
derived,
|
||||
formerValue,
|
||||
`${role}: the tool-contract derivation must reproduce the former map value exactly (HALT.md: zero disagreements across all 11)`,
|
||||
);
|
||||
}
|
||||
assert.equal(Object.keys(PRE_3897_CODEX_AGENT_SANDBOX).length, 11);
|
||||
});
|
||||
|
||||
test('T22 nonWritingFallbackRoleDerivesReadOnly: a fallback role declaring neither Write nor Edit derives read-only (S2)', () => {
|
||||
const role = 'gsd-user-profiler'; // tools: Read (no Write/Edit), never in the map
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role), false);
|
||||
assert.equal(declaresWriteOrEdit(realAgentToolsRaw(role)), false);
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||||
const toml = generateCodexAgentToml(role, content);
|
||||
assert.ok(toml.includes('sandbox_mode = "read-only"'));
|
||||
});
|
||||
|
||||
test('T23 heldRoleStaysReadOnlyWithARecordedReason: one of the 17 held roles stays read-only via an explicit, reasoned hold (S3)', () => {
|
||||
assert.equal(typeof CODEX_SANDBOX_HOLDS, 'object', 'CODEX_SANDBOX_HOLDS does not exist yet');
|
||||
const role = 'gsd-doc-writer'; // declares Write+Edit; one of HALT.md's 16
|
||||
assert.ok(declaresWriteOrEdit(realAgentToolsRaw(role)), 'sanity: this role must actually declare a writing tool');
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, role), `${role} must be an explicit hold entry`);
|
||||
const entry = CODEX_SANDBOX_HOLDS[role];
|
||||
const reason = typeof entry === 'string' ? entry : entry && entry.reason;
|
||||
assert.equal(typeof reason, 'string', `the hold for ${role} must carry a recorded reason string, not a bare boolean pin`);
|
||||
assert.ok(reason.length > 0);
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||||
const toml = generateCodexAgentToml(role, content);
|
||||
assert.ok(toml.includes('sandbox_mode = "read-only"'), 'byte-identical output despite the hold, per N6');
|
||||
});
|
||||
|
||||
test('T24 staleHoldFailsRatherThanBeingHonored: a hold whose role no longer derives broader must FAIL, naming the role (S4)', () => {
|
||||
assert.equal(
|
||||
typeof CODEX_SANDBOX_HOLDS,
|
||||
'object',
|
||||
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for staleness',
|
||||
);
|
||||
// Every REAL hold entry, right now, must still derive workspace-write from
|
||||
// the tool contract. A hold for a role whose tools no longer declare
|
||||
// Write/Edit is exactly the staleness this row exists to catch; without
|
||||
// this assertion the hold list is honored unconditionally forever, which
|
||||
// is the hand-maintained-subset-map defect this rung deletes, rebuilt one
|
||||
// list later (the ledger claim HALT.md makes).
|
||||
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
|
||||
assert.ok(
|
||||
declaresWriteOrEdit(realAgentToolsRaw(role)),
|
||||
`stale hold: ${role} is pinned to read-only but its CURRENT tool contract no longer declares Write/Edit — this hold must fail validation, not be silently honored`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('T25 holdForUnknownRoleFails: a hold naming a role that no longer exists in agents/ must FAIL (S5)', () => {
|
||||
assert.equal(
|
||||
typeof CODEX_SANDBOX_HOLDS,
|
||||
'object',
|
||||
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for an unknown role',
|
||||
);
|
||||
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(AGENTS_DIR, `${role}.md`)),
|
||||
`stale hold: ${role} is pinned but no longer exists in agents/ — this hold must fail validation`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('T26 newWritingRoleGetsTheContractNotThePin: a brand-new agent declaring Write, with no hold, derives workspace-write (S6) — RED today, falls back to read-only', () => {
|
||||
const newAgentContent = `---
|
||||
name: gsd-totally-new-agent
|
||||
description: A brand-new writing agent that has never been in the map or a hold
|
||||
tools: Read, Write, Bash
|
||||
---
|
||||
|
||||
<role>You are a brand-new agent.</role>`;
|
||||
// Today: CODEX_AGENT_SANDBOX['gsd-totally-new-agent'] is undefined, so the
|
||||
// `|| 'read-only'` fallback silently under-grants it — the exact defect
|
||||
// §8.3 exists to fix (24 of 35 roles fell through this fallback).
|
||||
const toml = generateCodexAgentToml('gsd-totally-new-agent', newAgentContent);
|
||||
assert.ok(
|
||||
toml.includes('sandbox_mode = "workspace-write"'),
|
||||
'a new agent declaring Write, absent from both the map and any hold, must derive workspace-write from its own tool contract — not silently fall back to read-only',
|
||||
);
|
||||
});
|
||||
|
||||
test('T27 absentToolContractIsNotAGrant: an agent with no tools: frontmatter at all derives read-only (N8/S7)', () => {
|
||||
const noToolsContent = `---
|
||||
name: gsd-no-contract-agent
|
||||
description: Declares no tools frontmatter key at all
|
||||
---
|
||||
|
||||
<role>You have no declared tools.</role>`;
|
||||
const toml = generateCodexAgentToml('gsd-no-contract-agent', noToolsContent);
|
||||
assert.ok(
|
||||
toml.includes('sandbox_mode = "read-only"'),
|
||||
'absence of a tools: contract must never be read as a grant of workspace-write',
|
||||
);
|
||||
});
|
||||
|
||||
// #3897 security review follow-up (post-merge blocker): `bin/install.js`'s
|
||||
// Codex install loop used to key the CODEX_SANDBOX_HOLDS lookup off the
|
||||
// agent's OWN frontmatter `name:` field (`extractFrontmatterField(frontmatter,
|
||||
// 'name') || file.replace('.md', '')`) rather than its filename — so editing,
|
||||
// or merely recasing, a held role's `name:` field (same file, same tool
|
||||
// contract) silently derived `workspace-write` instead of the pinned
|
||||
// `read-only`. Unlike the deleted CODEX_AGENT_SANDBOX map (an ALLOWLIST whose
|
||||
// unmatched-key fallback was `read-only`, i.e. safe), CODEX_SANDBOX_HOLDS is a
|
||||
// SUBTRACTION from a derivation that defaults to `workspace-write`, so the
|
||||
// identical lookup-key mismatch now fails OPEN — a severity flip. The fix
|
||||
// keys the hold off the canonical source FILENAME stem (what
|
||||
// `validateCodexSandboxHolds` already verifies exists), threaded through
|
||||
// `installCodexConfig`'s per-file loop independently of the frontmatter
|
||||
// `name:` used for the TOML body, with a case-insensitive lookup as a second
|
||||
// line of defense.
|
||||
test('heldRoleCannotEscapeItsHoldByRenamingFrontmatter_3897: editing or recasing a held role\'s frontmatter name: must not change its sandbox_mode from read-only', () => {
|
||||
const { installCodexConfig } = require('../bin/install.js');
|
||||
const heldRole = 'gsd-doc-writer'; // one of the 17 CODEX_SANDBOX_HOLDS entries
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, heldRole),
|
||||
`sanity: ${heldRole} must be a real CODEX_SANDBOX_HOLDS entry`,
|
||||
);
|
||||
|
||||
const variants = [
|
||||
{ label: 'frontmatter name: edited to a different value', newName: 'gsd-doc-writer-x' },
|
||||
{ label: 'frontmatter name: merely recased', newName: 'GSD-Doc-Writer' },
|
||||
];
|
||||
|
||||
for (const { label, newName } of variants) {
|
||||
const tmpAgentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-tamper-src-'));
|
||||
const tmpDest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-tamper-dest-'));
|
||||
try {
|
||||
fs.cpSync(AGENTS_DIR, tmpAgentsSrc, { recursive: true });
|
||||
const rolePath = path.join(tmpAgentsSrc, `${heldRole}.md`);
|
||||
const original = fs.readFileSync(rolePath, 'utf8');
|
||||
const tampered = original.replace(/^name:\s*.*$/m, `name: ${newName}`);
|
||||
assert.notEqual(tampered, original, `sanity: the frontmatter name: line must actually change (${label})`);
|
||||
fs.writeFileSync(rolePath, tampered);
|
||||
|
||||
// The FILE on disk is untouched (still `gsd-doc-writer.md`) — only its
|
||||
// frontmatter content changed. validateCodexSandboxHolds only checks
|
||||
// the file exists, so it does not (and should not) catch this by itself.
|
||||
installCodexConfig(tmpDest, tmpAgentsSrc);
|
||||
|
||||
const emittedTomlPath = path.join(tmpDest, 'agents', `${newName}.toml`);
|
||||
assert.ok(
|
||||
fs.existsSync(emittedTomlPath),
|
||||
`${label}: expected an emitted .toml at ${emittedTomlPath} (named after the tampered frontmatter name, per existing TOML-naming behavior — unrelated to this fix)`,
|
||||
);
|
||||
const toml = fs.readFileSync(emittedTomlPath, 'utf8');
|
||||
const sandboxLine = toml.match(/^sandbox_mode = "([^"]{0,50})"$/m);
|
||||
assert.ok(sandboxLine, `${label}: emitted .toml must contain a sandbox_mode line`);
|
||||
assert.equal(
|
||||
sandboxLine[1],
|
||||
'read-only',
|
||||
`${label}: a held role's sandbox_mode must stay read-only even when its frontmatter name: diverges ` +
|
||||
`from its own filename — the hold is keyed off the file, not a self-declared field. Got: ${sandboxLine[1]}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpAgentsSrc);
|
||||
cleanup(tmpDest);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('N6 (post-fix regression check): every real agents/ role remains byte-identical in emitted sandbox_mode after the filename-identity fix', () => {
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): driven
|
||||
// from the real roster (AGENT_ROSTER_ROLES), not the hardcoded
|
||||
// EXPECTED_SANDBOX_BY_ROLE key set — the roster-parity test above already
|
||||
// fails loudly if the two sets ever diverge, so `checked` here is a
|
||||
// genuine roster count, not a fixture-pinned literal that would silently
|
||||
// stop growing when a 36th agent lands.
|
||||
let checked = 0;
|
||||
for (const role of AGENT_ROSTER_ROLES) {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
|
||||
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
|
||||
);
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||||
const toml = generateCodexAgentToml(role, content);
|
||||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||||
assert.ok(match, `${role} must emit a sandbox_mode line`);
|
||||
assert.equal(
|
||||
match[1],
|
||||
EXPECTED_SANDBOX_BY_ROLE[role],
|
||||
`${role}: sandbox_mode must not drift as a side effect of the #3897 filename-identity fix`,
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
assert.equal(
|
||||
checked,
|
||||
AGENT_ROSTER_ROLES.length,
|
||||
'N6: every real role in agents/ must be checked and byte-identical',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3897 security review F1/F3/F4/F5 regressions ─────────────────────────
|
||||
//
|
||||
// Isolated security review of this rung found a fail-open: `bin/install.js`'s
|
||||
// Codex emit loop DECIDED sandbox_mode for the source filename stem but
|
||||
// APPLIED it to the emitted `.toml`'s path, which is keyed on the
|
||||
// frontmatter `name:` value instead — so a renamed file, or a sibling file
|
||||
// whose `name:` collides with a held role, could land a held role's own
|
||||
// artifact at `workspace-write`. F1(a)/F1(b) below assert on the EMITTED
|
||||
// ARTIFACT (the written `.toml`'s `sandbox_mode`), never on
|
||||
// `deriveCodexSandboxMode`'s return value directly — the whole defect is
|
||||
// that the derivation and the emitted artifact could disagree.
|
||||
describe('#3897 security review: F1 filename/name identity confusion, F3 confusables, F4/F5 totality', () => {
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
const {
|
||||
installCodexConfig,
|
||||
deriveCodexSandboxMode: deriveCodexSandboxModeLocal,
|
||||
} = require('../bin/install.js');
|
||||
const {
|
||||
normalizeSandboxIdentity,
|
||||
isSandboxHeld,
|
||||
extractToolsValue: extractToolsValueLocal,
|
||||
} = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
||||
|
||||
function sandboxModeOfToml(toml) {
|
||||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
test('F1(a) rename case: a source file whose FILENAME differs from a held role, but whose frontmatter name: IS the held role, emits read-only', () => {
|
||||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1a-src-'));
|
||||
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1a-dest-'));
|
||||
try {
|
||||
const original = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-doc-writer.md'), 'utf8');
|
||||
assert.ok(/^name:\s*gsd-doc-writer\s*$/m.test(original), 'sanity: fixture source declares name: gsd-doc-writer');
|
||||
// Renamed FILE, unchanged frontmatter name: — the fileStem no longer
|
||||
// matches the held role, but the emitted TOML is still named after
|
||||
// `gsd-doc-writer` (the frontmatter name:).
|
||||
fs.writeFileSync(path.join(src, 'gsd-doc-writer-v2.md'), original);
|
||||
|
||||
installCodexConfig(dest, src);
|
||||
|
||||
const emittedPath = path.join(dest, 'agents', 'gsd-doc-writer.toml');
|
||||
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
|
||||
const toml = fs.readFileSync(emittedPath, 'utf8');
|
||||
assert.equal(
|
||||
sandboxModeOfToml(toml),
|
||||
'read-only',
|
||||
'F1(a): a held role\'s own emitted .toml must stay read-only even when reached via a renamed source file whose filename stem is unheld',
|
||||
);
|
||||
} finally {
|
||||
cleanup(src);
|
||||
cleanup(dest);
|
||||
}
|
||||
});
|
||||
|
||||
test('F1(b) sibling-clobber case: a second, unheld source file whose frontmatter name: IS a held role must not widen the held role\'s emitted .toml', () => {
|
||||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1b-src-'));
|
||||
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1b-dest-'));
|
||||
try {
|
||||
const heldOriginal = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-doc-writer.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(src, 'gsd-doc-writer.md'), heldOriginal);
|
||||
// A sibling file — different filename stem, no hold entry for IT — but
|
||||
// its frontmatter `name:` collides with the held role. Named to sort
|
||||
// AFTER `gsd-doc-writer.md` (readdirSync/emit-loop order is
|
||||
// alphabetical) so this sibling's write is the LAST one to the shared
|
||||
// `gsd-doc-writer.toml` output path — the exact ordering the pre-fix
|
||||
// defect needed to actually clobber the held role's own artifact
|
||||
// (a sibling sorting BEFORE it gets silently overwritten again by the
|
||||
// legitimate file's own correct read-only write, masking the bug).
|
||||
const sibling = [
|
||||
'---',
|
||||
'name: gsd-doc-writer',
|
||||
'description: sibling file colliding on frontmatter name',
|
||||
'tools: Read, Write, Edit',
|
||||
'---',
|
||||
'',
|
||||
'<role>sibling</role>',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(src, 'gsd-zzz-attacker-clone.md'), sibling);
|
||||
|
||||
installCodexConfig(dest, src);
|
||||
|
||||
const emittedPath = path.join(dest, 'agents', 'gsd-doc-writer.toml');
|
||||
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
|
||||
const toml = fs.readFileSync(emittedPath, 'utf8');
|
||||
assert.equal(
|
||||
sandboxModeOfToml(toml),
|
||||
'read-only',
|
||||
'F1(b): a sibling file whose frontmatter name: collides with a held role must not clobber that role\'s emitted .toml with workspace-write',
|
||||
);
|
||||
} finally {
|
||||
cleanup(src);
|
||||
cleanup(dest);
|
||||
}
|
||||
});
|
||||
|
||||
const F3_CONFUSABLE_VECTORS = [
|
||||
['Turkish dotted I (İ)', 'gsd-doc-wrİter'],
|
||||
['Turkish dotless i (ı)', 'gsd-doc-wrıter'],
|
||||
['fullwidth leading g (g)', 'gsd-doc-writer'],
|
||||
['NFD combining acute on r (writeŕ)', 'gsd-doc-writeŕ'],
|
||||
['trailing ASCII space', 'gsd-doc-writer '],
|
||||
['trailing NBSP', 'gsd-doc-writer '],
|
||||
['trailing dot', 'gsd-doc-writer.'],
|
||||
['trailing newline', 'gsd-doc-writer\n'],
|
||||
['trailing carriage return', 'gsd-doc-writer\r'],
|
||||
['relative-path prefix ./', './gsd-doc-writer'],
|
||||
['path traversal ../agents/', '../agents/gsd-doc-writer'],
|
||||
];
|
||||
|
||||
for (const [label, vector] of F3_CONFUSABLE_VECTORS) {
|
||||
test(`F3 confusable/whitespace/path vector — ${label} — derives read-only`, () => {
|
||||
const mode = deriveCodexSandboxModeLocal(vector, 'Read, Write, Edit');
|
||||
assert.equal(
|
||||
mode,
|
||||
'read-only',
|
||||
`F3: identity ${JSON.stringify(vector)} (${label}) must derive read-only — either it normalizes onto the real held key, or it is unrecognizable and must fail closed`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('F3: isSandboxHeld flags each confusable vector as held or suspicious (never silently neither)', () => {
|
||||
for (const [label, vector] of F3_CONFUSABLE_VECTORS) {
|
||||
const { held, suspicious } = isSandboxHeld(vector);
|
||||
assert.ok(held || suspicious, `${label} (${JSON.stringify(vector)}) must be held or suspicious`);
|
||||
}
|
||||
});
|
||||
|
||||
test('F5: "All tools except Write, Edit" derives read-only (negation excludes Write/Edit)', () => {
|
||||
const mode = deriveCodexSandboxModeLocal('gsd-negation-agent', 'All tools except Write, Edit');
|
||||
assert.equal(mode, 'read-only', 'excluding Write and Edit after "except" must derive read-only');
|
||||
});
|
||||
|
||||
test('F5: "All tools except Agent" derives workspace-write (Write/Edit are not excluded)', () => {
|
||||
const mode = deriveCodexSandboxModeLocal('gsd-negation-agent', 'All tools except Agent');
|
||||
assert.equal(mode, 'workspace-write', 'excluding only Agent leaves Write/Edit granted, so this must still derive workspace-write');
|
||||
});
|
||||
|
||||
test('totality: deriveCodexSandboxMode never throws for undefined/null/[]/[undefined] identities', () => {
|
||||
for (const identity of [undefined, null, [], [undefined]]) {
|
||||
assert.doesNotThrow(
|
||||
() => deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit'),
|
||||
`deriveCodexSandboxMode must not throw for identity ${JSON.stringify(identity)}`,
|
||||
);
|
||||
const result = deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit');
|
||||
assert.equal(typeof result, 'string', `deriveCodexSandboxMode must return a string for identity ${JSON.stringify(identity)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 4): two exotic
|
||||
// identity shapes more adversarial than the undefined/null/[]/[undefined]
|
||||
// set above — an object whose OWN toString throws, and a null-prototype
|
||||
// object (no inherited Object.prototype methods at all, so even a
|
||||
// defensive `.toString`/`.hasOwnProperty` call site would blow up). Both
|
||||
// must derive a string, never throw — "TOTAL for any input" per this
|
||||
// function's own docstring is not just for well-behaved falsy/array shapes.
|
||||
test('totality: deriveCodexSandboxMode never throws for an identity with a throwing toString', () => {
|
||||
const hostile = { toString() { throw new Error('id-boom'); } };
|
||||
assert.doesNotThrow(
|
||||
() => deriveCodexSandboxModeLocal(hostile, 'Read'),
|
||||
'deriveCodexSandboxMode must not throw for an identity whose toString throws',
|
||||
);
|
||||
assert.equal(typeof deriveCodexSandboxModeLocal(hostile, 'Read'), 'string');
|
||||
});
|
||||
|
||||
test('totality: deriveCodexSandboxMode never throws for a null-prototype identity object', () => {
|
||||
const nullProto = Object.create(null);
|
||||
assert.doesNotThrow(
|
||||
() => deriveCodexSandboxModeLocal(nullProto, 'Read'),
|
||||
'deriveCodexSandboxMode must not throw for a null-prototype identity object',
|
||||
);
|
||||
assert.equal(typeof deriveCodexSandboxModeLocal(nullProto, 'Read'), 'string');
|
||||
});
|
||||
|
||||
test('totality: normalizeSandboxIdentity never throws for undefined/null/non-string input', () => {
|
||||
for (const raw of [undefined, null, 42, {}, []]) {
|
||||
assert.doesNotThrow(() => normalizeSandboxIdentity(raw), `must not throw for ${JSON.stringify(raw)}`);
|
||||
assert.equal(normalizeSandboxIdentity(raw), null, `non-string input ${JSON.stringify(raw)} must normalize to null`);
|
||||
}
|
||||
});
|
||||
|
||||
test('F4: extractToolsValue returns undefined (never throws) for undefined/null/Buffer input', () => {
|
||||
for (const value of [undefined, null, Buffer.from('tools: Write')]) {
|
||||
assert.doesNotThrow(() => extractToolsValueLocal(value), `extractToolsValue must not throw for ${String(value)}`);
|
||||
assert.equal(extractToolsValueLocal(value), undefined, `extractToolsValue must return undefined for non-string input ${String(value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('F4: extractToolsValue still returns the parsed value for real string content — inline form (no regression)', () => {
|
||||
const content = ['---', 'tools: Read, Write', '---', ''].join('\n');
|
||||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||||
});
|
||||
|
||||
// ─── #3897 list-form parse fix: YAML block-list `tools:` ─────────────────
|
||||
//
|
||||
// `agents/gsd-nyquist-auditor.md` and `agents/gsd-security-auditor.md` are
|
||||
// the only two roster files using this shape. The pre-fix single-line
|
||||
// regex `/^tools:\s*(.+)$/m` let `\s*` swallow the newline after a bare
|
||||
// `tools:` key and matched into the FIRST list item's own line, returning
|
||||
// just `"- Read"` — a real Write/Edit DECLARATION read as an absence.
|
||||
|
||||
test('list-form: a tools: block list declaring Write derives workspace-write (FAILS before the parse fix — pre-fix reader returned "- Read")', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd-list-form-writer',
|
||||
'tools:',
|
||||
' - Read',
|
||||
' - Write',
|
||||
' - Edit',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'<role>list-form writer</role>',
|
||||
].join('\n');
|
||||
const toolsRaw = extractToolsValueLocal(content);
|
||||
assert.equal(toolsRaw, 'Read, Write, Edit, Bash', 'list items must be joined the same way the comma-tokenizer downstream expects');
|
||||
assert.equal(
|
||||
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
|
||||
'workspace-write',
|
||||
'a tools: block list declaring Write must derive workspace-write, not read-only from a truncated first-item read',
|
||||
);
|
||||
});
|
||||
|
||||
test('list-form: a tools: block list with NO write tool derives read-only (no over-correction)', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd-list-form-reader',
|
||||
'tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' - Glob',
|
||||
'---',
|
||||
'',
|
||||
'<role>list-form reader</role>',
|
||||
].join('\n');
|
||||
const toolsRaw = extractToolsValueLocal(content);
|
||||
assert.equal(toolsRaw, 'Read, Bash, Glob');
|
||||
assert.equal(
|
||||
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
|
||||
'read-only',
|
||||
'a list-form tools: with no Write/Edit item must still derive read-only',
|
||||
);
|
||||
});
|
||||
|
||||
test('list-form: the list terminates at the next frontmatter key and does not swallow its value', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd-list-form-terminates',
|
||||
'tools:',
|
||||
' - Read',
|
||||
' - Write',
|
||||
'color: blue',
|
||||
'---',
|
||||
'',
|
||||
'<role>list-form terminates before a sibling key</role>',
|
||||
].join('\n');
|
||||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||||
// The sibling key's own value must remain independently readable — it
|
||||
// must never have been consumed as a phantom third list item.
|
||||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||||
assert.equal(extractFrontmatterField(frontmatter, 'color'), 'blue');
|
||||
});
|
||||
|
||||
test('list-form: the list terminates at the closing --- and does not run past the frontmatter', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd-list-form-eof',
|
||||
'tools:',
|
||||
' - Read',
|
||||
' - Write',
|
||||
'---',
|
||||
'- this looks like a list item but is BODY text, not frontmatter',
|
||||
].join('\n');
|
||||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||||
});
|
||||
|
||||
test('roster truth: gsd-nyquist-auditor DERIVES workspace-write from its real tool contract AND is HELD, so its emitted .toml stays read-only', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-nyquist-auditor.md'), 'utf8');
|
||||
const toolsRaw = extractToolsValueLocal(content);
|
||||
assert.ok(
|
||||
/\bWrite\b/.test(toolsRaw) && /\bEdit\b/.test(toolsRaw),
|
||||
`sanity: gsd-nyquist-auditor's real tools: must declare both Write and Edit, got ${JSON.stringify(toolsRaw)}`,
|
||||
);
|
||||
// Derivation WITHOUT the hold (an identity guaranteed never held/suspicious,
|
||||
// same probe idiom as the rung-3 describe block's PARITY_PROBE_IDENTITY)
|
||||
// must show the role genuinely derives workspace-write from its contract —
|
||||
// this is what proves derive-and-hold is doing real work, not that the
|
||||
// parser happens to agree with the pin by accident.
|
||||
assert.equal(
|
||||
deriveCodexSandboxModeLocal('zzz-nyquist-unheld-probe-never-a-real-role', toolsRaw),
|
||||
'workspace-write',
|
||||
'gsd-nyquist-auditor must genuinely derive workspace-write from its tool contract once list-form tools: parses correctly',
|
||||
);
|
||||
// The REAL identity IS held, so the actual emitted artifact stays read-only.
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, 'gsd-nyquist-auditor'),
|
||||
'gsd-nyquist-auditor must be an explicit CODEX_SANDBOX_HOLDS entry',
|
||||
);
|
||||
const { generateCodexAgentToml: generateCodexAgentTomlLocal } = require('../bin/install.js');
|
||||
const toml = generateCodexAgentTomlLocal('gsd-nyquist-auditor', content);
|
||||
assert.ok(
|
||||
toml.includes('sandbox_mode = "read-only"'),
|
||||
'gsd-nyquist-auditor\'s emitted .toml must stay read-only (held), even though it now derives workspace-write',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3241: shared isAnthropicFlavoredModel / CLAUDE_AGENT_ALIASES surface ─────
|
||||
// Phase 2/3 need one predicate. 40-design.md's "seam decision" moves
|
||||
// CLAUDE_AGENT_ALIASES into model-catalog.cjs and defines isAnthropicFlavoredModel
|
||||
@@ -1091,29 +1862,46 @@ describe('installCodexConfig sandboxTier threading seam', () => {
|
||||
// codex.sandboxTier === 'codex-agent-sandbox') confirms the real registry has
|
||||
// valid values for all 15 runtimes.
|
||||
|
||||
// ─── CODEX_AGENT_SANDBOX mapping ────────────────────────────────────────────────
|
||||
// ─── CODEX_AGENT_SANDBOX (deleted map): derivation regression baseline ──────────
|
||||
|
||||
describe('CODEX_AGENT_SANDBOX', () => {
|
||||
test('has all 11 agents mapped', () => {
|
||||
const agentNames = Object.keys(CODEX_AGENT_SANDBOX);
|
||||
describe('CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)', () => {
|
||||
// bin/install.js's CODEX_AGENT_SANDBOX map is gone (ADR-3473 §8.3, #3897
|
||||
// rung 3) — deriveCodexSandboxMode is the sole owner of sandbox_mode now.
|
||||
// These tests assert the SAME underlying property the deleted map's own
|
||||
// suite used to assert (which 11 roles get which sandbox_mode), but against
|
||||
// the real derivation and the real agents/*.md content, sourced from the
|
||||
// PRE_3897_CODEX_AGENT_SANDBOX baseline literal above rather than the map.
|
||||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
function realDerivedSandboxMode(role) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||||
const toolsRaw = extractFrontmatterField(frontmatter || '', 'tools') || '';
|
||||
return deriveCodexSandboxMode(role, toolsRaw);
|
||||
}
|
||||
|
||||
test('has all 11 baseline agents', () => {
|
||||
const agentNames = Object.keys(PRE_3897_CODEX_AGENT_SANDBOX);
|
||||
assert.strictEqual(agentNames.length, 11, 'has 11 agents');
|
||||
});
|
||||
|
||||
test('workspace-write agents have write tools', () => {
|
||||
test('workspace-write agents still derive workspace-write', () => {
|
||||
const writeAgents = [
|
||||
'gsd-executor', 'gsd-planner', 'gsd-phase-researcher',
|
||||
'gsd-project-researcher', 'gsd-research-synthesizer', 'gsd-verifier',
|
||||
'gsd-codebase-mapper', 'gsd-roadmapper', 'gsd-debugger',
|
||||
];
|
||||
for (const name of writeAgents) {
|
||||
assert.strictEqual(CODEX_AGENT_SANDBOX[name], 'workspace-write', `${name} is workspace-write`);
|
||||
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'workspace-write', `${name} baseline is workspace-write`);
|
||||
assert.strictEqual(realDerivedSandboxMode(name), 'workspace-write', `${name} still derives workspace-write`);
|
||||
}
|
||||
});
|
||||
|
||||
test('read-only agents have no write tools', () => {
|
||||
test('read-only agents still derive read-only', () => {
|
||||
const readOnlyAgents = ['gsd-plan-checker', 'gsd-integration-checker'];
|
||||
for (const name of readOnlyAgents) {
|
||||
assert.strictEqual(CODEX_AGENT_SANDBOX[name], 'read-only', `${name} is read-only`);
|
||||
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'read-only', `${name} baseline is read-only`);
|
||||
assert.strictEqual(realDerivedSandboxMode(name), 'read-only', `${name} still derives read-only`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { computeDependencyLevels } = require('../gsd-core/bin/lib/phase.cjs');
|
||||
const { computeDependencyLevels, buildShortFormToId } = require('../gsd-core/bin/lib/phase.cjs');
|
||||
const { extractCanonicalPlanId } = require('../gsd-core/bin/lib/core-utils.cjs');
|
||||
|
||||
// Helper: build rawPlans + planMap + canonicalToId from a simple spec.
|
||||
// spec is an array of { id, dependsOn } objects.
|
||||
@@ -240,3 +241,145 @@ describe('computeDependencyLevels — unresolved dependency reporting (#3427, AD
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3897 rung 4 (ADR-3473 §8.9) — shortFormToId, the recovered third
|
||||
// depends_on resolution tier (D3, 40-design.md), recovered from the retired
|
||||
// SDK lineage (sdk/src/query/phase.ts at 11918dcc3^) with one deliberate
|
||||
// narrowing (a trailing dash-segment must be a plan NUMBER — /^\d+$/ — see
|
||||
// {@link buildShortFormToId}'s own comment in phase.cts for why: an
|
||||
// unconstrained trailing segment let `depends_on: ["auth"]` silently
|
||||
// mis-resolve to whichever of two non-numeric-suffixed plans sorted first,
|
||||
// with zero warning — isolated correctness review, #3897).
|
||||
//
|
||||
// #3897 rung 4 (isolated correctness review, NIT finding 7): this file used
|
||||
// to reimplement the algorithm in-test (`buildShortFormMap`) and assert
|
||||
// against that COPY — exactly the generative-fix-divergence shape CLAUDE.md
|
||||
// warns about: after the numeric-only fix above landed here, the in-test
|
||||
// copy would have silently kept the OLD (wrong) behavior forever, agreeing
|
||||
// with itself while disagreeing with the real implementation. Fixed by
|
||||
// importing and asserting against the REAL exported
|
||||
// {@link buildShortFormToId} from phase.cjs — the same function
|
||||
// `cmdPhasePlanIndex` actually calls — so this file can never again drift
|
||||
// from its own subject.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildInputsWithShortForm(spec) {
|
||||
const rawPlans = spec.map((s) => ({ id: s.id, dependsOn: s.dependsOn ?? [] }));
|
||||
const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
|
||||
const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
|
||||
const shortFormToId = buildShortFormToId(rawPlans);
|
||||
return { rawPlans, planMap, canonicalToId, shortFormToId };
|
||||
}
|
||||
|
||||
describe('computeDependencyLevels — shortFormToId, the third depends_on tier (#3897 rung 4)', () => {
|
||||
|
||||
// T42 — RED today: a bare plan-number short form is dropped by both
|
||||
// existing tiers (planMap wants the full id, canonicalToId wants the
|
||||
// canonical prefix `24-01`), so 24-02 never resolves its dependency on
|
||||
// 24-01-auth-hardening and stays at level 0 instead of level 1.
|
||||
test('T42 shortFormPlanNumberResolves_3427: depends_on: ["01"] resolves to the in-phase sibling plan (D3)', () => {
|
||||
const { rawPlans, planMap, canonicalToId, shortFormToId } = buildInputsWithShortForm([
|
||||
{ id: '24-01-auth-hardening', dependsOn: [] },
|
||||
{ id: '24-02-token-rotation', dependsOn: ['01'] },
|
||||
]);
|
||||
assert.deepStrictEqual([...shortFormToId.entries()], [['01', '24-01-auth-hardening'], ['02', '24-02-token-rotation']]);
|
||||
|
||||
const result = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
||||
assert.strictEqual((result.unresolved ?? []).length, 0, 'the bare short form must resolve — no dropped edge');
|
||||
assert.strictEqual(result.level.get('24-01-auth-hardening'), 0);
|
||||
assert.strictEqual(
|
||||
result.level.get('24-02-token-rotation'),
|
||||
1,
|
||||
'the short-form dependency on "01" must place 24-02-token-rotation one level after 24-01-auth-hardening — today the edge is dropped and both sit at level 0',
|
||||
);
|
||||
});
|
||||
|
||||
// T44 (D4, boundary): two plans whose canonical id happens to share the
|
||||
// same trailing short form. First write (array/insertion order, which in
|
||||
// production is sorted-plan-file order) wins deterministically — the
|
||||
// retired SDK's own rule, preserved rather than improved (L2: "01" is
|
||||
// ambiguous by construction whenever it happens).
|
||||
test('T44 duplicateShortFormIsFirstWriteWinsDeterministically: the FIRST plan to claim a short form wins it, repeatably', () => {
|
||||
const spec = [
|
||||
{ id: '24-01-auth-hardening', dependsOn: [] }, // canonical '24-01' -> shortForm '01', inserted FIRST
|
||||
{ id: '9-01-legacy-carryover', dependsOn: [] }, // canonical '9-01' -> shortForm '01' too, inserted SECOND
|
||||
{ id: 'consumer', dependsOn: ['01'] },
|
||||
];
|
||||
// Run it twice — determinism means the SAME plan wins both times, not
|
||||
// just A time.
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const { rawPlans, planMap, canonicalToId, shortFormToId } = buildInputsWithShortForm(spec);
|
||||
assert.strictEqual(
|
||||
shortFormToId.get('01'),
|
||||
'24-01-auth-hardening',
|
||||
`run ${run}: the FIRST plan to declare short form "01" must win it, every time`,
|
||||
);
|
||||
const result = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
||||
assert.strictEqual((result.unresolved ?? []).length, 0);
|
||||
assert.strictEqual(
|
||||
result.level.get('consumer'),
|
||||
result.level.get('24-01-auth-hardening') + 1,
|
||||
`run ${run}: "consumer" must resolve to the FIRST-written short-form owner (24-01-auth-hardening), not the second (9-01-legacy-carryover)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// T45 (D5, boundary): a canonical id with no dash at all (e.g. a bare
|
||||
// phase-number-only plan id) must never be added to the short-form index —
|
||||
// `lastDash > 0` excludes it — and its own full-id dependency resolution
|
||||
// must be completely unaffected by the third tier's presence.
|
||||
test('T45 canonicalIdWithoutDashIsNotShortFormIndexed: a no-dash canonical id is excluded from the short-form tier and resolves unaffected (D5)', () => {
|
||||
const { rawPlans, planMap, canonicalToId, shortFormToId } = buildInputsWithShortForm([
|
||||
{ id: '24', dependsOn: [] }, // extractCanonicalPlanId('24') === '24' — no dash
|
||||
{ id: 'B', dependsOn: ['24'] },
|
||||
]);
|
||||
assert.strictEqual(shortFormToId.has('24'), false, 'a canonical id with no dash must never be added to the short-form index');
|
||||
assert.strictEqual(shortFormToId.size, 0);
|
||||
|
||||
const result = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
||||
assert.strictEqual(result.visited, 2);
|
||||
assert.strictEqual(result.level.get('24'), 0);
|
||||
assert.strictEqual(result.level.get('B'), 1, 'the full-id dependency must resolve exactly as before, unaffected by the (absent) short-form entry');
|
||||
});
|
||||
|
||||
// #3897 rung 4 (isolated correctness review, NIT finding 8a): the D5
|
||||
// comment on buildShortFormToId names TWO excluded shapes — `lastDash ===
|
||||
// 0` and a trailing dash — but only `lastDash === -1` (T45 above) was
|
||||
// ever exercised. Both boundary rows below.
|
||||
test('T46 lastDashAtIndexZeroIsNotShortFormIndexed: a canonical id starting with a dash (D5 boundary) is excluded', () => {
|
||||
// extractCanonicalPlanId('-01') === '-01' (no phase-token pair found —
|
||||
// splitting on '-' filters out the leading empty segment, leaving a
|
||||
// single token '01' with nothing after it to pair with), so
|
||||
// canonical.lastIndexOf('-') === 0 here — the D5 boundary this rung's
|
||||
// `lastDash > 0` guard must exclude.
|
||||
const { shortFormToId } = buildInputsWithShortForm([
|
||||
{ id: '-01', dependsOn: [] },
|
||||
]);
|
||||
assert.strictEqual(
|
||||
extractCanonicalPlanId('-01'),
|
||||
'-01',
|
||||
'sanity: extractCanonicalPlanId must leave this id unpaired, so lastDash === 0',
|
||||
);
|
||||
assert.strictEqual(shortFormToId.has('01'), false, 'a canonical id whose ONLY dash is at index 0 must never be added to the short-form index (D5)');
|
||||
assert.strictEqual(shortFormToId.size, 0);
|
||||
});
|
||||
|
||||
test('T47 trailingDashIsNotShortFormIndexed: a canonical id ending in a dash (D5 boundary) is excluded', () => {
|
||||
// extractCanonicalPlanId('09-') === '09-' (the trailing empty segment is
|
||||
// filtered out of `parts`, leaving no second token to pair with), so
|
||||
// canonical.lastIndexOf('-') === canonical.length - 1 here — the other
|
||||
// D5 boundary this rung's `lastDash < canonical.length - 1` guard must
|
||||
// exclude.
|
||||
const { shortFormToId } = buildInputsWithShortForm([
|
||||
{ id: '09-', dependsOn: [] },
|
||||
]);
|
||||
assert.strictEqual(
|
||||
extractCanonicalPlanId('09-'),
|
||||
'09-',
|
||||
'sanity: extractCanonicalPlanId must leave this id unpaired, so lastDash === canonical.length - 1',
|
||||
);
|
||||
assert.strictEqual(shortFormToId.size, 0, 'a canonical id ending in a dash must never be added to the short-form index (D5)');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1343,6 +1343,149 @@ describe('#3885 (ADR-3473 §8.5): phase-plan-index names a dropped depends_on to
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3897 rung 4 (ADR-3473 §8.9) — shortFormToId, the recovered third
|
||||
// depends_on resolution tier (D3). Today `resolveDependencyId`
|
||||
// (gsd-core/bin/lib/phase.cjs) has exactly two tiers — planMap (full id) and
|
||||
// canonicalToId (canonical prefix, e.g. `24-01`) — so a bare plan-number
|
||||
// short form (`depends_on: ["01"]`) resolves via NEITHER and is silently
|
||||
// dropped: the dependent plan collapses to a DAG root (wave 1) instead of its
|
||||
// declared wave.
|
||||
//
|
||||
// T43 is the CONSUMER-OUTPUT identity row (ADR-3180 Decision 4(b)): it
|
||||
// asserts on `phase-plan-index`'s emitted `waves` map through the REAL CLI,
|
||||
// never on `resolveDependencyId`/`computeDependencyLevels` in isolation — a
|
||||
// unit assertion on the resolver alone would have passed throughout this
|
||||
// defect's entire life, since nothing forces a unit test to reflect what the
|
||||
// consumer (execute-phase.md, partial-wave.md) actually reads.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('#3897 rung 4 (ADR-3473 §8.9): shortFormToId, the bare plan-number depends_on tier', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// T43 — RED today: the CONSUMER-OUTPUT identity row. 26-02 depends on the
|
||||
// bare short form '01'; today that edge is dropped, so 26-02 collapses into
|
||||
// wave 1 alongside 26-01 instead of its own, later wave.
|
||||
test('T43 shortFormDependencyProducesRealWaves_3427: a bare plan-number depends_on produces REAL waves, not one collapsed wave 1', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '26-shortform');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '26-01-auth-hardening-PLAN.md'),
|
||||
'---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n',
|
||||
);
|
||||
// Bare plan-number short form — NOT a full id, NOT a canonical prefix.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '26-02-followup-PLAN.md'),
|
||||
"---\nwave: 2\nautonomous: true\ndepends_on:\n - '01'\n---\n<objective>Plan B.</objective>\n",
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase-plan-index 26', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
const waves = output.waves;
|
||||
|
||||
const wave01 = Object.keys(waves).find((w) => waves[w].some((id) => id.startsWith('26-01')));
|
||||
const wave02 = Object.keys(waves).find((w) => waves[w].some((id) => id.startsWith('26-02')));
|
||||
assert.ok(wave01 !== undefined, '26-01-auth-hardening should appear in waves');
|
||||
assert.ok(wave02 !== undefined, '26-02-followup should appear in waves');
|
||||
assert.ok(
|
||||
Number(wave01) < Number(wave02),
|
||||
`the bare short form '01' must resolve to 26-01-auth-hardening and place 26-02-followup in a LATER wave — today the edge is dropped and both plans collapse into the same wave (got wave01=${wave01}, wave02=${wave02})`,
|
||||
);
|
||||
// Today's defect also manufactures a wave-mismatch warning from the
|
||||
// dropped edge (declared wave: 2 but DAG places it in wave 1) — once the
|
||||
// short form resolves, that warning must be gone too.
|
||||
const warnings = output.warnings ?? [];
|
||||
assert.ok(
|
||||
!warnings.some((w) => /declared wave:/.test(w) && w.includes('26-02')),
|
||||
`no manufactured wave-mismatch warning should remain once the short form resolves; got: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// T49 — the short form must resolve IN-PHASE ONLY. A plan '01' living in a
|
||||
// DIFFERENT phase must never satisfy a same-named short-form dependency in
|
||||
// this phase — resolution is scoped per phase-plan-index invocation, never
|
||||
// global across the project.
|
||||
//
|
||||
// #3897 rung 4 (isolated correctness review, MINOR finding 5): the
|
||||
// ORIGINAL version of this test gave the target phase its OWN plan '01'
|
||||
// (27-01-real) alongside the decoy (99-01-decoy). That construction cannot
|
||||
// actually falsify a globally-scoped map: sorted plan-file order always
|
||||
// resolves '01' to whichever phase's own plan sorts first among ALL
|
||||
// candidates, and phase 27 sorts before phase 99 either way — so a
|
||||
// GLOBALLY-scoped shortFormToId would have produced the exact same
|
||||
// wave01 < wave02 result this test asserted, passing for the wrong reason.
|
||||
// Verified empirically (see the isolated review's probe): building
|
||||
// shortFormToId from the phase-scoped rawPlans vs. from the UNION of both
|
||||
// phases' rawPlans produces byte-identical `unresolved`/`level` results for
|
||||
// the original fixture shape.
|
||||
//
|
||||
// Fixed per the reviewer's own working construction: the TARGET phase has
|
||||
// NO plan of its own numbered '01' at all (only '27-05-followup'), and the
|
||||
// decoy phase's plan IS numbered '01' (11-01-decoy). Correct (per-phase)
|
||||
// behavior is that '01' does NOT resolve — the edge is dropped with the
|
||||
// existing #3427 unresolved-token warning, and 27-05 collapses to wave 1.
|
||||
// A globally-scoped map would instead let '01' resolve to 11-01-decoy, a
|
||||
// node outside this phase's rawPlans — which computeDependencyLevels can
|
||||
// never satisfy, so it manufactures a false depends_on CYCLE report
|
||||
// instead of a clean wave assignment. This construction was confirmed,
|
||||
// by direct unit probe against the real exported `buildShortFormToId` and
|
||||
// `computeDependencyLevels`, to distinguish the correct from the buggy
|
||||
// scoping — the ORIGINAL fixture shape above did not.
|
||||
test('T49 shortFormDoesNotReachAcrossPhases: a phase with NO plan of its own numbered "01" must NOT resolve depends_on: ["01"] via a same-numbered plan in a different phase', () => {
|
||||
// A decoy phase whose OWN plan is numbered '01' — the only '01' anywhere
|
||||
// in the project is in THIS phase, not phase 27.
|
||||
const decoyPhaseDir = path.join(tmpDir, '.planning', 'phases', '11-decoy');
|
||||
fs.mkdirSync(decoyPhaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(decoyPhaseDir, '11-01-decoy-PLAN.md'),
|
||||
'---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Decoy plan in a different phase.</objective>\n',
|
||||
);
|
||||
|
||||
// Target phase has NO plan of its own numbered '01' — only '05'.
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '27-inphase-only');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '27-05-followup-PLAN.md'),
|
||||
"---\nwave: 2\nautonomous: true\ndepends_on:\n - '01'\n---\n<objective>Plan with a dangling short-form dependency.</objective>\n",
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase-plan-index 27', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
// The decoy phase's plan must never even appear in THIS phase's output.
|
||||
assert.ok(
|
||||
!output.plans.some((p) => p.id.startsWith('11-')),
|
||||
'a different phase\'s plan must never appear in this phase\'s plan-index output at all',
|
||||
);
|
||||
|
||||
// Correct behavior: the token does not resolve in-phase, so the edge is
|
||||
// DROPPED — the #3427 unresolved-token warning fires by name, and
|
||||
// 27-05-followup collapses to wave 1 rather than being scheduled behind
|
||||
// a cross-phase phantom edge (which, per the probe above, would instead
|
||||
// surface as a manufactured depends_on cycle error, not a clean pass).
|
||||
const warnings = output.warnings ?? [];
|
||||
assert.ok(
|
||||
warnings.some((w) => /does not resolve to any plan in this phase/.test(w) && w.includes('27-05')),
|
||||
`expected an unresolved-token warning naming 27-05-followup's dangling '01' dependency; got: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
const wave05 = Object.keys(output.waves).find((w) => output.waves[w].some((id) => id.startsWith('27-05')));
|
||||
assert.strictEqual(
|
||||
wave05,
|
||||
'1',
|
||||
`27-05-followup must collapse to wave 1 (dropped edge, no valid in-phase '01') — the short form must NOT reach the decoy in phase 11; got wave ${wave05}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// phase-plan-index — canonical XML format (template-aligned)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
398
tests/runtime-marker-resolution.test.cjs
Normal file
398
tests/runtime-marker-resolution.test.cjs
Normal file
@@ -0,0 +1,398 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* #3897 rung 2 — the per-install `.gsd-runtime` marker rung in the CANONICAL
|
||||
* runtime resolver (`resolveRuntime`, src/runtime-slash.cts).
|
||||
*
|
||||
* MINED FROM PR #3382 (closed, unmerged — "Closing unmerged — not on the
|
||||
* merits."): that PR's `tests/runtime-marker-resolution.test.cjs` drove real
|
||||
* `bin/install.js` installs end-to-end (6 tests). This file reuses its
|
||||
* fixture/seam CONTRACT — `readInstallRuntimeMarker`, `_setInstallRuntimeMarkerForTests`,
|
||||
* `_resetInstallRuntimeMarkerCacheForTests` exported from the canonical owner
|
||||
* `runtime-slash.cts`, the same `neutralProject` shape (a `.planning/config.json`
|
||||
* with NO `runtime` key, matching what `config-new-project` actually writes),
|
||||
* and the "marker rung sits between config and the default" precedence — but
|
||||
* exercises it at the resolver-unit level (in-process, monkeypatched `fs`)
|
||||
* rather than through full spawned installs, per the test-matrix's rung-2
|
||||
* automation note ("unit on the resolution ladder + the cache; integration
|
||||
* for the hooks' delegation").
|
||||
*
|
||||
* Today (before #3897 rung 2 lands) `src/runtime-slash.cts` exports no marker
|
||||
* reader and no test seams at all — `resolveRuntime` is a one-liner over
|
||||
* `resolveExplicitRuntime(...) ?? 'claude'`. Every test below therefore fails
|
||||
* at `assertMarkerSeamExists` first, which is the correct RED: the rung does
|
||||
* not exist yet.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const RUNTIME_SLASH_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-slash.cjs');
|
||||
const RUNTIME_NAME_POLICY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs');
|
||||
const AGENT_ISOLATION_GUARD_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js');
|
||||
const CURSOR_SUBAGENT_START_PATH = path.join(__dirname, '..', 'hooks', 'gsd-cursor-subagent-start.js');
|
||||
|
||||
/** A project whose config carries NO `runtime` key — what config-new-project writes. */
|
||||
function neutralProject(t) {
|
||||
const dir = createTempProject('gsd-marker-proj-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}');
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Fresh require of the built lib, bypassing require() cache so each test sees current disk state. */
|
||||
function requireRuntimeSlash() {
|
||||
delete require.cache[require.resolve(RUNTIME_SLASH_PATH)];
|
||||
return require(RUNTIME_SLASH_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard clause every test opens with: the marker rung's seam surface must
|
||||
* exist on the CANONICAL owner before any scenario can be exercised. On the
|
||||
* current tree this assertion itself is the RED — `runtime-slash.cjs` has no
|
||||
* marker reader and no test seams.
|
||||
*/
|
||||
function assertMarkerSeamExists(rs) {
|
||||
assert.equal(
|
||||
typeof rs.readInstallRuntimeMarker,
|
||||
'function',
|
||||
'runtime-slash.cjs must export readInstallRuntimeMarker — the canonical marker reader (#3897 rung 2) does not exist yet',
|
||||
);
|
||||
assert.equal(
|
||||
typeof rs._setInstallRuntimeMarkerForTests,
|
||||
'function',
|
||||
'runtime-slash.cjs must export _setInstallRuntimeMarkerForTests — the test seam for the marker rung does not exist yet',
|
||||
);
|
||||
assert.equal(
|
||||
typeof rs._resetInstallRuntimeMarkerCacheForTests,
|
||||
'function',
|
||||
'runtime-slash.cjs must export _resetInstallRuntimeMarkerCacheForTests',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with `overrides` applied to process.env, restoring exactly what
|
||||
* was there before. An override value of `undefined` DELETES that key for
|
||||
* the duration of `fn` (never `Object.assign`s it — assigning `undefined`
|
||||
* onto `process.env` coerces to the literal string `"undefined"`, not a
|
||||
* deletion) — #3897 rung 4 (isolated correctness review, NIT finding 8b):
|
||||
* every `delete process.env.GSD_RUNTIME` in this file used to bypass this
|
||||
* helper entirely and never restore the ambient value afterward, leaking a
|
||||
* forced-unset GSD_RUNTIME into whatever test (in this file or a later one
|
||||
* sharing the same process) ran next. Every test below now routes through
|
||||
* this helper, `undefined`-keyed, so ambient env state is always restored.
|
||||
*/
|
||||
function withEnv(overrides, fn) {
|
||||
const saved = {};
|
||||
for (const key of Object.keys(overrides)) saved[key] = process.env[key];
|
||||
for (const key of Object.keys(overrides)) {
|
||||
const value = overrides[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(overrides)) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('per-install .gsd-runtime marker rung in the canonical resolver (#3897 rung 2)', () => {
|
||||
test('T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
const proj = neutralProject(t);
|
||||
rs._setInstallRuntimeMarkerForTests('codex');
|
||||
t.after(() => rs._resetInstallRuntimeMarkerCacheForTests());
|
||||
|
||||
withEnv({ GSD_RUNTIME: undefined }, () => {
|
||||
assert.equal(
|
||||
rs.resolveRuntime(proj),
|
||||
'codex',
|
||||
'no env, no config.runtime, marker says codex (R3) — resolveRuntime must consult the marker instead of falling straight to claude',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('T1 envRuntimeOutranksConfigAndMarker', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
const proj = neutralProject(t);
|
||||
fs.writeFileSync(path.join(proj, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
rs._setInstallRuntimeMarkerForTests('gemini');
|
||||
t.after(() => rs._resetInstallRuntimeMarkerCacheForTests());
|
||||
|
||||
withEnv({ GSD_RUNTIME: 'codex' }, () => {
|
||||
assert.equal(rs.resolveRuntime(proj), 'codex', 'GSD_RUNTIME must outrank both config.runtime and the install marker');
|
||||
});
|
||||
});
|
||||
|
||||
test('T2 configOutranksInstallMarker', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
const proj = neutralProject(t);
|
||||
fs.writeFileSync(path.join(proj, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
rs._setInstallRuntimeMarkerForTests('codex');
|
||||
t.after(() => rs._resetInstallRuntimeMarkerCacheForTests());
|
||||
|
||||
withEnv({ GSD_RUNTIME: undefined }, () => {
|
||||
assert.equal(rs.resolveRuntime(proj), 'cursor', 'config.runtime must outrank the install marker');
|
||||
});
|
||||
});
|
||||
|
||||
test('T4 noSignalsFallsBackToClaude', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
const proj = neutralProject(t);
|
||||
rs._setInstallRuntimeMarkerForTests(null);
|
||||
t.after(() => rs._resetInstallRuntimeMarkerCacheForTests());
|
||||
|
||||
withEnv({ GSD_RUNTIME: undefined }, () => {
|
||||
assert.equal(rs.resolveRuntime(proj), 'claude', 'with no env, no config.runtime and no marker, the default is unchanged');
|
||||
});
|
||||
});
|
||||
|
||||
test('T5 emptyMarkerFallsThrough', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
rs._resetInstallRuntimeMarkerCacheForTests();
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
fs.readFileSync = (p, enc) => {
|
||||
if (typeof p === 'string' && p.endsWith('.gsd-runtime')) return ' \n\t ';
|
||||
return originalReadFileSync(p, enc);
|
||||
};
|
||||
t.after(() => {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
rs._resetInstallRuntimeMarkerCacheForTests();
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
rs.readInstallRuntimeMarker(),
|
||||
null,
|
||||
'a whitespace-only marker file must resolve to null (trimmed to empty), not an empty-string runtime name',
|
||||
);
|
||||
|
||||
const proj = neutralProject(t);
|
||||
withEnv({ GSD_RUNTIME: undefined }, () => {
|
||||
assert.equal(rs.resolveRuntime(proj), 'claude', 'an empty marker must fall through to claude');
|
||||
});
|
||||
});
|
||||
|
||||
test('T6 unreadableMarkerNeverThrows', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
rs._resetInstallRuntimeMarkerCacheForTests();
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
// IO failure injected by monkeypatching fs, never chmod (root bypasses
|
||||
// mode bits — a chmod test would pass with zero coverage in root CI).
|
||||
fs.readFileSync = (p, enc) => {
|
||||
if (typeof p === 'string' && p.endsWith('.gsd-runtime')) {
|
||||
const err = new Error('EACCES: permission denied, open .gsd-runtime');
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return originalReadFileSync(p, enc);
|
||||
};
|
||||
t.after(() => {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
rs._resetInstallRuntimeMarkerCacheForTests();
|
||||
});
|
||||
|
||||
assert.doesNotThrow(
|
||||
() => rs.readInstallRuntimeMarker(),
|
||||
'an EACCES marker read must never throw out of readInstallRuntimeMarker — a hook that throws breaks the session (N4)',
|
||||
);
|
||||
assert.equal(rs.readInstallRuntimeMarker(), null);
|
||||
|
||||
const proj = neutralProject(t);
|
||||
let result;
|
||||
withEnv({ GSD_RUNTIME: undefined }, () => {
|
||||
assert.doesNotThrow(() => {
|
||||
result = rs.resolveRuntime(proj);
|
||||
});
|
||||
});
|
||||
assert.equal(result, 'claude', 'an unreadable marker must fall through to claude, never crash the caller');
|
||||
});
|
||||
|
||||
test('T7 unknownMarkerNameIsNormalizedNotTrusted', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
const proj = neutralProject(t);
|
||||
rs._setInstallRuntimeMarkerForTests('NotARealRuntime-V9');
|
||||
t.after(() => rs._resetInstallRuntimeMarkerCacheForTests());
|
||||
|
||||
const viaMarker = withEnv({ GSD_RUNTIME: undefined }, () => rs.resolveRuntime(proj));
|
||||
const viaEnv = withEnv({ GSD_RUNTIME: 'NotARealRuntime-V9' }, () => rs.resolveRuntime(neutralProject(t)));
|
||||
|
||||
assert.equal(
|
||||
viaMarker,
|
||||
viaEnv,
|
||||
'the marker rung must apply the SAME resolveRuntimeNameFromCandidates normalization GSD_RUNTIME does (N1) — one name policy, not two',
|
||||
);
|
||||
assert.notEqual(
|
||||
viaMarker,
|
||||
'NotARealRuntime-V9',
|
||||
'the raw marker string must never be returned verbatim — it must be routed through normalization like the env rung',
|
||||
);
|
||||
});
|
||||
|
||||
test('T8 hostileMarkerContentIsInert', (t) => {
|
||||
const rs = requireRuntimeSlash();
|
||||
assertMarkerSeamExists(rs);
|
||||
rs._resetInstallRuntimeMarkerCacheForTests();
|
||||
const hostile = 'codex\n../../etc/passwd | ||||