@medusasec/sensitive-spans 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,173 @@
1
+ // Receipts — a per-endpoint, hash-chained, signed record of agent activity.
2
+ //
3
+ // Every receipt carries the previous receipt's hash and a strictly increasing
4
+ // sequence number, and is signed with an ECDSA P-256 key that never leaves
5
+ // the endpoint (non-extractable, generated in the service worker). The
6
+ // dashboard verifies the chain with the public key the endpoint publishes,
7
+ // so a gap, a reorder, or an edited row is detectable after the fact.
8
+ //
9
+ // Receipts never contain prompt text or file contents — only actor, action,
10
+ // host, verdict/outcome and category counts.
11
+ //
12
+ // Pure/portable pieces (canonicalize, hashing, chain verification) take the
13
+ // crypto primitives as arguments so the same code runs in the service worker,
14
+ // in node tests, and (ported) in the dashboard.
15
+
16
+ export const RECEIPT_VERSION = 1;
17
+ export const SIG_ALG = "ES256"; // ECDSA P-256 / SHA-256
18
+
19
+ /** Deterministic JSON: sorted keys, no undefined, no whitespace. */
20
+ export function canonicalize(v) {
21
+ if (v === undefined) return "null";
22
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
23
+ if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]";
24
+ const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
25
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(v[k])).join(",") + "}";
26
+ }
27
+
28
+ export function bytesToHex(buf) {
29
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ export function b64url(buf) {
33
+ const bytes = new Uint8Array(buf);
34
+ let s = "";
35
+ for (const b of bytes) s += String.fromCharCode(b);
36
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
+ }
38
+
39
+ export function b64urlDecode(s) {
40
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
41
+ const bin = atob(String(s).replace(/-/g, "+").replace(/_/g, "/") + pad);
42
+ const out = new Uint8Array(bin.length);
43
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
44
+ return out;
45
+ }
46
+
47
+ export async function sha256Hex(str, subtle = globalThis.crypto?.subtle) {
48
+ const data = new TextEncoder().encode(str);
49
+ return bytesToHex(await subtle.digest("SHA-256", data));
50
+ }
51
+
52
+ /** Strip the fields that are computed over the body. */
53
+ export function receiptBody(r) {
54
+ const { hash, sig, ...body } = r || {};
55
+ void hash; void sig;
56
+ return body;
57
+ }
58
+
59
+ export async function hashReceiptBody(body, subtle) {
60
+ return sha256Hex(canonicalize(body), subtle);
61
+ }
62
+
63
+ /** Key id = first 16 hex of sha256(canonical public JWK). */
64
+ export async function keyIdFromJwk(jwk, subtle) {
65
+ const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
66
+ return (await sha256Hex(canonicalize(pub), subtle)).slice(0, 16);
67
+ }
68
+
69
+ export async function generateKeypair(subtle = globalThis.crypto?.subtle) {
70
+ return subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
71
+ }
72
+
73
+ export function makeSigner(privateKey, subtle = globalThis.crypto?.subtle) {
74
+ return async (hashHex) => {
75
+ const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(hashHex));
76
+ return b64url(sig);
77
+ };
78
+ }
79
+
80
+ export async function makeVerifier(pubJwk, subtle = globalThis.crypto?.subtle) {
81
+ const key = await subtle.importKey("jwk", { kty: pubJwk.kty, crv: pubJwk.crv, x: pubJwk.x, y: pubJwk.y }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
82
+ return async (hashHex, sigB64url) => {
83
+ try {
84
+ return await subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, b64urlDecode(sigB64url), new TextEncoder().encode(hashHex));
85
+ } catch {
86
+ return false;
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Build the next receipt in a chain.
93
+ * @param {object} entry {endpoint, session, actor, action, host, verdict, outcome, categories, detail}
94
+ * @param {object} chain {seq, prev} — state before this receipt
95
+ * @param {object} keys {sign(hashHex)→b64url, kid}
96
+ */
97
+ export async function makeReceipt(entry, chain, keys, subtle = globalThis.crypto?.subtle, now = () => new Date().toISOString()) {
98
+ const body = {
99
+ v: RECEIPT_VERSION,
100
+ seq: (Number(chain?.seq) || 0) + 1,
101
+ prev: chain?.prev || null,
102
+ kid: keys.kid,
103
+ ts: now(),
104
+ ...sanitizeEntry(entry),
105
+ };
106
+ const hash = await hashReceiptBody(body, subtle);
107
+ const sig = await keys.sign(hash);
108
+ return { ...body, hash, sig };
109
+ }
110
+
111
+ // Whitelist the entry fields so a caller can never smuggle prompt text in.
112
+ export function sanitizeEntry(e) {
113
+ const out = {};
114
+ const s = (v, n = 200) => (v == null ? null : String(v).slice(0, n));
115
+ out.endpoint = s(e?.endpoint, 80);
116
+ out.session = s(e?.session, 64);
117
+ out.action = s(e?.action, 40);
118
+ out.host = s(e?.host, 253);
119
+ if (e?.verdict != null) out.verdict = s(e.verdict, 20);
120
+ if (e?.outcome != null) out.outcome = s(e.outcome, 20);
121
+ if (e?.categories && typeof e.categories === "object") {
122
+ const c = {};
123
+ for (const [k, v] of Object.entries(e.categories)) {
124
+ const n = Number(v);
125
+ if (Number.isFinite(n) && n > 0) c[String(k).toLowerCase().slice(0, 32)] = n;
126
+ }
127
+ out.categories = c;
128
+ }
129
+ if (e?.actor && typeof e.actor === "object") {
130
+ out.actor = {
131
+ kind: s(e.actor.kind, 20),
132
+ agent: e.actor.agent ? { id: s(e.actor.agent.id, 64), key: s(e.actor.agent.key, 64), label: s(e.actor.agent.label, 80) } : null,
133
+ confidence: s(e.actor.confidence, 10),
134
+ evidence: Array.isArray(e.actor.evidence) ? e.actor.evidence.slice(0, 8).map((x) => s(x, 40)) : [],
135
+ };
136
+ }
137
+ if (e?.detail && typeof e.detail === "object") {
138
+ const d = {};
139
+ for (const [k, v] of Object.entries(e.detail).slice(0, 12)) {
140
+ if (typeof v === "number" || typeof v === "boolean") d[s(k, 32)] = v;
141
+ else if (typeof v === "string") d[s(k, 32)] = v.slice(0, 120);
142
+ }
143
+ out.detail = d;
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Verify a chain: recomputed hashes, prev links, monotonic seq, signatures.
150
+ * `verify(hashHex, sig)` → boolean. Returns {ok, checked, errors:[{seq,reason}]}.
151
+ */
152
+ export async function verifyReceipts(receipts, verify, subtle = globalThis.crypto?.subtle) {
153
+ const list = [...(receipts || [])].sort((a, b) => (a.seq || 0) - (b.seq || 0));
154
+ const errors = [];
155
+ let prev = null;
156
+ for (const r of list) {
157
+ const body = receiptBody(r);
158
+ const hash = await hashReceiptBody(body, subtle);
159
+ if (hash !== r.hash) errors.push({ seq: r.seq, reason: "hash_mismatch" });
160
+ if (prev) {
161
+ if (r.prev !== prev.hash) errors.push({ seq: r.seq, reason: "broken_link" });
162
+ if (r.seq !== prev.seq + 1) errors.push({ seq: r.seq, reason: r.seq === prev.seq ? "duplicate_seq" : "gap" });
163
+ }
164
+ if (verify) {
165
+ // Verify over the RECOMPUTED hash: a tampered body with its original
166
+ // hash/sig pair must fail here too, not only on the hash comparison.
167
+ const ok = await verify(hash, r.sig);
168
+ if (!ok) errors.push({ seq: r.seq, reason: "bad_signature" });
169
+ }
170
+ prev = r;
171
+ }
172
+ return { ok: errors.length === 0, checked: list.length, errors };
173
+ }
@@ -0,0 +1,64 @@
1
+ // WebMCP inventory — what tools do pages expose to agents, and which get called?
2
+ //
3
+ // Chrome's WebMCP origin trial lets a page register typed tools for agents via
4
+ // navigator.modelContext / document.modelContext .registerTool(). The
5
+ // MAIN-world probe in netguard.js wraps that call and reports registrations
6
+ // and executions to the service worker. This module holds the pure parts:
7
+ // normalizing tool records, deduping per host, and shaping the telemetry.
8
+
9
+ export function normalizeTool(t) {
10
+ if (!t || typeof t !== "object") return null;
11
+ const name = String(t.name || "").slice(0, 80);
12
+ if (!name) return null;
13
+ return {
14
+ name,
15
+ description: String(t.description || "").slice(0, 200),
16
+ params: Array.isArray(t.params) ? t.params.map((p) => String(p).slice(0, 40)).slice(0, 20) : [],
17
+ readOnly: t.readOnly === true,
18
+ };
19
+ }
20
+
21
+ /** Merge a registration into a per-host list (by name; newest description wins). */
22
+ export function mergeTools(existing, incoming) {
23
+ const out = new Map((existing || []).map((t) => [t.name, t]));
24
+ for (const raw of incoming || []) {
25
+ const t = normalizeTool(raw);
26
+ if (t) out.set(t.name, t);
27
+ }
28
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
29
+ }
30
+
31
+ /** Stable fingerprint of a tool set, so unchanged inventories are not re-sent. */
32
+ export function toolsFingerprint(tools) {
33
+ return (tools || []).map((t) => `${t.name}|${t.params.join(",")}|${t.readOnly ? "r" : "w"}`).join(";");
34
+ }
35
+
36
+ export function toolsEvent(host, tools) {
37
+ return {
38
+ message_type: "webmcp_tools",
39
+ rule_name: "webmcp_tools",
40
+ verdict: "allow",
41
+ tool_name: host || null,
42
+ reason: `${tools.length} WebMCP tool${tools.length === 1 ? "" : "s"} registered`,
43
+ metadata: { client: "browser-extension", host: host || "", tools, count: tools.length },
44
+ };
45
+ }
46
+
47
+ export function toolCallEvent({ host, name, actor, gate, blocked, receipt, session_id }) {
48
+ return {
49
+ message_type: "webmcp_call",
50
+ rule_name: blocked ? "agent_gate_tool_call" : "webmcp_call",
51
+ verdict: blocked ? "block" : "allow",
52
+ tool_name: host || null,
53
+ reason: blocked ? `agent tool call "${name}" denied by policy` : `agent tool call "${name}"`,
54
+ metadata: {
55
+ client: "browser-extension",
56
+ host: host || "",
57
+ tool: String(name || "").slice(0, 80),
58
+ ...(actor ? { actor } : {}),
59
+ ...(gate ? { agent_gate: { action: gate.action, kind: "tool_call" } } : {}),
60
+ ...(receipt ? { receipt } : {}),
61
+ ...(session_id ? { session_id } : {}),
62
+ },
63
+ };
64
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@medusasec/sensitive-spans",
3
+ "version": "0.1.0",
4
+ "description": "Dependency-free browser-side primitives from the Medusa extension: sensitive-span detection with measured precision guards, actor attribution (human vs. agent), actor-aware policy, pseudonymization, and signed receipt chains.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "main": "./dist/index.js",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "node build.mjs",
17
+ "test": "node build.mjs && node --test test.mjs",
18
+ "prepublishOnly": "node build.mjs && node --test test.mjs"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/joshmaster2165/medusa-agent.git",
23
+ "directory": "packages/sensitive-spans"
24
+ },
25
+ "keywords": [
26
+ "dlp",
27
+ "pii",
28
+ "secrets",
29
+ "prompt-injection",
30
+ "ai-agents",
31
+ "browser-agents",
32
+ "webmcp",
33
+ "receipts",
34
+ "attribution"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "homepage": "https://github.com/joshmaster2165/medusa-agent/tree/main/packages/sensitive-spans",
40
+ "bugs": {
41
+ "url": "https://github.com/joshmaster2165/medusa-agent/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ }
46
+ }