@chatpanel/bridge 0.10.31 → 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 +1 -1
- package/scripts/sync-events.mjs +1 -1
- package/src/events/skill-scan.js +218 -0
- package/src/mcp-capabilities.js +140 -0
- package/src/server.js +36 -20
- package/src/skills.js +65 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
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": [
|
package/scripts/sync-events.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
395
|
-
|
|
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
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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,8 +36,9 @@
|
|
|
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
|
|
@@ -85,15 +86,29 @@ export const AGENT_SKILL_DIRS = Object.freeze([
|
|
|
85
86
|
* Only the first is written. The rest belong to whatever else the user runs, and a tool
|
|
86
87
|
* that edits another tool's configuration directory is a tool people uninstall.
|
|
87
88
|
*/
|
|
88
|
-
|
|
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 = []) {
|
|
89
103
|
// Split on the PLATFORM's list separator, not a fixed set. Splitting on ':' everywhere
|
|
90
104
|
// cut "C:\\Users\\me\\skills" into "C" and "\\Users\\me\\skills" on Windows, which is the
|
|
91
105
|
// one platform where a drive letter makes that character part of an ordinary path.
|
|
92
|
-
const
|
|
106
|
+
const fromEnv = String(env.CHATPANEL_SKILL_DIRS || '')
|
|
93
107
|
.split(/\r?\n/)
|
|
94
108
|
.flatMap((line) => line.split(delimiter))
|
|
95
109
|
.map((s) => s.trim())
|
|
96
110
|
.filter(Boolean);
|
|
111
|
+
const extra = [...new Set([...fromEnv, ...cleanDirs(extraDirs)])];
|
|
97
112
|
return [
|
|
98
113
|
...AGENT_SKILL_DIRS.map((d) => ({
|
|
99
114
|
dir: join(home, ...d.segments),
|
|
@@ -101,7 +116,7 @@ export function skillRoots(env = process.env, home = os.homedir()) {
|
|
|
101
116
|
label: d.label,
|
|
102
117
|
writable: !!d.writable,
|
|
103
118
|
})),
|
|
104
|
-
...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 })),
|
|
105
120
|
];
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -228,16 +243,23 @@ async function loadSkill(dir, relPath, source) {
|
|
|
228
243
|
const dirName = relPath.split('/').pop();
|
|
229
244
|
const files = await packageFiles(dir);
|
|
230
245
|
const skill = skillRecord({ meta, body, dirName, relPath, source, files, hash });
|
|
231
|
-
|
|
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 };
|
|
232
252
|
}
|
|
233
253
|
|
|
234
254
|
/**
|
|
235
255
|
* Scan every root. Returns an INDEX keyed by id — and that index is the only thing a
|
|
236
256
|
* later read may resolve a requested name against.
|
|
237
257
|
*/
|
|
238
|
-
export async function scanSkills({ roots =
|
|
258
|
+
export async function scanSkills({ roots, extraDirs = [], platform = process.platform } = {}) {
|
|
259
|
+
roots = roots || skillRoots(process.env, os.homedir(), extraDirs);
|
|
239
260
|
const index = new Map();
|
|
240
261
|
const problems = [];
|
|
262
|
+
const quarantined = [];
|
|
241
263
|
for (const { dir: root, source } of roots) {
|
|
242
264
|
const walk = async (dir, rel, depth) => {
|
|
243
265
|
if (index.size >= MAX_SKILLS || depth > MAX_DEPTH) return;
|
|
@@ -257,8 +279,13 @@ export async function scanSkills({ roots = skillRoots(), platform = process.plat
|
|
|
257
279
|
return null;
|
|
258
280
|
});
|
|
259
281
|
if (loaded) {
|
|
260
|
-
//
|
|
261
|
-
|
|
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.
|
|
262
289
|
index.set(loaded.skill.id, { ...loaded, root, source });
|
|
263
290
|
}
|
|
264
291
|
} else {
|
|
@@ -268,7 +295,7 @@ export async function scanSkills({ roots = skillRoots(), platform = process.plat
|
|
|
268
295
|
};
|
|
269
296
|
await walk(root, '', 1);
|
|
270
297
|
}
|
|
271
|
-
return { index, problems };
|
|
298
|
+
return { index, problems, quarantined };
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
/** Level 0 — what exists, cheaply. No bodies. */
|
|
@@ -296,10 +323,16 @@ export async function readPackageFile(index, name, relPath) {
|
|
|
296
323
|
const hit = index.get(String(name || ''));
|
|
297
324
|
if (!hit) return { error: 'unknown skill' };
|
|
298
325
|
if (!isSafeSkillPath(relPath)) return { error: 'unsafe path' };
|
|
299
|
-
// '
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
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' };
|
|
303
336
|
|
|
304
337
|
const target = resolve(hit.dir, relPath);
|
|
305
338
|
let real;
|
|
@@ -329,26 +362,38 @@ export async function readPackageFile(index, name, relPath) {
|
|
|
329
362
|
const CACHE_MS = 5000;
|
|
330
363
|
let cached = null;
|
|
331
364
|
|
|
332
|
-
export async function skillIndex({ force = false, now = Date.now } = {}) {
|
|
365
|
+
export async function skillIndex({ force = false, now = Date.now, extraDirs = [] } = {}) {
|
|
333
366
|
const t = now();
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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 };
|
|
337
373
|
return value;
|
|
338
374
|
}
|
|
339
375
|
|
|
340
376
|
export function clearSkillCache() { cached = null; }
|
|
341
377
|
|
|
342
378
|
/** The `/health` summary — counts and roots, never contents. */
|
|
343
|
-
export async function skillsHealth() {
|
|
344
|
-
const { index, problems } = await skillIndex();
|
|
379
|
+
export async function skillsHealth(extraDirs = []) {
|
|
380
|
+
const { index, problems, quarantined } = await skillIndex({ extraDirs });
|
|
345
381
|
const used = new Set([...index.values()].map((v) => v.root));
|
|
346
382
|
return {
|
|
347
383
|
count: index.size,
|
|
348
384
|
// Only roots that actually contributed. A list of every path we looked in would be
|
|
349
385
|
// mostly absent directories, and would say nothing about what the user has.
|
|
350
|
-
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),
|
|
351
387
|
sources: [...new Set([...index.values()].map((v) => v.source))].sort(),
|
|
352
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 } : {}),
|
|
353
392
|
};
|
|
354
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
|
+
}
|