mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
26af277afd
* feat(hermes-attestation-guardian): release v0.0.2 hardening * docs(wiki): add v0.0.2 hardening update note * docs: add Hermes support coverage to README and compatibility report * fix(hermes-attestation-guardian): address baz review on crontab detection and doc dedup * feat(wiki): add PR-200 skill feature/platform matrix * docs(wiki): rewrite PR-200 matrix as narrative capability mapping * docs(readme): add skill feature matrix with requested headers * docs(readme): replace unknowns with mapped yes/no feature matrix * docs: move NanoClaw and CI/CD details from README to wiki modules * docs(readme): remove platform/suite sections and keep wiki module pointers * docs(readme): refresh project structure to match current repo * feat(hermes-attestation-guardian): add signed advisory feed verification pipeline * feat(hermes-attestation-guardian): add advisory-gated guarded skill verification * feat(hermes-attestation-guardian): add advisory scheduler helper and phase-3 parity docs * docs(wiki): expand hermes attestation guardian capability coverage * fix(pr-200): address Baz review findings across Hermes parity rollout * test(sandbox): extend Hermes regression to cover feed, guarded verify, and advisory scheduler * fix(pr-200): address Baz semver parsing and feed-state fallback visibility * fix(ci): suppress shellcheck false positives in sandbox inline docker script * fix(hermes-attestation-guardian): fail closed on unsupported advisory ranges * fix(hermes-attestation-guardian): restore safe install verdict in sandbox * fix(sandbox): capture guarded verify exit under set -e * fix(semver): fail closed on malformed affected specifiers * docs(readme): clarify hermes capability matrix wording * refactor(feed): share signed artifact verification flow * refactor(cron): share managed block helpers across setup scripts * fix(feed): require checksum manifest artifacts when enabled * chore(hermes-skill): relocate sandbox test, refresh docs, and add v0.1.0 release notes * chore(docs): remove remaining hermes parity plan file * chore(release): roll hermes-attestation-guardian to v0.1.0 * chore(release): remove standalone v0.1.0 release notes file * docs(hermes): update README status to v0.1.0 --------- Co-authored-by: David Abutbul <David.a@prompt.security>
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(__dirname, "..");
|
|
const guardedVerifyScript = path.join(skillRoot, "scripts", "guarded_skill_verify.mjs");
|
|
|
|
function runNode(args = [], env = {}) {
|
|
return spawnSync(process.execPath, [guardedVerifyScript, ...args], {
|
|
cwd: skillRoot,
|
|
encoding: "utf8",
|
|
env: { ...process.env, ...env },
|
|
});
|
|
}
|
|
|
|
function signPayload(payloadRaw, privateKeyPem) {
|
|
const key = crypto.createPrivateKey(privateKeyPem);
|
|
const signature = crypto.sign(null, Buffer.from(payloadRaw, "utf8"), key);
|
|
return signature.toString("base64");
|
|
}
|
|
|
|
async function withTempDir(run) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-guarded-"));
|
|
try {
|
|
await run(dir);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function writeFeedArtifacts({ dir, advisories, keyPair, signatureKeyPair = keyPair }) {
|
|
const feedPath = path.join(dir, "feed.json");
|
|
const feedSigPath = `${feedPath}.sig`;
|
|
const checksumsPath = path.join(dir, "checksums.json");
|
|
const checksumsSigPath = `${checksumsPath}.sig`;
|
|
const publicKeyPath = path.join(dir, "feed-public.pem");
|
|
|
|
const feedRaw = JSON.stringify(
|
|
{
|
|
version: "1.0.0",
|
|
updated: "2026-04-20T00:00:00Z",
|
|
advisories,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
|
|
const publicKeyPem = keyPair.publicKey.export({ type: "spki", format: "pem" });
|
|
const signingPrivatePem = signatureKeyPair.privateKey.export({ type: "pkcs8", format: "pem" });
|
|
|
|
await fs.writeFile(feedPath, feedRaw, "utf8");
|
|
const feedSignature = `${signPayload(feedRaw, signingPrivatePem)}\n`;
|
|
await fs.writeFile(feedSigPath, feedSignature, "utf8");
|
|
|
|
const sha256 = (value) => crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
const checksumsRaw = JSON.stringify(
|
|
{
|
|
files: {
|
|
[path.basename(feedPath)]: sha256(feedRaw),
|
|
[path.basename(feedSigPath)]: sha256(feedSignature),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
await fs.writeFile(checksumsPath, `${checksumsRaw}\n`, "utf8");
|
|
await fs.writeFile(checksumsSigPath, `${signPayload(`${checksumsRaw}\n`, signingPrivatePem)}\n`, "utf8");
|
|
|
|
await fs.writeFile(publicKeyPath, publicKeyPem, "utf8");
|
|
|
|
return { feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath };
|
|
}
|
|
|
|
function hermesEnv(base) {
|
|
return {
|
|
HERMES_HOME: path.join(base, ".hermes"),
|
|
HERMES_ADVISORY_FEED_SOURCE: "local",
|
|
};
|
|
}
|
|
|
|
function localFeedEnv({ feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath }) {
|
|
return {
|
|
HERMES_LOCAL_ADVISORY_FEED: feedPath,
|
|
HERMES_LOCAL_ADVISORY_FEED_SIG: feedSigPath,
|
|
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS: checksumsPath,
|
|
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG: checksumsSigPath,
|
|
HERMES_ADVISORY_FEED_PUBLIC_KEY: publicKeyPath,
|
|
};
|
|
}
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-CONSERVATIVE",
|
|
severity: "high",
|
|
affected: ["demo-skill@>=1.2.3"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "demo-skill"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(result.status, 42, `conservative name-only match should gate with 42: ${result.stderr}`);
|
|
assert.ok(result.stdout.includes("No --version provided; applying conservative name-based advisory gate."), result.stdout);
|
|
assert.ok(result.stdout.includes("ADV-CONSERVATIVE"), result.stdout);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-VERSION-MATCH",
|
|
severity: "critical",
|
|
affected: ["versioned-skill@>=2.0.0"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "versioned-skill", "--version", "2.1.0"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(result.status, 42, `explicit version match should gate with 42: ${result.stderr}`);
|
|
assert.ok(result.stdout.includes("ADV-VERSION-MATCH"), result.stdout);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-NONMATCH",
|
|
severity: "medium",
|
|
affected: ["different-skill@>=1.0.0"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(result.status, 0, `non-matching skill should pass: ${result.stderr}`);
|
|
assert.ok(result.stdout.includes("No advisory matches found for candidate."), result.stdout);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-MALFORMED-AFFECTED",
|
|
severity: "high",
|
|
affected: ["missing-at-specifier"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "missing-at-specifier", "--version", "1.0.0"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(result.status, 1, `malformed affected entry without '@' must fail closed: ${result.stderr}`);
|
|
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-CONFIRM",
|
|
severity: "high",
|
|
affected: ["confirm-me@1.0.0"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "confirm-me", "--version", "1.0.0", "--confirm-advisory"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(result.status, 0, `--confirm-advisory should allow proceed: ${result.stderr}`);
|
|
assert.ok(result.stderr.includes("WARNING: proceeding despite 1 advisory match(es)"), result.stderr);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const verifierKeys = crypto.generateKeyPairSync("ed25519");
|
|
const signerKeys = crypto.generateKeyPairSync("ed25519");
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: verifierKeys,
|
|
signatureKeyPair: signerKeys,
|
|
advisories: [
|
|
{
|
|
id: "ADV-BROKEN-SIG",
|
|
severity: "high",
|
|
affected: ["broken-skill@*"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const strictResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(strictResult.status, 1, "invalid signature must fail closed without unsigned bypass");
|
|
assert.ok(strictResult.stderr.includes("CRITICAL: advisory feed verification failed"), strictResult.stderr);
|
|
|
|
const bypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0", "--allow-unsigned"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(bypassResult.status, 0, `unsigned bypass should allow verification path to continue: ${bypassResult.stderr}`);
|
|
assert.ok(bypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via --allow-unsigned"), bypassResult.stderr);
|
|
|
|
const envBypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED: "1",
|
|
});
|
|
|
|
assert.equal(
|
|
envBypassResult.status,
|
|
0,
|
|
`env-configured unsigned bypass should allow verification path to continue: ${envBypassResult.stderr}`,
|
|
);
|
|
assert.ok(
|
|
envBypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via resolved env/config policy"),
|
|
envBypassResult.stderr,
|
|
);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const invalidArgResult = runNode(["--skill", "demo-skill", "--definitely-invalid-arg"], {
|
|
...hermesEnv(tempDir),
|
|
});
|
|
|
|
assert.equal(invalidArgResult.status, 1, "unknown CLI argument must fail");
|
|
assert.ok(invalidArgResult.stderr.includes("Unknown argument: --definitely-invalid-arg"), invalidArgResult.stderr);
|
|
});
|
|
|
|
await withTempDir(async (tempDir) => {
|
|
const keys = crypto.generateKeyPairSync("ed25519");
|
|
const semverCases = [
|
|
{ label: "caret-accept", versionSpec: "^1.2.3", candidateVersion: "1.9.0", expectedStatus: 42 },
|
|
{ label: "caret-reject-major-bump", versionSpec: "^1.2.3", candidateVersion: "2.0.0", expectedStatus: 0 },
|
|
{ label: "caret-zero-minor-accept", versionSpec: "^0.2.3", candidateVersion: "0.2.99", expectedStatus: 42 },
|
|
{ label: "caret-zero-minor-reject", versionSpec: "^0.2.3", candidateVersion: "0.3.0", expectedStatus: 0 },
|
|
{ label: "caret-zero-zero-patch-accept", versionSpec: "^0.0.3", candidateVersion: "0.0.3", expectedStatus: 42 },
|
|
{ label: "caret-zero-zero-patch-reject", versionSpec: "^0.0.3", candidateVersion: "0.0.99", expectedStatus: 0 },
|
|
{ label: "tilde-accept", versionSpec: "~1.2.3", candidateVersion: "1.2.9", expectedStatus: 42 },
|
|
{ label: "tilde-reject-minor-bump", versionSpec: "~1.2.3", candidateVersion: "1.3.0", expectedStatus: 0 },
|
|
{ label: "wildcard-accept", versionSpec: "1.2.*", candidateVersion: "1.2.99", expectedStatus: 42 },
|
|
{ label: "wildcard-reject", versionSpec: "1.2.*", candidateVersion: "1.3.0", expectedStatus: 0 },
|
|
{ label: "malformed-comparator-fail-closed", versionSpec: ">>1.2.3", candidateVersion: "1.9.0", expectedStatus: 1 },
|
|
{ label: "comparator-set-fail-closed", versionSpec: ">=1 <2", candidateVersion: "1.9.0", expectedStatus: 1 },
|
|
{ label: "logical-or-fail-closed", versionSpec: "1.2 || 1.3", candidateVersion: "1.2.5", expectedStatus: 1 },
|
|
];
|
|
|
|
for (const semverCase of semverCases) {
|
|
const artifacts = await writeFeedArtifacts({
|
|
dir: tempDir,
|
|
keyPair: keys,
|
|
advisories: [
|
|
{
|
|
id: `ADV-SEMVER-${semverCase.label.toUpperCase()}`,
|
|
severity: "high",
|
|
affected: [`semver-skill@${semverCase.versionSpec}`],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = runNode(["--skill", "semver-skill", "--version", semverCase.candidateVersion], {
|
|
...hermesEnv(tempDir),
|
|
...localFeedEnv(artifacts),
|
|
});
|
|
|
|
assert.equal(
|
|
result.status,
|
|
semverCase.expectedStatus,
|
|
`${semverCase.label} expected status ${semverCase.expectedStatus}, got ${result.status}. stderr=${result.stderr}`,
|
|
);
|
|
if (semverCase.expectedStatus === 1) {
|
|
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log("guarded_skill_verify.test.mjs: ok");
|