ambitry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Detecting credentials and personal data in outgoing prompts.
3
+ *
4
+ * Everything here runs locally and only ever reports *that* something was
5
+ * found, plus a masked preview. The secret itself is never written to the
6
+ * trace store — a tool that logs the credentials it catches has moved the
7
+ * leak rather than closed it.
8
+ *
9
+ * The design bias throughout is against false positives. A detector that
10
+ * fires on ordinary text gets muted within a day, and a muted detector is
11
+ * worth less than none at all because it also buys false confidence.
12
+ */
13
+ /** Mask a secret down to something recognisable but unusable. */
14
+ function mask(value) {
15
+ if (value.length <= 12)
16
+ return `${value.slice(0, 2)}${'•'.repeat(6)}`;
17
+ return `${value.slice(0, 4)}${'•'.repeat(8)}${value.slice(-4)}`;
18
+ }
19
+ /** Luhn check — without it, any 16-digit order number reads as a card. */
20
+ function luhn(digits) {
21
+ let sum = 0;
22
+ let double = false;
23
+ for (let i = digits.length - 1; i >= 0; i--) {
24
+ let d = digits.charCodeAt(i) - 48;
25
+ if (double) {
26
+ d *= 2;
27
+ if (d > 9)
28
+ d -= 9;
29
+ }
30
+ sum += d;
31
+ double = !double;
32
+ }
33
+ return sum % 10 === 0;
34
+ }
35
+ /** Shannon entropy in bits per character. */
36
+ function entropy(value) {
37
+ const freq = new Map();
38
+ for (const ch of value)
39
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
40
+ let bits = 0;
41
+ for (const n of freq.values()) {
42
+ const p = n / value.length;
43
+ bits -= p * Math.log2(p);
44
+ }
45
+ return bits;
46
+ }
47
+ const RULES = [
48
+ // Vendor-prefixed keys. These carry their own namespace, so a match is
49
+ // near-certain and worth flagging as critical.
50
+ { kind: 'AWS access key ID', severity: 'critical', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
51
+ { kind: 'GitHub token', severity: 'critical', pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
52
+ // The lookahead matters: Anthropic keys are `sk-ant-…`, so without it every
53
+ // Anthropic key is also reported as an OpenAI one.
54
+ { kind: 'OpenAI API key', severity: 'critical', pattern: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
55
+ { kind: 'Anthropic API key', severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
56
+ { kind: 'Stripe live key', severity: 'critical', pattern: /\b[rs]k_live_[A-Za-z0-9]{16,}\b/g },
57
+ { kind: 'Slack token', severity: 'critical', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
58
+ { kind: 'Google API key', severity: 'critical', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
59
+ { kind: 'Private key', severity: 'critical', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
60
+ { kind: 'JSON Web Token', severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
61
+ {
62
+ kind: 'Payment card number',
63
+ severity: 'high',
64
+ pattern: /\b(?:\d[ -]?){13,19}\b/g,
65
+ confirm: (m) => {
66
+ const digits = m.replace(/[ -]/g, '');
67
+ return digits.length >= 13 && digits.length <= 19 && luhn(digits);
68
+ },
69
+ },
70
+ {
71
+ kind: 'Email address',
72
+ severity: 'medium',
73
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
74
+ // Placeholder addresses are all over prompts and docs; flagging them is
75
+ // exactly the noise that gets the whole feature switched off.
76
+ confirm: (m) => !/@(?:example|test|localhost|invalid|sample)\./i.test(m) && !/^(?:you|user|someone|name|email)@/i.test(m),
77
+ },
78
+ ];
79
+ /**
80
+ * Assignment-shaped high-entropy strings, e.g. `api_key: "9f8a...".`
81
+ *
82
+ * Requires both a credential-ish name and genuinely random-looking content.
83
+ * Either signal alone produces far too much noise: prose has key-like words,
84
+ * and base64 blobs are often just data.
85
+ */
86
+ const ASSIGNED = /\b([A-Za-z_][A-Za-z0-9_]*(?:key|token|secret|password|passwd|credential|auth)[A-Za-z0-9_]*)\b\s*[:=]\s*["']?([A-Za-z0-9+/_=-]{20,})["']?/gi;
87
+ export function scan(text) {
88
+ const found = new Map();
89
+ const add = (kind, severity, value) => {
90
+ const existing = found.get(kind);
91
+ if (existing)
92
+ existing.count++;
93
+ else
94
+ found.set(kind, { kind, severity, preview: mask(value), count: 1 });
95
+ };
96
+ for (const rule of RULES) {
97
+ for (const match of text.matchAll(rule.pattern)) {
98
+ const value = match[0];
99
+ if (rule.confirm && !rule.confirm(value))
100
+ continue;
101
+ add(rule.kind, rule.severity, value);
102
+ }
103
+ }
104
+ for (const match of text.matchAll(ASSIGNED)) {
105
+ const value = match[2];
106
+ // 3.5 bits/char clears base64-encoded English but not a random key.
107
+ if (entropy(value) < 3.5)
108
+ continue;
109
+ // Skip anything a vendor rule already caught, to avoid double-reporting.
110
+ if (RULES.some((r) => r.kind !== 'Email address' && new RegExp(r.pattern.source).test(value)))
111
+ continue;
112
+ add(`High-entropy value in "${match[1]}"`, 'high', value);
113
+ }
114
+ return [...found.values()].sort((a, b) => ({ critical: 0, high: 1, medium: 2 })[a.severity] - ({ critical: 0, high: 1, medium: 2 })[b.severity]);
115
+ }
116
+ /** Walk a parsed request body and scan every string it contains. */
117
+ export function scanRequest(body) {
118
+ const parts = [];
119
+ const walk = (node) => {
120
+ if (typeof node === 'string')
121
+ parts.push(node);
122
+ else if (Array.isArray(node))
123
+ node.forEach(walk);
124
+ else if (node && typeof node === 'object')
125
+ Object.values(node).forEach(walk);
126
+ };
127
+ walk(body);
128
+ return scan(parts.join('\n'));
129
+ }
@@ -0,0 +1,8 @@
1
+ import http from 'node:http';
2
+ import type { Store } from './db.ts';
3
+ import type { Controls } from './policy.ts';
4
+ export interface ServerDeps {
5
+ store: Store;
6
+ controls: Controls;
7
+ }
8
+ export declare function createServer({ store, controls }: ServerDeps): http.Server;
package/dist/server.js ADDED
@@ -0,0 +1,193 @@
1
+ import http from 'node:http';
2
+ import { routeFor, forwardHeaders, redactHeaders } from "./providers.js";
3
+ import { ResponseParser, parseRequest } from "./parse.js";
4
+ import { filterBody, filterStream } from "./enforce.js";
5
+ import { costUsd } from "./pricing.js";
6
+ import { scanRequest } from "./secrets.js";
7
+ import { renderViewer } from "./viewer.js";
8
+ /** Response headers that must not be copied verbatim to the client. */
9
+ const SKIP_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection']);
10
+ function readBody(req) {
11
+ return new Promise((resolve, reject) => {
12
+ const chunks = [];
13
+ req.on('data', (c) => chunks.push(c));
14
+ req.on('end', () => resolve(Buffer.concat(chunks)));
15
+ req.on('error', reject);
16
+ });
17
+ }
18
+ function json(res, status, payload) {
19
+ const body = JSON.stringify(payload);
20
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
21
+ res.end(body);
22
+ }
23
+ export function createServer({ store, controls }) {
24
+ return http.createServer(async (req, res) => {
25
+ const url = req.url ?? '/';
26
+ if (url === '/' || url.startsWith('/_ambitry')) {
27
+ return handleControl(url, req, res, { store, controls });
28
+ }
29
+ const route = routeFor(url);
30
+ if (!route) {
31
+ return json(res, 404, {
32
+ error: 'Unknown path. Point your SDK at /anthropic or /openai.',
33
+ examples: ['http://localhost:8787/anthropic', 'http://localhost:8787/openai/v1'],
34
+ });
35
+ }
36
+ const body = await readBody(req);
37
+ const parsed = parseRequest(route.provider.id, body);
38
+ const requestJson = safeJson(body);
39
+ // Scanned on the way out, so a leak is caught on the request that carries
40
+ // it rather than after the provider has already received it.
41
+ const findings = scanRequest(requestJson);
42
+ // Checked before forwarding: a kill switch that only stops responses
43
+ // would still let the request bill and, worse, still let it act.
44
+ const gate = controls.checkRequest(store);
45
+ if (!gate.allowed) {
46
+ const id = store.beginTrace({
47
+ provider: route.provider.id,
48
+ path: url,
49
+ model: parsed.model,
50
+ streamed: parsed.stream,
51
+ request: { headers: redactHeaders(req.headers), body: requestJson },
52
+ });
53
+ store.recordFindings(id, findings);
54
+ store.finishTrace(id, { status: 403, error: gate.reason });
55
+ return json(res, 403, { type: 'error', error: { type: 'ambitry_blocked', message: gate.reason } });
56
+ }
57
+ const traceId = store.beginTrace({
58
+ provider: route.provider.id,
59
+ path: url,
60
+ model: parsed.model,
61
+ streamed: parsed.stream,
62
+ request: { headers: redactHeaders(req.headers), body: requestJson },
63
+ });
64
+ store.recordFindings(traceId, findings);
65
+ let upstream;
66
+ try {
67
+ upstream = await fetch(route.upstream, {
68
+ method: req.method,
69
+ headers: forwardHeaders(req.headers),
70
+ // Uint8Array rather than the Buffer itself: Buffer subclasses it, but
71
+ // fetch's BodyInit union does not name Buffer, so tsc rejects it.
72
+ body: ['GET', 'HEAD'].includes(req.method ?? 'GET') ? undefined : new Uint8Array(body),
73
+ });
74
+ }
75
+ catch (err) {
76
+ store.finishTrace(traceId, { error: `Upstream request failed: ${err.message}` });
77
+ return json(res, 502, { type: 'error', error: { type: 'ambitry_upstream', message: err.message } });
78
+ }
79
+ const headers = {};
80
+ upstream.headers.forEach((value, name) => {
81
+ if (!SKIP_RESPONSE_HEADERS.has(name.toLowerCase()))
82
+ headers[name] = value;
83
+ });
84
+ const parser = new ResponseParser(route.provider.id, parsed.stream);
85
+ // The central trade-off. With no rule that can reject anything there is
86
+ // nothing to withhold, so bytes stream straight through and the proxy
87
+ // adds no latency. Only when a deny rule exists do we buffer, because
88
+ // bytes already written to the client cannot be recalled.
89
+ if (!controls.enforcing) {
90
+ res.writeHead(upstream.status, headers);
91
+ if (upstream.body) {
92
+ for await (const chunk of upstream.body) {
93
+ const buf = Buffer.from(chunk);
94
+ parser.push(buf);
95
+ res.write(buf);
96
+ }
97
+ }
98
+ res.end();
99
+ return record(store, controls, traceId, parser, upstream.status, []);
100
+ }
101
+ const chunks = [];
102
+ if (upstream.body) {
103
+ for await (const chunk of upstream.body) {
104
+ const buf = Buffer.from(chunk);
105
+ chunks.push(buf);
106
+ parser.push(buf);
107
+ }
108
+ }
109
+ const raw = Buffer.concat(chunks).toString('utf8');
110
+ const result = parser.finish();
111
+ const denials = [];
112
+ for (const call of result.toolCalls) {
113
+ const decision = controls.checkTool(call.name);
114
+ if (!decision.allowed)
115
+ denials.push({ index: call.index, name: call.name, reason: decision.reason });
116
+ }
117
+ const out = parsed.stream
118
+ ? filterStream(route.provider.id, raw, denials)
119
+ : filterBody(route.provider.id, raw, denials);
120
+ res.writeHead(upstream.status, headers);
121
+ res.end(out);
122
+ recordParsed(store, controls, traceId, result, upstream.status, denials);
123
+ });
124
+ }
125
+ function safeJson(body) {
126
+ try {
127
+ return JSON.parse(body.toString('utf8'));
128
+ }
129
+ catch {
130
+ return { raw: body.toString('utf8').slice(0, 10_000) };
131
+ }
132
+ }
133
+ function record(store, controls, id, parser, status, denials) {
134
+ recordParsed(store, controls, id, parser.finish(), status, denials);
135
+ }
136
+ function recordParsed(store, controls, id, result, status, denials) {
137
+ const denied = new Map(denials.map((d) => [d.index, d.reason]));
138
+ result.toolCalls.forEach((call, seq) => {
139
+ const reason = denied.get(call.index);
140
+ store.recordToolCall({
141
+ traceId: id,
142
+ seq,
143
+ name: call.name,
144
+ input: call.input,
145
+ decision: reason ? 'deny' : 'allow',
146
+ reason: reason ?? null,
147
+ });
148
+ });
149
+ store.finishTrace(id, {
150
+ status,
151
+ inputTokens: result.inputTokens,
152
+ outputTokens: result.outputTokens,
153
+ costUsd: costUsd(result.model, result.inputTokens, result.outputTokens),
154
+ response: { model: result.model, toolCalls: result.toolCalls },
155
+ });
156
+ }
157
+ function handleControl(url, req, res, { store, controls }) {
158
+ const path = url.split('?')[0];
159
+ if (path === '/' || path === '/_ambitry' || path === '/_ambitry/') {
160
+ const html = renderViewer();
161
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
162
+ return res.end(html);
163
+ }
164
+ if (path === '/_ambitry/api/status') {
165
+ const now = Date.now();
166
+ return json(res, 200, {
167
+ killed: controls.isKilled,
168
+ enforcing: controls.enforcing,
169
+ limits: controls.limits,
170
+ spend: { lastHour: store.spendSince(now - 3_600_000), lastDay: store.spendSince(now - 86_400_000) },
171
+ findings: store.findingsSummary(now - 7 * 86_400_000),
172
+ });
173
+ }
174
+ if (path === '/_ambitry/api/traces') {
175
+ return json(res, 200, store.recentTraces(200));
176
+ }
177
+ const single = path.match(/^\/_ambitry\/api\/traces\/([\w-]+)$/);
178
+ if (single) {
179
+ const trace = store.trace(single[1]);
180
+ return trace
181
+ ? json(res, 200, { ...trace, findings: store.findingsFor(single[1]) })
182
+ : json(res, 404, { error: 'No such trace' });
183
+ }
184
+ if (path === '/_ambitry/api/kill' && req.method === 'POST') {
185
+ controls.kill('Stopped by kill switch');
186
+ return json(res, 200, { killed: true });
187
+ }
188
+ if (path === '/_ambitry/api/revive' && req.method === 'POST') {
189
+ controls.revive();
190
+ return json(res, 200, { killed: false });
191
+ }
192
+ return json(res, 404, { error: 'Unknown control endpoint' });
193
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The local dashboard, served from the proxy itself.
3
+ *
4
+ * One self-contained HTML string with no external requests — no CDN, no
5
+ * fonts, no analytics. A tool that reads your prompts has no business
6
+ * opening network connections you did not ask for, and "view source, it
7
+ * talks to nothing" is a claim anyone can check in a minute.
8
+ *
9
+ * Controls sit above traces on purpose: spend and the kill switch are what
10
+ * you leave running and glance at daily; the trace list is the evidence
11
+ * behind them.
12
+ */
13
+ export declare function renderViewer(): string;
package/dist/viewer.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The local dashboard, served from the proxy itself.
3
+ *
4
+ * One self-contained HTML string with no external requests — no CDN, no
5
+ * fonts, no analytics. A tool that reads your prompts has no business
6
+ * opening network connections you did not ask for, and "view source, it
7
+ * talks to nothing" is a claim anyone can check in a minute.
8
+ *
9
+ * Controls sit above traces on purpose: spend and the kill switch are what
10
+ * you leave running and glance at daily; the trace list is the evidence
11
+ * behind them.
12
+ */
13
+ export function renderViewer() {
14
+ return `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>ambitry</title>
20
+ <style>
21
+ :root {
22
+ --bg: #fbfbfa; --panel: #fff; --line: #e4e4e1; --ink: #1a1a18;
23
+ --dim: #6b6b66; --accent: #2f6f4e; --deny: #b0342c; --warn: #b8721f;
24
+ color-scheme: light dark;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root { --bg:#131313; --panel:#1a1a19; --line:#2e2e2c; --ink:#eceae5;
28
+ --dim:#94948d; --accent:#6fbf92; --deny:#e8776d; --warn:#dda54a; }
29
+ }
30
+ * { box-sizing: border-box; }
31
+ body { margin:0; background:var(--bg); color:var(--ink);
32
+ font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
33
+ .wrap { max-width:940px; margin:0 auto; padding:24px 16px 64px; }
34
+ h1 { font-size:15px; letter-spacing:.14em; text-transform:uppercase; margin:0; color:var(--dim); font-weight:600; }
35
+ .head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; }
36
+ .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:22px; }
37
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
38
+ .card .k { color:var(--dim); font-size:11px; letter-spacing:.09em; text-transform:uppercase; }
39
+ .card .v { font-size:22px; margin-top:4px; font-variant-numeric:tabular-nums; }
40
+ .bar { height:3px; background:var(--line); border-radius:2px; margin-top:8px; overflow:hidden; }
41
+ .bar i { display:block; height:100%; background:var(--accent); }
42
+ .bar.hot i { background:var(--warn); }
43
+ button { font:inherit; cursor:pointer; border-radius:6px; padding:7px 13px;
44
+ border:1px solid var(--line); background:var(--panel); color:var(--ink); }
45
+ button.kill { border-color:var(--deny); color:var(--deny); }
46
+ button.live { background:var(--deny); border-color:var(--deny); color:#fff; }
47
+ table { width:100%; border-collapse:collapse; background:var(--panel);
48
+ border:1px solid var(--line); border-radius:8px; overflow:hidden; }
49
+ th { text-align:left; font-size:11px; letter-spacing:.09em; text-transform:uppercase;
50
+ color:var(--dim); font-weight:600; padding:9px 12px; border-bottom:1px solid var(--line); }
51
+ td { padding:9px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
52
+ tr:last-child td { border-bottom:0; }
53
+ tr.row { cursor:pointer; }
54
+ tr.row:hover td { background:color-mix(in srgb, var(--ink) 4%, transparent); }
55
+ .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
56
+ .tag { display:inline-block; font-size:11px; padding:1px 7px; border-radius:99px;
57
+ border:1px solid currentColor; margin:1px 3px 1px 0; }
58
+ .ok { color:var(--accent); } .no { color:var(--deny); }
59
+ .alert { border:1px solid var(--deny); border-left-width:3px; border-radius:8px;
60
+ background:color-mix(in srgb, var(--deny) 7%, var(--panel));
61
+ padding:14px 16px; margin-bottom:22px; }
62
+ .alert h3 { margin:0 0 4px; font-size:14px; color:var(--deny); }
63
+ .alert p { margin:0 0 10px; color:var(--dim); font-size:13px; }
64
+ .alert .f { display:flex; justify-content:space-between; gap:12px;
65
+ padding:6px 0; border-top:1px solid var(--line); }
66
+ .alert .f:first-of-type { border-top:0; }
67
+ .alert .mono { color:var(--dim); font-size:12.5px; }
68
+ .empty { padding:40px 16px; text-align:center; color:var(--dim); }
69
+ .empty code { background:var(--panel); border:1px solid var(--line); padding:2px 6px; border-radius:4px; }
70
+ .detail { background:var(--panel); border:1px solid var(--line); border-radius:8px;
71
+ padding:14px; margin-top:12px; }
72
+ pre { margin:6px 0 0; padding:10px; background:var(--bg); border:1px solid var(--line);
73
+ border-radius:6px; overflow-x:auto; font-size:12px; }
74
+ .muted { color:var(--dim); }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <div class="wrap">
79
+ <div class="head">
80
+ <h1>ambitry</h1>
81
+ <button id="kill"></button>
82
+ </div>
83
+ <div id="alert"></div>
84
+ <div class="cards" id="cards"></div>
85
+ <div id="list"></div>
86
+ <div id="detail"></div>
87
+ </div>
88
+ <script>
89
+ const $ = (id) => document.getElementById(id);
90
+ const money = (n) => n == null ? '—' : '$' + Number(n).toFixed(n < 1 ? 4 : 2);
91
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
92
+
93
+ function meter(label, spent, cap) {
94
+ const pct = cap ? Math.min(100, (spent / cap) * 100) : 0;
95
+ const bar = cap
96
+ ? '<div class="bar' + (pct > 80 ? ' hot' : '') + '"><i style="width:' + pct + '%"></i></div>'
97
+ : '<div class="k" style="margin-top:8px">no cap set</div>';
98
+ return '<div class="card"><div class="k">' + label + '</div><div class="v">' + money(spent) +
99
+ (cap ? ' <span class="muted" style="font-size:13px">/ ' + money(cap) + '</span>' : '') + '</div>' + bar + '</div>';
100
+ }
101
+
102
+ async function refresh() {
103
+ const [status, traces] = await Promise.all([
104
+ fetch('/_ambitry/api/status').then(r => r.json()),
105
+ fetch('/_ambitry/api/traces').then(r => r.json()),
106
+ ]);
107
+
108
+ const k = $('kill');
109
+ k.textContent = status.killed ? 'Agents stopped — resume' : 'Stop all agents';
110
+ k.className = status.killed ? 'live' : 'kill';
111
+
112
+ // The headline. What people came for is "am I leaking anything", and the
113
+ // answer belongs above the fold, not inside a trace nobody opens.
114
+ const f = status.findings || [];
115
+ $('alert').innerHTML = !f.length ? '' :
116
+ '<div class="alert"><h3>' + f.reduce((n, x) => n + x.occurrences, 0) +
117
+ ' credential' + (f.reduce((n, x) => n + x.occurrences, 0) === 1 ? '' : 's') +
118
+ ' sent to your model provider</h3>' +
119
+ '<p>Found in outgoing prompts over the last 7 days. The finding keeps only a masked preview — ' +
120
+ 'but the prompt itself is in your local trace file, so treat that file as sensitive.</p>' +
121
+ f.map(x => '<div class="f"><span>' + esc(x.kind) + ' <span class="mono">' + esc(x.preview) + '</span></span>' +
122
+ '<span class="mono">' + x.occurrences + '× in ' + x.traces + ' request' + (x.traces === 1 ? '' : 's') +
123
+ '</span></div>').join('') + '</div>';
124
+
125
+ const blocked = traces.filter(t => t.status === 403).length;
126
+ $('cards').innerHTML =
127
+ meter('Spend · last hour', status.spend.lastHour, status.limits.perHourUsd) +
128
+ meter('Spend · last day', status.spend.lastDay, status.limits.perDayUsd) +
129
+ '<div class="card"><div class="k">Requests</div><div class="v">' + traces.length + '</div>' +
130
+ '<div class="k" style="margin-top:8px">' + (status.enforcing ? 'policy enforcing' : 'logging only') + '</div></div>' +
131
+ '<div class="card"><div class="k">Blocked</div><div class="v">' + blocked + '</div>' +
132
+ '<div class="k" style="margin-top:8px">by policy</div></div>';
133
+
134
+ if (!traces.length) {
135
+ $('list').innerHTML = '<div class="empty">No traffic yet. Point your SDK at ' +
136
+ '<code>http://localhost:' + location.port + '/anthropic</code> and run your agent.</div>';
137
+ return;
138
+ }
139
+
140
+ $('list').innerHTML = '<table><thead><tr><th>Time</th><th>Model</th><th>Tools</th>' +
141
+ '<th class="num">Tokens</th><th class="num">Cost</th></tr></thead><tbody>' +
142
+ traces.map(t => '<tr class="row" data-id="' + t.id + '">' +
143
+ '<td>' + new Date(t.started_at).toLocaleTimeString() +
144
+ (t.status === 403 ? ' <span class="tag no">blocked</span>' : '') + '</td>' +
145
+ '<td>' + esc(t.model || '—') + '</td>' +
146
+ '<td class="muted">' + (t.streamed ? 'stream' : '') + '</td>' +
147
+ '<td class="num">' + ((t.input_tokens ?? 0) + (t.output_tokens ?? 0) || '—') + '</td>' +
148
+ '<td class="num">' + money(t.cost_usd) + '</td></tr>').join('') +
149
+ '</tbody></table>';
150
+
151
+ document.querySelectorAll('tr.row').forEach(r =>
152
+ r.onclick = () => showTrace(r.dataset.id));
153
+ }
154
+
155
+ async function showTrace(id) {
156
+ const { trace, toolCalls } = await fetch('/_ambitry/api/traces/' + id).then(r => r.json());
157
+ const calls = toolCalls.length
158
+ ? toolCalls.map(c => '<div style="margin-top:10px">' +
159
+ '<span class="tag ' + (c.decision === 'deny' ? 'no' : 'ok') + '">' + c.decision + '</span>' +
160
+ '<strong>' + esc(c.name) + '</strong>' +
161
+ (c.reason ? ' <span class="muted">— ' + esc(c.reason) + '</span>' : '') +
162
+ '<pre>' + esc(JSON.stringify(JSON.parse(c.input_json), null, 2)) + '</pre></div>').join('')
163
+ : '<div class="muted" style="margin-top:8px">No tool calls in this turn.</div>';
164
+
165
+ $('detail').innerHTML = '<div class="detail"><div class="k muted">' + esc(trace.provider) +
166
+ ' · ' + esc(trace.path) + (trace.error ? ' · <span class="no">' + esc(trace.error) + '</span>' : '') +
167
+ '</div>' + calls + '</div>';
168
+ $('detail').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
169
+ }
170
+
171
+ $('kill').onclick = async () => {
172
+ const status = await fetch('/_ambitry/api/status').then(r => r.json());
173
+ await fetch('/_ambitry/api/' + (status.killed ? 'revive' : 'kill'), { method: 'POST' });
174
+ refresh();
175
+ };
176
+
177
+ refresh();
178
+ setInterval(refresh, 2000);
179
+ </script>
180
+ </body>
181
+ </html>`;
182
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "ambitry",
3
+ "version": "0.1.0",
4
+ "description": "Audit log and permission layer for AI agents. One-line integration, zero runtime dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ambitry": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "dev": "node --experimental-strip-types src/cli.ts",
16
+ "test": "node --experimental-strip-types --test test/*.test.ts",
17
+ "prepublishOnly": "cp ../../README.md ../../LICENSE . && npm run build"
18
+ },
19
+ "keywords": [
20
+ "ai",
21
+ "agents",
22
+ "audit",
23
+ "proxy",
24
+ "observability",
25
+ "security",
26
+ "llm"
27
+ ],
28
+ "license": "Apache-2.0",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.7.0",
34
+ "@types/node": "^22.10.0"
35
+ }
36
+ }