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.
- package/LICENSE +202 -0
- package/README.md +163 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +114 -0
- package/dist/db.d.ts +94 -0
- package/dist/db.js +137 -0
- package/dist/demo.d.ts +18 -0
- package/dist/demo.js +113 -0
- package/dist/dryrun.d.ts +30 -0
- package/dist/dryrun.js +64 -0
- package/dist/enforce.d.ts +24 -0
- package/dist/enforce.js +120 -0
- package/dist/parse.d.ts +60 -0
- package/dist/parse.js +197 -0
- package/dist/policy.d.ts +50 -0
- package/dist/policy.js +141 -0
- package/dist/pricing.d.ts +45 -0
- package/dist/pricing.js +70 -0
- package/dist/providers.d.ts +32 -0
- package/dist/providers.js +112 -0
- package/dist/secrets.d.ts +23 -0
- package/dist/secrets.js +129 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +193 -0
- package/dist/viewer.d.ts +13 -0
- package/dist/viewer.js +182 -0
- package/package.json +36 -0
package/dist/db.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
const SCHEMA = `
|
|
4
|
+
create table if not exists traces (
|
|
5
|
+
id text primary key,
|
|
6
|
+
started_at integer not null,
|
|
7
|
+
ended_at integer,
|
|
8
|
+
provider text not null,
|
|
9
|
+
path text not null,
|
|
10
|
+
model text,
|
|
11
|
+
status integer,
|
|
12
|
+
streamed integer not null default 0,
|
|
13
|
+
input_tokens integer,
|
|
14
|
+
output_tokens integer,
|
|
15
|
+
cost_usd real,
|
|
16
|
+
request_json text not null,
|
|
17
|
+
response_json text,
|
|
18
|
+
error text
|
|
19
|
+
);
|
|
20
|
+
create table if not exists tool_calls (
|
|
21
|
+
id text primary key,
|
|
22
|
+
trace_id text not null references traces(id),
|
|
23
|
+
seq integer not null,
|
|
24
|
+
name text not null,
|
|
25
|
+
input_json text not null,
|
|
26
|
+
decision text not null,
|
|
27
|
+
reason text
|
|
28
|
+
);
|
|
29
|
+
create table if not exists findings (
|
|
30
|
+
id text primary key,
|
|
31
|
+
trace_id text not null references traces(id),
|
|
32
|
+
kind text not null,
|
|
33
|
+
severity text not null,
|
|
34
|
+
preview text not null,
|
|
35
|
+
count integer not null
|
|
36
|
+
);
|
|
37
|
+
create index if not exists idx_traces_started on traces(started_at desc);
|
|
38
|
+
create index if not exists idx_findings_trace on findings(trace_id);
|
|
39
|
+
create index if not exists idx_tool_calls_trace on tool_calls(trace_id);
|
|
40
|
+
create index if not exists idx_tool_calls_name on tool_calls(name);
|
|
41
|
+
`;
|
|
42
|
+
export class Store {
|
|
43
|
+
db;
|
|
44
|
+
constructor(path) {
|
|
45
|
+
this.db = new DatabaseSync(path);
|
|
46
|
+
// WAL keeps the dashboard readable while the proxy is mid-write.
|
|
47
|
+
this.db.exec('pragma journal_mode = WAL');
|
|
48
|
+
this.db.exec(SCHEMA);
|
|
49
|
+
}
|
|
50
|
+
beginTrace(input) {
|
|
51
|
+
const id = randomUUID();
|
|
52
|
+
this.db
|
|
53
|
+
.prepare(`insert into traces (id, started_at, provider, path, model, streamed, request_json)
|
|
54
|
+
values (?, ?, ?, ?, ?, ?, ?)`)
|
|
55
|
+
.run(id, Date.now(), input.provider, input.path, input.model, input.streamed ? 1 : 0, JSON.stringify(input.request));
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
finishTrace(id, patch) {
|
|
59
|
+
this.db
|
|
60
|
+
.prepare(`update traces set
|
|
61
|
+
ended_at = ?, status = ?, input_tokens = ?, output_tokens = ?,
|
|
62
|
+
cost_usd = ?, response_json = ?, error = ?
|
|
63
|
+
where id = ?`)
|
|
64
|
+
.run(Date.now(), patch.status ?? null, patch.inputTokens ?? null, patch.outputTokens ?? null, patch.costUsd ?? null, patch.response === undefined ? null : JSON.stringify(patch.response), patch.error ?? null, id);
|
|
65
|
+
}
|
|
66
|
+
recordToolCall(t) {
|
|
67
|
+
this.db
|
|
68
|
+
.prepare(`insert into tool_calls (id, trace_id, seq, name, input_json, decision, reason)
|
|
69
|
+
values (?, ?, ?, ?, ?, ?, ?)`)
|
|
70
|
+
.run(randomUUID(), t.traceId, t.seq, t.name, JSON.stringify(t.input ?? null), t.decision, t.reason ?? null);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Persist what a scan found.
|
|
74
|
+
*
|
|
75
|
+
* Only the kind, severity, and masked preview are stored — never the secret
|
|
76
|
+
* itself. A tool that logs the credentials it catches has moved the leak,
|
|
77
|
+
* not closed it.
|
|
78
|
+
*/
|
|
79
|
+
recordFindings(traceId, findings) {
|
|
80
|
+
const stmt = this.db.prepare(`insert into findings (id, trace_id, kind, severity, preview, count) values (?, ?, ?, ?, ?, ?)`);
|
|
81
|
+
for (const f of findings) {
|
|
82
|
+
stmt.run(randomUUID(), traceId, f.kind, f.severity, f.preview, f.count);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
findingsFor(traceId) {
|
|
86
|
+
return this.db
|
|
87
|
+
.prepare(`select kind, severity, preview, count from findings where trace_id = ?`)
|
|
88
|
+
.all(traceId);
|
|
89
|
+
}
|
|
90
|
+
/** Aggregate across recent traffic, for the dashboard headline. */
|
|
91
|
+
findingsSummary(since) {
|
|
92
|
+
return this.db
|
|
93
|
+
.prepare(
|
|
94
|
+
// min(preview) is arbitrary but stable — one masked sample per kind is
|
|
95
|
+
// all the dashboard shows, and they are masked anyway.
|
|
96
|
+
`select f.kind, f.severity, min(f.preview) as preview,
|
|
97
|
+
sum(f.count) as occurrences, count(distinct f.trace_id) as traces
|
|
98
|
+
from findings f join traces t on t.id = f.trace_id
|
|
99
|
+
where t.started_at >= ?
|
|
100
|
+
group by f.kind, f.severity
|
|
101
|
+
order by case f.severity when 'critical' then 0 when 'high' then 1 else 2 end, occurrences desc`)
|
|
102
|
+
.all(since);
|
|
103
|
+
}
|
|
104
|
+
/** Total spend since a given epoch-ms timestamp. Used for spend caps. */
|
|
105
|
+
spendSince(since) {
|
|
106
|
+
const row = this.db
|
|
107
|
+
.prepare(`select coalesce(sum(cost_usd), 0) as total from traces where started_at >= ?`)
|
|
108
|
+
.get(since);
|
|
109
|
+
return row?.total ?? 0;
|
|
110
|
+
}
|
|
111
|
+
/** Tool calls in a window, for evaluating a policy against real history. */
|
|
112
|
+
toolCallsSince(since) {
|
|
113
|
+
return this.db
|
|
114
|
+
.prepare(`select c.name, c.decision, t.started_at
|
|
115
|
+
from tool_calls c join traces t on t.id = c.trace_id
|
|
116
|
+
where t.started_at >= ?
|
|
117
|
+
order by t.started_at asc`)
|
|
118
|
+
.all(since);
|
|
119
|
+
}
|
|
120
|
+
recentTraces(limit = 100) {
|
|
121
|
+
return this.db
|
|
122
|
+
.prepare(`select * from traces order by started_at desc limit ?`)
|
|
123
|
+
.all(limit);
|
|
124
|
+
}
|
|
125
|
+
trace(id) {
|
|
126
|
+
const trace = this.db.prepare(`select * from traces where id = ?`).get(id);
|
|
127
|
+
if (!trace)
|
|
128
|
+
return null;
|
|
129
|
+
const toolCalls = this.db
|
|
130
|
+
.prepare(`select * from tool_calls where trace_id = ? order by seq asc`)
|
|
131
|
+
.all(id);
|
|
132
|
+
return { trace, toolCalls };
|
|
133
|
+
}
|
|
134
|
+
close() {
|
|
135
|
+
this.db.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
package/dist/demo.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Point the proxy at the stub and drive traffic through it.
|
|
3
|
+
*
|
|
4
|
+
* Returns a cleanup function. The caller owns the proxy; this only supplies
|
|
5
|
+
* the fake upstream and the requests.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runDemo(proxyPort: number, write: (s: string) => void): Promise<() => void>;
|
|
8
|
+
/** Policy used in demo mode, so there is something to see being enforced. */
|
|
9
|
+
export declare const DEMO_POLICY: {
|
|
10
|
+
default: "allow";
|
|
11
|
+
tools: {
|
|
12
|
+
allow: never[];
|
|
13
|
+
deny: string[];
|
|
14
|
+
};
|
|
15
|
+
spend: {
|
|
16
|
+
perDayUsd: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
package/dist/demo.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
// Ordered to tell a story on the dashboard: normal work, a leaked
|
|
3
|
+
// credential, then an action policy stops.
|
|
4
|
+
const SCRIPT = [
|
|
5
|
+
{
|
|
6
|
+
label: 'reading the repo',
|
|
7
|
+
prompt: 'Summarise the open issues in this repository.',
|
|
8
|
+
tool: { name: 'read_file', input: { path: 'README.md' } },
|
|
9
|
+
inputTokens: 18_400,
|
|
10
|
+
outputTokens: 900,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
label: 'searching',
|
|
14
|
+
prompt: 'Find every caller of the billing module.',
|
|
15
|
+
tool: { name: 'search_code', input: { query: 'billing.charge(' } },
|
|
16
|
+
inputTokens: 41_200,
|
|
17
|
+
outputTokens: 1_600,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: 'leaking a credential',
|
|
21
|
+
prompt: 'Deploy the staging stack. Use AKIAIOSFODNN7EXAMPLE with secret ' +
|
|
22
|
+
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY and notify ops@realcompany.com.',
|
|
23
|
+
tool: { name: 'run_command', input: { cmd: 'terraform apply -auto-approve' } },
|
|
24
|
+
inputTokens: 22_800,
|
|
25
|
+
outputTokens: 1_100,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: 'attempting a destructive action',
|
|
29
|
+
prompt: 'Clean up the stale test accounts.',
|
|
30
|
+
tool: { name: 'delete_user', input: { id: 'u_8842', reason: 'stale' } },
|
|
31
|
+
inputTokens: 12_500,
|
|
32
|
+
outputTokens: 700,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'sending mail',
|
|
36
|
+
prompt: 'Let the team know the cleanup finished.',
|
|
37
|
+
tool: { name: 'send_email', input: { to: 'team@realcompany.com', subject: 'Cleanup done' } },
|
|
38
|
+
inputTokens: 9_300,
|
|
39
|
+
outputTokens: 400,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
/** Stub provider returning Anthropic-shaped responses. */
|
|
43
|
+
function startStub() {
|
|
44
|
+
let turn = 0;
|
|
45
|
+
const server = http.createServer((req, res) => {
|
|
46
|
+
req.resume();
|
|
47
|
+
req.on('end', () => {
|
|
48
|
+
const t = SCRIPT[Math.min(turn++, SCRIPT.length - 1)];
|
|
49
|
+
const content = [{ type: 'text', text: 'Working on it.' }];
|
|
50
|
+
if (t.tool)
|
|
51
|
+
content.push({ type: 'tool_use', id: `toolu_${turn}`, name: t.tool.name, input: t.tool.input });
|
|
52
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({
|
|
54
|
+
id: `msg_demo_${turn}`,
|
|
55
|
+
model: 'claude-opus-5',
|
|
56
|
+
stop_reason: t.tool ? 'tool_use' : 'end_turn',
|
|
57
|
+
content,
|
|
58
|
+
usage: { input_tokens: t.inputTokens, output_tokens: t.outputTokens },
|
|
59
|
+
}));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, close: () => server.close() }));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
/**
|
|
68
|
+
* Point the proxy at the stub and drive traffic through it.
|
|
69
|
+
*
|
|
70
|
+
* Returns a cleanup function. The caller owns the proxy; this only supplies
|
|
71
|
+
* the fake upstream and the requests.
|
|
72
|
+
*/
|
|
73
|
+
export async function runDemo(proxyPort, write) {
|
|
74
|
+
const stub = await startStub();
|
|
75
|
+
process.env.AMBITRY_ANTHROPIC_BASE = `http://127.0.0.1:${stub.port}`;
|
|
76
|
+
write(`\n Running a scripted agent — no API key used, nothing leaves this machine.\n\n`);
|
|
77
|
+
for (const t of SCRIPT) {
|
|
78
|
+
// Paced so the dashboard visibly fills rather than appearing all at once.
|
|
79
|
+
await sleep(700);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/anthropic/v1/messages`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json', 'x-api-key': 'demo-not-a-real-key' },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
model: 'claude-opus-5',
|
|
86
|
+
max_tokens: 1024,
|
|
87
|
+
messages: [{ role: 'user', content: t.prompt }],
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
// A denied tool call comes back 200 with the call stripped out, so
|
|
91
|
+
// status alone would report a blocked action as having succeeded.
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
const mark = res.status === 403 ? 'stopped' : text.includes('blocked by policy') ? 'blocked' : 'ok';
|
|
94
|
+
write(` ${mark.padEnd(8)} ${t.label}\n`);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
write(` failed ${t.label} — ${err.message}\n`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
write(`\n Open http://localhost:${proxyPort} — you should see a leaked AWS key,\n` +
|
|
101
|
+
` a blocked delete_user call, and what the run cost.\n\n` +
|
|
102
|
+
` Ctrl-C to stop.\n\n`);
|
|
103
|
+
return () => {
|
|
104
|
+
stub.close();
|
|
105
|
+
delete process.env.AMBITRY_ANTHROPIC_BASE;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Policy used in demo mode, so there is something to see being enforced. */
|
|
109
|
+
export const DEMO_POLICY = {
|
|
110
|
+
default: 'allow',
|
|
111
|
+
tools: { allow: [], deny: ['delete_*', 'send_email'] },
|
|
112
|
+
spend: { perDayUsd: 5 },
|
|
113
|
+
};
|
package/dist/dryrun.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Store } from './db.ts';
|
|
2
|
+
import { type Policy } from './policy.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Evaluate a policy against traffic that already happened.
|
|
5
|
+
*
|
|
6
|
+
* Nobody switches on deny-by-default blind — the risk of silently breaking a
|
|
7
|
+
* working agent is too high, so the safest posture is the one least likely to
|
|
8
|
+
* be adopted. Answering "what would this have blocked last week" from real
|
|
9
|
+
* history removes the guesswork, and it is the reason to come back and tune.
|
|
10
|
+
*/
|
|
11
|
+
export interface ToolVerdict {
|
|
12
|
+
name: string;
|
|
13
|
+
calls: number;
|
|
14
|
+
wouldBlock: boolean;
|
|
15
|
+
reason: string | null;
|
|
16
|
+
/** True when the current policy already blocks it — no change. */
|
|
17
|
+
blockedToday: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface DryRunResult {
|
|
20
|
+
since: number;
|
|
21
|
+
totalCalls: number;
|
|
22
|
+
/** Calls that run today but the candidate policy would stop. */
|
|
23
|
+
newlyBlocked: number;
|
|
24
|
+
/** Calls blocked today that the candidate policy would let through. */
|
|
25
|
+
newlyAllowed: number;
|
|
26
|
+
tools: ToolVerdict[];
|
|
27
|
+
}
|
|
28
|
+
export declare function dryRun(store: Store, policy: Policy, days: number): DryRunResult;
|
|
29
|
+
/** Render a dry run for the terminal. */
|
|
30
|
+
export declare function formatDryRun(r: DryRunResult, days: number): string;
|
package/dist/dryrun.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Controls } from "./policy.js";
|
|
2
|
+
export function dryRun(store, policy, days) {
|
|
3
|
+
const since = Date.now() - days * 86_400_000;
|
|
4
|
+
const calls = store.toolCallsSince(since);
|
|
5
|
+
const controls = new Controls(policy);
|
|
6
|
+
// Grouped by name because that is the unit a policy rule addresses; a
|
|
7
|
+
// per-call listing is unreadable at any real traffic volume.
|
|
8
|
+
const byName = new Map();
|
|
9
|
+
for (const c of calls) {
|
|
10
|
+
const entry = byName.get(c.name) ?? { calls: 0, blockedToday: 0 };
|
|
11
|
+
entry.calls++;
|
|
12
|
+
if (c.decision === 'deny')
|
|
13
|
+
entry.blockedToday++;
|
|
14
|
+
byName.set(c.name, entry);
|
|
15
|
+
}
|
|
16
|
+
let newlyBlocked = 0;
|
|
17
|
+
let newlyAllowed = 0;
|
|
18
|
+
const tools = [...byName.entries()]
|
|
19
|
+
.map(([name, { calls: n, blockedToday }]) => {
|
|
20
|
+
const decision = controls.checkTool(name);
|
|
21
|
+
const wouldBlock = !decision.allowed;
|
|
22
|
+
// Counted per call, not per tool: a rule that stops one call a week and
|
|
23
|
+
// one that stops four hundred are very different decisions.
|
|
24
|
+
if (wouldBlock)
|
|
25
|
+
newlyBlocked += n - blockedToday;
|
|
26
|
+
else
|
|
27
|
+
newlyAllowed += blockedToday;
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
calls: n,
|
|
31
|
+
wouldBlock,
|
|
32
|
+
reason: decision.reason,
|
|
33
|
+
blockedToday: blockedToday > 0,
|
|
34
|
+
};
|
|
35
|
+
})
|
|
36
|
+
.sort((a, b) => Number(b.wouldBlock) - Number(a.wouldBlock) || b.calls - a.calls);
|
|
37
|
+
return { since, totalCalls: calls.length, newlyBlocked, newlyAllowed, tools };
|
|
38
|
+
}
|
|
39
|
+
/** Render a dry run for the terminal. */
|
|
40
|
+
export function formatDryRun(r, days) {
|
|
41
|
+
if (r.totalCalls === 0) {
|
|
42
|
+
return `\n No tool calls in the last ${days} days — nothing to evaluate.\n\n`;
|
|
43
|
+
}
|
|
44
|
+
const lines = [
|
|
45
|
+
`\n Policy dry run — ${r.totalCalls} tool call${r.totalCalls === 1 ? '' : 's'} over ${days} days\n`,
|
|
46
|
+
];
|
|
47
|
+
const width = Math.max(...r.tools.map((t) => t.name.length), 4);
|
|
48
|
+
for (const t of r.tools) {
|
|
49
|
+
const mark = t.wouldBlock ? 'BLOCK' : 'allow';
|
|
50
|
+
const note = t.wouldBlock && t.reason ? ` ${t.reason}` : '';
|
|
51
|
+
lines.push(` ${mark.padEnd(6)} ${t.name.padEnd(width)} ${String(t.calls).padStart(4)}×${note}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
if (r.newlyBlocked > 0) {
|
|
55
|
+
lines.push(` ${r.newlyBlocked} call${r.newlyBlocked === 1 ? '' : 's'} that ran would have been stopped.`);
|
|
56
|
+
}
|
|
57
|
+
if (r.newlyAllowed > 0) {
|
|
58
|
+
lines.push(` ${r.newlyAllowed} call${r.newlyAllowed === 1 ? '' : 's'} blocked today would be let through.`);
|
|
59
|
+
}
|
|
60
|
+
if (r.newlyBlocked === 0 && r.newlyAllowed === 0) {
|
|
61
|
+
lines.push(` No change against this history.`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n') + '\n\n';
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing denied tool calls from an upstream response.
|
|
3
|
+
*
|
|
4
|
+
* The agent never sees a blocked call, so it cannot execute it. That is the
|
|
5
|
+
* whole enforcement mechanism, and it is worth being precise about its limit:
|
|
6
|
+
* it governs tools invoked *through this proxy's LLM traffic*. It is not
|
|
7
|
+
* network egress control, and it is weaker than a `before_tool_call` hook
|
|
8
|
+
* inside the agent framework, which sees every call regardless of origin.
|
|
9
|
+
*/
|
|
10
|
+
export interface Denial {
|
|
11
|
+
index: number;
|
|
12
|
+
name: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
/** Rewrite a complete JSON response body with denied calls removed. */
|
|
16
|
+
export declare function filterBody(provider: string, body: string, denials: Denial[]): string;
|
|
17
|
+
/**
|
|
18
|
+
* Rewrite a buffered SSE stream with denied calls removed.
|
|
19
|
+
*
|
|
20
|
+
* Replays the original event text verbatim when nothing is denied, so the
|
|
21
|
+
* common case is byte-identical to what the upstream sent and no SDK can
|
|
22
|
+
* tell the proxy was there.
|
|
23
|
+
*/
|
|
24
|
+
export declare function filterStream(provider: string, sse: string, denials: Denial[]): string;
|
package/dist/enforce.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing denied tool calls from an upstream response.
|
|
3
|
+
*
|
|
4
|
+
* The agent never sees a blocked call, so it cannot execute it. That is the
|
|
5
|
+
* whole enforcement mechanism, and it is worth being precise about its limit:
|
|
6
|
+
* it governs tools invoked *through this proxy's LLM traffic*. It is not
|
|
7
|
+
* network egress control, and it is weaker than a `before_tool_call` hook
|
|
8
|
+
* inside the agent framework, which sees every call regardless of origin.
|
|
9
|
+
*/
|
|
10
|
+
function noteFor(denials) {
|
|
11
|
+
const lines = denials.map((d) => `- ${d.name}: ${d.reason}`);
|
|
12
|
+
return `[ambitry] ${denials.length} tool call${denials.length === 1 ? '' : 's'} blocked by policy:\n${lines.join('\n')}`;
|
|
13
|
+
}
|
|
14
|
+
/** Rewrite a complete JSON response body with denied calls removed. */
|
|
15
|
+
export function filterBody(provider, body, denials) {
|
|
16
|
+
if (denials.length === 0)
|
|
17
|
+
return body;
|
|
18
|
+
let json;
|
|
19
|
+
try {
|
|
20
|
+
json = JSON.parse(body);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Unparseable upstream body: fail closed rather than forward something we
|
|
24
|
+
// could not inspect. A malformed response is not worth passing through.
|
|
25
|
+
return JSON.stringify({ type: 'error', error: { type: 'ambitry_blocked', message: noteFor(denials) } });
|
|
26
|
+
}
|
|
27
|
+
const denied = new Set(denials.map((d) => d.index));
|
|
28
|
+
if (provider === 'anthropic') {
|
|
29
|
+
const content = Array.isArray(json.content) ? json.content : [];
|
|
30
|
+
json.content = content.filter((_, i) => !denied.has(i));
|
|
31
|
+
json.content.push({ type: 'text', text: noteFor(denials) });
|
|
32
|
+
// The turn no longer contains tool calls, so leaving stop_reason as
|
|
33
|
+
// "tool_use" would make the SDK wait for a tool result that never comes.
|
|
34
|
+
if (json.stop_reason === 'tool_use')
|
|
35
|
+
json.stop_reason = 'end_turn';
|
|
36
|
+
return JSON.stringify(json);
|
|
37
|
+
}
|
|
38
|
+
const choice = json.choices?.[0];
|
|
39
|
+
if (choice?.message) {
|
|
40
|
+
const calls = Array.isArray(choice.message.tool_calls) ? choice.message.tool_calls : [];
|
|
41
|
+
const kept = calls.filter((_, i) => !denied.has(i));
|
|
42
|
+
if (kept.length > 0)
|
|
43
|
+
choice.message.tool_calls = kept;
|
|
44
|
+
else
|
|
45
|
+
delete choice.message.tool_calls;
|
|
46
|
+
choice.message.content = [choice.message.content, noteFor(denials)].filter(Boolean).join('\n\n');
|
|
47
|
+
if (choice.finish_reason === 'tool_calls')
|
|
48
|
+
choice.finish_reason = 'stop';
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(json);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite a buffered SSE stream with denied calls removed.
|
|
54
|
+
*
|
|
55
|
+
* Replays the original event text verbatim when nothing is denied, so the
|
|
56
|
+
* common case is byte-identical to what the upstream sent and no SDK can
|
|
57
|
+
* tell the proxy was there.
|
|
58
|
+
*/
|
|
59
|
+
export function filterStream(provider, sse, denials) {
|
|
60
|
+
if (denials.length === 0)
|
|
61
|
+
return sse;
|
|
62
|
+
const denied = new Set(denials.map((d) => d.index));
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const block of sse.split('\n\n')) {
|
|
65
|
+
if (block.trim() === '')
|
|
66
|
+
continue;
|
|
67
|
+
const dataLine = block.split('\n').find((l) => l.trim().startsWith('data:'));
|
|
68
|
+
const payload = dataLine?.trim().slice(5).trim();
|
|
69
|
+
if (!payload || payload === '[DONE]') {
|
|
70
|
+
out.push(block);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
let event;
|
|
74
|
+
try {
|
|
75
|
+
event = JSON.parse(payload);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
out.push(block);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (provider === 'anthropic') {
|
|
82
|
+
// Every event carrying a denied block index is dropped whole:
|
|
83
|
+
// content_block_start, its input_json_deltas, and content_block_stop.
|
|
84
|
+
if (typeof event.index === 'number' && denied.has(event.index))
|
|
85
|
+
continue;
|
|
86
|
+
if (event.type === 'message_delta' && event.delta?.stop_reason === 'tool_use') {
|
|
87
|
+
event.delta.stop_reason = 'end_turn';
|
|
88
|
+
out.push(`event: message_delta\ndata: ${JSON.stringify(event)}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out.push(block);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const calls = event.choices?.[0]?.delta?.tool_calls;
|
|
95
|
+
if (Array.isArray(calls)) {
|
|
96
|
+
const kept = calls.filter((c) => !denied.has(c.index ?? 0));
|
|
97
|
+
// Dropping the last fragment of a call would leave an empty delta, so
|
|
98
|
+
// skip the event entirely rather than emit a meaningless one.
|
|
99
|
+
if (kept.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
event.choices[0].delta.tool_calls = kept;
|
|
102
|
+
out.push(`data: ${JSON.stringify(event)}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
out.push(block);
|
|
106
|
+
}
|
|
107
|
+
const note = provider === 'anthropic'
|
|
108
|
+
? `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 99, content_block: { type: 'text', text: noteFor(denials) } })}\n\nevent: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 99 })}`
|
|
109
|
+
: `data: ${JSON.stringify({ choices: [{ delta: { content: `\n\n${noteFor(denials)}` }, index: 0 }] })}`;
|
|
110
|
+
// The notice is a content block, so it has to land before the events that
|
|
111
|
+
// close the message. Appending at the end puts it after `message_delta`
|
|
112
|
+
// (Anthropic) or `[DONE]` (OpenAI), and SDKs stop collecting content there
|
|
113
|
+
// — the block would be silently dropped by the very client it warns.
|
|
114
|
+
const terminal = out.findIndex((b) => b.includes('[DONE]') || b.includes('"message_delta"') || b.includes('"message_stop"'));
|
|
115
|
+
if (terminal === -1)
|
|
116
|
+
out.push(note);
|
|
117
|
+
else
|
|
118
|
+
out.splice(terminal, 0, note);
|
|
119
|
+
return out.join('\n\n') + '\n\n';
|
|
120
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ToolCall {
|
|
2
|
+
name: string;
|
|
3
|
+
/** Parsed arguments, or the raw string when the model emitted invalid JSON. */
|
|
4
|
+
input: unknown;
|
|
5
|
+
/**
|
|
6
|
+
* Content-block index (Anthropic) or choice index (OpenAI).
|
|
7
|
+
*
|
|
8
|
+
* Carried so enforcement can drop exactly the events belonging to a denied
|
|
9
|
+
* call when replaying a buffered stream, rather than discarding the turn.
|
|
10
|
+
*/
|
|
11
|
+
index: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ParsedRequest {
|
|
14
|
+
model: string | null;
|
|
15
|
+
stream: boolean;
|
|
16
|
+
/** Tools the caller offered the model, not the ones it chose. */
|
|
17
|
+
toolNames: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface ParsedResponse {
|
|
20
|
+
model: string | null;
|
|
21
|
+
inputTokens: number | null;
|
|
22
|
+
outputTokens: number | null;
|
|
23
|
+
toolCalls: ToolCall[];
|
|
24
|
+
}
|
|
25
|
+
export declare function parseRequest(provider: string, body: Buffer): ParsedRequest;
|
|
26
|
+
/**
|
|
27
|
+
* Accumulates a response — streaming or not — into a ParsedResponse.
|
|
28
|
+
*
|
|
29
|
+
* Two things make this fiddly, and both are silent-corruption bugs rather
|
|
30
|
+
* than crashes, so they are worth naming:
|
|
31
|
+
*
|
|
32
|
+
* 1. TCP chunks do not align with SSE event boundaries. A `data:` line can
|
|
33
|
+
* be split across two `push()` calls, so we hold a line buffer and only
|
|
34
|
+
* process complete lines.
|
|
35
|
+
* 2. Chunks can split a multi-byte UTF-8 character. `Buffer.toString()` per
|
|
36
|
+
* chunk would replace the halves with U+FFFD and corrupt any non-ASCII
|
|
37
|
+
* prompt. StringDecoder holds the partial bytes until the rest arrives.
|
|
38
|
+
*/
|
|
39
|
+
export declare class ResponseParser {
|
|
40
|
+
private decoder;
|
|
41
|
+
private lineBuffer;
|
|
42
|
+
/** Whole-body text, used only in the non-streaming path. */
|
|
43
|
+
private body;
|
|
44
|
+
private model;
|
|
45
|
+
private inputTokens;
|
|
46
|
+
private outputTokens;
|
|
47
|
+
/** Streaming tool calls, keyed by content-block / choice index. */
|
|
48
|
+
private partial;
|
|
49
|
+
private done;
|
|
50
|
+
private provider;
|
|
51
|
+
private streaming;
|
|
52
|
+
constructor(provider: string, streaming: boolean);
|
|
53
|
+
push(chunk: Buffer): void;
|
|
54
|
+
private handleLine;
|
|
55
|
+
private anthropicEvent;
|
|
56
|
+
private openaiEvent;
|
|
57
|
+
private flush;
|
|
58
|
+
finish(): ParsedResponse;
|
|
59
|
+
private parseWholeBody;
|
|
60
|
+
}
|