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.
@@ -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 literal text of a string argument, or `null` when it is computed.
78
- * A computed argument is not a Tier B miss to paper over — it is a Tier C
79
- * 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.
80
85
  * @param node - the argument expression.
81
- * @returns the literal text, or `null`.
86
+ * @returns the text, or `null`.
82
87
  */
83
88
  function literalText(node) {
84
- if (node === undefined)
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 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.
100
105
  * @param file - the parsed file.
101
- * @returns specifier text paired with the node it came from.
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
- if (isRequire || isImport) {
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
- /** 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. */
140
160
  function checkImports(file, accumulator) {
141
- for (const { specifier, node } of moduleSpecifiers(file)) {
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: `Imports \`${specifier}\`, which ${unmediated}`,
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 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',
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: `Imports \`${specifier}\`, which can move bytes off the machine`,
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 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',
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: `Imports \`${specifier}\` rather than using the \`ctx.fs\` service`,
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 computed specifier, or `process.getBuiltinModule("node:fs")`',
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 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',
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, building the description by concatenation, or registering '
448
- + 'the definition through a value this tool does not track — a definition exported from one file and passed '
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);