From c90958e0d34e2edfdcc46f56b779edaade6c16af Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Wed, 18 Mar 2026 17:59:07 -0400 Subject: [PATCH 1/2] fix: harden artifact integrity determinism --- .github/workflows/ci.yml | 6 +- .github/workflows/pr-fast.yml | 8 +- README.md | 4 + SECURITY.md | 2 + cmd/gait/duplicate_verify_cli_test.go | 173 +++++++++++++++++++++ cmd/gait/mcp_server_test.go | 55 +++++++ cmd/gait/mcp_test.go | 75 +++++++++ cmd/gait/run_record.go | 20 ++- cmd/gait/run_record_normalization_test.go | 179 ++++++++++++++++++++++ core/guard/pack.go | 10 ++ core/guard/pack_test.go | 68 ++++---- core/mcp/trust.go | 72 +++++++-- core/mcp/trust_test.go | 55 +++++++ core/pack/pack.go | 4 + core/pack/pack_test.go | 129 ++++++++++++++++ core/runpack/read.go | 3 + core/runpack/read_replay_test.go | 31 ++++ core/runpack/record.go | 153 ++++++++++++++++-- core/runpack/record_test.go | 102 ++++++++++++ core/runpack/verify.go | 19 +++ core/runpack/verify_test.go | 76 +++++++++ core/zipx/zipx.go | 30 ++++ core/zipx/zipx_test.go | 21 +++ docs-site/public/llm/contracts.md | 4 + docs-site/public/llm/quickstart.md | 4 + docs-site/public/llm/security.md | 2 + docs-site/public/llms.txt | 3 + docs/contracts/pack_producer_kit.md | 3 + docs/contracts/primitive_contract.md | 4 + docs/external_tool_registry_policy.md | 6 + docs/integration_checklist.md | 3 + docs/sdk/python.md | 6 + sdk/python/gait/client.py | 30 +++- sdk/python/gait/session.py | 67 ++++---- sdk/python/tests/test_session.py | 38 +++++ 35 files changed, 1378 insertions(+), 87 deletions(-) create mode 100644 cmd/gait/duplicate_verify_cli_test.go create mode 100644 cmd/gait/run_record_normalization_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a22cef7b..4c83e1dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,11 +233,15 @@ jobs: PYTHONPATH: . shell: bash run: | + go test ./core/pack -count=1 -run 'TestVerifyInspectAndDiffRejectDuplicateEntries' + go test ./core/runpack -count=1 -run 'TestVerifyZipRejectsDuplicateEntries|TestReadRunpackRejectsDuplicateEntries|TestRecordRunNormalizesMissingDigestsFromNormalization|TestRecordRunRejectsDigestMismatchAgainstNormalization' + go test ./core/mcp -count=1 -run 'TestEvaluateServerTrustInvalidDuplicateSnapshotFailsClosed' go test ./cmd/gait -count=1 -run 'TestRunGateEvalRequiresVerifiedContextEnvelopeForContextPolicies|TestPolicyTestEqualPriorityRenamesDoNotChangeVerdict' + go test ./cmd/gait -count=1 -run 'TestRunPackVerifyRejectsDuplicateEntries|TestRunVerifyRejectsDuplicateEntries|TestRunMCPVerifyRejectsDuplicateSnapshotIdentities|TestRunMCPProxyRejectsDuplicateSnapshotIdentities|TestMCPServeHandlerRejectsDuplicateSnapshotIdentities|TestRunRecordNormalizesMissingDigestsFromNormalization|TestRunRecordRejectsDigestMismatchAgainstNormalization' go test ./core/gate -count=1 -run 'TestEvaluatePolicyDetailedEqualPriorityRenameDoesNotChangeVerdict' go test ./core/jobruntime -count=1 -run 'TestSubmitAppendFailureRollsBackNewJob|TestMutationAppendFailureRollsBackStateAndRetrySucceeds|TestMutationAppendFailureWithDurableEventPreservesPendingMarker' cd sdk/python - uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error' + uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads' e2e: needs: changes diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index 03a3bd60..9315769d 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -86,11 +86,15 @@ jobs: PYTHONPATH: . shell: bash run: | + go test ./core/pack -count=1 -run 'TestVerifyInspectAndDiffRejectDuplicateEntries' + go test ./core/runpack -count=1 -run 'TestVerifyZipRejectsDuplicateEntries|TestReadRunpackRejectsDuplicateEntries|TestRecordRunNormalizesMissingDigestsFromNormalization|TestRecordRunRejectsDigestMismatchAgainstNormalization' + go test ./core/mcp -count=1 -run 'TestEvaluateServerTrustInvalidDuplicateSnapshotFailsClosed' go test ./cmd/gait -count=1 -run 'TestRunGateEvalRequiresVerifiedContextEnvelopeForContextPolicies|TestPolicyTestEqualPriorityRenamesDoNotChangeVerdict' + go test ./cmd/gait -count=1 -run 'TestRunPackVerifyRejectsDuplicateEntries|TestRunVerifyRejectsDuplicateEntries|TestRunMCPVerifyRejectsDuplicateSnapshotIdentities|TestRunMCPProxyRejectsDuplicateSnapshotIdentities|TestMCPServeHandlerRejectsDuplicateSnapshotIdentities|TestRunRecordNormalizesMissingDigestsFromNormalization|TestRunRecordRejectsDigestMismatchAgainstNormalization' go test ./core/gate -count=1 -run 'TestEvaluatePolicyDetailedEqualPriorityRenameDoesNotChangeVerdict' go test ./core/jobruntime -count=1 -run 'TestSubmitAppendFailureRollsBackNewJob|TestMutationAppendFailureRollsBackStateAndRetrySucceeds|TestMutationAppendFailureWithDurableEventPreservesPendingMarker' cd sdk/python - uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error' + uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads' windows-fast: name: pr-fast-windows @@ -119,4 +123,4 @@ jobs: PYTHONPATH: . run: | cd sdk/python - uv run --python 3.11 --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error' + uv run --python 3.11 --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads' diff --git a/README.md b/README.md index a6839739..40475dbc 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ See [`examples/integrations/openai_agents/`](examples/integrations/openai_agents The official LangChain surface is middleware with optional callback correlation. Enforcement happens in `wrap_tool_call`; callbacks are additive only. +`run_session(...)` and other Python run-capture helpers delegate digest completion to `gait run record` in Go rather than hashing artifact fields in Python. Normalize `set` values to JSON lists before calling the SDK; unsupported non-JSON values now fail deterministically instead of being coerced into digest-affecting output. + ```bash (cd sdk/python && uv sync --extra langchain --extra dev) (cd sdk/python && uv run --python 3.13 --extra langchain python ../../examples/integrations/langchain/quickstart.py --scenario allow) @@ -252,6 +254,7 @@ Current shipped model: - external scanners or registries produce a local trust snapshot - `gait mcp verify`, `gait mcp proxy`, and `gait mcp serve` consume that local file - Gait enforces the decision at the boundary; it does not replace the scanner +- duplicate normalized `server_id` / `server_name` entries invalidate the snapshot, and required high-risk trust paths fail closed on that ambiguity This is the right split with tools such as Snyk: external tooling finds the issue, and Gait enforces the runtime response. @@ -287,6 +290,7 @@ Every Gait decision can produce signed proof artifacts that map to operational a - `gait verify`, `gait pack verify`, and `gait trace verify` work offline - packs use Ed25519 signatures plus SHA-256 manifests +- duplicate ZIP entry names are treated as verification failures rather than ambiguous soft passes - artifacts are deterministic, versioned, and designed for PRs, incidents, change control, and audits Framework mapping and evidence docs: diff --git a/SECURITY.md b/SECURITY.md index 28078802..5151164e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,6 +39,8 @@ Security checks run in CI and local lint workflows: - `go vet`, `golangci-lint`, `gosec`, `govulncheck` for Go - `ruff`, `mypy`, `bandit`, `pytest` for Python wrapper code +- artifact verification fails closed on duplicate ZIP entry names +- MCP trust snapshots with duplicate normalized identities are treated as invalid and fail closed on required high-risk paths Release integrity is validated with signed checksums, SBOM, and provenance artifacts. diff --git a/cmd/gait/duplicate_verify_cli_test.go b/cmd/gait/duplicate_verify_cli_test.go new file mode 100644 index 00000000..ef935587 --- /dev/null +++ b/cmd/gait/duplicate_verify_cli_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Clyra-AI/gait/core/jobruntime" + packcore "github.com/Clyra-AI/gait/core/pack" + runpackcore "github.com/Clyra-AI/gait/core/runpack" + schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" +) + +func TestRunPackVerifyRejectsDuplicateEntries(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + jobsRoot := filepath.Join(workDir, "jobs") + jobID := "job_pack_duplicate_cli" + + if _, err := jobruntime.Submit(jobsRoot, jobruntime.SubmitOptions{JobID: jobID}); err != nil { + t.Fatalf("submit job: %v", err) + } + packPath := filepath.Join(workDir, "job_pack.zip") + if _, err := packcore.BuildJobPackFromPath(jobsRoot, jobID, packPath, "test-v24", nil); err != nil { + t.Fatalf("build job pack: %v", err) + } + + duplicatePath := filepath.Join(workDir, "job_pack_duplicate.zip") + if err := writeDuplicateEntryZip(packPath, duplicatePath, "job_state.json", []byte(`{"job_id":"evil"}`), false); err != nil { + t.Fatalf("write duplicate pack zip: %v", err) + } + + code, output := runPackJSON(t, []string{"verify", duplicatePath, "--json"}) + if code != exitVerifyFailed { + t.Fatalf("pack verify duplicate expected %d got %d output=%#v", exitVerifyFailed, code, output) + } + if output.OK { + t.Fatalf("expected pack verify output ok=false: %#v", output) + } + if !strings.Contains(output.Error, "zip contains duplicate entries: job_state.json") { + t.Fatalf("unexpected pack verify error: %#v", output) + } +} + +func TestRunVerifyRejectsDuplicateEntries(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + now := time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC) + runpackPath := filepath.Join(workDir, "runpack_valid.zip") + + if _, err := runpackcore.WriteRunpack(runpackPath, runpackcore.RecordOptions{ + Run: schemarunpack.Run{ + RunID: "run_duplicate_cli", + CreatedAt: now, + ProducerVersion: "0.0.0-test", + }, + Intents: []schemarunpack.IntentRecord{{ + IntentID: "intent_1", + RunID: "run_duplicate_cli", + ToolName: "tool.write", + ArgsDigest: strings.Repeat("a", 64), + }}, + Results: []schemarunpack.ResultRecord{{ + IntentID: "intent_1", + RunID: "run_duplicate_cli", + Status: "ok", + ResultDigest: strings.Repeat("b", 64), + }}, + Refs: schemarunpack.Refs{ + RunID: "run_duplicate_cli", + Receipts: []schemarunpack.RefReceipt{}, + }, + CaptureMode: "reference", + }); err != nil { + t.Fatalf("write runpack: %v", err) + } + + duplicatePath := filepath.Join(workDir, "runpack_duplicate.zip") + if err := writeDuplicateEntryZip(runpackPath, duplicatePath, "run.json", []byte(`{"run":"evil"}`), true); err != nil { + t.Fatalf("write duplicate runpack zip: %v", err) + } + + var code int + raw := captureStdout(t, func() { + code = runVerify([]string{"--json", duplicatePath}) + }) + if code != exitVerifyFailed { + t.Fatalf("verify duplicate expected %d got %d raw=%s", exitVerifyFailed, code, raw) + } + var output verifyOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode verify output: %v raw=%s", err, raw) + } + if output.OK { + t.Fatalf("expected verify output ok=false: %#v", output) + } + if !strings.Contains(output.Error, "zip contains duplicate entries: run.json") { + t.Fatalf("unexpected verify error output: %#v", output) + } +} + +func writeDuplicateEntryZip(srcPath string, dstPath string, entryName string, duplicatePayload []byte, duplicateFirst bool) error { + reader, err := zip.OpenReader(srcPath) + if err != nil { + return err + } + defer func() { + _ = reader.Close() + }() + + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + inserted := false + for _, file := range reader.File { + if file.Name == entryName && duplicateFirst && !inserted { + target, err := writer.Create(entryName) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(duplicatePayload); err != nil { + _ = writer.Close() + return err + } + inserted = true + } + + fileReader, err := file.Open() + if err != nil { + _ = writer.Close() + return err + } + payload, err := io.ReadAll(fileReader) + _ = fileReader.Close() + if err != nil { + _ = writer.Close() + return err + } + + target, err := writer.Create(file.Name) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(payload); err != nil { + _ = writer.Close() + return err + } + + if file.Name == entryName && !duplicateFirst && !inserted { + target, err := writer.Create(entryName) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(duplicatePayload); err != nil { + _ = writer.Close() + return err + } + inserted = true + } + } + if err := writer.Close(); err != nil { + return err + } + return os.WriteFile(dstPath, buffer.Bytes(), 0o600) +} diff --git a/cmd/gait/mcp_server_test.go b/cmd/gait/mcp_server_test.go index 1089060a..ce8738fb 100644 --- a/cmd/gait/mcp_server_test.go +++ b/cmd/gait/mcp_server_test.go @@ -438,6 +438,61 @@ func TestMCPServeHandlerEvaluateSSE(t *testing.T) { } } +func TestMCPServeHandlerRejectsDuplicateSnapshotIdentities(t *testing.T) { + workDir := t.TempDir() + snapshotPath := filepath.Join(workDir, "trust_snapshot.json") + policyPath := filepath.Join(workDir, "policy.yaml") + mustWriteFile(t, policyPath, strings.Join([]string{ + "default_verdict: allow", + "mcp_trust:", + " snapshot: " + snapshotPath, + " action: block", + " required_risk_classes: [high]", + }, "\n")+"\n") + mustWriteFile(t, snapshotPath, `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`) + + handler, err := newMCPServeHandler(mcpServeConfig{ + PolicyPath: policyPath, + DefaultAdapter: "mcp", + TraceDir: filepath.Join(workDir, "traces"), + KeyMode: "dev", + }) + if err != nil { + t.Fatalf("newMCPServeHandler: %v", err) + } + + requestBody := []byte(`{ + "run_id":"run_mcp_server_duplicate_snapshot", + "call":{ + "name":"tool.read", + "args":{"path":"/tmp/out.txt"}, + "server":{"server_id":"github","server_name":"GitHub"}, + "context":{"identity":"alice","workspace":"/repo/gait","risk_class":"high"} + } + }`) + request := httptest.NewRequest(http.MethodPost, "/v1/evaluate", bytes.NewReader(requestBody)) + request.Header.Set("content-type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("evaluate status: expected %d got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } + var response mcpServeEvaluateResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode evaluate response: %v", err) + } + if response.Verdict != "block" || response.MCPTrust == nil || response.MCPTrust.Status != "invalid" { + t.Fatalf("expected invalid trust block response, got %#v", response) + } + if response.ExitCode != exitPolicyBlocked { + t.Fatalf("expected exit code %d got %d", exitPolicyBlocked, response.ExitCode) + } + if !strings.Contains(strings.Join(response.ReasonCodes, ","), "mcp_trust_snapshot_invalid") { + t.Fatalf("expected duplicate snapshot reason code, got %#v", response) + } +} + func TestMCPServeHandlerEvaluateStream(t *testing.T) { workDir := t.TempDir() policyPath := filepath.Join(workDir, "policy.yaml") diff --git a/cmd/gait/mcp_test.go b/cmd/gait/mcp_test.go index 3c3419e6..ee4c0a9c 100644 --- a/cmd/gait/mcp_test.go +++ b/cmd/gait/mcp_test.go @@ -273,6 +273,81 @@ func TestRunMCPVerifyInfersPolicyRiskClass(t *testing.T) { } } +func TestRunMCPVerifyRejectsDuplicateSnapshotIdentities(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy.yaml") + mustWriteFile(t, policyPath, strings.Join([]string{ + "default_verdict: allow", + "mcp_trust:", + " snapshot: ./trust_snapshot.json", + " action: block", + " required_risk_classes: [high]", + }, "\n")+"\n") + mustWriteFile(t, filepath.Join(workDir, "trust_snapshot.json"), `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`) + serverPath := filepath.Join(workDir, "server.json") + mustWriteFile(t, serverPath, `{"server_id":"github","server_name":"GitHub"}`) + + var code int + raw := captureStdout(t, func() { + code = runMCPVerify([]string{"--policy", policyPath, "--server", serverPath, "--json"}) + }) + if code != exitPolicyBlocked { + t.Fatalf("runMCPVerify duplicate snapshot expected %d got %d (%s)", exitPolicyBlocked, code, raw) + } + var output mcpVerifyOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode verify output: %v", err) + } + if output.OK || output.MCPTrust == nil || output.MCPTrust.Status != "invalid" { + t.Fatalf("expected invalid verify output, got %#v", output) + } + if !strings.Contains(strings.Join(output.ReasonCodes, ","), "mcp_trust_snapshot_invalid") { + t.Fatalf("expected duplicate snapshot reason code, got %#v", output) + } +} + +func TestRunMCPProxyRejectsDuplicateSnapshotIdentities(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy.yaml") + mustWriteFile(t, policyPath, strings.Join([]string{ + "default_verdict: allow", + "mcp_trust:", + " snapshot: ./trust_snapshot.json", + " action: block", + " required_risk_classes: [high]", + }, "\n")+"\n") + mustWriteFile(t, filepath.Join(workDir, "trust_snapshot.json"), `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`) + callPath := filepath.Join(workDir, "call.json") + mustWriteFile(t, callPath, `{ + "name":"tool.read", + "args":{"path":"/tmp/in.txt"}, + "server":{"server_id":"github","server_name":"GitHub"}, + "context":{"identity":"alice","workspace":"/repo/gait","risk_class":"high","run_id":"run_mcp_duplicate_snapshot"} +}`) + + var code int + raw := captureStdout(t, func() { + code = runMCPProxy([]string{"--policy", policyPath, "--call", callPath, "--json"}) + }) + if code != exitPolicyBlocked { + t.Fatalf("runMCPProxy duplicate snapshot expected %d got %d (%s)", exitPolicyBlocked, code, raw) + } + var output mcpProxyOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode proxy output: %v", err) + } + if output.Verdict != "block" || output.MCPTrust == nil || output.MCPTrust.Status != "invalid" { + t.Fatalf("expected invalid trust block output, got %#v", output) + } + if !strings.Contains(strings.Join(output.ReasonCodes, ","), "mcp_trust_snapshot_invalid") { + t.Fatalf("expected duplicate snapshot reason code, got %#v", output) + } +} + func TestRunMCPVerifyBlockedAndReadServerErrors(t *testing.T) { workDir := t.TempDir() withWorkingDir(t, workDir) diff --git a/cmd/gait/run_record.go b/cmd/gait/run_record.go index c986ba13..8c778788 100644 --- a/cmd/gait/run_record.go +++ b/cmd/gait/run_record.go @@ -17,11 +17,17 @@ import ( ) type runRecordInput struct { - Run schemarunpack.Run `json:"run"` - Intents []schemarunpack.IntentRecord `json:"intents"` - Results []schemarunpack.ResultRecord `json:"results"` - Refs schemarunpack.Refs `json:"refs"` - CaptureMode string `json:"capture_mode"` + Run schemarunpack.Run `json:"run"` + Intents []schemarunpack.IntentRecord `json:"intents"` + Results []schemarunpack.ResultRecord `json:"results"` + Refs schemarunpack.Refs `json:"refs"` + CaptureMode string `json:"capture_mode"` + Normalization runRecordNormalization `json:"normalization,omitempty"` +} + +type runRecordNormalization struct { + IntentArgs map[string]json.RawMessage `json:"intent_args,omitempty"` + ResultPayloads map[string]json.RawMessage `json:"result_payloads,omitempty"` } type runRecordOutput struct { @@ -239,6 +245,10 @@ func runRecord(arguments []string) int { Refs: recordInput.Refs, CaptureMode: resolvedCaptureMode, SignKey: signingKey.Private, + Normalization: runpack.DigestNormalizationOptions{ + IntentArgs: recordInput.Normalization.IntentArgs, + ResultPayloads: recordInput.Normalization.ResultPayloads, + }, }) if err != nil { return writeRunRecordOutput(jsonOutput, runRecordOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) diff --git a/cmd/gait/run_record_normalization_test.go b/cmd/gait/run_record_normalization_test.go new file mode 100644 index 00000000..b07e1926 --- /dev/null +++ b/cmd/gait/run_record_normalization_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + runpackcore "github.com/Clyra-AI/gait/core/runpack" +) + +func TestRunRecordNormalizesMissingDigestsFromNormalization(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + inputPath := filepath.Join(workDir, "run_record.json") + outDir := filepath.Join(workDir, "gait-out") + + payload := map[string]any{ + "run": map[string]any{ + "schema_id": "gait.runpack.run", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_normalized", + "env": map[string]any{ + "os": "darwin", + "arch": "arm64", + "runtime": "python3.11", + }, + "timeline": []map[string]any{{"event": "run_started", "ts": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)}}, + }, + "intents": []map[string]any{{ + "schema_id": "gait.runpack.intent", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 1, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_normalized", + "intent_id": "intent_0001", + "tool_name": "tool.allow", + }}, + "results": []map[string]any{{ + "schema_id": "gait.runpack.result", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 1, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_normalized", + "intent_id": "intent_0001", + "status": "ok", + }}, + "refs": map[string]any{ + "schema_id": "gait.runpack.refs", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 2, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_normalized", + "receipts": []map[string]any{{ + "ref_id": "trace_intent_0001", + "source_type": "gait.trace", + "source_locator": "trace://trace_intent_0001", + "retrieved_at": time.Date(2026, time.March, 18, 0, 0, 2, 0, time.UTC).Format(time.RFC3339), + "redaction_mode": "reference", + }}, + }, + "capture_mode": "reference", + "normalization": map[string]any{ + "intent_args": map[string]any{ + "intent_0001": map[string]any{"path": "/tmp/out.txt"}, + }, + "result_payloads": map[string]any{ + "intent_0001": map[string]any{ + "executed": true, + "verdict": "allow", + "reason_codes": []string{"default_allow"}, + "trace_id": "trace_1", + "trace_path": "trace.json", + "policy_digest": strings.Repeat("3", 64), + "intent_digest": strings.Repeat("2", 64), + }, + }, + }, + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + mustWriteFile(t, inputPath, string(encoded)+"\n") + + var code int + raw := captureStdout(t, func() { + code = runRecord([]string{"--input", inputPath, "--out-dir", outDir, "--json"}) + }) + if code != exitOK { + t.Fatalf("runRecord expected %d got %d raw=%s", exitOK, code, raw) + } + var output runRecordOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode output: %v raw=%s", err, raw) + } + if !output.OK || output.Bundle == "" { + t.Fatalf("unexpected runRecord output: %#v", output) + } + if code := runVerify([]string{"--json", output.Bundle}); code != exitOK { + t.Fatalf("runVerify expected %d got %d", exitOK, code) + } + recorded, err := runpackcore.ReadRunpack(output.Bundle) + if err != nil { + t.Fatalf("read recorded runpack: %v", err) + } + if recorded.Intents[0].ArgsDigest == "" || recorded.Results[0].ResultDigest == "" { + t.Fatalf("expected normalized digests in recorded runpack: %#v %#v", recorded.Intents[0], recorded.Results[0]) + } + if recorded.Refs.Receipts[0].QueryDigest == "" || recorded.Refs.Receipts[0].ContentDigest == "" { + t.Fatalf("expected normalized ref digests in recorded runpack: %#v", recorded.Refs.Receipts[0]) + } +} + +func TestRunRecordRejectsDigestMismatchAgainstNormalization(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + inputPath := filepath.Join(workDir, "run_record_bad.json") + + payload := map[string]any{ + "run": map[string]any{ + "schema_id": "gait.runpack.run", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad", + "env": map[string]any{"os": "darwin", "arch": "arm64", "runtime": "python3.11"}, + "timeline": []map[string]any{{"event": "run_started", "ts": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)}}, + }, + "intents": []map[string]any{{ + "schema_id": "gait.runpack.intent", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 1, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad", + "intent_id": "intent_0001", + "tool_name": "tool.allow", + "args_digest": strings.Repeat("a", 64), + }}, + "results": []map[string]any{}, + "refs": map[string]any{ + "schema_id": "gait.runpack.refs", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 2, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad", + "receipts": []map[string]any{}, + }, + "capture_mode": "reference", + "normalization": map[string]any{ + "intent_args": map[string]any{ + "intent_0001": map[string]any{"path": "/tmp/out.txt"}, + }, + }, + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + mustWriteFile(t, inputPath, string(encoded)+"\n") + + var code int + raw := captureStdout(t, func() { + code = runRecord([]string{"--input", inputPath, "--out-dir", filepath.Join(workDir, "gait-out"), "--json"}) + }) + if code != exitInvalidInput { + t.Fatalf("runRecord mismatch expected %d got %d raw=%s", exitInvalidInput, code, raw) + } + var output runRecordOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode output: %v raw=%s", err, raw) + } + if !strings.Contains(output.Error, "intent intent_0001 args digest mismatch") { + t.Fatalf("unexpected mismatch error: %#v", output) + } +} diff --git a/core/guard/pack.go b/core/guard/pack.go index 7093b788..6e55840a 100644 --- a/core/guard/pack.go +++ b/core/guard/pack.go @@ -16,6 +16,7 @@ import ( "strings" "time" + coreerrors "github.com/Clyra-AI/gait/core/errors" "github.com/Clyra-AI/gait/core/runpack" schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" schemaguard "github.com/Clyra-AI/gait/core/schema/v1/guard" @@ -332,6 +333,15 @@ func VerifyPackWithOptions(path string, opts VerifyOptions) (VerifyResult, error _ = zipCloser.Close() }() } + if duplicates := zipx.DuplicatePaths(zipReader.File); len(duplicates) > 0 { + return VerifyResult{}, coreerrors.Wrap( + fmt.Errorf("zip contains duplicate entries: %s", strings.Join(duplicates, ", ")), + coreerrors.CategoryVerification, + "guard_pack_duplicate_entries", + "rebuild the artifact so each zip path is unique", + false, + ) + } var files map[string]*zip.File if len(zipReader.File) > 4 { diff --git a/core/guard/pack_test.go b/core/guard/pack_test.go index aa0f79c1..18b995bb 100644 --- a/core/guard/pack_test.go +++ b/core/guard/pack_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + coreerrors "github.com/Clyra-AI/gait/core/errors" "github.com/Clyra-AI/gait/core/runpack" schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" schemaguard "github.com/Clyra-AI/gait/core/schema/v1/guard" @@ -284,13 +285,12 @@ func TestVerifyPackWithSignatures(t *testing.T) { } } -func TestVerifyPackDetectsTamperedLastDuplicateEntry(t *testing.T) { +func TestVerifyPackRejectsDuplicateEntries(t *testing.T) { workDir := t.TempDir() now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) matchingContent := []byte(`{"status":"ok"}`) tamperedContent := []byte(`{"status":"tampered"}`) expectedHash := sha256Hex(matchingContent) - tamperedHash := sha256Hex(tamperedContent) manifestBytes, err := marshalCanonicalJSON(schemaguard.PackManifest{ SchemaID: "gait.guard.pack_manifest", @@ -310,32 +310,48 @@ func TestVerifyPackDetectsTamperedLastDuplicateEntry(t *testing.T) { t.Fatalf("marshal manifest: %v", err) } - var archive bytes.Buffer - if err := zipx.WriteDeterministicZip(&archive, []zipx.File{ - {Path: "pack_manifest.json", Data: manifestBytes, Mode: 0o644}, - {Path: "evidence.json", Data: matchingContent, Mode: 0o644}, - {Path: "evidence.json", Data: tamperedContent, Mode: 0o644}, - }); err != nil { - t.Fatalf("write duplicate-entry zip: %v", err) - } - - packPath := filepath.Join(workDir, "duplicate_entries.zip") - if err := os.WriteFile(packPath, archive.Bytes(), 0o600); err != nil { - t.Fatalf("write duplicate-entry zip: %v", err) + cases := []struct { + name string + files []zipx.File + }{ + { + name: "good_then_tampered", + files: []zipx.File{ + {Path: "pack_manifest.json", Data: manifestBytes, Mode: 0o644}, + {Path: "evidence.json", Data: matchingContent, Mode: 0o644}, + {Path: "evidence.json", Data: tamperedContent, Mode: 0o644}, + }, + }, + { + name: "tampered_then_good", + files: []zipx.File{ + {Path: "pack_manifest.json", Data: manifestBytes, Mode: 0o644}, + {Path: "evidence.json", Data: tamperedContent, Mode: 0o644}, + {Path: "evidence.json", Data: matchingContent, Mode: 0o644}, + }, + }, } - verifyResult, err := VerifyPack(packPath) - if err != nil { - t.Fatalf("verify duplicate-entry zip: %v", err) - } - if len(verifyResult.HashMismatches) != 1 { - t.Fatalf("expected one hash mismatch, got %#v", verifyResult.HashMismatches) - } - if verifyResult.HashMismatches[0].Path != "evidence.json" { - t.Fatalf("expected mismatch for evidence.json, got %#v", verifyResult.HashMismatches) - } - if verifyResult.HashMismatches[0].Expected != expectedHash || verifyResult.HashMismatches[0].Actual != tamperedHash { - t.Fatalf("unexpected mismatch payload: %#v", verifyResult.HashMismatches[0]) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var archive bytes.Buffer + if err := zipx.WriteDeterministicZip(&archive, tc.files); err != nil { + t.Fatalf("write duplicate-entry zip: %v", err) + } + + packPath := filepath.Join(workDir, tc.name+".zip") + if err := os.WriteFile(packPath, archive.Bytes(), 0o600); err != nil { + t.Fatalf("write duplicate-entry zip: %v", err) + } + + if _, err := VerifyPack(packPath); err == nil { + t.Fatalf("expected duplicate-entry verification error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category error, got %q (%v)", coreerrors.CategoryOf(err), err) + } else if !strings.Contains(err.Error(), "zip contains duplicate entries: evidence.json") { + t.Fatalf("expected duplicate-entry error, got %v", err) + } + }) } } diff --git a/core/mcp/trust.go b/core/mcp/trust.go index d20b5fc3..9a2ab19f 100644 --- a/core/mcp/trust.go +++ b/core/mcp/trust.go @@ -2,6 +2,7 @@ package mcp import ( "encoding/json" + "errors" "fmt" "os" "sort" @@ -18,6 +19,32 @@ const ( mcpTrustSnapshotSchemaVersion = "1.0.0" ) +type trustSnapshotErrorCode string + +const ( + trustSnapshotErrorUnavailable trustSnapshotErrorCode = "unavailable" + trustSnapshotErrorInvalid trustSnapshotErrorCode = "invalid" +) + +type trustSnapshotError struct { + code trustSnapshotErrorCode + err error +} + +func (e *trustSnapshotError) Error() string { + if e == nil || e.err == nil { + return "trust snapshot error" + } + return e.err.Error() +} + +func (e *trustSnapshotError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + type TrustSnapshot struct { SchemaID string `json:"schema_id"` SchemaVersion string `json:"schema_version"` @@ -83,8 +110,14 @@ func EvaluateServerTrust(policy gate.MCPTrustPolicy, server *ServerInfo, riskCla snapshot, err := LoadTrustSnapshot(policy.SnapshotPath) if err != nil { - decision.Status = "missing" - decision.ReasonCodes = []string{"mcp_trust_snapshot_unavailable"} + switch trustSnapshotErrorCodeOf(err) { + case trustSnapshotErrorInvalid: + decision.Status = "invalid" + decision.ReasonCodes = []string{"mcp_trust_snapshot_invalid"} + default: + decision.Status = "missing" + decision.ReasonCodes = []string{"mcp_trust_snapshot_unavailable"} + } decision.Enforced = decision.Required return decision } @@ -151,29 +184,30 @@ func EvaluateServerTrust(policy gate.MCPTrustPolicy, server *ServerInfo, riskCla func LoadTrustSnapshot(path string) (TrustSnapshot, error) { trimmedPath := strings.TrimSpace(path) if trimmedPath == "" { - return TrustSnapshot{}, fmt.Errorf("trust snapshot path is required") + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("trust snapshot path is required")) } // #nosec G304 -- trust snapshot path is explicit local user input from policy. raw, err := os.ReadFile(trimmedPath) if err != nil { - return TrustSnapshot{}, fmt.Errorf("read trust snapshot: %w", err) + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorUnavailable, fmt.Errorf("read trust snapshot: %w", err)) } var snapshot TrustSnapshot if err := json.Unmarshal(raw, &snapshot); err != nil { - return TrustSnapshot{}, fmt.Errorf("parse trust snapshot: %w", err) + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("parse trust snapshot: %w", err)) } if strings.TrimSpace(snapshot.SchemaID) == "" { snapshot.SchemaID = mcpTrustSnapshotSchemaID } if snapshot.SchemaID != mcpTrustSnapshotSchemaID { - return TrustSnapshot{}, fmt.Errorf("unsupported trust snapshot schema_id %q", snapshot.SchemaID) + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("unsupported trust snapshot schema_id %q", snapshot.SchemaID)) } if strings.TrimSpace(snapshot.SchemaVersion) == "" { snapshot.SchemaVersion = mcpTrustSnapshotSchemaVersion } if snapshot.SchemaVersion != mcpTrustSnapshotSchemaVersion { - return TrustSnapshot{}, fmt.Errorf("unsupported trust snapshot schema_version %q", snapshot.SchemaVersion) + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("unsupported trust snapshot schema_version %q", snapshot.SchemaVersion)) } + seenKeys := make(map[string]int, len(snapshot.Entries)) for index := range snapshot.Entries { snapshot.Entries[index].ServerID = strings.TrimSpace(snapshot.Entries[index].ServerID) snapshot.Entries[index].ServerName = strings.TrimSpace(snapshot.Entries[index].ServerName) @@ -181,9 +215,14 @@ func LoadTrustSnapshot(path string) (TrustSnapshot, error) { snapshot.Entries[index].Source = strings.ToLower(strings.TrimSpace(snapshot.Entries[index].Source)) snapshot.Entries[index].Status = strings.ToLower(strings.TrimSpace(snapshot.Entries[index].Status)) snapshot.Entries[index].EvidencePath = strings.TrimSpace(snapshot.Entries[index].EvidencePath) - if normalizedServerKey(snapshot.Entries[index].ServerID, snapshot.Entries[index].ServerName) == "" { - return TrustSnapshot{}, fmt.Errorf("trust snapshot entry[%d] requires server_id or server_name", index) + key := normalizedServerKey(snapshot.Entries[index].ServerID, snapshot.Entries[index].ServerName) + if key == "" { + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("trust snapshot entry[%d] requires server_id or server_name", index)) } + if previousIndex, ok := seenKeys[key]; ok { + return TrustSnapshot{}, wrapTrustSnapshotError(trustSnapshotErrorInvalid, fmt.Errorf("trust snapshot contains duplicate server identity %q at entries[%d] and entries[%d]", key, previousIndex, index)) + } + seenKeys[key] = index } sort.Slice(snapshot.Entries, func(i, j int) bool { return normalizedServerKey(snapshot.Entries[i].ServerID, snapshot.Entries[i].ServerName) < @@ -201,6 +240,21 @@ func findTrustSnapshotEntry(entries []TrustSnapshotEntry, serverKey string) (Tru return TrustSnapshotEntry{}, false } +func wrapTrustSnapshotError(code trustSnapshotErrorCode, err error) error { + if err == nil { + return nil + } + return &trustSnapshotError{code: code, err: err} +} + +func trustSnapshotErrorCodeOf(err error) trustSnapshotErrorCode { + var snapshotErr *trustSnapshotError + if errors.As(err, &snapshotErr) { + return snapshotErr.code + } + return "" +} + func normalizedServerKey(serverID string, serverName string) string { if trimmed := strings.ToLower(strings.TrimSpace(serverID)); trimmed != "" { return trimmed diff --git a/core/mcp/trust_test.go b/core/mcp/trust_test.go index 7b745b90..05c0f965 100644 --- a/core/mcp/trust_test.go +++ b/core/mcp/trust_test.go @@ -156,6 +156,44 @@ func TestEvaluateServerTrustMissingSnapshotAndPublisherMismatch(t *testing.T) { } } +func TestEvaluateServerTrustInvalidDuplicateSnapshotFailsClosed(t *testing.T) { + workDir := t.TempDir() + snapshotPath := filepath.Join(workDir, "trust_snapshot.json") + writeTrustSnapshot(t, snapshotPath, TrustSnapshot{ + SchemaID: mcpTrustSnapshotSchemaID, + SchemaVersion: mcpTrustSnapshotSchemaVersion, + CreatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC), + ProducerVersion: "test", + Entries: []TrustSnapshotEntry{ + { + ServerID: "github", + Status: "trusted", + UpdatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC), + Score: 0.95, + }, + { + ServerName: "GitHub", + Status: "blocked", + UpdatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC), + Score: 0.10, + }, + }, + }) + + decision := EvaluateServerTrust(gate.MCPTrustPolicy{ + Enabled: true, + SnapshotPath: snapshotPath, + Action: "block", + RequiredRiskClasses: []string{"high"}, + }, &ServerInfo{ServerID: "github"}, "high", time.Date(2026, time.March, 2, 0, 0, 0, 0, time.UTC)) + if decision == nil || decision.Status != "invalid" || !decision.Enforced { + t.Fatalf("expected invalid enforced decision, got %#v", decision) + } + if len(decision.ReasonCodes) != 1 || decision.ReasonCodes[0] != "mcp_trust_snapshot_invalid" { + t.Fatalf("unexpected reason codes: %#v", decision) + } +} + func TestLoadTrustSnapshotValidationAndHelpers(t *testing.T) { workDir := t.TempDir() invalidPath := filepath.Join(workDir, "invalid.json") @@ -194,6 +232,23 @@ func TestLoadTrustSnapshotValidationAndHelpers(t *testing.T) { if got := mcpTrustMaxAgeSeconds("2h"); got != 7200 { t.Fatalf("unexpected max age seconds: %d", got) } + + duplicatePath := filepath.Join(workDir, "duplicate.json") + writeTrustSnapshot(t, duplicatePath, TrustSnapshot{ + SchemaID: mcpTrustSnapshotSchemaID, + SchemaVersion: mcpTrustSnapshotSchemaVersion, + CreatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC), + ProducerVersion: "test", + Entries: []TrustSnapshotEntry{ + {ServerID: "github", UpdatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)}, + {ServerName: "GitHub", UpdatedAt: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)}, + }, + }) + if _, err := LoadTrustSnapshot(duplicatePath); err == nil { + t.Fatalf("expected duplicate normalized server identity to fail") + } else if trustSnapshotErrorCodeOf(err) != trustSnapshotErrorInvalid { + t.Fatalf("expected invalid snapshot error classification, got %q (%v)", trustSnapshotErrorCodeOf(err), err) + } } func gateResultAllow() schemagate.GateResult { diff --git a/core/pack/pack.go b/core/pack/pack.go index 742695c6..305f2a7c 100644 --- a/core/pack/pack.go +++ b/core/pack/pack.go @@ -963,6 +963,10 @@ func openZip(path string) (*openedZip, error) { if err != nil { return nil, fmt.Errorf("open zip: %w", err) } + if duplicates := zipx.DuplicatePaths(reader.File); len(duplicates) > 0 { + _ = reader.Close() + return nil, verificationError(fmt.Errorf("zip contains duplicate entries: %s", strings.Join(duplicates, ", "))) + } files := make(map[string]*zip.File, len(reader.File)) for _, file := range reader.File { files[file.Name] = file diff --git a/core/pack/pack_test.go b/core/pack/pack_test.go index 8fd2d937..f1089e9e 100644 --- a/core/pack/pack_test.go +++ b/core/pack/pack_test.go @@ -844,6 +844,69 @@ func TestVerifyRejectsSchemaInvalidRunPayload(t *testing.T) { } } +func TestVerifyInspectAndDiffRejectDuplicateEntries(t *testing.T) { + workDir := t.TempDir() + jobsRoot := filepath.Join(workDir, "jobs") + jobID := "job_duplicate_pack" + + if _, err := jobruntime.Submit(jobsRoot, jobruntime.SubmitOptions{JobID: jobID}); err != nil { + t.Fatalf("submit job: %v", err) + } + packPath := filepath.Join(workDir, "job_pack.zip") + if _, err := BuildJobPackFromPath(jobsRoot, jobID, packPath, "test-v24", nil); err != nil { + t.Fatalf("build job pack: %v", err) + } + + cases := []struct { + name string + duplicateFirst bool + duplicateBytes []byte + duplicateTarget string + }{ + { + name: "duplicate_last", + duplicateFirst: false, + duplicateBytes: []byte(`{"job_id":"evil"}`), + duplicateTarget: "job_state.json", + }, + { + name: "duplicate_first", + duplicateFirst: true, + duplicateBytes: []byte(`{"job_id":"evil"}`), + duplicateTarget: "job_state.json", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mutatedPath := filepath.Join(workDir, tc.name+".zip") + if err := rewriteZipWithDuplicateEntry(packPath, mutatedPath, tc.duplicateTarget, tc.duplicateBytes, tc.duplicateFirst); err != nil { + t.Fatalf("rewrite duplicate pack: %v", err) + } + + if _, err := Verify(mutatedPath, VerifyOptions{}); err == nil { + t.Fatalf("expected verify duplicate-entry error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category error, got %q (%v)", coreerrors.CategoryOf(err), err) + } else if !strings.Contains(err.Error(), "zip contains duplicate entries: job_state.json") { + t.Fatalf("expected duplicate-entry verify error, got %v", err) + } + + if _, err := Inspect(mutatedPath); err == nil { + t.Fatalf("expected inspect duplicate-entry error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category inspect error, got %q (%v)", coreerrors.CategoryOf(err), err) + } + + if _, err := Diff(mutatedPath, packPath); err == nil { + t.Fatalf("expected diff duplicate-entry error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category diff error, got %q (%v)", coreerrors.CategoryOf(err), err) + } + }) + } +} + func TestExtractRunpackVariants(t *testing.T) { workDir := t.TempDir() runpackPath := createRunpackFixture(t, workDir, "run_extract") @@ -1330,6 +1393,72 @@ func rewriteZip(srcPath string, dstPath string, mutate func(name string, payload return os.WriteFile(dstPath, buffer.Bytes(), 0o600) } +func rewriteZipWithDuplicateEntry(srcPath string, dstPath string, entryName string, duplicatePayload []byte, duplicateFirst bool) error { + src, err := zip.OpenReader(srcPath) + if err != nil { + return err + } + defer func() { + _ = src.Close() + }() + + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + wroteDuplicate := false + for _, entry := range src.File { + reader, err := entry.Open() + if err != nil { + _ = writer.Close() + return err + } + payload, err := io.ReadAll(reader) + _ = reader.Close() + if err != nil { + _ = writer.Close() + return err + } + if entry.Name == entryName && duplicateFirst && !wroteDuplicate { + target, err := writer.Create(entryName) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(duplicatePayload); err != nil { + _ = writer.Close() + return err + } + wroteDuplicate = true + } + + target, err := writer.Create(entry.Name) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(payload); err != nil { + _ = writer.Close() + return err + } + + if entry.Name == entryName && !duplicateFirst && !wroteDuplicate { + target, err := writer.Create(entryName) + if err != nil { + _ = writer.Close() + return err + } + if _, err := target.Write(duplicatePayload); err != nil { + _ = writer.Close() + return err + } + wroteDuplicate = true + } + } + if err := writer.Close(); err != nil { + return err + } + return os.WriteFile(dstPath, buffer.Bytes(), 0o600) +} + func writeZipEntries(path string, entries map[string][]byte) error { var buffer bytes.Buffer writer := zip.NewWriter(&buffer) diff --git a/core/runpack/read.go b/core/runpack/read.go index 0d243cb3..c700832e 100644 --- a/core/runpack/read.go +++ b/core/runpack/read.go @@ -30,6 +30,9 @@ func ReadRunpack(path string) (Runpack, error) { defer func() { _ = zipReader.Close() }() + if err := rejectDuplicateZipEntries(zipReader.File); err != nil { + return Runpack{}, err + } manifestFile, manifestFound := findZipFile(zipReader.File, "manifest.json") if !manifestFound { diff --git a/core/runpack/read_replay_test.go b/core/runpack/read_replay_test.go index d4ef84c8..19c08e1b 100644 --- a/core/runpack/read_replay_test.go +++ b/core/runpack/read_replay_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + coreerrors "github.com/Clyra-AI/gait/core/errors" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" "github.com/Clyra-AI/gait/core/zipx" ) @@ -61,6 +62,36 @@ func TestReadRunpackMissingFile(t *testing.T) { } } +func TestReadRunpackRejectsDuplicateEntries(t *testing.T) { + manifestFiles, runpackFiles := buildCompleteRunpackFixture() + manifestBytes, err := buildManifestBytes("run_duplicate", manifestFiles, nil) + if err != nil { + t.Fatalf("build manifest: %v", err) + } + baseFiles := append([]zipx.File{{Path: "manifest.json", Data: manifestBytes, Mode: 0o644}}, runpackFiles...) + + cases := []struct { + name string + duplicateFirst bool + }{ + {name: "duplicate_first", duplicateFirst: true}, + {name: "duplicate_last", duplicateFirst: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeRunpackZipWithDuplicate(t, baseFiles, "run.json", []byte(`{"run":"evil"}`+"\n"), tc.duplicateFirst) + if _, err := ReadRunpack(path); err == nil { + t.Fatalf("expected duplicate-entry read error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category error, got %q (%v)", coreerrors.CategoryOf(err), err) + } else if !strings.Contains(err.Error(), "zip contains duplicate entries: run.json") { + t.Fatalf("unexpected duplicate-entry error: %v", err) + } + }) + } +} + func TestReadRunpackManifestDigestMismatch(t *testing.T) { manifestFiles, runpackFiles := buildCompleteRunpackFixture() manifestBytes, err := buildManifestBytes("run_test", manifestFiles, nil) diff --git a/core/runpack/record.go b/core/runpack/record.go index 548791fe..f050599e 100644 --- a/core/runpack/record.go +++ b/core/runpack/record.go @@ -23,12 +23,18 @@ import ( ) type RecordOptions struct { - Run schemarunpack.Run - Intents []schemarunpack.IntentRecord - Results []schemarunpack.ResultRecord - Refs schemarunpack.Refs - CaptureMode string - SignKey ed25519.PrivateKey + Run schemarunpack.Run + Intents []schemarunpack.IntentRecord + Results []schemarunpack.ResultRecord + Refs schemarunpack.Refs + CaptureMode string + SignKey ed25519.PrivateKey + Normalization DigestNormalizationOptions +} + +type DigestNormalizationOptions struct { + IntentArgs map[string]json.RawMessage + ResultPayloads map[string]json.RawMessage } type RecordResult struct { @@ -45,9 +51,15 @@ func RecordRun(options RecordOptions) (RecordResult, error) { run := options.Run applyRunDefaults(&run) + var err error intents := applyIntentDefaults(options.Intents, run) results := applyResultDefaults(options.Results, run) - refs, err := applyRefsDefaults(options.Refs, run) + refs := applyRefsDefaults(options.Refs, run) + intents, results, refs, err = normalizeDigestBearingFields(intents, results, refs, options.Normalization) + if err != nil { + return RecordResult{}, fmt.Errorf("normalize digest-bearing fields: %w", err) + } + refs, err = contextproof.NormalizeRefs(refs) if err != nil { return RecordResult{}, fmt.Errorf("normalize refs: %w", err) } @@ -306,7 +318,7 @@ func applyResultDefaults(results []schemarunpack.ResultRecord, run schemarunpack return out } -func applyRefsDefaults(refs schemarunpack.Refs, run schemarunpack.Run) (schemarunpack.Refs, error) { +func applyRefsDefaults(refs schemarunpack.Refs, run schemarunpack.Run) schemarunpack.Refs { if refs.SchemaID == "" { refs.SchemaID = "gait.runpack.refs" } @@ -325,9 +337,128 @@ func applyRefsDefaults(refs schemarunpack.Refs, run schemarunpack.Run) (schemaru if refs.Receipts == nil { refs.Receipts = []schemarunpack.RefReceipt{} } - normalized, err := contextproof.NormalizeRefs(refs) + return refs +} + +func normalizeDigestBearingFields(intents []schemarunpack.IntentRecord, results []schemarunpack.ResultRecord, refs schemarunpack.Refs, options DigestNormalizationOptions) ([]schemarunpack.IntentRecord, []schemarunpack.ResultRecord, schemarunpack.Refs, error) { + intentIndexByID := make(map[string]int, len(intents)) + for index := range intents { + intent := &intents[index] + if rawArgs, ok := options.IntentArgs[intent.IntentID]; ok && len(rawArgs) > 0 { + expectedDigest, _, err := digestJSONObjectRaw(rawArgs) + if err != nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("intent %s args digest: %w", intent.IntentID, err) + } + if intent.ArgsDigest == "" { + intent.ArgsDigest = expectedDigest + } else if !equalHex(intent.ArgsDigest, expectedDigest) { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("intent %s args digest mismatch", intent.IntentID) + } + } else if intent.ArgsDigest == "" { + if intent.Args == nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("intent %s requires args_digest or normalization.intent_args", intent.IntentID) + } + expectedDigest, err := digestJSONValue(intent.Args) + if err != nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("intent %s args digest: %w", intent.IntentID, err) + } + intent.ArgsDigest = expectedDigest + } + intentIndexByID[intent.IntentID] = index + } + + resultIndexByIntentID := make(map[string]int, len(results)) + for index := range results { + result := &results[index] + if rawPayload, ok := options.ResultPayloads[result.IntentID]; ok && len(rawPayload) > 0 { + expectedDigest, _, err := digestJSONObjectRaw(rawPayload) + if err != nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("result %s digest: %w", result.IntentID, err) + } + if result.ResultDigest == "" { + result.ResultDigest = expectedDigest + } else if !equalHex(result.ResultDigest, expectedDigest) { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("result %s digest mismatch", result.IntentID) + } + } else if result.ResultDigest == "" { + if result.Result == nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("result %s requires result_digest or normalization.result_payloads", result.IntentID) + } + expectedDigest, err := digestJSONValue(result.Result) + if err != nil { + return nil, nil, schemarunpack.Refs{}, fmt.Errorf("result %s digest: %w", result.IntentID, err) + } + result.ResultDigest = expectedDigest + } + resultIndexByIntentID[result.IntentID] = index + } + + for index := range refs.Receipts { + receipt := &refs.Receipts[index] + if err := normalizeReceiptDigests(receipt, intents, results, intentIndexByID, resultIndexByIntentID); err != nil { + return nil, nil, schemarunpack.Refs{}, err + } + } + return intents, results, refs, nil +} + +func normalizeReceiptDigests(receipt *schemarunpack.RefReceipt, intents []schemarunpack.IntentRecord, results []schemarunpack.ResultRecord, intentIndexByID map[string]int, resultIndexByIntentID map[string]int) error { + if receipt == nil { + return nil + } + if !strings.EqualFold(strings.TrimSpace(receipt.SourceType), "gait.trace") || !strings.HasPrefix(strings.TrimSpace(receipt.RefID), "trace_") { + if receipt.QueryDigest == "" { + return fmt.Errorf("receipt %s requires query_digest", receipt.RefID) + } + if receipt.ContentDigest == "" { + return fmt.Errorf("receipt %s requires content_digest", receipt.RefID) + } + return nil + } + + intentID := strings.TrimPrefix(strings.TrimSpace(receipt.RefID), "trace_") + intentIndex, ok := intentIndexByID[intentID] + if !ok { + return fmt.Errorf("receipt %s references unknown intent %s", receipt.RefID, intentID) + } + expectedQueryDigest, err := digestJSONValue(map[string]any{ + "tool_name": intents[intentIndex].ToolName, + "args_digest": intents[intentIndex].ArgsDigest, + }) + if err != nil { + return fmt.Errorf("receipt %s query digest: %w", receipt.RefID, err) + } + if receipt.QueryDigest == "" { + receipt.QueryDigest = expectedQueryDigest + } + + resultIndex, ok := resultIndexByIntentID[intentID] + if !ok { + return fmt.Errorf("receipt %s references unknown result for intent %s", receipt.RefID, intentID) + } + expectedContentDigest := results[resultIndex].ResultDigest + if expectedContentDigest == "" { + return fmt.Errorf("receipt %s content digest cannot be derived", receipt.RefID) + } + if receipt.ContentDigest == "" { + receipt.ContentDigest = expectedContentDigest + } + return nil +} + +func digestJSONObjectRaw(raw json.RawMessage) (string, bool, error) { + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + return "", false, err + } + digest, err := digestJSONValue(value) + return digest, true, err +} + +func digestJSONValue(value any) (string, error) { + raw, err := json.Marshal(value) if err != nil { - return schemarunpack.Refs{}, err + return "", err } - return normalized, nil + return jcs.DigestJCS(raw) } diff --git a/core/runpack/record_test.go b/core/runpack/record_test.go index 49ae8264..cd760106 100644 --- a/core/runpack/record_test.go +++ b/core/runpack/record_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -338,6 +339,107 @@ func TestRecordRunInvalidIntent(test *testing.T) { } } +func TestRecordRunNormalizesMissingDigestsFromNormalization(test *testing.T) { + run := schemarunpack.Run{ + RunID: "run_normalized", + Env: schemarunpack.RunEnv{OS: "linux", Arch: "amd64", Runtime: "go"}, + Timeline: []schemarunpack.TimelineEvt{ + {Event: "start", TS: time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC)}, + }, + } + intents := []schemarunpack.IntentRecord{{ + IntentID: "intent_1", + ToolName: "tool.write", + }} + results := []schemarunpack.ResultRecord{{ + IntentID: "intent_1", + Status: "ok", + }} + refs := schemarunpack.Refs{ + Receipts: []schemarunpack.RefReceipt{{ + RefID: "trace_intent_1", + SourceType: "gait.trace", + SourceLocator: "trace://trace_intent_1", + RetrievedAt: time.Date(2026, time.February, 5, 0, 0, 1, 0, time.UTC), + RedactionMode: "reference", + }}, + } + + result, err := RecordRun(RecordOptions{ + Run: run, + Intents: intents, + Results: results, + Refs: refs, + Normalization: DigestNormalizationOptions{ + IntentArgs: map[string]json.RawMessage{ + "intent_1": json.RawMessage(`{"path":"/tmp/out.txt"}`), + }, + ResultPayloads: map[string]json.RawMessage{ + "intent_1": json.RawMessage(`{"executed":true,"verdict":"allow","reason_codes":["default_allow"]}`), + }, + }, + }) + if err != nil { + test.Fatalf("record run: %v", err) + } + files := readZipFiles(test, result.ZipBytes) + + intentsDecoded, err := decodeJSONL[schemarunpack.IntentRecord](files["intents.jsonl"]) + if err != nil { + test.Fatalf("decode intents: %v", err) + } + if intentsDecoded[0].ArgsDigest == "" { + test.Fatalf("expected normalized args digest") + } + + resultsDecoded, err := decodeJSONL[schemarunpack.ResultRecord](files["results.jsonl"]) + if err != nil { + test.Fatalf("decode results: %v", err) + } + if resultsDecoded[0].ResultDigest == "" { + test.Fatalf("expected normalized result digest") + } + + var refsDecoded schemarunpack.Refs + if err := json.Unmarshal(files["refs.json"], &refsDecoded); err != nil { + test.Fatalf("decode refs: %v", err) + } + if refsDecoded.Receipts[0].QueryDigest == "" || refsDecoded.Receipts[0].ContentDigest == "" { + test.Fatalf("expected normalized receipt digests: %#v", refsDecoded.Receipts[0]) + } + if refsDecoded.Receipts[0].ContentDigest != resultsDecoded[0].ResultDigest { + test.Fatalf("expected content digest to match normalized result digest") + } +} + +func TestRecordRunRejectsDigestMismatchAgainstNormalization(test *testing.T) { + run := schemarunpack.Run{ + RunID: "run_mismatch", + Env: schemarunpack.RunEnv{OS: "linux", Arch: "amd64", Runtime: "go"}, + Timeline: []schemarunpack.TimelineEvt{ + {Event: "start", TS: time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC)}, + }, + } + intents := []schemarunpack.IntentRecord{{ + IntentID: "intent_1", + ToolName: "tool.write", + ArgsDigest: strings.Repeat("a", 64), + }} + if _, err := RecordRun(RecordOptions{ + Run: run, + Intents: intents, + Normalization: DigestNormalizationOptions{ + IntentArgs: map[string]json.RawMessage{ + "intent_1": json.RawMessage(`{"path":"/tmp/out.txt"}`), + }, + }, + }); err == nil { + test.Fatalf("expected args digest mismatch error") + } else if !strings.Contains(err.Error(), "intent intent_1 args digest mismatch") { + test.Fatalf("unexpected mismatch error: %v", err) + } +} + func TestWriteRunpack(test *testing.T) { run := schemarunpack.Run{ RunID: "run_write", diff --git a/core/runpack/verify.go b/core/runpack/verify.go index f9c4781a..b9bef311 100644 --- a/core/runpack/verify.go +++ b/core/runpack/verify.go @@ -12,7 +12,9 @@ import ( "sort" "strings" + coreerrors "github.com/Clyra-AI/gait/core/errors" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" + "github.com/Clyra-AI/gait/core/zipx" sign "github.com/Clyra-AI/proof/signing" ) @@ -50,6 +52,9 @@ func VerifyZip(path string, opts VerifyOptions) (VerifyResult, error) { defer func() { _ = zipReader.Close() }() + if err := rejectDuplicateZipEntries(zipReader.File); err != nil { + return VerifyResult{}, err + } manifestFile, manifestFound := findZipFile(zipReader.File, "manifest.json") if !manifestFound { @@ -191,6 +196,20 @@ func VerifyZip(path string, opts VerifyOptions) (VerifyResult, error) { return result, nil } +func rejectDuplicateZipEntries(files []*zip.File) error { + duplicates := zipx.DuplicatePaths(files) + if len(duplicates) == 0 { + return nil + } + return coreerrors.Wrap( + fmt.Errorf("zip contains duplicate entries: %s", strings.Join(duplicates, ", ")), + coreerrors.CategoryVerification, + "runpack_duplicate_entries", + "rebuild the artifact so each zip path is unique", + false, + ) +} + func signableManifestBytes(manifest []byte) ([]byte, error) { var obj map[string]json.RawMessage if err := json.Unmarshal(manifest, &obj); err != nil { diff --git a/core/runpack/verify_test.go b/core/runpack/verify_test.go index ce09d45b..dc30bb70 100644 --- a/core/runpack/verify_test.go +++ b/core/runpack/verify_test.go @@ -1,6 +1,7 @@ package runpack import ( + "archive/zip" "bytes" "crypto/ed25519" "encoding/json" @@ -9,6 +10,7 @@ import ( "testing" "time" + coreerrors "github.com/Clyra-AI/gait/core/errors" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" "github.com/Clyra-AI/gait/core/zipx" sign "github.com/Clyra-AI/proof/signing" @@ -119,6 +121,36 @@ func TestVerifyZipMissingManifest(test *testing.T) { } } +func TestVerifyZipRejectsDuplicateEntries(t *testing.T) { + manifestFiles, runpackFiles := buildCompleteRunpackFixture() + manifestBytes, err := buildManifestBytes("run_test", manifestFiles, nil) + if err != nil { + t.Fatalf("build manifest: %v", err) + } + baseFiles := append([]zipx.File{{Path: "manifest.json", Data: manifestBytes, Mode: 0o644}}, runpackFiles...) + + cases := []struct { + name string + duplicateFirst bool + }{ + {name: "duplicate_first", duplicateFirst: true}, + {name: "duplicate_last", duplicateFirst: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + zipPath := writeRunpackZipWithDuplicate(t, baseFiles, "run.json", []byte(`{"run":"evil"}`+"\n"), tc.duplicateFirst) + if _, err := VerifyZip(zipPath, VerifyOptions{}); err == nil { + t.Fatalf("expected duplicate-entry verification error") + } else if coreerrors.CategoryOf(err) != coreerrors.CategoryVerification { + t.Fatalf("expected verification-category error, got %q (%v)", coreerrors.CategoryOf(err), err) + } else if err.Error() != "zip contains duplicate entries: run.json" { + t.Fatalf("unexpected duplicate-entry error: %v", err) + } + }) + } +} + func TestVerifyZipMissingFile(test *testing.T) { keyPair, err := sign.GenerateKeyPair() if err != nil { @@ -494,3 +526,47 @@ func writeRunpackZip(test *testing.T, files []zipx.File) string { } return path } + +func writeRunpackZipWithDuplicate(test *testing.T, files []zipx.File, duplicatePath string, duplicatePayload []byte, duplicateFirst bool) string { + test.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + inserted := false + for _, file := range files { + if file.Path == duplicatePath && duplicateFirst && !inserted { + target, err := writer.Create(duplicatePath) + if err != nil { + test.Fatalf("create duplicate zip entry: %v", err) + } + if _, err := target.Write(duplicatePayload); err != nil { + test.Fatalf("write duplicate zip entry: %v", err) + } + inserted = true + } + target, err := writer.Create(file.Path) + if err != nil { + test.Fatalf("create zip entry: %v", err) + } + if _, err := target.Write(file.Data); err != nil { + test.Fatalf("write zip entry: %v", err) + } + if file.Path == duplicatePath && !duplicateFirst && !inserted { + target, err := writer.Create(duplicatePath) + if err != nil { + test.Fatalf("create duplicate zip entry: %v", err) + } + if _, err := target.Write(duplicatePayload); err != nil { + test.Fatalf("write duplicate zip entry: %v", err) + } + inserted = true + } + } + if err := writer.Close(); err != nil { + test.Fatalf("close zip writer: %v", err) + } + path := filepath.Join(test.TempDir(), "runpack_duplicate.zip") + if err := os.WriteFile(path, buffer.Bytes(), 0o600); err != nil { + test.Fatalf("write duplicate zip file: %v", err) + } + return path +} diff --git a/core/zipx/zipx.go b/core/zipx/zipx.go index 4a7b4fb6..20963f0d 100644 --- a/core/zipx/zipx.go +++ b/core/zipx/zipx.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strings" "time" ) @@ -59,3 +60,32 @@ func normalizeMode(mode os.FileMode) os.FileMode { } return 0o644 } + +// DuplicatePaths returns sorted duplicate entry names in a zip archive. +func DuplicatePaths(files []*zip.File) []string { + if len(files) == 0 { + return nil + } + seen := make(map[string]struct{}, len(files)) + duplicates := make(map[string]struct{}) + for _, file := range files { + name := filepath.ToSlash(strings.TrimSpace(file.Name)) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + duplicates[name] = struct{}{} + continue + } + seen[name] = struct{}{} + } + if len(duplicates) == 0 { + return nil + } + out := make([]string, 0, len(duplicates)) + for name := range duplicates { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/core/zipx/zipx_test.go b/core/zipx/zipx_test.go index eaae3dd0..4e9d5429 100644 --- a/core/zipx/zipx_test.go +++ b/core/zipx/zipx_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "io" + "reflect" "testing" "time" ) @@ -103,6 +104,26 @@ func TestWriteDeterministicZipWriteError(t *testing.T) { } } +func TestDuplicatePaths(t *testing.T) { + files := []*zip.File{ + {FileHeader: zip.FileHeader{Name: "a.txt"}}, + {FileHeader: zip.FileHeader{Name: "dir/b.txt"}}, + {FileHeader: zip.FileHeader{Name: "a.txt"}}, + {FileHeader: zip.FileHeader{Name: "dir/b.txt"}}, + {FileHeader: zip.FileHeader{Name: "dir/c.txt"}}, + } + if got, want := DuplicatePaths(files), []string{"a.txt", "dir/b.txt"}; !reflect.DeepEqual(got, want) { + t.Fatalf("DuplicatePaths() = %#v, want %#v", got, want) + } +} + +func TestDuplicatePathsNone(t *testing.T) { + files := []*zip.File{{FileHeader: zip.FileHeader{Name: "a.txt"}}, {FileHeader: zip.FileHeader{Name: "dir/b.txt"}}} + if got := DuplicatePaths(files); got != nil { + t.Fatalf("expected no duplicates, got %#v", got) + } +} + func zipSum(t *testing.T, files []File) string { t.Helper() var buf bytes.Buffer diff --git a/docs-site/public/llm/contracts.md b/docs-site/public/llm/contracts.md index e0274a15..5673df1b 100644 --- a/docs-site/public/llm/contracts.md +++ b/docs-site/public/llm/contracts.md @@ -5,10 +5,13 @@ Stable OSS contracts include: - **PackSpec v1**: Unified portable artifact envelope for run, job, and call evidence with Ed25519 signatures and SHA-256 manifest. Schema: `schemas/v1/pack/manifest.schema.json`. - includes first-class export surfaces: `gait pack export --otel-out ...` and `--postgres-sql-out ...` for observability and metadata indexing. - `gait pack verify` remains offline-first, but a supplied verify key that produces `signature_status=failed` is a verification failure, not a soft pass. + - duplicate ZIP entry names are verification failures, even if one duplicate would otherwise hash-match. - **ContextSpec v1**: Deterministic context evidence envelopes with privacy-aware modes and fail-closed enforcement. Required context-proof checks are satisfied through a verified `--context-envelope` input on `gait gate eval`, `gait mcp proxy`, or `gait mcp serve`, rather than raw intent claims. - **Primitive Contract**: Four deterministic primitives — capture, enforce, regress, diagnose. - **CLI Meta Contract**: `gait --help` is text-only and exits `0`; machine-readable version discovery uses `gait version --json` or the `--version` / `-v` aliases. - **Python SDK Demo Contract**: machine-readable SDK/demo capture consumes `gait demo --json` output only; the human text form is non-contractual. + - `run_session(...)` delegates digest-bearing runpack fields to `gait run record` in Go rather than hashing them in Python. + - unsupported `set` values and other non-JSON payloads are rejected deterministically. - **Doctor Install Contract**: `gait doctor --json` is truthful for a clean writable binary-install lane, returning `status=pass|warn` there and only surfacing repo-only checks from a Gait repo checkout. - **Repo Policy Contract**: `gait init` writes `.gait.yaml` and returns `detected_signals`, `generated_rules`, and `unknown_signals`; `gait check` reports the live contract with `default_verdict`, `rule_count`, structured `findings`, compatibility `gap_warnings`, and install-safe `next_commands`. - **Draft Proposal Migration Contract**: keep the shipped policy DSL (`schema_id`, `schema_version`, `default_verdict`, optional `fail_closed`, optional `mcp_trust`, `rules`); proposal keys like `version`, `name`, `boundaries`, `defaults`, `trust_sources`, and `unknown_server` return deterministic migration guidance instead of enabling a second DSL. @@ -17,6 +20,7 @@ Stable OSS contracts include: - **MCP Trust + Trace Onboarding**: local MCP trust snapshots and observe-only `gait trace` are additive onboarding contracts over the same signed trace and policy surfaces. - `mcp_trust.snapshot` must point at a local file; scanners and registries remain complementary inputs. - `gait mcp verify --json` reports `trust_model=local_snapshot` and `snapshot_path` when MCP trust is configured. + - duplicate normalized MCP identities invalidate the snapshot, and required high-risk trust checks fail closed. - wrapper JSON reports `boundary_contract=explicit_trace_reference`, `trace_reference_required=true`, and stable `failure_reason` values such as `missing_trace_reference` and `invalid_trace_artifact`. - **Script Governance Contract**: Script intent steps, deterministic `script_hash`, Wrkr-derived context matching fields, and signed approved-script registry entries. Fast-path allow requires a verify key; missing verification prerequisites disable fast-path in standard low-risk mode and fail closed in high-risk / `oss-prod` paths. - **Delegation Contract**: delegated execution is only authoritative when each claimed delegation hop is backed by signed token evidence; multi-hop chains must stay contiguous and terminate at the requester identity, and policy-required delegation scope must come from the token's signed `scope` or signed `scope_class`. diff --git a/docs-site/public/llm/quickstart.md b/docs-site/public/llm/quickstart.md index 432526f3..ff349afc 100644 --- a/docs-site/public/llm/quickstart.md +++ b/docs-site/public/llm/quickstart.md @@ -44,6 +44,8 @@ verify=ok For SDKs and wrappers, prefer the JSON form and treat the text form as human-facing output only. +`run_session(...)` and other Python run-capture helpers delegate digest-bearing artifact fields to `gait run record` in Go. Convert `set` values to JSON lists before calling the SDK; unsupported non-JSON payloads are rejected. + For binary discovery and install automation, use `gait version --json` (or `gait --version --json` / `gait -v --json`). `gait --help` is text-only and exits `0`. Context-required policies must pass `--context-envelope ` on `gait gate eval`, `gait mcp proxy`, or `gait mcp serve`; raw intent context claims are not authoritative by themselves. @@ -95,6 +97,8 @@ For MCP server admission, keep trust inputs local: gait mcp verify --policy ./examples/integrations/mcp_trust/policy.yaml --server ./examples/integrations/mcp_trust/server_github.json --json ``` +Duplicate normalized `server_id` / `server_name` entries invalidate the trust snapshot and fail closed on required high-risk checks. + For emergency preemption drills: ```bash diff --git a/docs-site/public/llm/security.md b/docs-site/public/llm/security.md index 6e5eec07..54406805 100644 --- a/docs-site/public/llm/security.md +++ b/docs-site/public/llm/security.md @@ -5,6 +5,7 @@ - Structured intent model for policy decisions (not free-form prompt filtering). - Destructive paths support phase-aware plan/apply boundaries plus fail-closed destructive budgets. - Deterministic and offline verification for all artifact types (runpacks, jobpacks, callpacks). +- Duplicate ZIP entry names fail verification rather than falling back to ambiguous first/last-wins behavior. - Ed25519 signatures and SHA-256 manifest integrity in PackSpec v1. - Signed traces and explicit reason codes for blocked actions. - Approval tokens can carry bounded destructive scope (`max_targets`, `max_ops`); overruns fail closed. @@ -14,6 +15,7 @@ - Durable jobs with deterministic stop reasons and checkpoint integrity. - No hosted service dependency required for core operation. - MCP trust inputs remain local-file based and complementary to external scanners or registries; Gait enforces, it does not become the scanner. +- Duplicate normalized MCP trust identities invalidate the local snapshot and required high-risk trust paths fail closed. Operational references: diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index 2fbde96c..24db818f 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -60,6 +60,7 @@ - Equal-priority rule overlaps resolve to the most restrictive verdict for that priority tier, not by rule name ordering. - `gait --help` is text-only and exits `0`; machine consumers should use `gait version --json`, `gait --version --json`, or `gait -v --json`. - Machine-readable wrappers and SDKs use `gait demo --json`; the text form is human-facing only. +- Python run-session capture delegates digest-bearing artifact fields to `gait run record` in Go; convert `set` values to JSON lists before calling the SDK. - `examples/integrations/claude_code/` is a reference adapter; hook/runtime/input errors fail closed by default and `GAIT_CLAUDE_UNSAFE_FAIL_OPEN=1` is an unsafe opt-in override. - `gait doctor --json` is install-safe in a clean writable directory: it returns the installed-binary lane with `status=pass|warn` and only adds repo-only checks in a Gait repo checkout. - High-risk enforcement is not production-ready until `gait doctor --production-readiness --json` returns `ok=true`, typically from config seeded by `examples/config/oss_prod_template.yaml` from a repo checkout or by fetching that same file after a binary-only install. @@ -69,6 +70,8 @@ - CLI migration: use `gait mcp verify`, not `gait mcp-verify`, and `gait capture --out ...`, not `gait capture --save-as ...`. - Wrapper JSON contract: `gait test`, `gait enforce`, and `gait trace` require `trace_path=` from the child integration and expose `boundary_contract=explicit_trace_reference`, `trace_reference_required=true`, and stable `failure_reason` values for missing or invalid seams. - MCP trust JSON contract: `gait mcp verify --json` reports `trust_model=local_snapshot` and `snapshot_path`; trust evaluation stays offline-first and file-based. +- Duplicate ZIP entry names in packs or runpacks are verification failures. +- Duplicate normalized MCP trust identities invalidate the snapshot and required high-risk trust checks fail closed. - Offline verification for packs, runpacks, and traces. - Structured intent model for policy decisions, not free-form prompt filtering. - SayToken capability tokens for voice agent commitment gating. diff --git a/docs/contracts/pack_producer_kit.md b/docs/contracts/pack_producer_kit.md index 424a4e7e..c62db7e9 100644 --- a/docs/contracts/pack_producer_kit.md +++ b/docs/contracts/pack_producer_kit.md @@ -52,6 +52,7 @@ Producers MUST: - use stable file modes - hash bytes exactly as written into archive - compute `pack_id` from canonicalized manifest with empty `pack_id` and no signatures +- avoid duplicate ZIP entry names; `gait pack verify` treats duplicate names as verification failure ## Required Files (`pack_type=run`) @@ -67,4 +68,6 @@ If a producer emits the required schema and hash contract, Gait consumers verify - `gait pack inspect` - `gait pack diff` +Duplicate entry names are not a valid interop variant; consumers fail closed on ambiguous archives. + This enables artifact-format adoption without runtime lock-in. diff --git a/docs/contracts/primitive_contract.md b/docs/contracts/primitive_contract.md index e70d67f7..9c7eada9 100644 --- a/docs/contracts/primitive_contract.md +++ b/docs/contracts/primitive_contract.md @@ -251,11 +251,13 @@ Producer obligations: - MUST generate byte-stable artifacts for identical inputs. - MUST use RFC 8785 (JCS) canonicalization for digest-bearing JSON. - MUST record `capture_mode` (`reference` default, `raw` explicit). +- SDK or wrapper helper inputs MAY omit digest-bearing fields only when a Go-authoritative normalization path computes them before artifact emission. Consumer obligations: - MUST verify manifest and file digests before trust. - MUST treat missing required files or digest mismatches as verification failure. +- MUST treat duplicate ZIP entry names in artifact archives as verification failure. - MUST treat `context_evidence_mode=required` with missing `context_set_digest` as invalid input. ## PackSpec (`gait.pack.*`, `1.0.0`) @@ -287,6 +289,7 @@ Consumer obligations: - MUST verify declared file hashes and reject undeclared files. - MUST treat schema mismatch and hash mismatch as verification failure. +- MUST treat duplicate ZIP entry names as verification failure even when one duplicate would otherwise satisfy the declared hash. - SHOULD support legacy runpack/evidence verification during v2.4 compatibility window. - SHOULD expose deterministic context drift summary in diff outputs when context data exists. @@ -296,6 +299,7 @@ Consumer obligations: - `--context-envelope ` - `--context-evidence-mode best_effort|required` - `--unsafe-context-raw` + - additive normalization helpers may be used by thin SDK/wrapper lanes so Go computes digest-bearing record fields before runpack emission - `gait regress run`: - `--context-conformance` - `--allow-context-runtime-drift` diff --git a/docs/external_tool_registry_policy.md b/docs/external_tool_registry_policy.md index 52b4da7b..225a46b8 100644 --- a/docs/external_tool_registry_policy.md +++ b/docs/external_tool_registry_policy.md @@ -19,6 +19,12 @@ Use this when an external source such as Snyk or an internal registry produces t 3. Preflight with `gait mcp verify`. 4. Enforce the same trust policy through `gait mcp proxy` or `gait mcp serve`. +Snapshot rule: + +- normalized MCP server identities must be unique across `server_id` / `server_name` +- duplicate normalized identities invalidate the snapshot +- required high-risk trust paths fail closed when the snapshot is invalid + `gait mcp verify --json` reports that contract explicitly with `trust_model=local_snapshot` and `snapshot_path=`. The evaluator does not fetch hosted registry data at decision time. Example policy contract: diff --git a/docs/integration_checklist.md b/docs/integration_checklist.md index 3a24c515..9931af00 100644 --- a/docs/integration_checklist.md +++ b/docs/integration_checklist.md @@ -70,6 +70,8 @@ Reference: `docs/agent_integration_boundary.md`. - If you use `gait test`, `gait enforce`, or `gait trace`, the child integration must emit `trace_path=`; wrapper JSON exposes `boundary_contract=explicit_trace_reference` and stable `failure_reason` values when that seam is missing or invalid. - Context-required policies: pass `--context-envelope ` from the local capture boundary; on `gait mcp serve`, either pin that envelope at server startup or explicitly enable same-host request paths before accepting `call.context.context_envelope_path`. Raw intent context fields are not authoritative by themselves. - SDK or CI automation: use `gait demo --json` for machine-readable smoke checks and handoff metadata. +- Python run-session capture should pass raw normalization data to `gait run record` and let Go compute digest-bearing artifact fields; do not hash portable artifact digests in the wrapper. +- MCP trust snapshots must use unique normalized `server_id` / `server_name` identities; duplicates are invalid and high-risk trust checks fail closed. - CI regression loop: persist the trace or runpack from that same boundary, then wire `gait regress bootstrap --from ... --json --junit ...`. ## Core Track (First Integration, Required) @@ -83,6 +85,7 @@ Run these first. Stop if expected output is missing. 2. Verify artifact: - `gait verify run_demo --json` - expect `ok=true` +- duplicate ZIP entry names in runpacks or packs are verification failures, not ambiguous soft passes 3. Policy decision shape: - `gait gate eval ... --json` - expect deterministic `verdict`, `reason_codes`, `intent_digest`, `policy_digest`, `trace_path` diff --git a/docs/sdk/python.md b/docs/sdk/python.md index 002f5bd6..fe94c317 100644 --- a/docs/sdk/python.md +++ b/docs/sdk/python.md @@ -38,6 +38,8 @@ The SDK executes commands via `subprocess.run(...)` with a bounded timeout. - JSON-decoding is strict for command responses expected to be JSON - demo capture consumes machine-readable `gait demo --json` output only - non-zero exits raise `GaitCommandError` with command, exit code, stdout, and stderr +- `run_session(...)` delegates digest-bearing runpack fields to `gait run record`; Go computes or validates `args_digest`, `result_digest`, and trace receipt digests before artifact emission +- unsupported non-JSON values such as Python `set` are rejected deterministically; convert them to stable JSON types before calling the SDK ## Migration Note @@ -116,6 +118,10 @@ Python 3.11 or higher. Use the `@gate_tool` decorator from the SDK. It automatically evaluates gate policy before executing the tool and records the result. +### How does `run_session(...)` keep digests deterministic? + +`run_session(...)` records raw normalization payloads and lets `gait run record` compute authoritative digests in Go/JCS. The SDK no longer synthesizes portable artifact digests locally. + ### How does the official LangChain integration work? The official surface is `GaitLangChainMiddleware`, and enforcement only happens in `wrap_tool_call`. diff --git a/sdk/python/gait/client.py b/sdk/python/gait/client.py index fedb00dc..e4130914 100644 --- a/sdk/python/gait/client.py +++ b/sdk/python/gait/client.py @@ -97,7 +97,9 @@ def evaluate_gate( ) -> GateEvalResult: with tempfile.TemporaryDirectory(prefix="gait-intent-") as tmp_dir: intent_path = Path(tmp_dir) / "intent.json" - intent_path.write_text(json.dumps(intent.to_dict(), indent=2) + "\n", encoding="utf-8") + intent_path.write_text( + json.dumps(_json_ready(intent.to_dict()), indent=2) + "\n", encoding="utf-8" + ) command = _command_prefix(gait_bin) + [ "gate", @@ -265,7 +267,9 @@ def record_runpack( with tempfile.TemporaryDirectory(prefix="gait-run-record-") as tmp_dir: input_path = Path(tmp_dir) / "run_record.json" - input_path.write_text(json.dumps(dict(record_input), indent=2) + "\n", encoding="utf-8") + input_path.write_text( + json.dumps(_json_ready(dict(record_input)), indent=2) + "\n", encoding="utf-8" + ) command = _command_prefix(gait_bin) + [ "run", @@ -379,3 +383,25 @@ def _command_prefix(gait_bin: str | Sequence[str]) -> list[str]: if isinstance(gait_bin, str): return [gait_bin] return [str(part) for part in gait_bin] + + +def _json_ready(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _json_ready(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_ready(item) for item in value] + if isinstance(value, set): + raise TypeError("set values are not supported in Gait JSON payloads; convert to a list") + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, datetime): + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + if hasattr(value, "model_dump") and callable(value.model_dump): + return _json_ready(value.model_dump()) + if hasattr(value, "dict") and callable(value.dict): + return _json_ready(value.dict()) + if hasattr(value, "to_dict") and callable(value.to_dict): + return _json_ready(value.to_dict()) + raise TypeError(f"unsupported JSON value in Gait payloads: {type(value).__name__}") diff --git a/sdk/python/gait/session.py b/sdk/python/gait/session.py index 0053e1d8..32da315c 100644 --- a/sdk/python/gait/session.py +++ b/sdk/python/gait/session.py @@ -1,7 +1,5 @@ from __future__ import annotations -import hashlib -import json import platform import sys from contextvars import ContextVar, Token @@ -103,6 +101,8 @@ def __init__( self._context_set_digest: str | None = None self._context_evidence_mode: str | None = context_evidence_mode self._context_refs: list[str] = [] + self._normalization_intent_args: dict[str, Any] = {} + self._normalization_result_payloads: dict[str, Any] = {} def __enter__(self) -> "RunSession": self._token = _ACTIVE_RUN_SESSION.set(self) @@ -144,10 +144,9 @@ def record_attempt( self._attempt_count += 1 intent_id = f"intent_{self._attempt_count:04d}" created_at = _utc_now() - - args_digest = intent.args_digest or _sha256_json(intent.args) - intent_payload = intent.to_dict() - intent_digest = intent.intent_digest or _sha256_json(intent_payload) + normalized_args = _json_ready(intent.args) + args_digest = intent.args_digest + intent_digest = decision.intent_digest or intent.intent_digest if intent.context.context_set_digest and not self._context_set_digest: self._context_set_digest = str(intent.context.context_set_digest) if intent.context.context_evidence_mode and not self._context_evidence_mode: @@ -165,10 +164,12 @@ def record_attempt( "run_id": self.run_id, "intent_id": intent_id, "tool_name": intent.tool_name, - "args_digest": args_digest, } + if args_digest: + intent_record["args_digest"] = args_digest if self.capture_mode == "raw" and self.include_raw_payload: - intent_record["args"] = _json_compatible(intent.args) + intent_record["args"] = normalized_args + self._normalization_intent_args[intent_id] = normalized_args self._intents.append(intent_record) verdict = decision.verdict or "unknown" @@ -182,11 +183,13 @@ def record_attempt( "policy_digest": decision.policy_digest, "intent_digest": decision.intent_digest or intent_digest, } + if intent_digest: + result_payload["intent_digest"] = intent_digest if error is not None: result_payload["error"] = str(error) if executed and result is not None: - result_payload["result"] = _json_compatible(result) - result_digest = _sha256_json(result_payload) + result_payload["result"] = _json_ready(result) + normalized_result_payload = _json_ready(result_payload) result_record: dict[str, Any] = { "schema_id": "gait.runpack.result", @@ -196,10 +199,10 @@ def record_attempt( "run_id": self.run_id, "intent_id": intent_id, "status": status, - "result_digest": result_digest, } if self.capture_mode == "raw" and self.include_raw_payload: - result_record["result"] = _json_compatible(result_payload) + result_record["result"] = normalized_result_payload + self._normalization_result_payloads[intent_id] = normalized_result_payload self._results.append(result_record) trace_ref = decision.trace_id or intent_id @@ -208,10 +211,6 @@ def record_attempt( "ref_id": f"trace_{intent_id}", "source_type": "gait.trace", "source_locator": source_locator, - "query_digest": _sha256_json( - {"tool_name": intent.tool_name, "args_digest": args_digest} - ), - "content_digest": result_digest, "retrieved_at": _isoformat(created_at), "redaction_mode": self.capture_mode, "retrieval_params": { @@ -276,6 +275,14 @@ def finalize(self) -> RunRecordCapture: record_input["refs"]["context_evidence_mode"] = self._context_evidence_mode if self._context_refs: record_input["refs"]["context_ref_count"] = len(self._context_refs) + if self._normalization_intent_args or self._normalization_result_payloads: + record_input["normalization"] = {} + if self._normalization_intent_args: + record_input["normalization"]["intent_args"] = dict(self._normalization_intent_args) + if self._normalization_result_payloads: + record_input["normalization"]["result_payloads"] = dict( + self._normalization_result_payloads + ) self._record_input = record_input self._capture = record_runpack( record_input=record_input, @@ -319,25 +326,23 @@ def _isoformat(value: datetime) -> str: return value.astimezone(UTC).isoformat().replace("+00:00", "Z") -def _sha256_json(value: Any) -> str: - canonical = _canonical_json_bytes(value) - return hashlib.sha256(canonical).hexdigest() - - -def _canonical_json_bytes(value: Any) -> bytes: - text = json.dumps(_json_compatible(value), sort_keys=True, separators=(",", ":")) - return text.encode("utf-8") - - -def _json_compatible(value: Any) -> Any: +def _json_ready(value: Any) -> Any: if isinstance(value, Mapping): - return {str(key): _json_compatible(item) for key, item in value.items()} - if isinstance(value, (list, tuple, set)): - return [_json_compatible(item) for item in value] + return {str(key): _json_ready(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_ready(item) for item in value] + if isinstance(value, set): + raise TypeError("set values are not supported in run session payloads; convert to a list") if isinstance(value, (str, int, float, bool)) or value is None: return value if isinstance(value, Path): return str(value) if isinstance(value, datetime): return _isoformat(value) - return str(value) + if hasattr(value, "model_dump") and callable(value.model_dump): + return _json_ready(value.model_dump()) + if hasattr(value, "dict") and callable(value.dict): + return _json_ready(value.dict()) + if hasattr(value, "to_dict") and callable(value.to_dict): + return _json_ready(value.to_dict()) + raise TypeError(f"unsupported JSON value in run session payloads: {type(value).__name__}") diff --git a/sdk/python/tests/test_session.py b/sdk/python/tests/test_session.py index 94a8d3f6..af59159e 100644 --- a/sdk/python/tests/test_session.py +++ b/sdk/python/tests/test_session.py @@ -64,6 +64,12 @@ def test_run_session_captures_attempts_and_emits_runpack( assert len(record_input["intents"]) == 2 assert len(record_input["results"]) == 2 assert record_input["capture_mode"] == "reference" + assert "args_digest" not in record_input["intents"][0] + assert "result_digest" not in record_input["results"][0] + assert "query_digest" not in record_input["refs"]["receipts"][0] + assert "content_digest" not in record_input["refs"]["receipts"][0] + assert record_input["normalization"]["intent_args"]["intent_0001"]["path"] == "/tmp/ok.txt" + assert record_input["normalization"]["result_payloads"]["intent_0001"]["executed"] is True def test_run_session_records_executor_errors( @@ -101,3 +107,35 @@ def test_run_session_records_executor_errors( assert session.attempts[0].status == "error" record_input = json.loads(capture_input_path.read_text(encoding="utf-8")) assert record_input["results"][0]["status"] == "error" + assert "result_digest" not in record_input["results"][0] + assert record_input["normalization"]["result_payloads"]["intent_0001"]["error"] == "boom" + + +def test_run_session_rejects_set_payloads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fake_gait = tmp_path / "fake_gait.py" + create_fake_gait_script(fake_gait) + capture_input_path = tmp_path / "record_input_set.json" + monkeypatch.setenv("FAKE_GAIT_RECORD_CAPTURE", str(capture_input_path)) + + adapter = ToolAdapter( + policy_path=tmp_path / "policy.yaml", + gait_bin=[sys.executable, str(fake_gait)], + ) + intent = capture_intent( + tool_name="tool.allow", + args={"tags": {"alpha", "beta"}}, + context=IntentContext(identity="alice", workspace="/repo/gait", risk_class="high"), + ) + + with run_session( + run_id="run_sdk_set_error", + gait_bin=[sys.executable, str(fake_gait)], + cwd=tmp_path, + out_dir=tmp_path / "gait-out", + ): + with pytest.raises(TypeError, match="set values are not supported"): + adapter.execute( + intent=intent, + executor=lambda _: {"status": "ok"}, + cwd=tmp_path, + ) From 03513b2f14646073e5c4421320a616b98f086aa4 Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Wed, 18 Mar 2026 18:17:39 -0400 Subject: [PATCH 2/2] fix: address actionable PR comments (loop 1) --- cmd/gait/run_record_normalization_test.go | 88 +++++++++++++++++++++++ core/runpack/record.go | 4 ++ core/runpack/record_test.go | 47 ++++++++++++ 3 files changed, 139 insertions(+) diff --git a/cmd/gait/run_record_normalization_test.go b/cmd/gait/run_record_normalization_test.go index b07e1926..a8a63b51 100644 --- a/cmd/gait/run_record_normalization_test.go +++ b/cmd/gait/run_record_normalization_test.go @@ -177,3 +177,91 @@ func TestRunRecordRejectsDigestMismatchAgainstNormalization(t *testing.T) { t.Fatalf("unexpected mismatch error: %#v", output) } } + +func TestRunRecordRejectsReceiptDigestMismatchAgainstNormalization(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + inputPath := filepath.Join(workDir, "run_record_bad_receipt.json") + + payload := map[string]any{ + "run": map[string]any{ + "schema_id": "gait.runpack.run", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad_receipt", + "env": map[string]any{"os": "darwin", "arch": "arm64", "runtime": "python3.11"}, + "timeline": []map[string]any{{"event": "run_started", "ts": time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)}}, + }, + "intents": []map[string]any{{ + "schema_id": "gait.runpack.intent", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 1, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad_receipt", + "intent_id": "intent_0001", + "tool_name": "tool.allow", + }}, + "results": []map[string]any{{ + "schema_id": "gait.runpack.result", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 1, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad_receipt", + "intent_id": "intent_0001", + "status": "ok", + }}, + "refs": map[string]any{ + "schema_id": "gait.runpack.refs", + "schema_version": "1.0.0", + "created_at": time.Date(2026, time.March, 18, 0, 0, 2, 0, time.UTC).Format(time.RFC3339), + "producer_version": "0.0.0-test", + "run_id": "run_record_bad_receipt", + "receipts": []map[string]any{{ + "ref_id": "trace_intent_0001", + "source_type": "gait.trace", + "source_locator": "trace://trace_intent_0001", + "query_digest": strings.Repeat("a", 64), + "retrieved_at": time.Date(2026, time.March, 18, 0, 0, 2, 0, time.UTC).Format(time.RFC3339), + "redaction_mode": "reference", + }}, + }, + "capture_mode": "reference", + "normalization": map[string]any{ + "intent_args": map[string]any{ + "intent_0001": map[string]any{"path": "/tmp/out.txt"}, + }, + "result_payloads": map[string]any{ + "intent_0001": map[string]any{ + "executed": true, + "verdict": "allow", + "reason_codes": []string{"default_allow"}, + "trace_id": "trace_1", + "trace_path": "trace.json", + "policy_digest": strings.Repeat("3", 64), + "intent_digest": strings.Repeat("2", 64), + }, + }, + }, + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + mustWriteFile(t, inputPath, string(encoded)+"\n") + + var code int + raw := captureStdout(t, func() { + code = runRecord([]string{"--input", inputPath, "--out-dir", filepath.Join(workDir, "gait-out"), "--json"}) + }) + if code != exitInvalidInput { + t.Fatalf("runRecord mismatch expected %d got %d raw=%s", exitInvalidInput, code, raw) + } + var output runRecordOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode output: %v raw=%s", err, raw) + } + if !strings.Contains(output.Error, "receipt trace_intent_0001 query digest mismatch") { + t.Fatalf("unexpected mismatch error: %#v", output) + } +} diff --git a/core/runpack/record.go b/core/runpack/record.go index f050599e..8f710547 100644 --- a/core/runpack/record.go +++ b/core/runpack/record.go @@ -430,6 +430,8 @@ func normalizeReceiptDigests(receipt *schemarunpack.RefReceipt, intents []schema } if receipt.QueryDigest == "" { receipt.QueryDigest = expectedQueryDigest + } else if !equalHex(receipt.QueryDigest, expectedQueryDigest) { + return fmt.Errorf("receipt %s query digest mismatch", receipt.RefID) } resultIndex, ok := resultIndexByIntentID[intentID] @@ -442,6 +444,8 @@ func normalizeReceiptDigests(receipt *schemarunpack.RefReceipt, intents []schema } if receipt.ContentDigest == "" { receipt.ContentDigest = expectedContentDigest + } else if !equalHex(receipt.ContentDigest, expectedContentDigest) { + return fmt.Errorf("receipt %s content digest mismatch", receipt.RefID) } return nil } diff --git a/core/runpack/record_test.go b/core/runpack/record_test.go index cd760106..7d62ea7b 100644 --- a/core/runpack/record_test.go +++ b/core/runpack/record_test.go @@ -440,6 +440,53 @@ func TestRecordRunRejectsDigestMismatchAgainstNormalization(test *testing.T) { } } +func TestRecordRunRejectsReceiptDigestMismatchAgainstNormalization(test *testing.T) { + run := schemarunpack.Run{ + RunID: "run_receipt_mismatch", + Env: schemarunpack.RunEnv{OS: "linux", Arch: "amd64", Runtime: "go"}, + Timeline: []schemarunpack.TimelineEvt{ + {Event: "start", TS: time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC)}, + }, + } + intents := []schemarunpack.IntentRecord{{ + IntentID: "intent_1", + ToolName: "tool.write", + }} + results := []schemarunpack.ResultRecord{{ + IntentID: "intent_1", + Status: "ok", + }} + refs := schemarunpack.Refs{ + Receipts: []schemarunpack.RefReceipt{{ + RefID: "trace_intent_1", + SourceType: "gait.trace", + SourceLocator: "trace://trace_intent_1", + QueryDigest: strings.Repeat("a", 64), + ContentDigest: strings.Repeat("b", 64), + RetrievedAt: time.Date(2026, time.February, 5, 0, 0, 1, 0, time.UTC), + RedactionMode: "reference", + }}, + } + if _, err := RecordRun(RecordOptions{ + Run: run, + Intents: intents, + Results: results, + Refs: refs, + Normalization: DigestNormalizationOptions{ + IntentArgs: map[string]json.RawMessage{ + "intent_1": json.RawMessage(`{"path":"/tmp/out.txt"}`), + }, + ResultPayloads: map[string]json.RawMessage{ + "intent_1": json.RawMessage(`{"executed":true,"verdict":"allow","reason_codes":["default_allow"]}`), + }, + }, + }); err == nil { + test.Fatalf("expected receipt digest mismatch error") + } else if !strings.Contains(err.Error(), "receipt trace_intent_1 query digest mismatch") { + test.Fatalf("unexpected mismatch error: %v", err) + } +} + func TestWriteRunpack(test *testing.T) { run := schemarunpack.Run{ RunID: "run_write",