dsh-plugin-inspector 0.6.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.
@@ -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))
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/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'),
package/lib/knowledge.js CHANGED
@@ -11,9 +11,15 @@
11
11
  */
12
12
  /**
13
13
  * Harness version these tables were transcribed from — the version string in
14
- * the checkout's own `packages/bundle/*&#47;package.json`.
14
+ * the shipped bundles' own `package.json`, which is `dsh`'s own version.
15
+ *
16
+ * Re-verified against `0.1.1-rc.2`, the release npm tags `latest`, by
17
+ * extracting each table from the published packages and diffing it against the
18
+ * one here. What moved: six rows inserted by the web bundle, three seam keys,
19
+ * and two sandbox traps this table had never carried. What did not: every row
20
+ * name, every row's bundle membership, and the waterfall event set.
15
21
  */
16
- export const HARNESS_REFERENCE = '0.1.0-rc.5';
22
+ export const HARNESS_REFERENCE = '0.1.1-rc.2';
17
23
  /**
18
24
  * The three profile bundles the harness ships, mapped to what each one is.
19
25
  * A package that *is* one of these composes the core rows rather than modifying
@@ -63,6 +69,7 @@ export const CORE_ROWS = new Map([
63
69
  ['cordis-host-runner', { module: '@deepseek-ai/dsh-cordis-host-runner', bundles: ['web-app'] }],
64
70
  ['credentials', { module: '@deepseek-ai/dsh-credentials-local', bundles: ['base'] }],
65
71
  ['directory-picker', { module: '@deepseek-ai/dsh-host-directory-picker-auto', bundles: ['web-app'] }],
72
+ ['file-reference-local', { module: '@deepseek-ai/dsh-file-reference-local', bundles: ['web-app'] }],
66
73
  ['fs-observation-policy', { module: '@deepseek-ai/dsh-fs-observation-policy', bundles: ['base'] }],
67
74
  ['fs-sandbox', { module: '@deepseek-ai/dsh-fs-sandbox', bundles: ['base'] }],
68
75
  ['goal', { module: '@deepseek-ai/dsh-goal', bundles: ['base'] }],
@@ -92,6 +99,7 @@ export const CORE_ROWS = new Map([
92
99
  ['session-projection', { module: '@deepseek-ai/dsh-session-projection', bundles: ['base'] }],
93
100
  ['session-projection-cache', { module: '@deepseek-ai/dsh-session-projection-cache', bundles: ['web-app'] }],
94
101
  ['session-query-sqlite', { module: '@deepseek-ai/dsh-session-query-sqlite', bundles: ['base'] }],
102
+ ['session-reference', { module: '@deepseek-ai/dsh-session-reference', bundles: ['web-app'] }],
95
103
  ['session-stats', { module: '@deepseek-ai/dsh-session-stats', bundles: ['web-app'] }],
96
104
  ['session-telemetry-otel', { module: '@deepseek-ai/dsh-session-telemetry-otel', bundles: ['base'] }],
97
105
  ['session-title', { module: '@deepseek-ai/dsh-session-title', bundles: ['base'] }],
@@ -137,6 +145,8 @@ export const CORE_ROWS = new Map([
137
145
  ['typert-gateway', { module: '@deepseek-ai/dsh-api-gateway', bundles: ['base'] }],
138
146
  ['typert-loader', { module: '@deepseek-ai/dsh-typert-loader', bundles: ['base'] }],
139
147
  ['ui-agent-preset', { module: '@deepseek-ai/dsh-client-ui-agent-preset', bundles: ['web-app'] }],
148
+ ['ui-attachment', { module: '@deepseek-ai/dsh-client-ui-attachment', bundles: ['web-app'] }],
149
+ ['ui-brand-official', { module: '@deepseek-ai/dsh-client-ui-brand-official', bundles: ['web-app'] }],
140
150
  ['ui-commands', { module: '@deepseek-ai/dsh-client-ui-commands', bundles: ['web-app'] }],
141
151
  ['ui-conversation', { module: '@deepseek-ai/dsh-client-ui-conversation', bundles: ['web-app'] }],
142
152
  ['ui-cordis', { module: '@deepseek-ai/dsh-client-ui-cordis', bundles: ['web-app'] }],
@@ -149,6 +159,8 @@ export const CORE_ROWS = new Map([
149
159
  ['ui-model-selection', { module: '@deepseek-ai/dsh-client-ui-model-selection', bundles: ['web-app'] }],
150
160
  ['ui-permission', { module: '@deepseek-ai/dsh-client-ui-permission-presets', bundles: ['web-app'] }],
151
161
  ['ui-plan', { module: '@deepseek-ai/dsh-client-ui-plan', bundles: ['web-app'] }],
162
+ ['ui-reference', { module: '@deepseek-ai/dsh-client-ui-reference', bundles: ['web-app'] }],
163
+ ['ui-renderer', { module: '@deepseek-ai/dsh-client-ui-renderer', bundles: ['web-app'] }],
152
164
  ['ui-settings', { module: '@deepseek-ai/dsh-client-ui-settings', bundles: ['web-app'] }],
153
165
  ['ui-settings-general', { module: '@deepseek-ai/dsh-client-ui-settings-general', bundles: ['web-app'] }],
154
166
  ['ui-settings-models', { module: '@deepseek-ai/dsh-client-ui-settings-models', bundles: ['web-app'] }],
@@ -207,10 +219,10 @@ export const SECURITY_ROW_IDS = new Map([
207
219
  * replaces a core service for every consumer in its scope.
208
220
  */
209
221
  export const SEAM_KEYS = new Set([
210
- 'agentDefaultModel', 'agentLoop', 'agentPresets', 'agents', 'apiProxy', 'approval',
211
- 'attachments', 'clientModules', 'codeRuntime', 'commands', 'compaction', 'credentials',
212
- 'directoryPicker', 'e2b', 'fs', 'goals', 'invariants', 'jobs', 'llm', 'lsp',
213
- 'messageFeedback', 'permissionPresets', 'planMode', 'sandbox', 'sandboxPolicy',
222
+ 'agentDefaultModel', 'agentLoop', 'agentPresets', 'agents', 'agentTeams', 'apiProxy', 'approval',
223
+ 'attachments', 'authorization', 'clientModules', 'codeRuntime', 'commands', 'compaction',
224
+ 'credentials', 'directoryPicker', 'e2b', 'fileReferences', 'fs', 'goals', 'invariants', 'jobs',
225
+ 'llm', 'lsp', 'messageFeedback', 'permissionPresets', 'planMode', 'sandbox', 'sandboxPolicy',
214
226
  'sessionPersistence', 'sessionProjectionCache', 'sessionProjections', 'sessionQuery',
215
227
  'sessionReferenceResolver', 'sessions', 'sessionTelemetry', 'sessionTitle', 'settings',
216
228
  'shell', 'shellEnv', 'skills', 'spillStore', 'storage', 'storageDomain', 'subagents',
@@ -218,10 +230,21 @@ export const SEAM_KEYS = new Set([
218
230
  'tools', 'typert', 'typertGateway', 'userQuestions', 'web', 'webServer', 'workflowEngine',
219
231
  'workspaceRegistry',
220
232
  ]);
221
- /** The subset of {@link SEAM_KEYS} whose replacement removes a constraint. */
233
+ /**
234
+ * The subset of {@link SEAM_KEYS} whose replacement removes a constraint.
235
+ *
236
+ * `authorization` joined the catalogue in `0.1.1-rc.2`: it is the registry of
237
+ * flows that obtain a credential through a conversation with the user, so
238
+ * providing it means owning that conversation. That is the same class of
239
+ * substitution as `credentials`, which this set already holds. The other two
240
+ * keys the release added are not: `fileReferences` decides which paths are
241
+ * offered for completion and `agentTeams` is the team form of `subagents`,
242
+ * which is deliberately not here either.
243
+ */
222
244
  export const SECURITY_SEAM_KEYS = new Set([
223
- 'approval', 'sandbox', 'sandboxPolicy', 'permissionPresets', 'credentials', 'subprocess',
224
- 'shell', 'fs', 'tools', 'agentLoop', 'sessionPersistence', 'sessionTelemetry', 'invariants',
245
+ 'approval', 'authorization', 'sandbox', 'sandboxPolicy', 'permissionPresets', 'credentials',
246
+ 'subprocess', 'shell', 'fs', 'tools', 'agentLoop', 'sessionPersistence', 'sessionTelemetry',
247
+ 'invariants',
225
248
  ]);
226
249
  /**
227
250
  * Waterfall events, from `EVENT_API` in the api-catalog. A listener on one of
@@ -229,6 +252,10 @@ export const SECURITY_SEAM_KEYS = new Set([
229
252
  * without calling it short-circuits the chain including the built-in behavior.
230
253
  *
231
254
  * Note there is no `fs/read-intent` — the intent family is write and edit only.
255
+ *
256
+ * Unchanged in `0.1.1-rc.2`. The catalogue's event set grew by four and lost
257
+ * one, but every addition carries `mode: 'emit'`, and only `mode: 'waterfall'`
258
+ * hands a listener the trailing `next` this set is about.
232
259
  */
233
260
  export const WATERFALL_EVENTS = new Set([
234
261
  'agent/pre-step', 'agent/request', 'agent/request-error', 'approval/request',
@@ -252,6 +279,8 @@ export const SANDBOX_DENIED_GLOBALS = new Map([
252
279
  ['setTimeout', "redirected to the cordis timer service (inject: ['timer'])"],
253
280
  ['setInterval', "redirected to the cordis timer service (inject: ['timer'])"],
254
281
  ['setImmediate', "redirected to the cordis timer service (inject: ['timer'])"],
282
+ ['clearTimeout', "redirected to the cordis timer service (inject: ['timer'])"],
283
+ ['clearInterval', "redirected to the cordis timer service (inject: ['timer'])"],
255
284
  ]);
256
285
  /**
257
286
  * Node builtins that start or evaluate code off the mediated path. A mounted
package/lib/npm.js CHANGED
@@ -7,16 +7,27 @@
7
7
  * fixed and their order is the guarantee:
8
8
  *
9
9
  * 1. read the version document (~3 KB) — which already answers
10
- * `hasInstallScript`, the install lifecycle scripts, and `dsh.bundle`;
10
+ * `hasInstallScript`, the install lifecycle scripts, `dsh.bundle`, and
11
+ * whether the registry holds a provenance attestation at all;
11
12
  * 2. download the tarball into memory;
12
13
  * 3. verify `dist.integrity` **before** anything parses a byte of it;
13
- * 4. decode in memory and analyse, exactly as the tarball path does.
14
+ * 4. read the provenance attestation, when step 1 said there is one, and check
15
+ * it against the bytes step 3 vouched for;
16
+ * 5. decode in memory and analyse, exactly as the tarball path does.
17
+ *
18
+ * Step 4 is the only request this module makes that is not unconditional, and
19
+ * it is skipped for every package the version document says has no attestation
20
+ * — which on the measured corpus is 28 packages in 40. It never fails an
21
+ * analysis: an endpoint that is down or a bundle that does not decode leaves
22
+ * the provenance fact in state `unreadable`, which is a different answer from
23
+ * `absent` and is printed as one.
14
24
  *
15
25
  * No subprocess, no disk write, no lifecycle script, and no `npm pack`.
16
26
  * @module dsh-plugin-inspector/npm
17
27
  */
28
+ import { provenanceAbsent, provenanceUnreadable, readProvenance, } from "./attestation.js";
18
29
  import { analyze } from "./inspect.js";
19
- import { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, resolvePackage, } from "./registry.js";
30
+ import { attestationUrl, DEFAULT_REGISTRY, fetchAttestation, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, } from "./registry.js";
20
31
  import { loadTarballBuffer } from "./source.js";
21
32
  /**
22
33
  * The metadata pre-check, which needs no tarball.
@@ -31,6 +42,46 @@ import { loadTarballBuffer } from "./source.js";
31
42
  export async function precheck(spec, options = {}) {
32
43
  return resolvePackage(parseSpec(spec), options);
33
44
  }
45
+ /**
46
+ * Read the registry's provenance attestation for a resolved package, when it
47
+ * has one, and check it against the bytes that were downloaded.
48
+ *
49
+ * Every failure on this path becomes a fact rather than a refusal. The tarball
50
+ * has already been checked against `dist.integrity`, so the analysis is sound
51
+ * whatever the attestation endpoint does, and turning a registry outage into
52
+ * exit code 2 would make provenance a precondition for reading a package
53
+ * instead of something reported about it.
54
+ * @param resolved - the packument reading for the version.
55
+ * @param registry - the registry base URL, without a trailing slash.
56
+ * @param tarball - the verified tarball bytes.
57
+ * @param options - where to fetch from.
58
+ * @returns what the registry says about the build origin, and what was checked.
59
+ */
60
+ async function readRegistryProvenance(resolved, registry, tarball, options) {
61
+ if (resolved.provenancePredicateType === null)
62
+ return provenanceAbsent();
63
+ let url;
64
+ try {
65
+ url = attestationUrl(registry, resolved.name, resolved.version);
66
+ }
67
+ catch (error) {
68
+ /* v8 ignore next -- `attestationUrl` refuses a name or version only with a RegistryError. */
69
+ if (!(error instanceof RegistryError))
70
+ throw error;
71
+ return provenanceUnreadable(null, error.message);
72
+ }
73
+ let body;
74
+ try {
75
+ body = await fetchAttestation(url, options);
76
+ }
77
+ catch (error) {
78
+ /* v8 ignore next -- `fetchAttestation` reports every refusal as a RegistryError. */
79
+ if (!(error instanceof RegistryError))
80
+ throw error;
81
+ return provenanceUnreadable(url, error.message);
82
+ }
83
+ return readProvenance(body, { name: resolved.name, version: resolved.version, tarball, url });
84
+ }
34
85
  /**
35
86
  * Fetch a published package and inspect it in memory.
36
87
  * @param spec - `<name>` or `<name>@<version>`; no version means the `latest` tag.
@@ -42,9 +93,11 @@ export async function precheck(spec, options = {}) {
42
93
  export async function inspectFromNpm(spec, options = {}) {
43
94
  const resolved = await precheck(spec, options);
44
95
  const verified = await fetchVerifiedTarball(resolved, options);
96
+ const registry = (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, '');
97
+ const attestation = await readRegistryProvenance(resolved, registry, verified.bytes, options);
45
98
  const provenance = {
46
99
  spec,
47
- registry: (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, ''),
100
+ registry,
48
101
  resolvedVersion: resolved.version,
49
102
  tarball: resolved.tarball,
50
103
  digest: verified.digest,
@@ -54,5 +107,5 @@ export async function inspectFromNpm(spec, options = {}) {
54
107
  tarballBytes: verified.bytes.byteLength,
55
108
  };
56
109
  const source = await loadTarballBuffer(verified.bytes, `npm:${resolved.name}@${resolved.version}`);
57
- return analyze(source, provenance);
110
+ return analyze(source, provenance, attestation);
58
111
  }
package/lib/registry.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * @module dsh-plugin-inspector/registry
20
20
  */
21
21
  import { createHash } from 'node:crypto';
22
+ import { MAX_ATTESTATION_BYTES } from "./attestation.js";
22
23
  import { INSTALL_LIFECYCLE_SCRIPTS } from "./knowledge.js";
23
24
  import { MAX_TOTAL_BYTES } from "./source.js";
24
25
  /** The public npm registry, used when no other is named. */
@@ -146,6 +147,7 @@ export async function resolvePackage(spec, options = {}) {
146
147
  integrity: typeof dist.integrity === 'string' ? dist.integrity : null,
147
148
  shasum: typeof dist.shasum === 'string' ? dist.shasum : null,
148
149
  hasInstallScript: record.hasInstallScript === true,
150
+ provenancePredicateType: asString(asRecord(asRecord(dist.attestations).provenance), 'predicateType'),
149
151
  lifecycleScripts: INSTALL_LIFECYCLE_SCRIPTS.filter(name => typeof scripts[name] === 'string'),
150
152
  bundlePatch: typeof bundle.patch === 'string' ? bundle.patch : null,
151
153
  metadataBytes: body.byteLength,
@@ -162,6 +164,16 @@ function asRecord(value) {
162
164
  ? value
163
165
  : {};
164
166
  }
167
+ /**
168
+ * Read a string field, or `null` when it is absent or of another type.
169
+ * @param record - the containing record.
170
+ * @param key - the field name.
171
+ * @returns the string, or `null`.
172
+ */
173
+ function asString(record, key) {
174
+ const value = record[key];
175
+ return typeof value === 'string' ? value : null;
176
+ }
165
177
  /**
166
178
  * Refuse a tarball URL that points somewhere other than the registry that
167
179
  * described it.
@@ -260,3 +272,45 @@ export async function fetchVerifiedTarball(resolved, options = {}) {
260
272
  const bytes = await readCapped(response, MAX_TARBALL_BYTES, `tarball ${resolved.tarball}`);
261
273
  return verifyIntegrity(bytes, resolved);
262
274
  }
275
+ /**
276
+ * The endpoint an npm-compatible registry serves a version's attestation
277
+ * bundle from.
278
+ *
279
+ * Built from the registry base URL rather than read out of
280
+ * `dist.attestations.url`, which is the opposite of how the tarball URL is
281
+ * handled and is deliberate: the tarball has to come from wherever the registry
282
+ * says because there is no other way to name it, so that URL is taken from the
283
+ * document and then refused unless it is same-origin. An attestation needs no
284
+ * such freedom. Constructing the path here means a doctored packument cannot
285
+ * redirect the request at all, not even to another path on the same host.
286
+ *
287
+ * The name and version are re-validated because both come out of the version
288
+ * document, which is registry-controlled: `name` is not necessarily the name
289
+ * that was asked for, and it is interpolated into a URL.
290
+ * @param registry - the registry base URL, without a trailing slash.
291
+ * @param name - the resolved package name.
292
+ * @param version - the resolved version.
293
+ * @returns the absolute URL.
294
+ * @throws RegistryError when the document's name or version would not address this endpoint.
295
+ */
296
+ export function attestationUrl(registry, name, version) {
297
+ if (!PACKAGE_NAME.test(name))
298
+ throw new RegistryError(`the version document names no npm package: ${name}`);
299
+ if (!VERSION_OR_TAG.test(version))
300
+ throw new RegistryError(`the version document names no version: ${version}`);
301
+ return `${registry}/-/npm/v1/attestations/${name}@${version}`;
302
+ }
303
+ /**
304
+ * Download an attestation document.
305
+ *
306
+ * Only ever called when the version document said there is one, so a package
307
+ * without provenance costs no request at all.
308
+ * @param url - the endpoint, from {@link attestationUrl}.
309
+ * @param options - where to fetch from.
310
+ * @returns the document as served.
311
+ * @throws RegistryError on a transport failure, a non-2xx status, or an oversized body.
312
+ */
313
+ export async function fetchAttestation(url, options = {}) {
314
+ const response = await get(url, 'application/json', options);
315
+ return readCapped(response, MAX_ATTESTATION_BYTES, 'attestation document');
316
+ }