@kill-switch/agent-guard 0.1.0 → 0.1.1

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/dist/alert.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { appendFileSync } from "node:fs";
14
14
  import { eventsPath, ensureGuardDir } from "./config.js";
15
15
  import { fmtUSD } from "./cost.js";
16
+ import { isSafeEndpoint, warnIfUnexpectedHost } from "./net.js";
16
17
  const TIMEOUT_MS = 2500;
17
18
  async function postJson(url, body, headers = {}) {
18
19
  const ctrl = new AbortController();
@@ -61,7 +62,9 @@ export async function dispatchAlert(cfg, evt) {
61
62
  if (cfg.slackWebhook) {
62
63
  tasks.push(postJson(cfg.slackWebhook, { text: slackText(evt) }));
63
64
  }
64
- if (cfg.apiKey && cfg.apiUrl) {
65
+ // Only POST the ks_live key to a safe endpoint; warn on an unexpected host.
66
+ if (cfg.apiKey && cfg.apiUrl && isSafeEndpoint(cfg.apiUrl)) {
67
+ warnIfUnexpectedHost(cfg.apiUrl, "api.kill-switch.net", "apiUrl");
65
68
  tasks.push(postJson(`${cfg.apiUrl.replace(/\/$/, "")}/agent-guard/events`, evt, { authorization: `Bearer ${cfg.apiKey}` }));
66
69
  }
67
70
  await Promise.allSettled(tasks);
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/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): void;
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,7 @@ 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";
26
27
  const UPSTREAMS = {
27
28
  anthropic: "https://api.anthropic.com",
28
29
  openai: "https://api.openai.com",
@@ -135,7 +136,7 @@ function meter(cfg, ledger, sessionId, parsed, now) {
135
136
  }
136
137
  export function startProxy(opts) {
137
138
  const cfg = loadConfig();
138
- const upstreamOrigin = opts.upstream.replace(/\/$/, "");
139
+ const upstreamOrigin = assertSafeEndpoint(opts.upstream, "upstream").replace(/\/$/, "");
139
140
  const blockedNotified = {};
140
141
  const server = createServer(async (req, res) => {
141
142
  const now = Date.now();
@@ -242,7 +243,9 @@ export function startProxy(opts) {
242
243
  }
243
244
  })();
244
245
  });
245
- server.listen(opts.port, () => {
246
+ // Bind to loopback only — this proxy forwards the caller's LLM API key
247
+ // upstream, so it must never be reachable from the local network.
248
+ server.listen(opts.port, "127.0.0.1", () => {
246
249
  process.stdout.write(`🛡 agent-guard proxy on http://localhost:${opts.port} → ${upstreamOrigin} (${opts.flavor})\n` +
247
250
  ` Caps: session hard ${fmtUSD(cfg.budget.sessionHardUSD)}, daily hard ${fmtUSD(cfg.budget.dailyHardUSD)}\n` +
248
251
  ` Point your agent at it, e.g.:\n` +
@@ -250,7 +253,14 @@ export function startProxy(opts) {
250
253
  ? ` ANTHROPIC_BASE_URL=http://localhost:${opts.port} claude\n`
251
254
  : ` OPENAI_BASE_URL=http://localhost:${opts.port}/v1 aider\n`));
252
255
  });
256
+ return server;
253
257
  }
254
258
  export function resolveUpstream(flavor, explicit) {
255
- return explicit || UPSTREAMS[flavor] || UPSTREAMS.anthropic;
259
+ const upstream = explicit || UPSTREAMS[flavor] || UPSTREAMS.anthropic;
260
+ assertSafeEndpoint(upstream, "upstream");
261
+ if (explicit) {
262
+ const expected = new URL(UPSTREAMS[flavor] || UPSTREAMS.anthropic).hostname;
263
+ warnIfUnexpectedHost(upstream, expected, "--upstream");
264
+ }
265
+ return upstream;
256
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kill-switch/agent-guard",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {