@chatpanel/bridge 0.10.30 → 0.10.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.30",
3
+ "version": "0.10.32",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -21,7 +21,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
21
21
  // Deliberately short. `skill-manifest.js` imports only `scopes.js`, which is why that
22
22
  // vocabulary was split out of `capability.js` — vendoring the capability machinery and
23
23
  // the event schema to reach a five-element array would defeat the point.
24
- const FILES = ['scopes.js', 'skill-manifest.js'];
24
+ const FILES = ['scopes.js', 'skill-manifest.js', 'skill-scan.js'];
25
25
 
26
26
  function pkgDir() {
27
27
  return [
@@ -0,0 +1,218 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/skill-scan.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
+ // skill-scan.js — is this skill safe to admit?
10
+ //
11
+ // A SKILL.md is a prompt that will run with page tools, MCP servers and history attached.
12
+ // Once skills can arrive from a hub, a repo or a shared folder, that makes it an injection
13
+ // payload with a distribution channel — the reason every serious agent harness quarantines
14
+ // what it installs. This is the gate F6 puts in front of admission.
15
+ //
16
+ // THE PRECISION PROBLEM IS THE WHOLE DESIGN. A scanner that flags legitimate skills is
17
+ // worse than none: people learn to click past it, and then it protects nothing. Real
18
+ // skills are full of shell examples, curl commands and API docs. So:
19
+ //
20
+ // • `dangerous` requires high-precision evidence — an instruction override aimed at the
21
+ // model, or a CREDENTIAL PATH combined with an OUTBOUND SINK in the same breath.
22
+ // Neither half alone is enough: `curl https://api.example.com` is documentation and
23
+ // `~/.aws/credentials` is a sentence about configuration.
24
+ // • `suspicious` is for things worth a human glance that are not proof of anything.
25
+ // • everything else is `clean`, and the common case must be clean.
26
+ //
27
+ // It is a heuristic gate, not a proof. It stops the obvious and the careless; it is not a
28
+ // claim that an admitted skill is safe, which is why provenance stays visible at use time
29
+ // and why scripts stay behind a separate confirmation.
30
+ //
31
+ // Pure and clock-free: the caller stamps the time, so the same input always produces the
32
+ // same finding list and a verdict can be cached by content hash.
33
+
34
+ export class SkillScanError extends Error {
35
+ constructor(code, message) { super(message); this.name = 'SkillScanError'; this.code = code; }
36
+ }
37
+
38
+ /** Bump when a rule changes: a cached verdict from an older scanner must not be trusted. */
39
+ export const SCANNER_VERSION = 1;
40
+
41
+ export const SCAN_VERDICTS = Object.freeze(['clean', 'suspicious', 'dangerous']);
42
+
43
+ const RANK = { clean: 0, suspicious: 1, dangerous: 2 };
44
+
45
+ // Characters that carry no visible meaning and exist in a prompt for one reason: to hide
46
+ // text from the person reading it while the model still sees it. Zero-width joiners,
47
+ // bidirectional overrides, and the Unicode tag block used for "invisible" instructions.
48
+ // eslint-disable-next-line no-misleading-character-class
49
+ const HIDDEN = /[​-‏‪-‮⁠-⁤⁦-⁩\u{e0000}-\u{e007f}]/u;
50
+
51
+ // Aimed at the MODEL rather than describing anything. Deliberately narrow: "ignore the
52
+ // previous section" is ordinary prose, "ignore all previous instructions" is not.
53
+ const OVERRIDE = [
54
+ /\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions?|prompts?|rules?|directions?)\b/i,
55
+ /\bdisregard\s+(?:all\s+|any\s+)?(?:previous|prior|the)\s+(?:instructions?|system\s+prompt|rules?)\b/i,
56
+ /\b(?:forget|override)\s+(?:everything|all)\s+(?:you|above|previously)\b/i,
57
+ /\byou\s+are\s+no\s+longer\s+(?:bound|restricted|required)\b/i,
58
+ /<\/?(?:system|assistant)\b[^>]*>/i,
59
+ /\bnew\s+system\s+prompt\s*:/i,
60
+ ];
61
+
62
+ // Telling the model to keep something from the person it is working for. Real injection
63
+ // signature — and also, sometimes, ordinary editorial guidance, which is why it is
64
+ // SUSPICIOUS rather than dangerous.
65
+ //
66
+ // Codex's own `plugin-creator` skill is the case that settled the severity: it says "Do
67
+ // not tell the user to run `codex plugin marketplace add`", which is advice about what to
68
+ // recommend, not concealment. A first-party skill quarantined by a rule that cannot tell
69
+ // those apart would teach people to click past the gate — so the phrasing is narrowed
70
+ // (`to <verb>` is excluded) AND the severity is honest about what the match proves.
71
+ const CONCEALMENT = [
72
+ /\b(?:do\s+not|don't|never)\s+(?:tell|inform)\s+the\s+user\b(?!\s+to\s)/i,
73
+ /\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,
74
+ /\bwithout\s+(?:telling|informing|notifying)\s+the\s+user\b/i,
75
+ /\bhide\s+(?:this|that|it)\s+from\s+the\s+user\b/i,
76
+ ];
77
+
78
+ // Things that identify a secret. A path or a well-known variable name — not the word
79
+ // "token", which appears in every API document ever written.
80
+ const CREDENTIAL = [
81
+ /~\/\.ssh\/|\bid_rsa\b|\bid_ed25519\b/i,
82
+ /~\/\.aws\/credentials|\bAWS_SECRET_ACCESS_KEY\b/i,
83
+ /\.env\b(?!\w)|\bprintenv\b|\benv\s*\|/i,
84
+ /~\/\.netrc|\.git-credentials|\bkeychain\s+dump\b/i,
85
+ /\bGITHUB_TOKEN\b|\bNPM_TOKEN\b|\bOPENAI_API_KEY\b|\bANTHROPIC_API_KEY\b/,
86
+ /~\/\.config\/(?:gh|gcloud)\/|\bgcloud\s+auth\s+print-access-token\b/i,
87
+ ];
88
+
89
+ // Something that sends data OFF the machine. An outbound body, not a fetch.
90
+ const SINK = [
91
+ /\bcurl\b[^\n|]*(?:-d|--data|--data-binary|-F|--form|-T|--upload-file)\b/i,
92
+ /\bwget\b[^\n|]*--post-(?:data|file)\b/i,
93
+ /\b(?:nc|netcat|ncat)\b\s+[\w.-]+\s+\d+/i,
94
+ /\bfetch\s*\([^)]*method\s*:\s*['"]POST/i,
95
+ /\brequests\.post\s*\(/i,
96
+ /\|\s*(?:curl|nc|netcat)\b/i,
97
+ /\bscp\b\s+\S+\s+\S+@/i,
98
+ ];
99
+
100
+ // Irreversible, and never something a skill document needs to demonstrate literally.
101
+ const DESTRUCTIVE = [
102
+ { 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' },
103
+ { re: /\bmkfs(?:\.\w+)?\b/, why: 'filesystem format' },
104
+ { re: /\bdd\s+[^\n]*\bof=\/dev\/(?:sd|nvme|disk)/, why: 'raw write to a block device' },
105
+ { re: /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/, why: 'fork bomb' },
106
+ { re: /\bchmod\s+-R\s+777\s+\//, why: 'world-writable root' },
107
+ { re: /\bhistory\s+-c\b|\bshred\b\s+[^\n]*\.(?:log|history)/, why: 'covering tracks' },
108
+ ];
109
+
110
+ const SUSPECT = [
111
+ { re: /\b(?:eval|exec)\s*\(\s*(?:atob|base64|Buffer\.from)/i, why: 'executes decoded content' },
112
+ { re: /\bbase64\s+-d\b[^\n]*\|\s*(?:sh|bash|zsh|python)/i, why: 'pipes decoded content to a shell' },
113
+ { re: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i, why: 'pipes a download straight into a shell' },
114
+ { re: /[A-Za-z0-9+/]{280,}={0,2}/, why: 'a large opaque base64 blob' },
115
+ { re: /\bhttp:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[\w.-]+/i, why: 'sends or fetches over plain HTTP' },
116
+ { re: /\bsudo\s+(?:-S\s+)?[^\n]*<<</i, why: 'feeds a password to sudo' },
117
+ ];
118
+
119
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
120
+ const excerpt = (text, index, len = 90) => text.slice(Math.max(0, index - 10), index + len).replace(/\s+/g, ' ').trim();
121
+
122
+ function find(text, re) {
123
+ const m = re.exec(text);
124
+ return m ? { index: m.index, match: m[0] } : null;
125
+ }
126
+
127
+ /**
128
+ * Scan one skill.
129
+ *
130
+ * @param name for the finding messages
131
+ * @param prompt the SKILL.md body — the thing that becomes a prompt
132
+ * @param files declared package paths, e.g. ['references/a.md', 'scripts/run.py']
133
+ * @param extra additional text to scan (a reference document's contents, when the caller
134
+ * has fetched them). Scanned under the same rules: a skill that points at a
135
+ * clean-looking file which itself carries the payload is the obvious dodge.
136
+ *
137
+ * -> { verdict, findings: [{ rule, severity, line, excerpt, why }], scanner }
138
+ */
139
+ export function scanSkill({ name = '', prompt = '', files = [], extra = '' } = {}) {
140
+ const text = [String(prompt || ''), String(extra || '')].filter(Boolean).join('\n\n');
141
+ const findings = [];
142
+ const add = (rule, severity, hit, why) => {
143
+ findings.push({
144
+ rule,
145
+ severity,
146
+ why,
147
+ line: hit ? lineOf(text, hit.index) : 0,
148
+ excerpt: hit ? excerpt(text, hit.index) : '',
149
+ });
150
+ };
151
+
152
+ if (HIDDEN.test(text)) {
153
+ // No legitimate reason for a procedure document to contain characters the reader
154
+ // cannot see but the model can.
155
+ add('hidden-text', 'dangerous', find(text, HIDDEN), 'contains characters that are invisible to a reader but not to the model');
156
+ }
157
+
158
+ for (const re of OVERRIDE) {
159
+ const hit = find(text, re);
160
+ if (hit) { add('instruction-override', 'dangerous', hit, 'tries to override the instructions it is running under'); break; }
161
+ }
162
+
163
+ for (const re of CONCEALMENT) {
164
+ const hit = find(text, re);
165
+ if (hit) { add('concealment', 'suspicious', hit, 'asks the model to keep something from the user'); break; }
166
+ }
167
+
168
+ // The combination is the evidence. Either half alone is ordinary documentation.
169
+ const cred = CREDENTIAL.map((re) => find(text, re)).find(Boolean);
170
+ const sink = SINK.map((re) => find(text, re)).find(Boolean);
171
+ if (cred && sink) {
172
+ add('credential-exfiltration', 'dangerous', cred, `names a credential (${cred.match.trim().slice(0, 40)}) alongside a command that sends data off the machine`);
173
+ } else if (cred) {
174
+ add('credential-mention', 'suspicious', cred, 'refers to a credential file or secret variable');
175
+ } else if (sink) {
176
+ add('outbound-data', 'suspicious', sink, 'sends data to a remote host');
177
+ }
178
+
179
+ for (const { re, why } of DESTRUCTIVE) {
180
+ const hit = find(text, re);
181
+ if (hit) add('destructive-command', 'dangerous', hit, why);
182
+ }
183
+
184
+ for (const { re, why } of SUSPECT) {
185
+ const hit = find(text, re);
186
+ if (hit) add('suspicious-pattern', 'suspicious', hit, why);
187
+ }
188
+
189
+ // Scripts are not scanned as prose — they are code, and this is not a code analyser.
190
+ // Their presence is reported so the reviewer knows execution is on the table at all.
191
+ const scripts = (Array.isArray(files) ? files : []).filter((f) => String(f).startsWith('scripts/'));
192
+ if (scripts.length) {
193
+ findings.push({
194
+ rule: 'ships-executable',
195
+ severity: 'suspicious',
196
+ why: `ships ${scripts.length} executable file${scripts.length === 1 ? '' : 's'} (${scripts.join(', ')}) — these run on your machine, not in the browser`,
197
+ line: 0,
198
+ excerpt: '',
199
+ });
200
+ }
201
+
202
+ const verdict = findings.reduce((worst, f) => (RANK[f.severity] > RANK[worst] ? f.severity : worst), 'clean');
203
+ return { verdict, findings, scanner: SCANNER_VERSION, name: String(name || '') };
204
+ }
205
+
206
+ /** Is this verdict allowed to enter the index at all? */
207
+ export function admits(verdict) {
208
+ return verdict !== 'dangerous';
209
+ }
210
+
211
+ /** A one-line reason, for a card or a refusal. */
212
+ export function scanSummary(scan) {
213
+ if (!scan || scan.verdict === 'clean') return '';
214
+ const worst = (scan.findings || []).filter((f) => f.severity === scan.verdict);
215
+ const first = worst[0];
216
+ const more = worst.length > 1 ? ` (+${worst.length - 1} more)` : '';
217
+ return first ? `${first.why}${more}` : scan.verdict;
218
+ }
@@ -0,0 +1,140 @@
1
+ // mcp-capabilities.js — ChatPanel's own capabilities, exposed as MCP tools.
2
+ //
3
+ // The /mcp endpoint used to relay ONLY the page tools of an active browser chat: nothing
4
+ // to offer unless ChatPanel was open and driving. That makes ChatPanel a thing you drive,
5
+ // never a thing another agent can draw on. This is the other direction — a Codex or a
6
+ // Claude Code that adds the ChatPanel MCP server once gets ChatPanel's bridge-native
7
+ // capabilities as ordinary tools, whether or not a browser is open.
8
+ //
9
+ // A REGISTRY, so a new capability becomes an MCP tool by being registered — not by editing
10
+ // the MCP handler. That is the whole point of the ask "future capabilities should be
11
+ // available automatically": the handler enumerates this list, it does not hardcode a set.
12
+ //
13
+ // WHAT BELONGS HERE: capabilities the bridge can serve on its own — skills (on disk),
14
+ // redaction (the vendored pii engine), local MCP proxying. Notes, meetings and chat history
15
+ // live in the browser's encrypted on-device store; the bridge cannot read them, so they are
16
+ // NOT here — they stay behind the active-session relay (or a future shared store), and
17
+ // exposing on-device history to an external CLI is a privacy decision to make deliberately,
18
+ // not a tool to switch on by default.
19
+
20
+ import { skillIndex, listRecords, readRecord, readPackageFile } from './skills.js';
21
+ import { sanitizeUnicode, hasHiddenChars } from './sanitize.js';
22
+
23
+ const MAX_REF = 24_000;
24
+
25
+ /**
26
+ * The always-on capability tools. Each: { name, description, schema, run(args) -> content }.
27
+ * `run` returns MCP content parts. Kept small and pure-ish; the store calls are the only IO.
28
+ */
29
+ export function capabilityTools() {
30
+ return [
31
+ {
32
+ name: 'chatpanel_skill_list',
33
+ description:
34
+ 'List the skills installed on this machine that ChatPanel can see — across every '
35
+ + 'agent harness (Claude Code, Codex, Copilot, Gemini, Hermes, ~/.agents) and any '
36
+ + 'configured folder. Returns each skill\'s name and one-line description. Call this '
37
+ + 'first, then chatpanel_skill_open to load the instructions of the one that fits.',
38
+ schema: { type: 'object', properties: {} },
39
+ async run() {
40
+ const { index } = await skillIndex();
41
+ const rows = listRecords(index).map((s) => ({
42
+ name: s.command || s.id,
43
+ title: s.name,
44
+ description: s.description || '',
45
+ from: s.origin?.source || 'local',
46
+ references: (s.files?.references || []).length || undefined,
47
+ }));
48
+ return text(JSON.stringify({ skills: rows }, null, 2));
49
+ },
50
+ },
51
+ {
52
+ name: 'chatpanel_skill_open',
53
+ description:
54
+ 'Load one skill\'s full instructions by name (from chatpanel_skill_list). Follow what '
55
+ + 'it returns. If the instructions point at reference files, read one with '
56
+ + 'chatpanel_skill_read.',
57
+ schema: {
58
+ type: 'object',
59
+ properties: { name: { type: 'string', description: 'The skill name from chatpanel_skill_list.' } },
60
+ required: ['name'],
61
+ },
62
+ async run(args) {
63
+ const { index } = await skillIndex();
64
+ const skill = readRecord(index, String(args?.name || '').trim());
65
+ if (!skill) return text(`No such skill "${args?.name}". Use chatpanel_skill_list to see what is available.`, true);
66
+ return text(skill.prompt || '(this skill has no extra instructions — just apply it.)');
67
+ },
68
+ },
69
+ {
70
+ name: 'chatpanel_skill_read',
71
+ description:
72
+ 'Read one reference file a skill\'s instructions point at (any path inside the skill\'s '
73
+ + 'own folder). Use only when the task needs it.',
74
+ schema: {
75
+ type: 'object',
76
+ properties: {
77
+ name: { type: 'string', description: 'The skill name.' },
78
+ path: { type: 'string', description: 'The reference path as written in the instructions, e.g. references/auth.md.' },
79
+ },
80
+ required: ['name', 'path'],
81
+ },
82
+ async run(args) {
83
+ const { index } = await skillIndex();
84
+ const out = await readPackageFile(index, String(args?.name || '').trim(), String(args?.path || '').trim());
85
+ if (out.error) return text(`Could not read it: ${out.error}`, true);
86
+ const body = out.text.length > MAX_REF ? `${out.text.slice(0, MAX_REF)}\n\n…[truncated]` : out.text;
87
+ return text(body);
88
+ },
89
+ },
90
+ {
91
+ // Bridge-native security hygiene: strip the invisible and look-alike characters used to
92
+ // smuggle instructions past a human reader — a real risk for a CLI processing pasted or
93
+ // fetched text. This is the unicode layer, honestly scoped: it is NOT full PII redaction
94
+ // (names, emails), which lives in the gateway with the complete engine. Naming it
95
+ // precisely matters more than sounding capable.
96
+ name: 'chatpanel_sanitize_text',
97
+ description:
98
+ 'Remove hidden and look-alike Unicode characters from text (zero-width joiners, '
99
+ + 'bidirectional overrides, the tag block, homoglyphs) — the tricks used to hide '
100
+ + 'instructions from a human while a model still sees them. Returns the cleaned text and '
101
+ + 'what was removed. Use before trusting text pasted or fetched from an untrusted source. '
102
+ + 'This is not full PII redaction (names, emails) — that lives in the ChatPanel gateway.',
103
+ schema: {
104
+ type: 'object',
105
+ properties: { text: { type: 'string', description: 'The text to sanitize.' } },
106
+ required: ['text'],
107
+ },
108
+ async run(args) {
109
+ const input = String(args?.text ?? '');
110
+ const { clean, removed, findings } = sanitizeUnicode(input);
111
+ return text(JSON.stringify({
112
+ clean,
113
+ hadHiddenCharacters: hasHiddenChars(input),
114
+ removed: removed || 0,
115
+ findings: findings || {}, // per-category counts, e.g. { bidi: 1, zeroWidth: 2 }
116
+ }, null, 2));
117
+ },
118
+ },
119
+ ];
120
+ }
121
+
122
+ function text(s, isError = false) {
123
+ return { content: [{ type: 'text', text: String(s) }], ...(isError ? { isError: true } : {}) };
124
+ }
125
+
126
+ /** MCP tools/list shape for the capability tools. */
127
+ export function capabilityToolSpecs() {
128
+ return capabilityTools().map((t) => ({ name: t.name, description: t.description, inputSchema: t.schema }));
129
+ }
130
+
131
+ /** Run one capability tool by name, or null if it is not one of ours. */
132
+ export async function runCapabilityTool(name, args) {
133
+ const tool = capabilityTools().find((t) => t.name === name);
134
+ if (!tool) return null;
135
+ try {
136
+ return await tool.run(args || {});
137
+ } catch (e) {
138
+ return text(`error: ${e?.message || e}`, true);
139
+ }
140
+ }
package/src/server.js CHANGED
@@ -37,7 +37,8 @@ import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
37
37
  import { connectorsFor } from './connectors.js';
38
38
  import * as custom from './engines/custom.js';
39
39
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
40
- import { skillIndex, listRecords, readRecord, readPackageFile, skillsHealth } from './skills.js';
40
+ import { skillIndex, listRecords, readRecord, readPackageFile, skillsHealth, quarantinedSkills } from './skills.js';
41
+ import { capabilityToolSpecs, runCapabilityTool } from './mcp-capabilities.js';
41
42
  import { DEFAULT_WORKSPACE, isDefaultWorkdir, resolveWorkdir, writeScopeNote } from './workdir.js';
42
43
  import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
43
44
  import { stripHidden } from './sanitize.js';
@@ -66,7 +67,7 @@ import {
66
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
67
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
68
69
  // this drifts from package.json, so the two can't silently diverge.
69
- const VERSION = '0.10.30';
70
+ const VERSION = '0.10.32';
70
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
71
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
72
73
 
@@ -386,20 +387,24 @@ async function handleHealth(res) {
386
387
  // The three routes are the progressive-disclosure ladder, so a client pays for a
387
388
  // skill's body only when it picks one, and for a reference file only when it needs it.
388
389
  // --------------------------------------------------------------------------
389
- async function handleSkillsList(res) {
390
- const { index, problems } = await skillIndex();
390
+ async function handleSkillsList(res, extraDirs) {
391
+ const { index, problems } = await skillIndex({ extraDirs });
391
392
  json(res, 200, { ok: true, skills: listRecords(index), problems });
392
393
  }
393
394
 
394
- async function handleSkillRead(res, name) {
395
- const { index } = await skillIndex();
395
+ async function handleSkillsQuarantined(res, extraDirs) {
396
+ json(res, 200, { ok: true, quarantined: await quarantinedSkills(extraDirs) });
397
+ }
398
+
399
+ async function handleSkillRead(res, name, extraDirs) {
400
+ const { index } = await skillIndex({ extraDirs });
396
401
  const skill = readRecord(index, name);
397
402
  if (!skill) return json(res, 404, { ok: false, error: 'unknown skill' });
398
403
  json(res, 200, { ok: true, skill });
399
404
  }
400
405
 
401
- async function handleSkillFile(res, name, relPath) {
402
- const { index } = await skillIndex();
406
+ async function handleSkillFile(res, name, relPath, extraDirs) {
407
+ const { index } = await skillIndex({ extraDirs });
403
408
  const out = await readPackageFile(index, name, relPath);
404
409
  // One shape for every refusal: a caller learns that it may not have the file, not
405
410
  // whether the path exists, which is the difference between an error and an oracle.
@@ -740,22 +745,27 @@ async function handleMcp(req, res, sessionId) {
740
745
  serverInfo: { name: 'chatpanel-browser', version: VERSION },
741
746
  });
742
747
  }
743
- // No active chat advertise zero tools rather than erroring, so a CLI with a
744
- // standing /mcp config (run outside ChatPanel) starts cleanly instead of failing.
745
- if (!session) {
746
- if (msg.method === 'tools/list') return reply({ tools: [] });
747
- return fail(-32001, 'No active ChatPanel session — open a chat with “Act on page” on.');
748
- }
748
+ // ChatPanel's own bridge-native capabilities (skills today; redaction and more later) are
749
+ // ALWAYS advertised a CLI that added this server once gets them whether or not a browser
750
+ // chat is open. The active session's page tools are added ON TOP when a chat is driving.
751
+ const capTools = capabilityToolSpecs();
752
+
749
753
  if (msg.method === 'tools/list') {
750
- return reply({
751
- tools: session.specs.map((s) => ({
754
+ const pageTools = session
755
+ ? session.specs.map((s) => ({
752
756
  name: s.name,
753
757
  description: s.description,
754
758
  inputSchema: s.parameters || { type: 'object', properties: {} },
755
- })),
756
- });
759
+ }))
760
+ : [];
761
+ return reply({ tools: [...capTools, ...pageTools] });
757
762
  }
758
763
  if (msg.method === 'tools/call') {
764
+ // A capability tool runs in the bridge and needs no browser; a page tool relays to the
765
+ // active chat. Capability tools win a name clash — they are ours and namespaced.
766
+ const cap = await runCapabilityTool(msg.params?.name, msg.params?.arguments || {});
767
+ if (cap) return reply(cap);
768
+ if (!session) return fail(-32001, 'That tool needs an active ChatPanel chat with “Act on page” on. ChatPanel\'s own tools (chatpanel_*) work without one.');
759
769
  try {
760
770
  return reply(await relayToolCall(session, msg.params?.name, msg.params?.arguments || {}));
761
771
  } catch (e) {
@@ -1061,15 +1071,21 @@ const server = createServer(async (req, res) => {
1061
1071
  if (blocked) return json(res, 403, { error: blocked });
1062
1072
  try {
1063
1073
  if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
1064
- if (req.method === 'GET' && url.pathname === '/skills') return handleSkillsList(res);
1074
+ // Custom skill folders the user configured in the extension, passed per request. They
1075
+ // are the user's own absolute paths on their own machine; the bridge validates and
1076
+ // scans them, and the same traversal/symlink guards apply to any file read from them.
1077
+ const extraDirs = url.searchParams.getAll('dir');
1078
+ if (req.method === 'GET' && url.pathname === '/skills') return handleSkillsList(res, extraDirs);
1079
+ if (req.method === 'GET' && url.pathname === '/skills-quarantined') return handleSkillsQuarantined(res, extraDirs);
1065
1080
  if (req.method === 'GET' && url.pathname.startsWith('/skills/')) {
1066
1081
  const rest = url.pathname.slice('/skills/'.length);
1067
1082
  const cut = rest.indexOf('/file/');
1068
- if (cut === -1) return handleSkillRead(res, decodeURIComponent(rest));
1083
+ if (cut === -1) return handleSkillRead(res, decodeURIComponent(rest), extraDirs);
1069
1084
  return handleSkillFile(
1070
1085
  res,
1071
1086
  decodeURIComponent(rest.slice(0, cut)),
1072
1087
  decodeURIComponent(rest.slice(cut + '/file/'.length)),
1088
+ extraDirs,
1073
1089
  );
1074
1090
  }
1075
1091
  if (req.method === 'GET' && url.pathname === '/v1/models') return handleCompatibleModels(res);
package/src/skills.js CHANGED
@@ -36,13 +36,19 @@
36
36
  import { readFile as fsReadFile, readdir, stat, realpath } from 'node:fs/promises';
37
37
  import { createHash } from 'node:crypto';
38
38
  import os from 'node:os';
39
- import { delimiter, join, resolve, sep } from 'node:path';
39
+ import { delimiter, isAbsolute, join, resolve, sep } from 'node:path';
40
40
  import { isSafeSkillPath, normalizeSkill, SKILL_FILE_KINDS } from './events/skill-manifest.js';
41
+ import { scanSkill, admits } from './events/skill-scan.js';
41
42
 
42
43
  const MAX_SKILL_MD = 512 * 1024; // a procedure document, not a corpus
43
44
  const MAX_ASSET = 4 * 1024 * 1024; // a reference doc or a template; images live elsewhere
44
45
  const MAX_SKILLS = 500; // a scan is bounded work, not "whatever is on disk"
45
- const MAX_DEPTH = 2; // <root>/<name>/ and <root>/<category>/<name>/
46
+ const MAX_DEPTH = 3; // <root>/<name>/, <root>/<category>/<name>/, and one
47
+ // namespace above that (Codex: .system/<name>/)
48
+
49
+ // Never a skill and never worth walking: version control and package metadata. Everything
50
+ // else hidden IS walked — see the namespace note in scanSkills.
51
+ const SKIP_DIRS = new Set(['.git', '.svn', '.hg', '.cache', '.DS_Store', 'node_modules']);
46
52
 
47
53
  /**
48
54
  * The agent CLIs that keep skills in a well-known directory, and what to call each one.
@@ -80,15 +86,29 @@ export const AGENT_SKILL_DIRS = Object.freeze([
80
86
  * Only the first is written. The rest belong to whatever else the user runs, and a tool
81
87
  * that edits another tool's configuration directory is a tool people uninstall.
82
88
  */
83
- export function skillRoots(env = process.env, home = os.homedir()) {
89
+ // User-configured folders come from two places: CHATPANEL_SKILL_DIRS (env, for an operator)
90
+ // and the extension's own setting, passed per request as `extraDirs`. Both are the user's
91
+ // OWN paths on their OWN machine reached over an authed loopback call — so they are allowed,
92
+ // but only if they are ABSOLUTE. A relative or empty entry is dropped rather than resolved
93
+ // against the bridge's cwd, which would scan somewhere the user never named.
94
+ function cleanDirs(list) {
95
+ return (Array.isArray(list) ? list : [])
96
+ .map((d) => String(d || '').trim())
97
+ .filter(Boolean)
98
+ .filter((d) => isAbsolute(d))
99
+ .slice(0, 16); // a bound, not a limit anyone will hit
100
+ }
101
+
102
+ export function skillRoots(env = process.env, home = os.homedir(), extraDirs = []) {
84
103
  // Split on the PLATFORM's list separator, not a fixed set. Splitting on ':' everywhere
85
104
  // cut "C:\\Users\\me\\skills" into "C" and "\\Users\\me\\skills" on Windows, which is the
86
105
  // one platform where a drive letter makes that character part of an ordinary path.
87
- const extra = String(env.CHATPANEL_SKILL_DIRS || '')
106
+ const fromEnv = String(env.CHATPANEL_SKILL_DIRS || '')
88
107
  .split(/\r?\n/)
89
108
  .flatMap((line) => line.split(delimiter))
90
109
  .map((s) => s.trim())
91
110
  .filter(Boolean);
111
+ const extra = [...new Set([...fromEnv, ...cleanDirs(extraDirs)])];
92
112
  return [
93
113
  ...AGENT_SKILL_DIRS.map((d) => ({
94
114
  dir: join(home, ...d.segments),
@@ -96,7 +116,7 @@ export function skillRoots(env = process.env, home = os.homedir()) {
96
116
  label: d.label,
97
117
  writable: !!d.writable,
98
118
  })),
99
- ...extra.map((dir) => ({ dir: resolve(dir), source: 'external', label: 'Custom', writable: false })),
119
+ ...extra.map((dir) => ({ dir: resolve(dir), source: 'external', label: 'Custom folder', writable: false })),
100
120
  ];
101
121
  }
102
122
 
@@ -223,31 +243,49 @@ async function loadSkill(dir, relPath, source) {
223
243
  const dirName = relPath.split('/').pop();
224
244
  const files = await packageFiles(dir);
225
245
  const skill = skillRecord({ meta, body, dirName, relPath, source, files, hash });
226
- return { skill, dir };
246
+ // Scanned before it can enter the index — even a local skill, because a folder synced
247
+ // from elsewhere or pulled by another tool is not something the user wrote. The verdict
248
+ // rides on the origin so a client can show it; the caller quarantines a dangerous one.
249
+ const scan = scanSkill({ name: skill.name, prompt: skill.prompt, files: files || [] });
250
+ skill.origin = { ...skill.origin, scanned: { verdict: scan.verdict, scanner: scan.scanner, findings: scan.findings.length } };
251
+ return { skill, dir, scan };
227
252
  }
228
253
 
229
254
  /**
230
255
  * Scan every root. Returns an INDEX keyed by id — and that index is the only thing a
231
256
  * later read may resolve a requested name against.
232
257
  */
233
- export async function scanSkills({ roots = skillRoots(), platform = process.platform } = {}) {
258
+ export async function scanSkills({ roots, extraDirs = [], platform = process.platform } = {}) {
259
+ roots = roots || skillRoots(process.env, os.homedir(), extraDirs);
234
260
  const index = new Map();
235
261
  const problems = [];
262
+ const quarantined = [];
236
263
  for (const { dir: root, source } of roots) {
237
264
  const walk = async (dir, rel, depth) => {
238
265
  if (index.size >= MAX_SKILLS || depth > MAX_DEPTH) return;
239
266
  for (const entry of await listDir(dir)) {
240
267
  if (index.size >= MAX_SKILLS) return;
241
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
268
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
242
269
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
243
270
  const child = join(dir, entry.name);
244
- const loaded = await loadSkill(child, childRel, source).catch((e) => {
245
- problems.push({ path: childRel, reason: String(e?.message || e) });
246
- return null;
247
- });
271
+ // A hidden directory is a NAMESPACE, not a skill. Codex ships its built-ins under
272
+ // `~/.codex/skills/.system/<name>/SKILL.md`, so skipping every dot-directory
273
+ // which was aimed at `.git` — hid six real skills. Walk through it, but never let
274
+ // it become a skill id of its own.
275
+ const loaded = entry.name.startsWith('.')
276
+ ? null
277
+ : await loadSkill(child, childRel, source).catch((e) => {
278
+ problems.push({ path: childRel, reason: String(e?.message || e) });
279
+ return null;
280
+ });
248
281
  if (loaded) {
249
- // First root wins: ChatPanel's own directory is authoritative over a shared one.
250
- if (!index.has(loaded.skill.id) && platformOk(loaded.skill, platform)) {
282
+ // A dangerous skill is kept on disk (the user may want to inspect or remove it)
283
+ // but never enters the index so it cannot be listed, read, offered as a slash
284
+ // command, or handed to a model. Recorded so a client can say WHY it is missing.
285
+ if (!admits(loaded.scan?.verdict)) {
286
+ quarantined.push({ id: loaded.skill.id, path: childRel, verdict: loaded.scan.verdict, findings: loaded.scan.findings });
287
+ } else if (!index.has(loaded.skill.id) && platformOk(loaded.skill, platform)) {
288
+ // First root wins: ChatPanel's own directory is authoritative over a shared one.
251
289
  index.set(loaded.skill.id, { ...loaded, root, source });
252
290
  }
253
291
  } else {
@@ -257,7 +295,7 @@ export async function scanSkills({ roots = skillRoots(), platform = process.plat
257
295
  };
258
296
  await walk(root, '', 1);
259
297
  }
260
- return { index, problems };
298
+ return { index, problems, quarantined };
261
299
  }
262
300
 
263
301
  /** Level 0 — what exists, cheaply. No bodies. */
@@ -285,10 +323,16 @@ export async function readPackageFile(index, name, relPath) {
285
323
  const hit = index.get(String(name || ''));
286
324
  if (!hit) return { error: 'unknown skill' };
287
325
  if (!isSafeSkillPath(relPath)) return { error: 'unsafe path' };
288
- // '/' by wire contract the HTTP path is URL-shaped, and isSafeSkillPath already
289
- // refuses backslashes, so a Windows-style separator never reaches here.
290
- const kind = String(relPath).split('/')[0];
291
- if (!SKILL_FILE_KINDS.includes(kind)) return { error: 'unsafe path' };
326
+ // A skill's docs live wherever its author put them a flat references/ for simple ones,
327
+ // a deep tree (foundry-agent/deploy/deploy.md, rbac/, quota/…) for rich ones. The SKILL.md
328
+ // IS the map, so the readable set is "any file inside this skill's own folder", enforced
329
+ // below by realpath containment not a fixed list of top-level directory names, which
330
+ // silently refused every nested reference the skill's own instructions pointed at.
331
+ //
332
+ // The one exception is scripts/: tier-3 executable code is run behind a confirmation, never
333
+ // handed to a model as text. '/' by wire contract — isSafeSkillPath already refuses
334
+ // backslashes, so a Windows separator never reaches here.
335
+ if (String(relPath).split('/')[0] === 'scripts') return { error: 'scripts are not readable as text' };
292
336
 
293
337
  const target = resolve(hit.dir, relPath);
294
338
  let real;
@@ -318,26 +362,38 @@ export async function readPackageFile(index, name, relPath) {
318
362
  const CACHE_MS = 5000;
319
363
  let cached = null;
320
364
 
321
- export async function skillIndex({ force = false, now = Date.now } = {}) {
365
+ export async function skillIndex({ force = false, now = Date.now, extraDirs = [] } = {}) {
322
366
  const t = now();
323
- if (!force && cached && t - cached.at < CACHE_MS) return cached.value;
324
- const value = await scanSkills();
325
- cached = { at: t, value };
367
+ // The cache key includes the custom folders a scan with extra dirs must not serve, or be
368
+ // served by, one without them.
369
+ const key = cleanDirs(extraDirs).map((d) => resolve(d)).sort().join('|');
370
+ if (!force && cached && cached.key === key && t - cached.at < CACHE_MS) return cached.value;
371
+ const value = await scanSkills({ extraDirs });
372
+ cached = { at: t, key, value };
326
373
  return value;
327
374
  }
328
375
 
329
376
  export function clearSkillCache() { cached = null; }
330
377
 
331
378
  /** The `/health` summary — counts and roots, never contents. */
332
- export async function skillsHealth() {
333
- const { index, problems } = await skillIndex();
379
+ export async function skillsHealth(extraDirs = []) {
380
+ const { index, problems, quarantined } = await skillIndex({ extraDirs });
334
381
  const used = new Set([...index.values()].map((v) => v.root));
335
382
  return {
336
383
  count: index.size,
337
384
  // Only roots that actually contributed. A list of every path we looked in would be
338
385
  // mostly absent directories, and would say nothing about what the user has.
339
- roots: skillRoots().filter((r) => used.has(r.dir)).map((r) => r.dir),
386
+ roots: skillRoots(process.env, os.homedir(), extraDirs).filter((r) => used.has(r.dir)).map((r) => r.dir),
340
387
  sources: [...new Set([...index.values()].map((v) => v.source))].sort(),
341
388
  ...(problems.length ? { problems: problems.length } : {}),
389
+ // A count, not the contents. That there is a blocked skill is worth surfacing; the
390
+ // findings are read on demand, not broadcast on every health poll.
391
+ ...(quarantined?.length ? { quarantined: quarantined.length } : {}),
342
392
  };
343
393
  }
394
+
395
+ /** The blocked skills, with their findings — for a client that wants to show them. */
396
+ export async function quarantinedSkills(extraDirs = []) {
397
+ const { quarantined } = await skillIndex({ extraDirs });
398
+ return quarantined || [];
399
+ }