Skip to content

Gate the launch policy against the dispatcher's; guard a Start's credentials - #12

Merged
TadMSTR merged 1 commit into
mainfrom
feat/roster-corpus-and-launch-guards
Aug 29, 2026
Merged

TadMSTR merged 1 commit into
mainfrom
feat/roster-corpus-and-launch-guards

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Build plan agent-workflow-interop-2026-08, Phase 5.5 and 5.6. Ticket vikunja#560. Task ae77eef0.

⚠️ CI is expected to be RED on the gate:corpus step until TadMSTR/task-dispatcher#6 merges. That gate reads the corpus from task-dispatcher's main, where it does not exist yet. Everything else — build, typecheck, unit tests, gate:vocabulary, npm audit — is green. Verified passing against the dispatcher's branch with TASK_DISPATCHER_REF=feat/bus-correlation-and-parity-gates: 27/27 cases, resolved values included. This is the documented merge-order constraint, not a defect.

5.5 — one corpus, two validators

~/scripts/agent-launch.yml is validated independently by this plugin and by task-dispatcher, in two languages, sharing no code — and both feed the result into a subprocess spawn. launch-policy.ts carried a comment stating the Python side "must keep computing this the same way". That is a comment where a test should be.

They have already disagreed once: Python called .resolve() on the project root while this side did a plain join, so with a symlink anywhere on the path the two diverged — and because LAUNCH_POLICY is built at import, on forge that made the dispatcher module fail to import on every tick.

npm run gate:corpus closes it. task-dispatcher owns the 27-case corpus; this side fetches it from that repo's main and asserts agreement case by case. Same direction and mechanism as gate:vocabulary, so this fleet has one pattern for cross-repo contracts rather than two.

It compares resolved values, and that is not belt-and-braces

Its first run found a second live divergence. Node's path.normalize keeps a trailing separator (/a/b/ → /a/b/) where Python's os.path.normpath strips it (/a/b/ → /a/b). A project_dir written with a trailing slash was accepted by both sides and resolved to two different strings — one of which becomes a spawned session's working directory. No verdict ever disagreed, so a verdict-only comparison would have reported these two implementations in perfect agreement through both bugs.

Fixed here with stripTrailingSep(), matching Python. The corpus case is pinned upstream.

Own CI step, separate from gate:vocabulary, because it reads a different upstream: one red means "edit src/vocabulary.ts", the other means "edit src/launch-policy.ts". Folding them together lets either hide the other and makes "which upstream moved" a log dive.

5.6 — pre-launch credential guards

src/launch-guards.ts refuses a Start of a directly-launched agent, by name, when SCOPED_MCP_BEARER_TOKEN is unresolved or no usable Anthropic credential is available — the two checks task-dispatcher has always made. Previously such a Start spawned a session doomed to 401 from every scoped-mcp tool, or to short-circuit to "Not logged in" before reading the prompt. Both present to the operator as an agent that started and did nothing.

The port is more than the two checks, and that is the substance of it

The dispatcher layers /opt/appdata/agents/<agent>/.env into the child environment (load_agent_env, mirroring what run-scoped-mcp-http.sh sources server-side). This plugin spawned with a bare {...process.env}.

Measured on forge before writing the guard: the CloudCLI process env carries CLAUDE_CODE_OAUTH_TOKEN but no SCOPED_MCP_BEARER_TOKEN. So a guard checking only process.env would have refused every non-run-as Start — correctly, in the sense that those sessions genuinely were starting without scoped-mcp tools, but that is the bug rather than the fix. Layering the agent's own env is what makes the guard a guard instead of a blanket refusal.

spawn() now uses guard.env, not process.env: checking one object and spawning with another would make the guard decoration.

The run-as asymmetry is preserved deliberately

Neither check runs for an agent with run_as_user, exactly as in the dispatcher. Those credentials are absent from this process by design — they live in a file only the target user can read, sourced by the launcher as that user, which performs the equivalent ${VAR:?} checks itself. Running these checks on that path would fail every launch for the one agent whose isolation is working correctly.

Two tests pin that asymmetry by name (a RUN-AS agent is not subjected to either check, a run-as agent gets the parent env untouched), so a later well-meaning "fix" cannot quietly remove it. A mutant that deletes the short-circuit, and one that makes the run-as path read the agent env file, are both caught.

Tests

151 → 175. Ten mutants covering every new branch, each confirmed to turn the suite or gate:corpus red:

Mutant Caught by
the run-as short-circuit removed run-as asymmetry tests
the run-as path reads the agent env it must not touch parent env untouched
the bearer-token guard dropped refused BY NAME
the Anthropic guard dropped refused BY NAME
the agent env not layered in what makes the guard passable at all
the parent env wins over the agent's identity agent env overrides the parent env
an expired OAuth counts as usable an EXPIRED OAuth token is not usable
the agent name not re-validated before becoming a path segment traversal test
the env parser splits on the last = base64/JWT padding test
the trailing-separator fix reverted gate:corpus

One survived the first run. The agent-name traversal test pointed at a path that did not exist, so readFileSync threw and the function returned {} whether or not the name check ran — it asserted the right outcome for the wrong reason, and deleting the guard left it green. The target file is now planted, so removing AGENT_NAME_RE changes the result rather than the route to it.

Deploy

Merged is not deployed. dist/ is gitignored and built by deploy.sh; the plugin stays live at whatever is currently installed until sysadmin runs ./deploy.sh && pm2 restart cloudcli. A deploy task follows.

🤖 Generated with Claude Code

…entials

Build plan agent-workflow-interop-2026-08, Phase 5.5 and 5.6.

5.5 — npm run gate:corpus, its own CI step. task-dispatcher owns a 27-case
accept/reject corpus for the launch policy; this side fetches it from that
repo's main and asserts validateLaunchPolicy() agrees on every case.
launch-policy.ts carried a comment saying the Python side "must keep computing
this the same way" — this is that comment as a test.

It compares RESOLVED VALUES, not just verdicts, and that immediately paid for
itself: Node's path.normalize keeps a trailing separator where Python's
os.path.normpath strips it, so a project_dir written with a trailing slash was
ACCEPTED by both sides and resolved to two different strings — one of which
becomes a spawned session's cwd. No verdict ever disagreed. That is the
.resolve() divergence in a second costume, and the fix is stripTrailingSep().

Separate from gate:vocabulary because it reads a different upstream: one red
means "edit vocabulary.ts", the other means "edit launch-policy.ts". Sharing a
step would let either hide the other.

5.6 — src/launch-guards.ts refuses a Start that cannot authenticate, by name,
instead of spawning a session that 401s from every scoped-mcp tool or
short-circuits to "Not logged in" before reading its prompt. Both present to the
operator as an agent that started and did nothing.

The port is not just the two checks. The dispatcher layers
/opt/appdata/agents/<agent>/.env into the child env and this plugin did not;
measured on forge, the CloudCLI process env has CLAUDE_CODE_OAUTH_TOKEN but no
SCOPED_MCP_BEARER_TOKEN. A guard reading only process.env would have refused
every non-run-as Start — correctly, in that those sessions really were starting
without scoped-mcp tools, but that is the bug and not the fix. spawn() now uses
the environment that was checked; checking one object and spawning with another
would make the guard decoration.

BOTH CHECKS ARE SKIPPED FOR A RUN-AS AGENT, DELIBERATELY. Those credentials are
absent from this process by design — the launcher sources them as the target
user and makes the equivalent checks itself. Running them here would fail every
launch for the one agent whose isolation is working correctly. Two tests pin
that asymmetry so a later "fix" cannot quietly remove it.

151 -> 175 tests. Ten mutants covering every new branch each confirmed to turn
the suite or gate:corpus red. One survived the first run: the agent-name
traversal test pointed at a path that did not exist, so readFileSync threw and
the function returned {} whether or not the name check ran — the right outcome
for the wrong reason. The target file is now planted, so deleting the guard
changes the result rather than the route to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

agent-id: developer
@TadMSTR
TadMSTR merged commit 9b03e79 into main Aug 29, 2026
1 of 3 checks passed
@TadMSTR
TadMSTR deleted the feat/roster-corpus-and-launch-guards branch August 29, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant