loopgen 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,24 +4,28 @@
4
4
  [![CI](https://github.com/Nagiici/Loopgen/actions/workflows/ci.yml/badge.svg)](https://github.com/Nagiici/Loopgen/actions/workflows/ci.yml)
5
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
- **Run your AI's coding loop and prove its work actually passed.**
7
+ **The vendor-neutral verification & provenance gate for AI-generated code bring your own agent.**
8
8
 
9
- Any model can *write* an `AGENTS.md`. loopgen does the part a stateless model can't: it **runs** a bounded
10
- loop, executes your real `test` / `lint` / `build` and **gates success on the exit codes**, **enforces**
11
- forbidden-path and iteration limits, and writes a **tamper-evident, hash-chained audit log + proof report**.
12
- Works with Claude Code, Codex, Cursor, and local models (Ollama, LM Studio, llama.cpp).
9
+ Whatever agent writes the code Claude Code, Codex, Cursor, or a local model loopgen does the part a
10
+ stateless model can't: it **runs** your real `test` / `lint` / `build`, **gates on the exit codes**,
11
+ **enforces** forbidden-path and iteration limits, and records the run in a hash-chained ledger. Locally
12
+ that's **tamper-evident evidence**; in CI it signs the entry against the **Sigstore/Rekor** public
13
+ transparency log — a **verifiable, signed attestation** you can gate merges on.
13
14
 
14
15
  ```bash
15
16
  npx loopgen init # scan your repo + pick bounded-loop templates (local wizard, nothing written)
16
- npx loopgen run test-repair # run the loop, verify it, and leave proof it passed (exits 0/1 for CI)
17
+ npx loopgen run test-repair # run + verify the loop; leave tamper-evident evidence (signed in CI) exits 0/1
17
18
  ```
18
19
 
19
- - ✅ **Prove the work** — `loopgen run` executes a loop, gates on your *real* verification commands, and
20
- writes an auditable record a chatbot cannot. *Referee* mode verifies any agent's change; *driven* mode
21
- drives a **local model** itself and **blocks forbidden writes before they land**.
20
+ - ✅ **Verify the work** — `loopgen run` executes a loop, gates on your *real* verification commands, checks
21
+ forbidden paths, and writes a hash-chained audit a chatbot cannot. *Referee* mode verifies any agent's
22
+ change; *driven* mode drives a **local model** itself and **blocks forbidden writes before they land**.
23
+ - 🔏 **Attest the work** — run it in CI and loopgen signs the audit entry against the **Sigstore/Rekor**
24
+ public transparency log (keyless — no keys to manage): a **verifiable, signed attestation** bound to the
25
+ commit, not just a local self-attested log. [Evidence vs. proof →](docs/THREAT-MODEL.md)
22
26
  - 🧾 **Govern the agents** — `loopgen audit` rolls up every dev's hash-chained ledger into a report + a
23
27
  self-contained HTML dashboard, and a CI **merge gate** (`audit check`, also a GitHub Action) blocks PRs
24
- that lack passing, untampered proof.
28
+ that lack passing, untampered — and optionally **attested** — proof.
25
29
  - 🏠 **Local-first & open source (MIT)** — no telemetry, no cloud; drives only your local models; API keys
26
30
  are referenced by env-var name only.
27
31
 
@@ -35,20 +39,23 @@ npx loopgen run test-repair # run the loop, verify it, and leave proof it passe
35
39
 
36
40
  ### 项目简介
37
41
 
38
- **跑你的 AI 编码循环 —— 并证明它的工作真的通过了。** 任何模型都能*写*一个 `AGENTS.md`;loopgen 做的是
39
- 模型本身做不到的那部分:**执行**一个有界循环,跑你真实的 `test` / `lint` / `build` 并**按退出码判定通过/
40
- 失败**,**强制**禁止路径与迭代上限,并写下一条**带哈希链、防篡改的审计 + 证明报告**。支持 Claude Code、
41
- Codex、Cursor 和本地模型(Ollama、LM Studio、llama.cpp)
42
+ **面向 AI 生成代码的厂商中立「验证 + 溯源」闸门 —— 自带 agent。** 无论代码由谁写(Claude Code、Codex、
43
+ Cursor 或本地模型),loopgen 做的是无状态模型做不到的那部分:**执行**你真实的 `test` / `lint` / `build`、
44
+ **按退出码判定**、**强制**禁止路径与迭代上限,并把这次运行记进一条哈希链账本。本地运行得到的是**防篡改证据
45
+ (tamper-evident evidence)**;在 CI 里它会把这条记录对 **Sigstore/Rekor** 公开透明日志签名 —— 一份可被
46
+ 第三方校验的**签名证明(attestation)**,可直接用于合并闸门。
42
47
 
43
48
  ```bash
44
49
  npx loopgen init # 扫描仓库 + 选择有界循环模板(本地向导,不写文件)
45
50
  npx loopgen run test-repair # 跑这个循环、验证它,并留下「通过」的证据(退出码 0/1,可接 CI)
46
51
  ```
47
52
 
48
- - ✅ **证明工作** —— `loopgen run` 执行循环、按你的真实验证命令判定、写下 chatbot 做不到的可审计记录。
49
- *referee* 模式验证任意 agent 的改动;*driven* 模式自己驱动**本地模型**,并在**落盘前拦截禁止路径写入**。
53
+ - ✅ **验证工作** —— `loopgen run` 执行循环、按你的真实验证命令判定、检查禁止路径,写下 chatbot 做不到的
54
+ 哈希链审计。*referee* 模式验证任意 agent 的改动;*driven* 模式自己驱动**本地模型**,并在**落盘前拦截禁止路径写入**。
55
+ - 🔏 **签名证明** —— 在 CI 里运行,loopgen 会把审计条目对 **Sigstore/Rekor** 公开透明日志签名(keyless,
56
+ 无需自管密钥):一份绑定 commit、**可被第三方校验的签名证明**,而非仅本地自证日志。[证据 vs 证明 →](docs/THREAT-MODEL.md)
50
57
  - 🧾 **治理 agent** —— `loopgen audit` 把每个开发者的哈希链账本聚合成报告 + 自包含 HTML 看板,CI **合并闸门**
51
- (`audit check`,也有现成 GitHub Action)挡住「缺少通过/未被篡改证据」的 PR。
58
+ (`audit check`,也有现成 GitHub Action)挡住「缺少通过/未被篡改、以及可选未签名证明」的 PR。
52
59
  - 🏠 **local-first & 开源(MIT)** —— 无遥测、无云调用;只驱动你的本地模型;API key 仅按环境变量名引用。
53
60
 
54
61
  > 仍可先用内置 demo 预览:`loopgen init` 不需要真实项目、也不会写入文件。生成的可审查文件包括
@@ -433,11 +440,11 @@ npm run loopgen -- run [loop] [project]
433
440
 
434
441
  `apply` always shows a diff first. Without `--yes`, it asks for confirmation before writing files.
435
442
 
436
- ### Run & prove the work (`loopgen run`)
443
+ ### Verify & attest the work (`loopgen run`)
437
444
 
438
- Generating config is just instructions. **`loopgen run` actually runs the verification and leaves proof** —
439
- something a stateless model cannot do. After you (or any agent — Claude Code, Cursor, Codex) complete a
440
- bounded change, run:
445
+ Generating config is just instructions. **`loopgen run` actually runs the verification and leaves a
446
+ tamper-evident record** — something a stateless model cannot do. After you (or any agent — Claude Code,
447
+ Cursor, Codex) complete a bounded change, run:
441
448
 
442
449
  ```bash
443
450
  npm run loopgen -- run test-repair .
@@ -450,8 +457,13 @@ in `.loopgen/reports/*.md`. The process exits `0` on pass and `1` on fail, so it
450
457
 
451
458
  - `--dry-run` — run the checks, write nothing.
452
459
  - `--base <ref>` — git ref to diff against (default `HEAD`).
453
- - Scope: referee mode is **detection, not a sandbox** — it proves the change passed your real verification
454
- and didn't modify forbidden paths; it does not block reads or out-of-tree writes.
460
+ - Scope: referee mode is **detection, not a sandbox** — it records that the change passed your real
461
+ verification and didn't modify forbidden paths; it does not block reads or out-of-tree writes.
462
+ - Trust: locally this is **tamper-evident evidence**. Run it in CI and loopgen signs the audit entry against
463
+ the **Sigstore/Rekor** public transparency log — a **verifiable, signed attestation** bound to the commit.
464
+ Attestation is automatic in CI with an OIDC identity (`--no-attest` to opt out); verify it with `loopgen
465
+ audit verify --attestation`. See [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md) for what each tier does and
466
+ doesn't prove.
455
467
 
456
468
  #### Driven mode — loopgen runs the loop (`--mode driven`)
457
469
 
@@ -482,10 +494,12 @@ Every `loopgen run` appends to a per-repo, hash-chained `.loopgen/audit.jsonl`.
482
494
  those ledgers into team-level, compliance-ready evidence and a CI gate — local-first, no server:
483
495
 
484
496
  ```bash
485
- npm run loopgen -- audit verify # prove the hash chain is intact (tamper check)
497
+ npm run loopgen -- audit verify # verify the hash chain is intact (tamper check)
498
+ npm run loopgen -- audit verify --attestation # also cryptographically verify the CI signatures
486
499
  npm run loopgen -- audit summary # one repo: pass rate, by loop, violations
487
500
  npm run loopgen -- audit aggregate ../repos --html gov.html --report gov.md # roll up many repos/devs
488
501
  npm run loopgen -- audit check --require test-repair --require-no-violations --require-chain # CI gate
502
+ npm run loopgen -- audit check --require-attested # gate on a verifiable CI attestation, not just a local log
489
503
  ```
490
504
 
491
505
  `aggregate` scans the given files/directories for `audit.jsonl`, merges them into one rollup, and can write
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { startLoopgenServer } from "./server.js";
14
14
  import { DEFAULT_ADAPTER_IDS, parseAdapterIds } from "./core/adapters.js";
15
15
  import { runLoop } from "./core/runner.js";
16
16
  import { readAuditLog, verifyAuditChain } from "./core/audit.js";
17
+ import { loadAttestationRef, verifyAttestation } from "./core/attest.js";
17
18
  import { buildSummary, collectAuditFiles, evaluatePolicy } from "./core/governance.js";
18
19
  import { renderGovernanceHtml, renderGovernanceMarkdown } from "./core/governance-report.js";
19
20
  import { promises as fsp } from "node:fs";
@@ -36,7 +37,7 @@ const { version } = require("../package.json");
36
37
  const program = new Command();
37
38
  program
38
39
  .name("loopgen")
39
- .description("Run your AI's coding loop and prove its work actually passed — real verification, a tamper-evident audit, and a CI gate.")
40
+ .description("Verification & provenance gate for AI-generated code — real verification, tamper-evident local evidence, and a verifiable signed attestation in CI. Bring your own agent.")
40
41
  .version(version);
41
42
  program
42
43
  .command("init")
@@ -168,7 +169,8 @@ program
168
169
  .option("--openai-compatible-model <model>", "driven mode: OpenAI-compatible model name")
169
170
  .option("--openai-compatible-base-url <url>", "driven mode: OpenAI-compatible base URL")
170
171
  .option("--openai-compatible-api-key-env <name>", "driven mode: env var name for the API key")
171
- .description("Run a loop's verification against the working tree and write a tamper-evident proof.")
172
+ .option("--no-attest", "skip signing the audit entry even in CI (attestation is automatic when an OIDC identity is present)")
173
+ .description("Run a loop's verification against the working tree; leaves tamper-evident evidence locally, or a verifiable signed attestation in CI.")
172
174
  .action(async (loop, project, options) => {
173
175
  const result = await runLoop({
174
176
  projectRoot: path.resolve(project),
@@ -185,7 +187,8 @@ program
185
187
  ollamaBaseUrl: options.ollamaBaseUrl,
186
188
  openaiCompatibleModel: options.openaiCompatibleModel,
187
189
  openaiCompatibleBaseUrl: options.openaiCompatibleBaseUrl,
188
- openaiCompatibleApiKeyEnv: options.openaiCompatibleApiKeyEnv
190
+ openaiCompatibleApiKeyEnv: options.openaiCompatibleApiKeyEnv,
191
+ attest: options.attest
189
192
  });
190
193
  if (options.json) {
191
194
  console.log(JSON.stringify(result, null, 2));
@@ -199,9 +202,11 @@ const audit = program.command("audit").description("Inspect, aggregate, and gate
199
202
  audit
200
203
  .command("verify")
201
204
  .argument("[project]", "project directory", ".")
202
- .description("Verify the audit hash chain is intact (tamper check).")
203
- .action(async (project) => {
204
- const entries = await readAuditLog(path.resolve(project));
205
+ .option("--attestation", "also cryptographically verify external attestations (cosign/Sigstore), not just the local chain")
206
+ .description("Verify the audit hash chain is intact (tamper check); with --attestation, verify the signed proofs too.")
207
+ .action(async (project, options) => {
208
+ const root = path.resolve(project);
209
+ const entries = await readAuditLog(root);
205
210
  const chain = verifyAuditChain(entries);
206
211
  if (chain.valid) {
207
212
  console.log(`Audit chain valid (${entries.length} entries).`);
@@ -210,6 +215,28 @@ audit
210
215
  console.log(`Audit chain BROKEN at entry ${chain.brokenAt}.`);
211
216
  process.exitCode = 1;
212
217
  }
218
+ if (options.attestation) {
219
+ const attested = entries.filter((entry) => entry.provenance?.tier === "attested");
220
+ if (!attested.length) {
221
+ console.log("Attestations: no CI-attested entries to verify.");
222
+ return;
223
+ }
224
+ let failures = 0;
225
+ for (const entry of attested) {
226
+ const ref = await loadAttestationRef(root, entry.entryId);
227
+ const result = await verifyAttestation({ projectRoot: root, entryHash: entry.hash, ci: entry.provenance?.ci, ref });
228
+ const id = entry.entryId.slice(0, 8);
229
+ if (result.ok)
230
+ console.log(` attestation ${id}… ok${result.reason ? ` (${result.reason})` : ""}`);
231
+ else {
232
+ console.log(` attestation ${id}… FAILED — ${result.reason ?? "unknown"}`);
233
+ failures += 1;
234
+ }
235
+ }
236
+ console.log(`Attestations: ${attested.length - failures}/${attested.length} verified.`);
237
+ if (failures)
238
+ process.exitCode = 1;
239
+ }
213
240
  });
214
241
  audit
215
242
  .command("summary")
@@ -259,6 +286,7 @@ audit
259
286
  .option("--since <iso>", "only consider runs at/after this ISO timestamp")
260
287
  .option("--require-no-violations", "fail if any run modified forbidden paths")
261
288
  .option("--require-chain", "fail if the audit chain is broken")
289
+ .option("--require-attested", "fail unless every run carries a CI attestation claim (verify the signatures with `audit verify --attestation`)")
262
290
  .description("Gate CI/merge on the audit log; exits 1 if the policy is not satisfied.")
263
291
  .action(async (project, options) => {
264
292
  const entries = await readAuditLog(path.resolve(project));
@@ -266,7 +294,8 @@ audit
266
294
  requireLoops: options.require ? options.require.split(",").map((item) => item.trim()).filter(Boolean) : undefined,
267
295
  since: options.since,
268
296
  requireNoViolations: options.requireNoViolations,
269
- requireChainValid: options.requireChain
297
+ requireChainValid: options.requireChain,
298
+ requireAttested: options.requireAttested
270
299
  };
271
300
  const result = evaluatePolicy(entries, policy);
272
301
  if (result.ok) {
@@ -352,10 +381,19 @@ function printRunResult(result) {
352
381
  console.log(` report: ${result.reportPath}`);
353
382
  if (!result.dryRun)
354
383
  console.log(` audit: .loopgen/audit.jsonl (${result.entry.hash.slice(0, 12)}…)`);
384
+ const tier = result.entry.provenance?.tier;
385
+ if (tier === "attested") {
386
+ const signed = result.attestation && result.attestation.method !== "none";
387
+ console.log(` trust: ${signed ? `CI-attested (signed via ${result.attestation.method}) — verify with \`loopgen audit verify --attestation\`` : "attestation requested but no signer available — local evidence only"}`);
388
+ }
389
+ else if (!result.dryRun) {
390
+ console.log(" trust: local evidence (run in CI for a verifiable signed attestation)");
391
+ }
355
392
  }
356
393
  function printSummary(summary) {
357
394
  console.log(`Runs: ${summary.total} (${summary.passed} pass / ${summary.failed} fail) — ${Math.round(summary.passRate * 100)}%`);
358
395
  console.log(`Modes: referee ${summary.byMode.referee}, driven ${summary.byMode.driven}`);
396
+ console.log(`Trust: local ${summary.byTier.local}, CI-attested ${summary.byTier.attested}`);
359
397
  console.log(`Chain: ${summary.chain.valid ? "valid" : `BROKEN at ${summary.chain.brokenAt}`}`);
360
398
  console.log(`Forbidden violations: ${summary.forbiddenViolationRuns} run(s); blocked attempts (prevented): ${summary.blockedAttempts}`);
361
399
  for (const [loop, stat] of Object.entries(summary.byLoop).sort((a, b) => b[1].total - a[1].total)) {
@@ -0,0 +1,184 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { canonicalize } from "./audit.js";
7
+ const execFileAsync = promisify(execFile);
8
+ export const ATTESTATION_DIR = path.join(".loopgen", "attestations");
9
+ export const LOOPGEN_PREDICATE_TYPE = "https://github.com/Nagiici/Loopgen/run-attestation/v1";
10
+ const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
11
+ function sha256Hex(value) {
12
+ return createHash("sha256").update(value).digest("hex");
13
+ }
14
+ function errMsg(error) {
15
+ if (error && typeof error === "object") {
16
+ const stderr = error.stderr;
17
+ if (typeof stderr === "string" && stderr.trim())
18
+ return stderr.trim().split("\n").slice(-1)[0];
19
+ if (error instanceof Error)
20
+ return error.message;
21
+ }
22
+ return String(error);
23
+ }
24
+ function isMissingBinary(error) {
25
+ return Boolean(error && typeof error === "object" && error.code === "ENOENT");
26
+ }
27
+ export function detectCiProvenance(env = process.env) {
28
+ if (env.GITHUB_ACTIONS === "true") {
29
+ const ci = {
30
+ provider: "github-actions",
31
+ repo: env.GITHUB_REPOSITORY,
32
+ ref: env.GITHUB_REF,
33
+ commitSha: env.GITHUB_SHA,
34
+ workflow: env.GITHUB_WORKFLOW,
35
+ workflowRef: env.GITHUB_WORKFLOW_REF,
36
+ runId: env.GITHUB_RUN_ID,
37
+ runAttempt: env.GITHUB_RUN_ATTEMPT,
38
+ runnerEnv: env.RUNNER_ENVIRONMENT,
39
+ actor: env.GITHUB_ACTOR
40
+ };
41
+ const canAttest = Boolean(env.ACTIONS_ID_TOKEN_REQUEST_URL && env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) || Boolean(env.SIGSTORE_ID_TOKEN);
42
+ return { ci, canAttest };
43
+ }
44
+ if (env.CI === "true") {
45
+ const ci = { provider: "other-ci", commitSha: env.GITHUB_SHA ?? env.CI_COMMIT_SHA };
46
+ return { ci, canAttest: Boolean(env.SIGSTORE_ID_TOKEN) };
47
+ }
48
+ return { canAttest: Boolean(env.SIGSTORE_ID_TOKEN) };
49
+ }
50
+ // ---------- attestation subject (the digest set the signature binds to) ----------
51
+ export function buildAttestationSubject(git, loop, verification) {
52
+ const loopSpecHash = sha256Hex(canonicalize(loop));
53
+ const verificationDigest = sha256Hex(canonicalize(verification.results.map((r) => ({ command: r.command, exitCode: r.exitCode, timedOut: r.timedOut }))));
54
+ return { commitSha: git.shaAfter, treeClean: git.clean, loopSpecHash, verificationDigest };
55
+ }
56
+ // ---------- default attestor: keyless Sigstore via cosign (best-effort, degrades to local) ----------
57
+ async function cosignAvailable() {
58
+ try {
59
+ await execFileAsync("cosign", ["version"], { timeout: 15_000 });
60
+ return true;
61
+ }
62
+ catch (error) {
63
+ if (isMissingBinary(error))
64
+ return false;
65
+ return true; // present but `version` exited non-zero for some other reason — still usable
66
+ }
67
+ }
68
+ function predicatePathFor(projectRoot, entryId) {
69
+ return path.join(projectRoot, ATTESTATION_DIR, `${entryId}.predicate.json`);
70
+ }
71
+ function bundlePathFor(projectRoot, entryId) {
72
+ return path.join(projectRoot, ATTESTATION_DIR, `${entryId}.sigstore.json`);
73
+ }
74
+ // cosign verify-blob requires an identity; derive it for GitHub OIDC, else signal "local checks only".
75
+ function githubIdentityArgs(ci) {
76
+ if (ci?.provider !== "github-actions" || !ci.repo)
77
+ return undefined;
78
+ const identity = ci.workflowRef
79
+ ? `https://github.com/${ci.workflowRef}`
80
+ : `^https://github.com/${ci.repo}/`;
81
+ const flag = ci.workflowRef ? "--certificate-identity" : "--certificate-identity-regexp";
82
+ return [flag, identity, "--certificate-oidc-issuer", GITHUB_OIDC_ISSUER];
83
+ }
84
+ export function createDefaultAttestor() {
85
+ return {
86
+ async produce(req) {
87
+ if (!(await cosignAvailable()))
88
+ return { method: "none" };
89
+ const predicatePath = predicatePathFor(req.projectRoot, req.entryId);
90
+ const bundlePath = bundlePathFor(req.projectRoot, req.entryId);
91
+ await fs.mkdir(path.dirname(predicatePath), { recursive: true });
92
+ const predicate = {
93
+ predicateType: LOOPGEN_PREDICATE_TYPE,
94
+ entryId: req.entryId,
95
+ entryHash: req.entryHash, // binds the signature to the exact audit entry hash
96
+ subject: req.subject,
97
+ ci: req.ci
98
+ };
99
+ await fs.writeFile(predicatePath, `${JSON.stringify(predicate, null, 2)}\n`, "utf8");
100
+ await execFileAsync("cosign", ["sign-blob", "--yes", "--bundle", bundlePath, predicatePath], {
101
+ timeout: 120_000
102
+ });
103
+ return {
104
+ method: "cosign-keyless",
105
+ bundlePath: path.relative(req.projectRoot, bundlePath),
106
+ predicateType: LOOPGEN_PREDICATE_TYPE
107
+ };
108
+ },
109
+ async verify(req) {
110
+ const { ref } = req;
111
+ if (ref.method !== "cosign-keyless" || !ref.bundlePath) {
112
+ return { ok: false, reason: "no external attestation bundle recorded for this entry" };
113
+ }
114
+ const bundlePath = path.join(req.projectRoot, ref.bundlePath);
115
+ const predicatePath = bundlePath.replace(/\.sigstore\.json$/, ".predicate.json");
116
+ const predicateRaw = await fs.readFile(predicatePath, "utf8").catch(() => undefined);
117
+ if (!predicateRaw)
118
+ return { ok: false, reason: "attestation predicate file is missing" };
119
+ const predicate = JSON.parse(predicateRaw);
120
+ // Local cross-checks: the signed payload must bind the live entry hash + the recorded CI identity.
121
+ if (predicate.entryHash !== req.entryHash) {
122
+ return { ok: false, reason: `signed entryHash ${predicate.entryHash} != audit entry hash ${req.entryHash}` };
123
+ }
124
+ if (req.ci?.repo && predicate.ci?.repo && req.ci.repo !== predicate.ci.repo) {
125
+ return { ok: false, reason: "signed CI repo does not match the audit entry's CI repo" };
126
+ }
127
+ if (!(await cosignAvailable())) {
128
+ return { ok: true, reason: "predicate cross-check passed; cosign not installed, signature not cryptographically verified" };
129
+ }
130
+ const identityArgs = githubIdentityArgs(predicate.ci ?? req.ci);
131
+ if (!identityArgs) {
132
+ return { ok: true, reason: "predicate cross-check passed; non-GitHub identity, cosign identity not constructed" };
133
+ }
134
+ try {
135
+ await execFileAsync("cosign", ["verify-blob", "--bundle", bundlePath, ...identityArgs, predicatePath], {
136
+ timeout: 120_000
137
+ });
138
+ return { ok: true };
139
+ }
140
+ catch (error) {
141
+ return { ok: false, reason: `cosign verify-blob failed: ${errMsg(error)}` };
142
+ }
143
+ }
144
+ };
145
+ }
146
+ // Re-derive the attestation reference for an entry from its deterministic sibling-bundle path.
147
+ // (The AttestationRef is out-of-band — not in the hashed entry — so verify reconstructs it by entryId.)
148
+ export async function loadAttestationRef(projectRoot, entryId) {
149
+ const bundlePath = bundlePathFor(projectRoot, entryId);
150
+ const exists = await fs
151
+ .stat(bundlePath)
152
+ .then(() => true)
153
+ .catch(() => false);
154
+ if (!exists)
155
+ return { method: "none" };
156
+ return { method: "cosign-keyless", bundlePath: path.relative(projectRoot, bundlePath), predicateType: LOOPGEN_PREDICATE_TYPE };
157
+ }
158
+ // ---------- top-level wrappers used by runner.ts / cli.ts ----------
159
+ // Produce an attestation. NEVER throws — a failed/unavailable signer must not fail a passing run; it
160
+ // just leaves the run at local-evidence grade (method: "none").
161
+ export async function produceAttestation(opts) {
162
+ const attestor = opts.attestor ?? createDefaultAttestor();
163
+ try {
164
+ return await attestor.produce({
165
+ projectRoot: opts.projectRoot,
166
+ entryId: opts.entryId,
167
+ entryHash: opts.entryHash,
168
+ subject: opts.subject,
169
+ ci: opts.ci
170
+ });
171
+ }
172
+ catch {
173
+ return { method: "none" };
174
+ }
175
+ }
176
+ export async function verifyAttestation(opts) {
177
+ const attestor = opts.attestor ?? createDefaultAttestor();
178
+ try {
179
+ return await attestor.verify({ projectRoot: opts.projectRoot, entryHash: opts.entryHash, ci: opts.ci, ref: opts.ref });
180
+ }
181
+ catch (error) {
182
+ return { ok: false, reason: errMsg(error) };
183
+ }
184
+ }
@@ -5,7 +5,7 @@ export const AUDIT_FILE = ".loopgen/audit.jsonl";
5
5
  // Deterministic JSON: object keys sorted recursively so the hash is stable across runs/machines.
6
6
  // undefined-valued keys are skipped (matching JSON.stringify) so the in-memory hash equals the hash
7
7
  // recomputed from the JSON-round-tripped entry — otherwise dropped keys would break the chain.
8
- function canonicalize(value) {
8
+ export function canonicalize(value) {
9
9
  if (value === null || typeof value !== "object")
10
10
  return JSON.stringify(value);
11
11
  if (Array.isArray(value))
@@ -16,6 +16,7 @@ export function renderGovernanceMarkdown(summary) {
16
16
  - Runs: **${summary.total}** (${summary.passed} passed / ${summary.failed} failed) — pass rate **${pct(summary.passRate)}**
17
17
  - Window: ${summary.firstAt ?? "—"} → ${summary.lastAt ?? "—"}
18
18
  - Modes: referee ${summary.byMode.referee} · driven ${summary.byMode.driven}
19
+ - Trust: local evidence ${summary.byTier.local} · **CI-attested** ${summary.byTier.attested}
19
20
  - Chain integrity: ${summary.chain.valid ? "**valid**" : `**BROKEN** (entry ${summary.chain.brokenAt})`}
20
21
  - Forbidden-path violations: **${summary.forbiddenViolationRuns}** run(s)
21
22
  - Blocked attempts (driven, prevented at apply time): **${summary.blockedAttempts}**
@@ -88,6 +89,7 @@ code{font-family:"IBM Plex Mono",monospace;font-size:13px}
88
89
  <div class="card"><div class="k">Forbidden violations</div><div class="v ${summary.forbiddenViolationRuns ? "bad" : "good"}">${summary.forbiddenViolationRuns}</div></div>
89
90
  <div class="card"><div class="k">Blocked (prevented)</div><div class="v">${summary.blockedAttempts}</div></div>
90
91
  <div class="card"><div class="k">Chain</div><div class="v ${summary.chain.valid ? "good" : "bad"}">${summary.chain.valid ? "valid" : "BROKEN"}</div></div>
92
+ <div class="card"><div class="k">CI-attested</div><div class="v">${summary.byTier.attested}/${summary.total}</div></div>
91
93
  </div>
92
94
  <h2>By loop</h2>
93
95
  <table><thead><tr><th>Loop</th><th>Runs</th><th>Passed</th><th>Pass rate</th></tr></thead><tbody>${loopRows || '<tr><td colspan="4">No runs.</td></tr>'}</tbody></table>
@@ -55,6 +55,7 @@ function summarizeEntries(entries, sources, chain) {
55
55
  const byLoop = {};
56
56
  const byActor = {};
57
57
  const byMode = { referee: 0, driven: 0 };
58
+ const byTier = { local: 0, attested: 0 };
58
59
  let passed = 0;
59
60
  let blockedAttempts = 0;
60
61
  let forbiddenViolationRuns = 0;
@@ -67,6 +68,10 @@ function summarizeEntries(entries, sources, chain) {
67
68
  byMode.driven += 1;
68
69
  else
69
70
  byMode.referee += 1;
71
+ if (entry.provenance?.tier === "attested")
72
+ byTier.attested += 1;
73
+ else
74
+ byTier.local += 1;
70
75
  byLoop[entry.loopId] ??= { total: 0, passed: 0 };
71
76
  byLoop[entry.loopId].total += 1;
72
77
  if (entry.passed)
@@ -95,6 +100,7 @@ function summarizeEntries(entries, sources, chain) {
95
100
  passRate: total ? passed / total : 0,
96
101
  byLoop,
97
102
  byMode,
103
+ byTier,
98
104
  byActor,
99
105
  blockedAttempts,
100
106
  forbiddenViolationRuns,
@@ -118,6 +124,12 @@ export function evaluatePolicy(entries, policy) {
118
124
  if (violations.length)
119
125
  failures.push(`${violations.length} run(s) modified forbidden paths`);
120
126
  }
127
+ if (policy.requireAttested) {
128
+ // Checks the in-band claim only; cryptographic verification is `loopgen audit verify --attestation`.
129
+ const notAttested = scoped.filter((entry) => entry.provenance?.tier !== "attested");
130
+ if (notAttested.length)
131
+ failures.push(`${notAttested.length} run(s) are not CI-attested (local self-attested only)`);
132
+ }
121
133
  for (const loopId of policy.requireLoops ?? []) {
122
134
  const ok = scoped.some((entry) => entry.loopId === loopId && entry.passed);
123
135
  if (!ok)
@@ -1,4 +1,4 @@
1
- export function renderProofReport(loop, entry, verification, iterationLogs) {
1
+ export function renderProofReport(loop, entry, verification, iterationLogs, attestation) {
2
2
  const banner = entry.passed ? "✅ PASS" : "❌ FAIL";
3
3
  const changed = [...entry.changedFiles.tracked, ...entry.changedFiles.untracked];
4
4
  const commandBlocks = verification.results
@@ -21,6 +21,7 @@ Loop: \`${entry.loopId}\` · Mode: ${entry.mode} · Iterations: ${entry.iteratio
21
21
  Generated: ${entry.timestamp}
22
22
  By: ${entry.actor.user ?? "unknown"}@${entry.actor.host ?? "unknown"}
23
23
  Audit entry: \`${entry.hash}\`
24
+ Trust: ${trustLine(entry, attestation)}
24
25
 
25
26
  ## Goal
26
27
 
@@ -51,7 +52,7 @@ ${forbiddenSection}
51
52
  ${entry.driven && iterationLogs ? `\n${renderIterationHistory(iterationLogs)}` : ""}
52
53
  ---
53
54
 
54
- ${scopeFooter(entry)}
55
+ ${scopeFooter(entry, attestation)}
55
56
  `;
56
57
  }
57
58
  function renderIterationHistory(iterationLogs) {
@@ -75,9 +76,28 @@ ${log.reasoning ? `> ${log.reasoning}\n` : ""}- Applied: ${applied}
75
76
  });
76
77
  return `## Iteration history\n\n${blocks.join("\n\n")}\n`;
77
78
  }
78
- function scopeFooter(entry) {
79
+ function scopeFooter(entry, attestation) {
79
80
  if (entry.driven) {
80
- return `> Scope: **bounded + enforced**. loopgen drove a local model (${entry.driven.model.adapter} · ${entry.driven.model.modelName}), blocked forbidden writes and non-allowlisted commands **at apply time**, bounded iterations, and verified each one (stop reason: ${entry.driven.stopReason}). The model still proposes actions — this is enforcement, not a sandbox. Audit is hash-chained in \`.loopgen/audit.jsonl\`.`;
81
+ return `> Scope: **bounded + enforced**. loopgen drove a local model (${entry.driven.model.adapter} · ${entry.driven.model.modelName}), blocked forbidden writes and non-allowlisted commands **at apply time**, bounded iterations, and verified each one (stop reason: ${entry.driven.stopReason}). The model still proposes actions — this is enforcement, not a sandbox.${trustFooter(entry, attestation)}`;
81
82
  }
82
- return `> Scope: this is **detection, not prevention**. loopgen ran the verification commands above and diffed the working tree after the work session; it does not sandbox the agent or block reads. The audit entry is hash-chained in \`.loopgen/audit.jsonl\` (tamper-evident against in-place edits).`;
83
+ return `> Scope: this is **detection, not prevention**. loopgen ran the verification commands above and diffed the working tree after the work session; it does not sandbox the agent or block reads.${trustFooter(entry, attestation)}`;
84
+ }
85
+ // Evidence (local) vs proof (CI-attested) — stated precisely so the report never over-claims.
86
+ function isSigned(entry, attestation) {
87
+ return entry.provenance?.tier === "attested" && Boolean(attestation && attestation.method !== "none");
88
+ }
89
+ function trustLine(entry, attestation) {
90
+ if (isSigned(entry, attestation)) {
91
+ return "**attested** (CI) — audit hash signed against Sigstore/Rekor; verify with `loopgen audit verify --attestation`";
92
+ }
93
+ if (entry.provenance?.tier === "attested") {
94
+ return "attested (CI) requested, but no signer was available — **local evidence only**";
95
+ }
96
+ return "**local** — tamper-evident evidence (re-run in CI for a verifiable signed attestation)";
97
+ }
98
+ function trustFooter(entry, attestation) {
99
+ if (isSigned(entry, attestation)) {
100
+ return ` This run is **CI-attested**: the audit entry hash is signed against the Sigstore/Rekor public transparency log and bound to commit \`${entry.git.shaAfter ?? "(none)"}\` — **verifiable proof** (\`loopgen audit verify --attestation\`).`;
101
+ }
102
+ return ` The audit entry is hash-chained in \`.loopgen/audit.jsonl\` — **tamper-evident local evidence**, not signed proof. Re-run in CI for a verifiable signed attestation.`;
83
103
  }
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { runDrivenLoop } from "./agent-loop.js";
6
+ import { buildAttestationSubject, detectCiProvenance, produceAttestation } from "./attest.js";
6
7
  import { appendAuditEntry, hashEntry, readAuditLog } from "./audit.js";
7
8
  import { checkForbiddenPaths } from "./forbidden.js";
8
9
  import * as git from "./git.js";
@@ -40,6 +41,7 @@ async function runReferee(projectRoot, loop, loopFile, options) {
40
41
  });
41
42
  const passed = verification.passed && forbidden.ok;
42
43
  const input = baseEntry(loopFile, loop, "referee", { base, shaBefore, shaAfter, clean }, changed, diffstat, forbidden, verification, 1, passed);
44
+ input.provenance = resolveProvenance(options, { shaAfter, clean }, loop, verification);
43
45
  return finalize(projectRoot, loop, input, verification, forbidden, options);
44
46
  }
45
47
  async function runDriven(projectRoot, loop, loopFile, options) {
@@ -75,6 +77,7 @@ async function runDriven(projectRoot, loop, loopFile, options) {
75
77
  const passed = driven.passed && forbidden.ok;
76
78
  const input = baseEntry(loopFile, loop, "driven", { base, shaBefore, shaAfter, clean }, changed, diffstat, forbidden, verification, driven.iterations.length, passed);
77
79
  input.driven = { stopReason: driven.stopReason, model: modelMeta, attempts: summarizeIterations(driven.iterations) };
80
+ input.provenance = resolveProvenance(options, { shaAfter, clean }, loop, verification);
78
81
  return finalize(projectRoot, loop, input, verification, forbidden, options, driven.iterations);
79
82
  }
80
83
  function baseEntry(loopFile, loop, mode, gitInfo, changed, diffstat, forbidden, verification, iterations, passed) {
@@ -112,13 +115,26 @@ async function finalize(projectRoot, loop, input, verification, forbidden, optio
112
115
  else {
113
116
  entry = await appendAuditEntry(projectRoot, input);
114
117
  }
118
+ // Root of trust: the signature's subject IS entry.hash, so this runs AFTER the entry is hashed and
119
+ // appended. A missing/failed signer never fails the run — it just leaves local-grade evidence.
120
+ let attestation;
121
+ if (!options.dryRun && entry.provenance?.tier === "attested" && entry.provenance.subject) {
122
+ attestation = await produceAttestation({
123
+ projectRoot,
124
+ entryId: entry.entryId,
125
+ entryHash: entry.hash,
126
+ subject: entry.provenance.subject,
127
+ ci: entry.provenance.ci,
128
+ attestor: options.attestor
129
+ });
130
+ }
115
131
  let reportPath;
116
132
  if (!options.dryRun && options.writeReport !== false) {
117
133
  const stamp = entry.timestamp.replace(/[:.]/g, "-");
118
134
  reportPath = path.join(".loopgen", "reports", `${loop.id}-${stamp}.md`);
119
135
  const absolute = path.join(projectRoot, reportPath);
120
136
  await fs.mkdir(path.dirname(absolute), { recursive: true });
121
- await fs.writeFile(absolute, renderProofReport(loop, entry, verification, iterationLogs), "utf8");
137
+ await fs.writeFile(absolute, renderProofReport(loop, entry, verification, iterationLogs, attestation), "utf8");
122
138
  }
123
139
  if (!options.dryRun) {
124
140
  await appendStateEntry(projectRoot, loop, entry);
@@ -131,9 +147,20 @@ async function finalize(projectRoot, loop, input, verification, forbidden, optio
131
147
  forbidden,
132
148
  reportPath,
133
149
  dryRun: Boolean(options.dryRun),
134
- iterationLogs
150
+ iterationLogs,
151
+ attestation
135
152
  };
136
153
  }
154
+ // Capture the trust tier + CI/OIDC claims + the digest set a signature will bind to.
155
+ // All fields are known BEFORE the entry is hashed, so this rides the chain like `driven`.
156
+ function resolveProvenance(options, gitInfo, loop, verification) {
157
+ const { ci, canAttest } = detectCiProvenance();
158
+ const subject = buildAttestationSubject(gitInfo, loop, verification);
159
+ // Default: auto-attest when an ambient OIDC identity exists; --attest forces, --no-attest disables.
160
+ const requested = options.attest === undefined ? canAttest : options.attest;
161
+ const tier = requested && Boolean(ci) && canAttest ? "attested" : "local";
162
+ return { tier, ci, subject };
163
+ }
137
164
  function summarizeIterations(logs) {
138
165
  return logs.map((log) => ({
139
166
  iteration: log.iteration,
@@ -0,0 +1,37 @@
1
+ # Reference workflow — produce a VERIFIABLE signed attestation for an AI-assisted change, then gate the
2
+ # merge on it. Copy into your repo's .github/workflows/.
3
+ #
4
+ # Why id-token: write? It hands the job a GitHub OIDC identity, so cosign can sign the audit entry hash
5
+ # keylessly against the Sigstore / Rekor PUBLIC transparency log — loopgen holds no keys. The signature
6
+ # binds an accountable CI identity (repo + workflow) to the exact commit + verification result.
7
+ # See docs/THREAT-MODEL.md for what this does and does not prove.
8
+ name: loopgen attested gate
9
+ on: [pull_request]
10
+
11
+ permissions:
12
+ contents: read
13
+ id-token: write # GitHub OIDC identity → keyless Sigstore signing
14
+
15
+ jobs:
16
+ attested-gate:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 20
23
+ - uses: sigstore/cosign-installer@v3 # the keyless signer used by `loopgen run --attest`
24
+ - run: npm install -g loopgen
25
+
26
+ # Run the loop's verification IN CI and sign the audit entry against Sigstore/Rekor.
27
+ # In CI with an OIDC identity, attestation is automatic; pass --no-attest to opt out.
28
+ - run: loopgen run test-repair --attest
29
+
30
+ # Gate the merge on a CRYPTOGRAPHICALLY VERIFIABLE attestation — not just a local self-attested log.
31
+ # require-attested makes the action install cosign and run `audit verify --attestation`.
32
+ - uses: Nagiici/Loopgen/.github/actions/audit-check@v0.7.0
33
+ with:
34
+ require: test-repair
35
+ require-no-violations: "true"
36
+ require-chain: "true"
37
+ require-attested: "true"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loopgen",
3
- "version": "0.6.2",
4
- "description": "Run your AI's coding loop and prove its work actually passed real verification, a tamper-evident audit, and a CI gate. Local-first.",
3
+ "version": "0.7.0",
4
+ "description": "The vendor-neutral verification & provenance gate for AI-generated code bring your own agent. Local runs leave tamper-evident evidence; CI runs produce a verifiable, signed attestation (Sigstore/SLSA) you can gate merges on.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=18"
@@ -28,6 +28,11 @@
28
28
  "keywords": [
29
29
  "ai-agents",
30
30
  "verification",
31
+ "provenance",
32
+ "attestation",
33
+ "sigstore",
34
+ "slsa",
35
+ "supply-chain",
31
36
  "audit",
32
37
  "ci",
33
38
  "governance",