dsh-plugin-inspector 0.5.0 → 0.7.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.
@@ -16,6 +16,7 @@ import ts from 'typescript';
16
16
  import { lineColumn, snippet } from "../files.js";
17
17
  import { scanInjection } from "../injection.js";
18
18
  import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, } from "../knowledge.js";
19
+ import { foldConstantString, isBuiltinModuleGetter } from "../syntax.js";
19
20
  /** Global functions that fetch over the network without any `ctx` service. */
20
21
  const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
21
22
  /** `process.env` keys whose names say they hold a secret. */
@@ -53,6 +54,13 @@ export function matchesCredentialPath(text) {
53
54
  const DYNAMIC_CODE_CALLEES = new Set([
54
55
  'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
55
56
  ]);
57
+ /**
58
+ * The harness's own tool-definition helper, exported from
59
+ * `@deepseek-ai/dsh-tools`. Every registered tool in the harness is built by
60
+ * either calling it or handing `tools.register` a literal, so recognising the
61
+ * two shapes is what tells a tool `description` from every other kind.
62
+ */
63
+ const TOOL_DEFINITION_HELPER = 'defineTool';
56
64
  /** `ctx.systemPrompt` members that change what the model is told. */
57
65
  const SYSTEM_PROMPT_MEMBERS = new Set([
58
66
  'section', 'context', 'variable', 'tools', 'suppressRuntimeContext',
@@ -67,18 +75,18 @@ function tierB(finding) {
67
75
  return { ...finding, tier: 'B', confidence: 'high', examples: [finding.evidence], occurrences: 1 };
68
76
  }
69
77
  /**
70
- * The literal text of a string argument, or `null` when it is computed.
71
- * A computed argument is not a Tier B miss to paper over — it is a Tier C
72
- * signal, and `tier-c.ts` records it.
78
+ * The text a string argument holds, folding the constant forms a `+` chain of
79
+ * literals, a template whose spans are literals, `[…].join(…)` over literals.
80
+ *
81
+ * Folding is bounded on purpose. An argument this cannot resolve is not a
82
+ * Tier B miss to paper over: it is a Tier C signal, and `tier-c.ts` records it
83
+ * by asking the same folder, so a site is either matched here or degraded
84
+ * there and never both.
73
85
  * @param node - the argument expression.
74
- * @returns the literal text, or `null`.
86
+ * @returns the text, or `null`.
75
87
  */
76
88
  function literalText(node) {
77
- if (node === undefined)
78
- return null;
79
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
80
- return node.text;
81
- return null;
89
+ return foldConstantString(node);
82
90
  }
83
91
  /**
84
92
  * Strip the `node:` prefix so `node:fs` and `fs` compare equal.
@@ -89,9 +97,13 @@ function bareModule(specifier) {
89
97
  return specifier.startsWith('node:') ? specifier.slice(5) : specifier;
90
98
  }
91
99
  /**
92
- * Every module specifier the file imports or requires, as literal text.
100
+ * Every module the file reaches by a name this tool can resolve.
101
+ *
102
+ * Three ways in, not two. `import` and `require` are the declarations a reader
103
+ * looks for; `process.getBuiltinModule('node:fs')` is a third that needs
104
+ * neither, returns the same module object, and appears in no import list.
93
105
  * @param file - the parsed file.
94
- * @returns specifier text paired with the node it came from.
106
+ * @returns one entry per resolved reference.
95
107
  */
96
108
  function moduleSpecifiers(file) {
97
109
  const found = [];
@@ -100,15 +112,16 @@ function moduleSpecifiers(file) {
100
112
  const text = literalText(node.moduleSpecifier);
101
113
  /* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
102
114
  if (text !== null)
103
- found.push({ specifier: text, node });
115
+ found.push({ specifier: text, node, via: 'import' });
104
116
  }
105
117
  if (ts.isCallExpression(node)) {
106
118
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
107
119
  const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
108
- if (isRequire || isImport) {
120
+ const isBuiltin = isBuiltinModuleGetter(node);
121
+ if (isRequire || isImport || isBuiltin) {
109
122
  const text = literalText(node.arguments[0]);
110
123
  if (text !== null)
111
- found.push({ specifier: text, node });
124
+ found.push({ specifier: text, node, via: isBuiltin ? 'builtin-getter' : 'import' });
112
125
  }
113
126
  }
114
127
  ts.forEachChild(node, visit);
@@ -129,9 +142,25 @@ function at(file, node) {
129
142
  snippet: snippet(file.text.slice(node.getStart(file.node), node.end)),
130
143
  };
131
144
  }
132
- /** B9, B7, B13 — what the file imports. */
145
+ /**
146
+ * How a finding names the way a module was reached.
147
+ *
148
+ * `Imports` would be false of `process.getBuiltinModule('node:fs')`, and the
149
+ * difference is the point of covering it: the module arrives with no import
150
+ * declaration and no `require` for a reader to find.
151
+ * @param reference - the resolved module reference.
152
+ * @returns the opening clause of the finding's title.
153
+ */
154
+ function reachedBy(reference) {
155
+ return reference.via === 'import'
156
+ ? `Imports \`${reference.specifier}\``
157
+ : `Loads \`${reference.specifier}\` through \`process.getBuiltinModule\``;
158
+ }
159
+ /** B9, B7, B13 — what modules the file reaches. */
133
160
  function checkImports(file, accumulator) {
134
- for (const { specifier, node } of moduleSpecifiers(file)) {
161
+ for (const reference of moduleSpecifiers(file)) {
162
+ const { specifier, node } = reference;
163
+ const reached = reachedBy(reference);
135
164
  const bare = bareModule(specifier);
136
165
  const unmediated = UNMEDIATED_PROCESS_MODULES.get(bare);
137
166
  if (unmediated !== undefined) {
@@ -143,12 +172,13 @@ function checkImports(file, accumulator) {
143
172
  // reads a credential or reaches the network. On its own it is a
144
173
  // capability half the ecosystem has.
145
174
  severity: 'medium',
146
- title: `Imports \`${specifier}\`, which ${unmediated}`,
175
+ title: `${reached}, which ${unmediated}`,
147
176
  detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
148
177
  + 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
149
178
  + 'bundle layer gets no such restriction, so this import does exactly what the harness forbids elsewhere.',
150
179
  evidence: at(file, node),
151
- bypass: 'a computed specifier — `await import(["node","child_process"].join(":"))` is not matched, which is why C2 downgrades every Tier B negative',
180
+ bypass: 'a specifier this tool cannot fold to a constant — `import(name)` against a binding which C2 '
181
+ + 'reports, so the negative degrades rather than passing quietly',
152
182
  }));
153
183
  }
154
184
  if (NETWORK_MODULES.has(bare)) {
@@ -157,11 +187,11 @@ function checkImports(file, accumulator) {
157
187
  name: 'network-egress',
158
188
  subject: specifier,
159
189
  severity: 'medium',
160
- title: `Imports \`${specifier}\`, which can move bytes off the machine`,
190
+ title: `${reached}, which can move bytes off the machine`,
161
191
  detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
162
192
  + 'declared reason. It is recorded because paired with a credential read it becomes B8.',
163
193
  evidence: at(file, node),
164
- bypass: 'a computed specifier, or a transitive dependency doing the request on this package\'s behalf',
194
+ bypass: 'a transitive dependency doing the request on this package\'s behalf',
165
195
  });
166
196
  accumulator.findings.push(finding);
167
197
  accumulator.networkCall ??= finding;
@@ -172,12 +202,12 @@ function checkImports(file, accumulator) {
172
202
  name: 'unmediated-filesystem',
173
203
  subject: specifier,
174
204
  severity: 'medium',
175
- title: `Imports \`${specifier}\` rather than using the \`ctx.fs\` service`,
205
+ title: `${reached} rather than using the \`ctx.fs\` service`,
176
206
  detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
177
207
  + '`fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and '
178
208
  + 'nothing appears in the session log.',
179
209
  evidence: at(file, node),
180
- bypass: 'a computed specifier, or `process.getBuiltinModule("node:fs")`',
210
+ bypass: 'a transitive dependency reading the file on this package\'s behalf',
181
211
  }));
182
212
  }
183
213
  }
@@ -203,7 +233,8 @@ function checkSeamReplacement(file, node, accumulator) {
203
233
  + 'package\'s implementation for every consumer in the scope, and consumers cannot tell the difference.'
204
234
  + (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
205
235
  evidence: at(file, node),
206
- bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched",
236
+ bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched — and neither is a "
237
+ + '`provide` destructured off `ctx` and called through the bare name. C2 reports both',
207
238
  }));
208
239
  }
209
240
  /** B5 — changing what the model is told. */
@@ -345,11 +376,75 @@ function checkCredentialRead(file, node, accumulator) {
345
376
  detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
346
377
  + 'must do it. It is recorded because paired with network access it becomes B8.',
347
378
  evidence: at(file, node),
348
- bypass: 'a computed key `process.env["API"+"_KEY"]` or reading the whole `process.env` object and indexing it later',
379
+ bypass: 'a key this tool cannot fold to a constant, or reading the whole `process.env` object and indexing it later',
349
380
  });
350
381
  accumulator.findings.push(finding);
351
382
  accumulator.credentialRead ??= finding;
352
383
  }
384
+ /**
385
+ * Whether a call hands its arguments to the tool registry: the registry call
386
+ * itself, `<ctx>.tools.register(…)`, or the harness's `defineTool(…)` helper,
387
+ * whose argument is a tool definition and nothing else.
388
+ * @param node - the call expression.
389
+ * @returns true when its arguments are tool definitions.
390
+ */
391
+ function isToolRegistration(node) {
392
+ const callee = node.expression;
393
+ if (ts.isIdentifier(callee))
394
+ return callee.text === TOOL_DEFINITION_HELPER;
395
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register'
396
+ && ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'tools';
397
+ }
398
+ /**
399
+ * Whether a name bound in this file is passed to a tool registration call, so
400
+ * a definition built as `const tool = {…}` and registered on a later line is
401
+ * still recognised as one.
402
+ * @param name - the bound identifier.
403
+ * @param file - the parsed file it was bound in.
404
+ * @returns true when a registration call in the same file receives it.
405
+ */
406
+ function isRegisteredName(name, file) {
407
+ let registered = false;
408
+ const visit = (node) => {
409
+ if (ts.isCallExpression(node) && isToolRegistration(node)
410
+ && node.arguments.some(argument => ts.isIdentifier(argument) && argument.text === name)) {
411
+ registered = true;
412
+ }
413
+ ts.forEachChild(node, visit);
414
+ };
415
+ ts.forEachChild(file.node, visit);
416
+ return registered;
417
+ }
418
+ /**
419
+ * Whether a `description` property belongs to a tool definition this package
420
+ * registers.
421
+ *
422
+ * The receiver guard is the whole check. `description` is one of the commonest
423
+ * property names in JavaScript — a JSON schema, an OpenAPI document, a
424
+ * changelog entry and a CLI option table all carry one — and none of that text
425
+ * reaches a model. Without the guard the injection heuristics run on release
426
+ * notes, and the finding's title then asserts something about a tool that the
427
+ * package does not have.
428
+ *
429
+ * Nested properties count, because the whole definition is model-visible: a
430
+ * parameter's `description` is rendered into the tool schema the model
431
+ * receives alongside the tool's own.
432
+ * @param node - the `description` property assignment.
433
+ * @param file - the parsed file it came from.
434
+ * @returns true when an enclosing object literal is a registered tool definition.
435
+ */
436
+ function isRegisteredToolDescription(node, file) {
437
+ let parent = node.parent;
438
+ for (;;) {
439
+ if (ts.isCallExpression(parent))
440
+ return isToolRegistration(parent);
441
+ if (ts.isVariableDeclaration(parent))
442
+ return ts.isIdentifier(parent.name) && isRegisteredName(parent.name.text, file);
443
+ if (!ts.isObjectLiteralExpression(parent) && !ts.isPropertyAssignment(parent))
444
+ return false;
445
+ parent = parent.parent;
446
+ }
447
+ }
353
448
  /** B10 — injection phrasing in a registered tool description. */
354
449
  function checkToolDescription(file, node, accumulator) {
355
450
  if (!ts.isPropertyAssignment(node))
@@ -359,6 +454,8 @@ function checkToolDescription(file, node, accumulator) {
359
454
  const text = literalText(node.initializer);
360
455
  if (text === null)
361
456
  return;
457
+ if (!isRegisteredToolDescription(node, file))
458
+ return;
362
459
  for (const match of scanInjection(text)) {
363
460
  accumulator.findings.push(tierB({
364
461
  checkId: 'B10',
@@ -366,11 +463,14 @@ function checkToolDescription(file, node, accumulator) {
366
463
  subject: match.ruleId,
367
464
  severity: 'high',
368
465
  title: `Tool description ${match.meaning}`,
369
- detail: `Heuristic \`${match.ruleId}\` matched a tool \`description\`, which is prompt text the model receives `
370
- + 'verbatim on every request that lists the tool. This is a natural-language heuristic: it will miss a '
371
- + 'rephrasing, and it can fire on a description that legitimately discusses the subject.',
466
+ detail: `Heuristic \`${match.ruleId}\` matched a \`description\` inside a registered tool definition, which is `
467
+ + 'prompt text the model receives verbatim on every request that lists the tool. This is a natural-language '
468
+ + 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
469
+ + 'subject.',
372
470
  evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
373
- bypass: 'any rephrasing the pattern does not cover, or building the description by concatenation',
471
+ bypass: 'any rephrasing the pattern does not cover, assembling the description out of anything this tool '
472
+ + 'cannot fold to a constant, or registering the definition through a value this tool does not track — a '
473
+ + 'definition exported from one file and passed to `tools.register` in another is not matched',
374
474
  }));
375
475
  }
376
476
  }
@@ -7,9 +7,11 @@
7
7
  * user is entitled to see it.
8
8
  *
9
9
  * A Tier C hit also has a mechanical consequence. Tier B recognises a whitelist
10
- * of syntactic shapes, so when code is minified, when identifiers are computed,
11
- * or when the shipped artifact has no readable source, a Tier B *positive* is
12
- * still true but a Tier B *negative* means nothing. `inspect.ts` reads the
10
+ * of syntactic shapes a member, on a receiver, taking a name it can resolve —
11
+ * so when code is minified, when a name is assembled out of something this tool
12
+ * cannot fold, when a member is detached from the receiver the checks match it
13
+ * on, or when the shipped artifact has no readable source, a Tier B *positive*
14
+ * is still true but a Tier B *negative* means nothing. `inspect.ts` reads the
13
15
  * output of this module to lower Tier B confidence and to forbid the report
14
16
  * from claiming nothing was found.
15
17
  * @module dsh-plugin-inspector/checks/tier-c
@@ -17,6 +19,7 @@
17
19
  import ts from 'typescript';
18
20
  import { lineColumn, snippet } from "../files.js";
19
21
  import { MAX_EXAMPLES } from "../model.js";
22
+ import { BUILTIN_MODULE_GETTER, foldConstantString, isBuiltinModuleGetter } from "../syntax.js";
20
23
  /** A line longer than this is not written by hand. */
21
24
  const MINIFIED_LINE_LENGTH = 500;
22
25
  /** Below this many bytes, a low line count says nothing. */
@@ -27,6 +30,42 @@ const DISPATCH_RECEIVERS = new Set(['ctx', 'context', 'globalThis', 'global']);
27
30
  const NAMED_TARGET_CALLEES = new Set([
28
31
  'on', 'once', 'provide', 'set', 'get', 'emit', 'waterfall', 'bail', 'parallel', 'serial',
29
32
  ]);
33
+ /**
34
+ * Members every Tier B check can only match *through* their receiver.
35
+ *
36
+ * B1 reads `<receiver>.provide` / `.set` / `.mixin`, B5 reads
37
+ * `<receiver>.on`, B11 reads `.plugin`, B10 reads `tools.register`, and B7,
38
+ * B9 and B13 read `process.getBuiltinModule`. Every one of them is a property
39
+ * access on a named receiver, so pulling the member off the receiver and
40
+ * binding it to a bare name removes the only thing those checks match on —
41
+ * while the call still does exactly what it did.
42
+ *
43
+ * This is not the assembled-name case. The name is right there in plain text;
44
+ * what is gone is the receiver, and following it to the call site is value
45
+ * tracking this tool does not do.
46
+ */
47
+ const RECEIVER_ANCHORED_MEMBERS = new Set([
48
+ 'provide', 'set', 'mixin', 'on', 'plugin', 'register', BUILTIN_MODULE_GETTER,
49
+ ]);
50
+ /**
51
+ * Receivers whose members {@link RECEIVER_ANCHORED_MEMBERS} names.
52
+ *
53
+ * The plugin context, plus `process` — the receiver of `getBuiltinModule`. The
54
+ * guard is what keeps `const { set } = options` out of the check.
55
+ */
56
+ const ANCHORED_RECEIVERS = new Set([...DISPATCH_RECEIVERS, 'process']);
57
+ /** The harness's plugin entry point, whose first parameter is the context. */
58
+ const PLUGIN_ENTRY = 'apply';
59
+ /** Why an unresolvable name makes every Tier B negative meaningless. */
60
+ const ASSEMBLED_NAME_DETAIL = 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of '
61
+ + 'them, so no Tier B negative for this package carries any information. A Tier B positive still does — the tool '
62
+ + 'saw what it saw.';
63
+ /** Why a detached member makes every Tier B negative meaningless. */
64
+ const DETACHED_MEMBER_DETAIL = 'Every Tier B check that matches this member matches it on its receiver: B1 reads '
65
+ + '`ctx.provide`, B5 reads `ctx.on`, B10 reads `tools.register`, B13 reads `process.getBuiltinModule`. Bound to a '
66
+ + 'bare name the member still does all of that, and the call site no longer says on what. Following the binding is '
67
+ + 'value tracking this tool does not do, so no Tier B negative for this package carries any information. A Tier B '
68
+ + 'positive still does — the tool saw what it saw.';
30
69
  /**
31
70
  * Build one Tier C finding. Confidence is `moderate`: these are heuristics
32
71
  * about form, and a hand-written file can legitimately have one long line.
@@ -85,20 +124,18 @@ function checkMinification(files) {
85
124
  }
86
125
  return findings;
87
126
  }
88
- /** C2 — names the analyzer cannot resolve without running the code. */
127
+ /** C2 — names and receivers the analyzer cannot resolve without running the code. */
89
128
  function checkDynamicDispatch(files) {
90
129
  const findings = [];
91
130
  for (const { path, text, node: source } of files) {
92
- const report = (node, what) => {
131
+ const report = (node, what, why) => {
93
132
  findings.push(tierC({
94
133
  checkId: 'C2',
95
134
  name: 'dynamic-dispatch',
96
135
  subject: what,
97
136
  severity: 'high',
98
137
  title: `Shipped source ${what}`,
99
- detail: 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of them, so no '
100
- + 'Tier B negative for this package carries any information. A Tier B positive still does — the tool saw '
101
- + 'what it saw.',
138
+ detail: why,
102
139
  evidence: {
103
140
  file: path,
104
141
  path: lineColumn(text, node.getStart(source)),
@@ -108,29 +145,36 @@ function checkDynamicDispatch(files) {
108
145
  }));
109
146
  };
110
147
  const isComputed = (node) => node !== undefined && !ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node);
148
+ // A specifier Tier B folded to a constant is one Tier B matched, so it is
149
+ // not a gap. Only what the folder gives up on degrades the report.
150
+ const isUnresolvedSpecifier = (node) => isComputed(node) && foldConstantString(node) === null;
111
151
  const visit = (node) => {
112
152
  if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)
113
153
  && DISPATCH_RECEIVERS.has(node.expression.text) && isComputed(node.argumentExpression)) {
114
- report(node, `resolves a member of \`${node.expression.text}\` from a computed name`);
154
+ report(node, `resolves a member of \`${node.expression.text}\` from a computed name`, ASSEMBLED_NAME_DETAIL);
115
155
  }
156
+ if (ts.isVariableDeclaration(node))
157
+ reportDetachedBindings(node, report);
158
+ if (ts.isFunctionLike(node))
159
+ reportDetachedContextParameter(node, report);
116
160
  if (ts.isCallExpression(node)) {
117
161
  const callee = node.expression;
118
162
  const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
119
163
  const isImport = callee.kind === ts.SyntaxKind.ImportKeyword;
120
- if ((isRequire || isImport) && isComputed(node.arguments[0])) {
121
- report(node, 'loads a module from a computed specifier');
164
+ if ((isRequire || isImport || isBuiltinModuleGetter(node)) && isUnresolvedSpecifier(node.arguments[0])) {
165
+ report(node, 'loads a module from a computed specifier', ASSEMBLED_NAME_DETAIL);
122
166
  }
123
167
  if (ts.isIdentifier(callee) && callee.text === 'atob') {
124
- report(node, 'decodes a base64 string at runtime');
168
+ report(node, 'decodes a base64 string at runtime', ASSEMBLED_NAME_DETAIL);
125
169
  }
126
170
  if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from'
127
171
  && ts.isIdentifier(callee.expression) && callee.expression.text === 'Buffer'
128
172
  && (literalOf(node.arguments[1]) === 'base64' || literalOf(node.arguments[1]) === 'base64url')) {
129
- report(node, 'decodes a base64 string at runtime');
173
+ report(node, 'decodes a base64 string at runtime', ASSEMBLED_NAME_DETAIL);
130
174
  }
131
175
  if (ts.isPropertyAccessExpression(callee) && NAMED_TARGET_CALLEES.has(callee.name.text)
132
176
  && isDispatchReceiver(callee.expression) && isAssembledName(node.arguments[0])) {
133
- report(node, `passes an assembled name to \`${receiverName(callee.expression)}.${callee.name.text}()\``);
177
+ report(node, `passes an assembled name to \`${receiverName(callee.expression)}.${callee.name.text}()\``, ASSEMBLED_NAME_DETAIL);
134
178
  }
135
179
  }
136
180
  ts.forEachChild(node, visit);
@@ -139,6 +183,84 @@ function checkDynamicDispatch(files) {
139
183
  }
140
184
  return findings;
141
185
  }
186
+ /**
187
+ * Report every {@link RECEIVER_ANCHORED_MEMBERS} member a binding pattern pulls
188
+ * out of a known receiver.
189
+ *
190
+ * A computed property in the pattern — `const { [name]: fn } = ctx` — is
191
+ * `ctx[name]` written as a destructuring, and is recorded as that same finding
192
+ * so the two spellings aggregate together.
193
+ * @param pattern - the binding pattern.
194
+ * @param receiver - how the finding names the object being destructured.
195
+ * @param report - the reporter.
196
+ */
197
+ function reportPatternMembers(pattern, receiver, report) {
198
+ for (const element of pattern.elements) {
199
+ const property = element.propertyName ?? element.name;
200
+ if (ts.isComputedPropertyName(property)) {
201
+ report(element, `resolves a member of ${receiver} from a computed name`, ASSEMBLED_NAME_DETAIL);
202
+ continue;
203
+ }
204
+ if (!ts.isIdentifier(property) || !RECEIVER_ANCHORED_MEMBERS.has(property.text))
205
+ continue;
206
+ report(element, `binds \`${property.text}\` off ${receiver} to a bare name`, DETACHED_MEMBER_DETAIL);
207
+ }
208
+ }
209
+ /**
210
+ * Whether an expression names a receiver whose members Tier B matches by name.
211
+ * @param node - the expression.
212
+ * @returns the receiver's own name, or `null`.
213
+ */
214
+ function anchoredReceiver(node) {
215
+ if (node === undefined)
216
+ return null;
217
+ if (ts.isIdentifier(node) && ANCHORED_RECEIVERS.has(node.text))
218
+ return node.text;
219
+ // `this.ctx` and `self.ctx` are the same receiver held on a field.
220
+ if (ts.isPropertyAccessExpression(node) && DISPATCH_RECEIVERS.has(node.name.text))
221
+ return node.name.text;
222
+ return null;
223
+ }
224
+ /**
225
+ * C2 — a variable declaration that detaches an anchored member from its
226
+ * receiver, in either spelling: `const { provide } = ctx`, or
227
+ * `const provide = ctx.provide`.
228
+ * @param node - the variable declaration.
229
+ * @param report - the reporter.
230
+ */
231
+ function reportDetachedBindings(node, report) {
232
+ const source = anchoredReceiver(node.initializer);
233
+ if (source !== null && ts.isObjectBindingPattern(node.name)) {
234
+ reportPatternMembers(node.name, `\`${source}\``, report);
235
+ return;
236
+ }
237
+ const initializer = node.initializer;
238
+ if (initializer === undefined || !ts.isPropertyAccessExpression(initializer))
239
+ return;
240
+ const receiver = anchoredReceiver(initializer.expression);
241
+ if (receiver === null || !RECEIVER_ANCHORED_MEMBERS.has(initializer.name.text))
242
+ return;
243
+ report(node, `binds \`${initializer.name.text}\` off \`${receiver}\` to a bare name`, DETACHED_MEMBER_DETAIL);
244
+ }
245
+ /**
246
+ * C2 — a plugin entry point that destructures its context parameter:
247
+ * `export function apply({ provide }) { … }`.
248
+ *
249
+ * The receiver is known here without any binding to follow: `apply`'s first
250
+ * parameter is the plugin context by the harness's own mount contract, which is
251
+ * what keeps this off every other function that destructures an options object.
252
+ * @param node - the function-like declaration.
253
+ * @param report - the reporter.
254
+ */
255
+ function reportDetachedContextParameter(node, report) {
256
+ const name = node.name;
257
+ if (name === undefined || !ts.isIdentifier(name) || name.text !== PLUGIN_ENTRY)
258
+ return;
259
+ const first = node.parameters[0];
260
+ if (first === undefined || !ts.isObjectBindingPattern(first.name))
261
+ return;
262
+ reportPatternMembers(first.name, 'the plugin context', report);
263
+ }
142
264
  /**
143
265
  * Whether an expression names the plugin context.
144
266
  *
@@ -174,16 +296,22 @@ function receiverName(node) {
174
296
  /* v8 ignore stop */
175
297
  }
176
298
  /**
177
- * Whether a node builds a string at runtime rather than naming one. A plain
178
- * identifier is deliberately excluded: `ctx.on(EVENT_NAME, …)` against a module
179
- * constant is ordinary code, and treating it as evasion would degrade the
299
+ * Whether a node builds a string at runtime rather than naming one, and does it
300
+ * in a way the constant folder could not follow.
301
+ *
302
+ * A plain identifier is deliberately excluded: `ctx.on(EVENT_NAME, …)` against a
303
+ * module constant is ordinary code, and treating it as evasion would degrade the
180
304
  * analysis of nearly every well-written plugin.
181
305
  * @param node - the argument node.
182
- * @returns true for concatenation, an interpolated template, or a call.
306
+ * @returns true for an unfoldable concatenation, template, or call.
183
307
  */
184
308
  function isAssembledName(node) {
185
309
  if (node === undefined)
186
310
  return false;
311
+ // A name Tier B folded to a constant is a name Tier B matched. Reporting it
312
+ // here as well would degrade the whole report over a site that was read.
313
+ if (foldConstantString(node) !== null)
314
+ return false;
187
315
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
188
316
  return true;
189
317
  if (ts.isTemplateExpression(node))
@@ -330,7 +458,9 @@ function checkUnreadableFiles(input) {
330
458
  detail: reason === 'binary'
331
459
  ? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
332
460
  + 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
333
- : 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
461
+ : 'These files were not read: each either passed a size or count cap, or is not a regular file the reader '
462
+ + 'can open — a symbolic link, a FIFO, a socket, or a directory it was refused. The subject names which. '
463
+ + 'Nothing is claimed about their contents.',
334
464
  /* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
335
465
  evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
336
466
  bypass: 'none — this finding is about the analysis, not about the plugin',
package/lib/index.js CHANGED
@@ -12,8 +12,9 @@
12
12
  * @module dsh-plugin-inspector
13
13
  */
14
14
  export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
15
+ export { MAX_ATTESTATION_BYTES, packageUrl, PROVENANCE_PREDICATE_TYPE, provenanceAbsent, provenanceUnavailable, provenanceUnreadable, readProvenance, } from "./attestation.js";
15
16
  export { inspectFromNpm, precheck } from "./npm.js";
16
- export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
17
+ export { attestationUrl, DEFAULT_REGISTRY, fetchAttestation, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
17
18
  export { renderHuman, renderJson } from "./report.js";
18
19
  export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, } from "./cordis-yaml.js";
19
20
  export { declaredPackages, ManifestError, parseManifest } from "./manifest.js";
package/lib/injection.js CHANGED
@@ -39,7 +39,13 @@ export const INJECTION_RULES = [
39
39
  },
40
40
  {
41
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,
42
+ // The dotted filenames carry their own boundary. A `\b` in front of the
43
+ // whole alternation cannot match at the start of `.env` or `.npmrc`: the
44
+ // preceding character is a space and the next is a `.`, so neither side of
45
+ // that position is a word character and the boundary does not exist there.
46
+ // Under a shared `\b` those two alternatives match nothing, while the
47
+ // word-initial ones beside them keep working and hide it.
48
+ pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}(?:\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|id_rsa)\b|\.(?:env|npmrc)\b)/i,
43
49
  meaning: 'instructs the model to move a credential somewhere',
44
50
  },
45
51
  {
package/lib/inspect.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * @module dsh-plugin-inspector/inspect
11
11
  */
12
12
  import { readFileSync } from 'node:fs';
13
+ import { provenanceUnavailable } from "./attestation.js";
13
14
  import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cordis-yaml.js";
14
15
  import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
15
16
  import { HARNESS_REFERENCE } from "./knowledge.js";
@@ -95,11 +96,13 @@ export async function inspect(target) {
95
96
  /**
96
97
  * Run every check over an already-decoded package.
97
98
  * @param source - the decoded package.
98
- * @param registry - provenance, when the bytes were fetched from a registry.
99
+ * @param registry - where the bytes came from, when they were fetched from a registry.
100
+ * @param provenance - what the registry's attestation said and what was checked
101
+ * of it; defaults to the `unavailable` fact the two local modes carry.
99
102
  * @returns the complete report.
100
103
  * @throws ManifestError when the manifest cannot be read.
101
104
  */
102
- export function analyze(source, registry) {
105
+ export function analyze(source, registry, provenance = provenanceUnavailable()) {
103
106
  const manifest = parseManifest(source.files.get('package.json') ?? '');
104
107
  const declared = manifest.dsh.bundle?.patch;
105
108
  const mountsAsBundle = declared !== undefined;
@@ -131,6 +134,7 @@ export function analyze(source, registry) {
131
134
  unmountedPatchFiles: others,
132
135
  sourceFiles,
133
136
  modelVisibleFiles,
137
+ provenance,
134
138
  };
135
139
  const tierC = runTierC(input);
136
140
  const unreadable = tierC.filter(finding => !NON_DEGRADING_CHECKS.has(finding.checkId));
@@ -148,6 +152,7 @@ export function analyze(source, registry) {
148
152
  packageName: manifest.name,
149
153
  packageVersion: manifest.version,
150
154
  license: manifest.license,
155
+ provenance,
151
156
  mountsAsBundle,
152
157
  bundlePatchPath: declared ?? null,
153
158
  shipsClientBundle: manifest.dsh.client !== undefined && manifest.exportPaths.includes('./client'),