Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Workspace } from "./workspace.js";
import type { ApprovalMode } from "./approval.js";
import { needsApproval, promptApproval } from "./approval.js";
import { evaluateCommandPolicy, policyDenialMessage } from "./command-policy.js";
import { evaluatePreToolUseHooks, type PreToolUseHook } from "./hook-contract.js";
import { folderTrustDenialMessage } from "./folder-trust.js";
import { estimateCostUsd, lookupModelPrice, formatCostUsd } from "./cost.js";
import { buildEffectiveSystemPrompt } from "./instruction-context.js";
Expand Down Expand Up @@ -44,6 +45,10 @@ export interface AgentOptions {
// each file a mutating-file tool touches is captured before the tool runs, so
// the turn's workspace mutations can later be reversed without a Git reset.
turnImages?: TurnImageCollector;
// User-owned PreToolUse hooks (resolved from the user settings scope by the
// caller). Each matching hook runs as a deny-only gate before a tool executes;
// a hook can only block a call, never approve it. Empty/undefined ⇒ no hooks.
preToolUseHooks?: readonly PreToolUseHook[];
}

// Cumulative usage and cost reported after each round. `estimatedCostUsd` is an
Expand Down Expand Up @@ -187,6 +192,7 @@ export async function runAgent(
const tools = createTools();
const toolMap = new Map(tools.map((t) => [t.name, t]));
const schemas = toolSchemasForOpenAI(tools);
const preToolUseHooks = opts.preToolUseHooks ?? [];

const messages: SessionMessage[] = [...existingMessages];

Expand Down Expand Up @@ -403,6 +409,7 @@ export async function runAgent(
opts.workspace,
opts.mutatingAllowed ?? true,
requestApproval,
preToolUseHooks,
opts.turnImages,
);
sink.toolResult({ id: tc.id, name: tc.name, result, round });
Expand Down Expand Up @@ -442,6 +449,7 @@ async function executeToolCall(
workspace: Workspace,
mutatingAllowed: boolean,
requestApproval: (name: string, args: Record<string, unknown>) => Promise<boolean>,
preToolUseHooks: readonly PreToolUseHook[],
turnImages?: TurnImageCollector,
): Promise<ToolResult> {
const tool = toolMap.get(tc.name);
Expand Down Expand Up @@ -474,6 +482,33 @@ async function executeToolCall(
}
}

// PreToolUse hook gate (user-owned, deny-only): a matching hook may block the
// call before approval is considered; silence leaves the normal approval flow
// unchanged. A hook can only ever deny, never approve. A hook timeout or spawn
// failure fails closed (the call is blocked) before any tool side effect.
if (preToolUseHooks.length > 0) {
let hookArgs: Record<string, unknown> = {};
try {
const parsed = JSON.parse(tc.arguments);
if (parsed && typeof parsed === "object") hookArgs = parsed as Record<string, unknown>;
} catch {
/* leave hookArgs empty */
}
const decision = await evaluatePreToolUseHooks(
preToolUseHooks,
{ toolName: tc.name, toolInput: hookArgs },
{ cwd: workspace.root },
);
if (decision.denied) {
return {
content: decision.reason
? `Tool call denied by a user PreToolUse hook: ${decision.reason}`
: "Tool call denied by a user PreToolUse hook",
isError: true,
};
}
}

if (needsApproval(approvalMode, tool.category)) {
let parsed: Record<string, unknown> = {};
try {
Expand Down
12 changes: 11 additions & 1 deletion src/effective-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const REGISTERED_SETTINGS_KEYS: readonly string[] = [
"approval",
"ui",
"workflows",
"hooks",
"diagnostics",
"probeTimeoutMs",
];
Expand All @@ -69,14 +70,23 @@ const OBJECT_SECTIONS = new Set([
"approval",
"ui",
"workflows",
"hooks",
"diagnostics",
]);

// Security-policy and credential-bearing sections a trusted project scope may
// never set at all. sandbox/approval are security policy; profiles/defaultProfile
// select the model endpoint and credential source, so a repository can never
// silently replace the selected endpoint or credential (only the user scope may).
const PROTECTED_PROJECT_SECTIONS = new Set(["sandbox", "approval", "profiles", "defaultProfile"]);
// hooks run arbitrary local shell commands before tool calls, so a repository can
// never inject a hook either — only the user-owned scope may declare one.
const PROTECTED_PROJECT_SECTIONS = new Set([
"sandbox",
"approval",
"profiles",
"defaultProfile",
"hooks",
]);

// Fields within a section a trusted project scope may never set. The model
// endpoint and the credential-source variable name are credential-bearing, so a
Expand Down
Loading
Loading