| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/extension" |
| 11 | "reasonix/internal/extension/dispatch" |
| 12 | "reasonix/internal/extension/protocol" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | type strictRecordingTool struct { |
| 18 | recordingTool |
| 19 | } |
| 20 | |
| 21 | func (s *strictRecordingTool) Schema() json.RawMessage { |
| 22 | return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}`) |
| 23 | } |
| 24 | |
| 25 | func TestInvalidArgumentsDoNotReachToolBeforeExtension(t *testing.T) { |
| 26 | client := &fakeDispatchClient{} |
| 27 | d := newExtDispatcher(client, true, nil, extension.PointToolBefore) |
| 28 | rec := &strictRecordingTool{recordingTool: recordingTool{name: "read_file", readOnly: true}} |
| 29 | reg := tool.NewRegistry() |
| 30 | reg.Add(rec) |
| 31 | a := New(nil, reg, NewSession(""), Options{Extensions: d}, event.Discard) |
| 32 | out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"unexpected":true}`}) |
| 33 | if out.errMsg == "" || !strings.Contains(out.output, "argument validation failed") { |
| 34 | t.Fatalf("outcome = %+v, want host validation failure", out) |
| 35 | } |
| 36 | if rec.execs != 0 { |
| 37 | t.Fatalf("invalid tool executed %d times", rec.execs) |
| 38 | } |
| 39 | if n := client.notifyCountFor(protocol.EventToolBefore); n != 0 { |
| 40 | t.Fatalf("tool.before sidecar calls = %d, want 0 before valid arguments", n) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestInvalidReplacementArgumentsRemainCorrectable(t *testing.T) { |
| 45 | client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) { |
| 46 | if ev == protocol.EventToolBefore { |
| 47 | return replaceWith(t, dispatch.ToolBeforePayload{Name: "read_file", Arguments: `{"arguments":{"path":"private-file"}}`}), nil |
| 48 | } |
| 49 | return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil |
| 50 | }} |
| 51 | d := newExtDispatcher(client, true, nil, extension.PointToolBefore) |
| 52 | rec := &strictRecordingTool{recordingTool: recordingTool{name: "read_file", readOnly: true}} |
| 53 | reg := tool.NewRegistry() |
| 54 | reg.Add(rec) |
| 55 | gate := &stubGate{} |
| 56 | a := New(nil, reg, NewSession(""), Options{Extensions: d, Gate: gate}, event.Discard) |
| 57 | for range 3 { |
| 58 | out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"original"}`}) |
| 59 | if out.blocked || !strings.Contains(out.output, `sole "arguments" wrapper`) { |
| 60 | t.Fatalf("replacement error: %+v", out) |
| 61 | } |
| 62 | } |
| 63 | if rec.execs != 0 || len(gate.checked) != 0 { |
| 64 | t.Fatal("invalid replacement reached execution or permission") |
| 65 | } |
| 66 | } |
| 67 |