Files
msd-core/tests/package-legitimacy-gate.test.cjs
Tom Boucher 11afca2968 feat(#656): Research module — content-addressed cache + provider seam + registry-API legitimacy (#664)
* feat(#656): add Research Store module (content-addressed cache, TTL staleness)

Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Research Provider module (waterfall + confidence + plan)

Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional)

Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1).

Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter

config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy)

Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset)

Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): sync inventory for research modules

Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457)

research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): backfill changeset pr number to #664

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

* chore(#656): satisfy eslint lint-tests gate

Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): harden package legitimacy per review (W1/W2/I3/I4)

W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4)

I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3)

Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests.

Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache)

HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green.

Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close code-review correctness findings

(1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green.

Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher documentation_lookup to shared @-reference

6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher philosophy + verification-protocol to shared @-references

philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1)

The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1)

project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2)

Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3)

scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles

Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): make classifyConfidence verification-evidence-driven (W3)

Confidence conflated provider authority with claim verification — context7/ref
stamped HIGH purely by provider identity, and the only verification lever was a
self-set --verified flag. Split into two axes: provider authority (static) +
verification evidence (code-computed). HIGH now requires ground-truth
corroboration (legitimacyVerdict OK), independent of provider; authority alone
caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a
MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a
correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI;
updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent).

Addresses davesienkowski's W3 review on #664.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#656): bind classify-confidence verdict to code, closing CLI self-grading

Adversarial review found the new --legitimacy-verdict flag was caller-supplied,
so an agent could self-assert OK->HIGH without any real legitimacy check —
reintroducing the exact self-grading hole W3 closes. Remove the free flag; the
CLI now computes the verdict via checkPackages only when --package/--ecosystem
is given (code-computed, not agent-asserted). Update the stale CLI test
(context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:58:48 -04:00

477 lines
16 KiB
JavaScript

'use strict';
/**
* Package Legitimacy Gate — structural contract tests (#2827)
*
* Verifies that the three agents (researcher, planner, executor) contain the
* interlocking instruction text that forms the slopsquatting defence gate.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const AGENTS = path.join(__dirname, '..', 'agents');
const RESEARCHER = path.join(AGENTS, 'gsd-phase-researcher.md');
const PLANNER = path.join(AGENTS, 'gsd-planner.md');
const EXECUTOR = path.join(AGENTS, 'gsd-executor.md');
function parseSections(md) {
const lines = md.split('\n');
const sections = [];
let current = { heading: '__preamble__', body: [] };
let inFence = false;
for (const line of lines) {
if (line.trimStart().startsWith('```')) inFence = !inFence;
if (!inFence && /^#{1,3} /.test(line)) {
sections.push(current);
current = { heading: line.replace(/^#+\s*/, '').trim(), body: [] };
continue;
}
current.body.push(line);
}
sections.push(current);
return sections;
}
function extractCodeBlocks(text) {
const blocks = [];
const lines = text.split('\n');
let inside = false;
let buf = [];
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
if (inside) {
blocks.push(buf.join('\n'));
buf = [];
}
inside = !inside;
continue;
}
if (inside) buf.push(line);
}
return blocks;
}
function extractResearchTemplate(content) {
const lines = content.split('\n');
let inside = false;
let isMarkdownFence = false;
let buf = [];
for (const line of lines) {
if (!inside && line.startsWith('```markdown')) {
inside = true;
isMarkdownFence = true;
buf = [];
continue;
}
if (inside && line.startsWith('```') && isMarkdownFence) {
const candidate = buf.join('\n');
if (/^\s*#\s+Phase\b/m.test(candidate)) return candidate;
inside = false;
isMarkdownFence = false;
continue;
}
if (inside) buf.push(line);
}
return '';
}
function extractPlanTemplate(content) {
const blocks = extractCodeBlocks(content);
for (const block of blocks) {
if (/^\s*<threat_model>/m.test(block)) return block;
}
return '';
}
function extractXmlElement(text, tag) {
const start = text.indexOf(`<${tag}>`);
const end = text.indexOf(`</${tag}>`);
if (start === -1 || end === -1) return '';
return text.slice(start, end + tag.length + 3);
}
function normalizeTokens(text) {
return text
.toLowerCase()
.replace(/https?:\/\//g, ' ')
.replace(/[\[\]]/g, '')
.replace(/[^a-z0-9{}:_-]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
}
function hasAllTokens(text, required) {
const tokenSet = new Set(normalizeTokens(text));
return required.every((token) => tokenSet.has(token.toLowerCase()));
}
function anyLineHasAll(lines, required) {
return lines.some((line) => hasAllTokens(line, required));
}
function parseMarkdownTable(lines) {
const tableLines = lines.filter((line) => /^\s*\|/.test(line));
if (tableLines.length < 2) return null;
const toCells = (line) => line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim());
const headers = toCells(tableLines[0]);
const rows = tableLines
.slice(2)
.map(toCells)
.filter((cells) => cells.length === headers.length)
.map((cells) => ({
cells,
fields: Object.fromEntries(headers.map((h, i) => [h, cells[i]])),
}));
return { headers, rows };
}
function parseMarkdownTables(lines) {
const groups = [];
let current = [];
for (const line of lines) {
if (/^\s*\|/.test(line)) {
current.push(line);
continue;
}
if (current.length > 0) {
groups.push(current);
current = [];
}
}
if (current.length > 0) groups.push(current);
return groups
.map((group) => parseMarkdownTable(group))
.filter(Boolean);
}
function lineIndexes(lines, predicate) {
const indexes = [];
for (let i = 0; i < lines.length; i += 1) {
if (predicate(lines[i], i)) indexes.push(i);
}
return indexes;
}
function inNearbyWindow(sourceIndexes, targetIndexes, distance) {
return sourceIndexes.some((src) => targetIndexes.some((dst) => Math.abs(src - dst) <= distance));
}
function readModel(filePath) {
const text = fs.readFileSync(filePath, 'utf-8');
return {
text,
lines: text.split('\n'),
sections: parseSections(text),
codeBlocks: extractCodeBlocks(text),
};
}
// allow-test-rule: source-text-is-the-product
// Agent .md files — their text IS what the runtime loads.
// Testing text content tests the deployed contract.
// Per CONTRIBUTING.md exception matrix.
describe('gsd-phase-researcher.md — package-legitimacy seam invocation', () => {
let model;
before(() => {
model = readModel(RESEARCHER);
});
test('invokes gsd-tools query package-legitimacy check inside a fenced code block', () => {
const found = model.codeBlocks.some((block) =>
hasAllTokens(block, ['package-legitimacy', 'check'])
);
assert.ok(found, 'researcher must invoke package-legitimacy check inside a fenced code block');
});
test('package-legitimacy invocation includes --ecosystem flag', () => {
const found = model.codeBlocks.some((block) =>
hasAllTokens(block, ['package-legitimacy', 'check']) && hasAllTokens(block, ['--ecosystem'])
);
assert.ok(found, 'package-legitimacy check must include --ecosystem flag');
});
test('documents SLOP, SUS, OK verdict interpretation', () => {
const hasSLOP = anyLineHasAll(model.lines, ['slop']);
const hasSUS = anyLineHasAll(model.lines, ['sus']);
const hasOK = anyLineHasAll(model.lines, ['ok']);
assert.ok(hasSLOP && hasSUS && hasOK, 'researcher must document SLOP, SUS, OK verdict interpretation');
});
test('documents [ASSUMED] tag for WebSearch-discovered packages not verified against authoritative source', () => {
const hasAssumedLine = anyLineHasAll(model.lines, ['assumed']);
const hasWebSearchOrTraining = model.lines.some((line) =>
hasAllTokens(line, ['websearch']) || hasAllTokens(line, ['training'])
);
assert.ok(
hasAssumedLine && hasWebSearchOrTraining,
'researcher must document [ASSUMED] tag for packages from non-authoritative sources'
);
});
});
describe('gsd-phase-researcher.md — Package Legitimacy Audit section in template', () => {
let templateSections;
before(() => {
const model = readModel(RESEARCHER);
const template = extractResearchTemplate(model.text);
templateSections = parseSections(template);
});
test('RESEARCH.md template contains Package Legitimacy Audit section', () => {
const section = templateSections.find((s) => s.heading === 'Package Legitimacy Audit');
assert.ok(section, 'RESEARCH.md template must include a Package Legitimacy Audit section');
});
test('Package Legitimacy Audit table has required columns', () => {
const section = templateSections.find((s) => s.heading === 'Package Legitimacy Audit');
assert.ok(section, 'Package Legitimacy Audit section must exist');
const table = parseMarkdownTable(section.body);
assert.ok(table, 'Package Legitimacy Audit section must include a markdown table');
// 'slopcheck' column renamed to 'Verdict' to reflect the code seam (gsd-tools query package-legitimacy)
const expected = ['Package', 'Registry', 'Age', 'Downloads', 'Verdict', 'Disposition'];
for (const column of expected) {
assert.ok(table.headers.includes(column), `audit table must have "${column}" column`);
}
});
test('audit section documents [SLOP], [SUS], and [OK] dispositions', () => {
const section = templateSections.find((s) => s.heading === 'Package Legitimacy Audit');
assert.ok(section, 'Package Legitimacy Audit section must exist');
const table = parseMarkdownTable(section.body);
assert.ok(table, 'Package Legitimacy Audit section must include a markdown table');
const rowTexts = table.rows.map((row) => row.cells.join(' '));
const slop = rowTexts.some((value) => hasAllTokens(value, ['slop']));
const sus = rowTexts.some((value) => hasAllTokens(value, ['sus']));
const ok = rowTexts.some((value) => hasAllTokens(value, ['ok']));
assert.ok(slop, 'audit section must document [SLOP] disposition');
assert.ok(sus, 'audit section must document [SUS] disposition');
assert.ok(ok, 'audit section must document [OK] disposition');
});
});
describe('gsd-phase-researcher.md — ecosystem-specific package verification', () => {
let model;
before(() => {
model = readModel(RESEARCHER);
});
test('documents pip index versions for Python phases', () => {
assert.ok(anyLineHasAll(model.lines, ['pip', 'index', 'versions']), 'researcher must document pip index versions');
});
test('documents cargo search for Rust phases', () => {
assert.ok(anyLineHasAll(model.lines, ['cargo', 'search']), 'researcher must document cargo search');
});
});
describe('gsd-phase-researcher.md — no npx --yes auto-download', () => {
let model;
before(() => {
model = readModel(RESEARCHER);
});
test('does not invoke npx --yes inside a code block', () => {
const found = model.codeBlocks.some((block) => hasAllTokens(block, ['npx', '--yes']));
assert.equal(found, false, 'researcher must not invoke npx --yes in any code block');
});
test('context7 is accessed via mcp__context7__ tools (not raw CLI)', () => {
// The research-plan seam routes context7 queries; the agent calls MCP tools directly.
// Verify the provider table references mcp__context7__ rather than a raw ctx7 CLI invocation.
const hasMcpContext7 = anyLineHasAll(model.lines, ['mcp__context7__']);
assert.ok(hasMcpContext7, 'researcher must reference mcp__context7__ tools for context7 access');
});
});
describe('gsd-phase-researcher.md — WebSearch-origin package tagging', () => {
let model;
before(() => {
model = readModel(RESEARCHER);
});
test('packages discovered via WebSearch are tagged [ASSUMED]', () => {
const webSearchLines = lineIndexes(model.lines, (line) => hasAllTokens(line, ['websearch']));
const assumedLines = lineIndexes(model.lines, (line) => hasAllTokens(line, ['assumed']));
assert.ok(webSearchLines.length > 0, 'researcher file must mention WebSearch');
assert.ok(assumedLines.length > 0, 'researcher file must mention [ASSUMED]');
assert.ok(
inNearbyWindow(webSearchLines, assumedLines, 25),
'researcher must instruct WebSearch-discovered packages are tagged [ASSUMED] in nearby guidance'
);
});
});
describe('gsd-planner.md — checkpoint gate for [ASSUMED]/[SUS] packages', () => {
let model;
before(() => {
model = readModel(PLANNER);
});
test('checkpoint:human-verify guidance references [ASSUMED] and [SUS]', () => {
const hasCheckpoint = anyLineHasAll(model.lines, ['checkpoint:human-verify']);
const hasAssumed = anyLineHasAll(model.lines, ['assumed']);
const hasSus = anyLineHasAll(model.lines, ['sus']);
assert.ok(hasCheckpoint && hasAssumed, 'planner must gate [ASSUMED] packages behind checkpoint:human-verify');
assert.ok(hasCheckpoint && hasSus, 'planner must gate [SUS] packages behind checkpoint:human-verify');
});
test('package-legitimacy checkpoint uses blocking-human gate and non-auto-approvable language', () => {
const hasBlockingHumanGate = anyLineHasAll(model.lines, ['checkpoint:human-verify', 'blocking-human']);
const hasNeverAutoApproveRule = model.lines.some((line) =>
hasAllTokens(line, ['never', 'auto-approvable']) ||
hasAllTokens(line, ['never', 'auto', 'approvable'])
);
assert.ok(hasBlockingHumanGate, 'planner legitimacy checkpoint must use gate="blocking-human"');
assert.ok(hasNeverAutoApproveRule, 'planner must state legitimacy checkpoints are never auto-approvable');
});
test('package verification checkpoint includes registry URL guidance', () => {
const hasRegistryGuidance = model.lines.some((line) =>
hasAllTokens(line, ['npmjs', 'package']) ||
hasAllTokens(line, ['pypi', 'project']) ||
hasAllTokens(line, ['crates', 'crates'])
);
assert.ok(hasRegistryGuidance, 'planner package-verify checkpoint must include registry URL examples');
});
});
describe('gsd-planner.md — supply-chain row in threat_model template', () => {
let planTemplate;
let threatModelBlock;
before(() => {
const model = readModel(PLANNER);
planTemplate = extractPlanTemplate(model.text);
threatModelBlock = extractXmlElement(planTemplate, 'threat_model');
});
test('PLAN.md template contains threat_model element', () => {
assert.ok(/^\s*<threat_model>/m.test(planTemplate), 'PLAN.md template must include <threat_model>');
});
test('threat_model template includes supply-chain row with mitigate disposition', () => {
const tables = parseMarkdownTables(threatModelBlock.split('\n'));
const strideTable = tables.find((table) => table.headers.includes('Threat ID'));
assert.ok(strideTable, 'threat_model must include STRIDE threat register table');
const supplyChainRow = strideTable.rows.find((row) => hasAllTokens(row.cells[0] || '', ['t-{phase}-sc']));
assert.ok(supplyChainRow, 'threat_model must include T-{phase}-SC supply-chain row');
const disposition = supplyChainRow.cells[3] || '';
assert.ok(hasAllTokens(disposition, ['mitigate']), 'supply-chain threat disposition must be mitigate');
});
});
describe('gsd-planner.md — no npx --yes auto-download', () => {
test('does not invoke npx --yes inside a code block', () => {
const model = readModel(PLANNER);
const found = model.codeBlocks.some((block) => hasAllTokens(block, ['npx', '--yes']));
assert.equal(found, false, 'planner must not invoke npx --yes in any code block');
});
});
describe('gsd-executor.md — package installs excluded from RULE 3 auto-fix', () => {
let model;
before(() => {
model = readModel(EXECUTOR);
});
test('does not invoke npx --yes inside a code block', () => {
const found = model.codeBlocks.some((block) => hasAllTokens(block, ['npx', '--yes']));
assert.equal(found, false, 'executor must not invoke npx --yes in any code block');
});
test('RULE 3 section explicitly excludes package-manager installs', () => {
const rule3Line = lineIndexes(model.lines, (line) => hasAllTokens(line, ['rule', '3']))[0];
assert.notEqual(rule3Line, undefined, 'executor must contain RULE 3 section');
const window = model.lines.slice(rule3Line, rule3Line + 35);
const hasInstallCommands =
anyLineHasAll(window, ['npm', 'install']) ||
anyLineHasAll(window, ['pip', 'install']) ||
anyLineHasAll(window, ['cargo', 'add']);
const hasExclusionLanguage =
anyLineHasAll(window, ['excluded']) ||
anyLineHasAll(window, ['not', 'auto-fixable']) ||
anyLineHasAll(window, ['do', 'not']);
assert.ok(hasInstallCommands && hasExclusionLanguage, 'RULE 3 must explicitly exclude package-manager installs');
});
test('failed package installs surface checkpoint:human-verify', () => {
const rule3Line = lineIndexes(model.lines, (line) => hasAllTokens(line, ['rule', '3']))[0];
assert.notEqual(rule3Line, undefined, 'executor must contain RULE 3 section');
const window = model.lines.slice(rule3Line, rule3Line + 50);
const hasFailureLanguage =
anyLineHasAll(window, ['failed', 'install']) ||
anyLineHasAll(window, ['install', 'fails']) ||
anyLineHasAll(window, ['install', 'failed']);
const hasCheckpoint = anyLineHasAll(window, ['checkpoint:human-verify']);
assert.ok(
hasFailureLanguage && hasCheckpoint,
'executor must emit checkpoint:human-verify when package install fails'
);
});
test('auto mode does not auto-approve package-legitimacy checkpoints', () => {
const autoModeLine = lineIndexes(model.lines, (line) => hasAllTokens(line, ['auto-mode', 'checkpoint', 'behavior']))[0];
assert.notEqual(autoModeLine, undefined, 'executor must define auto-mode checkpoint behavior');
const window = model.lines.slice(autoModeLine, autoModeLine + 25);
const hasExceptionRule =
anyLineHasAll(window, ['except', 'package-legitimacy', 'checkpoints']) ||
anyLineHasAll(window, ['do', 'not', 'auto-approve']) ||
anyLineHasAll(window, ['blocking-human']);
assert.ok(
hasExceptionRule,
'executor auto mode must explicitly block auto-approval for package-legitimacy checkpoints'
);
});
});