* feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools The predecessor package get-shit-done-cc publishes a colliding gsd-tools bin whose phases.clear DELETES where this package's ARCHIVES, and both print success-shaped output against a gitignored .planning/ -- which is how #3129 cost a user 43 phase directories with no error and nothing recoverable from git. The launcher's PATH branch now resolves gsd_run, published only by this package and self-locating via its own symlink chain to the sibling shim, instead of the colliding gsd-tools. A foreign handler becomes unreachable from PATH, and when no gsd_run is reachable the resolver fails closed rather than falling back -- that fallback was the vulnerability. This is smaller than the branch it replaces, which matters: the preamble is inlined into 113 shipped files and agents/gsd-verifier.md sits 2 bytes under a red-line size cap. unset -f gsd_run leads the preamble so a re-source is idempotent. Without it, command -v finds the shell function, returns a bare name, and the resolver falls through to an exit 1 that kills a sourced caller's shell. Adds gsd-tools runtime-identity, a manual diagnostic reporting this runtime's package coordinates over the baked package-identity (#498) and readHostVersion, with a strict total classifier: only a JSON object with an exact packageName verifies, since JSON.parse admits 0/"str"/[]/null/true. An inlined identity assertion was built and reviewed first, then withdrawn -- it breaks five frozen size ceilings and no assertion fits in 2 bytes. Closes #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3146): stop sync:launcher relocating a deliberate preamble placement Pre-existing defect, surfaced by this PR because sync is a no-op unless the snippet content actually changes. transformFile inserts the preamble into the first block that CALLS gsd_run, but gsd-core/workflows/explore.md deliberately places it in a bootstrap-only block that DEFINES gsd_run without calling it -- its own comment explains why: declining the research offer must not leave Step 5's commit call unbootstrapped. Stripping empties that block of calls, so the preamble migrated forward and broke the define-before-use invariant tests/explore-command.test.cjs pins. Reproduced on a pristine origin/next checkout with the base snippet and base file, so this was not introduced here. The insertion target now honours a block that already carried the preamble, falling back to the first calling block for files that have none yet. Adds a behavioral regression test over a two-block fixture. Also updates three runtime-launcher-parity tests that pinned the removed PATH fallback to gsd-tools. Their intent is preserved -- the PATH stub is renamed gsd_run so it is reachable by the new resolver, and the RUNTIME_DIR-wins test still asserts the stub is never invoked. Fixture shebangs move to an absolute /bin/sh, because the fixture PATH is deliberately restricted and #!/usr/bin/env sh could not resolve. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3146): backfill changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3146): document the FEATURES.md section-numbering practice The monotonically increasing section number in docs/FEATURES.md is the most frequent merge-conflict source in this repo, and it has TWO conflict cells, not one: the ### N. heading and the hand-maintained table of contents. Two PRs adding differently numbered features still collide on the TOC, so renumbering alone does not make a branch safe. This branch alone was renumbered 165 -> 166 -> 167 -> 168 across successive rebases. Adds a CONTRIBUTING section stating the practice: allocate the number last, never pre-emptively renumber, take max+1 after a rebase and update the TOC in the same commit, and never renumber someone else's section. Fork contributors are told explicitly they may leave the number to a maintainer at merge rather than chasing the counter. Agents are told to lease the allocation and to include the file in their published touched set. Records the durable fix as planned rather than pretending it exists: FEATURES.md should be generated from per-feature fragments the way CHANGELOG.md is generated from .changeset/, and the way tests/emitted-drift-acks/ works (#2914). Also renumbers this branch's own section to 168, leaving 167 to the PR already in flight. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* #3146 — runtime identity assertion.
|
|
*
|
|
* Two surfaces are under test:
|
|
* 1. `classifyIdentityProbe` / `buildIdentityPayload` — the pure classifier
|
|
* and payload builder (the defect-dense part).
|
|
* 2. The launcher preamble's shell check — the part that actually protects a
|
|
* workflow, because it runs on the CALLER side before a mutating verb.
|
|
*
|
|
* The preamble tests are behavioral: they stand up a fake `gsd-tools` on PATH
|
|
* and assert on what the preamble does, never on the snippet's text.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { runNode, runHook, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const {
|
|
classifyIdentityProbe,
|
|
buildIdentityPayload,
|
|
explainVerdict,
|
|
EXPECTED_PACKAGE_NAME,
|
|
} = require('../gsd-core/bin/lib/runtime-identity.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const GSD_TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
const SNIPPET = path.join(REPO_ROOT, 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
|
|
|
|
const okStdout = (name = EXPECTED_PACKAGE_NAME, version = '1.2.3') =>
|
|
JSON.stringify({ packageName: name, version });
|
|
|
|
describe('classifyIdentityProbe', () => {
|
|
test('classifies our own payload as ok', () => {
|
|
const v = classifyIdentityProbe({ stdout: okStdout(), exitCode: 0 });
|
|
assert.equal(v.reason, 'ok');
|
|
assert.equal(v.actual, EXPECTED_PACKAGE_NAME);
|
|
assert.equal(v.version, '1.2.3');
|
|
});
|
|
|
|
test('classifies predecessor usage text as no_identity_verb', () => {
|
|
// Shape verified against get-shit-done-cc@1.42.3: exit 1 + usage, no JSON.
|
|
const v = classifyIdentityProbe({
|
|
stdout: 'Usage: gsd-sdk <command> [args] [options]\n\nCommands:\n run <prompt>',
|
|
exitCode: 1,
|
|
});
|
|
assert.equal(v.reason, 'no_identity_verb');
|
|
assert.match(v.detail, /exit 1/);
|
|
});
|
|
|
|
test('classifies non-JSON stdout as unparseable', () => {
|
|
const v = classifyIdentityProbe({ stdout: 'not json at all', exitCode: 0 });
|
|
assert.equal(v.reason, 'unparseable');
|
|
});
|
|
|
|
test('classifies a foreign packageName as identity_mismatch', () => {
|
|
const v = classifyIdentityProbe({ stdout: okStdout('get-shit-done-cc'), exitCode: 0 });
|
|
assert.equal(v.reason, 'identity_mismatch');
|
|
assert.equal(v.actual, 'get-shit-done-cc');
|
|
});
|
|
|
|
test('classifies JSON without packageName as unparseable', () => {
|
|
const v = classifyIdentityProbe({ stdout: JSON.stringify({ version: '1.0.0' }), exitCode: 0 });
|
|
assert.equal(v.reason, 'unparseable');
|
|
});
|
|
|
|
// JSON.parse accepts all of these. A naive truthiness check would let `[]`
|
|
// through as a verified identity.
|
|
for (const [label, raw] of [
|
|
['number', '0'],
|
|
['string', '"str"'],
|
|
['array', '[]'],
|
|
['null', 'null'],
|
|
['boolean', 'true'],
|
|
]) {
|
|
test(`classifies non-object JSON (${label}) as unparseable`, () => {
|
|
const v = classifyIdentityProbe({ stdout: raw, exitCode: 0 });
|
|
assert.equal(v.reason, 'unparseable');
|
|
});
|
|
}
|
|
|
|
test('classifies spawn failure as probe_failed', () => {
|
|
const v = classifyIdentityProbe({ stdout: '', exitCode: null, spawnFailed: true });
|
|
assert.equal(v.reason, 'probe_failed');
|
|
});
|
|
|
|
test('classifies timeout as probe_failed without throwing', () => {
|
|
const v = classifyIdentityProbe({ stdout: '', exitCode: null, timedOut: true });
|
|
assert.equal(v.reason, 'probe_failed');
|
|
assert.match(v.detail, /timed out/);
|
|
});
|
|
|
|
test('ignores stderr noise by construction (stdout is the only input)', () => {
|
|
const v = classifyIdentityProbe({ stdout: okStdout(), exitCode: 0 });
|
|
assert.equal(v.reason, 'ok');
|
|
});
|
|
|
|
test('parses a CRLF payload as ok', () => {
|
|
const v = classifyIdentityProbe({ stdout: `${okStdout()}\r\n`, exitCode: 0 });
|
|
assert.equal(v.reason, 'ok');
|
|
});
|
|
|
|
test('classifies empty stdout as unparseable', () => {
|
|
assert.equal(classifyIdentityProbe({ stdout: '', exitCode: 0 }).reason, 'unparseable');
|
|
});
|
|
|
|
test('classifies whitespace-only stdout as unparseable', () => {
|
|
assert.equal(classifyIdentityProbe({ stdout: ' \n\t ', exitCode: 0 }).reason, 'unparseable');
|
|
});
|
|
|
|
test('ignores unknown payload keys so a future field cannot fail an older check', () => {
|
|
const stdout = JSON.stringify({
|
|
packageName: EXPECTED_PACKAGE_NAME,
|
|
version: '1.2.3',
|
|
somethingAddedLater: { nested: true },
|
|
});
|
|
assert.equal(classifyIdentityProbe({ stdout, exitCode: 0 }).reason, 'ok');
|
|
});
|
|
|
|
test('is total: never throws, always yields a known reason', () => {
|
|
const REASONS = new Set([
|
|
'ok',
|
|
'identity_mismatch',
|
|
'no_identity_verb',
|
|
'unparseable',
|
|
'probe_failed',
|
|
]);
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string(),
|
|
fc.oneof(fc.integer({ min: -8, max: 8 }), fc.constant(null)),
|
|
fc.boolean(),
|
|
fc.boolean(),
|
|
(stdout, exitCode, spawnFailed, timedOut) => {
|
|
const v = classifyIdentityProbe({ stdout, exitCode, spawnFailed, timedOut });
|
|
assert.ok(REASONS.has(v.reason));
|
|
if (v.reason === 'ok') {
|
|
// `ok` is reachable ONLY through a well-formed exact match.
|
|
assert.equal(exitCode, 0);
|
|
assert.equal(spawnFailed, false);
|
|
assert.equal(timedOut, false);
|
|
assert.equal(JSON.parse(stdout).packageName, EXPECTED_PACKAGE_NAME);
|
|
}
|
|
},
|
|
),
|
|
{ seed: 31460, numRuns: 300 },
|
|
);
|
|
});
|
|
|
|
test('truncates evidence at the 200-char boundary', () => {
|
|
const at = classifyIdentityProbe({ stdout: 'x'.repeat(200), exitCode: 1 });
|
|
const over = classifyIdentityProbe({ stdout: 'y'.repeat(201), exitCode: 1 });
|
|
const under = classifyIdentityProbe({ stdout: 'z'.repeat(199), exitCode: 1 });
|
|
assert.equal(under.detail.includes('…'), false);
|
|
assert.equal(at.detail.includes('…'), false);
|
|
assert.equal(over.detail.includes('…'), true);
|
|
});
|
|
|
|
test('rejects a decoy that embeds the expected name in another field', () => {
|
|
const stdout = JSON.stringify({ packageName: 'get-shit-done-cc', note: EXPECTED_PACKAGE_NAME });
|
|
assert.equal(classifyIdentityProbe({ stdout, exitCode: 0 }).reason, 'identity_mismatch');
|
|
});
|
|
});
|
|
|
|
describe('buildIdentityPayload', () => {
|
|
test('reports the injected version', () => {
|
|
const p = buildIdentityPayload({ readVersion: () => '4.5.6' });
|
|
assert.equal(p.packageName, EXPECTED_PACKAGE_NAME);
|
|
assert.equal(p.version, '4.5.6');
|
|
});
|
|
|
|
test('a fail-closed 0.0.0 version still verifies (identity-only assertion)', () => {
|
|
const p = buildIdentityPayload({ readVersion: () => '0.0.0' });
|
|
const v = classifyIdentityProbe({ stdout: JSON.stringify(p), exitCode: 0 });
|
|
assert.equal(v.reason, 'ok');
|
|
});
|
|
|
|
test('payload shape is minimal — exactly the two documented keys', () => {
|
|
const p = buildIdentityPayload({ readVersion: () => '1.0.0' });
|
|
assert.deepEqual(Object.keys(p).sort(), ['packageName', 'version']);
|
|
});
|
|
|
|
});
|
|
|
|
describe('explainVerdict', () => {
|
|
test('names both plausible causes for a missing verb', () => {
|
|
const v = classifyIdentityProbe({ stdout: 'Usage: gsd-sdk', exitCode: 1 });
|
|
const msg = explainVerdict(v, '/usr/local/bin/gsd-tools');
|
|
assert.match(msg, /different package/);
|
|
// A legitimate older gsd-core also lacks the verb — the message must say so.
|
|
assert.match(msg, /predating the verb/);
|
|
assert.match(msg, /\/usr\/local\/bin\/gsd-tools/);
|
|
});
|
|
|
|
test('names the foreign package on a mismatch', () => {
|
|
const v = classifyIdentityProbe({ stdout: okStdout('some-other-pkg'), exitCode: 0 });
|
|
assert.match(explainVerdict(v, '/x/gsd-tools'), /some-other-pkg/);
|
|
});
|
|
});
|
|
|
|
describe('runtime-identity verb', () => {
|
|
test('emits a parseable payload naming this package', () => {
|
|
const r = runNode([GSD_TOOLS, 'runtime-identity'], { timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.equal(r.outcome, OUTCOME.EXITED);
|
|
assert.equal(r.exitCode, 0);
|
|
const parsed = JSON.parse(r.stdout);
|
|
assert.equal(parsed.packageName, EXPECTED_PACKAGE_NAME);
|
|
assert.equal(typeof parsed.version, 'string');
|
|
});
|
|
|
|
test('the real verb output classifies as ok', () => {
|
|
const r = runNode([GSD_TOOLS, 'runtime-identity'], { timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.equal(classifyIdentityProbe({ stdout: r.stdout, exitCode: r.exitCode }).reason, 'ok');
|
|
});
|
|
|
|
test('--raw emits the same identity on a single line', () => {
|
|
const r = runNode([GSD_TOOLS, 'runtime-identity', '--raw'], { timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.stdout.trim().split('\n').length, 1);
|
|
assert.equal(JSON.parse(r.stdout).packageName, EXPECTED_PACKAGE_NAME);
|
|
});
|
|
});
|
|
|
|
describe('launcher resolver: PATH branch prefers the collision-free bin', () => {
|
|
let dir;
|
|
let binDir;
|
|
|
|
// The predecessor package `get-shit-done-cc` publishes a `gsd-tools` bin but
|
|
// NO `gsd_run`. Preferring `gsd_run` is therefore what makes the colliding
|
|
// name unreachable from PATH — this suite pins that, and pins that we never
|
|
// fall back to executing the foreign binary (#3146, #3129).
|
|
const writeFake = (name, body) => {
|
|
const p = path.join(binDir, name);
|
|
// Absolute interpreter: env.PATH below is restricted to the fixture bin, so
|
|
// `#!/usr/bin/env sh` could not resolve `sh` and the fake would never run.
|
|
fs.writeFileSync(p, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
|
return p;
|
|
};
|
|
|
|
const sourceAndRun = (script) => {
|
|
const harness = path.join(dir, 'harness.sh');
|
|
fs.writeFileSync(harness, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
|
|
return runHook(harness, [], {
|
|
// Absolute path: `env.PATH` below is deliberately restricted to the
|
|
// fixture bin, so a bare `sh` would not resolve and the child would die
|
|
// before sourcing anything — making every assertion here vacuous.
|
|
interpreter: '/bin/sh',
|
|
cwd: dir,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
// PATH holds ONLY our fixture bin, so a real gsd_run installed on the
|
|
// developer's machine cannot silently satisfy the resolver and turn a
|
|
// failing case green.
|
|
env: { PATH: binDir, HOME: dir, RUNTIME_DIR: dir },
|
|
});
|
|
};
|
|
|
|
const skipOnWindows = (t) => {
|
|
if (process.platform !== 'win32') return false;
|
|
t.skip('POSIX shell preamble is not executed on Windows runtimes');
|
|
return true;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
dir = createTempDir('gsd-3146-resolver-');
|
|
binDir = path.join(dir, 'bin');
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
// node must be reachable without putting its real directory on PATH.
|
|
fs.symlinkSync(process.execPath, path.join(binDir, 'node'));
|
|
});
|
|
|
|
afterEach(() => cleanup(dir));
|
|
|
|
test('prefers gsd_run when a foreign gsd-tools is also on PATH', (t) => {
|
|
if (skipOnWindows(t)) return;
|
|
const ours = path.join(dir, 'OURS');
|
|
writeFake('gsd_run', `: > "${ours}"`);
|
|
const foreign = path.join(dir, 'FOREIGN');
|
|
writeFake('gsd-tools', `: > "${foreign}"`);
|
|
|
|
sourceAndRun(`. "${SNIPPET}"; gsd_run query anything`);
|
|
|
|
assert.equal(fs.existsSync(ours), true, 'our gsd_run should have run');
|
|
assert.equal(fs.existsSync(foreign), false, 'the foreign gsd-tools must never run');
|
|
});
|
|
|
|
test('never executes a foreign gsd-tools when no gsd_run is reachable', (t) => {
|
|
if (skipOnWindows(t)) return;
|
|
const foreign = path.join(dir, 'FOREIGN');
|
|
writeFake('gsd-tools', `: > "${foreign}"`);
|
|
|
|
const r = sourceAndRun(`. "${SNIPPET}"; gsd_run query anything`);
|
|
|
|
// Fail closed: the resolver reaches its final else and errors, rather than
|
|
// silently handing a state-mutating verb to a package it was not written for.
|
|
assert.equal(fs.existsSync(foreign), false, 'the foreign gsd-tools must never run');
|
|
assert.notEqual(r.exitCode, 0);
|
|
});
|
|
|
|
// Regression: a second source finds the `gsd_run` FUNCTION, so `command -v`
|
|
// returns a bare name rather than a path. A revision that guarded on `-x`
|
|
// rejected it, fell through every branch, and hit the resolver's `exit 1` —
|
|
// which, in a SOURCED script, terminates the caller's shell. `unset -f` at
|
|
// the top of the preamble is what makes re-sourcing idempotent instead.
|
|
test('sourcing the preamble twice leaves a working launcher and a live shell', (t) => {
|
|
if (skipOnWindows(t)) return;
|
|
const ours = path.join(dir, 'OURS');
|
|
const alive = path.join(dir, 'ALIVE');
|
|
writeFake('gsd_run', `: > "${ours}"`);
|
|
|
|
const r = sourceAndRun(`. "${SNIPPET}"; . "${SNIPPET}"; : > "${alive}"; gsd_run query anything`);
|
|
|
|
assert.equal(fs.existsSync(alive), true, 'the second source must not kill the shell');
|
|
assert.equal(fs.existsSync(ours), true, 'the launcher must still work after re-sourcing');
|
|
assert.equal(r.exitCode, 0);
|
|
});
|
|
|
|
// A non-executable file named `gsd_run` must never be selected. This is what
|
|
// makes an explicit `-x` guard unnecessary: PATH search already requires an
|
|
// executable, so `command -v` simply does not return this file. An earlier
|
|
// revision carried such a guard, and it caused a worse bug — on a second
|
|
// source it rejected the shell FUNCTION's bare name and fell through to the
|
|
// resolver's `exit 1`, which kills a SOURCED caller's shell.
|
|
test('never selects a non-executable gsd_run, and still refuses the foreign bin', (t) => {
|
|
if (skipOnWindows(t)) return;
|
|
fs.writeFileSync(path.join(binDir, 'gsd_run'), 'not executable\n', { mode: 0o644 });
|
|
const foreign = path.join(dir, 'FOREIGN');
|
|
writeFake('gsd-tools', `: > "${foreign}"`);
|
|
|
|
const r = sourceAndRun(`. "${SNIPPET}"; gsd_run query anything`);
|
|
|
|
assert.equal(fs.existsSync(foreign), false, 'the foreign gsd-tools must never run');
|
|
assert.notEqual(r.exitCode, 0, 'resolution must fail closed');
|
|
});
|
|
});
|