Files
msd-core/tests/zcode-agent-mcp-grants.install.test.cjs
Dennis Alexis Valin Dittrich 925a363879 enhance(#4032): apply configured agent tool grants (#4238)
* test(4032): add failing installed-agent grants contract

Cover global and project agent_tools precedence at the real Claude installer seam before adding implementation.

* feat(4032): apply configured agent tool grants during staging

Resolve selector-level global and project config once per staging call, then append validated grants before runtime conversion.

* test(4032): cover host grant and quoted MCP contracts

Exercise installed host artifacts and prove ZCode must treat quoted MCP scalars like plain MCP grants.

* feat(4032): apply configured agent tool grants across runtimes

Move augmentation and scalar identity into the converter seam so every staged artifact preserves host policy.

* fix(4032): register agent tool grants in configuration

Accept documented agent_tools config without unknown-key warnings.\n\nKeep installer fixtures on the shared temporary-directory helper.

* fix(4032): translate configured MCP grants for Kilo

Reuse the converter-owned scalar decoder so quoted canonical grants reach Kilo's native permission keys without altering other host policies.

* fix(4032): decode YAML-escaped tool grants

* fix(4032): emit valid inline agent tool grants

* fix(4032): reject invalid trailing-colon grants

* test(#4032): cover cross-review remediation gaps

* fix(#4032): close cross-runtime grant gaps

* test(#4032): expose Kimi global project context

* fix(#4032): preserve Kimi project config context

* chore(#4032): add release note

* test(#4032): expose fork review regressions

* fix(#4032): address fork review findings

* test(#4032): make byte-stability assertion portable

Compare repeat installs at one root so platform-specific path rendering cannot
masquerade as an agent_tools behavior change.

* chore(#4032): bind changeset to upstream PR 4238

* fix(#4032): address trek-e review findings (2,3,4,5,6,7,8)

Fixes fail-closed decode-failure handling in ZCode's mcp__ stripper,
a comment-only `tools:` header mis-parse that silently dropped
configured grants, and a naive comma-split that could tear a quoted
scalar containing a literal comma. Documents Kilo's inherent
`{server}_{tool}` MCP-permission-key collision (external, fixed
format — not ours to widen) and locks the existing first-seen-wins
resolution in with a regression test.

Opts kimi/kimi-code out of the ADR-1235 pre-converter path-rewrite
step: routing Kimi through that pipeline (needed so project-scoped
agent_tools selectors reach it) was short-circuiting Kimi's own
neutralizeKimiAgentPrompt, which expects the original ~/.claude/gsd-core
text rather than a pre-rewritten Kimi path.

Extends the fast-check token pool and per-runtime install coverage
with the missing comment/comma/broad-runtime cases the prior review
flagged as untested.

* docs(#4032): add CONTEXT.md glossary entries for agent_tools resolver + pre-converter step

Documents readGsdEffectiveAgentTools (Install Model Override Resolver
Module) and the appendAgentTools pre-converter pipeline step (Runtime
Artifact Conversion Module), per contributor-standards.md's
new-seam glossary requirement (finding 1).

* fix(#4032): address agy adversarial review findings

An agy (gemini-3.8-flash-high) adversarial pass over the prior review-fix
commit found the fixes for findings 3, 4, 6 and 8 had unfixed sibling gaps,
plus a genuine new regression and two CONTEXT.md inaccuracies:

- ZCode's comment-only `tools: # note` header matched the inline-value
  branch instead of falling through to the block-list scan, so a following
  mcp__* item leaked through unstripped — the exact defect finding 4 fixed
  in appendAgentTools, unfixed in this sibling function.
- Reverted capabilities/kimi-code/capability.json's noPathRewrite: true.
  kimi-code uses the standard 'agents' kind with converter: null (not
  kimi-agents — confirmed by reading the descriptor, not its prose
  description), so it never went through the pipeline change finding 5
  fixed, and disabling its path rewrite broke every ~/.claude/ embed in
  its shipped agents instead.
- decodeToolScalar never stripped a trailing ` # comment` from a bare
  (unquoted) scalar, so a comment after a block-list item, or after an
  appended grant on an inline line, became part of the "tool name" —
  fixed at the source (one call site fixes every consumer).
- appendAgentTools's comment-index scan wasn't quote-aware, so a `#`
  inside a quoted scalar (`"mcp__server #1"`) was mistaken for a comment
  start and corrupted the quote.
- parseFrontmatterTools (Kimi/Qwen's tool-list reader, downstream of
  appendAgentTools's own output) had the same naive comma-split and
  comment-only-header gaps as findings 4 and 6, unpatched.
- The all-runtime smoke test's presence assertion was built on a guessed
  omit-list; empirically only 7 of 17 runtimes keep an arbitrary mcp__
  grant recognizable, replaced with a verified allowlist.
- CONTEXT.md claimed a `project:<agent>` selector prefix that does not
  exist (project override is a same-key merge across two config files)
  and mislabeled stageAgentsForRuntimeWithConverter's module.

* fix(#4032): address full-PR review (Opus critical/ponytail + agy)

A whole-PR pass (critical-code-reviewer + ponytail-review on Opus, plus a
second agy full-source adversarial pass) surfaced defects the earlier
finding-scoped passes couldn't reach:

- appendAgentTools corrupted a `tools:` line whose ENTIRE value is a
  leading quoted scalar (`tools: "Read"` -> `tools: "Read", Write`,
  invalid YAML) — there is no safe line-surgical rewrite here, so it now
  refuses to touch that shape instead of emitting broken frontmatter.
- decodeToolScalar's malformed-trailing-quote check ran BEFORE comment
  stripping, so a bare tool name with a quote inside its own trailing
  comment (`Bash # note: "internal"`) was wrongly rejected. Reordered.
- findUnquotedCommentIndex (added in the prior remediation commit) was
  built on a wrong model of YAML: a `#` after whitespace starts a real
  comment in a plain scalar regardless of nearby quote characters —
  verified against the actual parser. The one case that DOES need
  protection (a leading quoted scalar) is now refused outright above, so
  the quote-tracking scan was dead weight solving a problem that no
  longer reaches it. Removed; reverted to the plain `[ \t]#` scan.
- Kilo has a SEPARATE agent-frontmatter parser (convertClaudeToKiloFrontmatter,
  distinct from the buildKiloAgentPermissionBlock fixed earlier) with the
  same comment-only-header and naive-comma-split gaps as findings 4 and 6
  — unfixed in both its src/ and bin/install.js copies. Fixed in both,
  exporting splitToolScalars for bin/install.js to reuse rather than
  reimplementing it.
- Pipeline docstring in stageAgentsForRuntimeWithConverter still listed 5
  steps, omitting appendAgentTools (now step 3 of 6).
- docs/CONFIGURATION.md didn't state that a --global install still
  discovers agent_tools from the cwd's .planning/config.json (confirmed
  intentional and already covered by a dedicated test, not a bug).
- Removed install-engine.cts's deps.cwd injection seam: zero callers or
  tests ever populated it.

Two claims from this round were verified and rejected, not fixed:
prototype pollution via a `__proto__` selector key (empirically confirmed
`Object.prototype` is never touched — only reassigns the resolver's own
local object's prototype, with no observable effect), and a `*` grant
value crashing YAML parsing as an alias reference (empirically confirmed
it parses as plain scalar text, no crash). A pre-existing, unrelated
defect (extractFrontmatterField returns null for block-list `tools:` on
Copilot/Antigravity/Cursor/Codex/Qwen, affecting two shipped agents
today) was filed as a follow-up rather than fixed here — it predates
#4032 and isn't caused or worsened by this PR.

* fix(#4032): update stale slug-derivation-drift-guard fixture line

normalizeKimiSkillName's real closing brace moved from line 616 to 635 as a
side effect of this PR's edits to runtime-artifact-conversion.cts; the
MAJOR-1 fixture's hardcoded realEndLine had gone stale.

* fix(#4032): address CodeRabbit findings on projectDir threading and flow-sequence tools

bin/install.js's installAgentsKindStandalone call site omitted the projectDir
argument the function already supports, so a global install through this
legacy branch silently fell back to the runtime config dir instead of
process.cwd() when resolving project-scoped agent_tools grants — inconsistent
with the sibling installOpencodeFamilyArtifacts call site, which already
threads it correctly.

appendAgentTools' leading-quoted-scalar bailout did not cover a YAML flow
sequence (`tools: [Bash, Read]`): splitToolScalars tore it apart on the
in-sequence commas and appended past its closing bracket, producing invalid
frontmatter. Extended the bailout regex to also refuse a value starting with
`[`, matching the same "whole node, nothing may follow" reasoning already
applied to quoted scalars.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:52:45 -04:00

209 lines
10 KiB
JavaScript

'use strict';
// allow-test-rule: source-text-is-the-product (see #3384) — this suite asserts on the literal
// frontmatter of EMITTED install artifacts (installed agent .md files), which ARE the deployed
// contract: an mcp__* grant that survives install is treated by ZCode's dispatcher as a
// required-but-must-be-connected MCP server and hard-fails every subagent spawn.
/**
* zcode-agent-mcp-grants.install.test.cjs — 50-test-matrix.md rows 1-4 (issue #3384).
*
* ZCode's subagent dispatcher treats every `mcp__<server>__*` entry in an agent's
* `tools:` frontmatter as a REQUIRED MCP server and throws CONFIGURATION_ERROR on
* spawn when it is not connected. Claude Code treats the same grants as an optional
* allowlist, which is why the GSD sources carry them. The install must therefore
* filter `mcp__*` entries out of the tools list for ZCode — the same outcome Kimi
* already gets via its own conversion path — while leaving Claude Code's verbatim
* copy untouched.
*
* Every row spawns a REAL installer and asserts on the parsed frontmatter of what
* actually reached disk (behavioral seam — never on which internal converter ran).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { cleanup } = require('./helpers.cjs');
const { installerEnv } = require('./helpers/install-shared.cjs');
const { buildOverlayRepo } = require('./helpers/overlay-repo.cjs');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const REPO_ROOT = path.join(__dirname, '..');
/** The 8 MCP-granted agents named in the issue. Every one hard-fails spawn on
* ZCode with zero MCP servers configured, so every one must install clean. */
const MCP_GRANTED_AGENTS = [
'gsd-phase-researcher',
'gsd-project-researcher',
'gsd-ui-researcher',
'gsd-advisor-researcher',
'gsd-domain-researcher',
'gsd-ai-researcher',
'gsd-planner',
'gsd-executor',
];
/** Parse the `tools:` grant list out of an agent .md's YAML frontmatter.
* Handles both shapes GSD emits: inline comma list (`tools: A, B, C`) and
* YAML block list (`tools:\n - A\n - B`). Returns [] when the agent
* declares no tools. */
function parseFrontmatterTools(content) {
const lines = content.split(/\r?\n/);
if (lines[0] !== '---') return [];
const tools = [];
let collecting = false;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === '---') break; // end of frontmatter
if (collecting) {
const itemMatch = /^(\s*)-\s*(\S.*)$/.exec(line);
if (itemMatch && itemMatch[1].length <= 2) {
tools.push(itemMatch[2].trim());
continue;
}
collecting = false; // a non-list line ends the block list
}
const inlineMatch = /^tools:[ \t]*(.*)$/.exec(line);
if (inlineMatch) {
const value = inlineMatch[1].trim();
if (value) {
for (const tool of value.split(',')) {
const name = tool.trim();
if (name) tools.push(name);
}
} else {
collecting = true; // `tools:` alone starts a block list
}
}
}
return tools;
}
/** Spawn a real install of one runtime at one scope. Mirrors the seam shape of
* tests/agent-fragments-emission.install.test.cjs. Returns { result, root }
* where root is the install root (config dir for global, project cwd for local). */
function spawnInstall(runtime, scope, repoRoot = REPO_ROOT) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-3384-${runtime}-${scope}-`));
const args = ['--preserve-symlinks', '--preserve-symlinks-main', path.join(repoRoot, 'bin', 'install.js'), `--${runtime}`];
if (scope === 'global') {
args.push('--global', '--config-dir', root);
} else {
args.push('--local');
}
const seamResult = runNode(args, {
cwd: root,
env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: INSTALL_TIMEOUT_MS,
});
return { result: seamResult, root };
}
/** Locate an installed agent file for a runtime install root: global installs
* land at <configDir>/agents/<name>.md, local installs at <cwd>/.zcode/agents/<name>.md. */
function installedAgentPath(root, scope, agentName) {
return scope === 'global'
? path.join(root, 'agents', `${agentName}.md`)
: path.join(root, '.zcode', 'agents', `${agentName}.md`);
}
/** Rows 1/2 + row 3 (anti-vacuity) for one scope. Caller owns root cleanup. */
function assertZcodeAgentsClean(t, scope) {
const { result, root } = spawnInstall('zcode', scope);
t.after(() => cleanup(root));
assert.strictEqual(result.exitCode, 0,
`zcode ${scope} install must succeed\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
for (const agentName of MCP_GRANTED_AGENTS) {
const agentPath = installedAgentPath(root, scope, agentName);
assert.ok(fs.existsSync(agentPath), `zcode ${scope} install must write ${path.relative(root, agentPath)}`);
const content = fs.readFileSync(agentPath, 'utf8');
assert.ok(content.startsWith('---\n'),
`${agentName} (${scope}): installed agent must carry YAML frontmatter (starts: ${JSON.stringify(content.slice(0, 20))})`);
const tools = parseFrontmatterTools(content);
assert.ok(tools.length > 0,
`${agentName} (${scope}): tools list must not be emptied by the mcp__ filter`);
const mcpGrants = tools.filter((tool) => tool.startsWith('mcp__'));
assert.deepStrictEqual(mcpGrants, [],
`${agentName} (${scope}): installed tools must declare zero mcp__* grants — ZCode's dispatcher ` +
`treats each as a required MCP server and hard-fails the spawn when unconnected. Found: ${mcpGrants.join(', ')}`);
// Row 3 anti-vacuity: the filter must strip ONLY the mcp__* entries, never the
// whole grant list — every one of these agents needs its core tools to function.
for (const coreTool of ['Read', 'Bash']) {
assert.ok(tools.includes(coreTool),
`${agentName} (${scope}): non-MCP tool '${coreTool}' must survive the filter (found: ${tools.join(', ')})`);
}
}
}
// ─── Rows 1/2: a fresh ZCode install carries zero mcp__* grants (both scopes) ──
test('zcode global install: all 8 MCP-granted agents install with zero mcp__* tool grants (#3384)', (t) => {
assertZcodeAgentsClean(t, 'global');
});
test('zcode local install: all 8 MCP-granted agents install with zero mcp__* tool grants (#3384)', (t) => {
assertZcodeAgentsClean(t, 'local');
});
function zcodeFixture(tools) {
return `---\nname: gsd-phase-researcher\ndescription: ZCode quoted-MCP fixture\ntools: ${tools}\n---\n\nfixture body survives\n`;
}
function zcodeBlockFixture(items) {
return `---\nname: gsd-phase-researcher\ndescription: ZCode quoted-MCP fixture\ntools:\n${items.map((item) => ` - ${item}`).join('\n')}\n---\n\nfixture body survives\n`;
}
test('zcode treats quoted MCP scalars as equivalent to plain scalars (#4189)', (t) => {
const cases = [
{ name: 'mixed inline', source: zcodeFixture('Read, mcp__server__plain, \'mcp__server__single\', "mcp__server__double"'), expected: 'tools: Read' },
{ name: 'escaped inline', source: zcodeFixture('Read, "\\x6dcp__server__tool"'), expected: 'tools: Read' },
{ name: 'escaped inline unicode-16', source: zcodeFixture('Read, "\\u006dcp__server__tool"'), expected: 'tools: Read' },
{ name: 'escaped inline unicode-32', source: zcodeFixture('Read, "\\U0000006dcp__server__tool"'), expected: 'tools: Read' },
{ name: 'commented inline', source: zcodeFixture('Read, "mcp__server__double" # note'), expected: 'tools: Read' },
{ name: 'all inline', source: zcodeFixture('\'mcp__server__single\', "mcp__server__double"'), expected: null },
{ name: 'mixed block', source: zcodeBlockFixture(['Read', 'mcp__server__plain', "'mcp__server__single'", '"mcp__server__double"']), expected: 'tools:\n - Read' },
{ name: 'escaped block', source: zcodeBlockFixture(['Read', '"\\x6dcp__server__tool"']), expected: 'tools:\n - Read' },
{ name: 'escaped block unicode-16', source: zcodeBlockFixture(['Read', '"\\u006dcp__server__tool"']), expected: 'tools:\n - Read' },
{ name: 'escaped block unicode-32', source: zcodeBlockFixture(['Read', '"\\U0000006dcp__server__tool"']), expected: 'tools:\n - Read' },
{ name: 'commented block', source: zcodeBlockFixture(['Read', '"mcp__server__double" # note']), expected: 'tools:\n - Read' },
{ name: 'all block', source: zcodeBlockFixture(["'mcp__server__single'", '"mcp__server__double"']), expected: null },
];
for (const row of cases) {
const overlay = buildOverlayRepo({ 'agents/gsd-phase-researcher.md': row.source });
t.after(() => cleanup(overlay));
const { result, root } = spawnInstall('zcode', 'global', overlay);
t.after(() => cleanup(root));
assert.strictEqual(result.exitCode, 0, `${row.name}: zcode install must succeed\n${result.stderr}`);
const emitted = fs.readFileSync(path.join(root, 'agents', 'gsd-phase-researcher.md'), 'utf8');
assert.ok(!emitted.includes('mcp__server__'), `${row.name}: all semantic MCP entries must be removed`);
assert.ok(!emitted.includes('\\x6dcp__server__'), `${row.name}: YAML-escaped semantic MCP entries must be removed`);
assert.ok(emitted.includes('fixture body survives'), `${row.name}: unrelated body bytes must survive`);
if (row.expected === null) assert.ok(!/^tools:/m.test(emitted), `${row.name}: all-MCP lists must drop tools`);
else assert.ok(emitted.includes(row.expected), `${row.name}: non-MCP tool formatting must survive`);
}
});
// ─── Row 4: Claude Code parity — its mcp__* grants are an OPTIONAL allowlist ───
test('claude global install still carries mcp__* grants (optional-allowlist semantics untouched by #3384)', (t) => {
const { result, root } = spawnInstall('claude', 'global');
t.after(() => cleanup(root));
assert.strictEqual(result.exitCode, 0,
`claude global install must succeed\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
const agentPath = path.join(root, 'agents', 'gsd-phase-researcher.md');
assert.ok(fs.existsSync(agentPath), 'claude global install must write agents/gsd-phase-researcher.md');
const tools = parseFrontmatterTools(fs.readFileSync(agentPath, 'utf8'));
assert.ok(
tools.some((tool) => tool.startsWith('mcp__')),
`claude's own install must keep its mcp__* optional-allowlist grants verbatim (found tools: ${tools.join(', ')})`,
);
});