@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/ops.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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 { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { configPath, ensureGuardDir, DEFAULT_BUDGET } from "./config.js";
|
|
10
|
+
import { loadLedger, saveLedger, emptyLedger } from "./ledger.js";
|
|
11
|
+
/**
|
|
12
|
+
* Wire the agent-guard hook into Claude Code settings for PreToolUse,
|
|
13
|
+
* UserPromptSubmit, and Stop. Idempotent: re-running adds nothing if the hook
|
|
14
|
+
* command is already present.
|
|
15
|
+
*/
|
|
16
|
+
export function installHook(cliPath, execPath, opts = {}) {
|
|
17
|
+
const settingsPath = opts.global
|
|
18
|
+
? join(homedir(), ".claude", "settings.json")
|
|
19
|
+
: join(opts.cwd ?? process.cwd(), ".claude", "settings.json");
|
|
20
|
+
const command = opts.command || `"${execPath}" "${cliPath}" hook`;
|
|
21
|
+
let settings = {};
|
|
22
|
+
try {
|
|
23
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* new file */
|
|
27
|
+
}
|
|
28
|
+
settings.hooks ??= {};
|
|
29
|
+
const ensure = (event, withMatcher) => {
|
|
30
|
+
settings.hooks[event] ??= [];
|
|
31
|
+
const blob = JSON.stringify(settings.hooks[event]);
|
|
32
|
+
if (blob.includes("agent-guard") || blob.includes("cli.js") || blob.includes(command))
|
|
33
|
+
return false;
|
|
34
|
+
const entry = { hooks: [{ type: "command", command }] };
|
|
35
|
+
if (withMatcher)
|
|
36
|
+
entry.matcher = "*";
|
|
37
|
+
settings.hooks[event].push(entry);
|
|
38
|
+
return true;
|
|
39
|
+
};
|
|
40
|
+
const added = [];
|
|
41
|
+
if (ensure("PreToolUse", true))
|
|
42
|
+
added.push("PreToolUse");
|
|
43
|
+
if (ensure("UserPromptSubmit", false))
|
|
44
|
+
added.push("UserPromptSubmit");
|
|
45
|
+
if (ensure("Stop", false))
|
|
46
|
+
added.push("Stop");
|
|
47
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
48
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
49
|
+
return { settingsPath, command, added };
|
|
50
|
+
}
|
|
51
|
+
/** Write budget/webhook overrides to the config file. Returns the saved budget. */
|
|
52
|
+
export function setBudget(patch) {
|
|
53
|
+
let file = {};
|
|
54
|
+
try {
|
|
55
|
+
file = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* new */
|
|
59
|
+
}
|
|
60
|
+
const budget = { ...DEFAULT_BUDGET, ...(file.budget ?? {}) };
|
|
61
|
+
const set = (k, v) => {
|
|
62
|
+
if (v !== undefined && Number.isFinite(v))
|
|
63
|
+
budget[k] = v;
|
|
64
|
+
};
|
|
65
|
+
set("sessionSoftUSD", patch.sessionSoftUSD);
|
|
66
|
+
set("sessionHardUSD", patch.sessionHardUSD);
|
|
67
|
+
set("dailySoftUSD", patch.dailySoftUSD);
|
|
68
|
+
set("dailyHardUSD", patch.dailyHardUSD);
|
|
69
|
+
file.budget = budget;
|
|
70
|
+
if (patch.slackWebhook)
|
|
71
|
+
file.slackWebhook = patch.slackWebhook;
|
|
72
|
+
ensureGuardDir();
|
|
73
|
+
writeFileSync(configPath(), JSON.stringify(file, null, 2) + "\n");
|
|
74
|
+
return budget;
|
|
75
|
+
}
|
|
76
|
+
/** Clear the spend ledger. Scope: all | a single session | today's sessions. */
|
|
77
|
+
export function resetLedger(opts) {
|
|
78
|
+
if (opts.all) {
|
|
79
|
+
saveLedger(emptyLedger());
|
|
80
|
+
return "Ledger wiped.";
|
|
81
|
+
}
|
|
82
|
+
const ledger = loadLedger();
|
|
83
|
+
if (opts.session) {
|
|
84
|
+
delete ledger.sessions[opts.session];
|
|
85
|
+
saveLedger(ledger);
|
|
86
|
+
return `Cleared session ${opts.session}.`;
|
|
87
|
+
}
|
|
88
|
+
if (opts.today) {
|
|
89
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
90
|
+
for (const [id, s] of Object.entries(ledger.sessions)) {
|
|
91
|
+
if (new Date(s.lastAt).toISOString().slice(0, 10) === today)
|
|
92
|
+
delete ledger.sessions[id];
|
|
93
|
+
}
|
|
94
|
+
saveLedger(ledger);
|
|
95
|
+
return "Cleared today's sessions.";
|
|
96
|
+
}
|
|
97
|
+
return "Specify all, session <id>, or today.";
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model pricing table — USD per 1M tokens.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the rates used by the Anthropic/OpenAI provider checkers in the API
|
|
5
|
+
* package, but adds the cache rates an agent loop actually hits: a coding agent
|
|
6
|
+
* re-sends its whole context every turn, so cache reads dominate token volume.
|
|
7
|
+
*
|
|
8
|
+
* `cacheWrite` / `cacheRead` default to Anthropic's multipliers when omitted:
|
|
9
|
+
* cache write (5m) = input * 1.25, cache read = input * 0.10
|
|
10
|
+
* Override any of this via ~/.kill-switch/agent-guard/pricing.json (see config.ts).
|
|
11
|
+
*/
|
|
12
|
+
export interface ModelPricing {
|
|
13
|
+
/** USD per 1M input tokens */
|
|
14
|
+
input: number;
|
|
15
|
+
/** USD per 1M output tokens */
|
|
16
|
+
output: number;
|
|
17
|
+
/** USD per 1M cache-write tokens (defaults to input * 1.25) */
|
|
18
|
+
cacheWrite?: number;
|
|
19
|
+
/** USD per 1M cache-read tokens (defaults to input * 0.10) */
|
|
20
|
+
cacheRead?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Keys are normalized model ids (see {@link normalizeModel}). We match by
|
|
24
|
+
* longest-prefix so dated variants like `claude-3-5-sonnet-20241022` resolve
|
|
25
|
+
* to the base `claude-3-5-sonnet` entry without enumerating every snapshot.
|
|
26
|
+
*/
|
|
27
|
+
export declare const MODEL_PRICING: Record<string, ModelPricing>;
|
|
28
|
+
/** Used when a model id matches nothing in the table — assume premium Sonnet-class rates so we under-estimate spend never. */
|
|
29
|
+
export declare const FALLBACK_PRICING: ModelPricing;
|
|
30
|
+
/**
|
|
31
|
+
* Lowercase and strip provider prefixes (`anthropic/`, `openai/`) so ids from
|
|
32
|
+
* different agents normalize to the same key space.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeModel(model: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve pricing for a model id by longest-matching-prefix against the table.
|
|
37
|
+
* Falls back to {@link FALLBACK_PRICING} (premium rates) when nothing matches —
|
|
38
|
+
* a kill switch should over-count an unknown model, not under-count it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function pricingFor(model: string, table?: Record<string, ModelPricing>): ModelPricing;
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model pricing table — USD per 1M tokens.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the rates used by the Anthropic/OpenAI provider checkers in the API
|
|
5
|
+
* package, but adds the cache rates an agent loop actually hits: a coding agent
|
|
6
|
+
* re-sends its whole context every turn, so cache reads dominate token volume.
|
|
7
|
+
*
|
|
8
|
+
* `cacheWrite` / `cacheRead` default to Anthropic's multipliers when omitted:
|
|
9
|
+
* cache write (5m) = input * 1.25, cache read = input * 0.10
|
|
10
|
+
* Override any of this via ~/.kill-switch/agent-guard/pricing.json (see config.ts).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Keys are normalized model ids (see {@link normalizeModel}). We match by
|
|
14
|
+
* longest-prefix so dated variants like `claude-3-5-sonnet-20241022` resolve
|
|
15
|
+
* to the base `claude-3-5-sonnet` entry without enumerating every snapshot.
|
|
16
|
+
*/
|
|
17
|
+
export const MODEL_PRICING = {
|
|
18
|
+
// Anthropic — Claude
|
|
19
|
+
"claude-opus-4": { input: 15.0, output: 75.0 },
|
|
20
|
+
"claude-sonnet-4": { input: 3.0, output: 15.0 },
|
|
21
|
+
"claude-haiku-4": { input: 0.8, output: 4.0 },
|
|
22
|
+
"claude-3-7-sonnet": { input: 3.0, output: 15.0 },
|
|
23
|
+
"claude-3-5-sonnet": { input: 3.0, output: 15.0 },
|
|
24
|
+
"claude-3-5-haiku": { input: 0.8, output: 4.0 },
|
|
25
|
+
"claude-3-opus": { input: 15.0, output: 75.0 },
|
|
26
|
+
"claude-3-sonnet": { input: 3.0, output: 15.0 },
|
|
27
|
+
"claude-3-haiku": { input: 0.25, output: 1.25 },
|
|
28
|
+
// OpenAI
|
|
29
|
+
"gpt-4o": { input: 2.5, output: 10.0 },
|
|
30
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
31
|
+
"gpt-4.1": { input: 2.0, output: 8.0 },
|
|
32
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
33
|
+
"o3": { input: 2.0, output: 8.0 },
|
|
34
|
+
"o4-mini": { input: 1.1, output: 4.4 },
|
|
35
|
+
};
|
|
36
|
+
/** Used when a model id matches nothing in the table — assume premium Sonnet-class rates so we under-estimate spend never. */
|
|
37
|
+
export const FALLBACK_PRICING = { input: 3.0, output: 15.0 };
|
|
38
|
+
/**
|
|
39
|
+
* Lowercase and strip provider prefixes (`anthropic/`, `openai/`) so ids from
|
|
40
|
+
* different agents normalize to the same key space.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeModel(model) {
|
|
43
|
+
return model.toLowerCase().replace(/^(anthropic|openai)\//, "").trim();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve pricing for a model id by longest-matching-prefix against the table.
|
|
47
|
+
* Falls back to {@link FALLBACK_PRICING} (premium rates) when nothing matches —
|
|
48
|
+
* a kill switch should over-count an unknown model, not under-count it.
|
|
49
|
+
*/
|
|
50
|
+
export function pricingFor(model, table = MODEL_PRICING) {
|
|
51
|
+
const id = normalizeModel(model);
|
|
52
|
+
if (table[id])
|
|
53
|
+
return table[id];
|
|
54
|
+
let best;
|
|
55
|
+
let bestLen = -1;
|
|
56
|
+
for (const [key, price] of Object.entries(table)) {
|
|
57
|
+
if (id.startsWith(key) && key.length > bestLen) {
|
|
58
|
+
best = price;
|
|
59
|
+
bestLen = key.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best ?? FALLBACK_PRICING;
|
|
63
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-metering proxy — `agent-guard proxy`.
|
|
3
|
+
*
|
|
4
|
+
* A local reverse proxy you point an agent's API base URL at:
|
|
5
|
+
* ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
|
6
|
+
* OPENAI_BASE_URL=http://localhost:8787/v1 aider
|
|
7
|
+
*
|
|
8
|
+
* It forwards every request to the real upstream, parses the *real* usage out of
|
|
9
|
+
* the response (streaming or not), prices it, and accumulates spend in the shared
|
|
10
|
+
* ledger. Once the hard cap is reached it stops forwarding and returns HTTP 402 —
|
|
11
|
+
* a wall the agent cannot argue its way past, regardless of whether it supports
|
|
12
|
+
* hooks. This is the agent-agnostic backstop to the Claude Code hook.
|
|
13
|
+
*
|
|
14
|
+
* Double-count caveat: don't run Claude Code through BOTH the hook and the proxy
|
|
15
|
+
* — they'd each meter the same dollars. Hook for Claude Code; proxy for everything
|
|
16
|
+
* else (Cursor, Aider, raw scripts). See README.
|
|
17
|
+
*/
|
|
18
|
+
import { type TokenUsage } from "./cost.js";
|
|
19
|
+
export interface ProxyOptions {
|
|
20
|
+
port: number;
|
|
21
|
+
/** Upstream origin, e.g. https://api.anthropic.com */
|
|
22
|
+
upstream: string;
|
|
23
|
+
/** "anthropic" | "openai" — controls usage parsing. */
|
|
24
|
+
flavor: "anthropic" | "openai";
|
|
25
|
+
}
|
|
26
|
+
/** Parse a non-streaming JSON body into usage + model. Exported for testing. */
|
|
27
|
+
export declare function parseJsonUsage(flavor: string, text: string): {
|
|
28
|
+
model: string;
|
|
29
|
+
usage: TokenUsage;
|
|
30
|
+
} | null;
|
|
31
|
+
/** Parse accumulated SSE text into usage + model (Anthropic message_start/message_delta, OpenAI final chunk). Exported for testing. */
|
|
32
|
+
export declare function parseStreamUsage(flavor: string, sse: string): {
|
|
33
|
+
model: string;
|
|
34
|
+
usage: TokenUsage;
|
|
35
|
+
} | null;
|
|
36
|
+
export declare function startProxy(opts: ProxyOptions): void;
|
|
37
|
+
export declare function resolveUpstream(flavor: string, explicit?: string): string;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-metering proxy — `agent-guard proxy`.
|
|
3
|
+
*
|
|
4
|
+
* A local reverse proxy you point an agent's API base URL at:
|
|
5
|
+
* ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
|
6
|
+
* OPENAI_BASE_URL=http://localhost:8787/v1 aider
|
|
7
|
+
*
|
|
8
|
+
* It forwards every request to the real upstream, parses the *real* usage out of
|
|
9
|
+
* the response (streaming or not), prices it, and accumulates spend in the shared
|
|
10
|
+
* ledger. Once the hard cap is reached it stops forwarding and returns HTTP 402 —
|
|
11
|
+
* a wall the agent cannot argue its way past, regardless of whether it supports
|
|
12
|
+
* hooks. This is the agent-agnostic backstop to the Claude Code hook.
|
|
13
|
+
*
|
|
14
|
+
* Double-count caveat: don't run Claude Code through BOTH the hook and the proxy
|
|
15
|
+
* — they'd each meter the same dollars. Hook for Claude Code; proxy for everything
|
|
16
|
+
* else (Cursor, Aider, raw scripts). See README.
|
|
17
|
+
*/
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { Readable } from "node:stream";
|
|
20
|
+
import { loadConfig, mergedPricing, isPaused } from "./config.js";
|
|
21
|
+
import { MODEL_PRICING } from "./pricing.js";
|
|
22
|
+
import { costForUsage, fmtUSD } from "./cost.js";
|
|
23
|
+
import { loadLedger, saveLedger, addSessionCost, rollingDailyCost, prune, } from "./ledger.js";
|
|
24
|
+
import { evaluate } from "./budget.js";
|
|
25
|
+
import { dispatchAlert } from "./alert.js";
|
|
26
|
+
const UPSTREAMS = {
|
|
27
|
+
anthropic: "https://api.anthropic.com",
|
|
28
|
+
openai: "https://api.openai.com",
|
|
29
|
+
};
|
|
30
|
+
function todayKey(now) {
|
|
31
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
function readBody(req) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
req.on("data", (c) => chunks.push(c));
|
|
37
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
38
|
+
req.on("error", reject);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Parse a non-streaming JSON body into usage + model. Exported for testing. */
|
|
42
|
+
export function parseJsonUsage(flavor, text) {
|
|
43
|
+
try {
|
|
44
|
+
const body = JSON.parse(text);
|
|
45
|
+
const u = body.usage;
|
|
46
|
+
if (!u)
|
|
47
|
+
return null;
|
|
48
|
+
if (flavor === "openai") {
|
|
49
|
+
return {
|
|
50
|
+
model: body.model || "unknown",
|
|
51
|
+
usage: { inputTokens: u.prompt_tokens || 0, outputTokens: u.completion_tokens || 0 },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
model: body.model || "unknown",
|
|
56
|
+
usage: {
|
|
57
|
+
inputTokens: u.input_tokens || 0,
|
|
58
|
+
outputTokens: u.output_tokens || 0,
|
|
59
|
+
cacheCreationTokens: u.cache_creation_input_tokens || 0,
|
|
60
|
+
cacheReadTokens: u.cache_read_input_tokens || 0,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Parse accumulated SSE text into usage + model (Anthropic message_start/message_delta, OpenAI final chunk). Exported for testing. */
|
|
69
|
+
export function parseStreamUsage(flavor, sse) {
|
|
70
|
+
let model = "unknown";
|
|
71
|
+
const usage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
72
|
+
let found = false;
|
|
73
|
+
for (const line of sse.split("\n")) {
|
|
74
|
+
const t = line.trim();
|
|
75
|
+
if (!t.startsWith("data:"))
|
|
76
|
+
continue;
|
|
77
|
+
const payload = t.slice(5).trim();
|
|
78
|
+
if (!payload || payload === "[DONE]")
|
|
79
|
+
continue;
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(payload);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (flavor === "anthropic") {
|
|
88
|
+
if (data.type === "message_start" && data.message?.usage) {
|
|
89
|
+
const u = data.message.usage;
|
|
90
|
+
model = data.message.model || model;
|
|
91
|
+
usage.inputTokens += u.input_tokens || 0;
|
|
92
|
+
usage.cacheCreationTokens = (usage.cacheCreationTokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
93
|
+
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (u.cache_read_input_tokens || 0);
|
|
94
|
+
found = true;
|
|
95
|
+
}
|
|
96
|
+
else if (data.type === "message_delta" && data.usage) {
|
|
97
|
+
usage.outputTokens += data.usage.output_tokens || 0;
|
|
98
|
+
found = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// OpenAI: usage arrives on the final chunk when stream_options.include_usage is set.
|
|
103
|
+
if (data.usage) {
|
|
104
|
+
model = data.model || model;
|
|
105
|
+
usage.inputTokens += data.usage.prompt_tokens || 0;
|
|
106
|
+
usage.outputTokens += data.usage.completion_tokens || 0;
|
|
107
|
+
found = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return found ? { model, usage } : null;
|
|
112
|
+
}
|
|
113
|
+
function send402(res, sessionUSD, dailyUSD, reasons) {
|
|
114
|
+
const body = JSON.stringify({
|
|
115
|
+
type: "error",
|
|
116
|
+
error: {
|
|
117
|
+
type: "kill_switch_budget_exceeded",
|
|
118
|
+
message: `Kill Switch blocked this request: hard spend cap reached. ` +
|
|
119
|
+
`Session ${fmtUSD(sessionUSD)}, daily ${fmtUSD(dailyUSD)}. ${reasons.join(" ")} ` +
|
|
120
|
+
`Raise the cap (AGENT_GUARD_SESSION_HARD / AGENT_GUARD_DAILY_HARD) or run \`agent-guard reset\`.`,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
res.writeHead(402, { "content-type": "application/json", "x-kill-switch": "blocked" });
|
|
124
|
+
res.end(body);
|
|
125
|
+
}
|
|
126
|
+
/** Meter a completed response's usage into the ledger. */
|
|
127
|
+
function meter(cfg, ledger, sessionId, parsed, now) {
|
|
128
|
+
if (!parsed)
|
|
129
|
+
return;
|
|
130
|
+
const pricing = mergedPricing(cfg, MODEL_PRICING);
|
|
131
|
+
const delta = costForUsage(parsed.model, parsed.usage, pricing);
|
|
132
|
+
addSessionCost(ledger, sessionId, delta, parsed.usage.inputTokens, parsed.usage.outputTokens, now);
|
|
133
|
+
prune(ledger, now);
|
|
134
|
+
saveLedger(ledger);
|
|
135
|
+
}
|
|
136
|
+
export function startProxy(opts) {
|
|
137
|
+
const cfg = loadConfig();
|
|
138
|
+
const upstreamOrigin = opts.upstream.replace(/\/$/, "");
|
|
139
|
+
const blockedNotified = {};
|
|
140
|
+
const server = createServer(async (req, res) => {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const sessionId = req.headers["x-agent-guard-session"] || `proxy:${todayKey(now)}`;
|
|
143
|
+
// 1) Pre-flight budget check — block before spending anything.
|
|
144
|
+
// Escape hatch: while a human has paused enforcement, never block (but still meter).
|
|
145
|
+
const ledger = loadLedger();
|
|
146
|
+
const sessionUSD = ledger.sessions[sessionId]?.costUSD ?? 0;
|
|
147
|
+
const dailyUSD = rollingDailyCost(ledger, now);
|
|
148
|
+
const verdict = evaluate({ sessionUSD, dailyUSD }, cfg.budget);
|
|
149
|
+
if (verdict.level === "block" && !isPaused(now)) {
|
|
150
|
+
if (!blockedNotified[sessionId]) {
|
|
151
|
+
blockedNotified[sessionId] = true;
|
|
152
|
+
dispatchAlert(cfg, {
|
|
153
|
+
ts: now, source: "proxy", sessionId, level: "block",
|
|
154
|
+
sessionUSD, dailyUSD, reasons: verdict.reasons, action: "returned HTTP 402",
|
|
155
|
+
}).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
send402(res, sessionUSD, dailyUSD, verdict.reasons);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// 2) Forward to upstream.
|
|
161
|
+
let reqBody;
|
|
162
|
+
try {
|
|
163
|
+
reqBody = await readBody(req);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
res.writeHead(400).end("bad request body");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const targetUrl = `${upstreamOrigin}${req.url ?? "/"}`;
|
|
170
|
+
const headers = new Headers();
|
|
171
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
172
|
+
if (v === undefined)
|
|
173
|
+
continue;
|
|
174
|
+
const key = k.toLowerCase();
|
|
175
|
+
if (["host", "content-length", "connection"].includes(key))
|
|
176
|
+
continue;
|
|
177
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
178
|
+
}
|
|
179
|
+
let upstream;
|
|
180
|
+
try {
|
|
181
|
+
upstream = await fetch(targetUrl, {
|
|
182
|
+
method: req.method,
|
|
183
|
+
headers,
|
|
184
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : new Uint8Array(reqBody),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
189
|
+
res.end(JSON.stringify({ error: "kill-switch proxy: upstream fetch failed", detail: String(err) }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// 3) Relay status + headers.
|
|
193
|
+
const respHeaders = {};
|
|
194
|
+
upstream.headers.forEach((v, k) => {
|
|
195
|
+
if (!["content-encoding", "content-length", "transfer-encoding", "connection"].includes(k.toLowerCase())) {
|
|
196
|
+
respHeaders[k] = v;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
res.writeHead(upstream.status, respHeaders);
|
|
200
|
+
const isStream = (upstream.headers.get("content-type") || "").includes("text/event-stream");
|
|
201
|
+
if (!upstream.body) {
|
|
202
|
+
res.end();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// 4) Tee the body: one branch to the client, one to accumulate for metering.
|
|
206
|
+
const [toClient, toMeter] = upstream.body.tee();
|
|
207
|
+
// Pipe to client.
|
|
208
|
+
Readable.fromWeb(toClient).pipe(res);
|
|
209
|
+
// Accumulate the meter branch, then price it.
|
|
210
|
+
(async () => {
|
|
211
|
+
try {
|
|
212
|
+
const reader = toMeter.getReader();
|
|
213
|
+
const decoder = new TextDecoder();
|
|
214
|
+
let buf = "";
|
|
215
|
+
// Cap accumulation for non-stream JSON to avoid holding giant bodies; SSE we need fully.
|
|
216
|
+
for (;;) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done)
|
|
219
|
+
break;
|
|
220
|
+
buf += decoder.decode(value, { stream: true });
|
|
221
|
+
}
|
|
222
|
+
const parsed = isStream
|
|
223
|
+
? parseStreamUsage(opts.flavor, buf)
|
|
224
|
+
: parseJsonUsage(opts.flavor, buf);
|
|
225
|
+
// Re-load ledger (the request may have been concurrent) and meter.
|
|
226
|
+
const fresh = loadLedger();
|
|
227
|
+
meter(cfg, fresh, sessionId, parsed, Date.now());
|
|
228
|
+
// Post-meter soft-cap alert (once).
|
|
229
|
+
const after = fresh.sessions[sessionId]?.costUSD ?? 0;
|
|
230
|
+
const afterDaily = rollingDailyCost(fresh, Date.now());
|
|
231
|
+
const v2 = evaluate({ sessionUSD: after, dailyUSD: afterDaily }, cfg.budget);
|
|
232
|
+
if (v2.level === "warn" && !blockedNotified[`warn:${sessionId}`]) {
|
|
233
|
+
blockedNotified[`warn:${sessionId}`] = true;
|
|
234
|
+
dispatchAlert(cfg, {
|
|
235
|
+
ts: Date.now(), source: "proxy", sessionId, level: "warn",
|
|
236
|
+
sessionUSD: after, dailyUSD: afterDaily, reasons: v2.reasons, action: "soft cap warning",
|
|
237
|
+
}).catch(() => { });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* metering must never break the proxied response */
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
});
|
|
245
|
+
server.listen(opts.port, () => {
|
|
246
|
+
process.stdout.write(`🛡 agent-guard proxy on http://localhost:${opts.port} → ${upstreamOrigin} (${opts.flavor})\n` +
|
|
247
|
+
` Caps: session hard ${fmtUSD(cfg.budget.sessionHardUSD)}, daily hard ${fmtUSD(cfg.budget.dailyHardUSD)}\n` +
|
|
248
|
+
` Point your agent at it, e.g.:\n` +
|
|
249
|
+
(opts.flavor === "anthropic"
|
|
250
|
+
? ` ANTHROPIC_BASE_URL=http://localhost:${opts.port} claude\n`
|
|
251
|
+
: ` OPENAI_BASE_URL=http://localhost:${opts.port}/v1 aider\n`));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
export function resolveUpstream(flavor, explicit) {
|
|
255
|
+
return explicit || UPSTREAMS[flavor] || UPSTREAMS.anthropic;
|
|
256
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared status report — the single computation behind `agent-guard status` and
|
|
3
|
+
* `ks guard status`, so both emit an identical JSON shape and never drift.
|
|
4
|
+
*/
|
|
5
|
+
import { type SessionRecord } from "./ledger.js";
|
|
6
|
+
import { type Budget, type VerdictLevel } from "./budget.js";
|
|
7
|
+
export interface StatusReport {
|
|
8
|
+
budget: Budget;
|
|
9
|
+
dailyUSD: number;
|
|
10
|
+
verdict: VerdictLevel;
|
|
11
|
+
reasons: string[];
|
|
12
|
+
paused: boolean;
|
|
13
|
+
/** Epoch ms the pause auto-expires, or null (indefinite / not paused). */
|
|
14
|
+
pauseUntil: number | null;
|
|
15
|
+
sessions: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
} & SessionRecord>;
|
|
18
|
+
}
|
|
19
|
+
/** Build the current status report from the on-disk config + ledger. */
|
|
20
|
+
export declare function buildStatusReport(now?: number): StatusReport;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared status report — the single computation behind `agent-guard status` and
|
|
3
|
+
* `ks guard status`, so both emit an identical JSON shape and never drift.
|
|
4
|
+
*/
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { isPaused, pauseExpiry } from "./config.js";
|
|
7
|
+
import { loadLedger, rollingDailyCost } from "./ledger.js";
|
|
8
|
+
import { evaluate } from "./budget.js";
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
/** Build the current status report from the on-disk config + ledger. */
|
|
11
|
+
export function buildStatusReport(now = Date.now()) {
|
|
12
|
+
const cfg = loadConfig();
|
|
13
|
+
const ledger = loadLedger();
|
|
14
|
+
const dailyUSD = rollingDailyCost(ledger, now);
|
|
15
|
+
const sessions = Object.entries(ledger.sessions)
|
|
16
|
+
.filter(([, s]) => now - s.lastAt < DAY_MS)
|
|
17
|
+
.sort((a, b) => b[1].lastAt - a[1].lastAt)
|
|
18
|
+
.map(([id, s]) => ({ id, ...s }));
|
|
19
|
+
const topSession = sessions[0]?.costUSD ?? 0;
|
|
20
|
+
const verdict = evaluate({ sessionUSD: topSession, dailyUSD }, cfg.budget);
|
|
21
|
+
return {
|
|
22
|
+
budget: cfg.budget,
|
|
23
|
+
dailyUSD,
|
|
24
|
+
verdict: verdict.level,
|
|
25
|
+
reasons: verdict.reasons,
|
|
26
|
+
paused: isPaused(now),
|
|
27
|
+
pauseUntil: pauseExpiry(),
|
|
28
|
+
sessions,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Claude Code transcript (JSONL) into total token usage by model.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code passes `transcript_path` in every hook payload. Each line is a
|
|
5
|
+
* JSON event; assistant turns carry `message.usage` with input/output and the
|
|
6
|
+
* two cache buckets, plus `message.model`. We sum per model so the hook can
|
|
7
|
+
* price a mixed-model session correctly. Malformed lines are skipped — a parse
|
|
8
|
+
* error must never wedge the kill switch.
|
|
9
|
+
*/
|
|
10
|
+
import type { TokenUsage } from "./cost.js";
|
|
11
|
+
export interface TranscriptTotals {
|
|
12
|
+
byModel: Map<string, TokenUsage>;
|
|
13
|
+
lines: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseTranscript(path: string): TranscriptTotals;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Claude Code transcript (JSONL) into total token usage by model.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code passes `transcript_path` in every hook payload. Each line is a
|
|
5
|
+
* JSON event; assistant turns carry `message.usage` with input/output and the
|
|
6
|
+
* two cache buckets, plus `message.model`. We sum per model so the hook can
|
|
7
|
+
* price a mixed-model session correctly. Malformed lines are skipped — a parse
|
|
8
|
+
* error must never wedge the kill switch.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
function addUsage(into, u) {
|
|
12
|
+
into.inputTokens += u.input_tokens || 0;
|
|
13
|
+
into.outputTokens += u.output_tokens || 0;
|
|
14
|
+
into.cacheCreationTokens = (into.cacheCreationTokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
15
|
+
into.cacheReadTokens = (into.cacheReadTokens || 0) + (u.cache_read_input_tokens || 0);
|
|
16
|
+
}
|
|
17
|
+
export function parseTranscript(path) {
|
|
18
|
+
const byModel = new Map();
|
|
19
|
+
let lines = 0;
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(path, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { byModel, lines };
|
|
26
|
+
}
|
|
27
|
+
for (const line of raw.split("\n")) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (!trimmed)
|
|
30
|
+
continue;
|
|
31
|
+
lines++;
|
|
32
|
+
let evt;
|
|
33
|
+
try {
|
|
34
|
+
evt = JSON.parse(trimmed);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const msg = evt?.message;
|
|
40
|
+
const usage = msg?.usage;
|
|
41
|
+
if (!usage)
|
|
42
|
+
continue;
|
|
43
|
+
const model = msg.model || "unknown";
|
|
44
|
+
let acc = byModel.get(model);
|
|
45
|
+
if (!acc) {
|
|
46
|
+
acc = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
47
|
+
byModel.set(model, acc);
|
|
48
|
+
}
|
|
49
|
+
addUsage(acc, usage);
|
|
50
|
+
}
|
|
51
|
+
return { byModel, lines };
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kill-switch/agent-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kill Switch for coding agents — stop runaway Claude Code / Cursor / Aider sessions from racking up an LLM bill. Native hook + token-metering proxy with per-session and daily-rolling budgets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-guard": "./dist/cli.js",
|
|
8
|
+
"ksg": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/cli.ts",
|
|
15
|
+
"test": "vitest run"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"commander": "^12.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"tsx": "^4.7.0",
|
|
23
|
+
"typescript": "^5.4.0",
|
|
24
|
+
"vitest": "^3.2.4"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"kill-switch",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"coding-agent",
|
|
37
|
+
"llm-cost",
|
|
38
|
+
"token-budget",
|
|
39
|
+
"anthropic",
|
|
40
|
+
"openai",
|
|
41
|
+
"cursor",
|
|
42
|
+
"aider",
|
|
43
|
+
"finops",
|
|
44
|
+
"guardrail"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/divinci-ai/kill-switch",
|
|
49
|
+
"directory": "packages/agent-guard"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://kill-switch.net",
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|