@kill-switch/agent-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/dist/alert.d.ts +27 -0
- package/dist/alert.js +68 -0
- package/dist/budget.d.ts +37 -0
- package/dist/budget.js +42 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +192 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +111 -0
- package/dist/cost.d.ts +22 -0
- package/dist/cost.js +41 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +190 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/ledger.d.ts +36 -0
- package/dist/ledger.js +79 -0
- package/dist/ops.d.ts +41 -0
- package/dist/ops.js +98 -0
- package/dist/pricing.d.ts +40 -0
- package/dist/pricing.js +63 -0
- package/dist/proxy.d.ts +37 -0
- package/dist/proxy.js +256 -0
- package/dist/report.d.ts +20 -0
- package/dist/report.js +30 -0
- package/dist/transcript.d.ts +15 -0
- package/dist/transcript.js +52 -0
- package/package.json +53 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration & paths for agent-guard.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (later wins): built-in defaults → config file
|
|
5
|
+
* (~/.kill-switch/agent-guard/config.json) → environment variables.
|
|
6
|
+
* Env override lets you set a tighter budget for a single risky run without
|
|
7
|
+
* editing files, e.g. `AGENT_GUARD_SESSION_HARD=10 claude`.
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
export const DEFAULT_BUDGET = {
|
|
13
|
+
sessionSoftUSD: 5,
|
|
14
|
+
sessionHardUSD: 20,
|
|
15
|
+
dailySoftUSD: 25,
|
|
16
|
+
dailyHardUSD: 100,
|
|
17
|
+
};
|
|
18
|
+
/** ~/.kill-switch/agent-guard — created on demand. */
|
|
19
|
+
export function guardDir() {
|
|
20
|
+
return join(homedir(), ".kill-switch", "agent-guard");
|
|
21
|
+
}
|
|
22
|
+
export function ensureGuardDir() {
|
|
23
|
+
const dir = guardDir();
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
export const ledgerPath = () => join(guardDir(), "ledger.json");
|
|
28
|
+
export const configPath = () => join(guardDir(), "config.json");
|
|
29
|
+
export const pricingPath = () => join(guardDir(), "pricing.json");
|
|
30
|
+
export const eventsPath = () => join(guardDir(), "events.jsonl");
|
|
31
|
+
/**
|
|
32
|
+
* Escape hatch. The hook/proxy fail OPEN while this sentinel exists, so a human
|
|
33
|
+
* can always disable enforcement from outside the agent loop — even with zero
|
|
34
|
+
* tooling: `touch ~/.kill-switch/agent-guard/PAUSED`.
|
|
35
|
+
*
|
|
36
|
+
* An empty file pauses indefinitely; a file containing an epoch-ms number pauses
|
|
37
|
+
* until that time, then enforcement resumes on its own.
|
|
38
|
+
*/
|
|
39
|
+
export const pausePath = () => join(guardDir(), "PAUSED");
|
|
40
|
+
/** True if enforcement is currently paused (sentinel present and not expired). */
|
|
41
|
+
export function isPaused(now) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(pausePath(), "utf8").trim();
|
|
44
|
+
if (!raw)
|
|
45
|
+
return true; // indefinite
|
|
46
|
+
const until = Number(raw);
|
|
47
|
+
return Number.isFinite(until) ? now < until : true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Read the pause expiry (epoch ms), or null if indefinite/not paused. */
|
|
54
|
+
export function pauseExpiry() {
|
|
55
|
+
try {
|
|
56
|
+
const raw = readFileSync(pausePath(), "utf8").trim();
|
|
57
|
+
const until = Number(raw);
|
|
58
|
+
return raw && Number.isFinite(until) ? until : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function writePause(untilMs) {
|
|
65
|
+
ensureGuardDir();
|
|
66
|
+
writeFileSync(pausePath(), untilMs && Number.isFinite(untilMs) ? String(untilMs) : "");
|
|
67
|
+
}
|
|
68
|
+
export function clearPause() {
|
|
69
|
+
try {
|
|
70
|
+
rmSync(pausePath());
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* not paused */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function readJson(path) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function num(envVal, fallback) {
|
|
85
|
+
const n = Number(envVal);
|
|
86
|
+
return envVal !== undefined && Number.isFinite(n) ? n : fallback;
|
|
87
|
+
}
|
|
88
|
+
export function loadConfig() {
|
|
89
|
+
const fileCfg = readJson(configPath()) ?? {};
|
|
90
|
+
const filePricing = readJson(pricingPath());
|
|
91
|
+
const fileBudget = fileCfg.budget ?? {};
|
|
92
|
+
const budget = {
|
|
93
|
+
sessionSoftUSD: num(process.env.AGENT_GUARD_SESSION_SOFT, fileBudget.sessionSoftUSD ?? DEFAULT_BUDGET.sessionSoftUSD),
|
|
94
|
+
sessionHardUSD: num(process.env.AGENT_GUARD_SESSION_HARD, fileBudget.sessionHardUSD ?? DEFAULT_BUDGET.sessionHardUSD),
|
|
95
|
+
dailySoftUSD: num(process.env.AGENT_GUARD_DAILY_SOFT, fileBudget.dailySoftUSD ?? DEFAULT_BUDGET.dailySoftUSD),
|
|
96
|
+
dailyHardUSD: num(process.env.AGENT_GUARD_DAILY_HARD, fileBudget.dailyHardUSD ?? DEFAULT_BUDGET.dailyHardUSD),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
budget,
|
|
100
|
+
pricingOverrides: { ...(fileCfg.pricingOverrides ?? {}), ...(filePricing ?? {}) },
|
|
101
|
+
apiKey: process.env.KILL_SWITCH_API_KEY ?? fileCfg.apiKey,
|
|
102
|
+
apiUrl: process.env.KILL_SWITCH_API_URL ?? fileCfg.apiUrl ?? "https://api.kill-switch.net",
|
|
103
|
+
slackWebhook: process.env.KILL_SWITCH_SLACK_WEBHOOK ?? fileCfg.slackWebhook,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Merge built-in pricing with any overrides from config. */
|
|
107
|
+
export function mergedPricing(cfg, base) {
|
|
108
|
+
if (!cfg.pricingOverrides || Object.keys(cfg.pricingOverrides).length === 0)
|
|
109
|
+
return base;
|
|
110
|
+
return { ...base, ...cfg.pricingOverrides };
|
|
111
|
+
}
|
package/dist/cost.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn token usage into dollars.
|
|
3
|
+
*
|
|
4
|
+
* Both the hook (parsing the Claude Code transcript) and the proxy (parsing live
|
|
5
|
+
* API responses) produce a {@link TokenUsage} and feed it through here, so cost
|
|
6
|
+
* math lives in exactly one place.
|
|
7
|
+
*/
|
|
8
|
+
import { type ModelPricing } from "./pricing.js";
|
|
9
|
+
export interface TokenUsage {
|
|
10
|
+
inputTokens: number;
|
|
11
|
+
outputTokens: number;
|
|
12
|
+
/** Anthropic cache-write tokens (a.k.a. cache_creation_input_tokens) */
|
|
13
|
+
cacheCreationTokens?: number;
|
|
14
|
+
/** Anthropic cache-read tokens (a.k.a. cache_read_input_tokens) */
|
|
15
|
+
cacheReadTokens?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Cost in USD for a single usage record on a given model. */
|
|
18
|
+
export declare function costForUsage(model: string, usage: TokenUsage, table?: Record<string, ModelPricing>): number;
|
|
19
|
+
/** Total token count across all four buckets — for display / "tokens burned" metrics. */
|
|
20
|
+
export declare function totalTokens(usage: TokenUsage): number;
|
|
21
|
+
/** Format a USD amount for terminal output. */
|
|
22
|
+
export declare function fmtUSD(n: number): string;
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn token usage into dollars.
|
|
3
|
+
*
|
|
4
|
+
* Both the hook (parsing the Claude Code transcript) and the proxy (parsing live
|
|
5
|
+
* API responses) produce a {@link TokenUsage} and feed it through here, so cost
|
|
6
|
+
* math lives in exactly one place.
|
|
7
|
+
*/
|
|
8
|
+
import { pricingFor } from "./pricing.js";
|
|
9
|
+
const PER_MILLION = 1_000_000;
|
|
10
|
+
/** Effective cache rates, applying Anthropic's default multipliers when a model omits them. */
|
|
11
|
+
function cacheRates(p) {
|
|
12
|
+
return {
|
|
13
|
+
write: p.cacheWrite ?? p.input * 1.25,
|
|
14
|
+
read: p.cacheRead ?? p.input * 0.1,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Cost in USD for a single usage record on a given model. */
|
|
18
|
+
export function costForUsage(model, usage, table) {
|
|
19
|
+
const p = pricingFor(model, table);
|
|
20
|
+
const { write, read } = cacheRates(p);
|
|
21
|
+
const input = usage.inputTokens || 0;
|
|
22
|
+
const output = usage.outputTokens || 0;
|
|
23
|
+
const cacheWrite = usage.cacheCreationTokens || 0;
|
|
24
|
+
const cacheRead = usage.cacheReadTokens || 0;
|
|
25
|
+
return ((input * p.input +
|
|
26
|
+
output * p.output +
|
|
27
|
+
cacheWrite * write +
|
|
28
|
+
cacheRead * read) /
|
|
29
|
+
PER_MILLION);
|
|
30
|
+
}
|
|
31
|
+
/** Total token count across all four buckets — for display / "tokens burned" metrics. */
|
|
32
|
+
export function totalTokens(usage) {
|
|
33
|
+
return ((usage.inputTokens || 0) +
|
|
34
|
+
(usage.outputTokens || 0) +
|
|
35
|
+
(usage.cacheCreationTokens || 0) +
|
|
36
|
+
(usage.cacheReadTokens || 0));
|
|
37
|
+
}
|
|
38
|
+
/** Format a USD amount for terminal output. */
|
|
39
|
+
export function fmtUSD(n) {
|
|
40
|
+
return `$${n.toFixed(n < 1 ? 4 : 2)}`;
|
|
41
|
+
}
|
package/dist/hook.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook entrypoint — `agent-guard hook`.
|
|
3
|
+
*
|
|
4
|
+
* Wired into .claude/settings.json for PreToolUse, UserPromptSubmit, and Stop.
|
|
5
|
+
* On every call it:
|
|
6
|
+
* 1. Reads the hook JSON from stdin (session_id, transcript_path, cwd, event).
|
|
7
|
+
* 2. Recomputes the session's total spend from the transcript (authoritative).
|
|
8
|
+
* 3. Derives rolling-24h spend from the ledger and evaluates the budget.
|
|
9
|
+
* 4. Emits the event-appropriate decision:
|
|
10
|
+
* - ok → exit 0 silently
|
|
11
|
+
* - warn → allow, surface a systemMessage + additionalContext (once/scope)
|
|
12
|
+
* - block → deny the tool / block the prompt with a reason
|
|
13
|
+
* 5. Fires alerts on the first warn and first block per scope.
|
|
14
|
+
*
|
|
15
|
+
* Design rule: this runs before every tool call, so it must be fast and must
|
|
16
|
+
* never crash the agent. Any internal error fails OPEN (exit 0, agent proceeds)
|
|
17
|
+
* — a buggy guard must not brick the user's session.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runHook(): Promise<void>;
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook entrypoint — `agent-guard hook`.
|
|
3
|
+
*
|
|
4
|
+
* Wired into .claude/settings.json for PreToolUse, UserPromptSubmit, and Stop.
|
|
5
|
+
* On every call it:
|
|
6
|
+
* 1. Reads the hook JSON from stdin (session_id, transcript_path, cwd, event).
|
|
7
|
+
* 2. Recomputes the session's total spend from the transcript (authoritative).
|
|
8
|
+
* 3. Derives rolling-24h spend from the ledger and evaluates the budget.
|
|
9
|
+
* 4. Emits the event-appropriate decision:
|
|
10
|
+
* - ok → exit 0 silently
|
|
11
|
+
* - warn → allow, surface a systemMessage + additionalContext (once/scope)
|
|
12
|
+
* - block → deny the tool / block the prompt with a reason
|
|
13
|
+
* 5. Fires alerts on the first warn and first block per scope.
|
|
14
|
+
*
|
|
15
|
+
* Design rule: this runs before every tool call, so it must be fast and must
|
|
16
|
+
* never crash the agent. Any internal error fails OPEN (exit 0, agent proceeds)
|
|
17
|
+
* — a buggy guard must not brick the user's session.
|
|
18
|
+
*/
|
|
19
|
+
import { loadConfig, mergedPricing, isPaused } from "./config.js";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { MODEL_PRICING } from "./pricing.js";
|
|
22
|
+
import { costForUsage } from "./cost.js";
|
|
23
|
+
import { parseTranscript } from "./transcript.js";
|
|
24
|
+
import { loadLedger, saveLedger, setSessionCost, rollingDailyCost, prune, } from "./ledger.js";
|
|
25
|
+
import { evaluate, warnKey } from "./budget.js";
|
|
26
|
+
import { dispatchAlert } from "./alert.js";
|
|
27
|
+
function readStdin() {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let data = "";
|
|
30
|
+
if (process.stdin.isTTY)
|
|
31
|
+
return resolve("");
|
|
32
|
+
process.stdin.setEncoding("utf8");
|
|
33
|
+
process.stdin.on("data", (c) => (data += c));
|
|
34
|
+
process.stdin.on("end", () => resolve(data));
|
|
35
|
+
process.stdin.on("error", () => resolve(data));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function emit(obj) {
|
|
39
|
+
process.stdout.write(JSON.stringify(obj));
|
|
40
|
+
}
|
|
41
|
+
/** Build the block decision shaped for the specific hook event. */
|
|
42
|
+
function blockDecision(event, reason, systemMessage) {
|
|
43
|
+
if (event === "PreToolUse") {
|
|
44
|
+
return {
|
|
45
|
+
hookSpecificOutput: {
|
|
46
|
+
hookEventName: "PreToolUse",
|
|
47
|
+
permissionDecision: "deny",
|
|
48
|
+
permissionDecisionReason: reason,
|
|
49
|
+
},
|
|
50
|
+
systemMessage,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// UserPromptSubmit (and others that honor decision/block)
|
|
54
|
+
return { decision: "block", reason, systemMessage };
|
|
55
|
+
}
|
|
56
|
+
function warnDecision(event, context, systemMessage) {
|
|
57
|
+
if (event === "PreToolUse" || event === "UserPromptSubmit") {
|
|
58
|
+
return {
|
|
59
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: context },
|
|
60
|
+
systemMessage,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { systemMessage };
|
|
64
|
+
}
|
|
65
|
+
export async function runHook() {
|
|
66
|
+
let input = {};
|
|
67
|
+
try {
|
|
68
|
+
const raw = await readStdin();
|
|
69
|
+
if (raw.trim())
|
|
70
|
+
input = JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
process.exit(0); // fail open
|
|
74
|
+
}
|
|
75
|
+
const event = input.hook_event_name || "PreToolUse";
|
|
76
|
+
const sessionId = input.session_id || "unknown-session";
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
// Escape hatch: if a human has paused enforcement, fail open immediately —
|
|
79
|
+
// before any budget math — so a paused guard can never block a tool call.
|
|
80
|
+
if (isPaused(now)) {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const cfg = loadConfig();
|
|
85
|
+
const pricing = mergedPricing(cfg, MODEL_PRICING);
|
|
86
|
+
// 1) Recompute authoritative session spend from the transcript.
|
|
87
|
+
let sessionUSD = 0;
|
|
88
|
+
let inTok = 0;
|
|
89
|
+
let outTok = 0;
|
|
90
|
+
if (input.transcript_path) {
|
|
91
|
+
const { byModel } = parseTranscript(input.transcript_path);
|
|
92
|
+
for (const [model, usage] of byModel) {
|
|
93
|
+
sessionUSD += costForUsage(model, usage, pricing);
|
|
94
|
+
inTok += usage.inputTokens;
|
|
95
|
+
outTok += usage.outputTokens;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 2) Persist + derive rolling daily.
|
|
99
|
+
const ledger = loadLedger();
|
|
100
|
+
const rec = setSessionCost(ledger, sessionId, sessionUSD, inTok, outTok, now);
|
|
101
|
+
const dailyUSD = rollingDailyCost(ledger, now);
|
|
102
|
+
// 3) Evaluate.
|
|
103
|
+
const verdict = evaluate({ sessionUSD, dailyUSD }, cfg.budget);
|
|
104
|
+
// 4) Alert (deduped per scope) + decide what to output.
|
|
105
|
+
const action = verdict.level === "block"
|
|
106
|
+
? event === "PreToolUse" ? "denied next tool call" : "blocked new prompt"
|
|
107
|
+
: verdict.level === "warn" ? "warned, agent allowed to continue" : "none";
|
|
108
|
+
const baseEvt = {
|
|
109
|
+
ts: now,
|
|
110
|
+
source: "hook",
|
|
111
|
+
sessionId,
|
|
112
|
+
sessionUSD,
|
|
113
|
+
dailyUSD,
|
|
114
|
+
reasons: verdict.reasons,
|
|
115
|
+
action,
|
|
116
|
+
cwd: input.cwd,
|
|
117
|
+
};
|
|
118
|
+
let shouldAlert = false;
|
|
119
|
+
if (verdict.level === "block") {
|
|
120
|
+
if (!rec.notified["block"]) {
|
|
121
|
+
rec.notified["block"] = true;
|
|
122
|
+
shouldAlert = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (verdict.level === "warn") {
|
|
126
|
+
for (const t of verdict.triggers) {
|
|
127
|
+
const k = warnKey(t.scope);
|
|
128
|
+
if (!rec.notified[k]) {
|
|
129
|
+
rec.notified[k] = true;
|
|
130
|
+
shouldAlert = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
prune(ledger, now);
|
|
135
|
+
saveLedger(ledger);
|
|
136
|
+
if (shouldAlert) {
|
|
137
|
+
await dispatchAlert(cfg, { ...baseEvt, level: verdict.level });
|
|
138
|
+
}
|
|
139
|
+
// 5) Output decision.
|
|
140
|
+
if (verdict.level === "block") {
|
|
141
|
+
const reason = renderBlockReason(verdict, sessionId);
|
|
142
|
+
// Stop events can't usefully block spend; allow them through (alert already fired).
|
|
143
|
+
if (event === "Stop") {
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
emit(blockDecision(event, reason, `🛑 Kill Switch stopped this agent — ${verdict.reasons[0] ?? "budget exceeded"}.`));
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
// Surface the warn nudge only on the first trip per scope (shouldAlert), not
|
|
150
|
+
// on every subsequent tool call — otherwise the agent's context fills with
|
|
151
|
+
// duplicate notices. After that, warnings stay silent until the hard cap.
|
|
152
|
+
if (verdict.level === "warn" && shouldAlert) {
|
|
153
|
+
const ctx = renderWarnContext(verdict);
|
|
154
|
+
emit(warnDecision(event, ctx, `⚠️ Kill Switch: ${verdict.reasons[0] ?? "approaching budget"}.`));
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
process.exit(0); // fail open on any unexpected error
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Absolute path to this CLI, so recovery commands work without PATH / npm-link. */
|
|
164
|
+
function selfCmd() {
|
|
165
|
+
try {
|
|
166
|
+
return `"${process.execPath}" "${fileURLToPath(import.meta.url).replace(/hook\.js$/, "cli.js")}"`;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return "agent-guard";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function renderBlockReason(v, sessionId) {
|
|
173
|
+
const cmd = selfCmd();
|
|
174
|
+
return [
|
|
175
|
+
"Kill Switch hard cap reached — further tool use is blocked to prevent a runaway bill.",
|
|
176
|
+
...v.reasons,
|
|
177
|
+
"Do not retry. The escape hatch belongs to the HUMAN, not you — tell the user to run ONE of these in their own shell (the `!` prefix in Claude Code bypasses this hook):",
|
|
178
|
+
`(1) PAUSE for 30 min: ${cmd} pause --minutes 30`,
|
|
179
|
+
`(2) RAISE the caps: ${cmd} config --session-hard 2000 --daily-hard 4000`,
|
|
180
|
+
`(3) RESET this session's spend: ${cmd} reset --session ${sessionId}`,
|
|
181
|
+
"Or, with zero tooling: `touch ~/.kill-switch/agent-guard/PAUSED` (and `rm` it to re-arm).",
|
|
182
|
+
].join(" ");
|
|
183
|
+
}
|
|
184
|
+
function renderWarnContext(v) {
|
|
185
|
+
return [
|
|
186
|
+
"Kill Switch budget notice (informational, you may continue):",
|
|
187
|
+
...v.reasons,
|
|
188
|
+
"Consider wrapping up or narrowing scope to avoid hitting the hard cap, which will halt tool use.",
|
|
189
|
+
].join(" ");
|
|
190
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kill-switch/agent-guard — Kill Switch for coding agents.
|
|
3
|
+
*
|
|
4
|
+
* Stop runaway Claude Code / Cursor / Aider sessions from racking up an LLM
|
|
5
|
+
* bill, via a native Claude Code hook and a token-metering proxy that share one
|
|
6
|
+
* per-session + daily-rolling budget.
|
|
7
|
+
*
|
|
8
|
+
* Programmatic surface (the CLI in cli.ts is the primary entrypoint):
|
|
9
|
+
*/
|
|
10
|
+
export { MODEL_PRICING, FALLBACK_PRICING, pricingFor, normalizeModel, type ModelPricing } from "./pricing.js";
|
|
11
|
+
export { costForUsage, totalTokens, fmtUSD, type TokenUsage } from "./cost.js";
|
|
12
|
+
export { evaluate, warnKey, type Budget, type Verdict, type Spend, type VerdictLevel } from "./budget.js";
|
|
13
|
+
export { loadLedger, saveLedger, setSessionCost, addSessionCost, rollingDailyCost, prune, emptyLedger, type Ledger, type SessionRecord, } from "./ledger.js";
|
|
14
|
+
export { parseTranscript, type TranscriptTotals } from "./transcript.js";
|
|
15
|
+
export { loadConfig, DEFAULT_BUDGET, guardDir, ensureGuardDir, configPath, pausePath, isPaused, pauseExpiry, writePause, clearPause, type GuardConfig, } from "./config.js";
|
|
16
|
+
export { dispatchAlert, type AlertEvent } from "./alert.js";
|
|
17
|
+
export { startProxy, resolveUpstream, type ProxyOptions } from "./proxy.js";
|
|
18
|
+
export { runHook } from "./hook.js";
|
|
19
|
+
export { buildStatusReport, type StatusReport } from "./report.js";
|
|
20
|
+
export { installHook, setBudget, resetLedger, type InstallOptions, type InstallResult, type BudgetPatch, } from "./ops.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kill-switch/agent-guard — Kill Switch for coding agents.
|
|
3
|
+
*
|
|
4
|
+
* Stop runaway Claude Code / Cursor / Aider sessions from racking up an LLM
|
|
5
|
+
* bill, via a native Claude Code hook and a token-metering proxy that share one
|
|
6
|
+
* per-session + daily-rolling budget.
|
|
7
|
+
*
|
|
8
|
+
* Programmatic surface (the CLI in cli.ts is the primary entrypoint):
|
|
9
|
+
*/
|
|
10
|
+
export { MODEL_PRICING, FALLBACK_PRICING, pricingFor, normalizeModel } from "./pricing.js";
|
|
11
|
+
export { costForUsage, totalTokens, fmtUSD } from "./cost.js";
|
|
12
|
+
export { evaluate, warnKey } from "./budget.js";
|
|
13
|
+
export { loadLedger, saveLedger, setSessionCost, addSessionCost, rollingDailyCost, prune, emptyLedger, } from "./ledger.js";
|
|
14
|
+
export { parseTranscript } from "./transcript.js";
|
|
15
|
+
export { loadConfig, DEFAULT_BUDGET, guardDir, ensureGuardDir, configPath, pausePath, isPaused, pauseExpiry, writePause, clearPause, } from "./config.js";
|
|
16
|
+
export { dispatchAlert } from "./alert.js";
|
|
17
|
+
export { startProxy, resolveUpstream } from "./proxy.js";
|
|
18
|
+
export { runHook } from "./hook.js";
|
|
19
|
+
export { buildStatusReport } from "./report.js";
|
|
20
|
+
export { installHook, setBudget, resetLedger, } from "./ops.js";
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend ledger — the single source of truth for "how much has been spent".
|
|
3
|
+
*
|
|
4
|
+
* Stored at ~/.kill-switch/agent-guard/ledger.json. Written atomically
|
|
5
|
+
* (tmp file + rename) because the hook and the proxy can both touch it.
|
|
6
|
+
*
|
|
7
|
+
* Daily-rolling cost is *derived*, not separately accumulated: it's the sum of
|
|
8
|
+
* every session whose last activity falls within the trailing 24h window. This
|
|
9
|
+
* makes double-counting impossible — the hook recomputes a session's total from
|
|
10
|
+
* the transcript (authoritative) while the proxy increments deltas, and neither
|
|
11
|
+
* can inflate the daily figure.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionRecord {
|
|
14
|
+
startedAt: number;
|
|
15
|
+
lastAt: number;
|
|
16
|
+
costUSD: number;
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
/** Dedup flags so soft-cap warnings fire once per scope, not every tool call. */
|
|
20
|
+
notified: Record<string, boolean>;
|
|
21
|
+
}
|
|
22
|
+
export interface Ledger {
|
|
23
|
+
version: 1;
|
|
24
|
+
sessions: Record<string, SessionRecord>;
|
|
25
|
+
}
|
|
26
|
+
export declare function emptyLedger(): Ledger;
|
|
27
|
+
export declare function loadLedger(): Ledger;
|
|
28
|
+
export declare function saveLedger(ledger: Ledger): void;
|
|
29
|
+
/** Authoritative set — used by the hook after recomputing from the transcript. */
|
|
30
|
+
export declare function setSessionCost(ledger: Ledger, id: string, costUSD: number, inputTokens: number, outputTokens: number, now: number): SessionRecord;
|
|
31
|
+
/** Incremental add — used by the proxy, which sees per-response deltas. */
|
|
32
|
+
export declare function addSessionCost(ledger: Ledger, id: string, deltaUSD: number, inputTokens: number, outputTokens: number, now: number): SessionRecord;
|
|
33
|
+
/** Sum of all sessions active within the trailing 24h window. */
|
|
34
|
+
export declare function rollingDailyCost(ledger: Ledger, now: number): number;
|
|
35
|
+
/** Drop sessions untouched for `days` to keep the ledger small. */
|
|
36
|
+
export declare function prune(ledger: Ledger, now: number, days?: number): void;
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend ledger — the single source of truth for "how much has been spent".
|
|
3
|
+
*
|
|
4
|
+
* Stored at ~/.kill-switch/agent-guard/ledger.json. Written atomically
|
|
5
|
+
* (tmp file + rename) because the hook and the proxy can both touch it.
|
|
6
|
+
*
|
|
7
|
+
* Daily-rolling cost is *derived*, not separately accumulated: it's the sum of
|
|
8
|
+
* every session whose last activity falls within the trailing 24h window. This
|
|
9
|
+
* makes double-counting impossible — the hook recomputes a session's total from
|
|
10
|
+
* the transcript (authoritative) while the proxy increments deltas, and neither
|
|
11
|
+
* can inflate the daily figure.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
14
|
+
import { ledgerPath, ensureGuardDir } from "./config.js";
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
export function emptyLedger() {
|
|
17
|
+
return { version: 1, sessions: {} };
|
|
18
|
+
}
|
|
19
|
+
export function loadLedger() {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(readFileSync(ledgerPath(), "utf8"));
|
|
22
|
+
if (data && data.version === 1 && data.sessions)
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* fall through to empty */
|
|
27
|
+
}
|
|
28
|
+
return emptyLedger();
|
|
29
|
+
}
|
|
30
|
+
export function saveLedger(ledger) {
|
|
31
|
+
ensureGuardDir();
|
|
32
|
+
const path = ledgerPath();
|
|
33
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(ledger, null, 2));
|
|
35
|
+
renameSync(tmp, path);
|
|
36
|
+
}
|
|
37
|
+
function ensureSession(ledger, id, now) {
|
|
38
|
+
let s = ledger.sessions[id];
|
|
39
|
+
if (!s) {
|
|
40
|
+
s = { startedAt: now, lastAt: now, costUSD: 0, inputTokens: 0, outputTokens: 0, notified: {} };
|
|
41
|
+
ledger.sessions[id] = s;
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
/** Authoritative set — used by the hook after recomputing from the transcript. */
|
|
46
|
+
export function setSessionCost(ledger, id, costUSD, inputTokens, outputTokens, now) {
|
|
47
|
+
const s = ensureSession(ledger, id, now);
|
|
48
|
+
s.costUSD = costUSD;
|
|
49
|
+
s.inputTokens = inputTokens;
|
|
50
|
+
s.outputTokens = outputTokens;
|
|
51
|
+
s.lastAt = now;
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
/** Incremental add — used by the proxy, which sees per-response deltas. */
|
|
55
|
+
export function addSessionCost(ledger, id, deltaUSD, inputTokens, outputTokens, now) {
|
|
56
|
+
const s = ensureSession(ledger, id, now);
|
|
57
|
+
s.costUSD += deltaUSD;
|
|
58
|
+
s.inputTokens += inputTokens;
|
|
59
|
+
s.outputTokens += outputTokens;
|
|
60
|
+
s.lastAt = now;
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
/** Sum of all sessions active within the trailing 24h window. */
|
|
64
|
+
export function rollingDailyCost(ledger, now) {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const s of Object.values(ledger.sessions)) {
|
|
67
|
+
if (now - s.lastAt < DAY_MS)
|
|
68
|
+
total += s.costUSD;
|
|
69
|
+
}
|
|
70
|
+
return total;
|
|
71
|
+
}
|
|
72
|
+
/** Drop sessions untouched for `days` to keep the ledger small. */
|
|
73
|
+
export function prune(ledger, now, days = 14) {
|
|
74
|
+
const cutoff = now - days * DAY_MS;
|
|
75
|
+
for (const [id, s] of Object.entries(ledger.sessions)) {
|
|
76
|
+
if (s.lastAt < cutoff)
|
|
77
|
+
delete ledger.sessions[id];
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/ops.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operations shared by the standalone `agent-guard` CLI and the `ks guard`
|
|
3
|
+
* subcommands, so both drive the same logic instead of duplicating it (or
|
|
4
|
+
* shelling out). Pure side-effecting helpers over config + Claude Code settings.
|
|
5
|
+
*/
|
|
6
|
+
import type { Budget } from "./budget.js";
|
|
7
|
+
export interface InstallOptions {
|
|
8
|
+
/** Install into ~/.claude/settings.json instead of ./.claude/settings.json */
|
|
9
|
+
global?: boolean;
|
|
10
|
+
/** Override the hook command (default: absolute path to this binary). */
|
|
11
|
+
command?: string;
|
|
12
|
+
/** Working dir for the project-local install (default: process.cwd()). */
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface InstallResult {
|
|
16
|
+
settingsPath: string;
|
|
17
|
+
command: string;
|
|
18
|
+
added: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Wire the agent-guard hook into Claude Code settings for PreToolUse,
|
|
22
|
+
* UserPromptSubmit, and Stop. Idempotent: re-running adds nothing if the hook
|
|
23
|
+
* command is already present.
|
|
24
|
+
*/
|
|
25
|
+
export declare function installHook(cliPath: string, execPath: string, opts?: InstallOptions): InstallResult;
|
|
26
|
+
/** Partial budget update (USD). Merges onto the existing config file. */
|
|
27
|
+
export interface BudgetPatch {
|
|
28
|
+
sessionSoftUSD?: number;
|
|
29
|
+
sessionHardUSD?: number;
|
|
30
|
+
dailySoftUSD?: number;
|
|
31
|
+
dailyHardUSD?: number;
|
|
32
|
+
slackWebhook?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Write budget/webhook overrides to the config file. Returns the saved budget. */
|
|
35
|
+
export declare function setBudget(patch: BudgetPatch): Budget;
|
|
36
|
+
/** Clear the spend ledger. Scope: all | a single session | today's sessions. */
|
|
37
|
+
export declare function resetLedger(opts: {
|
|
38
|
+
all?: boolean;
|
|
39
|
+
session?: string;
|
|
40
|
+
today?: boolean;
|
|
41
|
+
}): string;
|