@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 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();
@@ -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 by summing the
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
- * This is deliberately approximate and always labelled as such:
12
- * - Anthropic meters opaque "prompts" / "active hours", not tokens, so the
13
- * token budgets below are calibrated rough equivalents, not contractual.
14
- * - The ledger stores a session's cumulative tokens against a single
15
- * `lastAt`, not a time series, so a long session is counted wholesale into
16
- * whichever window its last activity falls in.
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
- * It exists to give hook-only users *a* signal and to nudge them toward
19
- * `ks guard proxy` for exact numbers never to block (subscription mode is
20
- * alert-only). When in doubt it under-claims utilization so it won't cry wolf.
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 token-equivalent budgets per window. Pro is the published
27
- * baseline; Max 5x / 20x scale the 5-hour burst ~linearly with the multiplier,
28
- * while the weekly cap scales more conservatively (Anthropic's weekly multiplier
29
- * is smaller than the per-session one). Tune via config if your mileage differs.
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
- fiveHourTokens: number;
33
- weeklyTokens: number;
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
- * Reset times are derived from the rolling window assumption (oldest in-window
39
- * activity + window length is unknowable here, so we report the window end from
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 by summing the
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
- * This is deliberately approximate and always labelled as such:
12
- * - Anthropic meters opaque "prompts" / "active hours", not tokens, so the
13
- * token budgets below are calibrated rough equivalents, not contractual.
14
- * - The ledger stores a session's cumulative tokens against a single
15
- * `lastAt`, not a time series, so a long session is counted wholesale into
16
- * whichever window its last activity falls in.
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
- * It exists to give hook-only users *a* signal and to nudge them toward
19
- * `ks guard proxy` for exact numbers never to block (subscription mode is
20
- * alert-only). When in doubt it under-claims utilization so it won't cry wolf.
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
- // Calibrated rough equivalents Pro ≈ 45 prompts / 5h, modest weekly cap.
25
- pro: { fiveHourTokens: 8_000_000, weeklyTokens: 120_000_000 },
26
- max5: { fiveHourTokens: 40_000_000, weeklyTokens: 480_000_000 },
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 tokens (input+output) across sessions whose last activity is within `windowMs`. */
32
- function tokensInWindow(ledger, now, windowMs) {
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 += (s.inputTokens || 0) + (s.outputTokens || 0);
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
- * Reset times are derived from the rolling window assumption (oldest in-window
43
- * activity + window length is unknowable here, so we report the window end from
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 fiveTokens = tokensInWindow(ledger, now, FIVE_HOUR_MS);
49
- const weekTokens = tokensInWindow(ledger, now, WEEK_MS);
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
- utilization: clamp(fiveTokens / b.fiveHourTokens),
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 { buildStatusReport } from "./report.js";
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 = buildStatusReport(now).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
- out[key] = Array.isArray(v) ? v.join(", ") : v;
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
- /** Clear the spend ledger. Scope: all | a single session | today's sessions. */
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
- /** Clear the spend ledger. Scope: all | a single session | today's sessions. */
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
- return "Ledger wiped.";
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
- state.subscriptionDetected = true;
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
- state.notified[key] = true;
171
+ newlyNotified.push(key);
172
172
  return true;
173
173
  });
174
- saveLimitsState(state);
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: once we've seen Anthropic's unified
202
- // rate-limit headers, the session is on a flat-fee plan where dollars are
203
- // meaningless, so we never 402 it we only pace + warn.
204
- const subscriptionMode = loadLimitsState().subscriptionDetected;
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
- function buildLimitsReport(cfg, ledger, now) {
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 when we have it.
24
- if (state.snapshot) {
25
- const windows = assessSnapshot(state.snapshot, thresholds, now);
26
- return {
27
- source: "headers",
28
- plan,
29
- subscriptionDetected: state.subscriptionDetected,
30
- observedAt: state.snapshot.observedAt,
31
- windows,
32
- level: worstLevel(windows),
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",
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": {