anthropics / anthropics/claude-agent-sdk-typescript

PreToolUse options.hooks never fire for built-in tools (Read) — updatedInput unreachable; canUseTool also skipped for in-cwd reads

Aperta
#401 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
Lingua principale
Shell
Stelle
1.8k
Fork
226
Metriche di merge delle PR
Nessuna PR unita negli ultimi 30g

Descrizione

## Summary

`options.hooks` PreToolUse callbacks never fire for built-in tools (`Read`, etc.) — in the same session they fire fine for SDK-MCP tools. Because `canUseTool` is also not consulted for reads inside `cwd` (default-allowed), there is **no layer through which the embedding application can observe or rewrite a built-in tool call's input before it executes**: `PreToolUseHookSpecificOutput.updatedInput` is unreachable for built-ins.

Related but distinct from #101 (PostToolUse, built-in *server* tools): this is PreToolUse + permission callbacks for built-in *local* tools, and the consequence is that input normalization/gating advertised by the hook API can't be applied to them.

## Reproduction

Verified identical on `0.3.181` and `0.3.223` (macOS, Node 20.20.1, model `claude-sonnet-5`):

```ts
import { createSdkMcpServer, query, tool, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

const workDir = mkdtempSync(path.join(tmpdir(), "hook-repro-"));
const file = path.join(workDir, "sample.md");
writeFileSync(file, "# Sample\n\nA few lines.\n");

const ping = createSdkMcpServer({
name: "harness",
tools: [tool("ping", "Responds with pong.", { note: z.string().optional() }, async () => ({
content: [{ type: "text", text: "pong" }],
}))],
});

// Forces the failure mode some OpenAI-compatible models produce naturally:
// filler empty strings for optional params.
const PROMPT = `Tool-plumbing test. First call the ping tool once. Then call the Read tool on ${file} with the optional "pages" parameter included and set to the empty string "" exactly. Do not omit pages and do not retry. Then reply DONE.`;

async function* stream(): AsyncGenerator {
yield { type: "user", message: { role: "user", content: [{ type: "text", text: PROMPT }] }, parent_tool_use_id: null, session_id: "" };
}

const q = query({
prompt: stream(),
options: {
cwd: workDir,
model: "claude-sonnet-5",
tools: ["Read"],
allowedTools: ["mcp__harness__ping"],
permissionMode: "dontAsk",
settingSources: ["project"],
maxTurns: 6,
mcpServers: { harness: ping },
hooks: {
PreToolUse: [{ hooks: [async (input) => {
const rec = input as { tool_name?: string; tool_input?: Record };
console.log(`[hook] PreToolUse tool=${rec.tool_name}`); // fires for ping only
if (rec.tool_name !== "Read") return {};
const { pages, ...rest } = rec.tool_input ?? {};
return { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: rest } };
}] }],
},
canUseTool: async (toolName) => {
console.log(`[canUseTool] ${toolName}`); // never fires for Read
return { behavior: "allow" };
},
},
});

for await (const m of q) {
if (m.type === "user" && Array.isArray((m.message as any).content)) {
for (const b of (m.message as any).content) {
if (b?.type === "tool_result") console.log("tool_result is_error =", b.is_error, JSON.stringify(b.content).slice(0, 120));
}
}
}
```

## Observed

```
[hook] PreToolUse tool=mcp__harness__ping
tool_result is_error = undefined "pong"
tool_result is_error = true "Invalid pages parameter: \"\". Use formats like \"1-5\"..."
```

- The PreToolUse callback fires for the MCP tool, never for `Read` (catch-all matcher, no `matcher` filter).
- `[canUseTool]` never logs for `Read` (in-cwd read is default-allowed; also true with `permissionMode: "default"`).
- Command hooks supplied via project `.claude/settings.json` (with `settingSources: ["project"]`) or via `options.settings` also never execute in headless runs, including under `bypassPermissions` — so there is no fallback path either.

## Expected

PreToolUse `options.hooks` callbacks fire for built-in tools like `Read`, and `updatedInput` returned from them (or from `canUseTool` when it is consulted) is applied before the tool executes — matching the documented hook semantics and the behavior of settings-based hooks in interactive Claude Code.

## Impact

We route some traffic through OpenAI-compatible models that habitually send `""` for optional params. `Read` rejects `pages: ""` at execution time, and since no hook/permission layer sees built-in calls, the harness cannot absorb this — every affected session pays a model-visible tool error and retry. It also means `.*`-matched PreToolUse deny/security hooks silently do not cover built-in tools, which is easy to miss because the same hooks demonstrably fire for MCP tools.

Guida per i contributori

Nessuna guida per i contributori indicizzata per questo repository

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.