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,411 @@
1
+ /**
2
+ * Tier B — capability detection over shipped source.
3
+ *
4
+ * Everything here answers "this plugin CAN do X", never "this plugin DOES X".
5
+ * The distinction is load-bearing for B8: finding a credential read and a
6
+ * network call in the same package is not evidence that the credential reaches
7
+ * the socket, and the finding says so.
8
+ *
9
+ * Parsing is `ts.createSourceFile` — syntax only. No program is created, no
10
+ * type checker is instantiated, no module is resolved, nothing is transpiled,
11
+ * and nothing is executed. Every check is a shape match on one AST node, which
12
+ * is also why every check has a one-line bypass, carried in the finding.
13
+ * @module dsh-plugin-inspector/checks/tier-b
14
+ */
15
+ import ts from 'typescript';
16
+ import { lineColumn, snippet } from "../files.js";
17
+ import { scanInjection } from "../injection.js";
18
+ import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, } from "../knowledge.js";
19
+ /** Global functions that fetch over the network without any `ctx` service. */
20
+ const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
21
+ /** `process.env` keys whose names say they hold a secret. */
22
+ const SECRET_ENV_KEY = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|APIKEY|SESSION)(?:_|$)|API_?KEY|ACCESS_?TOKEN/i;
23
+ /** Filesystem locations that hold credentials. */
24
+ const CREDENTIAL_PATH = /(?:\.npmrc|\.netrc|\.ssh\/|id_rsa|id_ed25519|\.aws\/|\.docker\/config\.json|\.git-credentials|credentials\.json|\.dsh\/credentials|\.env(?:\.[a-z]+)?$)/i;
25
+ /** Members of `ctx` that construct or evaluate code, or mount further plugins. */
26
+ const DYNAMIC_CODE_CALLEES = new Set([
27
+ 'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
28
+ ]);
29
+ /** `ctx.systemPrompt` members that change what the model is told. */
30
+ const SYSTEM_PROMPT_MEMBERS = new Set([
31
+ 'section', 'context', 'variable', 'tools', 'suppressRuntimeContext',
32
+ ]);
33
+ /**
34
+ * Build one Tier B finding. Confidence starts at `high`; `inspect.ts` lowers it
35
+ * when Tier C fires.
36
+ * @param finding - everything but the fixed fields.
37
+ * @returns the complete finding.
38
+ */
39
+ function tierB(finding) {
40
+ return { ...finding, tier: 'B', confidence: 'high' };
41
+ }
42
+ /**
43
+ * The literal text of a string argument, or `null` when it is computed.
44
+ * A computed argument is not a Tier B miss to paper over — it is a Tier C
45
+ * signal, and `tier-c.ts` records it.
46
+ * @param node - the argument expression.
47
+ * @returns the literal text, or `null`.
48
+ */
49
+ function literalText(node) {
50
+ if (node === undefined)
51
+ return null;
52
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
53
+ return node.text;
54
+ return null;
55
+ }
56
+ /**
57
+ * Strip the `node:` prefix so `node:fs` and `fs` compare equal.
58
+ * @param specifier - the module specifier.
59
+ * @returns the bare module name.
60
+ */
61
+ function bareModule(specifier) {
62
+ return specifier.startsWith('node:') ? specifier.slice(5) : specifier;
63
+ }
64
+ /**
65
+ * Every module specifier the file imports or requires, as literal text.
66
+ * @param file - the parsed file.
67
+ * @returns specifier text paired with the node it came from.
68
+ */
69
+ function moduleSpecifiers(file) {
70
+ const found = [];
71
+ const visit = (node) => {
72
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined) {
73
+ const text = literalText(node.moduleSpecifier);
74
+ if (text !== null)
75
+ found.push({ specifier: text, node });
76
+ }
77
+ if (ts.isCallExpression(node)) {
78
+ const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
79
+ const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
80
+ if (isRequire || isImport) {
81
+ const text = literalText(node.arguments[0]);
82
+ if (text !== null)
83
+ found.push({ specifier: text, node });
84
+ }
85
+ }
86
+ ts.forEachChild(node, visit);
87
+ };
88
+ ts.forEachChild(file.node, visit);
89
+ return found;
90
+ }
91
+ /**
92
+ * Evidence for one AST node.
93
+ * @param file - the parsed file.
94
+ * @param node - the node to locate.
95
+ * @returns the evidence record.
96
+ */
97
+ function at(file, node) {
98
+ return {
99
+ file: file.path,
100
+ path: lineColumn(file.text, node.getStart(file.node)),
101
+ snippet: snippet(file.text.slice(node.getStart(file.node), node.end)),
102
+ };
103
+ }
104
+ /** B9, B7, B13 — what the file imports. */
105
+ function checkImports(file, accumulator) {
106
+ for (const { specifier, node } of moduleSpecifiers(file)) {
107
+ const bare = bareModule(specifier);
108
+ const unmediated = UNMEDIATED_PROCESS_MODULES.get(bare);
109
+ if (unmediated !== undefined) {
110
+ accumulator.findings.push(tierB({
111
+ checkId: 'B9',
112
+ name: 'unmediated-process-api',
113
+ severity: 'critical',
114
+ title: `Imports \`${specifier}\`, which ${unmediated}`,
115
+ detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
116
+ + 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
117
+ + 'bundle layer gets no such restriction, so this import does exactly what the harness forbids elsewhere.',
118
+ evidence: at(file, node),
119
+ bypass: 'a computed specifier — `await import(["node","child_process"].join(":"))` — is not matched, which is why C2 downgrades every Tier B negative',
120
+ }));
121
+ }
122
+ if (NETWORK_MODULES.has(bare)) {
123
+ const finding = tierB({
124
+ checkId: 'B7',
125
+ name: 'network-egress',
126
+ severity: 'medium',
127
+ title: `Imports \`${specifier}\`, which can move bytes off the machine`,
128
+ detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
129
+ + 'declared reason. It is recorded because paired with a credential read it becomes B8.',
130
+ evidence: at(file, node),
131
+ bypass: 'a computed specifier, or a transitive dependency doing the request on this package\'s behalf',
132
+ });
133
+ accumulator.findings.push(finding);
134
+ accumulator.networkCall ??= finding;
135
+ }
136
+ if (UNMEDIATED_FS_MODULES.has(bare)) {
137
+ accumulator.findings.push(tierB({
138
+ checkId: 'B13',
139
+ name: 'unmediated-filesystem',
140
+ severity: 'medium',
141
+ title: `Imports \`${specifier}\` rather than using the \`ctx.fs\` service`,
142
+ detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
143
+ + '`fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and '
144
+ + 'nothing appears in the session log.',
145
+ evidence: at(file, node),
146
+ bypass: 'a computed specifier, or `process.getBuiltinModule("node:fs")`',
147
+ }));
148
+ }
149
+ }
150
+ }
151
+ /** B1 — replacing a core capability seam. */
152
+ function checkSeamReplacement(file, node, accumulator) {
153
+ if (!ts.isPropertyAccessExpression(node.expression))
154
+ return;
155
+ const method = node.expression.name.text;
156
+ if (method !== 'provide' && method !== 'set' && method !== 'mixin')
157
+ return;
158
+ const seam = literalText(node.arguments[0]);
159
+ if (seam === null || !SEAM_KEYS.has(seam))
160
+ return;
161
+ const critical = SECURITY_SEAM_KEYS.has(seam);
162
+ accumulator.findings.push(tierB({
163
+ checkId: 'B1',
164
+ name: 'seam-replacement',
165
+ severity: critical ? 'critical' : 'high',
166
+ title: `Replaces the \`${seam}\` capability seam via \`.${method}()\``,
167
+ detail: `\`${seam}\` is a catalogued core service. Providing it from a third-party layer substitutes this `
168
+ + 'package\'s implementation for every consumer in the scope, and consumers cannot tell the difference.'
169
+ + (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
170
+ evidence: at(file, node),
171
+ bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched",
172
+ }));
173
+ }
174
+ /** B5 — changing what the model is told. */
175
+ function checkSystemPrompt(file, node, accumulator) {
176
+ const callee = node.expression;
177
+ if (!ts.isPropertyAccessExpression(callee))
178
+ return;
179
+ const isPromptMember = SYSTEM_PROMPT_MEMBERS.has(callee.name.text)
180
+ && ts.isPropertyAccessExpression(callee.expression)
181
+ && callee.expression.name.text === 'systemPrompt';
182
+ const isAssembleListener = callee.name.text === 'on' && literalText(node.arguments[0]) === 'system-prompt/assemble';
183
+ if (!isPromptMember && !isAssembleListener)
184
+ return;
185
+ accumulator.findings.push(tierB({
186
+ checkId: 'B5',
187
+ name: 'system-prompt-mutation',
188
+ severity: 'high',
189
+ title: isAssembleListener
190
+ ? 'Listens on `system-prompt/assemble`'
191
+ : `Contributes to the system prompt via \`ctx.systemPrompt.${callee.name.text}()\``,
192
+ detail: 'The system prompt is the model\'s standing instructions. Text added here reaches every request in the '
193
+ + 'scope and is not attributable to this package from the model\'s side.',
194
+ evidence: at(file, node),
195
+ bypass: 'a computed member or event name, or contributing the same text through a registered tool description instead',
196
+ }));
197
+ }
198
+ /** B11 — mounting further plugins from inside this one. */
199
+ function checkNestedMount(file, node, accumulator) {
200
+ const callee = node.expression;
201
+ if (!ts.isPropertyAccessExpression(callee))
202
+ return;
203
+ const isPluginCall = callee.name.text === 'plugin' && ts.isIdentifier(callee.expression);
204
+ const isLoaderCall = ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'loader';
205
+ if (!isPluginCall && !isLoaderCall)
206
+ return;
207
+ accumulator.findings.push(tierB({
208
+ checkId: 'B11',
209
+ name: 'nested-plugin-mount',
210
+ severity: 'high',
211
+ title: 'Mounts further plugins at runtime',
212
+ detail: 'A layer that mounts other layers moves the analysis target: what actually runs is decided by code '
213
+ + 'rather than by the composed entry list, and none of it appears in `dsh --dump-config`.',
214
+ evidence: at(file, node),
215
+ bypass: 'a computed member name, or mounting through a helper imported from a dependency',
216
+ }));
217
+ }
218
+ /**
219
+ * Whether a `new Function(…)` is ever called.
220
+ *
221
+ * Constructing a function does not run anything: this tool compiles `!!js`
222
+ * expressions with `new Function` purely to learn whether they parse, and
223
+ * discards the result. A check that cannot tell that apart from an invocation
224
+ * fires on its own documented parse step, and an analyzer that fails its own
225
+ * default gate has no standing to gate anything else. So the finding is about
226
+ * the call, in either of the two forms it takes: invoked where it is built, or
227
+ * bound to a name that is called later in the same file.
228
+ * @param node - the `new Function(…)` expression.
229
+ * @param file - the parsed file it came from.
230
+ * @returns true when the constructed function is invoked.
231
+ */
232
+ function isInvokedFunctionCtor(node, file) {
233
+ const parent = node.parent;
234
+ if (parent !== undefined && ts.isCallExpression(parent) && parent.expression === node)
235
+ return true;
236
+ if (parent === undefined || !ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name))
237
+ return false;
238
+ const bound = parent.name.text;
239
+ let called = false;
240
+ const visit = (child) => {
241
+ if (ts.isCallExpression(child) && ts.isIdentifier(child.expression) && child.expression.text === bound)
242
+ called = true;
243
+ ts.forEachChild(child, visit);
244
+ };
245
+ ts.forEachChild(file.node, visit);
246
+ return called;
247
+ }
248
+ /** B12 — building code at runtime and running it. */
249
+ function checkDynamicCode(file, node, accumulator) {
250
+ const isEvalCall = ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'eval';
251
+ const isVmCall = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
252
+ && DYNAMIC_CODE_CALLEES.has(node.expression.name.text);
253
+ const isFunctionCtor = ts.isNewExpression(node) && ts.isIdentifier(node.expression)
254
+ && node.expression.text === 'Function' && isInvokedFunctionCtor(node, file);
255
+ if (!isEvalCall && !isVmCall && !isFunctionCtor)
256
+ return;
257
+ accumulator.findings.push(tierB({
258
+ checkId: 'B12',
259
+ name: 'dynamic-code-construction',
260
+ severity: 'high',
261
+ title: 'Builds and runs code at runtime',
262
+ detail: 'Whatever this evaluates is not in the package and cannot be analysed from it. Construction alone is '
263
+ + 'not the finding: a `new Function` whose result is never called compiles a string and discards it, which is '
264
+ + 'how this tool checks that a `!!js` expression parses.',
265
+ evidence: at(file, node),
266
+ bypass: 'building the function in one statement and calling it through a value this tool does not track',
267
+ }));
268
+ }
269
+ /** B6 — reading a credential. */
270
+ function checkCredentialRead(file, node, accumulator) {
271
+ let title = null;
272
+ if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
273
+ const outer = node.expression;
274
+ if (ts.isIdentifier(outer.expression) && outer.expression.text === 'process' && outer.name.text === 'env') {
275
+ if (SECRET_ENV_KEY.test(node.name.text))
276
+ title = `Reads the environment variable \`${node.name.text}\``;
277
+ }
278
+ }
279
+ if (ts.isElementAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
280
+ const outer = node.expression;
281
+ const key = literalText(node.argumentExpression);
282
+ if (ts.isIdentifier(outer.expression) && outer.expression.text === 'process' && outer.name.text === 'env'
283
+ && key !== null && SECRET_ENV_KEY.test(key)) {
284
+ title = `Reads the environment variable \`${key}\``;
285
+ }
286
+ }
287
+ if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && CREDENTIAL_PATH.test(node.text)) {
288
+ title = `References the credential location \`${node.text}\``;
289
+ }
290
+ if (ts.isPropertyAccessExpression(node) && node.name.text === 'credentials' && ts.isIdentifier(node.expression)) {
291
+ title = 'Reads the `credentials` service';
292
+ }
293
+ if (title === null)
294
+ return;
295
+ const finding = tierB({
296
+ checkId: 'B6',
297
+ name: 'credential-read',
298
+ severity: 'medium',
299
+ title,
300
+ detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
301
+ + 'must do it. It is recorded because paired with network access it becomes B8.',
302
+ evidence: at(file, node),
303
+ bypass: 'a computed key — `process.env["API"+"_KEY"]` — or reading the whole `process.env` object and indexing it later',
304
+ });
305
+ accumulator.findings.push(finding);
306
+ accumulator.credentialRead ??= finding;
307
+ }
308
+ /** B10 — injection phrasing in a registered tool description. */
309
+ function checkToolDescription(file, node, accumulator) {
310
+ if (!ts.isPropertyAssignment(node))
311
+ return;
312
+ if (!ts.isIdentifier(node.name) || node.name.text !== 'description')
313
+ return;
314
+ const text = literalText(node.initializer);
315
+ if (text === null)
316
+ return;
317
+ for (const match of scanInjection(text)) {
318
+ accumulator.findings.push(tierB({
319
+ checkId: 'B10',
320
+ name: 'model-visible-injection',
321
+ severity: 'high',
322
+ title: `Tool description ${match.meaning}`,
323
+ detail: `Heuristic \`${match.ruleId}\` matched a tool \`description\`, which is prompt text the model receives `
324
+ + 'verbatim on every request that lists the tool. This is a natural-language heuristic: it will miss a '
325
+ + 'rephrasing, and it can fire on a description that legitimately discusses the subject.',
326
+ evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
327
+ bypass: 'any rephrasing the pattern does not cover, or building the description by concatenation',
328
+ }));
329
+ }
330
+ }
331
+ /** B7 — network calls that need no import. */
332
+ function checkNetworkGlobals(file, node, accumulator) {
333
+ const callee = ts.isCallExpression(node) || ts.isNewExpression(node) ? node.expression : undefined;
334
+ if (callee === undefined || !ts.isIdentifier(callee) || !NETWORK_GLOBALS.has(callee.text))
335
+ return;
336
+ const finding = tierB({
337
+ checkId: 'B7',
338
+ name: 'network-egress',
339
+ severity: 'medium',
340
+ title: `Calls \`${callee.text}()\``,
341
+ detail: 'The harness\'s own dynamic-package sandbox traps `fetch` and redirects it to the `ctx.web` service, so '
342
+ + 'that untrusted code\'s network use is mediated. A mounted bundle layer is not in that sandbox and this call '
343
+ + 'goes straight out.',
344
+ evidence: at(file, node),
345
+ bypass: '`globalThis["fet"+"ch"]`, or a transitive dependency making the request',
346
+ });
347
+ accumulator.findings.push(finding);
348
+ accumulator.networkCall ??= finding;
349
+ }
350
+ /**
351
+ * Run every Tier B check.
352
+ * @param input - the decoded package.
353
+ * @returns findings, unordered.
354
+ */
355
+ export function runTierB(input) {
356
+ const accumulator = { findings: [], credentialRead: null, networkCall: null };
357
+ for (const path of input.sourceFiles) {
358
+ const text = input.source.files.get(path);
359
+ if (text === undefined)
360
+ continue;
361
+ const file = {
362
+ path,
363
+ text,
364
+ node: ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS),
365
+ };
366
+ checkImports(file, accumulator);
367
+ const visit = (node) => {
368
+ if (ts.isCallExpression(node)) {
369
+ checkSeamReplacement(file, node, accumulator);
370
+ checkSystemPrompt(file, node, accumulator);
371
+ checkNestedMount(file, node, accumulator);
372
+ }
373
+ checkDynamicCode(file, node, accumulator);
374
+ checkCredentialRead(file, node, accumulator);
375
+ checkToolDescription(file, node, accumulator);
376
+ checkNetworkGlobals(file, node, accumulator);
377
+ ts.forEachChild(node, visit);
378
+ };
379
+ ts.forEachChild(file.node, visit);
380
+ }
381
+ const pair = pairFinding(accumulator);
382
+ if (pair !== null)
383
+ accumulator.findings.push(pair);
384
+ return accumulator.findings;
385
+ }
386
+ /**
387
+ * B8 — a credential read and a network call in the same package.
388
+ * @param accumulator - the accumulated Tier B state.
389
+ * @returns the pair finding, or `null` when only one half is present.
390
+ */
391
+ function pairFinding(accumulator) {
392
+ const credential = accumulator.credentialRead;
393
+ const network = accumulator.networkCall;
394
+ if (credential === null || network === null)
395
+ return null;
396
+ const severity = 'critical';
397
+ return tierB({
398
+ checkId: 'B8',
399
+ name: 'exfiltration-capability',
400
+ severity,
401
+ title: 'This package can read a credential and can make a network call',
402
+ detail: 'This is a capability, not a dataflow. The tool found a credential read at '
403
+ + `${credential.evidence.file}:${credential.evidence.path ?? '?'} and a network call at `
404
+ + `${network.evidence.file}:${network.evidence.path ?? '?'}. It has NOT shown that the credential value `
405
+ + 'reaches the request, and it cannot: proving that needs value tracking this tool does not do. Many '
406
+ + 'legitimate packages — any telemetry or authenticated API client — trip this pair for good reasons. Treat '
407
+ + 'it as a prompt to read those two sites, not as a verdict.',
408
+ evidence: credential.evidence,
409
+ bypass: 'splitting the read and the send across two packages, or letting a dependency do either half',
410
+ });
411
+ }