diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index b8a04bae..a8781e77 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -97,6 +97,7 @@ func optionalTools() []tool.Tool { tool.OutlineTool{}, &tool.SmartReaderTool{}, tool.PatchTool{}, + tool.BatchTool{}, tool.TransactionTool{}, tool.NewAutoImportTool(), tool.ImportOrganizerTool{}, diff --git a/docs/plans/codex-adoption-plan.md b/docs/plans/codex-adoption-plan.md index 18d73075..24a76879 100644 --- a/docs/plans/codex-adoption-plan.md +++ b/docs/plans/codex-adoption-plan.md @@ -51,19 +51,26 @@ Three codex ideas are deliberately deferred as future RFCs; see `permission.sandbox_backend`, so operators can confirm real kernel-level isolation (seatbelt on macOS, landlock/seccomp on Linux, ACL on Windows, docker fallbacks) instead of only the strict/workspace/off label. +- **Batch tool** (safe core of codex Code Mode): a `Batch` tool runs a list of + read-only tool calls in a single turn, cutting agent round-trips for fan-out + research. It reuses the existing read-only allowlist and per-call schema + validation, so no mutation can bypass the normal tool pipeline. It delivers + Code Mode's primary token/round-trip benefit without embedding a script + runtime or adding a new execution authority boundary. ## Deliberately Deferred -- **Code Mode** (`code-mode`, `code-mode-runtime`, `v8-poc`): letting the model - author a short script that batches many tool calls into one sandboxed - execution. Promising token-cost lever, but it introduces an embedded JS - runtime and a new execution authority boundary. Requires its own threat - model (script capabilities, network/file scope, output trust) before any - implementation. Track as a standalone RFC. -- **Agent identity signing** (`agent-identity`): cryptographic identity for - agents and subagents woven into audit records. hawk's tamper-evident - security log covers integrity today; signed delegation chains are worth a - focused design once multi-org delegation exists. +- **Full Code Mode** (`code-mode`, `code-mode-runtime`, `v8-poc`): letting the + model author an arbitrary script that batches tool calls into one sandboxed + execution, including mutation and control flow. The `Batch` tool above covers + the safe read-only fan-out case. Arbitrary-script execution still requires an + embedded runtime, capabilities model, and output-trust threat model; track as + a standalone RFC. +- **Agent identity signing** (`agent-identity`): hawk already provides a + per-harness anonymous user identity (`internal/identity`) and a tamper-evident + HMAC-chained security log with session-scoped events (`internal/securitylog`). + Signed subagent delegation chains are worth a focused design once multi-org + delegation exists. - **Cloud tasks client** (`cloud-tasks*`): remote task queue integration. Hawk Cloud already provides sync/review surfaces; a queue protocol would duplicate that until a concrete consumer exists. diff --git a/internal/tool/batch.go b/internal/tool/batch.go new file mode 100644 index 00000000..3799b206 --- /dev/null +++ b/internal/tool/batch.go @@ -0,0 +1,94 @@ +package tool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// BatchTool executes a list of read-only tool calls in a single turn, reducing +// the round-trips the agent needs for fan-out research work (the safe core of +// codex's "code mode" batching, without an embedded script runtime). +// +// Security: only read-only tools (see IsReadOnly) are allowed. Every inner +// call is resolved through the session registry, schema-validated, and executed +// individually — no mutation can bypass the normal tool pipeline because a +// non-read-only call fails the request up front. +type BatchTool struct{} + +func (BatchTool) Name() string { return "Batch" } + +func (BatchTool) Aliases() []string { return []string{"batch"} } + +func (BatchTool) Description() string { + return "Run several read-only tool calls in one turn (fan-out research). Calls must be read-only tools." +} + +func (BatchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "calls": map[string]interface{}{ + "type": "array", + "description": "Read-only tool calls to execute in sequence", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "tool": map[string]interface{}{"type": "string", "description": "Read-only tool name (e.g. Read, Grep, Glob, LS, CodeSearch)"}, + "input": map[string]interface{}{"type": "object", "description": "Tool input object"}, + }, + "required": []string{"tool"}, + }, + }, + }, + "required": []string{"calls"}, + } +} + +type batchCall struct { + Tool string `json:"tool"` + Input json.RawMessage `json:"input"` +} + +func (BatchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { + var p struct { + Calls []batchCall `json:"calls"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("batch: %w", err) + } + if len(p.Calls) == 0 { + return "", errors.New("batch: calls is required and must not be empty") + } + + tc := GetToolContext(ctx) + if tc == nil || tc.Registry == nil { + return "", errors.New("batch: tool registry unavailable in this context") + } + + var b strings.Builder + for i, call := range p.Calls { + name := strings.TrimSpace(call.Tool) + if name == "" { + return "", fmt.Errorf("batch: call %d: tool name is required", i) + } + if !IsReadOnly(name) { + return "", fmt.Errorf("batch: call %d: %q is not read-only; batch only executes read-only tools", i, name) + } + inner, ok := tc.Registry.Get(name) + if !ok { + return "", fmt.Errorf("batch: call %d: unknown tool %q", i, name) + } + if err := ValidateToolInput(inner, call.Input); err != nil { + return "", fmt.Errorf("batch: call %d (%s): %w", i, name, err) + } + out, err := inner.Execute(ctx, call.Input) + if err != nil { + return "", fmt.Errorf("batch: call %d (%s): %w", i, name, err) + } + fmt.Fprintf(&b, "## %s\n%s\n\n", name, strings.TrimSpace(out)) + } + return strings.TrimRight(b.String(), "\n"), nil +} diff --git a/internal/tool/batch_test.go b/internal/tool/batch_test.go new file mode 100644 index 00000000..fc9ea612 --- /dev/null +++ b/internal/tool/batch_test.go @@ -0,0 +1,82 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// stubTool is a trivial read-only tool used to exercise the batch path without +// depending on the full registry of real tools. +type stubTool struct{} + +func (stubTool) Name() string { return "StubRead" } +func (stubTool) Description() string { return "test read-only tool" } +func (stubTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} +func (stubTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { return "ok", nil } + +// batchRegistry builds a registry containing a canonical read-only name that +// IsReadOnly recognizes, backed by the test stub. +func batchRegistry() *Registry { + r := NewRegistry() + _ = r.Register(stubTool{}) + // Alias the stub under the canonical read-only "Read" name so the guard + // passes in tests without importing the whole tool set. + _ = r.Register(readAlias{inner: stubTool{}}) + return r +} + +// readAlias presents stubTool under the read-only "Read" name. +type readAlias struct{ inner Tool } + +func (a readAlias) Name() string { return "Read" } +func (a readAlias) Aliases() []string { return []string{"read"} } +func (a readAlias) Description() string { return a.inner.Description() } +func (a readAlias) Parameters() map[string]interface{} { + return a.inner.Parameters() +} + +func (a readAlias) Execute(ctx context.Context, in json.RawMessage) (string, error) { + return a.inner.Execute(ctx, in) +} + +func TestBatchExecutesReadOnlyCalls(t *testing.T) { + reg := batchRegistry() + ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg}) + + var b BatchTool + out, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"Read","input":{}},{"tool":"Read","input":{}}]}`)) + if err != nil { + t.Fatalf("batch: %v", err) + } + if !strings.Contains(out, "## Read") || !strings.Contains(out, "ok") { + t.Fatalf("unexpected batch output: %q", out) + } +} + +func TestBatchRejectsNonReadOnly(t *testing.T) { + reg := batchRegistry() + ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg}) + + var b BatchTool + _, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"Write","input":{}}]}`)) + if err == nil { + t.Fatal("batch must reject a non-read-only tool") + } +} + +func TestBatchRejectsEmptyAndUnknown(t *testing.T) { + reg := batchRegistry() + ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg}) + var b BatchTool + + if _, err := b.Execute(ctx, []byte(`{"calls":[]}`)); err == nil { + t.Fatal("empty calls must error") + } + if _, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"NoSuchTool","input":{}}]}`)); err == nil { + t.Fatal("unknown tool must error") + } +}