diff --git a/COMMANDS.md b/COMMANDS.md index 9432daf..0b9d7cf 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -57,9 +57,11 @@ fable-agent plinius godmode "improve explanation quality" - `verify `: verify a target and emit an agentic attestation - `--out ` - `--run ` + - `--scan-all` - `attest `: alias for `verify` - `--out ` - `--run ` + - `--scan-all` ### `run` @@ -212,9 +214,11 @@ fable-agent plinius godmode "improve explanation quality" - `factory verify `: verify a target and emit an agentic attestation - `--out ` - `--run ` + - `--scan-all` - `factory attest `: alias for `factory verify`; emits an agentic attestation - `--out ` - `--run ` + - `--scan-all` - `factory deploy ` - `--token ` - `--gate-receipt ` diff --git a/src/fable5/attestation.test.ts b/src/fable5/attestation.test.ts index 06d1897..1358e28 100644 --- a/src/fable5/attestation.test.ts +++ b/src/fable5/attestation.test.ts @@ -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); diff --git a/src/fable5/attestation.ts b/src/fable5/attestation.ts index 93ed9e8..501641c 100644 --- a/src/fable5/attestation.ts +++ b/src/fable5/attestation.ts @@ -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[] { diff --git a/src/index.ts b/src/index.ts index 879856b..1b30a4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,11 +28,12 @@ program .description("Verify a target and emit an agentic attestation") .option("--out ", "Attestation output path") .option("--run ", "Emit attestation event to .runs//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 ", "Attestation output path") .option("--run ", "Emit attestation event to .runs//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 ", "Attestation output path") .option("--run ", "Emit attestation event to .runs//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 ", "Attestation output path") .option("--run ", "Emit attestation event to .runs//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 } });