Skip to content

Commit 8e3368e

Browse files
avifeneshagent-core-bot
andauthored
chore: sync core lib and CLAUDE.md from agent-core (#352)
Co-authored-by: agent-core-bot <noreply@agent-sh.github.io>
1 parent 6ebb22e commit 8e3368e

1 file changed

Lines changed: 203 additions & 3 deletions

File tree

lib/binary/index.js

Lines changed: 203 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@
1919
* script validates every zip entry before extracting it and rejects
2020
* absolute, UNC, and parent-traversal entries.
2121
*
22+
* Verification chain (in order, each gate must pass to proceed):
23+
* 1. TLS - https.get() pins the GitHub CA chain at the OS level.
24+
* 2. SHA-256 sidecar - `<asset>.sha256` fetched from the same release and
25+
* verified against the downloaded bytes. Closes basic tampering.
26+
* 3. SLSA build provenance (optional / required) - `gh attestation verify`
27+
* checks the Sigstore-signed attestation that agent-analyzer's release
28+
* workflow publishes via `actions/attest-build-provenance`. This closes
29+
* the "stolen release token uploads attacker binary + attacker sha256"
30+
* hole that steps 1 and 2 cannot see.
31+
*
32+
* SLSA verification is SOFT by default: if `gh` is not on PATH we log
33+
* a warning and proceed with just SHA-256. Set env var
34+
* `AGENT_ANALYZER_REQUIRE_ATTESTATION=1` to make a missing `gh` a hard
35+
* failure (recommended for CI). A present `gh` that reports a failed
36+
* verification is ALWAYS a hard failure regardless of the env var.
37+
*
2238
* @module lib/binary
2339
*/
2440

@@ -572,6 +588,117 @@ function findBinaryInScratch(scratch, binaryBaseName) {
572588
return null;
573589
}
574590

591+
// ---------------------------------------------------------------------------
592+
// SLSA build provenance verification
593+
// ---------------------------------------------------------------------------
594+
595+
/**
596+
* Result of an attempted SLSA attestation verification.
597+
* @typedef {Object} SlsaResult
598+
* @property {'verified'|'skipped'|'failed'} status
599+
* @property {string} [reason] human-readable detail (for skipped/failed)
600+
* @property {string} [stderr] captured stderr from `gh` (failed only)
601+
*/
602+
603+
/**
604+
* Default runner: spawn `gh attestation verify` and return the captured
605+
* exit code, stdout, and stderr. Injectable for tests.
606+
* @param {string} filePath
607+
* @param {string} repo e.g. `agent-sh/agent-analyzer`
608+
* @returns {{ status: number|null, stdout: string, stderr: string }}
609+
*/
610+
function defaultGhRunner(filePath, repo) {
611+
try {
612+
const stdout = cp.execFileSync(
613+
'gh',
614+
['attestation', 'verify', filePath, '--repo', repo, '--format', 'json'],
615+
{
616+
encoding: 'utf8',
617+
stdio: ['ignore', 'pipe', 'pipe'],
618+
timeout: 60000,
619+
windowsHide: true
620+
}
621+
);
622+
return { status: 0, stdout: stdout || '', stderr: '' };
623+
} catch (err) {
624+
return {
625+
status: typeof err.status === 'number' ? err.status : null,
626+
stdout: err.stdout ? String(err.stdout) : '',
627+
stderr: err.stderr ? String(err.stderr) : (err.message || '')
628+
};
629+
}
630+
}
631+
632+
/**
633+
* Returns true if the `gh` CLI is on PATH. Uses a short, non-privileged probe.
634+
* @param {function} [runner] optional probe; defaults to real `gh --version`
635+
* @returns {boolean}
636+
*/
637+
function isGhAvailable(runner) {
638+
if (typeof runner === 'function') {
639+
try { return !!runner(); } catch (e) { return false; }
640+
}
641+
try {
642+
cp.execFileSync('gh', ['--version'], {
643+
stdio: 'ignore',
644+
timeout: 5000,
645+
windowsHide: true
646+
});
647+
return true;
648+
} catch (e) {
649+
return false;
650+
}
651+
}
652+
653+
/**
654+
* Verify a downloaded asset's SLSA build provenance attestation via the
655+
* GitHub CLI. The check is SOFT by default: if `gh` is not installed the
656+
* function returns { status: 'skipped' } and the caller logs a warning. Set
657+
* `requireAttestation` (or the env var) to make a missing `gh` a failure.
658+
*
659+
* A present `gh` that reports verification failure ALWAYS returns
660+
* { status: 'failed' } regardless of `requireAttestation`; the caller is
661+
* expected to abort in that case.
662+
*
663+
* @param {string} filePath absolute path to the downloaded archive
664+
* @param {Object} [options]
665+
* @param {string} [options.repo] e.g. `agent-sh/agent-analyzer`
666+
* @param {boolean} [options.requireAttestation] defaults to env
667+
* `AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'`
668+
* @param {function} [options.ghRunner] injectable runner for tests. Receives
669+
* (filePath, repo), returns { status, stdout, stderr }.
670+
* @param {function} [options.ghProbe] injectable gh-on-PATH probe for tests.
671+
* @returns {SlsaResult}
672+
*/
673+
function verifySlsaAttestation(filePath, options) {
674+
const opts = options || {};
675+
const repo = opts.repo || GITHUB_REPO;
676+
const runner = typeof opts.ghRunner === 'function' ? opts.ghRunner : defaultGhRunner;
677+
const require_ = typeof opts.requireAttestation === 'boolean'
678+
? opts.requireAttestation
679+
: process.env.AGENT_ANALYZER_REQUIRE_ATTESTATION === '1';
680+
681+
const ghPresent = isGhAvailable(opts.ghProbe);
682+
if (!ghPresent) {
683+
const reason = '`gh` CLI not found on PATH';
684+
if (require_) {
685+
return { status: 'failed', reason: reason + ' (AGENT_ANALYZER_REQUIRE_ATTESTATION=1)' };
686+
}
687+
return { status: 'skipped', reason: reason };
688+
}
689+
690+
const result = runner(filePath, repo);
691+
if (result && result.status === 0) {
692+
return { status: 'verified' };
693+
}
694+
return {
695+
status: 'failed',
696+
reason: 'gh attestation verify exited with status ' +
697+
(result && result.status !== null ? result.status : 'unknown'),
698+
stderr: (result && result.stderr) || ''
699+
};
700+
}
701+
575702
// ---------------------------------------------------------------------------
576703
// Download + install
577704
// ---------------------------------------------------------------------------
@@ -582,11 +709,19 @@ function findBinaryInScratch(scratch, binaryBaseName) {
582709
* @param {Object} [options]
583710
* @param {boolean} [options.skipChecksum=false] LOCAL DEV ONLY. Skips the
584711
* `.sha256` sidecar fetch and verification. NEVER set this in production.
712+
* @param {boolean} [options.skipAttestation=false] LOCAL DEV ONLY. Skips the
713+
* SLSA attestation check entirely.
714+
* @param {boolean} [options.requireAttestation] when true, a missing `gh`
715+
* CLI becomes a hard failure. Defaults to
716+
* `process.env.AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'`.
717+
* @param {function} [options.ghRunner] injectable runner for tests.
718+
* @param {function} [options.ghProbe] injectable gh-on-PATH probe for tests.
585719
* @returns {Promise<string>} path to the installed binary
586720
*/
587721
async function downloadBinary(ver, options) {
588722
const opts = options || {};
589723
const skipChecksum = opts.skipChecksum === true;
724+
const skipAttestation = opts.skipAttestation === true;
590725

591726
const platformKey = getPlatformKey();
592727
if (!platformKey) {
@@ -643,6 +778,47 @@ async function downloadBinary(ver, options) {
643778
verifySha256(buf, expected, filename);
644779
}
645780

781+
// --- 2b. Verify SLSA build provenance (optional / required) ------------
782+
if (skipAttestation) {
783+
process.stderr.write(
784+
'[WARN] skipAttestation=true - SLSA verification disabled. ' +
785+
'This is LOCAL DEV ONLY and MUST NOT be used in production.\n'
786+
);
787+
} else {
788+
// `gh attestation verify` needs a real file. Persist buf to a tmp path,
789+
// verify, then drop it. Extraction continues from the in-memory buf so
790+
// we don't need the tmp file beyond the verify call.
791+
const attestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-slsa-'));
792+
const attestFile = path.join(attestDir, filename);
793+
try {
794+
fs.writeFileSync(attestFile, buf);
795+
const result = verifySlsaAttestation(attestFile, {
796+
repo: GITHUB_REPO,
797+
requireAttestation: opts.requireAttestation,
798+
ghRunner: opts.ghRunner,
799+
ghProbe: opts.ghProbe
800+
});
801+
if (result.status === 'verified') {
802+
process.stderr.write('[OK] SLSA attestation verified for ' + filename + '\n');
803+
} else if (result.status === 'skipped') {
804+
process.stderr.write(
805+
'[WARN] SLSA attestation check skipped: ' + result.reason + '. ' +
806+
'Install the GitHub CLI (`gh`) to enable provenance verification. ' +
807+
'Set AGENT_ANALYZER_REQUIRE_ATTESTATION=1 to require it.\n'
808+
);
809+
} else {
810+
// 'failed'
811+
throw new Error(
812+
'SLSA attestation verification failed for ' + filename + ': ' +
813+
result.reason + '. Refusing to execute binary.' +
814+
(result.stderr ? '\n--- gh stderr ---\n' + result.stderr : '')
815+
);
816+
}
817+
} finally {
818+
rmrf(attestDir);
819+
}
820+
}
821+
646822
// --- 3. Extract to isolated scratch dir + validate entries -------------
647823
const binaryBaseName = path.basename(binPath);
648824
let scratch;
@@ -707,7 +883,13 @@ async function ensureBinary(options) {
707883
}
708884
}
709885

710-
return downloadBinary(targetVer, { skipChecksum: opts.skipChecksum === true });
886+
return downloadBinary(targetVer, {
887+
skipChecksum: opts.skipChecksum === true,
888+
skipAttestation: opts.skipAttestation === true,
889+
requireAttestation: opts.requireAttestation,
890+
ghRunner: opts.ghRunner,
891+
ghProbe: opts.ghProbe
892+
});
711893
}
712894

713895
/**
@@ -730,11 +912,27 @@ function ensureBinarySync(options) {
730912

731913
const targetVer = (options && options.version) || ANALYZER_MIN_VERSION;
732914
const skipChecksum = !!(options && options.skipChecksum);
915+
const skipAttestation = !!(options && options.skipAttestation);
916+
// Forward requireAttestation when explicitly set (tri-state: undefined
917+
// lets the child fall back to the AGENT_ANALYZER_REQUIRE_ATTESTATION
918+
// env var, matching ensureBinary()). Without this forwarding, a sync
919+
// caller with requireAttestation:true would silently lose the hard-fail
920+
// intent when gh is missing.
921+
const requireAttestation = options && typeof options.requireAttestation === 'boolean'
922+
? options.requireAttestation
923+
: undefined;
733924
const selfPath = __filename;
925+
const ensureOpts = {
926+
version: targetVer,
927+
skipChecksum: skipChecksum,
928+
skipAttestation: skipAttestation
929+
};
930+
if (requireAttestation !== undefined) {
931+
ensureOpts.requireAttestation = requireAttestation;
932+
}
734933
const helperLines = [
735934
'var b = require(' + JSON.stringify(selfPath) + ');',
736-
'b.ensureBinary({ version: ' + JSON.stringify(targetVer) +
737-
', skipChecksum: ' + JSON.stringify(skipChecksum) + ' })',
935+
'b.ensureBinary(' + JSON.stringify(ensureOpts) + ')',
738936
' .then(function(p) { process.stdout.write(p); })',
739937
' .catch(function(e) { process.stderr.write(e.message); process.exit(1); });'
740938
];
@@ -798,6 +996,8 @@ module.exports = {
798996
assertSafeArchiveEntry,
799997
assertInsideRoot,
800998
downloadBinary,
999+
verifySlsaAttestation,
1000+
isGhAvailable,
8011001
// Exported for tests only
8021002
extractTarGzToScratch,
8031003
extractZipToScratch,

0 commit comments

Comments
 (0)