Repository navigation
Gate the launch policy against the dispatcher's; guard a Start's credentials - #12
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Build plan
agent-workflow-interop-2026-08, Phase 5.5 and 5.6. Ticket vikunja#560. Taskae77eef0.5.5 — one corpus, two validators
~/scripts/agent-launch.ymlis 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.tscarried 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 becauseLAUNCH_POLICYis built at import, on forge that made the dispatcher module fail to import on every tick.npm run gate:corpuscloses it. task-dispatcher owns the 27-case corpus; this side fetches it from that repo'smainand asserts agreement case by case. Same direction and mechanism asgate: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.normalizekeeps a trailing separator (/a/b/→/a/b/) where Python'sos.path.normpathstrips it (/a/b/→/a/b). Aproject_dirwritten 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 "editsrc/vocabulary.ts", the other means "editsrc/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.tsrefuses a Start of a directly-launched agent, by name, whenSCOPED_MCP_BEARER_TOKENis 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>/.envinto the child environment (load_agent_env, mirroring whatrun-scoped-mcp-http.shsources 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_TOKENbut noSCOPED_MCP_BEARER_TOKEN. So a guard checking onlyprocess.envwould 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 usesguard.env, notprocess.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:corpusred:parent env untouchedrefused BY NAMErefused BY NAMEwhat makes the guard passable at allagent env overrides the parent envan EXPIRED OAuth token is not usable=gate:corpusOne survived the first run. The agent-name traversal test pointed at a path that did not exist, so
readFileSyncthrew 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 removingAGENT_NAME_REchanges the result rather than the route to it.Deploy
Merged is not deployed.
dist/is gitignored and built bydeploy.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