fix(#1460): verify-or-reject capability --integrity per source; confine hook commands to the bundle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,18 @@ function capManifestVersion(dir, id) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, '.gsd', 'capabilities', id, 'capability.json'), 'utf8')).version;
|
||||
} catch { return null; }
|
||||
}
|
||||
/**
|
||||
* #1460 CONF-1: the expected absolute hook command — `script` resolved against the
|
||||
* capability install dir and confined via realpath of the existing ancestor chain
|
||||
* (so an ancestor symlink cannot escape). Mirrors confinedBundleScript in the source.
|
||||
*/
|
||||
function expectedBundleCommand(dir, id, script) {
|
||||
const capDir = path.join(dir, '.gsd', 'capabilities', id);
|
||||
const target = path.resolve(capDir, script);
|
||||
let parent = path.dirname(target);
|
||||
try { parent = fs.realpathSync(parent); } catch { /* lexical fallback below */ }
|
||||
return path.join(parent, path.basename(target));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install
|
||||
@@ -294,7 +306,8 @@ test('upgrade: a changed executable set without consent aborts and leaves the OL
|
||||
assert.strictEqual(res.requiresConsent, true);
|
||||
assert.strictEqual(capManifestVersion(dir, 'e'), '1.0.0', 'old bundle untouched');
|
||||
assert.strictEqual(readLedgerEntry(dir, 'e').version, '1.0.0', 'old ledger untouched');
|
||||
assert.strictEqual(readSettings(dir).hooks.PostToolUse[0].hooks[0].command, 'hooks/a.js');
|
||||
// #1460 CONF-1/(R): command is the ABSOLUTE confined path inside the bundle (POSIX single-quoted), not the raw relative form.
|
||||
assert.strictEqual(readSettings(dir).hooks.PostToolUse[0].hooks[0].command, shQuote(expectedBundleCommand(dir, 'e', 'hooks/a.js')));
|
||||
});
|
||||
|
||||
test('upgrade: a changed executable set WITH consent upgrades and re-derives shared edits', async () => {
|
||||
@@ -310,7 +323,110 @@ test('upgrade: a changed executable set WITH consent upgrades and re-derives sha
|
||||
assert.strictEqual(res.status, 'upgraded');
|
||||
const hooks = readSettings(dir).hooks.PostToolUse;
|
||||
assert.strictEqual(hooks.length, 1);
|
||||
assert.strictEqual(hooks[0].hooks[0].command, 'hooks/b.js', 'old shared edit stripped, new applied');
|
||||
// #1460 CONF-1/(R): re-derived command is the ABSOLUTE confined path inside the bundle (POSIX single-quoted).
|
||||
assert.strictEqual(hooks[0].hooks[0].command, shQuote(expectedBundleCommand(dir, 'e', 'hooks/b.js')), 'old shared edit stripped, new applied (quoted absolute confined path)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1460 (R) HIGH: the emitted hook `command` must be shell-safe
|
||||
// ---------------------------------------------------------------------------
|
||||
// A hook `command` string is consumed by a shell (first-party hooks emit
|
||||
// `node "${CLAUDE_PLUGIN_ROOT}/hooks/x.js"`). The non-manifest install-prefix
|
||||
// (the home/runtime dir) commonly contains spaces (e.g. "/Users/Bob Smith/...")
|
||||
// — written unquoted it word-splits and breaks (or, with a hostile prefix,
|
||||
// could inject). The emitted absolute command must be POSIX single-quoted.
|
||||
|
||||
/** A runtime dir whose absolute path contains a SPACE (mirrors "/Users/Bob Smith/.claude"). */
|
||||
function runtimeWithSpace() {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cap life-'));
|
||||
cleanups.push(base);
|
||||
const dir = path.join(base, 'Bob Smith', '.claude');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** POSIX single-quote a string the way applyCapabilitySharedEdits must. */
|
||||
function shQuote(s) {
|
||||
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
test('#1460 (R): emitted hook command is single-quoted when the install prefix contains a space', async () => {
|
||||
const dir = runtimeWithSpace();
|
||||
assert.ok(dir.includes(' '), 'precondition: install prefix contains a space');
|
||||
const res = await lifecycle.installCapability('./e', {
|
||||
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
||||
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/format.sh' })),
|
||||
});
|
||||
assert.strictEqual(res.status, 'installed', JSON.stringify(res));
|
||||
const command = readSettings(dir).hooks.PostToolUse[0].hooks[0].command;
|
||||
const expectedAbs = expectedBundleCommand(dir, 'e', 'hooks/format.sh');
|
||||
// revert-fails: without quoting the command is the bare space-containing absolute path,
|
||||
// which a shell would word-split (the second token would be executed as a command).
|
||||
assert.strictEqual(command, shQuote(expectedAbs), 'command must be POSIX single-quoted');
|
||||
// Defense-in-depth: emulate POSIX word-splitting — single-quoted runs are atomic (whitespace
|
||||
// inside them does NOT split). The quoted command must collapse to exactly ONE word (the path).
|
||||
const words = command.match(/'[^']*'|[^\s']+/g) || [];
|
||||
assert.strictEqual(words.length, 1, 'quoting must keep the space-containing path as a single shell word');
|
||||
assert.strictEqual(words[0], command, 'the single word IS the entire quoted command');
|
||||
// Negative control: the UNQUOTED path would split into >1 word at the space (the bug this prevents).
|
||||
assert.ok((expectedAbs.match(/'[^']*'|[^\s']+/g) || []).length > 1, 'precondition: the bare path word-splits');
|
||||
});
|
||||
|
||||
test('#1460 (R): a normal script under a normal prefix emits the quoted absolute command and stays strippable by CAP_MARKER', async () => {
|
||||
const dir = runtime();
|
||||
await lifecycle.installCapability('./e', {
|
||||
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
||||
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/run.js' })),
|
||||
});
|
||||
const before = readSettings(dir).hooks.PostToolUse;
|
||||
assert.strictEqual(before.length, 1);
|
||||
assert.strictEqual(before[0][CAP_MARKER], 'e');
|
||||
// revert-fails: without quoting the control command is the bare absolute path.
|
||||
assert.strictEqual(before[0].hooks[0].command, shQuote(expectedBundleCommand(dir, 'e', 'hooks/run.js')));
|
||||
// Strip is keyed on CAP_MARKER===capId, NOT the command string — quoting does not break it.
|
||||
const rem = await lifecycle.removeCapability('e', {
|
||||
runtimeDir: dir, hostVersion: '1.6.0', sharedFiles: ['settings.json'],
|
||||
});
|
||||
assert.strictEqual(rem.status, 'removed', JSON.stringify(rem));
|
||||
const after = readSettings(dir);
|
||||
assert.ok(!after || !after.hooks || !after.hooks.PostToolUse || after.hooks.PostToolUse.length === 0,
|
||||
'the stamped hook is stripped by CAP_MARKER regardless of quoting');
|
||||
});
|
||||
|
||||
test('#1460 (R): idempotent strip-then-reapply yields the identical quoted command', () => {
|
||||
const dir = runtime();
|
||||
const capId = 'e';
|
||||
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
|
||||
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(path.join(capDirPath, 'hooks', 'run.js'), '// x', 'utf8');
|
||||
const manifest = execCap(capId, '1.0.0', { script: 'hooks/run.js' });
|
||||
const apply = () => lifecycle.applyCapabilitySharedEdits({
|
||||
runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'],
|
||||
});
|
||||
const edits = apply();
|
||||
const first = readSettings(dir).hooks.PostToolUse;
|
||||
// The real install/upgrade transition is strip-then-apply; re-running it must converge.
|
||||
lifecycle.stripCapabilitySharedEdits({ runtimeDir: dir, capId, sharedEdits: edits });
|
||||
apply();
|
||||
const second = readSettings(dir).hooks.PostToolUse;
|
||||
assert.strictEqual(second.length, 1, 'idempotent: still exactly one stamped hook after strip+reapply');
|
||||
assert.strictEqual(second[0].hooks[0].command, first[0].hooks[0].command, 'identical quoted command on re-apply');
|
||||
assert.strictEqual(second[0].hooks[0].command, shQuote(expectedBundleCommand(dir, capId, 'hooks/run.js')));
|
||||
});
|
||||
|
||||
test('#1460 (R): confinedBundleScript returns null for an unsafe-char script (defense-in-depth)', () => {
|
||||
const dir = runtime();
|
||||
const capDirPath = path.join(dir, '.gsd', 'capabilities', 'e');
|
||||
fs.mkdirSync(capDirPath, { recursive: true });
|
||||
// Even when the file literally exists on disk inside the bundle, an unsafe-char script
|
||||
// name must be refused — so applyCapabilitySharedEdits skips it even if validation were
|
||||
// bypassed. revert-fails: without the allowlist guard this returns the absolute path.
|
||||
fs.writeFileSync(path.join(capDirPath, 'run.sh; touch pwn'), '// x', 'utf8');
|
||||
assert.strictEqual(lifecycle.confinedBundleScript(capDirPath, 'run.sh; touch pwn'), null);
|
||||
// A normal script still resolves to its confined absolute path.
|
||||
fs.writeFileSync(path.join(capDirPath, 'ok.sh'), '// x', 'utf8');
|
||||
const ok = lifecycle.confinedBundleScript(capDirPath, 'ok.sh');
|
||||
assert.ok(typeof ok === 'string' && ok.endsWith(path.join('e', 'ok.sh')), 'normal script resolves: ' + ok);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -813,6 +929,98 @@ test('applyCapabilitySharedEdits: __proto__ event/name is skipped (no pollution)
|
||||
assert.strictEqual({}.command, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1460 CONF-1 — a hook command is written as the ABSOLUTE path inside the
|
||||
// capability's own install dir, confined via realpath; a script that resolves
|
||||
// OUTSIDE the bundle is NOT written.
|
||||
// revert-fails: with the raw `command: script` restored (pre-fix), the absolute
|
||||
// assertion fails and a bundle-escaping script would be written.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('#1460 CONF-1: a relative hook script is emitted as the absolute path inside capDir', () => {
|
||||
const dir = runtime();
|
||||
const capId = 'conf1';
|
||||
// Make the install dir real so realpath confinement resolves a concrete chain.
|
||||
const capDir = path.join(dir, '.gsd', 'capabilities', capId);
|
||||
fs.mkdirSync(path.join(capDir, 'subdir'), { recursive: true });
|
||||
fs.writeFileSync(path.join(capDir, 'subdir', 'run.js'), '// hook', 'utf8');
|
||||
|
||||
lifecycle.applyCapabilitySharedEdits({
|
||||
runtimeDir: dir,
|
||||
capId,
|
||||
manifest: { hooks: [{ event: 'PostToolUse', script: 'subdir/run.js' }] },
|
||||
sharedFiles: ['settings.json'],
|
||||
});
|
||||
|
||||
const s = readSettings(dir);
|
||||
const command = s.hooks.PostToolUse[0].hooks[0].command;
|
||||
const expectedAbs = path.join(fs.realpathSync(path.join(capDir, 'subdir')), 'run.js');
|
||||
// #1460 (R): the emitted command is the absolute confined path, POSIX single-quoted.
|
||||
assert.strictEqual(command, shQuote(expectedAbs), 'command must be the quoted absolute confined path inside capDir');
|
||||
const unquoted = command.slice(1, -1); // strip the wrapping single quotes for the path-shape checks
|
||||
assert.ok(path.isAbsolute(unquoted), 'command must be absolute (CWD-independent)');
|
||||
assert.ok(unquoted.startsWith(fs.realpathSync(capDir) + path.sep), 'command must live inside the bundle');
|
||||
});
|
||||
|
||||
test('#1460 CONF-1: a script resolving OUTSIDE capDir via a symlinked subdir is NOT written (skipped)', (t) => {
|
||||
const dir = runtime();
|
||||
const capId = 'conf1-escape';
|
||||
const capDir = path.join(dir, '.gsd', 'capabilities', capId);
|
||||
fs.mkdirSync(capDir, { recursive: true });
|
||||
|
||||
// Plant a victim file outside the bundle and a symlinked subdir inside the bundle
|
||||
// that points at the victim's parent. A relative script "evil/run.js" would then
|
||||
// resolve to the victim through the symlink — confinement must refuse it.
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'conf1-outside-'));
|
||||
cleanups.push(outside);
|
||||
fs.writeFileSync(path.join(outside, 'run.js'), '// victim', 'utf8');
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(capDir, 'evil'));
|
||||
} catch {
|
||||
t.skip('symlink not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycle.applyCapabilitySharedEdits({
|
||||
runtimeDir: dir,
|
||||
capId,
|
||||
manifest: { hooks: [{ event: 'PostToolUse', script: 'evil/run.js' }] },
|
||||
sharedFiles: ['settings.json'],
|
||||
});
|
||||
|
||||
const s = readSettings(dir);
|
||||
// The escaping hook is skipped → no settings written at all (no touched edits).
|
||||
assert.strictEqual(s, null, 'no shared-config edit must be written for a bundle-escaping script');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1460 CONF-2 — REGRESSION GUARD: confinedSharedFile realpaths the FULL ancestor
|
||||
// chain, so an ANCESTOR symlink (not just the final component) cannot escape.
|
||||
// revert-fails: if confinedSharedFile were changed to realpath only the final
|
||||
// component, a path through a symlinked ancestor would resolve OUTSIDE runtimeDir
|
||||
// and this test (asserting null) would fail.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('#1460 CONF-2: confinedSharedFile refuses a path through a symlinked ANCESTOR directory', (t) => {
|
||||
const dir = runtime();
|
||||
// Build runtimeDir/inner where `inner` is a symlink to a directory OUTSIDE runtimeDir.
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'conf2-outside-'));
|
||||
cleanups.push(outside);
|
||||
fs.mkdirSync(path.join(outside, 'deep'), { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(dir, 'inner'));
|
||||
} catch {
|
||||
t.skip('symlink not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
// A path whose ANCESTOR ("inner") is the escaping symlink — the final component
|
||||
// ("settings.json") is not itself a link, so a final-component-only realpath would
|
||||
// miss the escape. confinedSharedFile realpaths the parent chain and must return null.
|
||||
const result = lifecycle.confinedSharedFile(dir, path.join('inner', 'deep', 'settings.json'));
|
||||
assert.strictEqual(result, null, 'a path through a symlinked ancestor must be refused (null)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Site B: reconcileCapabilities on a corrupt-present ledger must surface a
|
||||
// warning in its report, not silently do nothing (#1462).
|
||||
|
||||
@@ -1819,6 +1819,62 @@ describe('C4: description and hooks validation', () => {
|
||||
assert.deepEqual(hookErrors, [], 'Expected no hook errors for valid hooks entry, got: ' + JSON.stringify(hookErrors));
|
||||
});
|
||||
|
||||
// ─── #1460 (R) HIGH: hook script path must be shell-safe ──────────────────
|
||||
// The hook `script` is resolved to an absolute path and written verbatim as the hook
|
||||
// `command` STRING in settings.json (consumed by a shell). A manifest-controlled script
|
||||
// name containing shell metacharacters (`;`, `|`, `$`, backtick, whitespace, …) would
|
||||
// inject a second command at hook-exec time. Fail closed at the validator: reject any
|
||||
// script path outside the conservative [A-Za-z0-9._/-] allowlist. revert-fails: without
|
||||
// the allowlist these all pass the non-empty-string check and validate OK.
|
||||
for (const [label, script] of [
|
||||
['command-injection via `;`', 'run.sh; touch /tmp/pwn'],
|
||||
['embedded space', 'my hook.sh'],
|
||||
['command substitution `$( )`', 'run-$(whoami).sh'],
|
||||
['backtick substitution', 'run-`id`.sh'],
|
||||
['pipe metacharacter', 'a.sh|b.sh'],
|
||||
['newline injection', 'a.sh\ntouch /tmp/pwn'],
|
||||
['ampersand background', 'a.sh & evil'],
|
||||
['shell glob', 'hooks/*.sh'],
|
||||
['redirect', 'a.sh > /tmp/pwn'],
|
||||
['leading dash (option injection)', '-rf'],
|
||||
['single quote', "a'.sh"],
|
||||
['double quote', 'a".sh'],
|
||||
['NUL/control char', 'a\u0000.sh'],
|
||||
]) {
|
||||
test(`hook script with unsafe chars is rejected (${label})`, () => {
|
||||
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script }] };
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
|
||||
assert.ok(
|
||||
hookErrors.length > 0,
|
||||
`Expected a hooks[0].script rejection for ${label} (script=${JSON.stringify(script)}), got: ` + JSON.stringify(errors),
|
||||
);
|
||||
assert.ok(
|
||||
hookErrors.some((e) => /unsafe character/.test(e)),
|
||||
'Error should mention unsafe characters, got: ' + JSON.stringify(hookErrors),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('hook script with absolute path is rejected', () => {
|
||||
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '/etc/evil.sh' }] };
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
assert.ok(errors.some((e) => e.includes('hooks[0].script')), 'absolute script must be rejected: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('hook script with .. traversal is rejected', () => {
|
||||
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '../../etc/evil.sh' }] };
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
assert.ok(errors.some((e) => e.includes('hooks[0].script')), '.. script must be rejected: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('hook script with a normal nested relative path is still accepted', () => {
|
||||
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: 'hooks/sub-dir/format_v2.sh' }] };
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
|
||||
assert.deepEqual(hookErrors, [], 'Expected a normal nested relative script to be accepted, got: ' + JSON.stringify(hookErrors));
|
||||
});
|
||||
|
||||
test('description present in UI_CAP passes validation', () => {
|
||||
const errors = validateCapability(UI_CAP, 'ui');
|
||||
const descErrors = errors.filter((e) => e.includes('description'));
|
||||
|
||||
@@ -404,6 +404,7 @@ describe('tarball adapter — integrity via _setCapabilitySourceHttpGet', () =>
|
||||
|
||||
_setCapabilitySourceHttpGet(() => Promise.resolve({ statusCode: 200, body: tgzBuf }));
|
||||
|
||||
let tarCalls = 0;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveCapabilitySource('https://example.com/tarball-mismatch.tgz', {
|
||||
@@ -412,7 +413,8 @@ describe('tarball adapter — integrity via _setCapabilitySourceHttpGet', () =>
|
||||
integrity: badIntegrity,
|
||||
execOverrides: {
|
||||
tar: (_prog, args, _opts) => {
|
||||
// Should never be reached — integrity check fires first.
|
||||
// Must never be reached — integrity check fires before any tar invocation.
|
||||
tarCalls++;
|
||||
const extractDir = args[args.indexOf('-C') + 1];
|
||||
fs.writeFileSync(
|
||||
path.join(extractDir, 'capability.json'),
|
||||
@@ -426,12 +428,146 @@ describe('tarball adapter — integrity via _setCapabilitySourceHttpGet', () =>
|
||||
/integrity mismatch|mismatch/i
|
||||
);
|
||||
|
||||
// Integrity is verified over the raw .tgz bytes BEFORE any tar call — ordering invariant.
|
||||
assert.strictEqual(tarCalls, 0, 'tar must NOT be invoked — integrity verified over the .tgz bytes before extraction');
|
||||
|
||||
// No staged directory must exist.
|
||||
const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'tarball-mismatch');
|
||||
assert.ok(!fs.existsSync(finalDir), 'staged dir must NOT exist after integrity mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1460 CS-1 — a supplied --integrity is NEVER silently ignored.
|
||||
// - npm: verified over the produced .tgz bytes (same SRI sha512 domain as tarball).
|
||||
// - git / local: REJECTED with an actionable error (no single byte-SRI artifact).
|
||||
// revert-fails: with stageValidated's `integrity: null` restored on these adapters
|
||||
// (the pre-fix behaviour), the npm-mismatch case would silently resolve and the
|
||||
// git/local cases would silently resolve with integrity:null — every assert below
|
||||
// would then fail.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1460 CS-1 — supplied --integrity is verified or rejected per source (never silently dropped)', () => {
|
||||
let gsdHome = '';
|
||||
beforeEach(() => { gsdHome = createTempDir('gsd-home-'); });
|
||||
afterEach(() => { cleanup(gsdHome); });
|
||||
|
||||
/** Build a fake `npm pack` that writes a real .tgz of `tgzBytes` to the --pack-destination. */
|
||||
function fakeNpmPack(tgzBytes) {
|
||||
return (args) => {
|
||||
const destIdx = args.indexOf('--pack-destination');
|
||||
const dest = destIdx >= 0 ? args[destIdx + 1] : '';
|
||||
fs.writeFileSync(path.join(dest, 'cap.tgz'), tgzBytes);
|
||||
return { exitCode: 0, stdout: 'cap.tgz\n', stderr: '', signal: null, error: null };
|
||||
};
|
||||
}
|
||||
|
||||
/** A tar override that lists safe members and writes `cap`'s capability.json on extract. */
|
||||
function fakeTar(cap) {
|
||||
return (_prog, args) => {
|
||||
if (args[0] === '-tzf') {
|
||||
return { exitCode: 0, stdout: 'package/capability.json\n', stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (args[0] === '-tvzf') {
|
||||
return { exitCode: 0, stdout: '-rw-r--r-- 0 user group 10 Jan 1 2020 package/capability.json\n', stderr: '', signal: null, error: null };
|
||||
}
|
||||
const extractDir = args[args.indexOf('-C') + 1];
|
||||
const pkgDir = path.join(extractDir, 'package');
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pkgDir, 'capability.json'), JSON.stringify(cap), 'utf8');
|
||||
return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null };
|
||||
};
|
||||
}
|
||||
|
||||
test('npm + matching --integrity (over the .tgz bytes) → resolves', async () => {
|
||||
const cap = featureCap('npm-int-ok');
|
||||
const tgzBytes = Buffer.from('a deterministic npm tarball payload for integrity', 'utf8');
|
||||
const integrity = sha512b64(tgzBytes);
|
||||
|
||||
const result = await resolveCapabilitySource('npm:@org/npm-int-ok@^1.0.0', {
|
||||
gsdHome,
|
||||
hostVersion: '1.5.0',
|
||||
integrity,
|
||||
execOverrides: { npm: fakeNpmPack(tgzBytes), tar: fakeTar(cap) },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'npm-int-ok');
|
||||
assert.ok(result.integrity && result.integrity.startsWith('sha512-'), 'integrity must be recorded');
|
||||
assert.ok(fs.existsSync(result.stagedDir), 'staged dir must exist');
|
||||
});
|
||||
|
||||
test('npm + MISMATCHING --integrity → throws BEFORE promote/staging (no final dir)', async () => {
|
||||
const cap = featureCap('npm-int-bad');
|
||||
const tgzBytes = Buffer.from('the real npm tarball bytes', 'utf8');
|
||||
const badIntegrity = 'sha512-' + Buffer.from('not-the-real-hash').toString('base64');
|
||||
|
||||
let tarCalls = 0;
|
||||
const instrumentedTar = (_prog, args) => {
|
||||
// Must never be reached — integrity is verified over the .tgz bytes before any tar call.
|
||||
tarCalls++;
|
||||
return fakeTar(cap)(_prog, args);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveCapabilitySource('npm:@org/npm-int-bad@^1.0.0', {
|
||||
gsdHome,
|
||||
hostVersion: '1.5.0',
|
||||
integrity: badIntegrity,
|
||||
execOverrides: { npm: fakeNpmPack(tgzBytes), tar: instrumentedTar },
|
||||
}),
|
||||
/integrity mismatch|mismatch/i,
|
||||
);
|
||||
|
||||
// Integrity is verified over the raw .tgz bytes BEFORE any tar call — ordering invariant.
|
||||
assert.strictEqual(tarCalls, 0, 'tar extraction must NOT run — integrity verified over the .tgz bytes before extraction');
|
||||
|
||||
const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'npm-int-bad');
|
||||
assert.ok(!fs.existsSync(finalDir), 'no final dir after integrity mismatch');
|
||||
});
|
||||
|
||||
test('git + any --integrity → throws an actionable error (no silent resolve)', async () => {
|
||||
// The integrity reject must fire BEFORE the clone — so execGit is never called.
|
||||
const gitCalls = [];
|
||||
const fakeGit = (...callArgs) => {
|
||||
gitCalls.push(callArgs);
|
||||
return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null };
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveCapabilitySource('https://github.com/org/repo.git#v1.0.0', {
|
||||
gsdHome,
|
||||
hostVersion: '1.5.0',
|
||||
integrity: 'sha512-' + Buffer.from('anything').toString('base64'),
|
||||
execOverrides: { git: fakeGit },
|
||||
}),
|
||||
/integrity pinning is not supported for git sources|#sha:/i,
|
||||
);
|
||||
|
||||
assert.strictEqual(gitCalls.length, 0, 'execGit must NOT run — rejection fires before the clone');
|
||||
const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'repo');
|
||||
assert.ok(!fs.existsSync(finalDir), 'no final dir after git integrity rejection');
|
||||
});
|
||||
|
||||
test('local + --integrity → throws an actionable error (no silent resolve)', async () => {
|
||||
const capDir = makeLocalCap(featureCap('local-int-cap'));
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => resolveCapabilitySource(capDir, {
|
||||
gsdHome,
|
||||
hostVersion: '1.5.0',
|
||||
integrity: 'sha512-' + Buffer.from('anything').toString('base64'),
|
||||
}),
|
||||
/integrity pinning is not supported for local sources/i,
|
||||
);
|
||||
} finally {
|
||||
cleanup(capDir);
|
||||
}
|
||||
|
||||
const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'local-int-cap');
|
||||
assert.ok(!fs.existsSync(finalDir), 'no final dir after local integrity rejection');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security: shell metacharacters in spec / args → arrive as argv array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user