dsh-xray 0.2.0 → 0.3.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 CHANGED
@@ -17,19 +17,22 @@ npx dsh-xray diff # declared (static layers) vs actual (dump-config) tree
17
17
  npx dsh-xray snapshot # content-addressed lockfile of the effective composition
18
18
  npx dsh-xray deps [svc] # service dependency graph: providers, consumers, disable-cascade
19
19
  npx dsh-xray health # plugin lifecycle health: failed fibers, pending injects, transitions
20
+ npx dsh-xray cost # estimated context-token cost per model-facing tool schema
21
+ npx dsh-xray shadow # services provided by multiple plugins
22
+ npx dsh-xray audit # static scan of out-of-tree plugins for sensitive touchpoints
20
23
  ```
21
24
 
22
25
  All commands take `--profile <name>` (default `web`) and `--json`. `diff` exits `1` when the trees disagree; `health` exits `1` when any plugin is unhealthy. `attribute`, `conflicts`, and `snapshot` are fully static: they work even when dsh cannot start. `deps` and `health` read the runtime snapshot the mounted plugin maintains at `$DSH_HOME/xray/runtime.json`.
23
26
 
24
27
  ## Agent tool
25
28
 
26
- Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summary | deps | health`), so an agent can answer "what capabilities do I have / what plugin provides X / why is Y unavailable" about itself.
29
+ Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summary | deps | health | cost | shadow`), so an agent can answer "what capabilities do I have / what plugin provides X / why is Y unavailable" about itself.
27
30
 
28
31
  ## Capabilities
29
32
 
30
33
  Diagnostic imaging for a running composition — complementary to [dsh-doctor](https://www.npmjs.com/package/dsh-doctor) (rescue & recovery).
31
34
 
32
- Shipped in 0.2.x:
35
+ Shipped in 0.3.x:
33
36
 
34
37
  - **Layer attribution** — which layer introduced each active plugin: kernel bundle, profile dependency, `cordis.patch.yml` insert, or repository source
35
38
  - **Declared vs. actual diff** — installed-but-inactive, uninstalled-but-lingering patch rows
@@ -39,11 +42,9 @@ Shipped in 0.2.x:
39
42
  - **Runtime health** — per-plugin fiber lifecycle state, startup failures, transition history (`health`)
40
43
  - **Agent self-introspection** — the `xray_composition` tool lets agents inspect their own capability set
41
44
 
42
- Planned:
43
-
44
- - **Capability audit** — what installed plugins actually touch: network egress, shell, filesystem, env; permission diff across updates
45
- - **Command/tool shadowing** — plugins registering the same command/tool, and which one silently wins
46
- - **Context cost** — tokens each plugin injects into agent context: tool schemas, prompt sections, skills
45
+ - **Capability audit** — heuristic static scan of out-of-tree plugins: network egress, shell, filesystem, env, eval (`audit`)
46
+ - **Service shadowing** — services claimed by multiple plugins, per-plugin tool/command registrations (`shadow`)
47
+ - **Context cost** — estimated tokens each model-facing tool schema occupies (`cost`)
47
48
 
48
49
  ## Install
49
50
 
package/README.zh.md CHANGED
@@ -17,19 +17,22 @@ npx dsh-xray diff # 声明(静态层)vs 实际(dump-config)组合树
17
17
  npx dsh-xray snapshot # 当前生效组合的内容寻址 lockfile
18
18
  npx dsh-xray deps [svc] # 服务依赖图:提供者、消费者、停用级联
19
19
  npx dsh-xray health # 插件生命周期健康:失败 fiber、等待中的注入、状态迁移史
20
+ npx dsh-xray cost # 每个模型可见工具 schema 的估算 token 占用
21
+ npx dsh-xray shadow # 被多个插件同时提供的服务
22
+ npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
20
23
  ```
21
24
 
22
25
  所有命令支持 `--profile <name>`(默认 `web`)和 `--json`。`diff` 在两棵树不一致时退出码 `1`;`health` 在有插件不健康时退出码 `1`。`attribute`、`conflicts`、`snapshot` 是纯静态的:dsh 起不来时照样能跑。`deps` 和 `health` 读取已挂载插件维护在 `$DSH_HOME/xray/runtime.json` 的运行时快照。
23
26
 
24
27
  ## Agent 工具
25
28
 
26
- 挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health`),agent 可以自答"我有哪些能力 / 哪个插件提供 X / 为什么 Y 不可用"。
29
+ 挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow`),agent 可以自答"我有哪些能力 / 哪个插件提供 X / 为什么 Y 不可用"。
27
30
 
28
31
  ## 能力
29
32
 
30
33
  对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补。
31
34
 
32
- 0.2.x 已交付:
35
+ 0.3.x 已交付:
33
36
 
34
37
  - **来源归因** — 每个活跃插件来自哪一层:内核 bundle / profile 依赖 / `cordis.patch.yml` insert / repository 源
35
38
  - **声明 vs 实际 diff** — 装了但没生效、卸了但残留 patch 行
@@ -39,11 +42,9 @@ npx dsh-xray health # 插件生命周期健康:失败 fiber、等待中的
39
42
  - **运行时健康** — 每个插件的 fiber 生命周期状态、启动失败、状态迁移史(`health`)
40
43
  - **Agent 自省** — `xray_composition` 工具让 agent 检视自己的能力集
41
44
 
42
- 规划中:
43
-
44
- - **能力审计**已装插件实际触碰什么:网络外发、shell、文件系统、环境变量;更新前后权限 diff
45
- - **命令/工具重名检测** — 注册同名命令/工具时,谁静默覆盖谁
46
- - **上下文成本** — 每个插件往 agent 上下文注入多少 token:工具 schema、prompt 片段、skill 文档
45
+ - **能力审计** — 对 out-of-tree 插件的启发式静态扫描:网络外发、shell、文件系统、环境变量、动态求值(`audit`)
46
+ - **服务重名检测** — 被多个插件同时提供的服务,及每插件工具/命令注册数(`shadow`)
47
+ - **上下文成本**每个模型可见工具 schema 的估算 token 占用(`cost`)
47
48
 
48
49
  ## 安装
49
50
 
package/bin/xray.js CHANGED
@@ -178,6 +178,57 @@ function cmdHealth(args) {
178
178
  if (result.unhealthy.length) process.exitCode = 1;
179
179
  }
180
180
 
181
+ function cmdCost(args) {
182
+ const snap = readRuntimeSnapshot();
183
+ const result = model.contextCost(snap);
184
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
185
+ console.log(
186
+ `~${result.totalTokens} tokens across ${result.toolCount} tool schema(s) (captured ${result.capturedAt})\n`,
187
+ );
188
+ for (const t of result.tools) {
189
+ const bar = '█'.repeat(Math.max(1, Math.round(t.share / 2)));
190
+ console.log(`${pad(t.name, 28)} ${pad(`~${t.tokens}`, 8)} ${pad(`${t.share}%`, 7)} ${bar}`);
191
+ }
192
+ }
193
+
194
+ function cmdShadow(args) {
195
+ const snap = readRuntimeSnapshot();
196
+ const result = model.shadowing(snap);
197
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
198
+ if (!result.services.length) console.log('no service is provided by more than one plugin');
199
+ for (const s of result.services) {
200
+ console.log(`${s.service}: provided by ${s.providers.join(' AND ')}`);
201
+ }
202
+ if (result.registrars.length) {
203
+ console.log('\n# tool/command registrars:');
204
+ for (const r of result.registrars) {
205
+ console.log(` ${r.plugin}: ${r.registrations} registration(s)`);
206
+ }
207
+ }
208
+ if (result.services.length) process.exitCode = 1;
209
+ }
210
+
211
+ function cmdAudit(args) {
212
+ const { collectAudit } = require('../lib/collect/audit.js');
213
+ const data = collectStatic(args.profile);
214
+ const result = collectAudit(data);
215
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
216
+ if (!result.plugins.length) {
217
+ return console.log(
218
+ 'no out-of-tree plugins installed (kernel bundles are the trusted baseline)',
219
+ );
220
+ }
221
+ for (const p of result.plugins) {
222
+ console.log(`${p.name}@${p.version} (${p.scannedFiles} file(s) scanned)`);
223
+ if (!p.categories.length) console.log(' no sensitive touchpoints detected');
224
+ for (const c of p.categories) {
225
+ console.log(
226
+ ` ${c.label}: ${c.files.slice(0, 3).join(', ')}${c.files.length > 3 ? ', …' : ''}`,
227
+ );
228
+ }
229
+ }
230
+ }
231
+
181
232
  const commands = {
182
233
  attribute: cmdAttribute,
183
234
  conflicts: cmdConflicts,
@@ -185,6 +236,9 @@ const commands = {
185
236
  snapshot: cmdSnapshot,
186
237
  deps: cmdDeps,
187
238
  health: cmdHealth,
239
+ cost: cmdCost,
240
+ shadow: cmdShadow,
241
+ audit: cmdAudit,
188
242
  };
189
243
 
190
244
  const args = parseArgs(process.argv.slice(2));
@@ -201,8 +255,11 @@ Commands:
201
255
  snapshot content-addressed lockfile of the effective composition
202
256
  deps service dependency graph from the live runtime snapshot
203
257
  health plugin lifecycle health from the live runtime snapshot
258
+ cost estimated context-token cost of each model-facing tool schema
259
+ shadow services provided by multiple plugins, and per-plugin registrations
260
+ audit static scan of out-of-tree plugins for sensitive touchpoints
204
261
 
205
- deps/health need the plugin mounted: dsh plugin --profile web add dsh-xray`);
262
+ deps/health/cost/shadow need the plugin mounted: dsh plugin --profile web add dsh-xray`);
206
263
  process.exit(args._[0] ? 2 : 0);
207
264
  }
208
265
  try {
@@ -0,0 +1,115 @@
1
+ // Audit collector: static scan of installed plugin sources for sensitive
2
+ // capability touchpoints. Heuristic by design — a flag means "this pattern
3
+ // appears in the shipped code", not "this plugin is malicious".
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const CATEGORIES = [
9
+ {
10
+ id: 'network',
11
+ label: 'network egress',
12
+ patterns: [
13
+ /\bfetch\s*\(/,
14
+ /require\(['"](?:node:)?https?['"]\)/,
15
+ /from\s+['"](?:node:)?https?['"]/,
16
+ /\bWebSocket\b/,
17
+ /\baxios\b/,
18
+ /\bnode-fetch\b/,
19
+ ],
20
+ },
21
+ {
22
+ id: 'shell',
23
+ label: 'subprocess / shell',
24
+ patterns: [
25
+ /require\(['"](?:node:)?child_process['"]\)/,
26
+ /from\s+['"](?:node:)?child_process['"]/,
27
+ /\bexecSync|\bexecFile|\bspawnSync?\b/,
28
+ /\bnode-pty\b/,
29
+ ],
30
+ },
31
+ {
32
+ id: 'fs',
33
+ label: 'filesystem',
34
+ patterns: [
35
+ /require\(['"](?:node:)?fs(?:\/promises)?['"]\)/,
36
+ /from\s+['"](?:node:)?fs(?:\/promises)?['"]/,
37
+ ],
38
+ },
39
+ {
40
+ id: 'env',
41
+ label: 'environment variables',
42
+ patterns: [/\bprocess\.env\b/],
43
+ },
44
+ {
45
+ id: 'eval',
46
+ label: 'dynamic code evaluation',
47
+ patterns: [/\beval\s*\(/, /new\s+Function\s*\(/],
48
+ },
49
+ ];
50
+
51
+ const SCAN_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.ts']);
52
+ const MAX_FILE_BYTES = 1024 * 1024;
53
+ const MAX_FILES = 400;
54
+
55
+ function* walkFiles(dir, depth = 0) {
56
+ if (depth > 6) return;
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const entry of entries) {
64
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
65
+ const full = path.join(dir, entry.name);
66
+ if (entry.isDirectory()) yield* walkFiles(full, depth + 1);
67
+ else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) yield full;
68
+ }
69
+ }
70
+
71
+ /** Scan one plugin package directory. Returns per-category file hits. */
72
+ function auditPackage(dir) {
73
+ const hits = new Map(); // category id -> Set of relative files
74
+ let scanned = 0;
75
+ for (const file of walkFiles(dir)) {
76
+ if (++scanned > MAX_FILES) break;
77
+ let text;
78
+ try {
79
+ if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
80
+ text = fs.readFileSync(file, 'utf8');
81
+ } catch {
82
+ continue;
83
+ }
84
+ for (const cat of CATEGORIES) {
85
+ if (hits.get(cat.id)?.size >= 5) continue; // enough evidence
86
+ if (cat.patterns.some((p) => p.test(text))) {
87
+ if (!hits.has(cat.id)) hits.set(cat.id, new Set());
88
+ hits.get(cat.id).add(path.relative(dir, file));
89
+ }
90
+ }
91
+ }
92
+ return {
93
+ scannedFiles: scanned,
94
+ categories: CATEGORIES.filter((c) => hits.has(c.id)).map((c) => ({
95
+ id: c.id,
96
+ label: c.label,
97
+ files: [...hits.get(c.id)],
98
+ })),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Audit every out-of-tree plugin in a profile (kernel @deepseek-ai/* packages
104
+ * are the trusted baseline; auditing them adds noise, not signal).
105
+ */
106
+ function collectAudit(staticData) {
107
+ const results = [];
108
+ for (const pkg of staticData.packages) {
109
+ if (!pkg.dir) continue;
110
+ results.push({ name: pkg.name, version: pkg.version, ...auditPackage(pkg.dir) });
111
+ }
112
+ return { schema: 'dsh-xray/audit@1', capturedAt: new Date().toISOString(), plugins: results };
113
+ }
114
+
115
+ module.exports = { collectAudit, auditPackage, CATEGORIES };
@@ -55,6 +55,27 @@ function summarizeEffect(meta, depth = 0) {
55
55
  return out;
56
56
  }
57
57
 
58
+ /** Rough token estimate: ~4 chars per token for English/JSON. */
59
+ function estimateTokens(text) {
60
+ return Math.ceil(text.length / 4);
61
+ }
62
+
63
+ /** Capture the model-facing tool schemas (name/description/parameters). */
64
+ function snapshotTools(ctx) {
65
+ try {
66
+ const tools = ctx.get?.('tools') ?? ctx.root?.tools;
67
+ const schemas = tools?.schemas?.();
68
+ if (!Array.isArray(schemas)) return [];
69
+ return schemas.map((s) => ({
70
+ name: s.name,
71
+ description: s.description ?? '',
72
+ tokens: estimateTokens(JSON.stringify(s)),
73
+ }));
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
58
79
  /**
59
80
  * Snapshot every registered plugin runtime plus the service store.
60
81
  * @param ctx a live Cordis context (any fiber's ctx reaches the shared registry)
@@ -100,7 +121,15 @@ function snapshotRegistry(ctx) {
100
121
  registrySize: ctx.registry.size,
101
122
  plugins,
102
123
  services,
124
+ tools: snapshotTools(ctx),
103
125
  };
104
126
  }
105
127
 
106
- module.exports = { snapshotRegistry, injectNames, provideNames, stateName };
128
+ module.exports = {
129
+ snapshotRegistry,
130
+ snapshotTools,
131
+ injectNames,
132
+ provideNames,
133
+ stateName,
134
+ estimateTokens,
135
+ };
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const os = require('node:os');
4
4
  const { snapshotRegistry, stateName } = require('./collect/runtime.js');
5
- const { serviceGraph, health } = require('./model.js');
5
+ const { serviceGraph, health, shadowing, contextCost } = require('./model.js');
6
6
 
7
7
  const name = 'dsh-xray';
8
8
 
@@ -101,7 +101,7 @@ function apply(ctx) {
101
101
  parameters: {
102
102
  view: {
103
103
  type: 'string',
104
- description: 'summary | deps | health (default summary)',
104
+ description: 'summary | deps | health | cost | shadow (default summary)',
105
105
  },
106
106
  },
107
107
  output: {
@@ -113,10 +113,13 @@ function apply(ctx) {
113
113
  snap.transitions = Object.fromEntries(transitions);
114
114
  if (args.view === 'deps') return serviceGraph(snap);
115
115
  if (args.view === 'health') return health(snap);
116
+ if (args.view === 'cost') return contextCost(snap);
117
+ if (args.view === 'shadow') return shadowing(snap);
116
118
  return {
117
119
  plugins: snap.plugins.length,
118
120
  unhealthy: health(snap).unhealthy.length,
119
121
  services: Object.keys(serviceGraph(snap).services).length,
122
+ toolSchemaTokens: contextCost(snap).totalTokens,
120
123
  capturedAt: snap.capturedAt,
121
124
  };
122
125
  },
package/lib/model.js CHANGED
@@ -247,4 +247,60 @@ function health(snap) {
247
247
  };
248
248
  }
249
249
 
250
- module.exports = { replayLayers, attribute, conflicts, diff, snapshot, serviceGraph, health };
250
+ /** F7: same-name registrations where a later writer silently shadows. */
251
+ function shadowing(snap) {
252
+ // Service ownership comes from the reflect store; callback `provide` is a
253
+ // secondary source. Cordis rejects same-layer duplicate tools itself, so
254
+ // what we surface is cross-source duplication: one service claimed by
255
+ // multiple plugins (isolation scopes make this legal — and invisible).
256
+ const out = { services: [], registrars: [] };
257
+ const providersByService = new Map();
258
+ for (const s of snap.services ?? []) {
259
+ if (!providersByService.has(s.name)) providersByService.set(s.name, []);
260
+ if (s.provider) providersByService.get(s.name).push(s.provider);
261
+ }
262
+ for (const p of snap.plugins) {
263
+ for (const s of p.provide) {
264
+ const list = providersByService.get(s) ?? [];
265
+ if (!list.includes(p.name)) providersByService.set(s, [...list, p.name]);
266
+ }
267
+ }
268
+ for (const [service, providers] of providersByService) {
269
+ if (providers.length > 1) out.services.push({ service, providers });
270
+ }
271
+ for (const p of snap.plugins) {
272
+ const registrations = p.fibers
273
+ .flatMap((f) => f.effects)
274
+ .filter((e) => /tools\.register|commands?\./.test(e?.label ?? '')).length;
275
+ if (registrations > 0) out.registrars.push({ plugin: p.name, registrations });
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** F8: estimated context cost of each model-facing tool schema. */
281
+ function contextCost(snap) {
282
+ const tools = (snap.tools ?? []).slice().sort((a, b) => b.tokens - a.tokens);
283
+ const total = tools.reduce((sum, t) => sum + t.tokens, 0);
284
+ return {
285
+ totalTokens: total,
286
+ toolCount: tools.length,
287
+ tools: tools.map((t) => ({
288
+ name: t.name,
289
+ tokens: t.tokens,
290
+ share: total ? Math.round((t.tokens / total) * 1000) / 10 : 0,
291
+ })),
292
+ capturedAt: snap.capturedAt,
293
+ };
294
+ }
295
+
296
+ module.exports = {
297
+ replayLayers,
298
+ attribute,
299
+ conflicts,
300
+ diff,
301
+ snapshot,
302
+ serviceGraph,
303
+ health,
304
+ shadowing,
305
+ contextCost,
306
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
5
5
  "repository": {
6
6
  "type": "git",