@kill-switch/agent-guard 0.1.0 → 0.1.2
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 +51 -1
- package/dist/alert.d.ts +12 -2
- package/dist/alert.js +15 -1
- package/dist/cli.js +46 -15
- package/dist/config.d.ts +25 -0
- package/dist/config.js +22 -0
- package/dist/estimate.d.ts +44 -0
- package/dist/estimate.js +71 -0
- package/dist/hook.js +35 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/limits.d.ts +83 -0
- package/dist/limits.js +133 -0
- package/dist/net.d.ts +15 -0
- package/dist/net.js +45 -0
- package/dist/ops.d.ts +12 -0
- package/dist/ops.js +27 -1
- package/dist/pacing.d.ts +53 -0
- package/dist/pacing.js +127 -0
- package/dist/proxy.d.ts +2 -1
- package/dist/proxy.js +75 -4
- package/dist/report.d.ts +25 -0
- package/dist/report.js +90 -0
- package/package.json +3 -2
package/dist/limits.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription rate-limit awareness — the "how much of my Claude Code plan have
|
|
3
|
+
* I burned" half of the guard, complementary to the dollar ledger.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code on a Pro/Max subscription is NOT billed per token — the scarce
|
|
6
|
+
* resource is the plan's rate-limit quota, measured in two rolling windows:
|
|
7
|
+
* - a 5-hour window (burst protection), and
|
|
8
|
+
* - a 7-day window (the real lockout risk, "resets a couple times a month").
|
|
9
|
+
*
|
|
10
|
+
* Anthropic reports exactly where you stand in both windows on every API
|
|
11
|
+
* response, via `anthropic-ratelimit-unified-*` headers. The proxy already sees
|
|
12
|
+
* every response, so it can read these and know the *real* remaining quota and
|
|
13
|
+
* the *real* reset times — no estimation, no guessing when limits reset.
|
|
14
|
+
*
|
|
15
|
+
* This module owns: parsing those headers into a {@link LimitSnapshot}, and the
|
|
16
|
+
* small global state file (`limits.json`) that persists the latest snapshot plus
|
|
17
|
+
* whether we've ever seen subscription headers (so the rest of the guard can
|
|
18
|
+
* switch into alert-only subscription mode).
|
|
19
|
+
*
|
|
20
|
+
* Header formats are owned by Anthropic, not us, and aren't fully contract-
|
|
21
|
+
* documented, so parsing is deliberately defensive: utilization is accepted as
|
|
22
|
+
* either a 0–1 fraction or a 0–100 percent; reset is accepted as an ISO 8601
|
|
23
|
+
* timestamp, an epoch (s or ms), or a relative seconds-until-reset.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
26
|
+
import { limitsPath, ensureGuardDir } from "./config.js";
|
|
27
|
+
/** Nominal window durations, used for pacing math when a reset time is unknown. */
|
|
28
|
+
export const WINDOW_MS = {
|
|
29
|
+
"5h": 5 * 60 * 60 * 1000,
|
|
30
|
+
weekly: 7 * 24 * 60 * 60 * 1000,
|
|
31
|
+
};
|
|
32
|
+
export function emptyLimitsState() {
|
|
33
|
+
return { version: 1, subscriptionDetected: false, snapshot: null, notified: {} };
|
|
34
|
+
}
|
|
35
|
+
export function loadLimitsState() {
|
|
36
|
+
try {
|
|
37
|
+
const data = JSON.parse(readFileSync(limitsPath(), "utf8"));
|
|
38
|
+
if (data && data.version === 1) {
|
|
39
|
+
return {
|
|
40
|
+
version: 1,
|
|
41
|
+
subscriptionDetected: data.subscriptionDetected ?? false,
|
|
42
|
+
snapshot: data.snapshot ?? null,
|
|
43
|
+
notified: data.notified ?? {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* fall through to empty */
|
|
49
|
+
}
|
|
50
|
+
return emptyLimitsState();
|
|
51
|
+
}
|
|
52
|
+
export function saveLimitsState(state) {
|
|
53
|
+
ensureGuardDir();
|
|
54
|
+
const path = limitsPath();
|
|
55
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
56
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
/** Wrap a plain `Record<string,string>` so it satisfies {@link HeaderGetter}. */
|
|
60
|
+
export function recordHeaders(rec) {
|
|
61
|
+
const lower = {};
|
|
62
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
63
|
+
if (v === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
lower[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
|
|
66
|
+
}
|
|
67
|
+
return { get: (name) => lower[name.toLowerCase()] ?? null };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse a utilization header value into a 0–1 fraction.
|
|
71
|
+
* Accepts "0.62", "62", "62%". Values >1.5 are treated as percentages.
|
|
72
|
+
*/
|
|
73
|
+
export function parseUtilization(raw) {
|
|
74
|
+
if (raw == null)
|
|
75
|
+
return null;
|
|
76
|
+
const n = Number(String(raw).replace(/%$/, "").trim());
|
|
77
|
+
if (!Number.isFinite(n) || n < 0)
|
|
78
|
+
return null;
|
|
79
|
+
const frac = n > 1.5 ? n / 100 : n;
|
|
80
|
+
return Math.max(0, Math.min(1, frac));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a reset header value into an absolute epoch-ms timestamp.
|
|
84
|
+
* Accepts ISO 8601 ("2026-06-13T18:00:00Z"), epoch seconds, epoch ms, or a
|
|
85
|
+
* relative seconds-until-reset (small numbers). `now` anchors the relative case.
|
|
86
|
+
*/
|
|
87
|
+
export function parseReset(raw, now) {
|
|
88
|
+
if (raw == null)
|
|
89
|
+
return null;
|
|
90
|
+
const s = String(raw).trim();
|
|
91
|
+
if (!s)
|
|
92
|
+
return null;
|
|
93
|
+
// Numeric: disambiguate ms / seconds / relative-seconds by magnitude.
|
|
94
|
+
if (/^\d+(\.\d+)?$/.test(s)) {
|
|
95
|
+
const n = Number(s);
|
|
96
|
+
if (!Number.isFinite(n))
|
|
97
|
+
return null;
|
|
98
|
+
if (n > 1e12)
|
|
99
|
+
return Math.round(n); // epoch ms
|
|
100
|
+
if (n > 1e9)
|
|
101
|
+
return Math.round(n * 1000); // epoch seconds
|
|
102
|
+
return Math.round(now + n * 1000); // relative seconds-until-reset
|
|
103
|
+
}
|
|
104
|
+
const t = Date.parse(s);
|
|
105
|
+
return Number.isNaN(t) ? null : t;
|
|
106
|
+
}
|
|
107
|
+
function parseWindow(h, prefix, key, now) {
|
|
108
|
+
const util = parseUtilization(h.get(`${prefix}-${key === "5h" ? "5h" : "7d"}-utilization`));
|
|
109
|
+
const reset = parseReset(h.get(`${prefix}-${key === "5h" ? "5h" : "7d"}-reset`), now);
|
|
110
|
+
const status = h.get(`${prefix}-${key === "5h" ? "5h" : "7d"}-status`) || undefined;
|
|
111
|
+
if (util == null && reset == null && !status)
|
|
112
|
+
return null;
|
|
113
|
+
return { utilization: util ?? 0, resetAt: reset, status };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Read the `anthropic-ratelimit-unified-*` family into a {@link LimitSnapshot}.
|
|
117
|
+
* Returns null when no unified headers are present (i.e. not a subscription
|
|
118
|
+
* session, or an endpoint that doesn't emit them) — callers use that null to
|
|
119
|
+
* mean "stay in dollar mode for this response".
|
|
120
|
+
*/
|
|
121
|
+
export function parseUnifiedHeaders(h, now) {
|
|
122
|
+
const prefix = "anthropic-ratelimit-unified";
|
|
123
|
+
const fiveHour = parseWindow(h, prefix, "5h", now);
|
|
124
|
+
const weekly = parseWindow(h, prefix, "weekly", now);
|
|
125
|
+
const status = h.get(`${prefix}-status`) || null;
|
|
126
|
+
if (!fiveHour && !weekly && !status)
|
|
127
|
+
return null;
|
|
128
|
+
return { fiveHour, weekly, status, observedAt: now };
|
|
129
|
+
}
|
|
130
|
+
/** Stable dedup key for a pacing alert: re-alerts when the window resets. */
|
|
131
|
+
export function limitNotifyKey(window, level, resetAt) {
|
|
132
|
+
return `${window}:${level}:${resetAt ?? 0}`;
|
|
133
|
+
}
|
package/dist/net.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint safety checks (security: M-1).
|
|
3
|
+
*
|
|
4
|
+
* The proxy forwards the caller's LLM API key to `upstream`, and breach alerts
|
|
5
|
+
* POST the user's ks_live key to `apiUrl`. A poisoned local config / flag could
|
|
6
|
+
* redirect those credentials to an attacker. We can't host-allowlist (users may
|
|
7
|
+
* self-host the API), but we can refuse insecure schemes and surface a warning
|
|
8
|
+
* when the host isn't the expected default — so a redirect is never silent.
|
|
9
|
+
*/
|
|
10
|
+
/** True for http(s) URLs that are safe to send credentials to. */
|
|
11
|
+
export declare function isSafeEndpoint(raw: string): boolean;
|
|
12
|
+
/** Validate a credential-bearing endpoint; throws on an unsafe URL. */
|
|
13
|
+
export declare function assertSafeEndpoint(raw: string, label: string): string;
|
|
14
|
+
/** Warn (non-fatal) to stderr when a credential-bearing host isn't the default. */
|
|
15
|
+
export declare function warnIfUnexpectedHost(raw: string, expectedHost: string, label: string): void;
|
package/dist/net.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint safety checks (security: M-1).
|
|
3
|
+
*
|
|
4
|
+
* The proxy forwards the caller's LLM API key to `upstream`, and breach alerts
|
|
5
|
+
* POST the user's ks_live key to `apiUrl`. A poisoned local config / flag could
|
|
6
|
+
* redirect those credentials to an attacker. We can't host-allowlist (users may
|
|
7
|
+
* self-host the API), but we can refuse insecure schemes and surface a warning
|
|
8
|
+
* when the host isn't the expected default — so a redirect is never silent.
|
|
9
|
+
*/
|
|
10
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
11
|
+
/** True for http(s) URLs that are safe to send credentials to. */
|
|
12
|
+
export function isSafeEndpoint(raw) {
|
|
13
|
+
try {
|
|
14
|
+
const u = new URL(raw);
|
|
15
|
+
if (u.protocol === "https:")
|
|
16
|
+
return true;
|
|
17
|
+
// plaintext http is only acceptable to loopback (local dev / self-test)
|
|
18
|
+
return u.protocol === "http:" && LOCAL_HOSTS.has(u.hostname);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Validate a credential-bearing endpoint; throws on an unsafe URL. */
|
|
25
|
+
export function assertSafeEndpoint(raw, label) {
|
|
26
|
+
if (!isSafeEndpoint(raw)) {
|
|
27
|
+
throw new Error(`Refusing to use ${label}="${raw}": it must be an https:// URL ` +
|
|
28
|
+
`(http:// is allowed only for localhost). This protects your API key ` +
|
|
29
|
+
`from being sent over an insecure or unexpected channel.`);
|
|
30
|
+
}
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
/** Warn (non-fatal) to stderr when a credential-bearing host isn't the default. */
|
|
34
|
+
export function warnIfUnexpectedHost(raw, expectedHost, label) {
|
|
35
|
+
try {
|
|
36
|
+
const host = new URL(raw).hostname;
|
|
37
|
+
if (host !== expectedHost) {
|
|
38
|
+
process.stderr.write(`⚠ agent-guard: ${label} points at "${host}", not "${expectedHost}". ` +
|
|
39
|
+
`Your API key will be sent there — make sure that's intended.\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* assertSafeEndpoint handles invalid URLs */
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/ops.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* subcommands, so both drive the same logic instead of duplicating it (or
|
|
4
4
|
* shelling out). Pure side-effecting helpers over config + Claude Code settings.
|
|
5
5
|
*/
|
|
6
|
+
import { type LimitsConfig } from "./config.js";
|
|
6
7
|
import type { Budget } from "./budget.js";
|
|
7
8
|
export interface InstallOptions {
|
|
8
9
|
/** Install into ~/.claude/settings.json instead of ./.claude/settings.json */
|
|
@@ -33,6 +34,17 @@ export interface BudgetPatch {
|
|
|
33
34
|
}
|
|
34
35
|
/** Write budget/webhook overrides to the config file. Returns the saved budget. */
|
|
35
36
|
export declare function setBudget(patch: BudgetPatch): Budget;
|
|
37
|
+
/** Partial subscription-limits update. Merges onto the existing config file. */
|
|
38
|
+
export interface LimitsPatch {
|
|
39
|
+
plan?: LimitsConfig["plan"];
|
|
40
|
+
fiveHourSoftPct?: number;
|
|
41
|
+
fiveHourDangerPct?: number;
|
|
42
|
+
weeklySoftPct?: number;
|
|
43
|
+
weeklyDangerPct?: number;
|
|
44
|
+
burnRatioWarn?: number;
|
|
45
|
+
}
|
|
46
|
+
/** Write subscription-limit overrides to the config file. Returns the saved limits. */
|
|
47
|
+
export declare function setLimits(patch: LimitsPatch): LimitsConfig;
|
|
36
48
|
/** Clear the spend ledger. Scope: all | a single session | today's sessions. */
|
|
37
49
|
export declare function resetLedger(opts: {
|
|
38
50
|
all?: boolean;
|
package/dist/ops.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { configPath, ensureGuardDir, DEFAULT_BUDGET } from "./config.js";
|
|
9
|
+
import { configPath, ensureGuardDir, DEFAULT_BUDGET, DEFAULT_LIMITS } from "./config.js";
|
|
10
10
|
import { loadLedger, saveLedger, emptyLedger } from "./ledger.js";
|
|
11
11
|
/**
|
|
12
12
|
* Wire the agent-guard hook into Claude Code settings for PreToolUse,
|
|
@@ -73,6 +73,32 @@ export function setBudget(patch) {
|
|
|
73
73
|
writeFileSync(configPath(), JSON.stringify(file, null, 2) + "\n");
|
|
74
74
|
return budget;
|
|
75
75
|
}
|
|
76
|
+
/** Write subscription-limit overrides to the config file. Returns the saved limits. */
|
|
77
|
+
export function setLimits(patch) {
|
|
78
|
+
let file = {};
|
|
79
|
+
try {
|
|
80
|
+
file = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* new */
|
|
84
|
+
}
|
|
85
|
+
const limits = { ...DEFAULT_LIMITS, ...(file.limits ?? {}) };
|
|
86
|
+
if (patch.plan && ["auto", "pro", "max5", "max20"].includes(patch.plan))
|
|
87
|
+
limits.plan = patch.plan;
|
|
88
|
+
const setPct = (k, v) => {
|
|
89
|
+
if (v !== undefined && Number.isFinite(v))
|
|
90
|
+
limits[k] = v;
|
|
91
|
+
};
|
|
92
|
+
setPct("fiveHourSoftPct", patch.fiveHourSoftPct);
|
|
93
|
+
setPct("fiveHourDangerPct", patch.fiveHourDangerPct);
|
|
94
|
+
setPct("weeklySoftPct", patch.weeklySoftPct);
|
|
95
|
+
setPct("weeklyDangerPct", patch.weeklyDangerPct);
|
|
96
|
+
setPct("burnRatioWarn", patch.burnRatioWarn);
|
|
97
|
+
file.limits = limits;
|
|
98
|
+
ensureGuardDir();
|
|
99
|
+
writeFileSync(configPath(), JSON.stringify(file, null, 2) + "\n");
|
|
100
|
+
return limits;
|
|
101
|
+
}
|
|
76
102
|
/** Clear the spend ledger. Scope: all | a single session | today's sessions. */
|
|
77
103
|
export function resetLedger(opts) {
|
|
78
104
|
if (opts.all) {
|
package/dist/pacing.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pacing engine — the "intelligent" half the user asked for.
|
|
3
|
+
*
|
|
4
|
+
* Blocking at "90% of weekly" is dumb: 90% on day 6 is fine, but 60% on day 2
|
|
5
|
+
* means you'll be locked out mid-week. The resource you're spending is a budget
|
|
6
|
+
* that should last until the window resets, so the real question isn't "how much
|
|
7
|
+
* is left" — it's "at this burn rate, will I run out before the window resets?".
|
|
8
|
+
*
|
|
9
|
+
* For each window we compute:
|
|
10
|
+
* - expected utilization = fraction of the window already elapsed
|
|
11
|
+
* - burn ratio = actual / expected (1.0 = perfectly on pace)
|
|
12
|
+
* - projected exhaustion = when utilization hits 1.0 at the current rate
|
|
13
|
+
* - will-lock-out-before-reset = exhaustion lands before the reset
|
|
14
|
+
*
|
|
15
|
+
* The level (ok / warn / danger) is the worse of two signals: absolute
|
|
16
|
+
* utilization against soft/danger thresholds, and pacing (burning fast enough to
|
|
17
|
+
* lock out before reset). In subscription mode the guard never blocks on this —
|
|
18
|
+
* it surfaces the assessment as a warning so the human can ease off or switch to
|
|
19
|
+
* a cheaper model before Anthropic's own limit stops them mid-task.
|
|
20
|
+
*/
|
|
21
|
+
import { type LimitSnapshot, type LimitWindow, type WindowState } from "./limits.js";
|
|
22
|
+
export interface PacingThresholds {
|
|
23
|
+
/** Per-window soft / danger utilization thresholds (0–1). */
|
|
24
|
+
fiveHourSoftPct: number;
|
|
25
|
+
fiveHourDangerPct: number;
|
|
26
|
+
weeklySoftPct: number;
|
|
27
|
+
weeklyDangerPct: number;
|
|
28
|
+
/** Burn ratio above which pacing alone escalates (with meaningful utilization). */
|
|
29
|
+
burnRatioWarn: number;
|
|
30
|
+
}
|
|
31
|
+
export type PacingLevel = "ok" | "warn" | "danger";
|
|
32
|
+
export interface PacingAssessment {
|
|
33
|
+
window: LimitWindow;
|
|
34
|
+
/** 0–1 fraction of the window consumed. */
|
|
35
|
+
utilization: number;
|
|
36
|
+
/** Epoch ms the window resets, or null if unknown. */
|
|
37
|
+
resetAt: number | null;
|
|
38
|
+
/** actual / expected utilization; null when elapsed is unknown (no reset time). */
|
|
39
|
+
burnRatio: number | null;
|
|
40
|
+
/** Epoch ms we project utilization hits 100% at the current rate, or null. */
|
|
41
|
+
projectedExhaustionAt: number | null;
|
|
42
|
+
/** True when projected exhaustion lands before the window resets. */
|
|
43
|
+
willLockOutBeforeReset: boolean;
|
|
44
|
+
level: PacingLevel;
|
|
45
|
+
/** One-line human summary. */
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
/** Assess a single window's pacing. `now` is epoch ms. */
|
|
49
|
+
export declare function assessWindow(window: LimitWindow, state: WindowState, thresholds: PacingThresholds, now: number): PacingAssessment;
|
|
50
|
+
/** Assess every window present in a snapshot. */
|
|
51
|
+
export declare function assessSnapshot(snap: LimitSnapshot, thresholds: PacingThresholds, now: number): PacingAssessment[];
|
|
52
|
+
/** Worst level across a set of assessments. */
|
|
53
|
+
export declare function worstLevel(assessments: PacingAssessment[]): PacingLevel;
|
package/dist/pacing.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pacing engine — the "intelligent" half the user asked for.
|
|
3
|
+
*
|
|
4
|
+
* Blocking at "90% of weekly" is dumb: 90% on day 6 is fine, but 60% on day 2
|
|
5
|
+
* means you'll be locked out mid-week. The resource you're spending is a budget
|
|
6
|
+
* that should last until the window resets, so the real question isn't "how much
|
|
7
|
+
* is left" — it's "at this burn rate, will I run out before the window resets?".
|
|
8
|
+
*
|
|
9
|
+
* For each window we compute:
|
|
10
|
+
* - expected utilization = fraction of the window already elapsed
|
|
11
|
+
* - burn ratio = actual / expected (1.0 = perfectly on pace)
|
|
12
|
+
* - projected exhaustion = when utilization hits 1.0 at the current rate
|
|
13
|
+
* - will-lock-out-before-reset = exhaustion lands before the reset
|
|
14
|
+
*
|
|
15
|
+
* The level (ok / warn / danger) is the worse of two signals: absolute
|
|
16
|
+
* utilization against soft/danger thresholds, and pacing (burning fast enough to
|
|
17
|
+
* lock out before reset). In subscription mode the guard never blocks on this —
|
|
18
|
+
* it surfaces the assessment as a warning so the human can ease off or switch to
|
|
19
|
+
* a cheaper model before Anthropic's own limit stops them mid-task.
|
|
20
|
+
*/
|
|
21
|
+
import { WINDOW_MS } from "./limits.js";
|
|
22
|
+
function windowLabel(w) {
|
|
23
|
+
return w === "5h" ? "5-hour" : "weekly";
|
|
24
|
+
}
|
|
25
|
+
function fmtClock(epochMs, now) {
|
|
26
|
+
const dt = new Date(epochMs);
|
|
27
|
+
const sameDay = new Date(now).toDateString() === dt.toDateString();
|
|
28
|
+
// Day-of-week + time reads naturally for a multi-day weekly window.
|
|
29
|
+
return sameDay
|
|
30
|
+
? dt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
|
31
|
+
: dt.toLocaleString([], { weekday: "short", hour: "numeric", minute: "2-digit" });
|
|
32
|
+
}
|
|
33
|
+
function fmtDuration(ms) {
|
|
34
|
+
if (ms <= 0)
|
|
35
|
+
return "now";
|
|
36
|
+
const h = ms / (60 * 60 * 1000);
|
|
37
|
+
if (h < 1)
|
|
38
|
+
return `${Math.round(ms / 60000)}m`;
|
|
39
|
+
if (h < 24)
|
|
40
|
+
return `${h.toFixed(h < 10 ? 1 : 0)}h`;
|
|
41
|
+
return `${(h / 24).toFixed(1)}d`;
|
|
42
|
+
}
|
|
43
|
+
/** Assess a single window's pacing. `now` is epoch ms. */
|
|
44
|
+
export function assessWindow(window, state, thresholds, now) {
|
|
45
|
+
const util = Math.max(0, Math.min(1, state.utilization));
|
|
46
|
+
const soft = window === "5h" ? thresholds.fiveHourSoftPct : thresholds.weeklySoftPct;
|
|
47
|
+
const danger = window === "5h" ? thresholds.fiveHourDangerPct : thresholds.weeklyDangerPct;
|
|
48
|
+
const duration = WINDOW_MS[window];
|
|
49
|
+
// elapsed = duration - timeUntilReset; only known when we have a reset time.
|
|
50
|
+
let elapsed = null;
|
|
51
|
+
if (state.resetAt != null) {
|
|
52
|
+
const untilReset = state.resetAt - now;
|
|
53
|
+
elapsed = Math.max(0, Math.min(duration, duration - untilReset));
|
|
54
|
+
}
|
|
55
|
+
let burnRatio = null;
|
|
56
|
+
let projectedExhaustionAt = null;
|
|
57
|
+
let willLockOut = false;
|
|
58
|
+
if (elapsed != null && elapsed > 0) {
|
|
59
|
+
const expected = elapsed / duration;
|
|
60
|
+
burnRatio = expected > 0 ? util / expected : null;
|
|
61
|
+
if (util > 0 && util < 1) {
|
|
62
|
+
const ratePerMs = util / elapsed; // utilization per ms so far
|
|
63
|
+
const msToFull = (1 - util) / ratePerMs;
|
|
64
|
+
projectedExhaustionAt = now + msToFull;
|
|
65
|
+
if (state.resetAt != null)
|
|
66
|
+
willLockOut = projectedExhaustionAt < state.resetAt;
|
|
67
|
+
}
|
|
68
|
+
else if (util >= 1) {
|
|
69
|
+
projectedExhaustionAt = now;
|
|
70
|
+
willLockOut = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Level: worse of absolute-utilization and pacing signals. A projected lockout
|
|
74
|
+
// only escalates once you've used a meaningful slice of the window — otherwise
|
|
75
|
+
// tiny noise near a linear burn (e.g. 15% used, exhaustion landing a few hours
|
|
76
|
+
// before a reset days away) would scream danger far too early. We gate it at
|
|
77
|
+
// half the soft threshold.
|
|
78
|
+
const lockoutFloor = soft * 0.5;
|
|
79
|
+
const lockoutMatters = willLockOut && util >= lockoutFloor;
|
|
80
|
+
let level = "ok";
|
|
81
|
+
if (util >= danger || (lockoutMatters && util >= soft))
|
|
82
|
+
level = "danger";
|
|
83
|
+
else if (util >= soft ||
|
|
84
|
+
lockoutMatters ||
|
|
85
|
+
(burnRatio != null && burnRatio >= thresholds.burnRatioWarn && util >= lockoutFloor))
|
|
86
|
+
level = "warn";
|
|
87
|
+
const pct = Math.round(util * 100);
|
|
88
|
+
const label = windowLabel(window);
|
|
89
|
+
const parts = [`${label} limit ${pct}% used`];
|
|
90
|
+
if (state.resetAt != null)
|
|
91
|
+
parts.push(`resets ${fmtClock(state.resetAt, now)}`);
|
|
92
|
+
if (burnRatio != null && burnRatio >= 1.2 && level !== "ok")
|
|
93
|
+
parts.push(`burning ${burnRatio.toFixed(1)}× pace`);
|
|
94
|
+
// Only surface the lockout projection once it actually drives the level —
|
|
95
|
+
// keeps low-utilization projection noise out of the message.
|
|
96
|
+
if (lockoutMatters && projectedExhaustionAt != null && state.resetAt != null) {
|
|
97
|
+
const before = state.resetAt - projectedExhaustionAt;
|
|
98
|
+
parts.push(`→ lockout in ~${fmtDuration(projectedExhaustionAt - now)} (${fmtDuration(before)} before reset)`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
window,
|
|
102
|
+
utilization: util,
|
|
103
|
+
resetAt: state.resetAt,
|
|
104
|
+
burnRatio,
|
|
105
|
+
projectedExhaustionAt,
|
|
106
|
+
willLockOutBeforeReset: willLockOut,
|
|
107
|
+
level,
|
|
108
|
+
message: parts.join(", "),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Assess every window present in a snapshot. */
|
|
112
|
+
export function assessSnapshot(snap, thresholds, now) {
|
|
113
|
+
const out = [];
|
|
114
|
+
if (snap.fiveHour)
|
|
115
|
+
out.push(assessWindow("5h", snap.fiveHour, thresholds, now));
|
|
116
|
+
if (snap.weekly)
|
|
117
|
+
out.push(assessWindow("weekly", snap.weekly, thresholds, now));
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** Worst level across a set of assessments. */
|
|
121
|
+
export function worstLevel(assessments) {
|
|
122
|
+
if (assessments.some((a) => a.level === "danger"))
|
|
123
|
+
return "danger";
|
|
124
|
+
if (assessments.some((a) => a.level === "warn"))
|
|
125
|
+
return "warn";
|
|
126
|
+
return "ok";
|
|
127
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* — they'd each meter the same dollars. Hook for Claude Code; proxy for everything
|
|
16
16
|
* else (Cursor, Aider, raw scripts). See README.
|
|
17
17
|
*/
|
|
18
|
+
import { type Server } from "node:http";
|
|
18
19
|
import { type TokenUsage } from "./cost.js";
|
|
19
20
|
export interface ProxyOptions {
|
|
20
21
|
port: number;
|
|
@@ -33,5 +34,5 @@ export declare function parseStreamUsage(flavor: string, sse: string): {
|
|
|
33
34
|
model: string;
|
|
34
35
|
usage: TokenUsage;
|
|
35
36
|
} | null;
|
|
36
|
-
export declare function startProxy(opts: ProxyOptions):
|
|
37
|
+
export declare function startProxy(opts: ProxyOptions): Server;
|
|
37
38
|
export declare function resolveUpstream(flavor: string, explicit?: string): string;
|
package/dist/proxy.js
CHANGED
|
@@ -23,6 +23,9 @@ import { costForUsage, fmtUSD } from "./cost.js";
|
|
|
23
23
|
import { loadLedger, saveLedger, addSessionCost, rollingDailyCost, prune, } from "./ledger.js";
|
|
24
24
|
import { evaluate } from "./budget.js";
|
|
25
25
|
import { dispatchAlert } from "./alert.js";
|
|
26
|
+
import { assertSafeEndpoint, warnIfUnexpectedHost } from "./net.js";
|
|
27
|
+
import { parseUnifiedHeaders, loadLimitsState, saveLimitsState, limitNotifyKey, } from "./limits.js";
|
|
28
|
+
import { assessSnapshot, worstLevel } from "./pacing.js";
|
|
26
29
|
const UPSTREAMS = {
|
|
27
30
|
anthropic: "https://api.anthropic.com",
|
|
28
31
|
openai: "https://api.openai.com",
|
|
@@ -133,20 +136,67 @@ function meter(cfg, ledger, sessionId, parsed, now) {
|
|
|
133
136
|
prune(ledger, now);
|
|
134
137
|
saveLedger(ledger);
|
|
135
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Read Anthropic's `unified-*` rate-limit headers off a response, persist the
|
|
141
|
+
* snapshot, latch subscription mode on, and fire a deduped pacing alert when a
|
|
142
|
+
* window crosses into warn/danger. Returns true if subscription headers were
|
|
143
|
+
* seen. Alert-only by design — this never blocks (a subscription session already
|
|
144
|
+
* paid a flat fee; the scarce resource is quota, and Anthropic's own limit is
|
|
145
|
+
* the real wall).
|
|
146
|
+
*/
|
|
147
|
+
function captureLimits(cfg, headers, sessionId, now) {
|
|
148
|
+
const snap = parseUnifiedHeaders(headers, now);
|
|
149
|
+
if (!snap)
|
|
150
|
+
return false;
|
|
151
|
+
const state = loadLimitsState();
|
|
152
|
+
state.subscriptionDetected = true;
|
|
153
|
+
state.snapshot = snap;
|
|
154
|
+
const assessments = assessSnapshot(snap, cfg.limits, now);
|
|
155
|
+
const fresh = assessments.filter((a) => {
|
|
156
|
+
if (a.level === "ok")
|
|
157
|
+
return false;
|
|
158
|
+
const key = limitNotifyKey(a.window, a.level, a.resetAt);
|
|
159
|
+
if (state.notified[key])
|
|
160
|
+
return false;
|
|
161
|
+
state.notified[key] = true;
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
saveLimitsState(state);
|
|
165
|
+
if (fresh.length) {
|
|
166
|
+
const level = worstLevel(fresh);
|
|
167
|
+
dispatchAlert(cfg, {
|
|
168
|
+
ts: now,
|
|
169
|
+
source: "proxy",
|
|
170
|
+
kind: "limit",
|
|
171
|
+
sessionId,
|
|
172
|
+
level: level === "danger" ? "danger" : "warn",
|
|
173
|
+
sessionUSD: 0,
|
|
174
|
+
dailyUSD: 0,
|
|
175
|
+
reasons: fresh.map((a) => a.message),
|
|
176
|
+
action: level === "danger" ? "on pace to lock out before reset" : "approaching plan limit",
|
|
177
|
+
limits: fresh.map((a) => ({ window: a.window, utilization: a.utilization, resetAt: a.resetAt, level: a.level })),
|
|
178
|
+
}).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
136
182
|
export function startProxy(opts) {
|
|
137
183
|
const cfg = loadConfig();
|
|
138
|
-
const upstreamOrigin = opts.upstream.replace(/\/$/, "");
|
|
184
|
+
const upstreamOrigin = assertSafeEndpoint(opts.upstream, "upstream").replace(/\/$/, "");
|
|
139
185
|
const blockedNotified = {};
|
|
140
186
|
const server = createServer(async (req, res) => {
|
|
141
187
|
const now = Date.now();
|
|
142
188
|
const sessionId = req.headers["x-agent-guard-session"] || `proxy:${todayKey(now)}`;
|
|
143
189
|
// 1) Pre-flight budget check — block before spending anything.
|
|
144
190
|
// Escape hatch: while a human has paused enforcement, never block (but still meter).
|
|
191
|
+
// Subscription mode is ALERT-ONLY: once we've seen Anthropic's unified
|
|
192
|
+
// rate-limit headers, the session is on a flat-fee plan where dollars are
|
|
193
|
+
// meaningless, so we never 402 it — we only pace + warn.
|
|
194
|
+
const subscriptionMode = loadLimitsState().subscriptionDetected;
|
|
145
195
|
const ledger = loadLedger();
|
|
146
196
|
const sessionUSD = ledger.sessions[sessionId]?.costUSD ?? 0;
|
|
147
197
|
const dailyUSD = rollingDailyCost(ledger, now);
|
|
148
198
|
const verdict = evaluate({ sessionUSD, dailyUSD }, cfg.budget);
|
|
149
|
-
if (verdict.level === "block" && !isPaused(now)) {
|
|
199
|
+
if (verdict.level === "block" && !isPaused(now) && !subscriptionMode) {
|
|
150
200
|
if (!blockedNotified[sessionId]) {
|
|
151
201
|
blockedNotified[sessionId] = true;
|
|
152
202
|
dispatchAlert(cfg, {
|
|
@@ -189,6 +239,15 @@ export function startProxy(opts) {
|
|
|
189
239
|
res.end(JSON.stringify({ error: "kill-switch proxy: upstream fetch failed", detail: String(err) }));
|
|
190
240
|
return;
|
|
191
241
|
}
|
|
242
|
+
// 2.5) Read Anthropic's subscription rate-limit headers (alert-only).
|
|
243
|
+
if (opts.flavor === "anthropic") {
|
|
244
|
+
try {
|
|
245
|
+
captureLimits(cfg, upstream.headers, sessionId, Date.now());
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* limit capture must never break the proxied response */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
192
251
|
// 3) Relay status + headers.
|
|
193
252
|
const respHeaders = {};
|
|
194
253
|
upstream.headers.forEach((v, k) => {
|
|
@@ -242,15 +301,27 @@ export function startProxy(opts) {
|
|
|
242
301
|
}
|
|
243
302
|
})();
|
|
244
303
|
});
|
|
245
|
-
|
|
304
|
+
// Bind to loopback only — this proxy forwards the caller's LLM API key
|
|
305
|
+
// upstream, so it must never be reachable from the local network.
|
|
306
|
+
server.listen(opts.port, "127.0.0.1", () => {
|
|
246
307
|
process.stdout.write(`🛡 agent-guard proxy on http://localhost:${opts.port} → ${upstreamOrigin} (${opts.flavor})\n` +
|
|
247
308
|
` Caps: session hard ${fmtUSD(cfg.budget.sessionHardUSD)}, daily hard ${fmtUSD(cfg.budget.dailyHardUSD)}\n` +
|
|
309
|
+
(opts.flavor === "anthropic"
|
|
310
|
+
? ` Subscription mode: reads Anthropic rate-limit headers → paces your Pro/Max plan (alert-only)\n`
|
|
311
|
+
: "") +
|
|
248
312
|
` Point your agent at it, e.g.:\n` +
|
|
249
313
|
(opts.flavor === "anthropic"
|
|
250
314
|
? ` ANTHROPIC_BASE_URL=http://localhost:${opts.port} claude\n`
|
|
251
315
|
: ` OPENAI_BASE_URL=http://localhost:${opts.port}/v1 aider\n`));
|
|
252
316
|
});
|
|
317
|
+
return server;
|
|
253
318
|
}
|
|
254
319
|
export function resolveUpstream(flavor, explicit) {
|
|
255
|
-
|
|
320
|
+
const upstream = explicit || UPSTREAMS[flavor] || UPSTREAMS.anthropic;
|
|
321
|
+
assertSafeEndpoint(upstream, "upstream");
|
|
322
|
+
if (explicit) {
|
|
323
|
+
const expected = new URL(UPSTREAMS[flavor] || UPSTREAMS.anthropic).hostname;
|
|
324
|
+
warnIfUnexpectedHost(upstream, expected, "--upstream");
|
|
325
|
+
}
|
|
326
|
+
return upstream;
|
|
256
327
|
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared status report — the single computation behind `agent-guard status` and
|
|
3
3
|
* `ks guard status`, so both emit an identical JSON shape and never drift.
|
|
4
|
+
*
|
|
5
|
+
* Two halves:
|
|
6
|
+
* - the dollar budget (session + daily-rolling), always present; and
|
|
7
|
+
* - the subscription rate-limit standing (5-hour + weekly pacing), present
|
|
8
|
+
* once we've seen Anthropic's unified headers via the proxy, or estimated
|
|
9
|
+
* when the user has pinned a plan tier. Alert-only — never blocks.
|
|
4
10
|
*/
|
|
5
11
|
import { type SessionRecord } from "./ledger.js";
|
|
6
12
|
import { type Budget, type VerdictLevel } from "./budget.js";
|
|
13
|
+
import { type PacingAssessment, type PacingLevel } from "./pacing.js";
|
|
14
|
+
export interface LimitsReport {
|
|
15
|
+
/** Where the numbers came from. "none" = no data and no pinned plan to estimate from. */
|
|
16
|
+
source: "headers" | "estimated" | "none";
|
|
17
|
+
plan: string;
|
|
18
|
+
subscriptionDetected: boolean;
|
|
19
|
+
/** Epoch ms the snapshot was observed (headers) or computed (estimated). */
|
|
20
|
+
observedAt: number | null;
|
|
21
|
+
windows: PacingAssessment[];
|
|
22
|
+
level: PacingLevel;
|
|
23
|
+
}
|
|
7
24
|
export interface StatusReport {
|
|
8
25
|
budget: Budget;
|
|
9
26
|
dailyUSD: number;
|
|
@@ -15,6 +32,14 @@ export interface StatusReport {
|
|
|
15
32
|
sessions: Array<{
|
|
16
33
|
id: string;
|
|
17
34
|
} & SessionRecord>;
|
|
35
|
+
/** Subscription rate-limit pacing — present whenever we have data to show. */
|
|
36
|
+
limits: LimitsReport;
|
|
18
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Render the subscription rate-limit section as plain text lines (no color), so
|
|
40
|
+
* both the `agent-guard` and `ks guard` status views stay identical. Returns an
|
|
41
|
+
* empty array when there's nothing useful to show.
|
|
42
|
+
*/
|
|
43
|
+
export declare function formatLimitsLines(limits: LimitsReport, now?: number): string[];
|
|
19
44
|
/** Build the current status report from the on-disk config + ledger. */
|
|
20
45
|
export declare function buildStatusReport(now?: number): StatusReport;
|