@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.
- package/package.json +6 -2
- package/scripts/sync-channels.mjs +93 -0
- package/scripts/sync-events.mjs +9 -1
- package/scripts/sync-pii.mjs +71 -0
- package/src/api-compat.js +4 -0
- package/src/channels/adapters/telegram.js +248 -0
- package/src/channels/bridge.js +59 -0
- package/src/channels/eventlog.js +57 -0
- package/src/channels/invoke.js +172 -0
- package/src/channels/normalize.js +58 -0
- package/src/channels/pairing.js +80 -0
- package/src/channels/service.js +270 -0
- package/src/channels/stream.js +86 -0
- package/src/cli-errors.js +75 -11
- package/src/engines/claude.js +41 -1
- package/src/engines/codex.js +134 -56
- package/src/events/capability.js +134 -0
- package/src/events/event.js +183 -0
- package/src/events/reach.js +31 -0
- package/src/events/ref.js +60 -0
- package/src/events/scopes.js +1 -1
- package/src/events/view.js +96 -0
- package/src/mcp-quarantine.js +110 -0
- package/src/pii/index.js +29 -0
- package/src/pii/net.js +111 -0
- package/src/pii/pii-detect.js +177 -0
- package/src/pii/pii-redact.js +399 -0
- package/src/pii/pipeline.js +137 -0
- package/src/pii/sanitize.js +179 -0
- package/src/pii/tool-harness.js +152 -0
- package/src/pii/tool-rank.js +110 -0
- package/src/sanitize.js +6 -136
- package/src/server.js +76 -2
|
@@ -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
|
+
}
|
package/src/events/scopes.js
CHANGED
|
@@ -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
|
+
}
|
package/src/pii/index.js
ADDED
|
@@ -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 });
|