@chatpanel/bridge 0.10.42 → 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.
@@ -49,6 +49,37 @@ const IDLE_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
49
49
  // gated behind the agent's permission mode.
50
50
  const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
51
51
 
52
+ // A remote (channel) caller declares the actor's trust tier via options.reach. Unlike
53
+ // permissionMode (which a local, already-trusted UI chooses), reach is a CEILING enforced HERE
54
+ // at the trust boundary — a paired-but-prompt-injected phone cannot exceed its tier no matter
55
+ // what the message says. This is the tool-authorization half of feature-f7 §7; pairing proves
56
+ // WHO, this constrains WHAT.
57
+ //
58
+ // Posture (security-first): the exfil chain needs read-a-secret AND an egress to send it. We cut
59
+ // the egress on every capped tier by dropping web tools, so even machine-wide reads can't phone
60
+ // home; writes/shell are never granted to a capped tier.
61
+ // device — conversational only: no filesystem, no web, no writes/shell.
62
+ // trusted — machine-wide READ (inspect your own files/projects), but NO web and NO writes/shell.
63
+ // any — no cap here (operator/local console); falls through to permissionMode below.
64
+ const CHANNEL_ALLOW = Object.freeze({
65
+ device: Object.freeze(['TodoWrite', 'Task']),
66
+ trusted: Object.freeze(['Read', 'Grep', 'Glob', 'TodoWrite', 'Task']),
67
+ });
68
+ // Belt-and-suspenders: the allow-list already omits these, but we also forbid them explicitly so
69
+ // a capped turn can never reach shell, writes, or a network egress even via an MCP alias.
70
+ const CHANNEL_DENY = Object.freeze(['Bash', 'Edit', 'Write', 'WebFetch', 'WebSearch']);
71
+
72
+ /**
73
+ * Tool policy for a channel/remote caller. Returns { allow, deny } for a capped tier, or null
74
+ * when reach is absent or 'any' (no cap — the existing permissionMode logic applies). An unknown
75
+ * tier is treated as the MOST restrictive ('device'), never as "no cap" — fail closed.
76
+ */
77
+ export function channelToolPolicy(reach) {
78
+ if (!reach || reach === 'any') return null;
79
+ const allow = CHANNEL_ALLOW[reach] || CHANNEL_ALLOW.device;
80
+ return { allow: [...allow], deny: [...CHANNEL_DENY] };
81
+ }
82
+
52
83
  let lastReason = 'Claude Code not found.';
53
84
  let lastProbe = 0;
54
85
  let cachedOk = false;
@@ -272,10 +303,19 @@ export async function chat({ messages, system, options, images }, emit, { signal
272
303
  mcpAllow.push(...mcpConfig.allowedTools);
273
304
  }
274
305
 
306
+ // A remote (channel) caller's reach tier caps the toolset and OVERRIDES permissionMode: a
307
+ // capped tier never gets --permission-mode, so writes/shell stay denied headlessly even if a
308
+ // message (or a bug upstream) asked to escalate. Local callers (no reach) keep the existing
309
+ // permissionMode behavior unchanged.
310
+ const channelPolicy = channelToolPolicy(options.reach);
311
+ if (channelPolicy) {
312
+ args.push('--allowedTools', ...channelPolicy.allow, ...mcpAllow);
313
+ args.push('--disallowedTools', ...channelPolicy.deny);
314
+ }
275
315
  // Gate writes/shell behind the chosen mode; otherwise restrict to read-only
276
316
  // tools so headless runs never block on an approval prompt. The relayed browser
277
317
  // tools are always pre-allowed (the user explicitly armed them this turn).
278
- if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
318
+ else if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
279
319
  else if (permissionMode === 'acceptEdits') {
280
320
  args.push('--permission-mode', 'acceptEdits');
281
321
  if (mcpAllow.length) args.push('--allowedTools', ...mcpAllow);
@@ -0,0 +1,134 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/capability.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 capability signature — one call shape a rule, a schedule, the user or a model all
10
+ // invoke identically, through one policy path.
11
+ //
12
+ // `actor` is the field that makes capabilities turn-independent; it is the whole of the
13
+ // "capabilities are not turn-shaped" principle expressed as data rather than as a
14
+ // subsystem.
15
+ //
16
+ // `requirements` is what the router dispatches on: not "which model" but "what must be
17
+ // true". {maxLatencyMs:100, deterministic:true, egress:'none'} selects class R or M on a
18
+ // host that can realize it — or REFUSES. Silently exceeding a declared budget is the
19
+ // failure mode this exists to prevent.
20
+
21
+ import { CLASSES, EFFECTS, EGRESS, ACTOR_KINDS, SCOPE_KINDS, EventError } from './event.js';
22
+ import { DATA_SCOPES } from './scopes.js';
23
+ import { validateView } from './view.js';
24
+
25
+ export { DATA_SCOPES } from './scopes.js';
26
+
27
+ const str = (v) => typeof v === 'string' && v.length > 0;
28
+ const strs = (v, allowed = null) => Array.isArray(v) && v.every((x) => str(x) && (!allowed || allowed.includes(x)));
29
+
30
+ /**
31
+ * Validate a capability DECLARATION — the static surface a reviewer, a user or an admin
32
+ * approves BEFORE the capability runs. Everything here is readable without executing
33
+ * anything, which is what makes load-time approval possible.
34
+ */
35
+ export function validateCapability(c) {
36
+ if (!c || typeof c !== 'object') throw new EventError('SHAPE', 'capability must be an object');
37
+ if (!str(c.id)) throw new EventError('SHAPE', 'capability.id required');
38
+ if (!str(c.version)) throw new EventError('SHAPE', 'capability.version required');
39
+ if (!CLASSES.includes(c.class)) throw new EventError('SHAPE', `capability.class must be one of ${CLASSES}`);
40
+ if (!strs(c.requires)) throw new EventError('SHAPE', 'capability.requires must be string[]');
41
+ if (!strs(c.provides)) throw new EventError('SHAPE', 'capability.provides must be string[]');
42
+ if (!strs(c.reads, DATA_SCOPES)) throw new EventError('SHAPE', `capability.reads must be within ${DATA_SCOPES}`);
43
+ if (!strs(c.writes, DATA_SCOPES)) throw new EventError('SHAPE', `capability.writes must be within ${DATA_SCOPES}`);
44
+ if (!EGRESS.includes(c.egress)) throw new EventError('SHAPE', `capability.egress must be one of ${EGRESS}`);
45
+ if (!EFFECTS.includes(c.effects)) throw new EventError('SHAPE', `capability.effects must be one of ${EFFECTS}`);
46
+ if (typeof c.invoke !== 'function') throw new EventError('SHAPE', 'capability.invoke required');
47
+ if (typeof c.disclose !== 'function') throw new EventError('SHAPE', 'capability.disclose required');
48
+ if (!c.output || typeof c.output.render !== 'function') {
49
+ throw new EventError('SHAPE', 'capability.output.render required — canonical value and rendering are separate');
50
+ }
51
+ // A capability MAY ship its own UI. Optional, and validated against this capability so a
52
+ // view can never name a capability its owner isn't already allowed to call.
53
+ if (c.view != null) validateView(c.view, c);
54
+ // A class-R capability that declares egress is a contradiction: R is a determinism
55
+ // guarantee, and a network round-trip is not deterministic.
56
+ if (c.class === 'R' && c.egress !== 'none') {
57
+ throw new EventError('CONTRADICTION', 'class R must declare egress:none');
58
+ }
59
+ return c;
60
+ }
61
+
62
+ /**
63
+ * Validate an INVOCATION. Enforces the one rule that is easiest to forget and worst to
64
+ * miss: a capability that is not `pure` cannot be invoked without an idempotency key,
65
+ * because a retried delegated call would otherwise perform the side effect twice.
66
+ */
67
+ export function validateInvocation(inv, capability) {
68
+ if (!inv || typeof inv !== 'object') throw new EventError('SHAPE', 'invocation must be an object');
69
+ if (!str(inv.capability)) throw new EventError('SHAPE', 'invocation.capability required');
70
+ if (!inv.actor || !ACTOR_KINDS.includes(inv.actor.kind) || !str(inv.actor.id)) {
71
+ throw new EventError('SHAPE', `invocation.actor.kind must be one of ${ACTOR_KINDS}`);
72
+ }
73
+ if (!inv.scope || !SCOPE_KINDS.includes(inv.scope.kind) || !str(inv.scope.id)) {
74
+ throw new EventError('SHAPE', `invocation.scope.kind must be one of ${SCOPE_KINDS}`);
75
+ }
76
+ if (!Array.isArray(inv.causes)) throw new EventError('SHAPE', 'invocation.causes must be string[]');
77
+ const effects = capability ? capability.effects : inv.effects;
78
+ if (effects && effects !== 'pure' && !str(inv.idempotencyKey)) {
79
+ throw new EventError('IDEMPOTENCY', `invocation of a '${effects}' capability requires an idempotencyKey`);
80
+ }
81
+ return inv;
82
+ }
83
+
84
+ /**
85
+ * Can this capability satisfy these requirements on this host?
86
+ * Returns { ok, reasons[] } — REFUSING is a valid, expected outcome.
87
+ *
88
+ * `host` supplies what it can actually realize: { realizes: {R:{maxMs},M:{maxMs},...} }.
89
+ * Class is intrinsic (a guarantee); latency is host-bound. Never fuse the two.
90
+ */
91
+ export function canSatisfy(capability, requirements = {}, host = null) {
92
+ const reasons = [];
93
+ const { maxLatencyMs, deterministic, egress, maxCostUsd } = requirements;
94
+
95
+ if (deterministic === true && !['R', 'M'].includes(capability.class)) {
96
+ reasons.push(`class ${capability.class} is not deterministic`);
97
+ }
98
+ if (egress === 'none' && capability.egress !== 'none') {
99
+ reasons.push(`capability egresses '${capability.egress}', requirement is 'none'`);
100
+ }
101
+ if (egress === 'redacted' && capability.egress === 'delegated') {
102
+ reasons.push('delegated egress is not controlled, requirement is redacted');
103
+ }
104
+ if (maxLatencyMs != null && host) {
105
+ const realized = host.realizes && host.realizes[capability.class];
106
+ if (!realized) reasons.push(`host cannot realize class ${capability.class}`);
107
+ else if (realized.maxMs > maxLatencyMs) {
108
+ reasons.push(`host realizes class ${capability.class} at ~${realized.maxMs}ms, requirement is ${maxLatencyMs}ms`);
109
+ }
110
+ }
111
+ if (maxCostUsd != null && capability.class === 'C' && maxCostUsd <= 0) {
112
+ reasons.push('cloud class requires a positive cost ceiling');
113
+ }
114
+ return { ok: reasons.length === 0, reasons };
115
+ }
116
+
117
+ /**
118
+ * The model-facing projection — an ALLOWLIST built from exactly three fields.
119
+ *
120
+ * Never an omit-list. An omit-list leaks the next field someone adds; this cannot,
121
+ * because `invoke`, `effects`, `cost`, `writes` and `egress` are never copied.
122
+ */
123
+ export function toModelSchema(capability) {
124
+ return {
125
+ name: capability.id,
126
+ description: capability.disclose().gist,
127
+ parameters: capability.input || { type: 'object', properties: {} },
128
+ };
129
+ }
130
+
131
+ /** The same allowlist over a toolset — the only supported way to build a model request. */
132
+ export function toModelSchemas(capabilities) {
133
+ return capabilities.map(toModelSchema);
134
+ }
@@ -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
+ }
@@ -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,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';