@chatpanel/bridge 0.10.42 → 0.11.1
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/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/engines/claude.js +41 -1
- 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/view.js +96 -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 +86 -1
package/src/engines/claude.js
CHANGED
|
@@ -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
|
+
}
|
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';
|