@jovan158/vantage
Version:
See and control what your coding agent does
89 lines (88 loc) • 4.03 kB
JavaScript
// `vantage hook [agent] [config]`: started by the agent, not by a human —
// before each tool call (the decision itself is in src/hook.ts), when a reply
// is finished, and for some agents when a session starts. The agent's own
// dialect is translated by src/agents/hooks.ts; without an agent name it is
// Claude Code's.
import fs from "node:fs";
import { decide } from "../hook.js";
import { loadPolicy } from "../policy.js";
import { loadRules, policyFilePath } from "../rules.js";
import { readBudgetState, waitForMetering } from "../budget.js";
import { takeAlerts, chatText } from "../outbox.js";
import { toolTarget } from "../turn.js";
import { hookProtocol, readHookConfig } from "../agents/hooks.js";
export async function cmdHook(args = []) {
const agent = args[0];
const protocol = hookProtocol(agent);
const chunks = [];
for await (const chunk of process.stdin)
chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf8");
const call = protocol.parse(raw);
// The session's settings, named by the hook command (Claude Code's hook
// may also get them from the environment).
const config = readHookConfig(args[1]);
if (config)
Object.assign(process.env, config);
if (!call)
return 0; // unreadable input never blocks work
const outbox = process.env.VANTAGE_OUTBOX_FILE;
const answer = { decision: null, reason: "", alerts: "" };
if (call.event === "stop") {
// Let the finished reply be metered, so an alert it caused (a quota
// warning, a secret) shows now rather than after the next one.
await waitForMetering(process.env.VANTAGE_INFLIGHT_FILE, 1000);
answer.alerts = chatText(takeAlerts(outbox));
}
else if (call.event === "start") {
try {
if (process.env.VANTAGE_MEMORY_FILE)
answer.context = fs.readFileSync(process.env.VANTAGE_MEMORY_FILE, "utf8");
}
catch {
/* no memory then */
}
}
else if (call.event === "tool" && call.tool) {
// With a budget: let the reply that asked for this tool be metered first.
if (process.env.VANTAGE_BUDGET_FILE)
await waitForMetering(process.env.VANTAGE_INFLIGHT_FILE);
const cwd = call.cwd ?? process.cwd();
const budget = readBudgetState(process.env.VANTAGE_BUDGET_FILE);
const v = decide(call.tool, loadPolicy(cwd), budget, {
input: call.input,
rules: loadRules(process.env.VANTAGE_POLICY_FILE ?? policyFilePath(cwd)),
cwd,
});
// decide() never allows: Vantage only narrows what the agent permits.
answer.decision = v.decision === "allow" ? null : v.decision;
answer.reason = v.reason;
if (v.decision === "ask" && protocol.ask === "none") {
answer.decision = "deny";
answer.reason = `${v.reason} This agent cannot pause to ask, so the action was not run: tell the user what you wanted to do, so they can do it themselves or change the rule.`;
}
answer.alerts = chatText(takeAlerts(outbox));
if (answer.decision)
record(call.tool, call.input, answer.decision, answer.reason);
}
const reply = protocol.reply(call, answer);
if (reply.stdout)
process.stdout.write(reply.stdout);
return reply.exitCode;
}
// What the hook stopped or asked about goes into the event log, so watch and
// replay show it next to the calls that went through. Best effort: a log that
// cannot be written must never change the decision.
function record(tool, input, decision, reason) {
const file = process.env.VANTAGE_EVENTS_FILE;
if (!file)
return;
const target = toolTarget(input);
const event = { ts: new Date().toISOString(), type: "decision", tool, ...(target ? { target } : {}), decision, reason };
try {
fs.appendFileSync(file, JSON.stringify(event) + "\n");
}
catch {
/* the decision stands either way */
}
}