@chatpanel/events 0.102.0 → 0.103.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/search-engines.js
CHANGED
|
@@ -1,81 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// This one is not speculative. The engine list existed as an array in the search runtime
|
|
4
|
-
// AND as a copy in the settings page, and when a broken engine was removed from one it kept
|
|
5
|
-
// appearing in the other. Two implementations of one list disagree eventually; the only
|
|
6
|
-
// reliable fix is for there to be one list.
|
|
7
|
-
//
|
|
8
|
-
// It is also where the plugin model pays off soonest for a USER: an engine is a name, a URL
|
|
9
|
-
// template, and a way to read the result. That is a thing someone could contribute without
|
|
10
|
-
// touching any code we wrote.
|
|
11
|
-
|
|
12
|
-
export class SearchEngineError extends Error {
|
|
13
|
-
constructor(code, message) { super(message); this.name = 'SearchEngineError'; this.code = code; }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const ENGINE_KINDS = Object.freeze(['serp', 'api']);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* @param kind 'serp' — an HTML results page to read; 'api' — a service that answers
|
|
20
|
-
* directly. The distinction is not cosmetic: a SERP can be blocked and
|
|
21
|
-
* needs a fallback, an API can need a key and must not run without one.
|
|
22
|
-
* @param needsKey the engine sends queries to a third party that requires credentials.
|
|
23
|
-
* Engines that need a key stay OFF until one exists, because a default that
|
|
24
|
-
* sends the user's queries somewhere they never chose is not a default.
|
|
25
|
-
*/
|
|
26
|
-
export function defineSearchEngine({ id, name, url, kind = 'serp', enabled = false, needsKey = false, retired = false }) {
|
|
27
|
-
if (!id) throw new SearchEngineError('BAD_ENGINE', 'engine.id required');
|
|
28
|
-
if (!ENGINE_KINDS.includes(kind)) throw new SearchEngineError('BAD_ENGINE', `engine '${id}': unknown kind '${kind}'`);
|
|
29
|
-
if (!retired && !url) throw new SearchEngineError('BAD_ENGINE', `engine '${id}': url required`);
|
|
30
|
-
return Object.freeze({ id, name: name || id, url, kind, enabled, needsKey, retired });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Reconcile a user's stored list against the declared engines.
|
|
35
|
-
*
|
|
36
|
-
* The one function both the runtime and the settings page call, so what search uses and
|
|
37
|
-
* what settings shows cannot drift — which is the entire bug this replaces.
|
|
38
|
-
*/
|
|
39
|
-
export function reconcileEngines(stored, declared, { hasKey = false } = {}) {
|
|
40
|
-
const byId = new Map(declared.map((e) => [e.id, e]));
|
|
41
|
-
const retired = new Set(declared.filter((e) => e.retired).map((e) => e.id));
|
|
42
|
-
|
|
43
|
-
let out = (Array.isArray(stored) && stored.length ? stored : declared)
|
|
44
|
-
.filter((e) => e?.id && !retired.has(e.id))
|
|
45
|
-
.map((e) => ({ ...(byId.get(e.id) || {}), ...e }));
|
|
46
|
-
|
|
47
|
-
// Anything declared but never seen by this user is added, off unless it ships on. A user
|
|
48
|
-
// who has saved settings once must still receive new engines.
|
|
49
|
-
for (const d of declared) {
|
|
50
|
-
if (d.retired || out.some((e) => e.id === d.id)) continue;
|
|
51
|
-
out.push({ ...d });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
out = out.map((e) => {
|
|
55
|
-
const d = byId.get(e.id);
|
|
56
|
-
if (!d?.needsKey) return e;
|
|
57
|
-
// "They turned it off" and "it has never been offered" are different states: a key
|
|
58
|
-
// enables the second, never overrides the first.
|
|
59
|
-
const known = Array.isArray(stored) && stored.some((s) => s?.id === e.id);
|
|
60
|
-
const declined = known && stored.find((s) => s.id === e.id)?.enabled === false;
|
|
61
|
-
return { ...e, enabled: hasKey && !declined };
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Never leave the user with nothing usable. Retiring an engine can empty a list that
|
|
65
|
-
// contained only that engine, and a search feature that silently has no engines is a
|
|
66
|
-
// worse failure than the one being fixed.
|
|
67
|
-
if (!out.some((e) => e.enabled !== false && !e.needsKey)) {
|
|
68
|
-
for (const d of declared) {
|
|
69
|
-
if (d.retired || d.needsKey) continue;
|
|
70
|
-
const have = out.find((e) => e.id === d.id);
|
|
71
|
-
if (have) have.enabled = d.enabled !== false;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The order to actually try: enabled first (in list order), then the rest as fallbacks. */
|
|
78
|
-
export function attemptOrder(engines) {
|
|
79
|
-
const live = engines.filter((e) => !e.retired);
|
|
80
|
-
return [...live.filter((e) => e.enabled !== false), ...live.filter((e) => e.enabled === false)];
|
|
81
|
-
}
|
|
1
|
+
class d extends Error{constructor(r,i){super(i),this.name="SearchEngineError",this.code=r}}const u=Object.freeze(["serp","api"]);function p({id:n,name:r,url:i,kind:o="serp",enabled:f=!1,needsKey:t=!1,retired:e=!1}){if(!n)throw new d("BAD_ENGINE","engine.id required");if(!u.includes(o))throw new d("BAD_ENGINE",`engine '${n}': unknown kind '${o}'`);if(!e&&!i)throw new d("BAD_ENGINE",`engine '${n}': url required`);return Object.freeze({id:n,name:r||n,url:i,kind:o,enabled:f,needsKey:t,retired:e})}function E(n,r,{hasKey:i=!1}={}){const o=new Map(r.map(e=>[e.id,e])),f=new Set(r.filter(e=>e.retired).map(e=>e.id));let t=(Array.isArray(n)&&n.length?n:r).filter(e=>e?.id&&!f.has(e.id)).map(e=>({...o.get(e.id)||{},...e}));for(const e of r)e.retired||t.some(s=>s.id===e.id)||t.push({...e});if(t=t.map(e=>{if(!o.get(e.id)?.needsKey)return e;const l=Array.isArray(n)&&n.some(a=>a?.id===e.id)&&n.find(a=>a.id===e.id)?.enabled===!1;return{...e,enabled:i&&!l}}),!t.some(e=>e.enabled!==!1&&!e.needsKey))for(const e of r){if(e.retired||e.needsKey)continue;const s=t.find(c=>c.id===e.id);s&&(s.enabled=e.enabled!==!1)}return t}function h(n){const r=n.filter(i=>!i.retired);return[...r.filter(i=>i.enabled!==!1),...r.filter(i=>i.enabled===!1)]}export{u as ENGINE_KINDS,d as SearchEngineError,h as attemptOrder,p as defineSearchEngine,E as reconcileEngines};
|
package/skill-manifest.js
CHANGED
|
@@ -1,330 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A skill was a settings record: a prompt plus a few dropdowns, normalized by whichever
|
|
4
|
-
// client happened to save it. That was survivable while every skill was hand-written by
|
|
5
|
-
// the person running it. It stops being survivable the moment a skill can ARRIVE from
|
|
6
|
-
// somewhere — a hub, a repo, another agent's skill directory — because the questions
|
|
7
|
-
// change from "what does this prompt say" to "where did it come from, what may it reach,
|
|
8
|
-
// and has anything checked it".
|
|
9
|
-
//
|
|
10
|
-
// So this module owns the record. Not the storage (a platform question), not the UI (a
|
|
11
|
-
// platform question), not the fetching (F6 S3) — the shape, its evolution, and the two
|
|
12
|
-
// derivations no client may compute for itself:
|
|
13
|
-
//
|
|
14
|
-
// • TRUST IS DERIVED, NEVER DECLARED. A skill cannot say it is trusted. `trustOf()`
|
|
15
|
-
// reads provenance: shipped by us -> 'built-in', fetched -> 'community', neither ->
|
|
16
|
-
// 'user'. A stored `trust` field is stripped on normalize, and `builtin` is forced
|
|
17
|
-
// false whenever an origin is present — otherwise "trusted" would be a string an
|
|
18
|
-
// importer sets.
|
|
19
|
-
// • DECLARED ACCESS IS COMPUTED FROM THE RECORD. Everything a skill can reach is
|
|
20
|
-
// readable before it runs, which is what makes load-time approval possible (P10).
|
|
21
|
-
// If a reviewer's summary were assembled by the install screen, the install screen
|
|
22
|
-
// would be the security boundary.
|
|
23
|
-
//
|
|
24
|
-
// Versioning follows the log's discipline: additive only, absence means the previous
|
|
25
|
-
// default, and the upcast chain exists from the start so adding v3 does not mean
|
|
26
|
-
// rewriting every reader.
|
|
27
|
-
|
|
28
|
-
import { DATA_SCOPES } from './scopes.js';
|
|
29
|
-
|
|
30
|
-
export class SkillManifestError extends Error {
|
|
31
|
-
constructor(code, message) { super(message); this.name = 'SkillManifestError'; this.code = code; }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Schema version of the RECORD. Distinct from `version`, which is the author's semver. */
|
|
35
|
-
export const SKILL_MANIFEST_VERSION = 2;
|
|
36
|
-
|
|
37
|
-
export const SKILL_CONTEXTS = Object.freeze(['auto', 'page', 'selection', 'tabs', 'none']);
|
|
38
|
-
export const SKILL_HISTORY_SCOPES = Object.freeze(['none', 'chats', 'meetings', 'all']);
|
|
39
|
-
export const SKILL_MCP_MODES = Object.freeze(['none', 'selected', 'default']);
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Derived, never stored. 'community' is deliberately the level for a skill fetched from
|
|
43
|
-
* a vendor repo with a famous name — only what we ship went through our review.
|
|
44
|
-
*/
|
|
45
|
-
export const SKILL_TRUST = Object.freeze(['built-in', 'user', 'community']);
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The directories a package may carry, matching the agentskills.io layout. `scripts` is
|
|
49
|
-
* the tier-3 one: it cannot run in a browser extension and must not be offered as if it
|
|
50
|
-
* could (see `needsBridge`).
|
|
51
|
-
*/
|
|
52
|
-
export const SKILL_FILE_KINDS = Object.freeze(['references', 'scripts', 'assets', 'templates', 'examples']);
|
|
53
|
-
|
|
54
|
-
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
55
|
-
|
|
56
|
-
// A slash command is typed by a human and matched case-insensitively against a stored
|
|
57
|
-
// string; anything outside this grammar either cannot be typed or collides with the
|
|
58
|
-
// leading-slash parse.
|
|
59
|
-
const COMMAND = /^[a-z0-9][a-z0-9_-]*$/;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* A file path inside a package directory.
|
|
63
|
-
*
|
|
64
|
-
* The check is a security boundary, not tidiness: these strings become filesystem paths
|
|
65
|
-
* in the bridge's skill store, and a package is authored by a stranger. Rejected are
|
|
66
|
-
* absolute paths, any `..` segment, backslashes (a Windows separator that a POSIX
|
|
67
|
-
* `split('/')` would not see as one), leading/trailing whitespace and control characters.
|
|
68
|
-
* Allowing one of them is a directory-traversal write on someone's machine.
|
|
69
|
-
*/
|
|
70
|
-
export function isSafeSkillPath(p) {
|
|
71
|
-
if (!str(p) || p.length > 255) return false;
|
|
72
|
-
if (p !== p.trim()) return false;
|
|
73
|
-
if (p.includes('\\')) return false;
|
|
74
|
-
// eslint-disable-next-line no-control-regex
|
|
75
|
-
if (/[\u0000-\u001f\u007f]/.test(p)) return false;
|
|
76
|
-
if (p.startsWith('/') || /^[a-z]:/i.test(p)) return false;
|
|
77
|
-
const parts = p.split('/');
|
|
78
|
-
if (parts.some((seg) => seg === '' || seg === '.' || seg === '..')) return false;
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Did this skill come from somewhere, or did the user write it? */
|
|
83
|
-
export function originOf(skill) {
|
|
84
|
-
const o = skill?.origin;
|
|
85
|
-
return o && typeof o === 'object' && str(o.source) && str(o.id) ? o : null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Derived from provenance, never read from the record. A skill that arrived from outside
|
|
90
|
-
* is 'community' whoever published it; 'built-in' means we shipped it.
|
|
91
|
-
*/
|
|
92
|
-
export function trustOf(skill) {
|
|
93
|
-
if (originOf(skill)) return 'community';
|
|
94
|
-
return skill?.builtin ? 'built-in' : 'user';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The files a package carries, per directory, already filtered to safe paths. */
|
|
98
|
-
export function skillFiles(skill) {
|
|
99
|
-
const files = skill?.files;
|
|
100
|
-
const out = {};
|
|
101
|
-
if (!files || typeof files !== 'object') return out;
|
|
102
|
-
for (const kind of SKILL_FILE_KINDS) {
|
|
103
|
-
const list = Array.isArray(files[kind]) ? files[kind].filter(isSafeSkillPath) : [];
|
|
104
|
-
if (list.length) out[kind] = [...new Set(list)];
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* True when running this skill fully needs a host that can execute code — i.e. the
|
|
111
|
-
* bridge. The extension may still store, show and run the PROMPT half; what it may not
|
|
112
|
-
* do is pretend the scripts ran.
|
|
113
|
-
*/
|
|
114
|
-
export function needsBridge(skill) {
|
|
115
|
-
return (skillFiles(skill).scripts || []).length > 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Everything this skill can reach, computed from the record. This is what an install
|
|
120
|
-
* review, the Plugins lens and an admin export all read — one derivation, so the three
|
|
121
|
-
* cannot disagree about what was approved.
|
|
122
|
-
*/
|
|
123
|
-
export function declaredAccess(skill = {}) {
|
|
124
|
-
const files = skillFiles(skill);
|
|
125
|
-
const mcpMode = SKILL_MCP_MODES.includes(skill.mcpMode) ? skill.mcpMode : 'none';
|
|
126
|
-
const context = SKILL_CONTEXTS.includes(skill.context) ? skill.context : 'auto';
|
|
127
|
-
const history = SKILL_HISTORY_SCOPES.includes(skill.historyContext) ? skill.historyContext : 'none';
|
|
128
|
-
const reads = new Set(Array.isArray(skill.reads) ? skill.reads.filter((r) => DATA_SCOPES.includes(r)) : []);
|
|
129
|
-
// The dropdowns ARE access statements; a record that declared `reads` separately from
|
|
130
|
-
// them could claim less than it takes.
|
|
131
|
-
if (context !== 'none') reads.add('page');
|
|
132
|
-
if (history === 'chats' || history === 'all') reads.add('chats');
|
|
133
|
-
if (history === 'meetings' || history === 'all') reads.add('meetings');
|
|
134
|
-
return {
|
|
135
|
-
trust: trustOf(skill),
|
|
136
|
-
reads: [...reads].sort(),
|
|
137
|
-
page: context,
|
|
138
|
-
history,
|
|
139
|
-
mcp: mcpMode,
|
|
140
|
-
mcpServerIds: mcpMode === 'selected' && Array.isArray(skill.mcpServerIds) ? [...skill.mcpServerIds] : [],
|
|
141
|
-
sources: Array.isArray(skill.sources) ? [...new Set(skill.sources.filter(str))].sort() : [],
|
|
142
|
-
surfaces: Array.isArray(skill.surfaces) ? [...new Set(skill.surfaces.filter(str))].sort() : [],
|
|
143
|
-
scripts: files.scripts || [],
|
|
144
|
-
needsBridge: needsBridge(skill),
|
|
145
|
-
meeting: !!skill.meeting,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Human label for where a skill came from, for the surfaces that must show provenance. */
|
|
150
|
-
export function originLabel(skill) {
|
|
151
|
-
const o = originOf(skill);
|
|
152
|
-
if (!o) return trustOf(skill) === 'built-in' ? 'Built-in' : 'Written here';
|
|
153
|
-
return o.id.length > 48 ? `${o.source} · …${o.id.slice(-40)}` : `${o.source} · ${o.id}`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Two records describing the same upstream skill — for update checks and dedupe. */
|
|
157
|
-
export function sameSkillOrigin(a, b) {
|
|
158
|
-
const x = originOf(a);
|
|
159
|
-
const y = originOf(b);
|
|
160
|
-
return !!x && !!y && x.source === y.source && x.id === y.id;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Has upstream changed since this was installed? `null` when unanswerable (no origin, or
|
|
165
|
-
* nothing was hashed) — which callers must treat as "do not claim it is current" rather
|
|
166
|
-
* than as "up to date".
|
|
167
|
-
*/
|
|
168
|
-
export function skillIsStale(skill, upstreamHash) {
|
|
169
|
-
const o = originOf(skill);
|
|
170
|
-
if (!o || !str(o.hash) || !str(upstreamHash)) return null;
|
|
171
|
-
return o.hash !== upstreamHash;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Validate a skill DECLARATION — the static surface approved before anything runs.
|
|
176
|
-
* Throws on the first problem, like `validateCapability`.
|
|
177
|
-
*/
|
|
178
|
-
export function validateSkill(skill) {
|
|
179
|
-
if (!skill || typeof skill !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
|
|
180
|
-
if (!str(skill.id)) throw new SkillManifestError('SHAPE', 'skill.id required');
|
|
181
|
-
if (!str(skill.name)) throw new SkillManifestError('SHAPE', `skill '${skill.id}': name required`);
|
|
182
|
-
if (skill.prompt != null && typeof skill.prompt !== 'string') {
|
|
183
|
-
throw new SkillManifestError('SHAPE', `skill '${skill.id}': prompt must be a string`);
|
|
184
|
-
}
|
|
185
|
-
if (skill.command != null && skill.command !== '' && !COMMAND.test(String(skill.command))) {
|
|
186
|
-
throw new SkillManifestError('SHAPE', `skill '${skill.id}': command must match ${COMMAND}`);
|
|
187
|
-
}
|
|
188
|
-
for (const [field, allowed] of [
|
|
189
|
-
['context', SKILL_CONTEXTS], ['historyContext', SKILL_HISTORY_SCOPES], ['mcpMode', SKILL_MCP_MODES],
|
|
190
|
-
]) {
|
|
191
|
-
if (skill[field] != null && !allowed.includes(skill[field])) {
|
|
192
|
-
throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be one of ${allowed}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (skill.reads != null) {
|
|
196
|
-
if (!Array.isArray(skill.reads) || !skill.reads.every((r) => DATA_SCOPES.includes(r))) {
|
|
197
|
-
throw new SkillManifestError('SHAPE', `skill '${skill.id}': reads must be within ${DATA_SCOPES}`);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const field of ['sources', 'surfaces']) {
|
|
201
|
-
if (skill[field] != null && (!Array.isArray(skill[field]) || !skill[field].every(str))) {
|
|
202
|
-
throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be an array of ids`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (skill.origin != null) {
|
|
206
|
-
const o = skill.origin;
|
|
207
|
-
if (typeof o !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': origin must be an object`);
|
|
208
|
-
// Without a source and an id it cannot be re-fetched or compared, which is the whole
|
|
209
|
-
// reason the field exists; a half-origin is worse than none because it looks answered.
|
|
210
|
-
if (!str(o.source) || !str(o.id)) {
|
|
211
|
-
throw new SkillManifestError('ORIGIN', `skill '${skill.id}': origin needs both source and id`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (skill.files != null) {
|
|
215
|
-
if (typeof skill.files !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': files must be an object`);
|
|
216
|
-
for (const kind of Object.keys(skill.files)) {
|
|
217
|
-
if (!SKILL_FILE_KINDS.includes(kind)) {
|
|
218
|
-
throw new SkillManifestError('FILES', `skill '${skill.id}': unknown file kind '${kind}'`);
|
|
219
|
-
}
|
|
220
|
-
const list = skill.files[kind];
|
|
221
|
-
if (!Array.isArray(list) || !list.every(str)) {
|
|
222
|
-
throw new SkillManifestError('FILES', `skill '${skill.id}': files.${kind} must be an array of paths`);
|
|
223
|
-
}
|
|
224
|
-
const bad = list.find((p) => !isSafeSkillPath(p));
|
|
225
|
-
if (bad) throw new SkillManifestError('PATH', `skill '${skill.id}': unsafe path in files.${kind}: ${JSON.stringify(bad)}`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// Files without an origin means someone hand-wrote a package record; that is allowed,
|
|
229
|
-
// but scripts without an origin cannot be scanned against anything, and an unscannable
|
|
230
|
-
// script is exactly what the admission gate exists to refuse.
|
|
231
|
-
if (!originOf(skill) && (skill.files?.scripts || []).length) {
|
|
232
|
-
throw new SkillManifestError('ORIGIN', `skill '${skill.id}': scripts require an origin to be scannable`);
|
|
233
|
-
}
|
|
234
|
-
return skill;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* v(n) -> v(n+1). Each MUST be total: it may not throw for any record of its version.
|
|
239
|
-
*
|
|
240
|
-
* v1 -> v2 stamps the version and nothing else, on purpose. Every field F6 adds is
|
|
241
|
-
* absence-means-the-old-default, so there is nothing to fill in — the same discipline as
|
|
242
|
-
* storing which plugins are DISABLED rather than the full state, so a field added later
|
|
243
|
-
* needs no migration.
|
|
244
|
-
*/
|
|
245
|
-
export const SKILL_UPCASTERS = Object.freeze({
|
|
246
|
-
1: (s) => ({ ...s, v: SKILL_MANIFEST_VERSION }),
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
/** Carry a stored skill forward to the current schema. Pure; never mutates the input. */
|
|
250
|
-
export function upcastSkill(stored) {
|
|
251
|
-
if (!stored || typeof stored !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
|
|
252
|
-
// A record written before the version existed is v1 by definition.
|
|
253
|
-
let s = typeof stored.v === 'number' ? stored : { ...stored, v: 1 };
|
|
254
|
-
let guard = 0;
|
|
255
|
-
while (s.v < SKILL_MANIFEST_VERSION) {
|
|
256
|
-
const step = SKILL_UPCASTERS[s.v];
|
|
257
|
-
if (!step) throw new SkillManifestError('UPCAST', `no upcaster from v${s.v}`);
|
|
258
|
-
s = step(s);
|
|
259
|
-
if (++guard > 64) throw new SkillManifestError('UPCAST', 'upcaster chain did not terminate');
|
|
260
|
-
}
|
|
261
|
-
if (s.v > SKILL_MANIFEST_VERSION) {
|
|
262
|
-
throw new SkillManifestError('UPCAST', `skill is v${s.v}; this reader only knows v${SKILL_MANIFEST_VERSION}`);
|
|
263
|
-
}
|
|
264
|
-
return s;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export function upcastSkills(stored) {
|
|
268
|
-
return (Array.isArray(stored) ? stored : []).map(upcastSkill);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Coerce a record into the canonical shape a writer stores. Total and forgiving — this
|
|
273
|
-
* runs on save, where throwing would cost a user their edit; `validateSkill` is the
|
|
274
|
-
* strict gate, used where a record ARRIVES.
|
|
275
|
-
*
|
|
276
|
-
* The security-relevant coercions are the ones that cannot be left to a caller:
|
|
277
|
-
* `trust` is never persisted, `builtin` cannot survive an origin, and an unsafe file
|
|
278
|
-
* path is dropped rather than stored and rejected later.
|
|
279
|
-
*/
|
|
280
|
-
export function normalizeSkill(skill) {
|
|
281
|
-
if (!skill || typeof skill !== 'object') return skill;
|
|
282
|
-
const out = { ...upcastSkill(skill) };
|
|
283
|
-
|
|
284
|
-
// Skills predate the enabled flag — absence means enabled.
|
|
285
|
-
out.enabled = out.enabled !== false;
|
|
286
|
-
|
|
287
|
-
const mode = String(out.mcpMode || 'none').toLowerCase();
|
|
288
|
-
out.mcpMode = SKILL_MCP_MODES.includes(mode) ? mode : 'none';
|
|
289
|
-
const ids = Array.isArray(out.mcpServerIds) ? out.mcpServerIds : [];
|
|
290
|
-
out.mcpServerIds = out.mcpMode === 'selected'
|
|
291
|
-
? [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))]
|
|
292
|
-
: [];
|
|
293
|
-
|
|
294
|
-
// Derived, never stored: a record that could assert its own trust would make every
|
|
295
|
-
// check downstream a formality.
|
|
296
|
-
delete out.trust;
|
|
297
|
-
|
|
298
|
-
const origin = originOf(out);
|
|
299
|
-
if (origin) {
|
|
300
|
-
out.builtin = false; // "we shipped it" is not something an import may claim
|
|
301
|
-
out.origin = {
|
|
302
|
-
source: origin.source,
|
|
303
|
-
id: origin.id,
|
|
304
|
-
...(str(origin.url) ? { url: origin.url } : {}),
|
|
305
|
-
...(str(origin.hash) ? { hash: origin.hash } : {}),
|
|
306
|
-
...(origin.scanned && typeof origin.scanned === 'object' ? { scanned: { ...origin.scanned } } : {}),
|
|
307
|
-
};
|
|
308
|
-
} else if (out.origin != null) {
|
|
309
|
-
delete out.origin; // a half-origin looks answered and is not
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (out.reads != null) {
|
|
313
|
-
out.reads = [...new Set((Array.isArray(out.reads) ? out.reads : []).filter((r) => DATA_SCOPES.includes(r)))].sort();
|
|
314
|
-
}
|
|
315
|
-
for (const field of ['sources', 'surfaces']) {
|
|
316
|
-
if (out[field] != null) {
|
|
317
|
-
out[field] = [...new Set((Array.isArray(out[field]) ? out[field] : []).filter(str))];
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (out.files != null) {
|
|
322
|
-
const files = skillFiles(out);
|
|
323
|
-
if (Object.keys(files).length) out.files = files;
|
|
324
|
-
else delete out.files;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (out.version != null && !str(out.version)) delete out.version;
|
|
328
|
-
|
|
329
|
-
return out;
|
|
330
|
-
}
|
|
1
|
+
import{DATA_SCOPES as a}from"./scopes.js";class o extends Error{constructor(t,r){super(r),this.name="SkillManifestError",this.code=t}}const u=2,S=Object.freeze(["auto","page","selection","tabs","none"]),l=Object.freeze(["none","chats","meetings","all"]),d=Object.freeze(["none","selected","default"]),I=Object.freeze(["built-in","user","community"]),m=Object.freeze(["references","scripts","assets","templates","examples"]),s=e=>typeof e=="string"&&e.length>0,h=/^[a-z0-9][a-z0-9_-]*$/;function w(e){return!(!s(e)||e.length>255||e!==e.trim()||e.includes("\\")||/[\u0000-\u001f\u007f]/.test(e)||e.startsWith("/")||/^[a-z]:/i.test(e)||e.split("/").some(r=>r===""||r==="."||r===".."))}function f(e){const t=e?.origin;return t&&typeof t=="object"&&s(t.source)&&s(t.id)?t:null}function y(e){return f(e)?"community":e?.builtin?"built-in":"user"}function p(e){const t=e?.files,r={};if(!t||typeof t!="object")return r;for(const i of m){const n=Array.isArray(t[i])?t[i].filter(w):[];n.length&&(r[i]=[...new Set(n)])}return r}function g(e){return(p(e).scripts||[]).length>0}function E(e={}){const t=p(e),r=d.includes(e.mcpMode)?e.mcpMode:"none",i=S.includes(e.context)?e.context:"auto",n=l.includes(e.historyContext)?e.historyContext:"none",c=new Set(Array.isArray(e.reads)?e.reads.filter(b=>a.includes(b)):[]);return i!=="none"&&c.add("page"),(n==="chats"||n==="all")&&c.add("chats"),(n==="meetings"||n==="all")&&c.add("meetings"),{trust:y(e),reads:[...c].sort(),page:i,history:n,mcp:r,mcpServerIds:r==="selected"&&Array.isArray(e.mcpServerIds)?[...e.mcpServerIds]:[],sources:Array.isArray(e.sources)?[...new Set(e.sources.filter(s))].sort():[],surfaces:Array.isArray(e.surfaces)?[...new Set(e.surfaces.filter(s))].sort():[],scripts:t.scripts||[],needsBridge:g(e),meeting:!!e.meeting}}function v(e){const t=f(e);return t?t.id.length>48?`${t.source} · …${t.id.slice(-40)}`:`${t.source} · ${t.id}`:y(e)==="built-in"?"Built-in":"Written here"}function O(e,t){const r=f(e),i=f(t);return!!r&&!!i&&r.source===i.source&&r.id===i.id}function j(e,t){const r=f(e);return!r||!s(r.hash)||!s(t)?null:r.hash!==t}function P(e){if(!e||typeof e!="object")throw new o("SHAPE","skill must be an object");if(!s(e.id))throw new o("SHAPE","skill.id required");if(!s(e.name))throw new o("SHAPE",`skill '${e.id}': name required`);if(e.prompt!=null&&typeof e.prompt!="string")throw new o("SHAPE",`skill '${e.id}': prompt must be a string`);if(e.command!=null&&e.command!==""&&!h.test(String(e.command)))throw new o("SHAPE",`skill '${e.id}': command must match ${h}`);for(const[t,r]of[["context",S],["historyContext",l],["mcpMode",d]])if(e[t]!=null&&!r.includes(e[t]))throw new o("SHAPE",`skill '${e.id}': ${t} must be one of ${r}`);if(e.reads!=null&&(!Array.isArray(e.reads)||!e.reads.every(t=>a.includes(t))))throw new o("SHAPE",`skill '${e.id}': reads must be within ${a}`);for(const t of["sources","surfaces"])if(e[t]!=null&&(!Array.isArray(e[t])||!e[t].every(s)))throw new o("SHAPE",`skill '${e.id}': ${t} must be an array of ids`);if(e.origin!=null){const t=e.origin;if(typeof t!="object")throw new o("SHAPE",`skill '${e.id}': origin must be an object`);if(!s(t.source)||!s(t.id))throw new o("ORIGIN",`skill '${e.id}': origin needs both source and id`)}if(e.files!=null){if(typeof e.files!="object")throw new o("SHAPE",`skill '${e.id}': files must be an object`);for(const t of Object.keys(e.files)){if(!m.includes(t))throw new o("FILES",`skill '${e.id}': unknown file kind '${t}'`);const r=e.files[t];if(!Array.isArray(r)||!r.every(s))throw new o("FILES",`skill '${e.id}': files.${t} must be an array of paths`);const i=r.find(n=>!w(n));if(i)throw new o("PATH",`skill '${e.id}': unsafe path in files.${t}: ${JSON.stringify(i)}`)}}if(!f(e)&&(e.files?.scripts||[]).length)throw new o("ORIGIN",`skill '${e.id}': scripts require an origin to be scannable`);return e}const x=Object.freeze({1:e=>({...e,v:u})});function A(e){if(!e||typeof e!="object")throw new o("SHAPE","skill must be an object");let t=typeof e.v=="number"?e:{...e,v:1},r=0;for(;t.v<u;){const i=x[t.v];if(!i)throw new o("UPCAST",`no upcaster from v${t.v}`);if(t=i(t),++r>64)throw new o("UPCAST","upcaster chain did not terminate")}if(t.v>u)throw new o("UPCAST",`skill is v${t.v}; this reader only knows v${u}`);return t}function L(e){return(Array.isArray(e)?e:[]).map(A)}function M(e){if(!e||typeof e!="object")return e;const t={...A(e)};t.enabled=t.enabled!==!1;const r=String(t.mcpMode||"none").toLowerCase();t.mcpMode=d.includes(r)?r:"none";const i=Array.isArray(t.mcpServerIds)?t.mcpServerIds:[];t.mcpServerIds=t.mcpMode==="selected"?[...new Set(i.map(c=>String(c||"").trim()).filter(Boolean))]:[],delete t.trust;const n=f(t);n?(t.builtin=!1,t.origin={source:n.source,id:n.id,...s(n.url)?{url:n.url}:{},...s(n.hash)?{hash:n.hash}:{},...n.scanned&&typeof n.scanned=="object"?{scanned:{...n.scanned}}:{}}):t.origin!=null&&delete t.origin,t.reads!=null&&(t.reads=[...new Set((Array.isArray(t.reads)?t.reads:[]).filter(c=>a.includes(c)))].sort());for(const c of["sources","surfaces"])t[c]!=null&&(t[c]=[...new Set((Array.isArray(t[c])?t[c]:[]).filter(s))]);if(t.files!=null){const c=p(t);Object.keys(c).length?t.files=c:delete t.files}return t.version!=null&&!s(t.version)&&delete t.version,t}export{S as SKILL_CONTEXTS,m as SKILL_FILE_KINDS,l as SKILL_HISTORY_SCOPES,u as SKILL_MANIFEST_VERSION,d as SKILL_MCP_MODES,I as SKILL_TRUST,x as SKILL_UPCASTERS,o as SkillManifestError,E as declaredAccess,w as isSafeSkillPath,g as needsBridge,M as normalizeSkill,v as originLabel,f as originOf,O as sameSkillOrigin,p as skillFiles,j as skillIsStale,y as trustOf,A as upcastSkill,L as upcastSkills,P as validateSkill};
|
package/skill-scan.js
CHANGED
|
@@ -1,210 +1,4 @@
|
|
|
1
|
-
|
|
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.
|
|
1
|
+
class A extends Error{constructor(t,o){super(o),this.name="SkillScanError",this.code=t}}const g=1,_=Object.freeze(["clean","suspicious","dangerous"]),f={clean:0,suspicious:1,dangerous:2},p=/[----\u{e0000}-\u{e007f}]/u,y=[/\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions?|prompts?|rules?|directions?)\b/i,/\bdisregard\s+(?:all\s+|any\s+)?(?:previous|prior|the)\s+(?:instructions?|system\s+prompt|rules?)\b/i,/\b(?:forget|override)\s+(?:everything|all)\s+(?:you|above|previously)\b/i,/\byou\s+are\s+no\s+longer\s+(?:bound|restricted|required)\b/i,/<\/?(?:system|assistant)\b[^>]*>/i,/\bnew\s+system\s+prompt\s*:/i],v=[/\b(?:do\s+not|don't|never)\s+(?:tell|inform)\s+the\s+user\b(?!\s+to\s)/i,/\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,/\bwithout\s+(?:telling|informing|notifying)\s+the\s+user\b/i,/\bhide\s+(?:this|that|it)\s+from\s+the\s+user\b/i],w=[/~\/\.ssh\/|\bid_rsa\b|\bid_ed25519\b/i,/~\/\.aws\/credentials|\bAWS_SECRET_ACCESS_KEY\b/i,/\.env\b(?!\w)|\bprintenv\b|\benv\s*\|/i,/~\/\.netrc|\.git-credentials|\bkeychain\s+dump\b/i,/\bGITHUB_TOKEN\b|\bNPM_TOKEN\b|\bOPENAI_API_KEY\b|\bANTHROPIC_API_KEY\b/,/~\/\.config\/(?:gh|gcloud)\/|\bgcloud\s+auth\s+print-access-token\b/i],E=[/\bcurl\b[^\n|]*(?:-d|--data|--data-binary|-F|--form|-T|--upload-file)\b/i,/\bwget\b[^\n|]*--post-(?:data|file)\b/i,/\b(?:nc|netcat|ncat)\b\s+[\w.-]+\s+\d+/i,/\bfetch\s*\([^)]*method\s*:\s*['"]POST/i,/\brequests\.post\s*\(/i,/\|\s*(?:curl|nc|netcat)\b/i,/\bscp\b\s+\S+\s+\S+@/i],S=[{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"},{re:/\bmkfs(?:\.\w+)?\b/,why:"filesystem format"},{re:/\bdd\s+[^\n]*\bof=\/dev\/(?:sd|nvme|disk)/,why:"raw write to a block device"},{re:/:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/,why:"fork bomb"},{re:/\bchmod\s+-R\s+777\s+\//,why:"world-writable root"},{re:/\bhistory\s+-c\b|\bshred\b\s+[^\n]*\.(?:log|history)/,why:"covering tracks"}],x=[{re:/\b(?:eval|exec)\s*\(\s*(?:atob|base64|Buffer\.from)/i,why:"executes decoded content"},{re:/\bbase64\s+-d\b[^\n]*\|\s*(?:sh|bash|zsh|python)/i,why:"pipes decoded content to a shell"},{re:/\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i,why:"pipes a download straight into a shell"},{re:/[A-Za-z0-9+/]{280,}={0,2}/,why:"a large opaque base64 blob"},{re:/\bhttp:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[\w.-]+/i,why:"sends or fetches over plain HTTP"},{re:/\bsudo\s+(?:-S\s+)?[^\n]*<<</i,why:"feeds a password to sudo"}],k=(s,t)=>s.slice(0,t).split(`
|
|
2
|
+
`).length,N=(s,t,o=90)=>s.slice(Math.max(0,t-10),t+o).replace(/\s+/g," ").trim();function c(s,t){const o=t.exec(s);return o?{index:o.index,match:o[0]}:null}function C({name:s="",prompt:t="",files:o=[],extra:u=""}={}){const i=[String(t||""),String(u||"")].filter(Boolean).join(`
|
|
25
3
|
|
|
26
|
-
export
|
|
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
|
-
}
|
|
4
|
+
`),b=[],a=(e,r,n,m)=>{b.push({rule:e,severity:r,why:m,line:n?k(i,n.index):0,excerpt:n?N(i,n.index):""})};p.test(i)&&a("hidden-text","dangerous",c(i,p),"contains characters that are invisible to a reader but not to the model");for(const e of y){const r=c(i,e);if(r){a("instruction-override","dangerous",r,"tries to override the instructions it is running under");break}}for(const e of v){const r=c(i,e);if(r){a("concealment","suspicious",r,"asks the model to keep something from the user");break}}const d=w.map(e=>c(i,e)).find(Boolean),h=E.map(e=>c(i,e)).find(Boolean);d&&h?a("credential-exfiltration","dangerous",d,`names a credential (${d.match.trim().slice(0,40)}) alongside a command that sends data off the machine`):d?a("credential-mention","suspicious",d,"refers to a credential file or secret variable"):h&&a("outbound-data","suspicious",h,"sends data to a remote host");for(const{re:e,why:r}of S){const n=c(i,e);n&&a("destructive-command","dangerous",n,r)}for(const{re:e,why:r}of x){const n=c(i,e);n&&a("suspicious-pattern","suspicious",n,r)}const l=(Array.isArray(o)?o:[]).filter(e=>String(e).startsWith("scripts/"));return l.length&&b.push({rule:"ships-executable",severity:"suspicious",why:`ships ${l.length} executable file${l.length===1?"":"s"} (${l.join(", ")}) — these run on your machine, not in the browser`,line:0,excerpt:""}),{verdict:b.reduce((e,r)=>f[r.severity]>f[e]?r.severity:e,"clean"),findings:b,scanner:g,name:String(s||"")}}function I(s){return s!=="dangerous"}function R(s){if(!s||s.verdict==="clean")return"";const t=(s.findings||[]).filter(i=>i.severity===s.verdict),o=t[0],u=t.length>1?` (+${t.length-1} more)`:"";return o?`${o.why}${u}`:s.verdict}export{g as SCANNER_VERSION,_ as SCAN_VERDICTS,A as SkillScanError,I as admits,C as scanSkill,R as scanSummary};
|