diff --git a/README.md b/README.md index 594654a1..3677a64a 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero ## Documentation - [Install](docs/INSTALL.md) +- [Run attestation](docs/ATTESTATION.md) - [Update flow](docs/UPDATE.md) - [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) - [Specialists](docs/SPECIALISTS.md) diff --git a/docs/ATTESTATION.md b/docs/ATTESTATION.md new file mode 100644 index 00000000..f0fbc813 --- /dev/null +++ b/docs/ATTESTATION.md @@ -0,0 +1,47 @@ +# Zero-Knowledge Run Attestation + +`internal/attest` lets a Zero run be committed and proven without revealing +anything about it. + +## What it provides + +| Piece | Meaning | +|---|---| +| `Transcript` | Append-only SHA-256 hash chain over run events. `head_i = SHA-256(head_{i-1} ‖ canonicalJSON(record_i))`. | +| `CommitmentHex()` | The chain head — a 32-byte payload commitment for on-chain attestation. | +| `Nullifier(secret, context)` | `SHA-256(secret ‖ context ‖ nonce?)`, bit-compatible with `@clawd/zk-client` `computeNullifier`. Proves a run happened exactly once. | +| `Attest(...)` | Public artifact carrying the four public inputs of clawd-zk `publish_attestation`: attester, modelHash, payloadCommitment, nullifier. | +| `VerifyJSONL` | Anyone holding the transcript file can replay the chain and check the commitment locally. | +| `norecursion_test.go` | Static call-graph gate: any direct or mutual recursion in the package fails `go test`. | + +## Privacy model + +The transcript stays local. Only the 32-byte commitment, the nullifier, and a +model-set hash are meant to ever be published. The chain proves *that* a run +happened, *which* model set produced it, and that it happened *exactly once* +— never prompts, tool calls, or outputs. + +## Using it from `zero exec` + +``` +zero exec --attest "fix the failing test" +``` + +writes a transcript (one hash-chained record per turn/tool-call/tool-result) +and an attestation summary to `.zero/attest/.{jsonl,json}` in the +workspace, and prints the commitment to stderr. `internal/attest` never makes +a network call itself — publishing the attestation on-chain via clawd-zk's +`publish_attestation` is a separate, external step. + +## Flow + +```go +tr := attest.NewTranscript() +tr.Append("task_start", 0, map[string]any{"prompt": prompt}) +// ... one Append per turn / tool call / tool result ... +tr.Append("run_done", 0, map[string]any{"answer": answer}) + +att, _ := tr.Attest(secret, "zero/run/v1", attest.ModelSetID(models)) +// att.PayloadCommitment + att.Nullifier + att.ModelHash → Groth16 circuit +// → clawd-zk publish_attestation +``` diff --git a/internal/agent/loop.go b/internal/agent/loop.go index badaf3f7..d25827ec 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -104,7 +104,24 @@ const escalationFailedNoticePrefix = "Note: could not switch to the requested mo // The system prompt (core coding-craft instructions + workspace context + safety // confirmation policy) is assembled in system_prompt.go via buildSystemPrompt. -func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) { +// Run executes the agent loop for a single prompt. When options.Transcript is +// set, it wraps runLoop to record a task_start event before the run and a +// run_done event after — regardless of which return path runLoop takes. +func Run(ctx context.Context, prompt string, provider Provider, options Options) (result Result, err error) { + if options.Transcript != nil { + _ = options.Transcript.Append("task_start", 0, map[string]any{"prompt": prompt}) + defer func() { + _ = options.Transcript.Append("run_done", result.Turns, map[string]any{ + "answer": result.FinalAnswer, + "incomplete": result.Incomplete, + "turns": result.Turns, + }) + }() + } + return runLoop(ctx, prompt, provider, options) +} + +func runLoop(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) { if provider == nil { return Result{}, errors.New("agent provider is required") } @@ -479,10 +496,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) if options.OnToolCall != nil { options.OnToolCall(call) } + if options.Transcript != nil { + _ = options.Transcript.Append("tool_call", result.Turns, map[string]any{"id": call.ID, "name": call.Name}) + } toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options) if options.OnToolResult != nil { options.OnToolResult(toolResult) } + if options.Transcript != nil { + _ = options.Transcript.Append("tool_result", result.Turns, map[string]any{"id": toolResult.ToolCallID, "status": string(toolResult.Status)}) + } // Union the deferred tools this result asked to load into the per-run // set BEFORE any abort/stop/guard branch, so a load that coincides with // a turn-ending result is still recorded for the next turn's partition. diff --git a/internal/agent/types.go b/internal/agent/types.go index b18a189d..13bebedd 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -3,6 +3,7 @@ package agent import ( "context" + "github.com/Gitlawb/zero/internal/attest" "github.com/Gitlawb/zero/internal/hooks" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/streamjson" @@ -270,6 +271,11 @@ type Options struct { // byte-identical to before). One instance per run — it holds attempt state. SelfCorrect *SelfCorrector + // Transcript, when set, folds task_start/tool_call/tool_result/run_done + // events into a local hash-chained attestation log as the run progresses. + // nil disables it entirely (the loop is byte-identical to before). + Transcript *attest.Transcript + // RequireCompletionSignal gates run completion for HEADLESS exec. Without it, // any assistant turn that produces text but no tool call is accepted as the // final answer. With it, a no-tool-call turn is NOT treated as "done" while diff --git a/internal/attest/attest.go b/internal/attest/attest.go new file mode 100644 index 00000000..ab0d9ebf --- /dev/null +++ b/internal/attest/attest.go @@ -0,0 +1,230 @@ +// Package attest gives Zero zero-knowledge run attestation: every agent run +// folds its events into a SHA-256 hash chain whose head is a 32-byte payload +// commitment, plus a nullifier proving the run happened exactly once. Layouts +// are bit-compatible with clawd-zk (@clawd/zk-client), so commitments can be +// published on-chain via publish_attestation without revealing prompts, tool +// calls, or outputs — only the commitment, the nullifier, and a model-set hash +// ever leave the machine. +package attest + +import ( + "bufio" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "time" +) + +const ( + transcriptDomain = "clawd-zero/transcript/v1" + attestationSchema = "clawd-zero/attestation/v1" + + // minNullifierSecret mirrors the @clawd/zk-client check. + minNullifierSecret = 16 +) + +// ── Record ─────────────────────────────────────────────────────────── + +// Record is one transcript entry. Field order is fixed by the struct, so +// json.Marshal is canonical for chaining purposes. +type Record struct { + Index int `json:"i"` + Kind string `json:"kind"` + TaskID int `json:"task"` + Payload json.RawMessage `json:"payload"` +} + +// ── Transcript ─────────────────────────────────────────────────────── + +// Transcript is an append-only hash-chained event log: +// +// head_0 = SHA-256(transcriptDomain) +// head_i = SHA-256(head_{i-1} || canonicalJSON(record_i)) +type Transcript struct { + head [32]byte + records []Record +} + +func NewTranscript() *Transcript { + return &Transcript{head: sha256.Sum256([]byte(transcriptDomain))} +} + +// Append adds a record and folds it into the hash chain. +func (t *Transcript) Append(kind string, taskID int, payload any) error { + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("transcript payload: %w", err) + } + rec := Record{Index: len(t.records), Kind: kind, TaskID: taskID, Payload: raw} + line, err := json.Marshal(rec) + if err != nil { + return fmt.Errorf("transcript record: %w", err) + } + h := sha256.New() + h.Write(t.head[:]) + h.Write(line) + copy(t.head[:], h.Sum(nil)) + t.records = append(t.records, rec) + return nil +} + +// Commitment returns the current chain head — the payloadCommitment. +func (t *Transcript) Commitment() [32]byte { return t.head } + +// CommitmentHex returns the chain head as 0x-prefixed hex. +func (t *Transcript) CommitmentHex() string { + return "0x" + hex.EncodeToString(t.head[:]) +} + +func (t *Transcript) Len() int { return len(t.records) } + +// WriteJSONL streams the transcript: one record per line, then a final +// commitment line. The file re-verifies with VerifyJSONL. +func (t *Transcript) WriteJSONL(w io.Writer) error { + bw := bufio.NewWriter(w) + for _, rec := range t.records { + line, err := json.Marshal(rec) + if err != nil { + return err + } + if _, err := bw.Write(append(line, '\n')); err != nil { + return err + } + } + final, _ := json.Marshal(map[string]string{"commitment": t.CommitmentHex()}) + if _, err := bw.Write(append(final, '\n')); err != nil { + return err + } + return bw.Flush() +} + +// SaveJSONL writes the transcript to path (0600 — transcripts are private). +func (t *Transcript) SaveJSONL(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + return t.WriteJSONL(f) +} + +// VerifyJSONL replays a transcript file and checks the recorded commitment +// against a freshly recomputed chain head. +func VerifyJSONL(r io.Reader) (string, error) { + head := sha256.Sum256([]byte(transcriptDomain)) + var claimed string + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1<<20), 1<<24) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var final struct { + Commitment string `json:"commitment"` + } + if err := json.Unmarshal(line, &final); err == nil && final.Commitment != "" { + claimed = final.Commitment + continue + } + h := sha256.New() + h.Write(head[:]) + h.Write(line) + copy(head[:], h.Sum(nil)) + } + if err := sc.Err(); err != nil { + return "", err + } + computed := "0x" + hex.EncodeToString(head[:]) + if claimed == "" { + return computed, fmt.Errorf("no commitment line found") + } + if claimed != computed { + return computed, fmt.Errorf("commitment mismatch: file claims %s, replay gives %s", claimed, computed) + } + return computed, nil +} + +// ── Nullifier — bit-compatible with @clawd/zk-client ───────────────── + +// Nullifier computes SHA-256(secret || context), matching +// computeNullifier({secret, context}) with no nonce in clawd-zk. +func Nullifier(secret []byte, context string) ([32]byte, error) { + return NullifierWithNonce(secret, context, nil) +} + +// NullifierWithNonce computes SHA-256(secret || context || nonce_u64le), +// matching computeNullifier({secret, context, nonce}) in clawd-zk. +func NullifierWithNonce(secret []byte, context string, nonce *uint64) ([32]byte, error) { + var out [32]byte + if len(secret) < minNullifierSecret { + return out, fmt.Errorf("nullifier secret must be at least %d bytes", minNullifierSecret) + } + h := sha256.New() + h.Write(secret) + h.Write([]byte(context)) + if nonce != nil { + var nb [8]byte + binary.LittleEndian.PutUint64(nb[:], *nonce) + h.Write(nb[:]) + } + copy(out[:], h.Sum(nil)) + return out, nil +} + +// ── Attestation ────────────────────────────────────────────────────── + +// Attestation is the public artifact of a run: everything needed to call +// clawd-zk publish_attestation (plus a Groth16 proof from the circuit). It +// reveals nothing about prompts, tool calls, or outputs. +type Attestation struct { + Schema string `json:"schema"` + Context string `json:"context"` + ModelHash string `json:"modelHash"` + PayloadCommitment string `json:"payloadCommitment"` + Nullifier string `json:"nullifier"` + Events int `json:"events"` + CreatedAt string `json:"createdAt"` +} + +// ModelSetID canonicalizes a set of model IDs (dedupe, sort, join) so the +// same model set always hashes to the same modelHash regardless of which +// model won which turn. +func ModelSetID(models []string) string { + seen := make(map[string]bool, len(models)) + uniq := make([]string, 0, len(models)) + for _, m := range models { + if m != "" && !seen[m] { + seen[m] = true + uniq = append(uniq, m) + } + } + sort.Strings(uniq) + return strings.Join(uniq, ",") +} + +// Attest builds the attestation for a finished transcript. modelID is hashed +// (SHA-256) so the chain learns *which* model class ran without the +// transcript; secret must be >=16 bytes of private material. +func (t *Transcript) Attest(secret []byte, context, modelID string) (*Attestation, error) { + null, err := Nullifier(secret, context) + if err != nil { + return nil, err + } + modelHash := sha256.Sum256([]byte(modelID)) + return &Attestation{ + Schema: attestationSchema, + Context: context, + ModelHash: "0x" + hex.EncodeToString(modelHash[:]), + PayloadCommitment: t.CommitmentHex(), + Nullifier: "0x" + hex.EncodeToString(null[:]), + Events: len(t.records), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} diff --git a/internal/attest/attest_test.go b/internal/attest/attest_test.go new file mode 100644 index 00000000..04ea3db2 --- /dev/null +++ b/internal/attest/attest_test.go @@ -0,0 +1,117 @@ +// Package attest :: attest_test.go +// Transcript chaining, tamper detection, and clawd-zk nullifier +// compatibility vectors. +package attest + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestTranscriptVerifyRoundTrip(t *testing.T) { + tr := NewTranscript() + _ = tr.Append("task_start", 0, map[string]any{"prompt": "hi"}) + _ = tr.Append("llm_turn", 0, map[string]any{"content": "hello"}) + _ = tr.Append("run_done", 0, map[string]any{"answer": "hello"}) + + var buf bytes.Buffer + if err := tr.WriteJSONL(&buf); err != nil { + t.Fatal(err) + } + got, err := VerifyJSONL(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + if got != tr.CommitmentHex() { + t.Fatalf("verify %s != commitment %s", got, tr.CommitmentHex()) + } + + tampered := bytes.Replace(buf.Bytes(), []byte("hello"), []byte("hacked"), 1) + if _, err := VerifyJSONL(bytes.NewReader(tampered)); err == nil { + t.Fatal("tampered transcript verified") + } +} + +func TestTranscriptDeterminism(t *testing.T) { + a, b := NewTranscript(), NewTranscript() + for _, tr := range []*Transcript{a, b} { + _ = tr.Append("k", 1, map[string]any{"x": 1}) + } + if a.CommitmentHex() != b.CommitmentHex() { + t.Fatal("same events, different commitments") + } + _ = b.Append("k", 1, map[string]any{"x": 2}) + if a.CommitmentHex() == b.CommitmentHex() { + t.Fatal("different events, same commitment") + } +} + +func TestNullifierMatchesZkClient(t *testing.T) { + secret := bytes.Repeat([]byte{0xAB}, 32) + contextTag := "solana-clawd/attestation/v1" + + // Reference construction, independent of the implementation: + // SHA-256(secret || utf8(context)) — no nonce. + h := sha256.New() + h.Write(secret) + h.Write([]byte(contextTag)) + want := hex.EncodeToString(h.Sum(nil)) + + got, err := Nullifier(secret, contextTag) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got[:]) != want { + t.Fatalf("nullifier mismatch: %x != %s", got, want) + } + + // With nonce: SHA-256(secret || context || u64le(7)). + h = sha256.New() + h.Write(secret) + h.Write([]byte(contextTag)) + h.Write([]byte{7, 0, 0, 0, 0, 0, 0, 0}) + want = hex.EncodeToString(h.Sum(nil)) + nonce := uint64(7) + got, err = NullifierWithNonce(secret, contextTag, &nonce) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got[:]) != want { + t.Fatal("nonce nullifier mismatch") + } + + if _, err := Nullifier([]byte("short"), contextTag); err == nil { + t.Fatal("accepted <16-byte secret") + } +} + +func TestAttestation(t *testing.T) { + tr := NewTranscript() + _ = tr.Append("run_done", 0, map[string]any{"answer": "42"}) + secret := bytes.Repeat([]byte{1}, 32) + + att, err := tr.Attest(secret, "zero/run/v1", "test/model") + if err != nil { + t.Fatal(err) + } + if att.PayloadCommitment != tr.CommitmentHex() { + t.Fatal("attestation commitment mismatch") + } + wantModel := sha256.Sum256([]byte("test/model")) + if att.ModelHash != "0x"+hex.EncodeToString(wantModel[:]) { + t.Fatal("modelHash mismatch") + } + if att.Schema != attestationSchema || att.Events != 1 { + t.Fatalf("bad attestation: %+v", att) + } +} + +func TestModelSetID(t *testing.T) { + a := ModelSetID([]string{"b/model", "a/model", "b/model", ""}) + b := ModelSetID([]string{"a/model", "b/model"}) + if a != b || a != "a/model,b/model" { + t.Fatalf("ModelSetID not canonical: %q vs %q", a, b) + } +} diff --git a/internal/attest/norecursion_test.go b/internal/attest/norecursion_test.go new file mode 100644 index 00000000..92ef6c28 --- /dev/null +++ b/internal/attest/norecursion_test.go @@ -0,0 +1,134 @@ +// Package attest :: norecursion_test.go +// "Zero" means zero recursion — and this test makes it a compile-gate, not a +// slogan. It parses every non-test file in the package, builds the +// intra-package static call graph (conservative: any call to a name declared +// in this package counts as an edge, closures attribute to their enclosing +// function), and fails on any cycle — self-calls and mutual recursion alike. +// The cycle detector itself is iterative. +package attest + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestZeroRecursion(t *testing.T) { + fset := token.NewFileSet() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + + type fn struct { + name string + calls map[string]bool + } + graph := map[string]*fn{} + declared := map[string]bool{} + var files []*ast.File + + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + files = append(files, f) + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok { + declared[fd.Name.Name] = true + } + } + } + + for _, f := range files { + for _, d := range f.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + node := &fn{name: fd.Name.Name, calls: map[string]bool{}} + ast.Inspect(fd.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + var callee string + switch x := call.Fun.(type) { + case *ast.Ident: + callee = x.Name + case *ast.SelectorExpr: + callee = x.Sel.Name + } + if callee != "" && declared[callee] { + node.calls[callee] = true + } + return true + }) + // Methods and funcs share a namespace here (conservative). + if prev, exists := graph[node.name]; exists { + for c := range node.calls { + prev.calls[c] = true + } + } else { + graph[node.name] = node + } + } + } + + if len(graph) == 0 { + t.Fatal("no functions found — wrong directory?") + } + + // Iterative three-color DFS over the call graph. + const ( + white = 0 + gray = 1 + black = 2 + ) + color := map[string]int{} + parent := map[string]string{} + + for start := range graph { + if color[start] != white { + continue + } + stack := []string{start} + for len(stack) > 0 { + cur := stack[len(stack)-1] + if color[cur] == white { + color[cur] = gray + for callee := range graph[cur].calls { + if _, ok := graph[callee]; !ok { + continue + } + switch color[callee] { + case white: + parent[callee] = cur + stack = append(stack, callee) + case gray: + // Reconstruct the cycle for the failure message. + cycle := []string{callee, cur} + for p := cur; p != callee && parent[p] != ""; p = parent[p] { + cycle = append(cycle, parent[p]) + } + t.Fatalf("recursion detected in internal/attest: %s — Zero means ZERO recursion", + fmt.Sprint(strings.Join(cycle, " → "))) + } + } + } else { + color[cur] = black + stack = stack[:len(stack)-1] + } + } + } +} diff --git a/internal/cli/app.go b/internal/cli/app.go index a5bbd62a..b10a6115 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1248,6 +1248,7 @@ Flags: --skip-permissions-unsafe Allow prompt-gated tools without approval --allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model --self-correct Run the post-edit verify-and-correct loop (auto-fix needs --auto medium or high) + --attest Record a local hash-chained attestation transcript for this run --notify Override notification mode for this run --no-notify Disable notifications for this run diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 069d9e79..4a475f9c 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -2,6 +2,8 @@ package cli import ( "context" + "crypto/rand" + "encoding/json" "errors" "fmt" "io" @@ -11,6 +13,7 @@ import ( "time" "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/attest" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/imageinput" "github.com/Gitlawb/zero/internal/lsp" @@ -113,6 +116,12 @@ type execOptions struct { // additional write roots for this run. Unioned with // config.SandboxConfig.AdditionalWriteRoots at scope construction time. addDirs []string + // attest opts the run into local zero-knowledge run attestation: a + // hash-chained transcript of the run is written to + // .zero/attest/.jsonl and its commitment/nullifier summary to + // .zero/attest/.attestation.json. Off by default — a run without + // the flag wires a nil Transcript, leaving the agent loop byte-identical. + attest bool } type execUsageError struct { @@ -503,6 +512,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // sessions the LSP half spawned (no-op when self-correct is off). selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy) defer lspShutdown() + var transcript *attest.Transcript + if options.attest { + transcript = attest.NewTranscript() + } result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{ MaxTurns: resolved.MaxTurns, ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider), @@ -524,6 +537,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in PermissionMode: permissionMode, Autonomy: options.autonomy, SelfCorrect: selfCorrector, + Transcript: transcript, // Headless exec: don't accept a no-tool-call turn as "done" while work // clearly remains (pending plan items / a mid-step continuation cue) — // nudge to continue, and finalize as INCOMPLETE rather than false success @@ -583,6 +597,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in sessionRecorder.append(sessions.EventUsage, payload) }, }) + if transcript != nil { + writeExecAttestation(transcript, workspaceRoot, runID, currentModel, stderr) + } notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion)) if writer.err != nil { return exitCrash @@ -718,6 +735,44 @@ func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) ( return corrector, cleanup } +// writeExecAttestation finalizes a --attest run: it writes the hash-chained +// transcript and an attestation summary (commitment/nullifier/modelHash) +// under .zero/attest in the workspace and prints the commitment to stderr. +// The secret is generated fresh per run — attestation here is a local audit +// artifact, not a replay-protection guarantee across runs. Failures are +// reported on stderr but never fail the run: attestation is best-effort. +func writeExecAttestation(transcript *attest.Transcript, workspaceRoot, runID, modelID string, stderr io.Writer) { + dir := filepath.Join(workspaceRoot, ".zero", "attest") + if err := os.MkdirAll(dir, 0o700); err != nil { + fmt.Fprintf(stderr, "[attest] failed to create %s: %v\n", dir, err) + return + } + if err := transcript.SaveJSONL(filepath.Join(dir, runID+".jsonl")); err != nil { + fmt.Fprintf(stderr, "[attest] failed to write transcript: %v\n", err) + return + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + fmt.Fprintf(stderr, "[attest] failed to generate secret: %v\n", err) + return + } + attestation, err := transcript.Attest(secret, "zero/exec/"+runID, modelID) + if err != nil { + fmt.Fprintf(stderr, "[attest] failed to build attestation: %v\n", err) + return + } + data, err := json.MarshalIndent(attestation, "", " ") + if err != nil { + fmt.Fprintf(stderr, "[attest] failed to encode attestation: %v\n", err) + return + } + if err := os.WriteFile(filepath.Join(dir, runID+".attestation.json"), data, 0o600); err != nil { + fmt.Fprintf(stderr, "[attest] failed to write attestation: %v\n", err) + return + } + fmt.Fprintf(stderr, "[attest] commitment %s\n", attestation.PayloadCommitment) +} + func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int { count := 0 for _, tool := range registry.All() { diff --git a/internal/cli/exec_attest_test.go b/internal/cli/exec_attest_test.go new file mode 100644 index 00000000..b30b1ad7 --- /dev/null +++ b/internal/cli/exec_attest_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/attest" + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +func TestParseExecAttestFlag(t *testing.T) { + t.Run("absent defaults to false", func(t *testing.T) { + options, _, err := parseExecArgs([]string{"hello"}) + if err != nil { + t.Fatalf("parseExecArgs returned error: %v", err) + } + if options.attest { + t.Fatal("attest = true, want false by default") + } + }) + + t.Run("flag sets true", func(t *testing.T) { + options, _, err := parseExecArgs([]string{"--attest", "hello"}) + if err != nil { + t.Fatalf("parseExecArgs returned error: %v", err) + } + if !options.attest { + t.Fatal("attest = false, want true") + } + if strings.Join(options.promptParts, " ") != "hello" { + t.Fatalf("promptParts = %#v, want [hello]", options.promptParts) + } + }) + + t.Run("flag rejects an inline value", func(t *testing.T) { + _, _, err := parseExecArgs([]string{"--attest=yes", "hello"}) + if err == nil { + t.Fatal("expected an error for --attest=yes, got nil") + } + }) +} + +func TestRunExecAttestWritesTranscriptAndAttestation(t *testing.T) { + dataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + cwd := t.TempDir() + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--attest", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { + return cwd, nil + }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + if exitCode != exitSuccess { + t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "[attest] commitment 0x") { + t.Fatalf("expected a commitment notice on stderr, got %q", stderr.String()) + } + + dir := filepath.Join(cwd, ".zero", "attest") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%s) returned error: %v", dir, err) + } + var jsonl, attestationJSON string + for _, e := range entries { + switch { + case strings.HasSuffix(e.Name(), ".jsonl"): + jsonl = filepath.Join(dir, e.Name()) + case strings.HasSuffix(e.Name(), ".attestation.json"): + attestationJSON = filepath.Join(dir, e.Name()) + } + } + if jsonl == "" || attestationJSON == "" { + t.Fatalf("expected a transcript and attestation file in %s, got %#v", dir, entries) + } + + f, err := os.Open(jsonl) + if err != nil { + t.Fatalf("Open(%s) returned error: %v", jsonl, err) + } + defer f.Close() + commitment, err := attest.VerifyJSONL(f) + if err != nil { + t.Fatalf("VerifyJSONL returned error: %v", err) + } + + data, err := os.ReadFile(attestationJSON) + if err != nil { + t.Fatalf("ReadFile(%s) returned error: %v", attestationJSON, err) + } + var got attest.Attestation + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal attestation returned error: %v", err) + } + if got.PayloadCommitment != commitment { + t.Fatalf("attestation commitment %q != replayed commitment %q", got.PayloadCommitment, commitment) + } + if got.Events == 0 { + t.Fatal("expected a non-empty transcript") + } +} + +func TestRunExecWithoutAttestFlagSkipsTranscript(t *testing.T) { + cwd := t.TempDir() + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { + return cwd, nil + }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + if exitCode != exitSuccess { + t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if strings.Contains(stderr.String(), "[attest]") { + t.Fatalf("expected no attest output without --attest, got %q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(cwd, ".zero", "attest")); !os.IsNotExist(err) { + t.Fatalf("expected no .zero/attest directory without --attest, err=%v", err) + } +} + +func TestRunExecHelpDocumentsAttest(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(stdout.String(), "--attest") { + t.Fatalf("expected exec help to document --attest, got %q", stdout.String()) + } +} diff --git a/internal/cli/exec_parse.go b/internal/cli/exec_parse.go index 617ccfa5..3f08fedf 100644 --- a/internal/cli/exec_parse.go +++ b/internal/cli/exec_parse.go @@ -28,6 +28,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) { options.allowEscalation = true case arg == "--self-correct": options.selfCorrect = true + case arg == "--attest": + options.attest = true case arg == "--no-notify": options.noNotify = true case arg == "--notify":