| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/evidence" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | func intPtr(v int) *int { return &v } |
| 18 | |
| 19 | func TestHostReceiptsAttestChangesAndVerifications(t *testing.T) { |
| 20 | summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 21 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{`src\parser.go`}}, |
| 22 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"parser_test.go"}}, |
| 23 | {ToolName: "bash", Success: true, Command: "go test ./parser", ExitCode: intPtr(0), Verification: evidence.VerificationPassed}, |
| 24 | {ToolName: "bash", Success: true, Command: "ls -la", ExitCode: intPtr(0), Verification: evidence.VerificationNotVerification}, |
| 25 | }} |
| 26 | |
| 27 | got := formatHostReceipts(summary, WritePathSet{}) |
| 28 | for _, want := range []string{"changed: parser_test.go, src/parser.go", "go test ./parser (verification passed, exit 0)"} { |
| 29 | if !strings.Contains(got, want) { |
| 30 | t.Fatalf("receipts block %q missing %q", got, want) |
| 31 | } |
| 32 | } |
| 33 | // A plain read command is not a claim the parent must adjudicate, so it |
| 34 | // never spends parent context. |
| 35 | if strings.Contains(got, "ls -la") { |
| 36 | t.Fatalf("receipts block must not list non-verification commands: %q", got) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestHostReceiptsStaySilentForReadOnlyChildren(t *testing.T) { |
| 41 | summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 42 | {ToolName: "read_file", Success: true, Read: true, Paths: []string{"parser.go"}}, |
| 43 | {ToolName: "grep", Success: true, Read: true}, |
| 44 | }} |
| 45 | if got := formatHostReceipts(summary, WritePathSet{}); got != "" { |
| 46 | t.Fatalf("read-only child produced a receipts block: %q", got) |
| 47 | } |
| 48 | if got := appendHostReceipts("just prose", summary, WritePathSet{}); got != "just prose" { |
| 49 | t.Fatalf("answer = %q, want it unchanged", got) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestHostReceiptsRecordFailedCommands(t *testing.T) { |
| 54 | summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 55 | {ToolName: "bash", Success: true, Command: "go build ./...", ExitCode: intPtr(2)}, |
| 56 | {ToolName: "bash", Success: true, Command: "go test ./parser", ExitCode: intPtr(1), Verification: evidence.VerificationFailed}, |
| 57 | }} |
| 58 | got := formatHostReceipts(summary, WritePathSet{}) |
| 59 | for _, want := range []string{"go build ./... (exit 2)", "go test ./parser (verification failed, exit 1)"} { |
| 60 | if !strings.Contains(got, want) { |
| 61 | t.Fatalf("receipts block %q missing %q", got, want) |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestDecorateExecutionReceiptCarriesHostObservedOutcome(t *testing.T) { |
| 67 | rec := evidence.Receipt{ToolName: "bash", Success: true} |
| 68 | decorateExecutionReceipt(&rec, " out ", &tool.ShellExecution{ |
| 69 | ExitCode: tool.IntPtr(3), |
| 70 | Verification: tool.ShellVerificationFailed, |
| 71 | }) |
| 72 | if rec.ExitCode == nil || *rec.ExitCode != 3 { |
| 73 | t.Fatalf("ExitCode = %v, want 3", rec.ExitCode) |
| 74 | } |
| 75 | if rec.Verification != evidence.VerificationFailed { |
| 76 | t.Fatalf("Verification = %q, want %q", rec.Verification, evidence.VerificationFailed) |
| 77 | } |
| 78 | if rec.OutputBytes != len("out") { |
| 79 | t.Fatalf("OutputBytes = %d, want %d", rec.OutputBytes, len("out")) |
| 80 | } |
| 81 | // A tool that ran no process must not gain a fabricated exit status. |
| 82 | plain := evidence.Receipt{ToolName: "read_file", Success: true} |
| 83 | decorateExecutionReceipt(&plain, "body", nil) |
| 84 | if plain.ExitCode != nil { |
| 85 | t.Fatalf("non-shell receipt gained ExitCode %v", plain.ExitCode) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | type fakeWriteFileTool struct{} |
| 90 | |
| 91 | func (fakeWriteFileTool) Name() string { return "write_file" } |
| 92 | func (fakeWriteFileTool) Description() string { return "Write a file." } |
| 93 | func (fakeWriteFileTool) Schema() json.RawMessage { |
| 94 | return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`) |
| 95 | } |
| 96 | func (fakeWriteFileTool) ReadOnly() bool { return false } |
| 97 | func (fakeWriteFileTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 98 | return "written", nil |
| 99 | } |
| 100 | |
| 101 | // The parent must learn what the child actually changed even when the child's |
| 102 | // own prose says nothing about it. |
| 103 | func TestSubAgentAnswerCarriesHostReceipts(t *testing.T) { |
| 104 | reg := tool.NewRegistry() |
| 105 | reg.Add(fakeWriteFileTool{}) |
| 106 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 107 | {toolCallChunk("1", "write_file", `{"path":"parser.go"}`), {Type: provider.ChunkDone}}, |
| 108 | {{Type: provider.ChunkText, Text: "all done"}, {Type: provider.ChunkDone}}, |
| 109 | }} |
| 110 | |
| 111 | answer, err := RunSubAgentWithSession(withNoClosedLoop(context.Background()), prov, reg, NewSession("sys"), |
| 112 | "fix the parser", Options{}, event.Discard) |
| 113 | if err != nil { |
| 114 | t.Fatalf("RunSubAgentWithSession: %v", err) |
| 115 | } |
| 116 | if !strings.Contains(answer, "all done") { |
| 117 | t.Fatalf("answer lost the child's own summary: %q", answer) |
| 118 | } |
| 119 | if !strings.Contains(answer, hostReceiptsHeader) || !strings.Contains(answer, "parser.go") { |
| 120 | t.Fatalf("answer missing host receipts for the write it performed: %q", answer) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestSplitHostReceiptsSeparatesProseFromAttestation(t *testing.T) { |
| 125 | answer := appendHostReceipts("did the thing", |
| 126 | evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 127 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"a.go"}}, |
| 128 | }}, WritePathSet{}) |
| 129 | prose, receipts := splitHostReceipts(answer) |
| 130 | if prose != "did the thing" { |
| 131 | t.Fatalf("prose = %q", prose) |
| 132 | } |
| 133 | if !strings.HasPrefix(receipts, hostReceiptsHeader) || !strings.Contains(receipts, "a.go") { |
| 134 | t.Fatalf("receipts = %q", receipts) |
| 135 | } |
| 136 | if p, r := splitHostReceipts("plain answer"); p != "plain answer" || r != "" { |
| 137 | t.Fatalf("plain answer split to %q / %q", p, r) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // A verbose child must not be able to push the host's attestation out of a |
| 142 | // fleet aggregate by writing a long answer. |
| 143 | func TestAggregateReservesHostReceiptsAgainstLongProse(t *testing.T) { |
| 144 | answer := appendHostReceipts(strings.Repeat("chatter. ", 8000), |
| 145 | evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 146 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"payments.go"}}, |
| 147 | {ToolName: "bash", Success: true, Command: "go test ./pay", ExitCode: intPtr(1), Verification: evidence.VerificationFailed}, |
| 148 | }}, WritePathSet{}) |
| 149 | |
| 150 | out := formatBoundedSubagentAggregate("fleet:\n", []subagentAggregateItem{ |
| 151 | {header: "1. writer\n", status: "completed\n", answer: answer, ref: "sa_1"}, |
| 152 | }) |
| 153 | if !strings.Contains(out, "preview truncated") { |
| 154 | t.Fatal("expected the prose to be truncated in this fixture") |
| 155 | } |
| 156 | for _, want := range []string{hostReceiptsHeader, "payments.go", "go test ./pay (verification failed, exit 1)"} { |
| 157 | if !strings.Contains(out, want) { |
| 158 | t.Fatalf("aggregate dropped %q from the host attestation:\n%s", want, out) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // When attestations alone would starve the budget they lose detail, never the |
| 164 | // fact that a write escaped the declared claim. |
| 165 | func TestAggregateDegradesReceiptsButKeepsViolations(t *testing.T) { |
| 166 | root := t.TempDir() |
| 167 | claim, err := NormalizeWritePaths(root, []string{"auth"}) |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | items := make([]subagentAggregateItem, 0, 64) |
| 172 | for i := range 64 { |
| 173 | summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{ |
| 174 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{filepath.Join(root, "auth", strings.Repeat("deep/", 20)+"f.go")}}, |
| 175 | {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{filepath.Join(root, strings.Repeat("out/", 20)+"escaped.go")}}, |
| 176 | }} |
| 177 | items = append(items, subagentAggregateItem{ |
| 178 | header: fmt.Sprintf("%d. writer\n", i+1), |
| 179 | status: "completed\n", |
| 180 | answer: appendHostReceipts("done", summary, claim), |
| 181 | }) |
| 182 | } |
| 183 | |
| 184 | out := formatBoundedSubagentAggregate("fleet:\n", items) |
| 185 | if n := strings.Count(out, hostReceiptsViolationLabel); n != len(items) { |
| 186 | t.Fatalf("violation lines = %d, want %d — a claim escape was dropped to save space", n, len(items)) |
| 187 | } |
| 188 | if len(out) > 32*1024 { |
| 189 | t.Fatalf("aggregate = %d bytes, over the tool output budget", len(out)) |
| 190 | } |
| 191 | } |
| 192 |