dsh-plugin-inspector 0.6.0 → 0.8.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.
- package/README.md +10 -8
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +110 -1
- package/lib/checks/tier-b.js +341 -28
- package/lib/checks/tier-c.js +146 -18
- package/lib/index.js +2 -1
- package/lib/inspect.js +7 -2
- package/lib/knowledge.js +289 -25
- package/lib/npm.js +58 -5
- package/lib/registry.js +54 -0
- package/lib/report.js +58 -7
- package/lib/syntax.js +135 -0
- package/lib/types/attestation.d.ts +173 -0
- package/lib/types/checks/input.d.ts +7 -0
- package/lib/types/checks/tier-c.d.ts +5 -3
- package/lib/types/index.d.ts +2 -1
- package/lib/types/inspect.d.ts +5 -2
- package/lib/types/knowledge.d.ts +212 -4
- package/lib/types/model.d.ts +13 -1
- package/lib/types/npm.d.ts +12 -2
- package/lib/types/registry.d.ts +40 -0
- package/lib/types/syntax.d.ts +51 -0
- package/package.json +2 -1
package/lib/checks/tier-b.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
import ts from 'typescript';
|
|
16
16
|
import { lineColumn, snippet } from "../files.js";
|
|
17
17
|
import { scanInjection } from "../injection.js";
|
|
18
|
-
import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, } from "../knowledge.js";
|
|
18
|
+
import { CONTEXT_RECEIVERS, DECISION_EVENTS, DECISION_EVENT_DEFAULTS, MUTATING_METHODS, NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, TEARDOWN_SURFACES, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, WATERFALL_EVENTS, } 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. */
|
|
@@ -74,18 +75,18 @@ function tierB(finding) {
|
|
|
74
75
|
return { ...finding, tier: 'B', confidence: 'high', examples: [finding.evidence], occurrences: 1 };
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
|
-
* The
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
80
85
|
* @param node - the argument expression.
|
|
81
|
-
* @returns the
|
|
86
|
+
* @returns the text, or `null`.
|
|
82
87
|
*/
|
|
83
88
|
function literalText(node) {
|
|
84
|
-
|
|
85
|
-
return null;
|
|
86
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
87
|
-
return node.text;
|
|
88
|
-
return null;
|
|
89
|
+
return foldConstantString(node);
|
|
89
90
|
}
|
|
90
91
|
/**
|
|
91
92
|
* Strip the `node:` prefix so `node:fs` and `fs` compare equal.
|
|
@@ -96,9 +97,13 @@ function bareModule(specifier) {
|
|
|
96
97
|
return specifier.startsWith('node:') ? specifier.slice(5) : specifier;
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
|
-
* Every module
|
|
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.
|
|
100
105
|
* @param file - the parsed file.
|
|
101
|
-
* @returns
|
|
106
|
+
* @returns one entry per resolved reference.
|
|
102
107
|
*/
|
|
103
108
|
function moduleSpecifiers(file) {
|
|
104
109
|
const found = [];
|
|
@@ -107,15 +112,16 @@ function moduleSpecifiers(file) {
|
|
|
107
112
|
const text = literalText(node.moduleSpecifier);
|
|
108
113
|
/* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
|
|
109
114
|
if (text !== null)
|
|
110
|
-
found.push({ specifier: text, node });
|
|
115
|
+
found.push({ specifier: text, node, via: 'import' });
|
|
111
116
|
}
|
|
112
117
|
if (ts.isCallExpression(node)) {
|
|
113
118
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
114
119
|
const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
115
|
-
|
|
120
|
+
const isBuiltin = isBuiltinModuleGetter(node);
|
|
121
|
+
if (isRequire || isImport || isBuiltin) {
|
|
116
122
|
const text = literalText(node.arguments[0]);
|
|
117
123
|
if (text !== null)
|
|
118
|
-
found.push({ specifier: text, node });
|
|
124
|
+
found.push({ specifier: text, node, via: isBuiltin ? 'builtin-getter' : 'import' });
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
ts.forEachChild(node, visit);
|
|
@@ -136,9 +142,25 @@ function at(file, node) {
|
|
|
136
142
|
snippet: snippet(file.text.slice(node.getStart(file.node), node.end)),
|
|
137
143
|
};
|
|
138
144
|
}
|
|
139
|
-
/**
|
|
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. */
|
|
140
160
|
function checkImports(file, accumulator) {
|
|
141
|
-
for (const
|
|
161
|
+
for (const reference of moduleSpecifiers(file)) {
|
|
162
|
+
const { specifier, node } = reference;
|
|
163
|
+
const reached = reachedBy(reference);
|
|
142
164
|
const bare = bareModule(specifier);
|
|
143
165
|
const unmediated = UNMEDIATED_PROCESS_MODULES.get(bare);
|
|
144
166
|
if (unmediated !== undefined) {
|
|
@@ -150,12 +172,13 @@ function checkImports(file, accumulator) {
|
|
|
150
172
|
// reads a credential or reaches the network. On its own it is a
|
|
151
173
|
// capability half the ecosystem has.
|
|
152
174
|
severity: 'medium',
|
|
153
|
-
title:
|
|
175
|
+
title: `${reached}, which ${unmediated}`,
|
|
154
176
|
detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
|
|
155
177
|
+ 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
|
|
156
178
|
+ 'bundle layer gets no such restriction, so this import does exactly what the harness forbids elsewhere.',
|
|
157
179
|
evidence: at(file, node),
|
|
158
|
-
bypass: 'a
|
|
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',
|
|
159
182
|
}));
|
|
160
183
|
}
|
|
161
184
|
if (NETWORK_MODULES.has(bare)) {
|
|
@@ -164,11 +187,11 @@ function checkImports(file, accumulator) {
|
|
|
164
187
|
name: 'network-egress',
|
|
165
188
|
subject: specifier,
|
|
166
189
|
severity: 'medium',
|
|
167
|
-
title:
|
|
190
|
+
title: `${reached}, which can move bytes off the machine`,
|
|
168
191
|
detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
|
|
169
192
|
+ 'declared reason. It is recorded because paired with a credential read it becomes B8.',
|
|
170
193
|
evidence: at(file, node),
|
|
171
|
-
bypass: 'a
|
|
194
|
+
bypass: 'a transitive dependency doing the request on this package\'s behalf',
|
|
172
195
|
});
|
|
173
196
|
accumulator.findings.push(finding);
|
|
174
197
|
accumulator.networkCall ??= finding;
|
|
@@ -179,12 +202,12 @@ function checkImports(file, accumulator) {
|
|
|
179
202
|
name: 'unmediated-filesystem',
|
|
180
203
|
subject: specifier,
|
|
181
204
|
severity: 'medium',
|
|
182
|
-
title:
|
|
205
|
+
title: `${reached} rather than using the \`ctx.fs\` service`,
|
|
183
206
|
detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
|
|
184
207
|
+ '`fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and '
|
|
185
208
|
+ 'nothing appears in the session log.',
|
|
186
209
|
evidence: at(file, node),
|
|
187
|
-
bypass: 'a
|
|
210
|
+
bypass: 'a transitive dependency reading the file on this package\'s behalf',
|
|
188
211
|
}));
|
|
189
212
|
}
|
|
190
213
|
}
|
|
@@ -210,7 +233,8 @@ function checkSeamReplacement(file, node, accumulator) {
|
|
|
210
233
|
+ 'package\'s implementation for every consumer in the scope, and consumers cannot tell the difference.'
|
|
211
234
|
+ (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
|
|
212
235
|
evidence: at(file, node),
|
|
213
|
-
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',
|
|
214
238
|
}));
|
|
215
239
|
}
|
|
216
240
|
/** B5 — changing what the model is told. */
|
|
@@ -352,7 +376,7 @@ function checkCredentialRead(file, node, accumulator) {
|
|
|
352
376
|
detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
|
|
353
377
|
+ 'must do it. It is recorded because paired with network access it becomes B8.',
|
|
354
378
|
evidence: at(file, node),
|
|
355
|
-
bypass: 'a
|
|
379
|
+
bypass: 'a key this tool cannot fold to a constant, or reading the whole `process.env` object and indexing it later',
|
|
356
380
|
});
|
|
357
381
|
accumulator.findings.push(finding);
|
|
358
382
|
accumulator.credentialRead ??= finding;
|
|
@@ -444,9 +468,9 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
444
468
|
+ 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
|
|
445
469
|
+ 'subject.',
|
|
446
470
|
evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
|
|
447
|
-
bypass: 'any rephrasing the pattern does not cover,
|
|
448
|
-
+ 'the definition through a value this tool does not track — a
|
|
449
|
-
+ 'to `tools.register` in another is not matched',
|
|
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',
|
|
450
474
|
}));
|
|
451
475
|
}
|
|
452
476
|
}
|
|
@@ -470,6 +494,292 @@ function checkNetworkGlobals(file, node, accumulator) {
|
|
|
470
494
|
accumulator.findings.push(finding);
|
|
471
495
|
accumulator.networkCall ??= finding;
|
|
472
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* The function a listener argument denotes, when this tool can see one.
|
|
499
|
+
*
|
|
500
|
+
* Two forms are followed: the function written at the call site, and a name
|
|
501
|
+
* bound to a function in the same file. That is the same bounded, single-file
|
|
502
|
+
* name resolution `isRegisteredName` already does for tool definitions, and it
|
|
503
|
+
* stops at the same place: a listener imported from another module, or built by
|
|
504
|
+
* a helper, is not resolved and produces no finding rather than a guess.
|
|
505
|
+
* @param node - the listener argument.
|
|
506
|
+
* @param file - the parsed file the call is in.
|
|
507
|
+
* @returns the function, or `null` when it cannot be resolved from this file.
|
|
508
|
+
*/
|
|
509
|
+
function resolveListener(node, file) {
|
|
510
|
+
if (node === undefined)
|
|
511
|
+
return null;
|
|
512
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
|
|
513
|
+
return node;
|
|
514
|
+
if (!ts.isIdentifier(node))
|
|
515
|
+
return null;
|
|
516
|
+
const wanted = node.text;
|
|
517
|
+
let found = null;
|
|
518
|
+
const visit = (child) => {
|
|
519
|
+
if (ts.isFunctionDeclaration(child) && child.name?.text === wanted)
|
|
520
|
+
found ??= child;
|
|
521
|
+
if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name) && child.name.text === wanted
|
|
522
|
+
&& child.initializer !== undefined
|
|
523
|
+
&& (ts.isArrowFunction(child.initializer) || ts.isFunctionExpression(child.initializer))) {
|
|
524
|
+
found ??= child.initializer;
|
|
525
|
+
}
|
|
526
|
+
ts.forEachChild(child, visit);
|
|
527
|
+
};
|
|
528
|
+
ts.forEachChild(file.node, visit);
|
|
529
|
+
return found;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Whether a listener can reach the `next` continuation the waterfall hands it.
|
|
533
|
+
*
|
|
534
|
+
* `next` is positional, not named: `EventsService.waterfall` pushes it as the
|
|
535
|
+
* last dispatch argument, so it is whatever the listener's trailing parameter
|
|
536
|
+
* is called. A listener that declares no parameter in that position never
|
|
537
|
+
* receives it, and one that declares it and never mentions it cannot call it.
|
|
538
|
+
* Either way the chain stops there.
|
|
539
|
+
*
|
|
540
|
+
* Every uncertain shape answers true, so the finding needs a listener whose
|
|
541
|
+
* trailing parameter is a plain name that the body does not contain: a rest
|
|
542
|
+
* parameter, a destructuring pattern, and any use of `arguments` all count as
|
|
543
|
+
* reaching it.
|
|
544
|
+
* @param fn - the resolved listener.
|
|
545
|
+
* @returns true when the listener can call `next`.
|
|
546
|
+
*/
|
|
547
|
+
function canReachNext(fn) {
|
|
548
|
+
const parameters = fn.parameters;
|
|
549
|
+
const last = parameters.at(-1);
|
|
550
|
+
if (last === undefined)
|
|
551
|
+
return false;
|
|
552
|
+
if (last.dotDotDotToken !== undefined || !ts.isIdentifier(last.name))
|
|
553
|
+
return true;
|
|
554
|
+
const wanted = last.name.text;
|
|
555
|
+
const body = fn.body;
|
|
556
|
+
if (body === undefined)
|
|
557
|
+
return true;
|
|
558
|
+
let referenced = false;
|
|
559
|
+
const visit = (node) => {
|
|
560
|
+
if (ts.isIdentifier(node) && (node.text === wanted || node.text === 'arguments'))
|
|
561
|
+
referenced = true;
|
|
562
|
+
ts.forEachChild(node, visit);
|
|
563
|
+
};
|
|
564
|
+
ts.forEachChild(body, visit);
|
|
565
|
+
return referenced;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Whether a listener registration asks to run ahead of the listeners already
|
|
569
|
+
* composed on the event.
|
|
570
|
+
*
|
|
571
|
+
* Both spellings the event bus accepts: the option object, and the boolean
|
|
572
|
+
* shorthand `EventsService.on` expands with `options = { prepend: options }`.
|
|
573
|
+
* @param node - the options argument, when there is one.
|
|
574
|
+
* @returns true when the registration prepends.
|
|
575
|
+
*/
|
|
576
|
+
function isPrepended(node) {
|
|
577
|
+
if (node === undefined)
|
|
578
|
+
return false;
|
|
579
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
580
|
+
return true;
|
|
581
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
582
|
+
return false;
|
|
583
|
+
return node.properties.some(property => ts.isPropertyAssignment(property)
|
|
584
|
+
&& ts.isIdentifier(property.name) && property.name.text === 'prepend'
|
|
585
|
+
&& property.initializer.kind === ts.SyntaxKind.TrueKeyword);
|
|
586
|
+
}
|
|
587
|
+
/** B14 — a waterfall listener that cannot delegate to the rest of the chain. */
|
|
588
|
+
function checkWaterfallVeto(file, node, accumulator) {
|
|
589
|
+
const callee = node.expression;
|
|
590
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
591
|
+
return;
|
|
592
|
+
if (callee.name.text !== 'on' && callee.name.text !== 'once')
|
|
593
|
+
return;
|
|
594
|
+
const event = literalText(node.arguments[0]);
|
|
595
|
+
if (event === null || !WATERFALL_EVENTS.has(event))
|
|
596
|
+
return;
|
|
597
|
+
const listener = resolveListener(node.arguments[1], file);
|
|
598
|
+
if (listener === null || canReachNext(listener))
|
|
599
|
+
return;
|
|
600
|
+
const decides = DECISION_EVENTS.has(event);
|
|
601
|
+
const prepended = isPrepended(node.arguments[2]);
|
|
602
|
+
const consequence = DECISION_EVENT_DEFAULTS.get(event);
|
|
603
|
+
accumulator.findings.push(tierB({
|
|
604
|
+
checkId: 'B14',
|
|
605
|
+
name: 'waterfall-veto',
|
|
606
|
+
subject: event,
|
|
607
|
+
severity: decides ? 'critical' : 'high',
|
|
608
|
+
title: `Listens on \`${event}\` and never calls \`next\``,
|
|
609
|
+
detail: `\`${event}\` is dispatched as a Cordis waterfall, which hands every listener a trailing \`next\` and `
|
|
610
|
+
+ 'ends the chain at the first listener that returns without calling it — the remaining listeners and the '
|
|
611
|
+
+ 'harness\'s own built-in behavior both stop for that dispatch. This listener '
|
|
612
|
+
+ (listener.parameters.length === 0
|
|
613
|
+
? 'declares no parameters, so it never receives `next` at all.'
|
|
614
|
+
: `never mentions its trailing parameter \`${(listener.parameters.at(-1)?.name).text}\`, `
|
|
615
|
+
+ 'which is the `next` the dispatch supplies.')
|
|
616
|
+
+ (consequence === undefined ? '' : ` Without it, ${consequence}.`)
|
|
617
|
+
+ (prepended
|
|
618
|
+
? ' It is registered with `prepend`, which unshifts it onto the listener list, so it claims every dispatch '
|
|
619
|
+
+ 'ahead of every listener composed before this layer mounted.'
|
|
620
|
+
: '')
|
|
621
|
+
+ ' The veto is per dispatch: nothing is unregistered, and every skipped listener runs again next time.',
|
|
622
|
+
evidence: at(file, node),
|
|
623
|
+
bypass: 'calling `next()` on a branch the listener never takes, or passing a listener this tool cannot resolve '
|
|
624
|
+
+ 'from the file it is registered in — one imported from another module, or returned by a helper',
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Read a property chain rooted at a plugin context and report which catalogued
|
|
629
|
+
* seam it goes through.
|
|
630
|
+
*
|
|
631
|
+
* The receiver guard is what keeps this off ordinary code, exactly as it does
|
|
632
|
+
* in B1 and C2: the chain has to start at a name that denotes a plugin context,
|
|
633
|
+
* and its first member has to be one of the catalogued seam keys.
|
|
634
|
+
* @param node - the innermost expression of a property access.
|
|
635
|
+
* @returns the seam and the depth, or `null` when the chain is not one.
|
|
636
|
+
*/
|
|
637
|
+
function seamChain(node) {
|
|
638
|
+
const members = [];
|
|
639
|
+
let cursor = node;
|
|
640
|
+
while (ts.isPropertyAccessExpression(cursor)) {
|
|
641
|
+
members.unshift(cursor.name.text);
|
|
642
|
+
cursor = cursor.expression;
|
|
643
|
+
}
|
|
644
|
+
// Two roots: a bare context name, and `this.ctx` — the form a plugin written
|
|
645
|
+
// as a class uses, which the Tier C detached-member check already treats as a
|
|
646
|
+
// known receiver.
|
|
647
|
+
if (cursor.kind === ts.SyntaxKind.ThisKeyword && members[0] === 'ctx')
|
|
648
|
+
members.shift();
|
|
649
|
+
else if (!ts.isIdentifier(cursor) || !CONTEXT_RECEIVERS.has(cursor.text))
|
|
650
|
+
return null;
|
|
651
|
+
const seam = members[0];
|
|
652
|
+
if (seam === undefined || !SEAM_KEYS.has(seam))
|
|
653
|
+
return null;
|
|
654
|
+
return { seam, depth: members.length - 1 };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Build the B15 finding for one write site.
|
|
658
|
+
* @param file - the parsed file.
|
|
659
|
+
* @param node - the node to cite.
|
|
660
|
+
* @param chain - the seam the write goes through.
|
|
661
|
+
* @param how - the clause naming what the write does.
|
|
662
|
+
* @returns the finding.
|
|
663
|
+
*/
|
|
664
|
+
function seamWriteFinding(file, node, chain, how) {
|
|
665
|
+
const critical = SECURITY_SEAM_KEYS.has(chain.seam);
|
|
666
|
+
return tierB({
|
|
667
|
+
checkId: 'B15',
|
|
668
|
+
name: 'seam-internals-write',
|
|
669
|
+
subject: chain.seam,
|
|
670
|
+
severity: critical ? 'critical' : 'high',
|
|
671
|
+
title: `${how} inside the \`${chain.seam}\` capability seam`,
|
|
672
|
+
detail: `\`${chain.seam}\` is a catalogued core service, and Cordis resolves it to one shared instance for the `
|
|
673
|
+
+ 'whole tree — a write through this package\'s context is what every other consumer reads afterwards. '
|
|
674
|
+
+ 'B1 reads `ctx.provide` / `ctx.set` / `ctx.mixin`, the declared ways to substitute a service, and Cordis '
|
|
675
|
+
+ 'refuses both from a layer that does not own the service: `provide` throws when the key is taken and `set` '
|
|
676
|
+
+ 'throws with "cannot set property in multiple fibers". Writing a member of the object those calls would '
|
|
677
|
+
+ 'have replaced reaches the same substitution and meets neither check.'
|
|
678
|
+
+ (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
|
|
679
|
+
evidence: at(file, node),
|
|
680
|
+
bypass: 'reaching the same object through a value this tool does not track — a local bound to `ctx.tools` and '
|
|
681
|
+
+ 'written through afterwards — or a computed member name, which C2 reports',
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/** B15 — writing into a catalogued seam's own object graph. */
|
|
685
|
+
function checkSeamWrite(file, node, accumulator) {
|
|
686
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
687
|
+
&& ts.isPropertyAccessExpression(node.left)) {
|
|
688
|
+
const chain = seamChain(node.left);
|
|
689
|
+
if (chain !== null && chain.depth >= 1) {
|
|
690
|
+
accumulator.findings.push(seamWriteFinding(file, node, chain, 'Assigns to a member'));
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (ts.isDeleteExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
695
|
+
const chain = seamChain(node.expression);
|
|
696
|
+
if (chain !== null && chain.depth >= 1) {
|
|
697
|
+
accumulator.findings.push(seamWriteFinding(file, node, chain, 'Deletes a member'));
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// A mutating call is only a finding when it reaches *past* the service's own
|
|
702
|
+
// API. `ctx.credentials.set(ref, value)` and `ctx.skills.register(skill)` are
|
|
703
|
+
// the seam's published methods and sit at depth 0; `ctx.tools.layers.global
|
|
704
|
+
// .guards.data.clear()` reaches through five members into the map a
|
|
705
|
+
// `ctx.tools.guard()` deny is filed in.
|
|
706
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression))
|
|
707
|
+
return;
|
|
708
|
+
if (!MUTATING_METHODS.has(node.expression.name.text))
|
|
709
|
+
return;
|
|
710
|
+
const chain = seamChain(node.expression.expression);
|
|
711
|
+
if (chain === null || chain.depth < 2)
|
|
712
|
+
return;
|
|
713
|
+
accumulator.findings.push(seamWriteFinding(file, node, chain, `Calls \`.${node.expression.name.text}()\` on state`));
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Whether a node sits on the left of an assignment or under a `delete`, which
|
|
717
|
+
* is what separates writing one of the Cordis bookkeeping tables from reading
|
|
718
|
+
* it. Reading is something an honest plugin does: `dsh-dlp` counts
|
|
719
|
+
* `ctx.events._hooks['approval/request']` to decide whether an approval would
|
|
720
|
+
* reach a human.
|
|
721
|
+
* @param node - the property access naming the surface.
|
|
722
|
+
* @returns true when the surface is being written rather than read.
|
|
723
|
+
*/
|
|
724
|
+
function isWriteTarget(node) {
|
|
725
|
+
let cursor = node;
|
|
726
|
+
for (;;) {
|
|
727
|
+
// Sources are parsed with `setParentNodes`, and the loop only ascends
|
|
728
|
+
// through property and element accesses, each of which has a parent — the
|
|
729
|
+
// statement that would hold a parentless node ends the walk one step below.
|
|
730
|
+
const parent = cursor.parent;
|
|
731
|
+
if (ts.isDeleteExpression(parent))
|
|
732
|
+
return true;
|
|
733
|
+
if (ts.isBinaryExpression(parent) && parent.left === cursor
|
|
734
|
+
&& (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
735
|
+
|| parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken))
|
|
736
|
+
return true;
|
|
737
|
+
// The surface is the receiver of a mutating call, either directly
|
|
738
|
+
// (`_hooks.clear()`) or after an index step (`_hooks[name].splice(0)`),
|
|
739
|
+
// which reaches the same table through the array it holds.
|
|
740
|
+
if (ts.isCallExpression(parent) && parent.expression === cursor
|
|
741
|
+
&& ts.isPropertyAccessExpression(cursor)) {
|
|
742
|
+
return MUTATING_METHODS.has(cursor.name.text);
|
|
743
|
+
}
|
|
744
|
+
if (!ts.isPropertyAccessExpression(parent) && !ts.isElementAccessExpression(parent))
|
|
745
|
+
return false;
|
|
746
|
+
cursor = parent;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** B16 — reaching the Cordis bookkeeping that owns other layers' registrations. */
|
|
750
|
+
function checkTeardown(file, node, accumulator) {
|
|
751
|
+
if (!ts.isPropertyAccessExpression(node))
|
|
752
|
+
return;
|
|
753
|
+
const outer = node.expression;
|
|
754
|
+
if (!ts.isPropertyAccessExpression(outer))
|
|
755
|
+
return;
|
|
756
|
+
if (!ts.isIdentifier(outer.expression) || !CONTEXT_RECEIVERS.has(outer.expression.text))
|
|
757
|
+
return;
|
|
758
|
+
const surface = TEARDOWN_SURFACES.find(entry => entry.service === outer.name.text && entry.member === node.name.text);
|
|
759
|
+
if (surface === undefined)
|
|
760
|
+
return;
|
|
761
|
+
if (!surface.readIsEnough && !isWriteTarget(node))
|
|
762
|
+
return;
|
|
763
|
+
accumulator.findings.push(tierB({
|
|
764
|
+
checkId: 'B16',
|
|
765
|
+
name: 'foreign-registration-teardown',
|
|
766
|
+
subject: `${surface.service}.${surface.member}`,
|
|
767
|
+
severity: 'critical',
|
|
768
|
+
title: `Reaches \`${surface.service}.${surface.member}\`, which owns other layers' registrations`,
|
|
769
|
+
detail: `\`ctx.${surface.service}.${surface.member}\` is ${surface.effect}. Cordis makes `
|
|
770
|
+
+ `\`ctx.${surface.service}\` an own property of the root context that every child inherits, so reaching it `
|
|
771
|
+
+ 'needs no `inject` declaration and nothing records that this layer did. A guard registered by a security '
|
|
772
|
+
+ 'plugin through `ctx.tools.guard()`, and every listener it composed, are removable this way — which is a '
|
|
773
|
+
+ 'wider reach than any single seam replacement, because it does not substitute a decision, it deletes the '
|
|
774
|
+
+ 'code that would have made one.'
|
|
775
|
+
+ (surface.readIsEnough
|
|
776
|
+
? ''
|
|
777
|
+
: ' Reading this surface is not the finding: an honest plugin counts the listeners on a seam to decide '
|
|
778
|
+
+ 'whether a prompt would reach a human. Only a write is raised.'),
|
|
779
|
+
evidence: at(file, node),
|
|
780
|
+
bypass: 'a computed member name, which C2 reports, or reaching the same table through a local bound earlier',
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
473
783
|
/**
|
|
474
784
|
* Run every Tier B check.
|
|
475
785
|
* @param input - the decoded package.
|
|
@@ -493,7 +803,10 @@ export function runTierB(input) {
|
|
|
493
803
|
checkSeamReplacement(file, node, accumulator);
|
|
494
804
|
checkSystemPrompt(file, node, accumulator);
|
|
495
805
|
checkNestedMount(file, node, accumulator);
|
|
806
|
+
checkWaterfallVeto(file, node, accumulator);
|
|
496
807
|
}
|
|
808
|
+
checkSeamWrite(file, node, accumulator);
|
|
809
|
+
checkTeardown(file, node, accumulator);
|
|
497
810
|
checkDynamicCode(file, node, accumulator);
|
|
498
811
|
checkCredentialRead(file, node, accumulator);
|
|
499
812
|
checkToolDescription(file, node, accumulator);
|