@jovan158/vantage
Version:
See and control what your coding agent does
119 lines (118 loc) • 5.48 kB
JavaScript
// PreToolUse enforcement for problem ② — the half the proxy cannot do.
//
// Why a hook and not the proxy: the proxy sees a tool *intention* in the
// response stream, but the tool is executed inside the agent and never travels
// through the proxy. Only the agent (via its hook) or the OS (via a sandbox) can
// actually stop a file write or a shell command. Claude Code's PreToolUse hook
// runs before execution and returns a decision, so that is the correct layer.
//
// Contract (verified against the Claude Code hooks documentation):
// stdin : { tool_name, tool_input, session_id, cwd, ... }
// stdout: { hookSpecificOutput: { hookEventName: "PreToolUse",
// permissionDecision: "allow"|"deny"|"ask",
// permissionDecisionReason: string } }
// exit 0: the JSON decides; no JSON means "no decision, normal flow".
import { classifyTool } from "./policy.js";
import { matchRules } from "./rules.js";
export function decide(toolName, policy, budget = null, ctx = {}) {
const type = classifyTool(toolName);
const match = matchRules(ctx.input, ctx.rules ?? [], ctx.cwd, undefined, policy[type]);
const level = match ? match.level : policy[type];
if (budget && (level === "allow" || level === "warn")) {
return {
type,
decision: "ask",
reason: `Vantage budget reached: ${budget.reason}. Approve to continue (tool: ${toolName}).`,
};
}
const rule = match ? `Vantage rule "${match.rule.pattern}" (${match.rule.kind}s in .vantage/policy.json)` : null;
switch (level) {
case "deny":
return {
type,
decision: "deny",
reason: rule
? `Blocked by ${rule} (tool: ${toolName}).`
: `Blocked by Vantage policy: ${type} actions are set to 'deny' (tool: ${toolName}). Change it with VANTAGE_POLICY or .vantage/policy.json.`,
};
case "ask":
return {
type,
decision: "ask",
reason: rule
? `${rule} requires your approval (tool: ${toolName}).`
: `Vantage policy requires approval for ${type} actions (tool: ${toolName}).`,
};
default:
return { type, decision: null, reason: "" };
}
}
export function buildOutput(v) {
if (!v.decision)
return null;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: v.decision,
permissionDecisionReason: v.reason,
},
};
}
export function parseHookInput(raw) {
try {
const json = JSON.parse(raw);
return typeof json === "object" && json !== null ? json : null;
}
catch {
return null;
}
}
// Whole hook run as one pure step: raw stdin -> stdout text (or "" for no
// decision). Unparseable input yields no decision rather than blocking work.
export function runHook(rawInput, policy, budget = null, rules = []) {
const input = parseHookInput(rawInput);
const tool = input?.tool_name;
if (!tool)
return "";
const output = buildOutput(decide(tool, policy, budget, { input: input.tool_input, rules, cwd: input.cwd }));
return output ? JSON.stringify(output) : "";
}
// `extra` names the agent and the session's hook settings (see
// src/agents/hooks.ts); Claude Code's hook reads them from the environment.
export function hookInvocation(execPath, entry, extra = []) {
// A dev checkout runs the TypeScript source, which needs type stripping.
const strip = entry.endsWith(".ts") ? ["--experimental-strip-types"] : [];
return { command: execPath, args: [...strip, entry, "hook", ...extra] };
}
// The same invocation as one shell command line, for agents that run hooks
// through a shell (Codex). Every
// part is quoted for the shell the agent uses on this platform.
export function hookCommandLine(inv, platform = process.platform) {
// A quoted program path is a string, not a command, to PowerShell; node
// under "C:\Program Files" is started by name then (npm put it on PATH).
const command = platform === "win32" && shellQuote(inv.command, platform) !== inv.command ? "node" : inv.command;
return [command, ...inv.args].map((a) => shellQuote(a, platform)).join(" ");
}
export function shellQuote(arg, platform = process.platform) {
if (platform === "win32") {
// cmd.exe and PowerShell both take a double-quoted path; a path cannot
// contain a double quote on Windows.
return /^[A-Za-z0-9_\\/:.=-]+$/.test(arg) ? arg : `"${arg}"`;
}
return /^[A-Za-z0-9_\/:.=@%+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
}
// The settings fragment that registers the hooks. Claude Code merges
// `--settings` with the user's own files and COMBINES list keys such as
// hooks.PreToolUse, so this adds our hooks without removing theirs.
// PreToolUse judges each tool call (rules, budget)
// Stop runs when a reply is finished, to show waiting alerts in the
// chat (see src/outbox.ts)
export function hookSettings(inv, events = { preToolUse: true }) {
const hook = { type: "command", command: inv.command, args: inv.args };
const hooks = {};
if (events.preToolUse)
hooks.PreToolUse = [{ matcher: "*", hooks: [hook] }];
if (events.stop)
hooks.Stop = [{ hooks: [hook] }];
return { hooks };
}