@chatpanel/bridge 0.10.41 → 0.11.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,183 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/event.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // The event envelope — durable facts only.
10
+ //
11
+ // ORDER WITHOUT CLOCKS. `at` is wall time and is ADVISORY: it is shown to humans and
12
+ // never consulted for ordering. `(host, seq)` plus `causes` are the authority, because
13
+ // once two hosts append concurrently a timestamp is not an order (see order.js).
14
+ //
15
+ // EPHEMERAL STREAMS ARE NOT THIS TYPE. Market ticks, live captions and DOM mutations
16
+ // live in an in-memory ring buffer with no id, no seq and no persistence; only the
17
+ // windowed aggregate that entered a model request or crossed the device boundary is
18
+ // promoted to an Event. That is structural rather than a rule to remember: durably
19
+ // logging a caption stream is ~3 MB per meeting, ~5.5 GB/year, and there is no Event
20
+ // type that would accept one.
21
+ //
22
+ // METADATA ONLY. Payloads carry Refs and counts, never content. `privacy.redacted`
23
+ // carries how many of each entity type were redacted and never the values — a log of
24
+ // what was redacted must not itself contain the redacted data.
25
+
26
+ import { isRef } from './ref.js';
27
+
28
+ export const CURRENT_VERSION = 1;
29
+
30
+ /** The seven families of v1. Adding is cheap; removing is not. */
31
+ export const EVENT_TYPES = Object.freeze({
32
+ turn: ['started', 'ended'],
33
+ // What the model was SHOWN and what it SAID. Deliberately arriving as refs, not text —
34
+ // see the note on the validators below.
35
+ assistant: ['prompted', 'message', 'reasoning'],
36
+ context: ['assembled', 'attached', 'expanded'],
37
+ capability: ['offered', 'granted', 'denied', 'activated', 'revoked', 'invoked', 'resulted'],
38
+ privacy: ['redacted', 'egress'],
39
+ policy: ['changed', 'guard_denied'],
40
+ data: ['created', 'updated', 'deleted'],
41
+ automation: ['fired', 'suppressed'],
42
+ });
43
+
44
+ export const ALL_TYPES = Object.freeze(
45
+ Object.entries(EVENT_TYPES).flatMap(([fam, kinds]) => kinds.map((k) => `${fam}.${k}`)),
46
+ );
47
+
48
+ // 'channel' is a message arriving from a paired external surface — Telegram, WhatsApp — that
49
+ // drives a turn the same way a person pressing send does. It is turn-independent for the same
50
+ // reason 'schedule' and 'agent' are: nobody is sitting in the panel when it fires, so consent
51
+ // and reach have to be settled at pairing time, not at the keystroke. The actor.id carries the
52
+ // surface and the sender, e.g. 'telegram:8412…'. See chatpanel-channels for the invoker.
53
+ export const ACTOR_KINDS = Object.freeze(['user', 'rule', 'schedule', 'model', 'agent', 'channel']);
54
+ export const SCOPE_KINDS = Object.freeze(['global', 'site', 'tab', 'session', 'agent']);
55
+ export const CLASSES = Object.freeze(['R', 'M', 'L', 'C', 'A', 'X', 'H']);
56
+ export const EFFECTS = Object.freeze(['pure', 'idempotent', 'replay-safe', 'non-replayable']);
57
+ export const EGRESS = Object.freeze(['none', 'redacted', 'delegated']);
58
+
59
+ export class EventError extends Error {
60
+ constructor(code, message, detail = null) {
61
+ super(message);
62
+ this.name = 'EventError';
63
+ this.code = code;
64
+ this.detail = detail;
65
+ }
66
+ }
67
+
68
+ const str = (v) => typeof v === 'string' && v.length > 0;
69
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
70
+ const arr = (v) => Array.isArray(v);
71
+
72
+ function actorOk(a) { return !!a && ACTOR_KINDS.includes(a.kind) && str(a.id); }
73
+ function scopeOk(s) { return !!s && SCOPE_KINDS.includes(s.kind) && str(s.id); }
74
+
75
+ // Per-type payload requirements. Deliberately light — enough that a malformed event
76
+ // cannot enter an append-only log, not a full schema language.
77
+ const PAYLOAD = {
78
+ 'turn.started': (p) => str(p.turnId),
79
+ 'turn.ended': (p) => str(p.turnId),
80
+
81
+ // ── assistant ─────────────────────────────────────────────────────────────
82
+ // "Model-visible means logged" was true of the toolset and false of the conversation:
83
+ // the log could say a turn happened and what it cost, but never what was asked or
84
+ // answered. That is the half a trajectory view needs, and the half replay cannot be
85
+ // checked without.
86
+ //
87
+ // CONTENT IS NEVER IN THE EVENT. Each of these carries a Ref — a content hash — and the
88
+ // bytes live in the blob store. Three reasons, in order of how much they matter:
89
+ // 1. The log stays metadata, so exporting or replicating it does not export the user's
90
+ // conversations by accident. That property is the whole reason an event log is safe
91
+ // to keep at all.
92
+ // 2. Deletion stays honest: an append-only log cannot unsay a message, but a blob can
93
+ // be crypto-shredded and the ref then resolves to verified-but-unavailable. Inlined
94
+ // text would make "delete my data" a lie the schema enforces forever.
95
+ // 3. Repeated content (the same system prompt on every turn) is stored once.
96
+ 'assistant.prompted': (p) => str(p.turnId) && isRef(p.ref),
97
+ 'assistant.message': (p) => str(p.turnId) && isRef(p.ref),
98
+ 'assistant.reasoning': (p) => str(p.turnId) && isRef(p.ref),
99
+
100
+ 'context.assembled': (p) => num(p.budget) && num(p.used) && !!p.parts && typeof p.parts === 'object'
101
+ && arr(p.resident) && p.resident.every(isRef) && num(p.reachableCount),
102
+ 'context.attached': (p) => isRef(p.ref),
103
+ 'context.expanded': (p) => isRef(p.ref) && num(p.tokens),
104
+
105
+ 'capability.offered': (p) => str(p.capability) && str(p.reason),
106
+ 'capability.granted': (p) => str(p.capability) && actorOk(p.actor),
107
+ 'capability.denied': (p) => str(p.capability) && actorOk(p.actor),
108
+ 'capability.activated': (p) => str(p.capability) && CLASSES.includes(p.classUsed),
109
+ 'capability.revoked': (p) => str(p.capability) && str(p.cause),
110
+ // I3 is enforced here structurally: a non-pure invocation without a key is not a
111
+ // valid event, so it cannot reach the log at all.
112
+ 'capability.invoked': (p) => str(p.capability) && actorOk(p.actor) && scopeOk(p.scope)
113
+ && EFFECTS.includes(p.effects) && (p.effects === 'pure' || str(p.idempotencyKey)),
114
+ 'capability.resulted': (p) => str(p.capability) && typeof p.ok === 'boolean'
115
+ && CLASSES.includes(p.classUsed) && !!p.cost && num(p.cost.ms),
116
+
117
+ // counts only — never values
118
+ 'privacy.redacted': (p) => !!p.counts && typeof p.counts === 'object'
119
+ && Object.values(p.counts).every(num),
120
+ 'privacy.egress': (p) => str(p.host) && typeof p.redacted === 'boolean'
121
+ && typeof p.controlled === 'boolean',
122
+
123
+ 'policy.changed': (p) => str(p.dial) && actorOk(p.actor) && 'from' in p && 'to' in p,
124
+ 'policy.guard_denied': (p) => str(p.capability) && str(p.reason),
125
+
126
+ 'data.created': (p) => isRef(p.ref),
127
+ 'data.updated': (p) => isRef(p.ref),
128
+ 'data.deleted': (p) => isRef(p.ref) && typeof p.shredded === 'boolean',
129
+
130
+ 'automation.fired': (p) => str(p.ruleId) && CLASSES.includes(p.classUsed),
131
+ 'automation.suppressed': (p) => str(p.ruleId) && str(p.reason),
132
+ };
133
+
134
+ /** Validate a durable event. Throws EventError; never mutates. */
135
+ export function validateEvent(e) {
136
+ if (!e || typeof e !== 'object') throw new EventError('SHAPE', 'event must be an object');
137
+ if (e.v !== CURRENT_VERSION) throw new EventError('VERSION', `expected v=${CURRENT_VERSION}, got ${e.v} — upcast first`, e.v);
138
+ if (!str(e.id)) throw new EventError('SHAPE', 'id required');
139
+ if (!str(e.host)) throw new EventError('SHAPE', 'host required');
140
+ if (!Number.isInteger(e.seq) || e.seq < 0) throw new EventError('SHAPE', 'seq must be a non-negative integer');
141
+ if (!arr(e.causes) || !e.causes.every(str)) throw new EventError('SHAPE', 'causes must be string[]');
142
+ if (!num(e.at)) throw new EventError('SHAPE', 'at required (advisory wall clock)');
143
+ if (!ALL_TYPES.includes(e.type)) throw new EventError('TYPE', `unknown type ${e.type}`, e.type);
144
+ if (!e.payload || typeof e.payload !== 'object') throw new EventError('SHAPE', 'payload required');
145
+ const check = PAYLOAD[e.type];
146
+ if (check && !check(e.payload)) throw new EventError('PAYLOAD', `payload invalid for ${e.type}`, e.type);
147
+ return e;
148
+ }
149
+
150
+ export function isValidEvent(e) {
151
+ try { validateEvent(e); return true; } catch { return false; }
152
+ }
153
+
154
+ /**
155
+ * A per-host append cursor. Owns `seq` — the only monotonic thing in the system — so
156
+ * callers cannot skip or reuse one.
157
+ *
158
+ * `now` and `newId` are injected so tests are deterministic and so the package holds no
159
+ * ambient dependency on a clock or a crypto implementation.
160
+ */
161
+ export function createAppender({ host, seq = 0, now = () => Date.now(), newId }) {
162
+ if (!str(host)) throw new EventError('SHAPE', 'host required');
163
+ const genId = newId || (() => globalThis.crypto.randomUUID());
164
+ let n = seq;
165
+ return {
166
+ get host() { return host; },
167
+ get seq() { return n; },
168
+ /** Build + validate the next event for this host. Does not persist — the caller stores it. */
169
+ append(type, payload, causes = []) {
170
+ const e = Object.freeze({
171
+ v: CURRENT_VERSION,
172
+ id: genId(),
173
+ host,
174
+ seq: n++,
175
+ causes: Object.freeze([...causes]),
176
+ at: now(),
177
+ type,
178
+ payload,
179
+ });
180
+ return validateEvent(e);
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,31 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/reach.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // reach.js — how far a request may travel, on its own so it can travel alone.
10
+ //
11
+ // One ordered vocabulary answers "how far may this go" for three very different callers: the
12
+ // model router ranks a model's reach against a turn's requirement, pairing gives a phone a
13
+ // ceiling, and the bridge maps that ceiling to a toolset. Three declarations, one vocabulary,
14
+ // or "how far may this reach" gets three answers that drift.
15
+ //
16
+ // A separate module rather than a constant inside router.js for the reason scopes.js exists:
17
+ // the consumers have wildly different weights. The bridge has zero runtime dependencies by
18
+ // design and vendors what it needs — pulling the 50 KB model router in to reach a
19
+ // three-element array is exactly the transitive-graph mistake that split DATA_SCOPES out of
20
+ // capability.js.
21
+ //
22
+ // ORDERED, least to most: a ceiling comparison is an index comparison.
23
+ export const REACH = Object.freeze(['device', 'trusted', 'any']);
24
+
25
+ /** Position in the ladder; an unknown tier ranks LOWEST, so a typo never widens reach. */
26
+ export const reachRank = (r) => Math.max(0, REACH.indexOf(r));
27
+
28
+ /** Does `have` satisfy `need`? Fails closed — an unknown `have` is treated as 'device'. */
29
+ export function reachSatisfies(have, need) {
30
+ return reachRank(have) >= reachRank(need);
31
+ }
@@ -0,0 +1,60 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/ref.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // References — how the log addresses content WITHOUT copying it.
10
+ //
11
+ // An event records "note n_88, lines 10-40, hash H" and never the text. Content that
12
+ // actually entered a model request is written once into a content-addressed blob store
13
+ // keyed by hash, so the same excerpt across a hundred turns costs one copy.
14
+ //
15
+ // Replay resolves a Ref by hash: match => exact reconstruction; blob absent or
16
+ // crypto-shredded => VERIFIED_BUT_UNAVAILABLE. It never silently substitutes today's
17
+ // version of the note, because that would make replay quietly wrong instead of loudly
18
+ // incomplete.
19
+
20
+ export const REF_KINDS = Object.freeze(['note', 'meeting', 'chat', 'page', 'result', 'blob']);
21
+
22
+ export const RESOLUTION = Object.freeze({
23
+ EXACT: 'exact', // blob present, hash matches
24
+ UNAVAILABLE: 'verified-but-unavailable', // we know what it was; we no longer hold it
25
+ DRIFTED: 'drifted', // source still exists but its hash changed
26
+ });
27
+
28
+ /** Build a Ref. `hash` is the content hash AT CAPTURE TIME — that is the whole point. */
29
+ export function makeRef({ kind, id, hash, range = null, stored = false }) {
30
+ if (!REF_KINDS.includes(kind)) throw new TypeError(`ref: unknown kind ${kind}`);
31
+ if (typeof id !== 'string' || !id) throw new TypeError('ref: id required');
32
+ if (typeof hash !== 'string' || !hash) throw new TypeError('ref: hash required');
33
+ const ref = { kind, id, hash };
34
+ if (range) {
35
+ if (!Number.isInteger(range.from) || !Number.isInteger(range.to) || range.to < range.from) {
36
+ throw new TypeError('ref: range must be {from,to} integers with to >= from');
37
+ }
38
+ ref.range = { from: range.from, to: range.to };
39
+ }
40
+ if (stored) ref.stored = true;
41
+ return Object.freeze(ref);
42
+ }
43
+
44
+ export function isRef(v) {
45
+ return !!v && typeof v === 'object'
46
+ && REF_KINDS.includes(v.kind)
47
+ && typeof v.id === 'string' && v.id.length > 0
48
+ && typeof v.hash === 'string' && v.hash.length > 0;
49
+ }
50
+
51
+ /**
52
+ * Classify what a replay can say about a Ref, given what the blob store holds now.
53
+ * `lookup(ref) -> { hash } | null`. Pure: the caller owns the store.
54
+ */
55
+ export function resolveRef(ref, lookup) {
56
+ const got = lookup(ref);
57
+ if (!got) return { resolution: RESOLUTION.UNAVAILABLE, ref };
58
+ if (got.hash !== ref.hash) return { resolution: RESOLUTION.DRIFTED, ref, actualHash: got.hash };
59
+ return { resolution: RESOLUTION.EXACT, ref, value: got.value };
60
+ }
@@ -17,4 +17,4 @@
17
17
  // zero runtime dependencies by design; pulling the capability machinery and the event
18
18
  // schema behind it to reach a five-element array would be the transitive-graph mistake
19
19
  // the extension's first-paint budget exists to prevent, one repo over.
20
- export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
20
+ export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'memory', 'page', 'files', 'net']);
@@ -0,0 +1,96 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/view.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // CAPABILITY VIEWS — a capability may ship its own UI, so a result can be an interactive
10
+ // component instead of a paragraph of text.
11
+ //
12
+ // This is NOT the model writing HTML. That already exists (an ```html artifact runs in the
13
+ // sandbox) and is deliberately powerless: it is untrusted text, so it can only draw. A view
14
+ // is declared by a capability that a user or admin approved at load time, which is what
15
+ // makes it safe to give it something a model-authored page can never have — the ability to
16
+ // ACT, by invoking capabilities.
17
+ //
18
+ // The rule that keeps that safe is one line: a view may invoke only what its own capability
19
+ // is already allowed to invoke. A calculator view can compute because its capability may; it
20
+ // cannot read history unless that capability was granted `history` in the first place. There
21
+ // is no path here that widens a permission — `mayInvoke` can only ever name capabilities the
22
+ // declaring capability already lists in `requires`, and validateViewInvocation refuses
23
+ // anything outside that set before the kernel is ever consulted.
24
+ //
25
+ // Pure and host-free by design: no DOM, no postMessage, no chrome.*. The client owns the
26
+ // transport; this owns what is legal to say over it.
27
+
28
+ import { EventError } from './event.js';
29
+
30
+ const str = (v) => typeof v === 'string' && v.length > 0;
31
+
32
+ /**
33
+ * Validate a view DECLARATION. Like the rest of the capability contract this must be
34
+ * readable — and therefore approvable — without executing anything.
35
+ */
36
+ export function validateView(view, capability = null) {
37
+ if (!view || typeof view !== 'object') throw new EventError('SHAPE', 'view must be an object');
38
+ if (!str(view.id)) throw new EventError('SHAPE', 'view.id required');
39
+ if (!str(view.html)) throw new EventError('SHAPE', 'view.html required — a self-contained document');
40
+ if (view.mayInvoke != null && !(Array.isArray(view.mayInvoke) && view.mayInvoke.every(str))) {
41
+ throw new EventError('SHAPE', 'view.mayInvoke must be string[]');
42
+ }
43
+ // NO PRIVILEGE ESCALATION BY DECLARATION. A view is part of its capability, so it cannot
44
+ // reach past it: every id it wants to call must already be in that capability's `requires`
45
+ // (or be the capability itself). Caught here, at approval time, rather than at call time.
46
+ if (capability && view.mayInvoke?.length) {
47
+ const allowed = new Set([capability.id, ...(capability.requires || [])]);
48
+ const extra = view.mayInvoke.filter((id) => !allowed.has(id));
49
+ if (extra.length) {
50
+ throw new EventError('CONTRADICTION',
51
+ `view.mayInvoke exceeds its capability: ${extra.join(', ')} not in requires`);
52
+ }
53
+ }
54
+ if (view.height != null && !(Number.isInteger(view.height) && view.height > 0 && view.height <= 2000)) {
55
+ throw new EventError('SHAPE', 'view.height must be a positive integer <= 2000');
56
+ }
57
+ return view;
58
+ }
59
+
60
+ /**
61
+ * Turn a message a VIEW sent into a capability invocation the kernel can judge — or refuse
62
+ * it. The view is sandboxed and its messages are untrusted input, so nothing here trusts a
63
+ * field: the capability id is checked against what the declaration allows, not against what
64
+ * the message claims to be entitled to.
65
+ *
66
+ * Returns { capability, args, callId }. Throws EventError otherwise.
67
+ */
68
+ export function validateViewInvocation(msg, capability) {
69
+ if (!capability?.view) throw new EventError('SHAPE', 'capability declares no view');
70
+ if (!msg || typeof msg !== 'object') throw new EventError('SHAPE', 'view message must be an object');
71
+ if (!str(msg.callId)) throw new EventError('SHAPE', 'view message needs a callId to correlate its result');
72
+ if (!str(msg.capability)) throw new EventError('SHAPE', 'view message must name a capability');
73
+
74
+ const allowed = new Set([capability.id, ...(capability.view.mayInvoke || [])]);
75
+ if (!allowed.has(msg.capability)) {
76
+ // The important refusal. A compromised or buggy view asking for something else stops
77
+ // here, before any kernel guard has to have an opinion about it.
78
+ throw new EventError('DENIED',
79
+ `view of "${capability.id}" may not invoke "${msg.capability}"`);
80
+ }
81
+ if (msg.args != null && (typeof msg.args !== 'object' || Array.isArray(msg.args))) {
82
+ throw new EventError('SHAPE', 'view invocation args must be an object');
83
+ }
84
+ return { capability: msg.capability, args: msg.args || {}, callId: msg.callId };
85
+ }
86
+
87
+ /**
88
+ * The state a view is mounted with, as it appears on a capability RESULT. Kept separate from
89
+ * the canonical value: `value` is what the capability computed and what everything else
90
+ * reasons about; `view`/`state` are only how it is shown. A host that cannot render views
91
+ * ignores these two fields and still has the whole answer.
92
+ */
93
+ export function viewResult(value, capability, state = null) {
94
+ if (!capability?.view) return { value };
95
+ return { value, view: capability.view.id, state: state ?? value };
96
+ }
@@ -0,0 +1,110 @@
1
+ // Why a turn should not die because a server it never used could not log in.
2
+ //
3
+ // A CLI agent loads EVERY MCP server in the user's own config on every single run. One that
4
+ // cannot authenticate — an expired OAuth token, a VPN-only host seen from a coffee shop —
5
+ // takes the whole turn down with it, even when the question had nothing to do with that
6
+ // server. The user is then told to go re-login to something they never asked for.
7
+ //
8
+ // So: read the failure, name the server, drop it, run again. Two ways in —
9
+ // 1. the user's own deny list (`options.mcpDisabled`), for servers they know they don't
10
+ // want ChatPanel to load at all; and
11
+ // 2. QUARANTINE — a server that just killed a run is dropped automatically and stays
12
+ // dropped for the rest of the bridge's session, since it will not have healed in the
13
+ // twenty seconds before the next message.
14
+ //
15
+ // Two rules keep this honest. It is never silent: dropping a tool without saying so is worse
16
+ // than failing loudly, so the engine emits a status line naming what it skipped. And it never
17
+ // drops ChatPanel's OWN injected server — that one failing is our bug to surface, not a
18
+ // nuisance to route around (silently disabling it would take "Act on page" with it).
19
+ //
20
+ // Engine-agnostic on purpose: Codex renders this as `-c mcp_servers.X.enabled=false`, Claude
21
+ // Code and Copilot as their own flags, but the POLICY — which servers may load this turn —
22
+ // is one decision, made here, not re-derived per engine.
23
+
24
+ import { mcpFailure } from './cli-errors.js';
25
+
26
+ // A server that failed is dropped for this long. Long enough that a chat session never pays
27
+ // the same failed startup twice; short enough that reconnecting the VPN and waiting a while
28
+ // brings the server back without restarting the bridge.
29
+ const TTL_MS = Number(process.env.CHATPANEL_MCP_QUARANTINE_MS) || 30 * 60_000;
30
+
31
+ const dropped = new Map(); // `${agent} ${server}` -> expiry epoch ms
32
+
33
+ const key = (agent, server) => `${agent} ${server}`;
34
+
35
+ function prune(now = Date.now()) {
36
+ for (const [k, expiry] of dropped) if (expiry <= now) dropped.delete(k);
37
+ }
38
+
39
+ /**
40
+ * Server names are interpolated into a config override key (`mcp_servers.<name>.enabled`),
41
+ * so they are validated rather than escaped: anything that isn't a plain MCP server name is
42
+ * dropped. Dots are excluded too — Codex's `-c` parser reads them as further path segments
43
+ * and rejects the quoted form. Accepts an array or a comma/space-separated string (what the
44
+ * settings field holds).
45
+ */
46
+ const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
47
+
48
+ /** One name, validated whole — no splitting, so junk is rejected rather than chopped valid. */
49
+ export function validName(value) {
50
+ const name = String(value || '').trim();
51
+ return NAME_RE.test(name) ? name : null;
52
+ }
53
+
54
+ export function normalizeNames(value) {
55
+ const list = Array.isArray(value) ? value : String(value || '').split(/[,\s]+/);
56
+ const out = [];
57
+ for (const raw of list) {
58
+ const name = validName(raw);
59
+ if (name && !out.includes(name)) out.push(name);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** The servers currently quarantined for an agent. */
65
+ export function quarantined(agent) {
66
+ prune();
67
+ const prefix = key(agent, '');
68
+ return [...dropped.keys()].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
69
+ }
70
+
71
+ /** Drop a server for this agent. Returns false when it was already dropped. */
72
+ export function quarantine(agent, server) {
73
+ const name = validName(server);
74
+ if (!name) return false;
75
+ const k = key(agent, name);
76
+ const fresh = !dropped.has(k);
77
+ dropped.set(k, Date.now() + TTL_MS);
78
+ return fresh;
79
+ }
80
+
81
+ /** Test seam — the store is process-global by design. */
82
+ export function resetQuarantine() {
83
+ dropped.clear();
84
+ }
85
+
86
+ /**
87
+ * Every server this run must not load: the user's deny list plus anything quarantined,
88
+ * minus the servers ChatPanel itself injected (never route around our own).
89
+ */
90
+ export function disabledMcpServers(agent, options = {}, protect = []) {
91
+ const guard = new Set(normalizeNames(protect));
92
+ const names = [...normalizeNames(options.mcpDisabled), ...quarantined(agent)];
93
+ return [...new Set(names)].filter((n) => !guard.has(n));
94
+ }
95
+
96
+ /**
97
+ * Should this failed run be retried without one of the agent's own MCP servers?
98
+ * Returns the server to drop, or null — and null is the safe answer: a failure that names no
99
+ * server, or names one we already dropped, means retrying would only fail the same way.
100
+ * @returns {{server: string, kind: string, short: string}|null}
101
+ */
102
+ export function planMcpRetry({ agent = '', text = '', protect = [], already = [] } = {}) {
103
+ const failure = mcpFailure(text);
104
+ if (!failure?.server) return null;
105
+ if (!validName(failure.server)) return null; // a name we could never write as an override
106
+ if (normalizeNames(protect).includes(failure.server)) return null;
107
+ if (normalizeNames(already).includes(failure.server)) return null;
108
+ if (quarantined(agent).includes(failure.server)) return null;
109
+ return { server: failure.server, kind: failure.kind, short: failure.short };
110
+ }
@@ -0,0 +1,29 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/index.js (npm @chatpanel/pii).
3
+ // Edit there, then run: npm run sync:pii
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // chatpanel-pii — the canonical ChatPanel privacy engine. Single source of truth
10
+ // for reversible PII redaction + pseudonymization, shared by the extension, the
11
+ // gateway, and the bridge. Pure + dependency-free ESM.
12
+ //
13
+ // import { createVault, redactText, restoreText, detectEntities } from 'chatpanel-pii';
14
+ //
15
+ // Submodules are also importable directly:
16
+ // 'chatpanel-pii/pii-redact.js' deterministic redact/restore + vault
17
+ // 'chatpanel-pii/pii-detect.js' local NER / LLM entity detection
18
+ // 'chatpanel-pii/pipeline.js' pure turn orchestration + tier/scope selection
19
+ // 'chatpanel-pii/tool-rank.js' deterministic tool narrowing (auto mode)
20
+ // 'chatpanel-pii/sanitize.js' Unicode de-steganography (strip invisible/format chars)
21
+ // 'chatpanel-pii/net.js' SSRF host classifier + outbound-URL guard
22
+
23
+ export * from './pii-redact.js';
24
+ export * from './pii-detect.js';
25
+ export * from './pipeline.js';
26
+ export * from './tool-rank.js';
27
+ export * from './tool-harness.js';
28
+ export * from './sanitize.js';
29
+ export * from './net.js';
package/src/pii/net.js ADDED
@@ -0,0 +1,111 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/net.js (npm @chatpanel/pii).
3
+ // Edit there, then run: npm run sync:pii
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // Shared host classifier + outbound-URL guard — the SSRF primitive.
10
+ //
11
+ // One implementation of "what is a loopback / cloud-metadata / private host",
12
+ // delivered the way the rest of @chatpanel/pii is: npm dependency for the
13
+ // gateway/bridge, vendorable into the browser extension (pure — only URL + string
14
+ // ops, no node APIs, so it runs in a Worker/service-worker too). Replaces the
15
+ // hand-maintained copies in the bridge (src/ssrf.js) and the extension
16
+ // (js/context.js isBlockedHost) so a security guard can't silently drift between
17
+ // the direct client path and the proxied path. See docs/secure-data-plane.md.
18
+ //
19
+ // The policy knobs cover the two legitimate trust contexts:
20
+ // • A MODEL / API / MCP endpoint (gateway upstream, bridge MCP proxy) may live on
21
+ // loopback (Ollama, LM Studio) or the LAN (a homelab GPU box) — so those are
22
+ // allowed by default — but must NEVER reach cloud instance metadata.
23
+ // • A WEB PAGE fetch (link title, page context) has no business touching loopback
24
+ // or any private host at all — call with { allowLoopback:false, allowPrivate:false }.
25
+ // Cloud metadata (169.254.169.254 & friends) and non-http(s) schemes are blocked in
26
+ // BOTH contexts, unconditionally. Re-run the assert on every redirect hop.
27
+
28
+ function ipv4(h) {
29
+ const m = String(h).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
30
+ if (!m) return null;
31
+ const o = m.slice(1).map(Number);
32
+ if (o.some((n) => n > 255)) return null;
33
+ return o;
34
+ }
35
+
36
+ const norm = (hostname) => String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
37
+
38
+ // Loopback = this host's own services (127.0.0.0/8, ::1, localhost, *.localhost).
39
+ export function isLoopbackHost(hostname) {
40
+ const h = norm(hostname);
41
+ if (!h) return false;
42
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
43
+ if (h === '::1') return true;
44
+ const o = ipv4(h);
45
+ return !!(o && o[0] === 127);
46
+ }
47
+
48
+ // Cloud instance metadata — the sharpest SSRF target (credential theft). Covers the
49
+ // link-local IMDS address used by AWS/GCP/Azure/DO (169.254.169.254), Alibaba's
50
+ // 100.100.100.200, and the GCP/name-based metadata hosts. ALWAYS blocked.
51
+ export function isMetadataHost(hostname) {
52
+ const h = norm(hostname);
53
+ if (h === 'metadata.google.internal' || h === 'metadata') return true;
54
+ const o = ipv4(h);
55
+ if (!o) return false;
56
+ if (o[0] === 169 && o[1] === 254) return true; // 169.254.169.254 (+ link-local)
57
+ if (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) return true; // Alibaba IMDS
58
+ return false;
59
+ }
60
+
61
+ // Private / internal address space, EXCLUDING loopback + metadata (checked
62
+ // separately): RFC1918, CGNAT, IPv6 ULA/link-local, mDNS .local, this-host 0.x/::.
63
+ export function isPrivateHost(hostname) {
64
+ const h = norm(hostname);
65
+ if (!h) return true;
66
+ if (h.endsWith('.local')) return true;
67
+ if (
68
+ h === '::' || h.startsWith('fc') || h.startsWith('fd') // IPv6 ULA
69
+ || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb') // link-local
70
+ ) return true;
71
+ const o = ipv4(h);
72
+ if (o) {
73
+ const [a, b] = o;
74
+ if (a === 0 || a === 10) return true; // this-host / RFC1918
75
+ if (a === 169 && b === 254) return true; // link-local
76
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
77
+ if (a === 192 && b === 168) return true; // RFC1918
78
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
79
+ }
80
+ return false;
81
+ }
82
+
83
+ // Policy-driven classifier. Returns true if `hostname` must be blocked under `policy`.
84
+ // Defaults model the ENDPOINT context (loopback + LAN allowed, metadata never).
85
+ export function isBlockedHost(hostname, { allowLoopback = true, allowPrivate = true } = {}) {
86
+ const h = norm(hostname);
87
+ if (!h) return true;
88
+ if (isMetadataHost(h)) return true; // never, in any context
89
+ if (isLoopbackHost(h)) return !allowLoopback;
90
+ if (isPrivateHost(h)) return !allowPrivate;
91
+ return false; // public host
92
+ }
93
+
94
+ // Assert a URL is fetchable under `policy`; returns the parsed URL or throws.
95
+ // Call on the initial URL AND after every redirect hop.
96
+ export function assertFetchableUrl(u, policy = {}) {
97
+ let parsed;
98
+ try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
99
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
100
+ throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
101
+ }
102
+ if (isBlockedHost(parsed.hostname, policy)) {
103
+ throw new Error(`refusing to reach a blocked address (${parsed.hostname})`);
104
+ }
105
+ return parsed;
106
+ }
107
+
108
+ // Endpoint context: model/API/MCP upstream — loopback + LAN OK, metadata never.
109
+ export const assertEndpointUrl = (u, opts = {}) => assertFetchableUrl(u, { allowLoopback: true, allowPrivate: true, ...opts });
110
+ // Web-page context: no loopback, no private, no metadata — genuinely public only.
111
+ export const assertPublicWebUrl = (u) => assertFetchableUrl(u, { allowLoopback: false, allowPrivate: false });