@chatpanel/events 0.7.0 → 0.9.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/index.js CHANGED
@@ -44,6 +44,7 @@ export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL }
44
44
  export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
45
45
  export { compileQuery, findMatches, matchIndexFor, expandReplacement, replaceMatch, replaceAll, replaceAllInRange, MAX_MATCHES } from './text-search.js';
46
46
  export { DATA_SCOPES } from './scopes.js';
47
+ export { SOURCE_TRUST, SkillSourceError, defineSkillSource, createSkillSourceRegistry } from './skill-sources.js';
47
48
  export { SKILL_MANIFEST_VERSION, SKILL_CONTEXTS, SKILL_HISTORY_SCOPES, SKILL_MCP_MODES, SKILL_TRUST, SKILL_FILE_KINDS, SKILL_UPCASTERS, SkillManifestError, isSafeSkillPath, originOf, trustOf, skillFiles, needsBridge, declaredAccess, originLabel, sameSkillOrigin, skillIsStale, validateSkill, upcastSkill, upcastSkills, normalizeSkill } from './skill-manifest.js';
48
49
  export { SKILL_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, skillVarGuidance, SkillVarError } from './skill-vars.js';
49
50
  export { outlineOf, parseListItem, continueList, indentSelection, toggleWrap, toggleLinePrefix, toggleTask, toggleLink, docStats, selectionStats } from './markdown-authoring.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -27,6 +27,8 @@
27
27
  "./scopes.js": "./scopes.js",
28
28
  "./search-engines.js": "./search-engines.js",
29
29
  "./skill-manifest.js": "./skill-manifest.js",
30
+ "./skill-scan.js": "./skill-scan.js",
31
+ "./skill-sources.js": "./skill-sources.js",
30
32
  "./skill-vars.js": "./skill-vars.js",
31
33
  "./sources-retrieval.js": "./sources-retrieval.js",
32
34
  "./sources.js": "./sources.js",
@@ -62,6 +64,8 @@
62
64
  "scopes.js",
63
65
  "search-engines.js",
64
66
  "skill-manifest.js",
67
+ "skill-scan.js",
68
+ "skill-sources.js",
65
69
  "skill-vars.js",
66
70
  "sources-retrieval.js",
67
71
  "sources.js",
package/skill-scan.js ADDED
@@ -0,0 +1,210 @@
1
+ // skill-scan.js — is this skill safe to admit?
2
+ //
3
+ // A SKILL.md is a prompt that will run with page tools, MCP servers and history attached.
4
+ // Once skills can arrive from a hub, a repo or a shared folder, that makes it an injection
5
+ // payload with a distribution channel — the reason every serious agent harness quarantines
6
+ // what it installs. This is the gate F6 puts in front of admission.
7
+ //
8
+ // THE PRECISION PROBLEM IS THE WHOLE DESIGN. A scanner that flags legitimate skills is
9
+ // worse than none: people learn to click past it, and then it protects nothing. Real
10
+ // skills are full of shell examples, curl commands and API docs. So:
11
+ //
12
+ // • `dangerous` requires high-precision evidence — an instruction override aimed at the
13
+ // model, or a CREDENTIAL PATH combined with an OUTBOUND SINK in the same breath.
14
+ // Neither half alone is enough: `curl https://api.example.com` is documentation and
15
+ // `~/.aws/credentials` is a sentence about configuration.
16
+ // • `suspicious` is for things worth a human glance that are not proof of anything.
17
+ // • everything else is `clean`, and the common case must be clean.
18
+ //
19
+ // It is a heuristic gate, not a proof. It stops the obvious and the careless; it is not a
20
+ // claim that an admitted skill is safe, which is why provenance stays visible at use time
21
+ // and why scripts stay behind a separate confirmation.
22
+ //
23
+ // Pure and clock-free: the caller stamps the time, so the same input always produces the
24
+ // same finding list and a verdict can be cached by content hash.
25
+
26
+ export class SkillScanError extends Error {
27
+ constructor(code, message) { super(message); this.name = 'SkillScanError'; this.code = code; }
28
+ }
29
+
30
+ /** Bump when a rule changes: a cached verdict from an older scanner must not be trusted. */
31
+ export const SCANNER_VERSION = 1;
32
+
33
+ export const SCAN_VERDICTS = Object.freeze(['clean', 'suspicious', 'dangerous']);
34
+
35
+ const RANK = { clean: 0, suspicious: 1, dangerous: 2 };
36
+
37
+ // Characters that carry no visible meaning and exist in a prompt for one reason: to hide
38
+ // text from the person reading it while the model still sees it. Zero-width joiners,
39
+ // bidirectional overrides, and the Unicode tag block used for "invisible" instructions.
40
+ // eslint-disable-next-line no-misleading-character-class
41
+ const HIDDEN = /[​-‏‪-‮⁠-⁤⁦-⁩\u{e0000}-\u{e007f}]/u;
42
+
43
+ // Aimed at the MODEL rather than describing anything. Deliberately narrow: "ignore the
44
+ // previous section" is ordinary prose, "ignore all previous instructions" is not.
45
+ const OVERRIDE = [
46
+ /\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions?|prompts?|rules?|directions?)\b/i,
47
+ /\bdisregard\s+(?:all\s+|any\s+)?(?:previous|prior|the)\s+(?:instructions?|system\s+prompt|rules?)\b/i,
48
+ /\b(?:forget|override)\s+(?:everything|all)\s+(?:you|above|previously)\b/i,
49
+ /\byou\s+are\s+no\s+longer\s+(?:bound|restricted|required)\b/i,
50
+ /<\/?(?:system|assistant)\b[^>]*>/i,
51
+ /\bnew\s+system\s+prompt\s*:/i,
52
+ ];
53
+
54
+ // Telling the model to keep something from the person it is working for. Real injection
55
+ // signature — and also, sometimes, ordinary editorial guidance, which is why it is
56
+ // SUSPICIOUS rather than dangerous.
57
+ //
58
+ // Codex's own `plugin-creator` skill is the case that settled the severity: it says "Do
59
+ // not tell the user to run `codex plugin marketplace add`", which is advice about what to
60
+ // recommend, not concealment. A first-party skill quarantined by a rule that cannot tell
61
+ // those apart would teach people to click past the gate — so the phrasing is narrowed
62
+ // (`to <verb>` is excluded) AND the severity is honest about what the match proves.
63
+ const CONCEALMENT = [
64
+ /\b(?:do\s+not|don't|never)\s+(?:tell|inform)\s+the\s+user\b(?!\s+to\s)/i,
65
+ /\b(?:do\s+not|don't|never)\s+(?:mention|reveal|disclose)\s+(?:this|that|it|any(?:thing)?)?\s*to\s+the\s+user\b/i,
66
+ /\bwithout\s+(?:telling|informing|notifying)\s+the\s+user\b/i,
67
+ /\bhide\s+(?:this|that|it)\s+from\s+the\s+user\b/i,
68
+ ];
69
+
70
+ // Things that identify a secret. A path or a well-known variable name — not the word
71
+ // "token", which appears in every API document ever written.
72
+ const CREDENTIAL = [
73
+ /~\/\.ssh\/|\bid_rsa\b|\bid_ed25519\b/i,
74
+ /~\/\.aws\/credentials|\bAWS_SECRET_ACCESS_KEY\b/i,
75
+ /\.env\b(?!\w)|\bprintenv\b|\benv\s*\|/i,
76
+ /~\/\.netrc|\.git-credentials|\bkeychain\s+dump\b/i,
77
+ /\bGITHUB_TOKEN\b|\bNPM_TOKEN\b|\bOPENAI_API_KEY\b|\bANTHROPIC_API_KEY\b/,
78
+ /~\/\.config\/(?:gh|gcloud)\/|\bgcloud\s+auth\s+print-access-token\b/i,
79
+ ];
80
+
81
+ // Something that sends data OFF the machine. An outbound body, not a fetch.
82
+ const SINK = [
83
+ /\bcurl\b[^\n|]*(?:-d|--data|--data-binary|-F|--form|-T|--upload-file)\b/i,
84
+ /\bwget\b[^\n|]*--post-(?:data|file)\b/i,
85
+ /\b(?:nc|netcat|ncat)\b\s+[\w.-]+\s+\d+/i,
86
+ /\bfetch\s*\([^)]*method\s*:\s*['"]POST/i,
87
+ /\brequests\.post\s*\(/i,
88
+ /\|\s*(?:curl|nc|netcat)\b/i,
89
+ /\bscp\b\s+\S+\s+\S+@/i,
90
+ ];
91
+
92
+ // Irreversible, and never something a skill document needs to demonstrate literally.
93
+ const DESTRUCTIVE = [
94
+ { re: /\brm\s+-[a-z]*[rR][a-z]*f[a-z]*\s+(?:--no-preserve-root\s+)?(?:\/|~|\$HOME)(?:\/\s|\/?[`'"\s]|$)/, why: 'recursive delete of a root or home directory' },
95
+ { re: /\bmkfs(?:\.\w+)?\b/, why: 'filesystem format' },
96
+ { re: /\bdd\s+[^\n]*\bof=\/dev\/(?:sd|nvme|disk)/, why: 'raw write to a block device' },
97
+ { re: /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/, why: 'fork bomb' },
98
+ { re: /\bchmod\s+-R\s+777\s+\//, why: 'world-writable root' },
99
+ { re: /\bhistory\s+-c\b|\bshred\b\s+[^\n]*\.(?:log|history)/, why: 'covering tracks' },
100
+ ];
101
+
102
+ const SUSPECT = [
103
+ { re: /\b(?:eval|exec)\s*\(\s*(?:atob|base64|Buffer\.from)/i, why: 'executes decoded content' },
104
+ { re: /\bbase64\s+-d\b[^\n]*\|\s*(?:sh|bash|zsh|python)/i, why: 'pipes decoded content to a shell' },
105
+ { re: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i, why: 'pipes a download straight into a shell' },
106
+ { re: /[A-Za-z0-9+/]{280,}={0,2}/, why: 'a large opaque base64 blob' },
107
+ { re: /\bhttp:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[\w.-]+/i, why: 'sends or fetches over plain HTTP' },
108
+ { re: /\bsudo\s+(?:-S\s+)?[^\n]*<<</i, why: 'feeds a password to sudo' },
109
+ ];
110
+
111
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
112
+ const excerpt = (text, index, len = 90) => text.slice(Math.max(0, index - 10), index + len).replace(/\s+/g, ' ').trim();
113
+
114
+ function find(text, re) {
115
+ const m = re.exec(text);
116
+ return m ? { index: m.index, match: m[0] } : null;
117
+ }
118
+
119
+ /**
120
+ * Scan one skill.
121
+ *
122
+ * @param name for the finding messages
123
+ * @param prompt the SKILL.md body — the thing that becomes a prompt
124
+ * @param files declared package paths, e.g. ['references/a.md', 'scripts/run.py']
125
+ * @param extra additional text to scan (a reference document's contents, when the caller
126
+ * has fetched them). Scanned under the same rules: a skill that points at a
127
+ * clean-looking file which itself carries the payload is the obvious dodge.
128
+ *
129
+ * -> { verdict, findings: [{ rule, severity, line, excerpt, why }], scanner }
130
+ */
131
+ export function scanSkill({ name = '', prompt = '', files = [], extra = '' } = {}) {
132
+ const text = [String(prompt || ''), String(extra || '')].filter(Boolean).join('\n\n');
133
+ const findings = [];
134
+ const add = (rule, severity, hit, why) => {
135
+ findings.push({
136
+ rule,
137
+ severity,
138
+ why,
139
+ line: hit ? lineOf(text, hit.index) : 0,
140
+ excerpt: hit ? excerpt(text, hit.index) : '',
141
+ });
142
+ };
143
+
144
+ if (HIDDEN.test(text)) {
145
+ // No legitimate reason for a procedure document to contain characters the reader
146
+ // cannot see but the model can.
147
+ add('hidden-text', 'dangerous', find(text, HIDDEN), 'contains characters that are invisible to a reader but not to the model');
148
+ }
149
+
150
+ for (const re of OVERRIDE) {
151
+ const hit = find(text, re);
152
+ if (hit) { add('instruction-override', 'dangerous', hit, 'tries to override the instructions it is running under'); break; }
153
+ }
154
+
155
+ for (const re of CONCEALMENT) {
156
+ const hit = find(text, re);
157
+ if (hit) { add('concealment', 'suspicious', hit, 'asks the model to keep something from the user'); break; }
158
+ }
159
+
160
+ // The combination is the evidence. Either half alone is ordinary documentation.
161
+ const cred = CREDENTIAL.map((re) => find(text, re)).find(Boolean);
162
+ const sink = SINK.map((re) => find(text, re)).find(Boolean);
163
+ if (cred && sink) {
164
+ add('credential-exfiltration', 'dangerous', cred, `names a credential (${cred.match.trim().slice(0, 40)}) alongside a command that sends data off the machine`);
165
+ } else if (cred) {
166
+ add('credential-mention', 'suspicious', cred, 'refers to a credential file or secret variable');
167
+ } else if (sink) {
168
+ add('outbound-data', 'suspicious', sink, 'sends data to a remote host');
169
+ }
170
+
171
+ for (const { re, why } of DESTRUCTIVE) {
172
+ const hit = find(text, re);
173
+ if (hit) add('destructive-command', 'dangerous', hit, why);
174
+ }
175
+
176
+ for (const { re, why } of SUSPECT) {
177
+ const hit = find(text, re);
178
+ if (hit) add('suspicious-pattern', 'suspicious', hit, why);
179
+ }
180
+
181
+ // Scripts are not scanned as prose — they are code, and this is not a code analyser.
182
+ // Their presence is reported so the reviewer knows execution is on the table at all.
183
+ const scripts = (Array.isArray(files) ? files : []).filter((f) => String(f).startsWith('scripts/'));
184
+ if (scripts.length) {
185
+ findings.push({
186
+ rule: 'ships-executable',
187
+ severity: 'suspicious',
188
+ why: `ships ${scripts.length} executable file${scripts.length === 1 ? '' : 's'} (${scripts.join(', ')}) — these run on your machine, not in the browser`,
189
+ line: 0,
190
+ excerpt: '',
191
+ });
192
+ }
193
+
194
+ const verdict = findings.reduce((worst, f) => (RANK[f.severity] > RANK[worst] ? f.severity : worst), 'clean');
195
+ return { verdict, findings, scanner: SCANNER_VERSION, name: String(name || '') };
196
+ }
197
+
198
+ /** Is this verdict allowed to enter the index at all? */
199
+ export function admits(verdict) {
200
+ return verdict !== 'dangerous';
201
+ }
202
+
203
+ /** A one-line reason, for a card or a refusal. */
204
+ export function scanSummary(scan) {
205
+ if (!scan || scan.verdict === 'clean') return '';
206
+ const worst = (scan.findings || []).filter((f) => f.severity === scan.verdict);
207
+ const first = worst[0];
208
+ const more = worst.length > 1 ? ` (+${worst.length - 1} more)` : '';
209
+ return first ? `${first.why}${more}` : scan.verdict;
210
+ }
@@ -0,0 +1,162 @@
1
+ // skill-sources.js — where skills can come from, as a contract instead of a panel.
2
+ //
3
+ // The instinct when adding "browse a skills hub" is to build the panel the MCP registry
4
+ // bar was built as: one file that knows one API. That is how `history-rag.js` came to
5
+ // hand-write three loaders, and how adding a fourth meant editing four files. The places
6
+ // skills come from are already several — a local bridge, skills.sh, GitHub taps, a site
7
+ // publishing /.well-known/skills, and whatever appears next — and they differ only in how
8
+ // three functions are implemented.
9
+ //
10
+ // So a source is a registration. Register one and browsing, searching and installing gain
11
+ // it at once, because each consumes the registry rather than a list.
12
+ //
13
+ // A SOURCE IS A CAPABILITY. `reads` is the declared access statement (P10) and `trust` is
14
+ // the level everything it produces inherits — a source cannot promote its own skills, and
15
+ // nothing fetched is ever `built-in`.
16
+ //
17
+ // ISOLATION IS THE POINT. One hub being down, slow, or returning garbage must cost that
18
+ // hub's section of the results and nothing else. A registry whose failure mode is "no
19
+ // skills anywhere" would be worse than the hardcoded list it replaced.
20
+ //
21
+ // WHAT IS SHARED IS THE CONTRACT, NOT THE SOURCE. Searching is pure orchestration and
22
+ // lives here; the fetch a real source performs needs a platform — the extension's
23
+ // `secureFetch`, the bridge's Node http, a mobile client's own stack — and is injected at
24
+ // registration, the same split `adapters.js` makes for execution.
25
+
26
+ import { DATA_SCOPES } from './scopes.js';
27
+
28
+ export class SkillSourceError extends Error {
29
+ constructor(code, message) { super(message); this.name = 'SkillSourceError'; this.code = code; }
30
+ }
31
+
32
+ /**
33
+ * Trust a source confers on everything it produces. There is no `built-in` here on
34
+ * purpose: only skills compiled into the product are ours, and no registration can claim
35
+ * that. `local` is a source on this machine (the bridge's store); `community` is anything
36
+ * off the network.
37
+ */
38
+ export const SOURCE_TRUST = Object.freeze(['local', 'community']);
39
+
40
+ /**
41
+ * Declare a source.
42
+ *
43
+ * @param id stable id; also the `origin.source` stamped on everything it yields
44
+ * @param label human name, for the picker and for provenance
45
+ * @param trust 'local' | 'community' — inherited, never self-assigned per skill
46
+ * @param reads declared access, e.g. ['net'] for a hub, [] for the local bridge
47
+ * @param list ({ query, cursor }) -> { items, nextCursor? }. `items` are RECORDS
48
+ * (name + description + files), never bodies — level 0 of the ladder
49
+ * @param read (id) -> a full record including its prompt. Level 1
50
+ * @param readFile (id, path) -> { path, text }. Level 2, optional: a source with no
51
+ * packages simply omits it
52
+ * @param available () -> boolean|Promise<boolean>. A source that cannot answer right now
53
+ * (bridge down, no network) is ABSENT rather than broken — the difference
54
+ * between "nothing here" and an error the user must interpret
55
+ */
56
+ export function defineSkillSource({
57
+ id, label, trust = 'community', reads = [], list, read, readFile = null, available = null,
58
+ }) {
59
+ if (!id || typeof id !== 'string') throw new SkillSourceError('BAD_SOURCE', 'source.id required');
60
+ if (!SOURCE_TRUST.includes(trust)) {
61
+ throw new SkillSourceError('BAD_SOURCE', `source '${id}': trust must be one of ${SOURCE_TRUST}`);
62
+ }
63
+ if (!Array.isArray(reads) || !reads.every((r) => DATA_SCOPES.includes(r))) {
64
+ throw new SkillSourceError('BAD_SOURCE', `source '${id}': reads must be within ${DATA_SCOPES}`);
65
+ }
66
+ if (typeof list !== 'function') throw new SkillSourceError('BAD_SOURCE', `source '${id}': list() required`);
67
+ if (typeof read !== 'function') throw new SkillSourceError('BAD_SOURCE', `source '${id}': read() required`);
68
+ return Object.freeze({
69
+ id,
70
+ label: label || id,
71
+ trust,
72
+ reads: Object.freeze([...reads]),
73
+ list,
74
+ read,
75
+ readFile: typeof readFile === 'function' ? readFile : null,
76
+ available: typeof available === 'function' ? available : () => true,
77
+ });
78
+ }
79
+
80
+ /** The registry a host binds sources into. Insertion-ordered, so results are stable. */
81
+ export function createSkillSourceRegistry() {
82
+ const sources = new Map();
83
+ return {
84
+ /** Register a source. Returns its remover, so registration is revertible (P15). */
85
+ add(source) {
86
+ if (sources.has(source.id)) throw new SkillSourceError('DUPLICATE', `source '${source.id}' already registered`);
87
+ sources.set(source.id, source);
88
+ return () => sources.delete(source.id);
89
+ },
90
+
91
+ list() { return [...sources.values()]; },
92
+ get(id) { return sources.get(id) || null; },
93
+ has(id) { return sources.has(id); },
94
+
95
+ /**
96
+ * Ask every available source at once.
97
+ *
98
+ * Returns one section PER SOURCE rather than a merged list: a merged list would have
99
+ * to rank across hubs that share no scoring, and it would hide which source an entry
100
+ * came from at exactly the moment that matters. A source that throws yields an
101
+ * `error` section and never rejects the call.
102
+ */
103
+ async search({ query = '', cursor = '', only = null } = {}) {
104
+ const wanted = [...sources.values()].filter((s) => !only || only.includes(s.id));
105
+ return Promise.all(wanted.map(async (source) => {
106
+ try {
107
+ if (!(await source.available())) return { source: source.id, label: source.label, items: [], absent: true };
108
+ const page = (await source.list({ query, cursor })) || {};
109
+ const items = Array.isArray(page.items) ? page.items : [];
110
+ return {
111
+ source: source.id,
112
+ label: source.label,
113
+ trust: source.trust,
114
+ // Provenance is stamped HERE, from the registration — a source that could
115
+ // label its own results could label them as something more trusted.
116
+ items: items.map((skill) => stampOrigin(skill, source)),
117
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
118
+ };
119
+ } catch (e) {
120
+ return { source: source.id, label: source.label, items: [], error: String(e?.message || e) };
121
+ }
122
+ }));
123
+ },
124
+
125
+ /** One skill, body included, with provenance stamped the same way. */
126
+ async read(sourceId, skillId) {
127
+ const source = sources.get(sourceId);
128
+ if (!source) throw new SkillSourceError('UNKNOWN_SOURCE', `no source '${sourceId}'`);
129
+ const skill = await source.read(skillId);
130
+ return skill ? stampOrigin(skill, source) : null;
131
+ },
132
+
133
+ async readFile(sourceId, skillId, path) {
134
+ const source = sources.get(sourceId);
135
+ if (!source) throw new SkillSourceError('UNKNOWN_SOURCE', `no source '${sourceId}'`);
136
+ if (!source.readFile) throw new SkillSourceError('NO_FILES', `source '${sourceId}' has no package files`);
137
+ return source.readFile(skillId, path);
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Stamp where a record came from, overriding whatever it claimed.
144
+ *
145
+ * A skill arriving from a source does not get to say which source it came from, or that
146
+ * it is ours: `builtin` is cleared here, and `trustOf()` then derives `community` from the
147
+ * presence of an origin. The record's own `origin.hash` survives — that is the content
148
+ * identity the scanner and the update check compare against, and only the fetcher knows it.
149
+ */
150
+ function stampOrigin(skill, source) {
151
+ if (!skill || typeof skill !== 'object') return skill;
152
+ const claimed = skill.origin && typeof skill.origin === 'object' ? skill.origin : {};
153
+ return {
154
+ ...skill,
155
+ builtin: false,
156
+ origin: {
157
+ ...claimed,
158
+ source: source.id,
159
+ id: String(claimed.id || skill.id || ''),
160
+ },
161
+ };
162
+ }