feat: add scan-all attestation
This commit is contained in:
parent
ebf1c7eddd
commit
77836ac7fe
|
|
@ -57,9 +57,11 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
- `verify <target>`: verify a target and emit an agentic attestation
|
||||
- `--out <path>`
|
||||
- `--run <id>`
|
||||
- `--scan-all`
|
||||
- `attest <target>`: alias for `verify`
|
||||
- `--out <path>`
|
||||
- `--run <id>`
|
||||
- `--scan-all`
|
||||
|
||||
### `run`
|
||||
|
||||
|
|
@ -212,9 +214,11 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
- `factory verify <target>`: verify a target and emit an agentic attestation
|
||||
- `--out <path>`
|
||||
- `--run <id>`
|
||||
- `--scan-all`
|
||||
- `factory attest <target>`: alias for `factory verify`; emits an agentic attestation
|
||||
- `--out <path>`
|
||||
- `--run <id>`
|
||||
- `--scan-all`
|
||||
- `factory deploy <command>`
|
||||
- `--token <token>`
|
||||
- `--gate-receipt <path>`
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { verifyTarget } from "./attestation.js";
|
|||
function okRunner(changed: string) {
|
||||
return (command: string, args: string[]) => {
|
||||
const full = [command, ...args].join(" ");
|
||||
if (full === "git status --short") return { status: 0, stdout: ` M ${changed}\n`, stderr: "" };
|
||||
if (full === "git status --short") return { status: 0, stdout: changed ? ` M ${changed}\n` : "", stderr: "" };
|
||||
if (full === "npm run -s build") return { status: 0, stdout: "", stderr: "" };
|
||||
if (full === "npm run -s test") return { status: 0, stdout: "", stderr: "" };
|
||||
if (full === "npm run -s docs:check") return { status: 0, stdout: "", stderr: "" };
|
||||
|
|
@ -15,16 +15,20 @@ function okRunner(changed: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function packageJson(dir: string) {
|
||||
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts: { build: "tsc", test: "vitest", "docs:check": "node x" } }));
|
||||
}
|
||||
|
||||
describe("agentic attestation", () => {
|
||||
it("emits a typed verified receipt for changed files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "attest-"));
|
||||
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts: { build: "tsc", test: "vitest", "docs:check": "node x" } }));
|
||||
packageJson(dir);
|
||||
const changed = path.join(dir, "src", "fable5", "safe.ts");
|
||||
fs.mkdirSync(path.dirname(changed), { recursive: true });
|
||||
fs.writeFileSync(changed, "export const add = (a: number, b: number) => a + b;\n");
|
||||
const out = path.join(dir, "receipt.json");
|
||||
|
||||
const receipt = verifyTarget({ target: dir, out, runner: okRunner(changed), now: new Date("2026-06-21T00:00:00.000Z") });
|
||||
const receipt = verifyTarget({ target: dir, out, runner: okRunner(changed), now: new Date("2026-06-21T00:00:00.000Z"), memoryCachePath: path.join(dir, ".cache.json") });
|
||||
|
||||
expect(receipt.schema).toBe("fable.verification.attestation.v1");
|
||||
expect(receipt.deploy_route).toBe("8099/deploy via git-proxy");
|
||||
|
|
@ -34,12 +38,26 @@ describe("agentic attestation", () => {
|
|||
expect(JSON.parse(fs.readFileSync(out, "utf-8")).schema).toBe("fable.verification.attestation.v1");
|
||||
});
|
||||
|
||||
it("scanAll verifies a clean repo target by scanning all files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "attest-all-"));
|
||||
packageJson(dir);
|
||||
fs.writeFileSync(path.join(dir, "safe.ts"), "export const ok = true;\n");
|
||||
fs.mkdirSync(path.join(dir, "TEMP"));
|
||||
fs.writeFileSync(path.join(dir, "TEMP", "glyph-risk.txt"), "\uE000\n");
|
||||
|
||||
const receipt = verifyTarget({ target: dir, scanAll: true, runner: okRunner(""), now: new Date("2026-06-21T00:00:00.000Z"), memoryCachePath: path.join(dir, ".cache.json") });
|
||||
|
||||
expect(receipt.verification_status).toBe("verified");
|
||||
expect(receipt.checks.find((c) => c.name === "target_files")?.detail).toContain("scannable total file(s)");
|
||||
expect(receipt.memory_b_cells.length).toBe(1);
|
||||
});
|
||||
|
||||
it("requires review for risky capabilities without treating review as verification failure", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "attest-risk-"));
|
||||
const file = path.join(dir, "index.ts");
|
||||
fs.writeFileSync(file, "import { execSync } from 'node:child_process'; execSync('echo hi');\n");
|
||||
|
||||
const receipt = verifyTarget({ target: file, runner: okRunner(file), now: new Date("2026-06-21T00:00:00.000Z") });
|
||||
const receipt = verifyTarget({ target: file, runner: okRunner(file), now: new Date("2026-06-21T00:00:00.000Z"), memoryCachePath: path.join(dir, ".cache.json") });
|
||||
|
||||
expect(receipt.verification_status).toBe("partial");
|
||||
expect(receipt.risk_findings.some((f) => f.kind === "shell_exec")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface VerifyTargetOptions {
|
|||
now?: Date;
|
||||
requestedBy?: "human" | "agent" | "ci";
|
||||
memoryCachePath?: string;
|
||||
scanAll?: boolean;
|
||||
runner?: (command: string, args: string[], cwd?: string) => { status: number | null; stdout: string; stderr: string };
|
||||
}
|
||||
|
||||
|
|
@ -59,8 +60,12 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
|
|||
const changed = runGitStatus(runner);
|
||||
checks.push(commandCheck("git_status", "git status --short", 0, changed.status, changed.stderr || `${changed.files.length} changed file(s)`));
|
||||
|
||||
const files = collectFiles(target, changed.files);
|
||||
checks.push({ name: "target_files", status: files.length > 0 ? "passed" : "skipped", detail: `${files.length} scannable changed file(s)` });
|
||||
const files = collectFiles(target, changed.files, opts.scanAll ?? false);
|
||||
checks.push({
|
||||
name: "target_files",
|
||||
status: files.length > 0 ? "passed" : "skipped",
|
||||
detail: `${files.length} scannable ${opts.scanAll ? "total" : "changed"} file(s)`,
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
|
|
@ -74,8 +79,16 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
|
|||
}
|
||||
}
|
||||
checks.push({ name: "glyph_legibility", status: risk_findings.some((f) => f.kind === "human_legibility") ? "failed" : "passed" });
|
||||
checks.push({ name: "memory_b_cell_cache", status: "passed", detail: `${memory_b_cells.filter((c) => c.encounter === "cache_hit").length} hit(s), ${memory_b_cells.filter((c) => c.encounter === "first_encounter").length} first encounter(s)` });
|
||||
checks.push({ name: "ast_capabilities", status: "passed", detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like", "memory_b_cell"].includes(f.kind)).length} capability finding(s)` });
|
||||
checks.push({
|
||||
name: "memory_b_cell_cache",
|
||||
status: "passed",
|
||||
detail: `${memory_b_cells.filter((c) => c.encounter === "cache_hit").length} hit(s), ${memory_b_cells.filter((c) => c.encounter === "first_encounter").length} first encounter(s)`,
|
||||
});
|
||||
checks.push({
|
||||
name: "ast_capabilities",
|
||||
status: "passed",
|
||||
detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like", "memory_b_cell"].includes(f.kind)).length} capability finding(s); review required but not a local verification failure`,
|
||||
});
|
||||
|
||||
const pkg = findPackageJson(target);
|
||||
if (pkg) {
|
||||
|
|
@ -116,18 +129,35 @@ export function writeAttestation(file: string, attestation: VerificationAttestat
|
|||
fs.writeFileSync(file, `${JSON.stringify(attestation, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function collectFiles(target: string, changed: string[]): string[] {
|
||||
function collectFiles(target: string, changed: string[], scanAll: boolean): string[] {
|
||||
const accept = (file: string) => /\.(ts|tsx|js|jsx|md|txt|json|yaml|yml)$/i.test(file);
|
||||
if (fs.statSync(target).isFile()) return accept(target) ? [target] : [];
|
||||
const root = path.resolve(target);
|
||||
if (scanAll) return walkFiles(root, accept);
|
||||
const changedFiles = changed
|
||||
.map((f) => f.trim().replace(/^[A-Z? ]+\s+/, ""))
|
||||
.map((f) => path.resolve(f))
|
||||
.filter((f) => f.startsWith(root) && fs.existsSync(f) && fs.statSync(f).isFile() && accept(f));
|
||||
// ponytail: repo verify scans changed files; use a file target for deep single-file audit.
|
||||
return [...new Set(changedFiles)].sort();
|
||||
}
|
||||
|
||||
function walkFiles(root: string, accept: (file: string) => boolean): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (file: string) => {
|
||||
const st = fs.statSync(file);
|
||||
if (st.isDirectory()) {
|
||||
for (const name of fs.readdirSync(file)) {
|
||||
if ([".git", "node_modules", "dist", ".fable", "LOGS", "TEMP"].includes(name)) continue;
|
||||
walk(path.join(file, name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (accept(file)) out.push(file);
|
||||
};
|
||||
walk(root);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function addAstFindings(report: AstSafetyReport, findings: RiskFinding[]): void {
|
||||
if (report.shell_exec) findings.push({ kind: "shell_exec", severity: "high", file: report.file, detail: "shell execution capability present" });
|
||||
if (report.network) findings.push({ kind: "network", severity: "medium", file: report.file, detail: "network capability present" });
|
||||
|
|
@ -138,7 +168,14 @@ function addAstFindings(report: AstSafetyReport, findings: RiskFinding[]): void
|
|||
|
||||
function addMemoryFindings(cell: MemoryBCellResult, findings: RiskFinding[]): void {
|
||||
const kinds = ["shell_exec", "network", "fs_write", "eval_like", "private_glyphs"].filter((k) => cell.reason.includes(k));
|
||||
for (const kind of kinds.length > 0 ? kinds : ["memory_b_cell"]) findings.push({ kind, severity: kind === "network" || kind === "fs_write" ? "medium" : "high", file: cell.file, detail: `memory B cell cache hit: ${cell.reason}` });
|
||||
for (const kind of kinds.length > 0 ? kinds : ["memory_b_cell"]) {
|
||||
findings.push({
|
||||
kind,
|
||||
severity: kind === "network" || kind === "fs_write" ? "medium" : "high",
|
||||
file: cell.file,
|
||||
detail: `memory B cell cache marks this pattern unsafe: ${cell.reason}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reviewReasonsFor(findings: RiskFinding[], changed: string[]): string[] {
|
||||
|
|
|
|||
20
src/index.ts
20
src/index.ts
|
|
@ -28,11 +28,12 @@ program
|
|||
.description("Verify a target and emit an agentic attestation")
|
||||
.option("--out <path>", "Attestation output path")
|
||||
.option("--run <id>", "Emit attestation event to .runs/<id>/channel.jsonl")
|
||||
.action(async (target: string, opts: { out?: string; run?: string }) => {
|
||||
.option("--scan-all", "Scan every scannable file under the target, not just changed files")
|
||||
.action(async (target: string, opts: { out?: string; run?: string; scanAll?: boolean }) => {
|
||||
const { verifyTarget } = await import("./fable5/attestation.js");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const out = opts.out ?? path.join(".fable", "attestations", stamp + ".json");
|
||||
const receipt = verifyTarget({ target, out });
|
||||
const receipt = verifyTarget({ target, out, scanAll: opts.scanAll });
|
||||
console.log(JSON.stringify(receipt, null, 2));
|
||||
console.log("\n Attestation: " + out + "\n");
|
||||
await emitRunEvent(opts.run, { source: "gate", type: receipt.verification_status === "failed" ? "error" : "receipt", data: { kind: "agentic-attestation", receipt, path: out } });
|
||||
|
|
@ -44,11 +45,12 @@ program
|
|||
.description("Alias for verify: emit an agentic attestation")
|
||||
.option("--out <path>", "Attestation output path")
|
||||
.option("--run <id>", "Emit attestation event to .runs/<id>/channel.jsonl")
|
||||
.action(async (target: string, opts: { out?: string; run?: string }) => {
|
||||
.option("--scan-all", "Scan every scannable file under the target, not just changed files")
|
||||
.action(async (target: string, opts: { out?: string; run?: string; scanAll?: boolean }) => {
|
||||
const { verifyTarget } = await import("./fable5/attestation.js");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const out = opts.out ?? path.join(".fable", "attestations", stamp + ".json");
|
||||
const receipt = verifyTarget({ target, out });
|
||||
const receipt = verifyTarget({ target, out, scanAll: opts.scanAll });
|
||||
console.log(JSON.stringify(receipt, null, 2));
|
||||
console.log("\n Attestation: " + out + "\n");
|
||||
await emitRunEvent(opts.run, { source: "gate", type: receipt.verification_status === "failed" ? "error" : "receipt", data: { kind: "agentic-attestation", receipt, path: out } });
|
||||
|
|
@ -1584,11 +1586,12 @@ factory
|
|||
.description("Verify a target and emit an agentic attestation")
|
||||
.option("--out <path>", "Attestation output path")
|
||||
.option("--run <id>", "Emit attestation event to .runs/<id>/channel.jsonl")
|
||||
.action(async (target: string, opts: { out?: string; run?: string }) => {
|
||||
.option("--scan-all", "Scan every scannable file under the target, not just changed files")
|
||||
.action(async (target: string, opts: { out?: string; run?: string; scanAll?: boolean }) => {
|
||||
const { verifyTarget } = await import("./fable5/attestation.js");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const out = opts.out ?? path.join(".fable", "attestations", stamp + ".json");
|
||||
const receipt = verifyTarget({ target, out });
|
||||
const receipt = verifyTarget({ target, out, scanAll: opts.scanAll });
|
||||
console.log(JSON.stringify(receipt, null, 2));
|
||||
console.log("\n Attestation: " + out + "\n");
|
||||
await emitRunEvent(opts.run, { source: "gate", type: receipt.verification_status === "failed" ? "error" : "receipt", data: { kind: "agentic-attestation", receipt, path: out } });
|
||||
|
|
@ -1600,11 +1603,12 @@ factory
|
|||
.description("Alias for factory verify: emit an agentic attestation")
|
||||
.option("--out <path>", "Attestation output path")
|
||||
.option("--run <id>", "Emit attestation event to .runs/<id>/channel.jsonl")
|
||||
.action(async (target: string, opts: { out?: string; run?: string }) => {
|
||||
.option("--scan-all", "Scan every scannable file under the target, not just changed files")
|
||||
.action(async (target: string, opts: { out?: string; run?: string; scanAll?: boolean }) => {
|
||||
const { verifyTarget } = await import("./fable5/attestation.js");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const out = opts.out ?? path.join(".fable", "attestations", stamp + ".json");
|
||||
const receipt = verifyTarget({ target, out });
|
||||
const receipt = verifyTarget({ target, out, scanAll: opts.scanAll });
|
||||
console.log(JSON.stringify(receipt, null, 2));
|
||||
console.log("\n Attestation: " + out + "\n");
|
||||
await emitRunEvent(opts.run, { source: "gate", type: receipt.verification_status === "failed" ? "error" : "receipt", data: { kind: "agentic-attestation", receipt, path: out } });
|
||||
|
|
|
|||
Loading…
Reference in New Issue