dsh-plugin-inspector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Parsing a Cordis patch layer with the harness's own `!!js` dialect, and
3
+ * modelling it the way `applyEntryPatches` does.
4
+ *
5
+ * The dialect is transcribed from `dsh/scripts/verify-cordis-config.ts` and
6
+ * `dsh/vendor/include/src/index.ts`: `yaml.JSON_SCHEMA` extended with one
7
+ * scalar type for `tag:yaml.org,2002:js`, whose constructor produces an inert
8
+ * `{ __jsExpr }` node. **The expression text is never evaluated here.** Where
9
+ * the harness would call `new Function('ctx', 'expr', 'with (ctx) { return
10
+ * eval(expr) }')`, this module only ever compiles `return (expr)` to learn
11
+ * whether it parses, and parses it a second time with the TypeScript parser to
12
+ * classify what it reaches. Neither compiles-and-calls.
13
+ *
14
+ * js-yaml is pinned to the harness's `^4.2.0`. Parsing the same bytes
15
+ * differently from the runtime would make every downstream result unsound.
16
+ * @module dsh-plugin-inspector/cordis-yaml
17
+ */
18
+ import yaml from 'js-yaml';
19
+ import ts from 'typescript';
20
+ import { HARNESS_INERT_CALLS, STATIC_ENTRY_FIELDS } from "./knowledge.js";
21
+ /** Module specifiers whose `config` holds other rows rather than plugin data. */
22
+ const TREE_CARRIERS = new Set([
23
+ '@deepseek-ai/cordis-plugin-group',
24
+ '@deepseek-ai/cordis-plugin-include',
25
+ 'cordis:include',
26
+ ]);
27
+ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
28
+ kind: 'scalar',
29
+ resolve: data => typeof data === 'string',
30
+ construct: (data) => {
31
+ if (typeof data !== 'string')
32
+ throw new TypeError('!!js requires a scalar string');
33
+ return { __jsExpr: data };
34
+ },
35
+ });
36
+ /**
37
+ * The entry-list dialect: JSON schema plus `!!js`. Deliberately *not*
38
+ * `DEFAULT_SCHEMA`, which would also accept `!!python`, `!!binary` and friends
39
+ * — matching the harness exactly means a tag the harness rejects is rejected
40
+ * here too, and shows up as a finding rather than as parsed data.
41
+ */
42
+ export const patchSchema = yaml.JSON_SCHEMA.extend(jsExprType);
43
+ /** Every classification, in the order the report tallies them. */
44
+ export const EXPRESSION_CLASSES = [
45
+ 'literal', 'inert-read', 'harness-call', 'call', 'mutation', 'module-access', 'unparseable',
46
+ ];
47
+ /** Nodes one patch layer may be walked through before the walk gives up. */
48
+ export const MAX_WALK_NODES = 200_000;
49
+ /** Nesting one patch layer may reach before the walk gives up. */
50
+ export const MAX_WALK_DEPTH = 200;
51
+ /**
52
+ * Charge one node against the budget, and refuse a node already walked.
53
+ * @param budget - the shared budget.
54
+ * @param value - the node about to be walked.
55
+ * @param depth - the current nesting depth.
56
+ * @returns true when the walk may descend into this node.
57
+ */
58
+ function admit(budget, value, depth) {
59
+ if (budget.limit !== null)
60
+ return false;
61
+ if (depth > MAX_WALK_DEPTH) {
62
+ budget.limit = 'depth';
63
+ return false;
64
+ }
65
+ budget.nodes += 1;
66
+ if (budget.nodes > MAX_WALK_NODES) {
67
+ budget.limit = 'nodes';
68
+ return false;
69
+ }
70
+ if (typeof value !== 'object' || value === null)
71
+ return true;
72
+ if (budget.visited.has(value))
73
+ return false;
74
+ budget.visited.add(value);
75
+ return true;
76
+ }
77
+ /** Thrown when the patch file cannot be parsed as an entry list. */
78
+ export class PatchParseError extends Error {
79
+ /** True when the failure is a `!js` single-bang tag, which never loads anywhere. */
80
+ singleBangTag;
81
+ /**
82
+ * @param message - the underlying parse diagnostic.
83
+ * @param singleBangTag - whether the raw text contains a `!js` tag.
84
+ */
85
+ constructor(message, singleBangTag) {
86
+ super(message);
87
+ this.singleBangTag = singleBangTag;
88
+ }
89
+ }
90
+ /**
91
+ * Whether a value is a plain object. Patch rows are objects; anything else at a
92
+ * row position is malformed input from an untrusted file.
93
+ * @param value - the parsed value.
94
+ * @returns true for a non-null, non-array object.
95
+ */
96
+ function isRecord(value) {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
98
+ }
99
+ /**
100
+ * Whether a value is a `!!js` node. Matches the loader's own predicate.
101
+ * @param value - the parsed value.
102
+ * @returns true for an expression node.
103
+ */
104
+ export function isJsExpr(value) {
105
+ return isRecord(value) && typeof value.__jsExpr === 'string';
106
+ }
107
+ /**
108
+ * Classify what a `!!js` expression can reach, by parsing it — never running
109
+ * it. Precedence is by reach: module access beats mutation beats an unknown
110
+ * call beats a known-inert call beats a read, so an expression is reported at
111
+ * its most capable form.
112
+ *
113
+ * The grading is by reach, not by syntactic form. `dshHomePath('sessions')` is
114
+ * a `CallExpression` and so is `steal()`, but the first is a helper the harness
115
+ * itself provides to these expressions and uses in its own shipped bundle,
116
+ * while the second names something this tool cannot resolve. Grading both as
117
+ * the same thing puts the harness's own configuration at the same severity as
118
+ * an attack and teaches the reader to skip the class.
119
+ * @param expression - the raw expression text.
120
+ * @returns the classification and, when it does not parse, the diagnostic.
121
+ */
122
+ export function classifyExpression(expression) {
123
+ try {
124
+ // Compilation only. The Function constructor never executes the body, and
125
+ // the resulting function is discarded without being called.
126
+ new Function(`return (${expression})`);
127
+ }
128
+ catch (error) {
129
+ return { class: 'unparseable', parseError: error instanceof Error ? error.message : String(error) };
130
+ }
131
+ const source = ts.createSourceFile('expr.ts', `(${expression})`, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
132
+ let sawCall = false;
133
+ let sawHarnessCall = false;
134
+ let sawMutation = false;
135
+ let sawModuleAccess = false;
136
+ let sawIdentifier = false;
137
+ const visit = (node) => {
138
+ if (ts.isIdentifier(node) && MODULE_REACHING_NAMES.has(node.text))
139
+ sawModuleAccess = true;
140
+ if (ts.isPropertyAccessExpression(node) && MODULE_REACHING_NAMES.has(node.name.text))
141
+ sawModuleAccess = true;
142
+ if (node.kind === ts.SyntaxKind.ImportKeyword)
143
+ sawModuleAccess = true;
144
+ if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind))
145
+ sawMutation = true;
146
+ if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
147
+ if (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
148
+ sawMutation = true;
149
+ }
150
+ if (ts.isDeleteExpression(node))
151
+ sawMutation = true;
152
+ if (ts.isCallExpression(node)) {
153
+ if (isInertCall(node))
154
+ sawHarnessCall = true;
155
+ else
156
+ sawCall = true;
157
+ }
158
+ if (ts.isNewExpression(node))
159
+ sawCall = true;
160
+ if (ts.isIdentifier(node))
161
+ sawIdentifier = true;
162
+ ts.forEachChild(node, visit);
163
+ };
164
+ ts.forEachChild(source, visit);
165
+ if (sawModuleAccess)
166
+ return { class: 'module-access' };
167
+ if (sawMutation)
168
+ return { class: 'mutation' };
169
+ if (sawCall)
170
+ return { class: 'call' };
171
+ if (sawHarnessCall)
172
+ return { class: 'harness-call' };
173
+ if (sawIdentifier)
174
+ return { class: 'inert-read' };
175
+ return { class: 'literal' };
176
+ }
177
+ /** Names that reach the module system, the global object, or the evaluator. */
178
+ const MODULE_REACHING_NAMES = new Set([
179
+ 'require', 'eval', 'Function', 'globalThis', 'global', 'getBuiltinModule',
180
+ 'constructor', 'binding', 'dlopen', '__proto__',
181
+ ]);
182
+ /** Binary operator kinds that write. */
183
+ const ASSIGNMENT_OPERATORS = new Set([
184
+ ts.SyntaxKind.EqualsToken,
185
+ ts.SyntaxKind.PlusEqualsToken,
186
+ ts.SyntaxKind.MinusEqualsToken,
187
+ ts.SyntaxKind.AsteriskEqualsToken,
188
+ ts.SyntaxKind.SlashEqualsToken,
189
+ ts.SyntaxKind.QuestionQuestionEqualsToken,
190
+ ts.SyntaxKind.BarBarEqualsToken,
191
+ ts.SyntaxKind.AmpersandAmpersandEqualsToken,
192
+ ]);
193
+ /**
194
+ * Calls the harness itself puts in scope for these expressions, or that only
195
+ * read the current process's own identity — `cwd: !!js process.cwd()` and
196
+ * `root: !!js dshHomePath('sessions')` are both from the shipped bundles.
197
+ * Grading these as an unknown call would fire on the reference configuration.
198
+ * @param node - the call expression.
199
+ * @returns true when the callee is a catalogued harness or process helper.
200
+ */
201
+ function isInertCall(node) {
202
+ const callee = node.expression;
203
+ if (ts.isIdentifier(callee))
204
+ return HARNESS_INERT_CALLS.has(callee.text);
205
+ if (!ts.isPropertyAccessExpression(callee))
206
+ return false;
207
+ if (!ts.isIdentifier(callee.expression))
208
+ return false;
209
+ return HARNESS_INERT_CALLS.has(`${callee.expression.text}.${callee.name.text}`);
210
+ }
211
+ /**
212
+ * Collect every `!!js` node below a value, with its diagnostic path. Mirrors
213
+ * `collectExpressionPaths` in the harness's verify script.
214
+ * @param value - the value to walk.
215
+ * @param path - the diagnostic path prefix.
216
+ * @param slot - the slot these expressions occupy.
217
+ * @param sink - accumulators, including the walk budget.
218
+ * @param depth - the current nesting depth.
219
+ */
220
+ function collect(value, path, slot, sink, depth) {
221
+ if (!admit(sink.budget, value, depth))
222
+ return;
223
+ if (isJsExpr(value)) {
224
+ const classified = classifyExpression(value.__jsExpr);
225
+ sink.expressions.push({
226
+ path,
227
+ expression: value.__jsExpr,
228
+ slot,
229
+ classification: classified.class,
230
+ ...classified.parseError !== undefined ? { parseError: classified.parseError } : {},
231
+ });
232
+ return;
233
+ }
234
+ if (Array.isArray(value)) {
235
+ for (const [index, item] of value.entries())
236
+ collect(item, `${path}[${index}]`, slot, sink, depth + 1);
237
+ return;
238
+ }
239
+ if (!isRecord(value))
240
+ return;
241
+ for (const [key, child] of Object.entries(value))
242
+ collect(child, `${path}.${key}`, slot, sink, depth + 1);
243
+ }
244
+ /**
245
+ * Collect the expressions of one row or patch, applying the loader's rule:
246
+ * `config` is interpolated recursively, `disabled` only at its own top level,
247
+ * and every other field stays literal so an expression there is inert data.
248
+ * @param entry - the row or patch object.
249
+ * @param path - its diagnostic path.
250
+ * @param sink - accumulators, including the walk budget.
251
+ * @param configIsData - false when `config` holds child rows rather than plugin data.
252
+ * @param depth - the current nesting depth.
253
+ */
254
+ function collectEntryExpressions(entry, path, sink, configIsData, depth) {
255
+ if (configIsData && 'config' in entry)
256
+ collect(entry.config, `${path}.config`, 'config', sink, depth + 1);
257
+ if ('disabled' in entry) {
258
+ const disabled = entry.disabled;
259
+ const slot = isJsExpr(disabled) ? 'disabled' : 'inert';
260
+ collect(disabled, `${path}.disabled`, slot, sink, depth + 1);
261
+ }
262
+ for (const field of STATIC_ENTRY_FIELDS) {
263
+ if (field in entry)
264
+ collect(entry[field], `${path}.${field}`, 'inert', sink, depth + 1);
265
+ }
266
+ }
267
+ /**
268
+ * The service names an `isolate` or `intercept` entry field names. The loader
269
+ * reads both as a dictionary keyed by service name (`entry.options.isolate?.[name]`).
270
+ * @param value - the raw field value.
271
+ * @returns the service names, or an empty list when the field is absent or malformed.
272
+ */
273
+ function serviceNames(value) {
274
+ return isRecord(value) ? Object.keys(value) : [];
275
+ }
276
+ /**
277
+ * Whether a row carries other rows in its `config` rather than plugin data.
278
+ * The loader keeps such a config literal and evaluates each child expression in
279
+ * the child's own fiber instead.
280
+ * @param entry - the row.
281
+ * @returns true for a group or include carrier.
282
+ */
283
+ function isTreeCarrier(entry) {
284
+ return entry.group === true || (typeof entry.name === 'string' && TREE_CARRIERS.has(entry.name));
285
+ }
286
+ /**
287
+ * Walk one inserted row and, when it carries children, its children too.
288
+ * @param value - the row.
289
+ * @param path - its diagnostic path.
290
+ * @param intoGroupId - the group this row is inserted into, or `null`.
291
+ * @param sink - accumulators.
292
+ * @param depth - the current nesting depth.
293
+ */
294
+ function walkRow(value, path, intoGroupId, sink, depth) {
295
+ if (!isRecord(value))
296
+ return;
297
+ if (!admit(sink.budget, value, depth))
298
+ return;
299
+ const carrier = isTreeCarrier(value);
300
+ sink.inserts.push({
301
+ path,
302
+ id: typeof value.id === 'string' ? value.id : null,
303
+ name: typeof value.name === 'string' ? value.name : null,
304
+ config: value.config,
305
+ intoGroupId,
306
+ isolate: serviceNames(value.isolate),
307
+ intercept: serviceNames(value.intercept),
308
+ });
309
+ collectEntryExpressions(value, path, sink, !carrier, depth);
310
+ if (!carrier)
311
+ return;
312
+ const config = value.config;
313
+ if (Array.isArray(config)) {
314
+ for (const [index, child] of config.entries()) {
315
+ walkRow(child, `${path}.config[${index}]`, typeof value.id === 'string' ? value.id : null, sink, depth + 1);
316
+ }
317
+ return;
318
+ }
319
+ if (isRecord(config) && Array.isArray(config.patches)) {
320
+ walkPatchList(config.patches, `${path}.config.patches`, sink, depth + 1);
321
+ }
322
+ }
323
+ /**
324
+ * Walk a patch list, separating inserts from overrides exactly as
325
+ * `applyEntryPatches` does: `insert` is checked first and wins outright, so a
326
+ * patch carrying both `insert` and override keys applies only the insert.
327
+ * @param list - the patch array.
328
+ * @param prefix - diagnostic path prefix.
329
+ * @param sink - accumulators.
330
+ * @param depth - the current nesting depth.
331
+ */
332
+ function walkPatchList(list, prefix, sink, depth) {
333
+ for (const [index, patch] of list.entries()) {
334
+ const path = `${prefix}[${index}]`;
335
+ if (!isRecord(patch))
336
+ continue;
337
+ if (!admit(sink.budget, patch, depth))
338
+ continue;
339
+ const id = typeof patch.id === 'string' ? patch.id : null;
340
+ if (Array.isArray(patch.insert)) {
341
+ for (const [rowIndex, row] of patch.insert.entries()) {
342
+ walkRow(row, `${path}.insert[${rowIndex}]`, id, sink, depth + 1);
343
+ }
344
+ continue;
345
+ }
346
+ if (id === null)
347
+ continue;
348
+ const overriddenKeys = Object.keys(patch).filter(key => key !== 'id' && key !== 'insert' && key !== 'name');
349
+ sink.overrides.push({
350
+ path,
351
+ id,
352
+ nameGuard: typeof patch.name === 'string' ? patch.name : null,
353
+ overriddenKeys,
354
+ disabled: 'disabled' in patch ? patch.disabled : undefined,
355
+ config: patch.config,
356
+ });
357
+ collectEntryExpressions(patch, path, sink, true, depth);
358
+ }
359
+ }
360
+ /**
361
+ * Parse a patch layer.
362
+ * @param file - package-relative path, used in findings.
363
+ * @param text - the YAML text.
364
+ * @returns the modelled patch document.
365
+ * @throws PatchParseError when the text is not a loadable entry list.
366
+ */
367
+ export function parsePatchDocument(file, text) {
368
+ let document;
369
+ try {
370
+ document = yaml.load(text, { schema: patchSchema });
371
+ }
372
+ catch (error) {
373
+ const message = error instanceof Error ? error.message : String(error);
374
+ throw new PatchParseError(message, /(?<!!)!js(?![a-zA-Z0-9_-])/.test(text));
375
+ }
376
+ if (!Array.isArray(document)) {
377
+ throw new PatchParseError('a patch layer must be a top-level array of entries', false);
378
+ }
379
+ const sink = {
380
+ overrides: [],
381
+ inserts: [],
382
+ expressions: [],
383
+ budget: { visited: new WeakSet(), nodes: 0, limit: null },
384
+ };
385
+ walkPatchList(document, '', sink, 0);
386
+ return {
387
+ file,
388
+ overrides: sink.overrides,
389
+ inserts: sink.inserts,
390
+ expressions: sink.expressions,
391
+ limit: sink.budget.limit,
392
+ };
393
+ }
package/lib/files.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Classifying the files inside an analysed package, and formatting excerpts of
3
+ * them for evidence.
4
+ * @module dsh-plugin-inspector/files
5
+ */
6
+ /** Extensions the TypeScript parser is asked to read. */
7
+ const SOURCE_EXTENSIONS = [
8
+ '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs',
9
+ ];
10
+ /** Longest evidence excerpt kept in a finding. */
11
+ const SNIPPET_LIMIT = 160;
12
+ /**
13
+ * Whether a path is JavaScript or TypeScript source this tool will parse.
14
+ * Declaration files are excluded: they carry types, never behavior.
15
+ * @param path - package-relative path.
16
+ * @returns true when the file should be parsed.
17
+ */
18
+ export function isSourceFile(path) {
19
+ if (path.endsWith('.d.ts') || path.endsWith('.d.mts') || path.endsWith('.d.cts'))
20
+ return false;
21
+ return SOURCE_EXTENSIONS.some(extension => path.endsWith(extension));
22
+ }
23
+ /**
24
+ * Whether a path is markdown that can reach the model verbatim.
25
+ *
26
+ * The reach is conditional and PLAN.md §6.1 says so: a `SKILL.md` inside an npm
27
+ * package is only discovered when the plugin registers it through
28
+ * `ctx.skills`, when a patch row redirects a skill root into the package, or
29
+ * when something copies it into the user's workspace. This predicate answers
30
+ * "is this the kind of file that would reach the model if it were found", not
31
+ * "will it be found".
32
+ * @param path - package-relative POSIX path.
33
+ * @returns true for skill and agent-instruction markdown.
34
+ */
35
+ export function isModelVisibleText(path) {
36
+ const segments = path.split('/');
37
+ const base = segments.at(-1) ?? '';
38
+ if (base === 'SKILL.md' || base === 'AGENTS.md' || base === 'CLAUDE.md')
39
+ return true;
40
+ // The filesystem provider scans a skill root at depth 1: `<root>/<name>.md`
41
+ // or `<root>/<name>/SKILL.md`. The bare `.md` form is only a skill when it
42
+ // sits directly inside a directory named `skills`.
43
+ return segments.length >= 2 && segments.at(-2) === 'skills' && base.endsWith('.md');
44
+ }
45
+ /**
46
+ * Whether a path is a Cordis config file, using the harness's own naming
47
+ * convention from `scripts/cordis-config-files.ts`.
48
+ * @param path - package-relative POSIX path.
49
+ * @returns true for a cordis YAML file.
50
+ */
51
+ export function isCordisConfigFile(path) {
52
+ const base = path.split('/').at(-1) ?? '';
53
+ return /cordis/.test(base) && (base.endsWith('.yml') || base.endsWith('.yaml'));
54
+ }
55
+ /**
56
+ * Resolve a manifest-declared path to the package-relative POSIX form used as
57
+ * map keys, or report that it leaves the package.
58
+ *
59
+ * Only `..` escapes. An absolute path does **not**: the launcher resolves the
60
+ * patch as `join(packageDir, declared)` (`packages/boot/app-boot/src/profile.ts`),
61
+ * and `join` re-roots an absolute second argument *inside* the first, so
62
+ * `join('/…/pkg', '/etc/passwd')` is `/…/pkg/etc/passwd`. A leading slash
63
+ * therefore names a file the package does not ship, not a file outside it.
64
+ * @param declared - the path exactly as the manifest declares it.
65
+ * @returns the normalised package-relative path, or `null` when it escapes.
66
+ */
67
+ export function normalizePackagePath(declared) {
68
+ const segments = [];
69
+ for (const segment of declared.replace(/^[a-zA-Z]:/, '').split(/[\\/]/)) {
70
+ if (segment === '' || segment === '.')
71
+ continue;
72
+ if (segment === '..') {
73
+ if (segments.length === 0)
74
+ return null;
75
+ segments.pop();
76
+ continue;
77
+ }
78
+ segments.push(segment);
79
+ }
80
+ return segments.join('/');
81
+ }
82
+ /**
83
+ * Render a value as JSON-like evidence without walking a graph as a tree.
84
+ * A YAML anchor makes one node reachable by many paths, so `JSON.stringify` on
85
+ * a patch row's `config` can be asked to serialise billions of nodes from a
86
+ * few hundred bytes of input. This stops at a depth and a length instead.
87
+ * @param value - the value to describe.
88
+ * @param maxDepth - how far to descend before writing an ellipsis.
89
+ * @param limit - the longest string to return.
90
+ * @returns the bounded rendering.
91
+ */
92
+ export function boundedJson(value, maxDepth = 4, limit = SNIPPET_LIMIT * 2) {
93
+ const seen = new WeakSet();
94
+ const render = (node, depth) => {
95
+ if (node === null || typeof node !== 'object')
96
+ return JSON.stringify(node) ?? 'null';
97
+ if (seen.has(node))
98
+ return '"…(repeated)"';
99
+ if (depth >= maxDepth)
100
+ return Array.isArray(node) ? '[…]' : '{…}';
101
+ seen.add(node);
102
+ if (Array.isArray(node))
103
+ return `[${node.slice(0, 8).map(item => render(item, depth + 1)).join(',')}]`;
104
+ const entries = Object.entries(node).slice(0, 16);
105
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${render(item, depth + 1)}`).join(',')}}`;
106
+ };
107
+ const text = render(value, 0);
108
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
109
+ }
110
+ /**
111
+ * Reduce text to a single-line excerpt safe to print in a report.
112
+ * @param text - the source text.
113
+ * @param limit - maximum characters to keep.
114
+ * @returns the collapsed, truncated excerpt.
115
+ */
116
+ export function snippet(text, limit = SNIPPET_LIMIT) {
117
+ const collapsed = text.replace(/\s+/g, ' ').trim();
118
+ return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
119
+ }
120
+ /**
121
+ * Convert a character offset to a `line:column` locator, both 1-based.
122
+ * @param text - the file text.
123
+ * @param offset - the character offset.
124
+ * @returns the locator.
125
+ */
126
+ export function lineColumn(text, offset) {
127
+ const before = text.slice(0, offset);
128
+ const line = before.split('\n').length;
129
+ const column = offset - (before.lastIndexOf('\n') + 1) + 1;
130
+ return `${line}:${column}`;
131
+ }
package/lib/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `dsh-plugin-inspector` — static pre-install analysis of a DeepSeek Harness
3
+ * plugin.
4
+ *
5
+ * The library face of the tool, for callers that want the report rather than
6
+ * the exit code. {@link inspect} decodes a plugin directory or npm tarball,
7
+ * runs the three check tiers over the decoded form, and returns a
8
+ * {@link Report}. It never installs, builds, imports, spawns, or evaluates
9
+ * anything from the analysed package.
10
+ *
11
+ * The ceiling is triage, not containment. See `README.md` §Limitations.
12
+ * @module dsh-plugin-inspector
13
+ */
14
+ export { exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
15
+ export { renderHuman, renderJson } from "./report.js";
16
+ export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, } from "./cordis-yaml.js";
17
+ export { declaredPackages, ManifestError, parseManifest } from "./manifest.js";
18
+ export { DEFAULT_LIMITS, loadSource, SourceError } from "./source.js";
19
+ export { globMatch, publishSet } from "./publish.js";
20
+ export { INJECTION_RULES, scanInjection } from "./injection.js";
21
+ export { compareFindings, SEVERITIES, SEVERITY_RANK, summarize, } from "./model.js";
22
+ export { CORE_ROWS, CORE_ROW_IDS, HARNESS_BUNDLE_PACKAGES, HARNESS_REFERENCE, SEAM_KEYS, SECURITY_ROW_IDS, WATERFALL_EVENTS, } from "./knowledge.js";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Prompt-injection heuristics, applied to model-visible text only.
3
+ *
4
+ * "Model-visible" is a narrow set on purpose: shipped skill and
5
+ * agent-instruction markdown, and the `description` string of a registered
6
+ * tool. Those reach the model verbatim, unescaped and uncapped. Ordinary source
7
+ * comments do not, and scanning them would produce a stream of false positives
8
+ * from documentation that happens to quote an attack.
9
+ *
10
+ * These are heuristics over natural language. They will miss a rephrased
11
+ * instruction and they will occasionally fire on a legitimate document that
12
+ * discusses prompt injection. Both directions are stated in the finding.
13
+ * @module dsh-plugin-inspector/injection
14
+ */
15
+ /**
16
+ * The rule table. Each pattern targets an instruction that only makes sense if
17
+ * the author expects a model rather than a person to read it.
18
+ */
19
+ export const INJECTION_RULES = [
20
+ {
21
+ id: 'override-prior-instructions',
22
+ pattern: /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all)\b[^.\n]{0,20}\b(?:instruction|prompt|rule|direction|guideline)/i,
23
+ meaning: 'tells the model to discard instructions it was already given',
24
+ },
25
+ {
26
+ id: 'role-reassignment',
27
+ pattern: /\b(?:you are now|from now on,? you (?:are|will|must|should)|your new (?:role|task|instruction))\b/i,
28
+ meaning: 'reassigns the model\'s role',
29
+ },
30
+ {
31
+ id: 'conceal-from-user',
32
+ pattern: /\b(?:do not|don't|never)\b[^.\n]{0,30}\b(?:tell|inform|mention|reveal|show|display|report)\b[^.\n]{0,20}\buser\b/i,
33
+ meaning: 'instructs the model to hide something from the user',
34
+ },
35
+ {
36
+ id: 'bypass-approval',
37
+ pattern: /\bwithout\b[^.\n]{0,30}\b(?:asking|confirming|approval|permission|prompting)\b/i,
38
+ meaning: 'instructs the model to act without the approval step',
39
+ },
40
+ {
41
+ id: 'credential-exfiltration',
42
+ pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|\.env|id_rsa|\.npmrc)\b/i,
43
+ meaning: 'instructs the model to move a credential somewhere',
44
+ },
45
+ {
46
+ id: 'system-prompt-disclosure',
47
+ pattern: /\b(?:reveal|print|output|repeat|show|dump)\b[^.\n]{0,30}\b(?:system prompt|initial instructions|your instructions)\b/i,
48
+ meaning: 'asks the model to disclose its system prompt',
49
+ },
50
+ {
51
+ id: 'pipe-to-shell',
52
+ pattern: /\b(?:curl|wget)\b[^\n|]{0,120}\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/i,
53
+ meaning: 'contains a download-and-execute shell pipeline',
54
+ },
55
+ {
56
+ id: 'encoded-payload',
57
+ pattern: /\b(?:base64\s+(?:-d|--decode)|atob\s*\(|echo\s+[A-Za-z0-9+/]{40,}={0,2}\s*\|)/,
58
+ meaning: 'contains an encoded payload the reader cannot evaluate',
59
+ },
60
+ {
61
+ id: 'hidden-characters',
62
+ pattern: /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]|[\u{E0000}-\u{E007F}]/u,
63
+ meaning: 'contains zero-width or bidirectional-control characters, which change what a human reader sees but not what the model reads',
64
+ },
65
+ {
66
+ id: 'hidden-html-instruction',
67
+ pattern: /<!--[^]{0,400}?\b(?:you (?:must|should|are)|instruction|assistant|ignore)\b[^]{0,400}?-->/i,
68
+ meaning: 'hides an instruction inside an HTML comment, invisible in rendered markdown',
69
+ },
70
+ ];
71
+ /**
72
+ * Scan model-visible text for injection phrasing.
73
+ * @param text - the text a model would receive.
74
+ * @returns one match per rule that fired, at its first occurrence.
75
+ */
76
+ export function scanInjection(text) {
77
+ const matches = [];
78
+ for (const rule of INJECTION_RULES) {
79
+ const found = rule.pattern.exec(text);
80
+ if (found === null)
81
+ continue;
82
+ matches.push({
83
+ ruleId: rule.id,
84
+ meaning: rule.meaning,
85
+ index: found.index,
86
+ excerpt: text.slice(Math.max(0, found.index - 20), found.index + found[0].length + 20),
87
+ });
88
+ }
89
+ return matches;
90
+ }