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
- 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.