@kill-switch/agent-guard 0.1.3 → 0.1.5
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 +5 -2
- package/dist/cli.js +4 -3
- package/dist/estimate.d.ts +27 -21
- package/dist/estimate.js +30 -36
- package/dist/hook.js +4 -4
- package/dist/limits.d.ts +0 -6
- package/dist/limits.js +10 -2
- package/dist/ops.d.ts +7 -1
- package/dist/ops.js +14 -3
- package/dist/proxy.js +66 -13
- package/dist/report.d.ts +8 -0
- package/dist/report.js +26 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,7 +154,10 @@ Tune the thresholds (0–1 utilization) if the defaults are too eager:
|
|
|
154
154
|
|
|
155
155
|
The first time the proxy sees the `unified-*` headers it writes the raw values once to
|
|
156
156
|
`~/.kill-switch/agent-guard/events.jsonl` (`kind: "unified-headers-observed"`) — so you can
|
|
157
|
-
confirm Anthropic's exact value formats with a single `cat`.
|
|
157
|
+
confirm Anthropic's exact value formats with a single `cat`. Only `unified-*` headers are
|
|
158
|
+
captured (an explicit allowlist — never `Authorization` / `x-api-key` / cookies), values are
|
|
159
|
+
length-capped, and the dump stays local. In `auto` mode the dollar-wall suppression trusts the
|
|
160
|
+
upstream's headers; pin `--plan` if you'd rather it not depend on what the upstream reports.
|
|
158
161
|
|
|
159
162
|
> Because subscription mode is alert-only, the "don't run both hook *and* proxy" caveat below
|
|
160
163
|
> doesn't bite here — running Claude Code through the proxy is exactly what feeds the limit
|
|
@@ -188,7 +191,7 @@ agent-guard proxy [--port 8787] [--flavor anthropic|openai] [--upstream URL]
|
|
|
188
191
|
agent-guard status [--json] spend vs budget + plan limits
|
|
189
192
|
agent-guard config [--session-hard N ...] view/set caps
|
|
190
193
|
agent-guard config [--plan max5 --weekly-soft 0.6 ...] view/set plan limits
|
|
191
|
-
agent-guard reset [--all|--today|--session <id>] clear the ledger
|
|
194
|
+
agent-guard reset [--all|--limits|--today|--session <id>] clear the ledger / subscription-limit state
|
|
192
195
|
agent-guard hook (internal) Claude Code entrypoint
|
|
193
196
|
```
|
|
194
197
|
|
package/dist/cli.js
CHANGED
|
@@ -213,11 +213,12 @@ program
|
|
|
213
213
|
// ── reset ────────────────────────────────────────────────────────────────────
|
|
214
214
|
program
|
|
215
215
|
.command("reset")
|
|
216
|
-
.description("Clear the spend ledger")
|
|
217
|
-
.option("--all", "Wipe all sessions")
|
|
216
|
+
.description("Clear the spend ledger and/or subscription-limit state")
|
|
217
|
+
.option("--all", "Wipe all sessions + subscription-limit state")
|
|
218
|
+
.option("--limits", "Clear subscription detection latch + snapshot only")
|
|
218
219
|
.option("--session <id>", "Clear a single session")
|
|
219
220
|
.option("--today", "Clear sessions active today")
|
|
220
221
|
.action((opts) => {
|
|
221
|
-
console.log(`✅ ${resetLedger({ all: opts.all, session: opts.session, today: opts.today })}`);
|
|
222
|
+
console.log(`✅ ${resetLedger({ all: opts.all, limits: opts.limits, session: opts.session, today: opts.today })}`);
|
|
222
223
|
});
|
|
223
224
|
program.parseAsync();
|
package/dist/estimate.d.ts
CHANGED
|
@@ -4,40 +4,46 @@
|
|
|
4
4
|
* Ground truth for plan limits lives in the `anthropic-ratelimit-unified-*`
|
|
5
5
|
* response headers, which only the proxy sees. A user running just the Claude
|
|
6
6
|
* Code hook (the common, zero-config setup) never sees those headers — so when
|
|
7
|
-
* they've told us their plan tier, we *estimate* where they stand
|
|
8
|
-
* tokens the ledger recorded inside each rolling window and dividing by a
|
|
9
|
-
* per-tier token budget.
|
|
7
|
+
* they've told us their plan tier, we *estimate* where they stand.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* We estimate from the ledger's **cost** (`costUSD`), not its token counts. That
|
|
10
|
+
* matters: a coding agent's volume is dominated by cache reads/writes, and the
|
|
11
|
+
* ledger only stores non-cache input/output token counts — so a token-based
|
|
12
|
+
* estimate undercounts real throughput (and thus rate-limit consumption) by a
|
|
13
|
+
* large factor. `costUSD` is priced from the *full* usage including cache, so it
|
|
14
|
+
* tracks actual consumption far better. We compare it against a rough per-tier
|
|
15
|
+
* **API-equivalent** dollar ceiling for each window.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* This is still deliberately approximate and always labelled as such:
|
|
18
|
+
* - Anthropic meters opaque "prompts" / "active hours", and the dollar ceilings
|
|
19
|
+
* below are rough API-equivalent calibrations, not contractual.
|
|
20
|
+
* - The ledger stores a session's cumulative cost against a single `lastAt`,
|
|
21
|
+
* not a time series, so a long session is counted wholesale into whichever
|
|
22
|
+
* window its last activity falls in.
|
|
23
|
+
*
|
|
24
|
+
* It exists to give hook-only users *a* directional signal and to nudge them
|
|
25
|
+
* toward `ks guard proxy` for exact numbers — never to block (subscription mode
|
|
26
|
+
* is alert-only).
|
|
21
27
|
*/
|
|
22
28
|
import { type LimitSnapshot } from "./limits.js";
|
|
23
29
|
import type { Ledger } from "./ledger.js";
|
|
24
30
|
export type PlanTier = "pro" | "max5" | "max20";
|
|
25
31
|
/**
|
|
26
|
-
* Rough per-tier
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
32
|
+
* Rough per-tier **API-equivalent USD** ceilings per window — what the plan's
|
|
33
|
+
* rate limit lets you consume before lock-out, expressed in the same list-price
|
|
34
|
+
* dollars the ledger meters. Scaled by plan: Pro is the baseline, Max 5x/20x lift
|
|
35
|
+
* the burst (5h) roughly with the multiplier and the weekly cap more
|
|
36
|
+
* conservatively. These are estimates; the proxy's real headers override them.
|
|
30
37
|
*/
|
|
31
38
|
export interface TierBudget {
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
fiveHourUSD: number;
|
|
40
|
+
weeklyUSD: number;
|
|
34
41
|
}
|
|
35
42
|
export declare const TIER_BUDGETS: Record<PlanTier, TierBudget>;
|
|
36
43
|
/**
|
|
37
44
|
* Build an estimated {@link LimitSnapshot} from the ledger for a known tier.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `now` as a conservative upper bound on time remaining).
|
|
45
|
+
* `resetAt` is null (the true rolling reset is unknowable without a per-event
|
|
46
|
+
* time series), so pacing reports utilization only — no fabricated reset/lockout.
|
|
41
47
|
*/
|
|
42
48
|
export declare function estimateSnapshot(ledger: Ledger, tier: PlanTier, now: number, budgets?: Record<PlanTier, TierBudget>): LimitSnapshot;
|
|
43
49
|
/** True when a snapshot came from {@link estimateSnapshot} rather than real headers. */
|
package/dist/estimate.js
CHANGED
|
@@ -4,63 +4,57 @@
|
|
|
4
4
|
* Ground truth for plan limits lives in the `anthropic-ratelimit-unified-*`
|
|
5
5
|
* response headers, which only the proxy sees. A user running just the Claude
|
|
6
6
|
* Code hook (the common, zero-config setup) never sees those headers — so when
|
|
7
|
-
* they've told us their plan tier, we *estimate* where they stand
|
|
8
|
-
* tokens the ledger recorded inside each rolling window and dividing by a
|
|
9
|
-
* per-tier token budget.
|
|
7
|
+
* they've told us their plan tier, we *estimate* where they stand.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* We estimate from the ledger's **cost** (`costUSD`), not its token counts. That
|
|
10
|
+
* matters: a coding agent's volume is dominated by cache reads/writes, and the
|
|
11
|
+
* ledger only stores non-cache input/output token counts — so a token-based
|
|
12
|
+
* estimate undercounts real throughput (and thus rate-limit consumption) by a
|
|
13
|
+
* large factor. `costUSD` is priced from the *full* usage including cache, so it
|
|
14
|
+
* tracks actual consumption far better. We compare it against a rough per-tier
|
|
15
|
+
* **API-equivalent** dollar ceiling for each window.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* This is still deliberately approximate and always labelled as such:
|
|
18
|
+
* - Anthropic meters opaque "prompts" / "active hours", and the dollar ceilings
|
|
19
|
+
* below are rough API-equivalent calibrations, not contractual.
|
|
20
|
+
* - The ledger stores a session's cumulative cost against a single `lastAt`,
|
|
21
|
+
* not a time series, so a long session is counted wholesale into whichever
|
|
22
|
+
* window its last activity falls in.
|
|
23
|
+
*
|
|
24
|
+
* It exists to give hook-only users *a* directional signal and to nudge them
|
|
25
|
+
* toward `ks guard proxy` for exact numbers — never to block (subscription mode
|
|
26
|
+
* is alert-only).
|
|
21
27
|
*/
|
|
22
28
|
import { WINDOW_MS } from "./limits.js";
|
|
23
29
|
export const TIER_BUDGETS = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
max20: { fiveHourTokens: 160_000_000, weeklyTokens: 1_400_000_000 },
|
|
30
|
+
pro: { fiveHourUSD: 16, weeklyUSD: 100 },
|
|
31
|
+
max5: { fiveHourUSD: 80, weeklyUSD: 500 },
|
|
32
|
+
max20: { fiveHourUSD: 300, weeklyUSD: 2000 },
|
|
28
33
|
};
|
|
29
34
|
const FIVE_HOUR_MS = WINDOW_MS["5h"];
|
|
30
35
|
const WEEK_MS = WINDOW_MS.weekly;
|
|
31
|
-
/** Sum
|
|
32
|
-
function
|
|
36
|
+
/** Sum metered cost across sessions whose last activity is within `windowMs`. */
|
|
37
|
+
function costInWindow(ledger, now, windowMs) {
|
|
33
38
|
let total = 0;
|
|
34
39
|
for (const s of Object.values(ledger.sessions)) {
|
|
35
40
|
if (now - s.lastAt < windowMs)
|
|
36
|
-
total +=
|
|
41
|
+
total += s.costUSD || 0;
|
|
37
42
|
}
|
|
38
43
|
return total;
|
|
39
44
|
}
|
|
40
45
|
/**
|
|
41
46
|
* Build an estimated {@link LimitSnapshot} from the ledger for a known tier.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `now` as a conservative upper bound on time remaining).
|
|
47
|
+
* `resetAt` is null (the true rolling reset is unknowable without a per-event
|
|
48
|
+
* time series), so pacing reports utilization only — no fabricated reset/lockout.
|
|
45
49
|
*/
|
|
46
50
|
export function estimateSnapshot(ledger, tier, now, budgets = TIER_BUDGETS) {
|
|
47
51
|
const b = budgets[tier];
|
|
48
|
-
const
|
|
49
|
-
const
|
|
52
|
+
const fiveUSD = costInWindow(ledger, now, FIVE_HOUR_MS);
|
|
53
|
+
const weekUSD = costInWindow(ledger, now, WEEK_MS);
|
|
50
54
|
const clamp = (n) => Math.max(0, Math.min(1, n));
|
|
51
55
|
return {
|
|
52
|
-
fiveHour: {
|
|
53
|
-
|
|
54
|
-
// Without a per-event time series we can't know the true rolling reset;
|
|
55
|
-
// report a full window from now as a conservative (latest-possible) reset.
|
|
56
|
-
resetAt: now + FIVE_HOUR_MS,
|
|
57
|
-
status: "estimated",
|
|
58
|
-
},
|
|
59
|
-
weekly: {
|
|
60
|
-
utilization: clamp(weekTokens / b.weeklyTokens),
|
|
61
|
-
resetAt: now + WEEK_MS,
|
|
62
|
-
status: "estimated",
|
|
63
|
-
},
|
|
56
|
+
fiveHour: { utilization: clamp(fiveUSD / b.fiveHourUSD), resetAt: null, status: "estimated" },
|
|
57
|
+
weekly: { utilization: clamp(weekUSD / b.weeklyUSD), resetAt: null, status: "estimated" },
|
|
64
58
|
status: "estimated",
|
|
65
59
|
observedAt: now,
|
|
66
60
|
};
|
package/dist/hook.js
CHANGED
|
@@ -24,7 +24,7 @@ import { parseTranscript } from "./transcript.js";
|
|
|
24
24
|
import { loadLedger, saveLedger, setSessionCost, rollingDailyCost, prune, } from "./ledger.js";
|
|
25
25
|
import { evaluate, warnKey } from "./budget.js";
|
|
26
26
|
import { dispatchAlert } from "./alert.js";
|
|
27
|
-
import {
|
|
27
|
+
import { buildLimitsReport } from "./report.js";
|
|
28
28
|
function readStdin() {
|
|
29
29
|
return new Promise((resolve) => {
|
|
30
30
|
let data = "";
|
|
@@ -151,7 +151,7 @@ export async function runHook() {
|
|
|
151
151
|
// snapshot the proxy persisted from Anthropic's headers (or a tier estimate),
|
|
152
152
|
// so even a hook-only session learns when it's about to lock out. Deduped per
|
|
153
153
|
// window+level so it doesn't repeat every tool call.
|
|
154
|
-
const limitMsg = limitNudge(rec, ledger, now);
|
|
154
|
+
const limitMsg = limitNudge(cfg, rec, ledger, now);
|
|
155
155
|
// Surface the warn nudge only on the first trip per scope (shouldAlert), not
|
|
156
156
|
// on every subsequent tool call — otherwise the agent's context fills with
|
|
157
157
|
// duplicate notices. After that, warnings stay silent until the hard cap.
|
|
@@ -175,9 +175,9 @@ export async function runHook() {
|
|
|
175
175
|
* session's notified map (and persists it) so the same warning doesn't repeat on
|
|
176
176
|
* every tool call. Returns null when there's nothing to surface.
|
|
177
177
|
*/
|
|
178
|
-
function limitNudge(rec, ledger, now) {
|
|
178
|
+
function limitNudge(cfg, rec, ledger, now) {
|
|
179
179
|
try {
|
|
180
|
-
const limits =
|
|
180
|
+
const limits = buildLimitsReport(cfg, ledger, now);
|
|
181
181
|
if (!limits.windows.length)
|
|
182
182
|
return null;
|
|
183
183
|
const urgent = limits.windows.find((w) => w.level === "danger") ?? limits.windows.find((w) => w.level === "warn");
|
package/dist/limits.d.ts
CHANGED
|
@@ -83,12 +83,6 @@ export declare function parseReset(raw: string | null | undefined, now: number):
|
|
|
83
83
|
export declare function parseUnifiedHeaders(h: HeaderGetter, now: number): LimitSnapshot | null;
|
|
84
84
|
/** Stable dedup key for a pacing alert: re-alerts when the window resets. */
|
|
85
85
|
export declare function limitNotifyKey(window: LimitWindow, level: string, resetAt: number | null): string;
|
|
86
|
-
/**
|
|
87
|
-
* Pull every `anthropic-ratelimit-unified-*` header out of a raw record, verbatim.
|
|
88
|
-
* Used for the write-once diagnostic — Anthropic's value *formats* (fraction vs.
|
|
89
|
-
* percent, ISO vs. epoch reset) aren't fully documented, so capturing the raw
|
|
90
|
-
* strings the first time we see them makes verification a single `cat` away.
|
|
91
|
-
*/
|
|
92
86
|
export declare function unifiedHeaderDump(rec: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
93
87
|
/** Append a one-time raw-header diagnostic to events.jsonl. Best-effort, never throws. */
|
|
94
88
|
export declare function logUnifiedHeaders(dump: Record<string, string>, now: number): void;
|
package/dist/limits.js
CHANGED
|
@@ -137,15 +137,23 @@ export function limitNotifyKey(window, level, resetAt) {
|
|
|
137
137
|
* Used for the write-once diagnostic — Anthropic's value *formats* (fraction vs.
|
|
138
138
|
* percent, ISO vs. epoch reset) aren't fully documented, so capturing the raw
|
|
139
139
|
* strings the first time we see them makes verification a single `cat` away.
|
|
140
|
+
*
|
|
141
|
+
* Security: this is an explicit **allowlist** by the `anthropic-ratelimit-unified`
|
|
142
|
+
* prefix — credential headers (Authorization, x-api-key, cookies) are never
|
|
143
|
+
* captured, even though the caller hands us the full response header set. Values
|
|
144
|
+
* are length-capped so a hostile/compromised upstream can't bloat the log.
|
|
140
145
|
*/
|
|
146
|
+
const MAX_DUMP_VALUE = 256;
|
|
141
147
|
export function unifiedHeaderDump(rec) {
|
|
142
148
|
const out = {};
|
|
143
149
|
for (const [k, v] of Object.entries(rec)) {
|
|
144
150
|
if (v == null)
|
|
145
151
|
continue;
|
|
146
152
|
const key = k.toLowerCase();
|
|
147
|
-
if (key.startsWith("anthropic-ratelimit-unified"))
|
|
148
|
-
|
|
153
|
+
if (!key.startsWith("anthropic-ratelimit-unified"))
|
|
154
|
+
continue;
|
|
155
|
+
const val = Array.isArray(v) ? v.join(", ") : v;
|
|
156
|
+
out[key] = val.length > MAX_DUMP_VALUE ? val.slice(0, MAX_DUMP_VALUE) + "…[truncated]" : val;
|
|
149
157
|
}
|
|
150
158
|
return out;
|
|
151
159
|
}
|
package/dist/ops.d.ts
CHANGED
|
@@ -45,9 +45,15 @@ export interface LimitsPatch {
|
|
|
45
45
|
}
|
|
46
46
|
/** Write subscription-limit overrides to the config file. Returns the saved limits. */
|
|
47
47
|
export declare function setLimits(patch: LimitsPatch): LimitsConfig;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Clear guard state. Scope: all (ledger + limits) | limits only | a single
|
|
50
|
+
* session | today's sessions. The `limits` scope clears the subscription
|
|
51
|
+
* detection latch + last snapshot — useful when you stop using a Pro/Max plan
|
|
52
|
+
* and want the dollar wall fully re-armed.
|
|
53
|
+
*/
|
|
49
54
|
export declare function resetLedger(opts: {
|
|
50
55
|
all?: boolean;
|
|
56
|
+
limits?: boolean;
|
|
51
57
|
session?: string;
|
|
52
58
|
today?: boolean;
|
|
53
59
|
}): string;
|
package/dist/ops.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { configPath, ensureGuardDir, DEFAULT_BUDGET, DEFAULT_LIMITS } from "./config.js";
|
|
10
10
|
import { loadLedger, saveLedger, emptyLedger } from "./ledger.js";
|
|
11
|
+
import { saveLimitsState, emptyLimitsState } from "./limits.js";
|
|
11
12
|
/**
|
|
12
13
|
* Wire the agent-guard hook into Claude Code settings for PreToolUse,
|
|
13
14
|
* UserPromptSubmit, and Stop. Idempotent: re-running adds nothing if the hook
|
|
@@ -99,11 +100,21 @@ export function setLimits(patch) {
|
|
|
99
100
|
writeFileSync(configPath(), JSON.stringify(file, null, 2) + "\n");
|
|
100
101
|
return limits;
|
|
101
102
|
}
|
|
102
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Clear guard state. Scope: all (ledger + limits) | limits only | a single
|
|
105
|
+
* session | today's sessions. The `limits` scope clears the subscription
|
|
106
|
+
* detection latch + last snapshot — useful when you stop using a Pro/Max plan
|
|
107
|
+
* and want the dollar wall fully re-armed.
|
|
108
|
+
*/
|
|
103
109
|
export function resetLedger(opts) {
|
|
104
110
|
if (opts.all) {
|
|
105
111
|
saveLedger(emptyLedger());
|
|
106
|
-
|
|
112
|
+
saveLimitsState(emptyLimitsState());
|
|
113
|
+
return "Ledger + subscription-limit state wiped.";
|
|
114
|
+
}
|
|
115
|
+
if (opts.limits) {
|
|
116
|
+
saveLimitsState(emptyLimitsState());
|
|
117
|
+
return "Subscription-limit state cleared (detection latch + snapshot).";
|
|
107
118
|
}
|
|
108
119
|
const ledger = loadLedger();
|
|
109
120
|
if (opts.session) {
|
|
@@ -120,5 +131,5 @@ export function resetLedger(opts) {
|
|
|
120
131
|
saveLedger(ledger);
|
|
121
132
|
return "Cleared today's sessions.";
|
|
122
133
|
}
|
|
123
|
-
return "Specify all, session <id>, or today.";
|
|
134
|
+
return "Specify all, limits, session <id>, or today.";
|
|
124
135
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -24,7 +24,7 @@ import { loadLedger, saveLedger, addSessionCost, rollingDailyCost, prune, } from
|
|
|
24
24
|
import { evaluate } from "./budget.js";
|
|
25
25
|
import { dispatchAlert } from "./alert.js";
|
|
26
26
|
import { assertSafeEndpoint, warnIfUnexpectedHost } from "./net.js";
|
|
27
|
-
import { parseUnifiedHeaders, recordHeaders, unifiedHeaderDump, logUnifiedHeaders, loadLimitsState, saveLimitsState, limitNotifyKey, } from "./limits.js";
|
|
27
|
+
import { parseUnifiedHeaders, recordHeaders, unifiedHeaderDump, logUnifiedHeaders, loadLimitsState, saveLimitsState, limitNotifyKey, WINDOW_MS, } from "./limits.js";
|
|
28
28
|
import { assessSnapshot, worstLevel } from "./pacing.js";
|
|
29
29
|
const UPSTREAMS = {
|
|
30
30
|
anthropic: "https://api.anthropic.com",
|
|
@@ -159,19 +159,34 @@ function captureLimits(cfg, headers, sessionId, now) {
|
|
|
159
159
|
logUnifiedHeaders(unifiedHeaderDump(rec), now);
|
|
160
160
|
state.headersLoggedAt = now;
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
state.snapshot = snap;
|
|
162
|
+
// Which windows newly cross into warn/danger (dedup vs. what we've alerted).
|
|
164
163
|
const assessments = assessSnapshot(snap, cfg.limits, now);
|
|
164
|
+
const newlyNotified = [];
|
|
165
165
|
const fresh = assessments.filter((a) => {
|
|
166
166
|
if (a.level === "ok")
|
|
167
167
|
return false;
|
|
168
168
|
const key = limitNotifyKey(a.window, a.level, a.resetAt);
|
|
169
169
|
if (state.notified[key])
|
|
170
170
|
return false;
|
|
171
|
-
|
|
171
|
+
newlyNotified.push(key);
|
|
172
172
|
return true;
|
|
173
173
|
});
|
|
174
|
-
|
|
174
|
+
// Re-read at write time to mitigate read-modify-write races: the file write is
|
|
175
|
+
// atomic (no corruption), but a concurrent response could otherwise clobber a
|
|
176
|
+
// newer snapshot or a just-set notified flag. Keep the newest snapshot by
|
|
177
|
+
// observedAt; union the notified flags.
|
|
178
|
+
const onDisk = loadLimitsState();
|
|
179
|
+
const keepNewer = onDisk.snapshot && onDisk.snapshot.observedAt > snap.observedAt;
|
|
180
|
+
const merged = {
|
|
181
|
+
version: 1,
|
|
182
|
+
subscriptionDetected: true,
|
|
183
|
+
snapshot: keepNewer ? onDisk.snapshot : snap,
|
|
184
|
+
notified: { ...onDisk.notified, ...state.notified },
|
|
185
|
+
headersLoggedAt: onDisk.headersLoggedAt ?? state.headersLoggedAt,
|
|
186
|
+
};
|
|
187
|
+
for (const key of newlyNotified)
|
|
188
|
+
merged.notified[key] = true;
|
|
189
|
+
saveLimitsState(merged);
|
|
175
190
|
if (fresh.length) {
|
|
176
191
|
const level = worstLevel(fresh);
|
|
177
192
|
dispatchAlert(cfg, {
|
|
@@ -189,6 +204,40 @@ function captureLimits(cfg, headers, sessionId, now) {
|
|
|
189
204
|
}
|
|
190
205
|
return true;
|
|
191
206
|
}
|
|
207
|
+
function planIsSubscription(plan) {
|
|
208
|
+
return plan === "pro" || plan === "max5" || plan === "max20";
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Should the dollar hard-cap 402 be suppressed for THIS proxy/request?
|
|
212
|
+
*
|
|
213
|
+
* Only for the **Anthropic** flavor — an OpenAI / other-API agent is billed per
|
|
214
|
+
* token and must keep its wall, even if a *different* (Claude Code) session once
|
|
215
|
+
* latched subscription mode on the shared `limits.json`. And only when we have a
|
|
216
|
+
* live reason to believe this is a flat-fee plan: either the operator pinned a
|
|
217
|
+
* subscription tier (`--plan`), or we saw real `unified-*` headers **recently**
|
|
218
|
+
* (within the 5-hour window). A stale, months-old detection must never disarm
|
|
219
|
+
* the wall — that's the bug this replaces (a permanent global latch).
|
|
220
|
+
*
|
|
221
|
+
* Residual edge: an Anthropic-flavor *API-key* agent run within 5h of a Claude
|
|
222
|
+
* Code subscription session (or under a pinned `--plan`) would also be
|
|
223
|
+
* suppressed. That's a narrow, opt-in-ish overlap; the common dual-use case
|
|
224
|
+
* (Claude Code + an OpenAI-flavor agent) is fully covered by the flavor gate.
|
|
225
|
+
*
|
|
226
|
+
* Trust model: in `auto` mode this trusts the upstream's `unified-*` headers, so
|
|
227
|
+
* a malicious/compromised Anthropic-compatible gateway could disarm the dollar
|
|
228
|
+
* wall by emitting fake subscription headers. That upstream already holds your
|
|
229
|
+
* API key (you pointed the proxy at it), and `net.ts` enforces https + warns on
|
|
230
|
+
* an unexpected host — so this isn't a new trust boundary. Pin `--plan` if you
|
|
231
|
+
* want suppression to be an explicit, upstream-independent choice.
|
|
232
|
+
*/
|
|
233
|
+
function dollarWallSuppressed(cfg, flavor, state, now) {
|
|
234
|
+
if (flavor !== "anthropic")
|
|
235
|
+
return false;
|
|
236
|
+
if (planIsSubscription(cfg.limits.plan))
|
|
237
|
+
return true;
|
|
238
|
+
const snap = state.snapshot;
|
|
239
|
+
return !!snap && now - snap.observedAt < WINDOW_MS["5h"] && !!(snap.fiveHour || snap.weekly);
|
|
240
|
+
}
|
|
192
241
|
export function startProxy(opts) {
|
|
193
242
|
const cfg = loadConfig();
|
|
194
243
|
const upstreamOrigin = assertSafeEndpoint(opts.upstream, "upstream").replace(/\/$/, "");
|
|
@@ -198,10 +247,10 @@ export function startProxy(opts) {
|
|
|
198
247
|
const sessionId = req.headers["x-agent-guard-session"] || `proxy:${todayKey(now)}`;
|
|
199
248
|
// 1) Pre-flight budget check — block before spending anything.
|
|
200
249
|
// Escape hatch: while a human has paused enforcement, never block (but still meter).
|
|
201
|
-
// Subscription mode is ALERT-ONLY:
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
250
|
+
// Subscription mode is ALERT-ONLY: a flat-fee Pro/Max session is paced, not
|
|
251
|
+
// dollar-gated. Scope that suppression tightly (flavor + pinned plan / fresh
|
|
252
|
+
// headers) so it never disarms the wall for a genuinely-billed agent.
|
|
253
|
+
let subscriptionMode = dollarWallSuppressed(cfg, opts.flavor, loadLimitsState(), now);
|
|
205
254
|
const ledger = loadLedger();
|
|
206
255
|
const sessionUSD = ledger.sessions[sessionId]?.costUSD ?? 0;
|
|
207
256
|
const dailyUSD = rollingDailyCost(ledger, now);
|
|
@@ -249,10 +298,13 @@ export function startProxy(opts) {
|
|
|
249
298
|
res.end(JSON.stringify({ error: "kill-switch proxy: upstream fetch failed", detail: String(err) }));
|
|
250
299
|
return;
|
|
251
300
|
}
|
|
252
|
-
// 2.5) Read Anthropic's subscription rate-limit headers (alert-only).
|
|
301
|
+
// 2.5) Read Anthropic's subscription rate-limit headers (alert-only). If this
|
|
302
|
+
// response carried them, treat the session as subscription for alert purposes
|
|
303
|
+
// too — even if the pre-flight check (run before we'd seen any headers) didn't.
|
|
253
304
|
if (opts.flavor === "anthropic") {
|
|
254
305
|
try {
|
|
255
|
-
captureLimits(cfg, upstream.headers, sessionId, Date.now())
|
|
306
|
+
if (captureLimits(cfg, upstream.headers, sessionId, Date.now()))
|
|
307
|
+
subscriptionMode = true;
|
|
256
308
|
}
|
|
257
309
|
catch {
|
|
258
310
|
/* limit capture must never break the proxied response */
|
|
@@ -294,11 +346,12 @@ export function startProxy(opts) {
|
|
|
294
346
|
// Re-load ledger (the request may have been concurrent) and meter.
|
|
295
347
|
const fresh = loadLedger();
|
|
296
348
|
meter(cfg, fresh, sessionId, parsed, Date.now());
|
|
297
|
-
// Post-meter soft-cap alert (once).
|
|
349
|
+
// Post-meter soft-cap alert (once). Skipped in subscription mode — the
|
|
350
|
+
// dollars are meaningless on a flat-fee plan, so a USD warn is just noise.
|
|
298
351
|
const after = fresh.sessions[sessionId]?.costUSD ?? 0;
|
|
299
352
|
const afterDaily = rollingDailyCost(fresh, Date.now());
|
|
300
353
|
const v2 = evaluate({ sessionUSD: after, dailyUSD: afterDaily }, cfg.budget);
|
|
301
|
-
if (v2.level === "warn" && !blockedNotified[`warn:${sessionId}`]) {
|
|
354
|
+
if (v2.level === "warn" && !subscriptionMode && !blockedNotified[`warn:${sessionId}`]) {
|
|
302
355
|
blockedNotified[`warn:${sessionId}`] = true;
|
|
303
356
|
dispatchAlert(cfg, {
|
|
304
357
|
ts: Date.now(), source: "proxy", sessionId, level: "warn",
|
package/dist/report.d.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { type SessionRecord } from "./ledger.js";
|
|
12
12
|
import { type Budget, type VerdictLevel } from "./budget.js";
|
|
13
13
|
import { type PacingAssessment, type PacingLevel } from "./pacing.js";
|
|
14
|
+
import type { GuardConfig } from "./config.js";
|
|
15
|
+
import type { Ledger } from "./ledger.js";
|
|
14
16
|
export interface LimitsReport {
|
|
15
17
|
/** Where the numbers came from. "none" = no data and no pinned plan to estimate from. */
|
|
16
18
|
source: "headers" | "estimated" | "none";
|
|
@@ -35,6 +37,12 @@ export interface StatusReport {
|
|
|
35
37
|
/** Subscription rate-limit pacing — present whenever we have data to show. */
|
|
36
38
|
limits: LimitsReport;
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Compute the subscription rate-limit section. Exported so the Claude Code hook
|
|
42
|
+
* can reuse its already-loaded cfg + ledger instead of paying for a second
|
|
43
|
+
* loadConfig/loadLedger on every tool call.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildLimitsReport(cfg: GuardConfig, ledger: Ledger, now: number): LimitsReport;
|
|
38
46
|
/**
|
|
39
47
|
* Render the subscription rate-limit section as plain text lines (no color), so
|
|
40
48
|
* both the `agent-guard` and `ks guard` status views stay identical. Returns an
|
package/dist/report.js
CHANGED
|
@@ -12,25 +12,38 @@ import { loadConfig } from "./config.js";
|
|
|
12
12
|
import { isPaused, pauseExpiry } from "./config.js";
|
|
13
13
|
import { loadLedger, rollingDailyCost } from "./ledger.js";
|
|
14
14
|
import { evaluate } from "./budget.js";
|
|
15
|
-
import { loadLimitsState } from "./limits.js";
|
|
15
|
+
import { loadLimitsState, WINDOW_MS } from "./limits.js";
|
|
16
16
|
import { assessSnapshot, worstLevel } from "./pacing.js";
|
|
17
17
|
import { estimateSnapshot } from "./estimate.js";
|
|
18
18
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Compute the subscription rate-limit section. Exported so the Claude Code hook
|
|
21
|
+
* can reuse its already-loaded cfg + ledger instead of paying for a second
|
|
22
|
+
* loadConfig/loadLedger on every tool call.
|
|
23
|
+
*/
|
|
24
|
+
export function buildLimitsReport(cfg, ledger, now) {
|
|
20
25
|
const state = loadLimitsState();
|
|
21
26
|
const thresholds = cfg.limits;
|
|
22
27
|
const plan = cfg.limits.plan;
|
|
23
|
-
// Prefer real header data
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
// Prefer real header data — but only while it's still usable. A snapshot older
|
|
29
|
+
// than the weekly window is too stale to trust at all; and any single window
|
|
30
|
+
// whose reset time has already passed has since rolled over (its utilization is
|
|
31
|
+
// from a prior window), so we drop it rather than present expired numbers — and
|
|
32
|
+
// a reset time in the past — as if they were live. If nothing usable remains we
|
|
33
|
+
// fall through to the estimate (or "none").
|
|
34
|
+
const snap = state.snapshot;
|
|
35
|
+
if (snap && now - snap.observedAt < WINDOW_MS.weekly) {
|
|
36
|
+
const windows = assessSnapshot(snap, thresholds, now).filter((w) => !(w.resetAt != null && w.resetAt <= now));
|
|
37
|
+
if (windows.length) {
|
|
38
|
+
return {
|
|
39
|
+
source: "headers",
|
|
40
|
+
plan,
|
|
41
|
+
subscriptionDetected: state.subscriptionDetected,
|
|
42
|
+
observedAt: snap.observedAt,
|
|
43
|
+
windows,
|
|
44
|
+
level: worstLevel(windows),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
34
47
|
}
|
|
35
48
|
// Otherwise estimate, but only when the user pinned a tier (opt-in, fuzzy).
|
|
36
49
|
if (plan === "pro" || plan === "max5" || plan === "max20") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kill-switch/agent-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|