@olurabian/audit 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oluwasegun Araba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @olurabian/audit
2
+
3
+ Can a compromised agent move money outside policy?
4
+
5
+ The Agent Payment Security Audit as a runnable. Nine questions about your agent's payment setup, scored on eight dimensions, with the blast radius in your own numbers. Anything you leave out comes back as Unknown with the exact question to ask, never a guess. No model in the loop, nothing leaves your machine.
6
+
7
+ ## Run it
8
+
9
+ ```bash
10
+ npx @olurabian/audit
11
+ ```
12
+
13
+ It asks the questions in your terminal and prints the readout.
14
+
15
+ - `--intake <file>` scores a saved intake instead of asking
16
+ - `--out <dir>` also writes `audit.md` and `audit.html`, and still runs alongside `--json`
17
+ - `--json` prints the readout as JSON
18
+ - `--example` prints an example intake to edit
19
+ - `--help` prints this list
20
+
21
+ To keep a copy, add `--out ./audit` and you get `audit.md` and `audit.html`. To answer once and rerun, start from `npx @olurabian/audit --example > intake.json`, edit it, then `npx @olurabian/audit --intake intake.json`.
22
+
23
+ A browser version with the same engine lives at https://deadlatch.dev/audit. Nothing you type there leaves the page either.
24
+
25
+ ## What it scores
26
+
27
+ Eight dimensions, each Closed, Partial, Exposed, or Unknown.
28
+
29
+ 1. Single path. Every money path funnels through one enforcement point.
30
+ 2. Custody. The credential lives where the agent cannot read it.
31
+ 3. Mediated execution. The agent submits an intent and something else pays.
32
+ 4. Intent-binding. Each approved spend is bound to an exact payee and amount.
33
+ 5. No splitting. Budget is reserved when a spend is approved, not when it settles.
34
+ 6. Human approval. Large spends wait for a person the agent cannot impersonate.
35
+ 7. Provable audit. Every decision and the settled amount sit in a tamper-evident record.
36
+ 8. Continuous verification. The money path is re-checked whenever the tool set changes.
37
+
38
+ Two framings come out of the score. Forgery is open when custody, single path, or mediated execution is exposed, meaning the agent can make a payment it was never handed the means to make. Misdirection is open when intent-binding is exposed or the agent approves its own spends, meaning it can hand you a perfectly in-policy request that is not what you meant.
39
+
40
+ ## The readout
41
+
42
+ Six sections, in this order and nothing else. Posture in one line. The money-path map, one path per line, mediated or not. Exposure, the eight verdicts with a one-line finding and the question to ask for each Unknown. Top breaches, at most three, each with the loss in money and the fix in one line. Which is open, forgery or misdirection or both. The shortest path, as many steps as it takes to close every open dimension, at most six, each named plainly as a governance layer, hands-on work, or a practice you keep, and none when nothing is open.
43
+
44
+ The last line is a plain next step. This is a diagnostic, not a sales tool.
45
+
46
+ ## Use the engine
47
+
48
+ ```js
49
+ import { score, render, exampleIntake } from "@olurabian/audit";
50
+
51
+ const readout = score(exampleIntake());
52
+ console.log(render(readout, "markdown"));
53
+ ```
54
+
55
+ The intake schema is exported as `questions`, so any form can render it. `score` is pure and deterministic. `render` gives you text, Markdown, or a self-contained HTML report.
56
+
57
+ ## The prompt
58
+
59
+ If you would rather use a model, the original prompt is in `prompts/agent-payment-security-audit.md`. It scores the same dimensions and follows the same rules.
60
+
61
+ ## License
62
+
63
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+ import { questions } from "./questions.js";
7
+ import { score } from "./score.js";
8
+ import { render } from "./render.js";
9
+ import { exampleIntake } from "./example.js";
10
+ const args = process.argv.slice(2);
11
+ const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
12
+ const has = (name) => args.includes(name);
13
+ if (has("--help") || has("-h")) {
14
+ stdout.write(`audit. Can a compromised agent move money outside policy?
15
+
16
+ npx @olurabian/audit ask the nine questions in the terminal
17
+ npx @olurabian/audit --intake a.json score a saved intake
18
+ --out <dir> also write audit.md and audit.html
19
+ --json print the readout as JSON
20
+ --example print an example intake to edit
21
+ --help print this list
22
+ `);
23
+ process.exit(0);
24
+ }
25
+ if (has("--example")) {
26
+ stdout.write(JSON.stringify(exampleIntake(), null, 2) + "\n");
27
+ process.exit(0);
28
+ }
29
+ function parseMoney(s) {
30
+ const m = s.trim().match(/^([£$€])?\s*([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]{3})?$/);
31
+ if (!m)
32
+ return undefined;
33
+ const sym = { "£": "GBP", "$": "USD", "€": "EUR" };
34
+ const currency = (m[3] ?? (m[1] ? sym[m[1]] : undefined) ?? "USD").toUpperCase();
35
+ return { amount: Number(m[2]), currency };
36
+ }
37
+ const BLANK = Symbol("blank");
38
+ const INVALID = Symbol("invalid");
39
+ async function ask() {
40
+ const rl = createInterface({ input: stdin, output: stdout });
41
+ const intake = {};
42
+ stdout.write("\nAgent Payment Security Audit. Nine questions. Leave a line blank to mark it unknown.\n");
43
+ try {
44
+ for (const q of questions) {
45
+ stdout.write(`\n${q.title}\n${q.prompt}\n`);
46
+ const answers = {};
47
+ let answered = false;
48
+ for (const f of q.fields) {
49
+ const v = await askField(rl, f);
50
+ if (v === INVALID) {
51
+ stdout.write(" Not a valid answer, marked unknown.\n");
52
+ if (f.kind === "choice" || f.kind === "boolean" || f.kind === "number")
53
+ answers[f.key] = "unknown";
54
+ // money: left absent, as today. list and text never produce INVALID.
55
+ }
56
+ else if (v === BLANK) {
57
+ if (f.kind === "choice" || f.kind === "boolean" || f.kind === "number" || f.kind === "list")
58
+ answers[f.key] = "unknown";
59
+ // money and text: left absent.
60
+ }
61
+ else {
62
+ answers[f.key] = v;
63
+ answered = true;
64
+ }
65
+ }
66
+ const notes = (await rl.question(" Notes, optional. ")).trim();
67
+ if (notes) {
68
+ answers.notes = notes;
69
+ answered = true;
70
+ }
71
+ if (answered)
72
+ intake[q.id] = answers;
73
+ }
74
+ }
75
+ finally {
76
+ rl.close();
77
+ }
78
+ return intake;
79
+ }
80
+ async function askField(rl, f) {
81
+ if (f.kind === "choice") {
82
+ const cs = f.choices ?? [];
83
+ stdout.write(` ${f.label}\n${cs.map((c, i) => ` ${i + 1}. ${c.label}`).join("\n")}\n`);
84
+ const a = (await rl.question(" Number. ")).trim();
85
+ if (!a)
86
+ return BLANK;
87
+ const n = Number(a);
88
+ if (!Number.isInteger(n) || n < 1 || n > cs.length)
89
+ return INVALID;
90
+ const v = cs[n - 1].value;
91
+ return v === "unknown" ? BLANK : v;
92
+ }
93
+ const a = (await rl.question(` ${f.label}${f.optional ? ", optional" : ""}. `)).trim();
94
+ if (!a)
95
+ return BLANK;
96
+ if (f.kind === "money") {
97
+ const m = parseMoney(a);
98
+ return m ?? INVALID;
99
+ }
100
+ if (f.kind === "list")
101
+ return a.split(",").map((s) => s.trim()).filter(Boolean);
102
+ if (f.kind === "boolean") {
103
+ if (/^(y|yes|true)$/i.test(a))
104
+ return true;
105
+ if (/^(n|no|false)$/i.test(a))
106
+ return false;
107
+ return INVALID;
108
+ }
109
+ if (f.kind === "number") {
110
+ const n = Number(a);
111
+ return Number.isFinite(n) ? n : INVALID;
112
+ }
113
+ return a;
114
+ }
115
+ async function main() {
116
+ let intake;
117
+ if (has("--intake")) {
118
+ const file = flag("--intake");
119
+ if (!file) {
120
+ process.stderr.write("audit: --intake needs a file path\n");
121
+ process.exit(1);
122
+ }
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
125
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
126
+ throw new Error("expected a JSON object with the intake fields");
127
+ intake = parsed;
128
+ }
129
+ catch (e) {
130
+ process.stderr.write(`audit: cannot read intake ${file} (${e.message})\n`);
131
+ process.exit(1);
132
+ }
133
+ }
134
+ else {
135
+ if (!stdin.isTTY) {
136
+ process.stderr.write("audit: no --intake given and no terminal to ask in; try --example\n");
137
+ process.exit(1);
138
+ }
139
+ intake = await ask();
140
+ }
141
+ const readout = score(intake);
142
+ const out = flag("--out");
143
+ if (out) {
144
+ mkdirSync(out, { recursive: true });
145
+ writeFileSync(join(out, "audit.md"), render(readout, "markdown"));
146
+ writeFileSync(join(out, "audit.html"), render(readout, "html"));
147
+ }
148
+ if (has("--json")) {
149
+ stdout.write(JSON.stringify(readout, null, 2) + "\n");
150
+ return;
151
+ }
152
+ stdout.write("\n" + render(readout, "text"));
153
+ if (out) {
154
+ stdout.write(`\nWritten. ${join(out, "audit.md")} and ${join(out, "audit.html")}\n`);
155
+ }
156
+ }
157
+ main().catch((e) => { process.stderr.write(`audit: ${e.message}\n`); process.exit(1); });
@@ -0,0 +1,3 @@
1
+ import type { Intake } from "./types.js";
2
+ /** A filled intake to start from. An honest, common shape, an agent with a rail key and settlement-time caps. */
3
+ export declare function exampleIntake(): Intake;
@@ -0,0 +1,14 @@
1
+ /** A filled intake to start from. An honest, common shape, an agent with a rail key and settlement-time caps. */
2
+ export function exampleIntake() {
3
+ return {
4
+ spend: { what: ["API credits", "data"], frequency: "daily", typical: { amount: 12.5, currency: "USD" }, notes: "Mostly LLM and search APIs." },
5
+ reach: { tools: ["http", "stripe-sdk", "filesystem"], mcpServers: ["search"], keysInRuntime: ["STRIPE_SECRET_KEY"], paymentPaths: 1 },
6
+ custody: { where: "agent-runtime" },
7
+ execution: { who: "agent-calls-rail" },
8
+ binding: { mode: "any-in-policy" },
9
+ limits: { perAction: { amount: 50, currency: "USD" }, perDay: { amount: 500, currency: "USD" }, enforcedAt: "at-settlement", reservedAtGrant: false },
10
+ approval: { mode: "in-band", threshold: { amount: 100, currency: "USD" } },
11
+ record: { exists: true, tamperEvident: false, settledAmountRecorded: false },
12
+ drift: { changeFrequency: "weekly", recheck: "never" },
13
+ };
14
+ }
@@ -0,0 +1,8 @@
1
+ export { DIMENSIONS, DIMENSION_LABEL } from "./types.js";
2
+ export type { Money, Verdict, Dimension, Intake, Field, FieldKind, Question, Readout, Openness, StepKind } from "./types.js";
3
+ export { questions } from "./questions.js";
4
+ export { score } from "./score.js";
5
+ export { render } from "./render.js";
6
+ export type { Format } from "./render.js";
7
+ export { exampleIntake } from "./example.js";
8
+ export { formatMoney } from "./money.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { DIMENSIONS, DIMENSION_LABEL } from "./types.js";
2
+ export { questions } from "./questions.js";
3
+ export { score } from "./score.js";
4
+ export { render } from "./render.js";
5
+ export { exampleIntake } from "./example.js";
6
+ export { formatMoney } from "./money.js";
@@ -0,0 +1,7 @@
1
+ import type { Money } from "./types.js";
2
+ export declare function formatMoney(m: Money): string;
3
+ /** The binding cap for blast radius: per day first, then per spend, then none. */
4
+ export declare function capOf(limits: {
5
+ perAction?: Money;
6
+ perDay?: Money;
7
+ } | undefined): Money | undefined;
package/dist/money.js ADDED
@@ -0,0 +1,13 @@
1
+ const SYMBOL = { USD: "$", GBP: "£", EUR: "€" };
2
+ export function formatMoney(m) {
3
+ const n = m.amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
4
+ if (!m.currency)
5
+ return n;
6
+ const cur = m.currency.toUpperCase();
7
+ const s = SYMBOL[cur];
8
+ return s ? `${s}${n}` : `${n} ${cur}`;
9
+ }
10
+ /** The binding cap for blast radius: per day first, then per spend, then none. */
11
+ export function capOf(limits) {
12
+ return limits?.perDay ?? limits?.perAction;
13
+ }
@@ -0,0 +1,2 @@
1
+ import type { Question } from "./types.js";
2
+ export declare const questions: Question[];
@@ -0,0 +1,97 @@
1
+ const unknown = { value: "unknown", label: "Unknown" };
2
+ export const questions = [
3
+ {
4
+ id: "spend",
5
+ title: "What the agent buys",
6
+ prompt: "What does the agent buy, and how often? API credits, compute, data, vendors, on-chain.",
7
+ fields: [
8
+ { key: "what", label: "What it buys", kind: "list" },
9
+ { key: "frequency", label: "How often", kind: "choice", choices: [
10
+ { value: "rare", label: "Rarely, a few times a month" }, { value: "daily", label: "Daily" }, { value: "continuous", label: "Continuously, many times an hour" }, unknown
11
+ ] },
12
+ { key: "typical", label: "Typical spend", kind: "money", optional: true },
13
+ ],
14
+ },
15
+ {
16
+ id: "reach",
17
+ title: "What the runtime can reach",
18
+ prompt: "What can the agent's runtime reach? List every tool, MCP server, SDK, and key in the agent's process.",
19
+ fields: [
20
+ { key: "tools", label: "Tools and SDKs", kind: "list" },
21
+ { key: "mcpServers", label: "MCP servers", kind: "list" },
22
+ { key: "keysInRuntime", label: "Payment credentials present in the agent's process, prompt, memory, or tools", kind: "list" },
23
+ { key: "paymentPaths", label: "How many distinct ways the runtime can move money", kind: "number", optional: true },
24
+ ],
25
+ },
26
+ {
27
+ id: "custody",
28
+ title: "Where the credential lives",
29
+ prompt: "Where does the payment credential live? In the agent's process or prompt or memory, or behind a separate service or signer.",
30
+ fields: [{ key: "where", label: "Credential location", kind: "choice", choices: [
31
+ { value: "agent-runtime", label: "In the agent's process, prompt, memory, or tools" }, { value: "separate-service", label: "Behind a separate service or signer the agent cannot read" }, unknown
32
+ ] }],
33
+ },
34
+ {
35
+ id: "execution",
36
+ title: "Who executes the payment",
37
+ prompt: "Who executes the payment? The agent calls the rail itself, or it submits an intent to something that executes.",
38
+ fields: [{ key: "who", label: "Executor", kind: "choice", choices: [
39
+ { value: "agent-calls-rail", label: "The agent calls the rail itself" }, { value: "intent-to-executor", label: "The agent submits an intent and something else executes behind a boundary" }, unknown
40
+ ] }],
41
+ },
42
+ {
43
+ id: "binding",
44
+ title: "What a grant is bound to",
45
+ prompt: "When a spend is approved, is it bound to a specific payee and amount, or can the agent supply any in-policy value?",
46
+ fields: [{ key: "mode", label: "Binding", kind: "choice", choices: [
47
+ { value: "bound-payee-and-amount", label: "Bound to an exact payee and amount, single use" }, { value: "any-in-policy", label: "The agent chooses the payee and amount within policy" }, unknown
48
+ ] }],
49
+ },
50
+ {
51
+ id: "limits",
52
+ title: "Limits and where they bite",
53
+ prompt: "What limits exist and where are they enforced? Per-action, daily, per-vendor. Checked before the spend or only at settlement.",
54
+ fields: [
55
+ { key: "perAction", label: "Cap per spend", kind: "money", optional: true },
56
+ { key: "perDay", label: "Cap per day", kind: "money", optional: true },
57
+ { key: "enforcedAt", label: "Where caps are checked", kind: "choice", choices: [
58
+ { value: "before-spend", label: "Before the spend" }, { value: "at-settlement", label: "Only when the spend settles" }, { value: "none", label: "No caps" }, unknown
59
+ ] },
60
+ { key: "reservedAtGrant", label: "Budget is reserved the moment a spend is approved, not when it settles", kind: "boolean" },
61
+ ],
62
+ },
63
+ {
64
+ id: "approval",
65
+ title: "Human approval",
66
+ prompt: "Is there human approval for large spends? None, in-band where the agent decides, or out of band where a person approves the exact spend.",
67
+ fields: [
68
+ { key: "mode", label: "Approval", kind: "choice", choices: [
69
+ { value: "none", label: "None" }, { value: "in-band", label: "In-band, the agent decides" }, { value: "out-of-band", label: "Out of band, a person approves the exact spend" }, unknown
70
+ ] },
71
+ { key: "threshold", label: "Approval threshold", kind: "money", optional: true },
72
+ ],
73
+ },
74
+ {
75
+ id: "record",
76
+ title: "The record",
77
+ prompt: "Is there a record of every decision and the amount actually settled, and can it be tampered with?",
78
+ fields: [
79
+ { key: "exists", label: "A record of every decision exists", kind: "boolean" },
80
+ { key: "tamperEvident", label: "The record is tamper-evident, hash-chained or signed", kind: "boolean" },
81
+ { key: "settledAmountRecorded", label: "The amount actually settled is in the record", kind: "boolean" },
82
+ ],
83
+ },
84
+ {
85
+ id: "drift",
86
+ title: "Drift",
87
+ prompt: "How often does the agent's tool or dependency set change, and is the money-path re-checked when it does?",
88
+ fields: [
89
+ { key: "changeFrequency", label: "How often tools or dependencies change", kind: "choice", choices: [
90
+ { value: "rare", label: "Rarely" }, { value: "weekly", label: "Weekly" }, { value: "daily", label: "Daily or more" }, unknown
91
+ ] },
92
+ { key: "recheck", label: "When the money path is re-checked", kind: "choice", choices: [
93
+ { value: "on-change", label: "Every time the tool or dependency set changes" }, { value: "at-deploy", label: "Once, at deploy" }, { value: "never", label: "Never" }, unknown
94
+ ] },
95
+ ],
96
+ },
97
+ ];
@@ -0,0 +1,3 @@
1
+ import type { Readout } from "./types.js";
2
+ export type Format = "text" | "markdown" | "html";
3
+ export declare function render(r: Readout, format: Format): string;
package/dist/render.js ADDED
@@ -0,0 +1,68 @@
1
+ import { DIMENSION_LABEL } from "./types.js";
2
+ const TITLES = ["Posture", "Money-path map", "Exposure", "Top breaches", "Which is open", "Shortest path"];
3
+ function lines(r) {
4
+ return [
5
+ { title: TITLES[0], body: [r.posture] },
6
+ { title: TITLES[1], body: r.moneyPaths.map((p) => p.path) },
7
+ { title: TITLES[2], body: r.exposure.map((e) => `${DIMENSION_LABEL[e.dimension]}. ${e.verdict}. ${e.finding}${e.question ? ` Ask. ${e.question}` : ""}`) },
8
+ { title: TITLES[3], body: r.topBreaches.length ? r.topBreaches.map((b, i) => `${i + 1}. ${DIMENSION_LABEL[b.dimension]}. ${b.blastRadius} Fix. ${b.fix}`) : ["None from what was described."] },
9
+ { title: TITLES[4], body: [`Forgery ${r.open.forgery}, misdirection ${r.open.misdirection}. ${r.open.why}`] },
10
+ { title: TITLES[5], body: [...r.shortestPath.map((s, i) => `${i + 1}. ${s.step} (${kindLabel(s.kind)})`), r.lastLine] },
11
+ ];
12
+ }
13
+ function kindLabel(k) {
14
+ return { "governance-layer": "a payment-governance layer", "hosted-control-plane": "a hosted control plane", "hands-on": "hands-on implementation", practice: "a practice you keep" }[k];
15
+ }
16
+ function collapseWs(s) {
17
+ return s.replace(/\s+/g, " ").trim();
18
+ }
19
+ function notesBlock(r) {
20
+ return r.notes.map((n) => `${n.dimension === "spend" ? "Spend" : DIMENSION_LABEL[n.dimension]}. ${collapseWs(n.text)}`);
21
+ }
22
+ export function render(r, format) {
23
+ if (format !== "text" && format !== "markdown" && format !== "html")
24
+ throw new Error("unknown format");
25
+ const secs = lines(r);
26
+ if (format === "text") {
27
+ const out = secs.map((s, i) => `${i + 1}. ${s.title}\n${s.body.map((b) => ` ${b}`).join("\n")}`);
28
+ if (r.notes.length)
29
+ out.push(`Notes you gave, echoed and never scored\n${notesBlock(r).map((b) => ` ${b}`).join("\n")}`);
30
+ return out.join("\n\n") + "\n";
31
+ }
32
+ if (format === "markdown") {
33
+ const out = ["# Agent Payment Security Audit", "", "Can a compromised agent move money outside policy?", ""];
34
+ secs.forEach((s, i) => { out.push(`## ${i + 1}. ${s.title}`, "", ...s.body.map((b) => (s.title === TITLES[2] || s.title === TITLES[1] ? `- ${b}` : b)), ""); });
35
+ if (r.notes.length)
36
+ out.push("## Notes you gave, echoed and never scored", "", ...notesBlock(r).map((b) => `- ${b}`), "");
37
+ return out.join("\n");
38
+ }
39
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
40
+ const link = (s) => esc(s).replace(/(https?:\/\/[^\s"'<>&]+)/g, (m) => {
41
+ const mm = m.match(/^(.*?)([.,;)\]]+)$/);
42
+ const url = mm ? mm[1] : m;
43
+ const trail = mm ? mm[2] : "";
44
+ return `<a href="${url}">${url}</a>${trail}`;
45
+ });
46
+ const verdictClass = (b) => (/\. Exposed\./.test(b) ? "exposed" : /\. Partial\./.test(b) ? "partial" : /\. Unknown\./.test(b) ? "unknown" : "closed");
47
+ const body = secs.map((s, i) => `<section><h2><span class="n">${i + 1}</span>${esc(s.title)}</h2>${s.title === TITLES[2] ? `<ul>${s.body.map((b) => `<li class="${verdictClass(b)}">${link(b)}</li>`).join("")}</ul>` : s.body.map((b) => `<p>${link(b)}</p>`).join("")}</section>`).join("");
48
+ const notes = r.notes.length ? `<section class="notes"><h2>Notes you gave, echoed and never scored</h2><ul>${notesBlock(r).map((b) => `<li>${esc(b)}</li>`).join("")}</ul></section>` : "";
49
+ return `<!doctype html>
50
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Agent Payment Security Audit</title>
51
+ <style>
52
+ :root{--bg:#f7f6f2;--ink:#161a1f;--muted:#5b6470;--line:#d9d6cc;--closed:#1f7a4d;--partial:#a3660d;--exposed:#b3261e;--unknown:#4a5563}
53
+ @media (prefers-color-scheme: dark){:root{--bg:#0b0e14;--ink:#e6ebf2;--muted:#9aa4b2;--line:#252a33;--closed:#5fd39a;--partial:#f0c060;--exposed:#ff7a6e;--unknown:#9aa4b2}}
54
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
55
+ main{max-width:760px;margin:0 auto;padding:40px 20px 64px}
56
+ h1{font-size:1.6rem;margin:0 0 4px}.q{color:var(--muted);margin:0 0 28px}
57
+ section{border-top:1px solid var(--line);padding:18px 0}
58
+ h2{font-size:1.05rem;margin:0 0 8px}.n{display:inline-block;width:1.6em;color:var(--muted);font-variant-numeric:tabular-nums}
59
+ p,li{margin:6px 0}ul{padding-left:1.2em}
60
+ li.closed::marker{color:var(--closed)}li.partial::marker{color:var(--partial)}li.exposed::marker{color:var(--exposed)}li.unknown::marker{color:var(--unknown)}
61
+ a{color:inherit}.foot{color:var(--muted);font-size:.85rem;margin-top:28px}
62
+ </style></head><body><main>
63
+ <h1>Agent Payment Security Audit</h1><p class="q">Can a compromised agent move money outside policy?</p>
64
+ ${body}${notes}
65
+ <p class="foot">Produced by @olurabian/audit. Deterministic, no model, nothing left your machine.</p>
66
+ </main></body></html>
67
+ `;
68
+ }
@@ -0,0 +1,20 @@
1
+ import type { Dimension, Intake, Readout, StepKind } from "./types.js";
2
+ export declare function blastRadius(d: Dimension, i: Intake): string;
3
+ export type Step = {
4
+ closes: Dimension[];
5
+ effort: number;
6
+ kind: StepKind;
7
+ step: string;
8
+ };
9
+ export declare const STEPS: Step[];
10
+ /**
11
+ * Greedy set cover over STEPS for the given open dimensions.
12
+ * Each iteration recomputes, for every candidate not yet chosen, the ratio of
13
+ * still-open dimensions it would close to its effort; picks the highest ratio,
14
+ * ties toward the candidate that closes more still-open dimensions, then table
15
+ * order for determinism. A candidate is kept only if it closes at least one
16
+ * dimension still open. Stops when nothing is open. Since STEPS has six
17
+ * entries, the result has at most six steps.
18
+ */
19
+ export declare function coverSteps(open: Dimension[]): Step[];
20
+ export declare function score(rawIntake: Intake): Readout;
package/dist/score.js ADDED
@@ -0,0 +1,460 @@
1
+ import { DIMENSIONS, DIMENSION_LABEL } from "./types.js";
2
+ import { formatMoney, capOf } from "./money.js";
3
+ import { questions } from "./questions.js";
4
+ function promptFor(id) {
5
+ const q = questions.find((q) => q.id === id);
6
+ if (!q)
7
+ throw new Error(`no question for ${id}`);
8
+ return q.prompt;
9
+ }
10
+ const DIMENSION_QUESTION_ID = {
11
+ "single-path": "execution",
12
+ "custody": "custody",
13
+ "mediated-execution": "execution",
14
+ "intent-binding": "binding",
15
+ "no-splitting": "limits",
16
+ "human-approval": "approval",
17
+ "provable-audit": "record",
18
+ "continuous-verification": "drift",
19
+ };
20
+ // ---------- normalisation ----------
21
+ function isPlainObject(v) {
22
+ return typeof v === "object" && v !== null && !Array.isArray(v);
23
+ }
24
+ /** Collapse runs of whitespace, including newlines, to one space. */
25
+ function collapseWhitespace(s) {
26
+ return s.replace(/\s+/g, " ").trim();
27
+ }
28
+ function normList(v) {
29
+ if (Array.isArray(v))
30
+ return v.filter((x) => typeof x === "string").map(collapseWhitespace);
31
+ if (v === "unknown")
32
+ return "unknown";
33
+ if (typeof v === "string" && v.length > 0)
34
+ return [collapseWhitespace(v)];
35
+ return "unknown";
36
+ }
37
+ function normMoney(v) {
38
+ if (!isPlainObject(v))
39
+ return undefined;
40
+ let amount = v.amount;
41
+ if (typeof amount === "string" && amount.trim() !== "") {
42
+ const n = Number(amount);
43
+ if (Number.isFinite(n))
44
+ amount = n;
45
+ }
46
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0)
47
+ return undefined;
48
+ const currency = typeof v.currency === "string" && v.currency.length > 0 ? v.currency : undefined;
49
+ return currency !== undefined ? { amount, currency } : { amount };
50
+ }
51
+ function normPaymentPaths(v) {
52
+ let n = v;
53
+ if (typeof n === "string" && n.trim() !== "") {
54
+ const parsed = Number(n);
55
+ if (Number.isFinite(parsed))
56
+ n = parsed;
57
+ }
58
+ if (typeof n === "number" && Number.isInteger(n) && n > 0)
59
+ return n;
60
+ return "unknown";
61
+ }
62
+ function normBool(v) {
63
+ if (v === true || v === false)
64
+ return v;
65
+ if (v === "true")
66
+ return true;
67
+ if (v === "false")
68
+ return false;
69
+ return "unknown";
70
+ }
71
+ function str(v) {
72
+ return typeof v === "string" ? collapseWhitespace(v) : undefined;
73
+ }
74
+ /** Normalise a hand-edited intake on a deep copy. score() never mutates its input. */
75
+ function normalize(intake) {
76
+ const src = intake;
77
+ const out = {};
78
+ const spend = isPlainObject(src.spend) ? src.spend : undefined;
79
+ if (spend) {
80
+ const notes = str(spend.notes);
81
+ const typical = normMoney(spend.typical);
82
+ out.spend = {
83
+ what: normList(spend.what),
84
+ frequency: spend.frequency,
85
+ ...(typical ? { typical } : {}),
86
+ ...(notes ? { notes } : {}),
87
+ };
88
+ }
89
+ const reach = isPlainObject(src.reach) ? src.reach : undefined;
90
+ if (reach) {
91
+ const notes = str(reach.notes);
92
+ out.reach = {
93
+ tools: normList(reach.tools),
94
+ mcpServers: normList(reach.mcpServers),
95
+ keysInRuntime: normList(reach.keysInRuntime),
96
+ paymentPaths: normPaymentPaths(reach.paymentPaths),
97
+ ...(notes ? { notes } : {}),
98
+ };
99
+ }
100
+ const custody = isPlainObject(src.custody) ? src.custody : undefined;
101
+ if (custody) {
102
+ const notes = str(custody.notes);
103
+ out.custody = { where: custody.where, ...(notes ? { notes } : {}) };
104
+ }
105
+ const execution = isPlainObject(src.execution) ? src.execution : undefined;
106
+ if (execution) {
107
+ const notes = str(execution.notes);
108
+ out.execution = { who: execution.who, ...(notes ? { notes } : {}) };
109
+ }
110
+ const binding = isPlainObject(src.binding) ? src.binding : undefined;
111
+ if (binding) {
112
+ const notes = str(binding.notes);
113
+ out.binding = { mode: binding.mode, ...(notes ? { notes } : {}) };
114
+ }
115
+ const limits = isPlainObject(src.limits) ? src.limits : undefined;
116
+ if (limits) {
117
+ const notes = str(limits.notes);
118
+ const perAction = normMoney(limits.perAction);
119
+ const perDay = normMoney(limits.perDay);
120
+ out.limits = {
121
+ ...(perAction ? { perAction } : {}),
122
+ ...(perDay ? { perDay } : {}),
123
+ enforcedAt: limits.enforcedAt,
124
+ reservedAtGrant: normBool(limits.reservedAtGrant),
125
+ ...(notes ? { notes } : {}),
126
+ };
127
+ }
128
+ const approval = isPlainObject(src.approval) ? src.approval : undefined;
129
+ if (approval) {
130
+ const notes = str(approval.notes);
131
+ const threshold = normMoney(approval.threshold);
132
+ out.approval = { mode: approval.mode, ...(threshold ? { threshold } : {}), ...(notes ? { notes } : {}) };
133
+ }
134
+ const record = isPlainObject(src.record) ? src.record : undefined;
135
+ if (record) {
136
+ const notes = str(record.notes);
137
+ out.record = {
138
+ exists: normBool(record.exists),
139
+ tamperEvident: normBool(record.tamperEvident),
140
+ settledAmountRecorded: normBool(record.settledAmountRecorded),
141
+ ...(notes ? { notes } : {}),
142
+ };
143
+ }
144
+ const drift = isPlainObject(src.drift) ? src.drift : undefined;
145
+ if (drift) {
146
+ const notes = str(drift.notes);
147
+ out.drift = { changeFrequency: drift.changeFrequency, recheck: drift.recheck, ...(notes ? { notes } : {}) };
148
+ }
149
+ return out;
150
+ }
151
+ function rule(d, i) {
152
+ const cap = capOf(i.limits);
153
+ switch (d) {
154
+ case "single-path": {
155
+ const who = i.execution?.who ?? "unknown";
156
+ const rawKeys = i.reach?.keysInRuntime;
157
+ const keys = Array.isArray(rawKeys) ? rawKeys : undefined;
158
+ if (who === "agent-calls-rail" || i.custody?.where === "agent-runtime" || (keys && keys.length > 0)) {
159
+ let finding;
160
+ if (keys && keys.length > 0) {
161
+ const n = keys.length;
162
+ finding = `The runtime holds ${n} payment credential${n === 1 ? "" : "s"}, so the agent can reach the rail on its own.`;
163
+ }
164
+ else if (i.custody?.where === "agent-runtime") {
165
+ finding = "The credential lives in the agent's runtime, so the agent can reach the rail on its own whatever the executor does.";
166
+ }
167
+ else {
168
+ finding = "The agent calls the rail itself, so nothing stands between a poisoned instruction and the money.";
169
+ }
170
+ return { verdict: "Exposed", finding };
171
+ }
172
+ if (rawKeys === "unknown" && who === "intent-to-executor") {
173
+ return {
174
+ verdict: "Partial",
175
+ finding: "Execution is mediated, but whether the runtime holds a payment credential is not described.",
176
+ question: promptFor("reach"),
177
+ };
178
+ }
179
+ if (who === "intent-to-executor") {
180
+ const paths = i.reach?.paymentPaths ?? "unknown";
181
+ if (paths === 1)
182
+ return { verdict: "Closed", finding: "Every money path funnels through one enforcement point." };
183
+ return { verdict: "Partial", finding: paths === "unknown" ? "Execution is mediated, but the number of money paths is not described." : `Execution is mediated, but the runtime has ${paths} money paths.` };
184
+ }
185
+ return { verdict: "Unknown", finding: "Execution is not described.", question: promptFor("execution") };
186
+ }
187
+ case "custody": {
188
+ const w = i.custody?.where ?? "unknown";
189
+ if (w === "separate-service")
190
+ return { verdict: "Closed", finding: "The credential lives behind a boundary the agent cannot read." };
191
+ if (w === "agent-runtime")
192
+ return { verdict: "Exposed", finding: "The credential sits where a compromised agent can read it." };
193
+ return { verdict: "Unknown", finding: "Credential location is not described." };
194
+ }
195
+ case "mediated-execution": {
196
+ const who = i.execution?.who ?? "unknown";
197
+ if (who === "intent-to-executor")
198
+ return { verdict: "Closed", finding: "The agent submits an intent and something else executes." };
199
+ if (who === "agent-calls-rail")
200
+ return { verdict: "Exposed", finding: "The agent calls the rail itself, so policy is advice." };
201
+ return { verdict: "Unknown", finding: "Execution is not described." };
202
+ }
203
+ case "intent-binding": {
204
+ const m = i.binding?.mode ?? "unknown";
205
+ if (m === "bound-payee-and-amount")
206
+ return { verdict: "Closed", finding: "Each approved spend is bound to an exact payee and amount." };
207
+ if (m === "any-in-policy")
208
+ return { verdict: "Exposed", finding: "The agent chooses the who and the how-much within policy." };
209
+ return { verdict: "Unknown", finding: "Binding is not described." };
210
+ }
211
+ case "no-splitting": {
212
+ const l = i.limits;
213
+ if (!l)
214
+ return { verdict: "Unknown", finding: "Limits are not described." };
215
+ if (l.enforcedAt === "none")
216
+ return { verdict: "Exposed", finding: "There is no cap to split under, and nothing stops a burst." };
217
+ if (l.enforcedAt === "at-settlement")
218
+ return { verdict: "Exposed", finding: "Caps bite only at settlement, so many small spends can clear before any of them count." };
219
+ if (l.reservedAtGrant === false)
220
+ return { verdict: "Exposed", finding: "Caps bite only at settlement, so many small spends can clear before any of them count." };
221
+ if (l.reservedAtGrant === true && cap)
222
+ return { verdict: "Closed", finding: "Budget is reserved when a spend is approved, so small spends cannot slip under the cap." };
223
+ if (l.enforcedAt === "before-spend" && l.reservedAtGrant === "unknown")
224
+ return { verdict: "Partial", finding: "Caps are checked before the spend, but reservation at approval is not described." };
225
+ if (l.enforcedAt === "before-spend" && l.reservedAtGrant === true && !cap)
226
+ return { verdict: "Partial", finding: "Budget is reserved at approval, but no cap is described." };
227
+ return { verdict: "Unknown", finding: "Where caps are enforced is not described." };
228
+ }
229
+ case "human-approval": {
230
+ const a = i.approval;
231
+ const m = a?.mode ?? "unknown";
232
+ if (m === "out-of-band" && a?.threshold)
233
+ return { verdict: "Closed", finding: `Spends above ${formatMoney(a.threshold)} wait for a person the agent cannot impersonate.` };
234
+ if (m === "out-of-band")
235
+ return { verdict: "Partial", finding: "Approval is out of band, but the threshold is not described." };
236
+ if (m === "in-band")
237
+ return { verdict: "Exposed", finding: "The agent approves its own request." };
238
+ if (m === "none")
239
+ return { verdict: "Exposed", finding: "Large spends auto-execute." };
240
+ return { verdict: "Unknown", finding: "Approval is not described." };
241
+ }
242
+ case "provable-audit": {
243
+ const r = i.record;
244
+ if (!r || r.exists === "unknown")
245
+ return { verdict: "Unknown", finding: "The record is not described." };
246
+ if (r.exists === false)
247
+ return { verdict: "Exposed", finding: "There is no record of decisions." };
248
+ if (r.tamperEvident === false || r.settledAmountRecorded === false)
249
+ return { verdict: "Exposed", finding: r.tamperEvident === false ? "The record can be edited after the fact." : "The settled amount is not in the record." };
250
+ if (r.tamperEvident === true && r.settledAmountRecorded === true)
251
+ return { verdict: "Closed", finding: "Every decision and the settled amount sit in a tamper-evident record." };
252
+ return { verdict: "Partial", finding: "A record exists, but its tamper evidence or the settled amount is not described." };
253
+ }
254
+ case "continuous-verification": {
255
+ const c = i.drift?.recheck ?? "unknown";
256
+ if (c === "on-change")
257
+ return { verdict: "Closed", finding: "The money path is re-checked whenever the tool set changes." };
258
+ if (c === "at-deploy")
259
+ return { verdict: "Partial", finding: "The money path was verified once at deploy and the tool set can drift." };
260
+ if (c === "never")
261
+ return { verdict: "Exposed", finding: "The capability surface can drift with no re-check." };
262
+ return { verdict: "Unknown", finding: "Re-checking is not described." };
263
+ }
264
+ }
265
+ }
266
+ export function blastRadius(d, i) {
267
+ const cap = capOf(i.limits);
268
+ const capText = cap ? `up to ${formatMoney(cap)}` : "unbounded, up to the balance behind the credential";
269
+ const perActionText = i.limits?.perAction ? `${formatMoney(i.limits.perAction)} per spend` : "unbounded per spend";
270
+ const perDayText = i.limits?.perDay ? `${formatMoney(i.limits.perDay)} per day` : "unbounded per day";
271
+ switch (d) {
272
+ case "single-path":
273
+ case "custody":
274
+ case "mediated-execution":
275
+ return `One poisoned tool result pays any address, ${capText}, with nothing between the agent and the rail.`;
276
+ case "intent-binding":
277
+ return `A compromised agent hands you a perfectly in-policy request that is not what you meant, ${perActionText} and ${perDayText}.`;
278
+ case "no-splitting": {
279
+ if (i.limits?.perDay && i.limits?.perAction)
280
+ return `Many small spends slip under ${formatMoney(i.limits.perDay)} before any of them settle, so the real cap is ${formatMoney(i.limits.perAction)} times however many spends fit inside the settlement lag.`;
281
+ if (i.limits?.perDay)
282
+ return `Many small spends slip under ${formatMoney(i.limits.perDay)} before any of them settle, so the real cap is set by the settlement lag, not the number.`;
283
+ return "Many small spends slip through before any of them settle, unbounded.";
284
+ }
285
+ case "human-approval": {
286
+ if (i.approval?.mode === "in-band") {
287
+ return i.approval.threshold
288
+ ? `The agent approves its own request, so the threshold of ${formatMoney(i.approval.threshold)} is advice.`
289
+ : "The agent approves its own request and no threshold applies, so the amount is unbounded, up to the balance behind the credential.";
290
+ }
291
+ if (i.limits?.perDay)
292
+ return `Spends of any size execute without a person, ${formatMoney(i.limits.perDay)} per day at most.`;
293
+ if (i.limits?.perAction)
294
+ return `Spends of any size execute without a person, ${formatMoney(i.limits.perAction)} per spend at most and no daily cap.`;
295
+ return "Spends of any size execute without a person, unbounded.";
296
+ }
297
+ case "provable-audit":
298
+ return "After an incident you cannot prove what moved. The log can be edited and the settled amount is not in it.";
299
+ case "continuous-verification":
300
+ return "A new tool or dependency can reopen a money path with no one noticing until money moves.";
301
+ }
302
+ }
303
+ const FIX = {
304
+ "single-path": "Make one broker the only path to the rail and remove every other payment primitive from the runtime.",
305
+ "custody": "Move the credential behind a service or signer the agent cannot read.",
306
+ "mediated-execution": "Have the agent submit intents and let the broker execute behind the boundary.",
307
+ "intent-binding": "Mint single-use grants bound to an exact payee and amount.",
308
+ "no-splitting": "Reserve budget when a grant is minted, not when it settles.",
309
+ "human-approval": "Gate spends above a threshold on an out-of-band approval.",
310
+ "provable-audit": "Write every decision and the settled amount to a hash-chained receipt store.",
311
+ "continuous-verification": "Re-check the money path on every tool or dependency change and alarm on drift.",
312
+ };
313
+ const SEVERITY = ["single-path", "custody", "mediated-execution", "intent-binding", "human-approval", "no-splitting", "provable-audit", "continuous-verification"];
314
+ export const STEPS = [
315
+ { closes: ["custody", "mediated-execution", "single-path"], effort: 3, kind: "governance-layer", step: "A payment-governance layer sits between the agent and the rail. The credential moves behind it, the agent submits intents, and the layer is the only path that can pay. Purse enforcement mode is one such layer." },
316
+ { closes: ["intent-binding", "no-splitting"], effort: 2, kind: "governance-layer", step: "The layer binds every grant to a payee and an amount, enforces caps before the spend, and reserves budget the moment a grant is minted, so an in-policy request cannot be misdirected and parallel small spends cannot outrun the day's limit." },
317
+ { closes: ["human-approval"], effort: 1, kind: "governance-layer", step: "Spends above a threshold wait for a person's approval given out of band, on a channel the agent cannot reach." },
318
+ { closes: ["provable-audit"], effort: 1, kind: "governance-layer", step: "Every decision and the settled amount sit in a hash-chained receipt store that anyone can verify with plain SHA-256." },
319
+ { closes: ["continuous-verification"], effort: 2, kind: "practice", step: "The money path is re-checked whenever the agent gains a tool, MCP server, or dependency, with an alarm on drift. A watcher can do the checking, the practice stays with you." },
320
+ { closes: ["single-path"], effort: 1, kind: "hands-on", step: "The agent has exactly one payment path, with no key, SDK, or tool in its runtime that reaches a rail on its own." },
321
+ ];
322
+ /**
323
+ * Greedy set cover over STEPS for the given open dimensions.
324
+ * Each iteration recomputes, for every candidate not yet chosen, the ratio of
325
+ * still-open dimensions it would close to its effort; picks the highest ratio,
326
+ * ties toward the candidate that closes more still-open dimensions, then table
327
+ * order for determinism. A candidate is kept only if it closes at least one
328
+ * dimension still open. Stops when nothing is open. Since STEPS has six
329
+ * entries, the result has at most six steps.
330
+ */
331
+ export function coverSteps(open) {
332
+ const remaining = new Set(open);
333
+ const pool = STEPS.filter((s) => s.closes.some((d) => remaining.has(d)));
334
+ const chosen = [];
335
+ while (remaining.size > 0) {
336
+ const ranked = pool
337
+ .filter((s) => !chosen.includes(s))
338
+ .map((s) => ({ s, gain: s.closes.filter((d) => remaining.has(d)).length }))
339
+ .filter((x) => x.gain > 0)
340
+ .sort((a, b) => b.gain / b.s.effort - a.gain / a.s.effort || b.gain - a.gain);
341
+ if (ranked.length === 0)
342
+ break;
343
+ const best = ranked[0].s;
344
+ chosen.push(best);
345
+ for (const d of best.closes)
346
+ remaining.delete(d);
347
+ }
348
+ return chosen;
349
+ }
350
+ function openness(verdicts, dims, exposedIf, partialIf) {
351
+ const vs = dims.map((d) => verdicts[d]);
352
+ if (dims.some((d) => exposedIf(d)))
353
+ return "open";
354
+ if (vs.includes("Partial") || dims.some((d) => partialIf?.(d)))
355
+ return "partial";
356
+ if (vs.every((v) => v === "Closed"))
357
+ return "closed";
358
+ return "unknown";
359
+ }
360
+ function cap1(s) {
361
+ return s.length ? s.charAt(0).toUpperCase() + s.slice(1) : s;
362
+ }
363
+ export function score(rawIntake) {
364
+ const intake = normalize(rawIntake);
365
+ const ruled = Object.fromEntries(DIMENSIONS.map((d) => [d, rule(d, intake)]));
366
+ const verdicts = Object.fromEntries(DIMENSIONS.map((d) => [d, ruled[d].verdict]));
367
+ const exposure = DIMENSIONS.map((d) => {
368
+ const verdict = verdicts[d];
369
+ const question = ruled[d].question ?? (verdict === "Unknown" ? promptFor(DIMENSION_QUESTION_ID[d]) : undefined);
370
+ return { dimension: d, verdict, finding: ruled[d].finding, ...(question ? { question } : {}) };
371
+ });
372
+ const forgery = openness(verdicts, ["single-path", "custody", "mediated-execution"], (d) => verdicts[d] === "Exposed");
373
+ const misdirection = openness(verdicts, ["intent-binding", "human-approval"], (d) => (d === "intent-binding" && verdicts[d] === "Exposed") || (d === "human-approval" && verdicts[d] === "Exposed" && intake.approval?.mode === "in-band"), (d) => d === "human-approval" && verdicts[d] === "Exposed" && intake.approval?.mode === "none" && verdicts["intent-binding"] === "Closed");
374
+ const unknowns = DIMENSIONS.filter((d) => verdicts[d] === "Unknown").length;
375
+ const closed = (ds) => ds.every((d) => verdicts[d] === "Closed");
376
+ const noneUnknown5to8 = DIMENSIONS.slice(4).every((d) => verdicts[d] !== "Unknown");
377
+ const noDimensionClosed = !DIMENSIONS.some((d) => verdicts[d] === "Closed");
378
+ const noDimensionUnknown = !DIMENSIONS.some((d) => verdicts[d] === "Unknown");
379
+ let posture;
380
+ if (closed([...DIMENSIONS]))
381
+ posture = "enforcement-grade";
382
+ else if (closed(DIMENSIONS.slice(0, 4)) && noneUnknown5to8)
383
+ posture = `enforcement-grade except ${DIMENSIONS.slice(4).filter((d) => verdicts[d] !== "Closed").map((d) => DIMENSION_LABEL[d].toLowerCase()).join(", ")}`;
384
+ else if (intake.execution?.who === "agent-calls-rail" && capOf(intake.limits))
385
+ posture = `advisory with caps, forgery ${forgery} and misdirection ${misdirection}`;
386
+ else if (noDimensionClosed && noDimensionUnknown)
387
+ posture = "no controls";
388
+ else if (unknowns >= 4)
389
+ posture = "mostly unknown";
390
+ else
391
+ posture = `partial controls, forgery ${forgery} and misdirection ${misdirection}`;
392
+ const moneyPaths = [];
393
+ const rawKeys = intake.reach?.keysInRuntime;
394
+ if (Array.isArray(rawKeys)) {
395
+ for (const k of rawKeys)
396
+ moneyPaths.push({ path: `agent to rail via ${k}, unmediated`, mediated: false });
397
+ }
398
+ else if (rawKeys === "unknown") {
399
+ moneyPaths.push({ path: "unknown, runtime credentials not described", mediated: "unknown" });
400
+ }
401
+ const who = intake.execution?.who ?? "unknown";
402
+ if (who === "intent-to-executor")
403
+ moneyPaths.push({ path: "agent to executor to rail, mediated", mediated: true });
404
+ else if (who === "agent-calls-rail")
405
+ moneyPaths.push({ path: "agent to rail, unmediated", mediated: false });
406
+ else
407
+ moneyPaths.push({ path: "unknown, execution not described", mediated: "unknown" });
408
+ const topBreaches = SEVERITY.filter((d) => verdicts[d] === "Exposed").slice(0, 3).map((d) => ({ dimension: d, blastRadius: blastRadius(d, intake), fix: FIX[d] }));
409
+ const open = DIMENSIONS.filter((d) => verdicts[d] === "Exposed" || verdicts[d] === "Partial");
410
+ const chosen = coverSteps(open);
411
+ const shortestPath = chosen.map((s) => ({ step: s.step, kind: s.kind }));
412
+ // Framing why-sentence, built from the dimensions actually open.
413
+ const keysArr = Array.isArray(rawKeys) ? rawKeys : undefined;
414
+ const forgeryClauses = [];
415
+ if (keysArr && keysArr.length > 0)
416
+ forgeryClauses.push("the runtime holds a payment credential");
417
+ if (intake.custody?.where === "agent-runtime")
418
+ forgeryClauses.push("the credential lives in the agent's runtime");
419
+ if (intake.execution?.who === "agent-calls-rail")
420
+ forgeryClauses.push("the agent calls the rail itself");
421
+ const approvalNoneBound = intake.approval?.mode === "none" && verdicts["intent-binding"] === "Closed";
422
+ const misdirectionClauses = [];
423
+ if (intake.binding?.mode === "any-in-policy")
424
+ misdirectionClauses.push("the agent chooses the who and the how-much within policy");
425
+ if (intake.approval?.mode === "in-band")
426
+ misdirectionClauses.push("the agent approves its own requests");
427
+ if (approvalNoneBound)
428
+ misdirectionClauses.push("large spends execute against bound grants without a person");
429
+ const forgerySentence = forgeryClauses.length ? `${cap1(forgeryClauses.join(" and "))}, so a poisoned instruction can forge a spend.` : undefined;
430
+ const misdirectionSentence = misdirectionClauses.length ? `${cap1(misdirectionClauses.join(" and "))}, so the agent can misdirect a spend it is allowed to request.` : undefined;
431
+ let why;
432
+ if (forgerySentence && misdirectionSentence)
433
+ why = `${forgerySentence} ${misdirectionSentence}`;
434
+ else if (forgerySentence)
435
+ why = forgerySentence;
436
+ else if (misdirectionSentence)
437
+ why = misdirectionSentence;
438
+ else if (forgery === "unknown" || misdirection === "unknown")
439
+ why = "Not enough of the setup is described to say which is open.";
440
+ else if (forgery === "partial" || misdirection === "partial")
441
+ why = "Neither is fully open, but one side rests on something not yet closed.";
442
+ else
443
+ why = "Custody stops forgery and binding stops misdirection.";
444
+ const lastLine = shortestPath.length > 0
445
+ ? `Start with the first step. If you want this done for you, https://olurabian.com/work`
446
+ : `Nothing to close from what was described. If you want it verified hands-on, https://olurabian.com/work`;
447
+ const notes = [];
448
+ const noteOf = (dimension, text) => { if (text?.trim())
449
+ notes.push({ dimension, text: text.trim() }); };
450
+ noteOf("spend", intake.spend?.notes);
451
+ noteOf("single-path", intake.reach?.notes);
452
+ noteOf("custody", intake.custody?.notes);
453
+ noteOf("mediated-execution", intake.execution?.notes);
454
+ noteOf("intent-binding", intake.binding?.notes);
455
+ noteOf("no-splitting", intake.limits?.notes);
456
+ noteOf("human-approval", intake.approval?.notes);
457
+ noteOf("provable-audit", intake.record?.notes);
458
+ noteOf("continuous-verification", intake.drift?.notes);
459
+ return { posture, moneyPaths, exposure, topBreaches, open: { forgery, misdirection, why }, shortestPath, lastLine, notes };
460
+ }
@@ -0,0 +1,110 @@
1
+ export type Money = {
2
+ amount: number;
3
+ currency?: string;
4
+ };
5
+ export type Verdict = "Closed" | "Partial" | "Exposed" | "Unknown";
6
+ export type Dimension = "single-path" | "custody" | "mediated-execution" | "intent-binding" | "no-splitting" | "human-approval" | "provable-audit" | "continuous-verification";
7
+ export declare const DIMENSIONS: readonly Dimension[];
8
+ export declare const DIMENSION_LABEL: Record<Dimension, string>;
9
+ export type Unknownable<T> = T | "unknown";
10
+ export interface Intake {
11
+ spend?: {
12
+ what: string[] | "unknown";
13
+ frequency: "rare" | "daily" | "continuous" | "unknown";
14
+ typical?: Money;
15
+ notes?: string;
16
+ };
17
+ reach?: {
18
+ tools: string[] | "unknown";
19
+ mcpServers: string[] | "unknown";
20
+ keysInRuntime: string[] | "unknown";
21
+ paymentPaths: number | "unknown";
22
+ notes?: string;
23
+ };
24
+ custody?: {
25
+ where: "agent-runtime" | "separate-service" | "unknown";
26
+ notes?: string;
27
+ };
28
+ execution?: {
29
+ who: "agent-calls-rail" | "intent-to-executor" | "unknown";
30
+ notes?: string;
31
+ };
32
+ binding?: {
33
+ mode: "bound-payee-and-amount" | "any-in-policy" | "unknown";
34
+ notes?: string;
35
+ };
36
+ limits?: {
37
+ perAction?: Money;
38
+ perDay?: Money;
39
+ enforcedAt: "before-spend" | "at-settlement" | "none" | "unknown";
40
+ reservedAtGrant: boolean | "unknown";
41
+ notes?: string;
42
+ };
43
+ approval?: {
44
+ mode: "none" | "in-band" | "out-of-band" | "unknown";
45
+ threshold?: Money;
46
+ notes?: string;
47
+ };
48
+ record?: {
49
+ exists: boolean | "unknown";
50
+ tamperEvident: boolean | "unknown";
51
+ settledAmountRecorded: boolean | "unknown";
52
+ notes?: string;
53
+ };
54
+ drift?: {
55
+ changeFrequency: "rare" | "weekly" | "daily" | "unknown";
56
+ recheck: "on-change" | "at-deploy" | "never" | "unknown";
57
+ notes?: string;
58
+ };
59
+ }
60
+ export type FieldKind = "choice" | "money" | "list" | "boolean" | "number" | "text";
61
+ export interface Field {
62
+ key: string;
63
+ label: string;
64
+ kind: FieldKind;
65
+ choices?: {
66
+ value: string;
67
+ label: string;
68
+ }[];
69
+ optional?: boolean;
70
+ }
71
+ export interface Question {
72
+ id: keyof Intake;
73
+ title: string;
74
+ prompt: string;
75
+ fields: Field[];
76
+ }
77
+ export type Openness = "open" | "partial" | "closed" | "unknown";
78
+ export type StepKind = "governance-layer" | "hosted-control-plane" | "hands-on" | "practice";
79
+ export interface Readout {
80
+ posture: string;
81
+ moneyPaths: {
82
+ path: string;
83
+ mediated: boolean | "unknown";
84
+ }[];
85
+ exposure: {
86
+ dimension: Dimension;
87
+ verdict: Verdict;
88
+ finding: string;
89
+ question?: string;
90
+ }[];
91
+ topBreaches: {
92
+ dimension: Dimension;
93
+ blastRadius: string;
94
+ fix: string;
95
+ }[];
96
+ open: {
97
+ forgery: Openness;
98
+ misdirection: Openness;
99
+ why: string;
100
+ };
101
+ shortestPath: {
102
+ step: string;
103
+ kind: StepKind;
104
+ }[];
105
+ lastLine: string;
106
+ notes: {
107
+ dimension: Dimension | "spend";
108
+ text: string;
109
+ }[];
110
+ }
package/dist/types.js ADDED
@@ -0,0 +1,14 @@
1
+ export const DIMENSIONS = [
2
+ "single-path", "custody", "mediated-execution", "intent-binding",
3
+ "no-splitting", "human-approval", "provable-audit", "continuous-verification",
4
+ ];
5
+ export const DIMENSION_LABEL = {
6
+ "single-path": "Single path",
7
+ "custody": "Custody",
8
+ "mediated-execution": "Mediated execution",
9
+ "intent-binding": "Intent-binding",
10
+ "no-splitting": "No splitting",
11
+ "human-approval": "Human approval",
12
+ "provable-audit": "Provable audit",
13
+ "continuous-verification": "Continuous verification",
14
+ };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@olurabian/audit",
3
+ "version": "0.1.0",
4
+ "description": "The Agent Payment Security Audit as a runnable. Can a compromised agent move money outside policy? Eight dimensions, honest Unknowns, blast radius in your own numbers.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "audit": "./dist/cli.js"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "prompts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
32
+ "test": "tsx --test test/*.test.ts",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "keywords": [
36
+ "ai-agents",
37
+ "payments",
38
+ "security",
39
+ "audit",
40
+ "agent-security",
41
+ "deadlatch",
42
+ "purse"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/ArabianAnalyst/audit.git"
47
+ },
48
+ "homepage": "https://github.com/ArabianAnalyst/audit#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/ArabianAnalyst/audit/issues"
51
+ },
52
+ "license": "MIT",
53
+ "devDependencies": {
54
+ "@lavamoat/allow-scripts": "^5.1.0",
55
+ "@types/node": "^22.10.0",
56
+ "tsx": "^4.19.0",
57
+ "typescript": "^5.7.0"
58
+ },
59
+ "lavamoat": {
60
+ "allowScripts": {
61
+ "tsx>esbuild#0.28.2": true
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,86 @@
1
+ # Agent Payment Security Audit
2
+
3
+ A self-serve prompt that answers one question about your AI agent.
4
+
5
+ > Can a compromised agent move money outside policy?
6
+
7
+ Paste your setup into a strong model with the prompt below, and it returns a short, honest readout scored against the same deployment contract Purse is built on. Where you leave something out, it marks that dimension Unknown and asks the exact question, rather than guessing. It fails honest, the way Purse fails closed.
8
+
9
+ This is a diagnostic, not a sales tool. It names where you are exposed and the shortest path to close it, whether or not that path is Purse.
10
+
11
+ ## How to use it
12
+
13
+ 1. Answer the eight intake questions about your setup.
14
+ 2. Paste the prompt below into a strong model, with your filled intake at the bottom.
15
+ 3. Read the readout. Anything you did not answer comes back as Unknown with the question to resolve it.
16
+
17
+ ## Intake
18
+
19
+ Eight questions, one per dimension. Answer them plainly.
20
+
21
+ 1. **What does the agent buy, and how often?** API credits, compute, data, vendors, on-chain.
22
+ 2. **What can the agent's runtime reach?** List every tool, MCP server, SDK, and key in the agent's process.
23
+ 3. **Where does the payment credential live?** In the agent's process or prompt or memory, or behind a separate service or signer.
24
+ 4. **Who executes the payment?** The agent calls the rail itself, or it submits an intent to something that executes.
25
+ 5. **What limits exist and where are they enforced?** Per-action, daily, per-vendor. Checked before the spend or only at settlement.
26
+ 6. **Is there human approval for large spends?** None, in-band (the agent decides), or out of band (a person approves the exact spend).
27
+ 7. **Is there a record of every decision and the amount actually settled, and can it be tampered with?**
28
+ 8. **How often does the agent's tool or dependency set change, and is the money-path re-checked when it does?**
29
+
30
+ ## The prompt
31
+
32
+ ```
33
+ You are the Agent-Payment-Security Auditor. You diagnose whether a team's AI agent could move money outside policy, and you produce a short, honest readout the team keeps whether or not they buy anything. You are built on one question.
34
+
35
+ Can a compromised agent move money outside policy?
36
+
37
+ A compromised agent means one hit by prompt injection, a poisoned tool result, or a jailbroken instruction. The goal is not to keep the agent honest. It is to show whether a dishonest agent can still be stopped.
38
+
39
+ ## Method
40
+
41
+ Score the setup on eight dimensions. Each is Closed, Partial, Exposed, or Unknown.
42
+
43
+ 1. Single path. Is every money path funneled through one enforcement point? Exposed if the agent's runtime holds a rail key, a second payment tool, or a direct payment primitive.
44
+ 2. Custody. Where does the credential live? Exposed if it sits in the agent's process, prompt, memory, or tools.
45
+ 3. Mediated execution. Does the agent execute the payment, or submit an intent to something that executes behind a boundary? Exposed if the agent calls the rail itself and policy is only advice.
46
+ 4. Intent-binding. Are spends bound to a specific approved payee and amount, or can the agent supply any in-policy value? Exposed if the agent chooses the who and the how-much within policy.
47
+ 5. No splitting. Are velocity caps enforced when a spend is reserved, or only when it settles? Exposed if many small spends can slip under a cap before any of them settle.
48
+ 6. Human approval. Are spends over a threshold gated out of band? Exposed if large spends auto-execute, or if the agent approves its own request.
49
+ 7. Provable audit. Is there a tamper-evident record of every decision and the amount actually settled? Exposed if logs can be edited, or the real settled amount is not recorded.
50
+ 8. Continuous verification. Is the single-path property re-checked when the agent gains a tool or dependency, or verified once at deploy? Exposed if the capability surface can drift silently.
51
+
52
+ Mark a dimension Unknown when the intake does not answer it. Do not guess. List the exact question to ask.
53
+
54
+ ## Two framings for the readout
55
+
56
+ Forgery vs misdirection. Custody (1 to 3) stops the agent forging a payment it was never handed the means to make. Intent-binding (4) stops it misdirecting one it is allowed to request. A setup can close forgery and still leak through misdirection, a compromised agent handing you a perfectly in-policy request that is not what you meant. This is the confused deputy at the payments layer. State clearly which of the two is open.
57
+
58
+ Blast radius, not a checklist. For every Exposed dimension, state the concrete loss in money, using their own numbers where the intake gives them. Not "no allowlist" but "one poisoned tool result pays any address, up to your daily cap of the amount you set, with no record you can prove." Specific beats a red mark.
59
+
60
+ ## Output
61
+
62
+ Produce the readout in this structure and nothing else.
63
+
64
+ 1. Posture. One honest line naming what they have. For example, advisory with caps, forgery open and misdirection open. Or, enforcement-grade except continuous verification.
65
+ 2. Money-path map. Every way money can currently leave, one per line, each marked mediated or unmediated.
66
+ 3. Exposure. The eight dimensions, each with its verdict and a one-line finding. Keep Unknown items in, with the question to ask.
67
+ 4. Top breaches. The one to three that matter most, each with the blast radius in money and the fix in one line.
68
+ 5. Which is open. Forgery, misdirection, or both, in one line, with why.
69
+ 6. Shortest path. Two to four concrete steps to close the top breaches. Order them by blast radius closed per unit of effort. Where a step is a payment-governance layer, a hosted control plane, or a hands-on implementation, say so plainly without pitching.
70
+
71
+ ## Voice and rules
72
+
73
+ - Write from the reader's side. Plain, precise, calm. Premium developer-tool register, never a sales pitch.
74
+ - No colons in prose. No em dashes. Use a period or a comma. Short sentences.
75
+ - Never invent a fact about their system. If you did not receive it, it is Unknown.
76
+ - Do not overstate the fix. If misdirection is not fully solved, say so. Honesty is the whole point.
77
+ - No hard sell anywhere. The diagnosis is the sell. The last line is a plain next step, not a close.
78
+
79
+ ## Intake
80
+
81
+ Paste your filled intake below.
82
+ ```
83
+
84
+ ## What the dimensions map to
85
+
86
+ Each dimension scores against the deployment contract in the main [README](https://github.com/ArabianAnalyst/purse#threat-model). Enforcement mode closes custody, mediated execution, intent-binding, no-splitting, human approval, and provable audit. Continuous verification is a property you maintain, not a state you reach, so it stays on you and your deployment.