dsh-perm-gate 2.1.0 → 2.6.1

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.
Files changed (84) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +347 -0
  4. package/INSTALL.ja.md +84 -8
  5. package/INSTALL.ko.md +80 -9
  6. package/INSTALL.md +83 -8
  7. package/INSTALL.zh.md +70 -7
  8. package/README.ja.md +130 -16
  9. package/README.ko.md +128 -17
  10. package/README.md +179 -25
  11. package/README.zh.md +121 -12
  12. package/assets/allow1.png +0 -0
  13. package/assets/allow2.png +0 -0
  14. package/assets/gateoff.png +0 -0
  15. package/assets/llms.png +0 -0
  16. package/assets/permlist.png +0 -0
  17. package/assets/setting-head.png +0 -0
  18. package/assets/setting-rules.png +0 -0
  19. package/cordis.patch.yml +37 -3
  20. package/docs/baseline-guard.md +56 -0
  21. package/docs/plan-rules-to-settings.md +217 -0
  22. package/docs/rules-format.en.md +294 -0
  23. package/docs/rules-format.md +279 -0
  24. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  25. package/dsh.plugin.json +22 -0
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/agent-identity.d.ts +43 -0
  28. package/lib/agent-identity.js +95 -0
  29. package/lib/allowlist.d.ts +27 -0
  30. package/lib/allowlist.js +103 -1
  31. package/lib/cli.js +11 -14
  32. package/lib/client.js +598 -6
  33. package/lib/client.js.map +1 -1
  34. package/lib/command-dispatcher.d.ts +22 -0
  35. package/lib/command-dispatcher.js +97 -0
  36. package/lib/command-semantics.d.ts +62 -0
  37. package/lib/command-semantics.js +6 -0
  38. package/lib/compiler.d.ts +50 -0
  39. package/lib/compiler.js +122 -0
  40. package/lib/config.d.ts +161 -7
  41. package/lib/config.js +78 -11
  42. package/lib/dry-run.d.ts +87 -0
  43. package/lib/dry-run.js +110 -0
  44. package/lib/engine.js +61 -4
  45. package/lib/evaluate.d.ts +9 -0
  46. package/lib/evaluate.js +264 -1
  47. package/lib/events.d.ts +69 -2
  48. package/lib/events.js +123 -0
  49. package/lib/index.d.ts +1 -1
  50. package/lib/index.js +320 -11
  51. package/lib/network-lifecycle.d.ts +107 -0
  52. package/lib/network-lifecycle.js +180 -0
  53. package/lib/network.d.ts +109 -0
  54. package/lib/network.js +196 -0
  55. package/lib/parsers/git.d.ts +43 -0
  56. package/lib/parsers/git.js +404 -0
  57. package/lib/parsers/shell-cmds.d.ts +32 -0
  58. package/lib/parsers/shell-cmds.js +293 -0
  59. package/lib/preset.d.ts +13 -0
  60. package/lib/preset.js +17 -0
  61. package/lib/proxy.d.ts +160 -0
  62. package/lib/proxy.js +529 -0
  63. package/lib/risk.d.ts +17 -3
  64. package/lib/risk.js +19 -6
  65. package/lib/rule-chain.d.ts +39 -0
  66. package/lib/rule-chain.js +213 -0
  67. package/lib/rule-dims.d.ts +174 -0
  68. package/lib/rule-dims.js +230 -0
  69. package/lib/rule.d.ts +60 -0
  70. package/lib/rule.js +69 -7
  71. package/lib/rules-view.d.ts +52 -0
  72. package/lib/rules-view.js +126 -0
  73. package/lib/runtime.d.ts +208 -7
  74. package/lib/runtime.js +433 -27
  75. package/lib/session-sweep.d.ts +45 -0
  76. package/lib/session-sweep.js +153 -0
  77. package/lib/shadow.d.ts +34 -0
  78. package/lib/shadow.js +143 -0
  79. package/lib/shell.d.ts +14 -0
  80. package/lib/shell.js +17 -0
  81. package/lib/watch.d.ts +42 -0
  82. package/lib/watch.js +170 -0
  83. package/package.json +26 -3
  84. package/scripts/patch-permission-glyph.mjs +392 -0
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Give this plugin's own permission tiers the composer glyphs the host will not
4
+ * draw for them.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * The composer's permission picker renders an icon per preset, looked up in a
9
+ * CLOSED map inside `@deepseek-ai/dsh-client-ui-conversation`:
10
+ *
11
+ * const permissionGlyphs = new Map([
12
+ * ["read-only", <svg …/>],
13
+ * ["workspace-write", <svg …/>],
14
+ * [FULL_ACCESS, <svg …/>], // const FULL_ACCESS = "danger-full-access"
15
+ * ]);
16
+ * function permissionGlyph(value) { return permissionGlyphs.get(value); }
17
+ *
18
+ * The source comment states the rule: "host-configured names outside the design
19
+ * set get none." There is no plugin-facing seam — the option objects the host
20
+ * supplies carry only `{value, name, description}`, and the map itself is a
21
+ * closure variable. So a plugin-contributed tier renders with no icon in the
22
+ * dropdown and falls back to plain text in the trigger.
23
+ *
24
+ * The only way to give our tiers the same glyph is to add entries to that map.
25
+ * This script does exactly that — one glyph per tier, copied from the built-in
26
+ * tier whose file sandbox the plugin tier shares:
27
+ *
28
+ * permissive sandbox: workspace-write <- the ["workspace-write",] entry
29
+ * permissive-full sandbox: danger-full-access <- the [FULL_ACCESS,] entry
30
+ *
31
+ * WHY THE SOURCES ARE DECLARED AND THE ANCHOR IS RESOLVED
32
+ * -------------------------------------------------------
33
+ * DSH 0.1.2 shipped four glyphs, `permissive` among them; 0.1.5-rc.2 ships three
34
+ * (`read-only` / `workspace-write` / `danger-full-access`) and the string
35
+ * `permissive` no longer occurs anywhere in the bundle — so a splice anchored on
36
+ * `["permissive",` dies with "anchor not found" on the current host. Two
37
+ * properties keep this working across such a design-set change:
38
+ *
39
+ * 1. the source key is a declaration (`GLYPH_TARGETS`) rather than a literal
40
+ * buried in the splice, so re-pointing a tier is a one-line edit; and
41
+ * 2. the anchor for a key is RESOLVED, not assumed to be a string literal:
42
+ * `["<key>",` first, then the constant alias the bundle may use instead
43
+ * (`[FULL_ACCESS,` for `"danger-full-access"`).
44
+ *
45
+ * When a declared source key is gone, the refusal names the keys the map DOES
46
+ * carry, so the next drift is diagnosed from the error text alone.
47
+ *
48
+ * VOLUNTARY, NOT AUTOMATIC
49
+ * ------------------------
50
+ * This edits a **host** package. It is deliberately NOT wired to `postinstall`:
51
+ * a plugin must not rewrite the harness it is installed into without being
52
+ * asked. Run it yourself, and re-run it after every DSH upgrade — a DSH
53
+ * upgrade replaces the file and the patch is gone. Reinstalling this plugin
54
+ * does NOT bring it back: `dsh plugin --profile web add …` writes only the
55
+ * profile's own `node_modules`, and on a stock install the DSH package, the
56
+ * profile's `node_modules` entry and this bundle are three paths to ONE file
57
+ * (a symlink and a junction into the same inode).
58
+ *
59
+ * USAGE
60
+ * npx dsh-perm-gate-patch-glyph # patch the bundle it finds
61
+ * npx dsh-perm-gate-patch-glyph --check # report only; exit 1 if missing
62
+ * node scripts/patch-permission-glyph.mjs <path/to/client.js>
63
+ *
64
+ * With no argument it probes the profile scopes first and the CLI install second,
65
+ * and lists any other DSH install that carries the same bundle — on a host with
66
+ * both, only one of them serves the UI, so the explicit-path form is the answer
67
+ * when the probe picks the wrong one. The script is idempotent (a second run is a
68
+ * no-op), backs the file up once, applies every target or none, and refuses to
69
+ * write a bundle it cannot slice correctly, so it can never half-apply.
70
+ */
71
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
72
+ import { execFileSync } from 'node:child_process'
73
+ import { homedir } from 'node:os'
74
+ import { dirname, join, resolve } from 'node:path'
75
+ import { pathToFileURL } from 'node:url'
76
+
77
+ /** Relative path from a package root to the bundle holding the map. */
78
+ const BUNDLE = join('@deepseek-ai', 'dsh-client-ui-conversation', 'lib', 'client.js')
79
+
80
+ /**
81
+ * The glyphs to add: each plugin tier, and the built-in tier it copies.
82
+ *
83
+ * The pairing is by file-sandbox meaning, not by name similarity — `permissive`
84
+ * runs in the same sandbox as `workspace-write`, `permissive-full` in the same
85
+ * sandbox as `danger-full-access` — so the icon a user sees matches the access
86
+ * the tier actually grants. A tier listed here gets that built-in's entry
87
+ * verbatim apart from its key.
88
+ */
89
+ export const GLYPH_TARGETS = [
90
+ { target: 'permissive', source: 'workspace-write' },
91
+ { target: 'permissive-full', source: 'danger-full-access' },
92
+ ]
93
+
94
+ /** Thrown for every refusal; the CLI turns it into an exit code 1. */
95
+ export class GlyphPatchError extends Error {}
96
+
97
+ /**
98
+ * Candidate bundle paths, most specific first. Pure: no filesystem access, so
99
+ * the probe order is unit-testable.
100
+ *
101
+ * Profile scope before CLI-install scope, and that order is load-bearing on a
102
+ * machine with more than one DSH: a profile's UI is served by the profile's own
103
+ * dependency scope, not by whichever global CLI install happens to sit beside the
104
+ * running `node`. Measured on this project's own host — a global install under
105
+ * `dirname(node)` existed and shadowed the profile's bundle under an older probe
106
+ * order, so the patch reported success while the picker stayed icon-free. On a
107
+ * stock single-install machine every candidate is one inode, so the order decides
108
+ * nothing there; it only matters exactly when guessing wrong is silent.
109
+ *
110
+ * `execDir` is `dirname(process.execPath)`. Deriving from the running node
111
+ * binary — never from a hardcoded nvm version or install path — is what keeps
112
+ * the script working across node upgrades and re-pointed install symlinks.
113
+ *
114
+ * @param {{ execDir: string, cwd: string, dshHome: string }} where
115
+ * @returns {string[]} absolute candidate paths
116
+ */
117
+ export function candidateBundles({ execDir, cwd, dshHome }) {
118
+ return [
119
+ // 1. Whatever profile the caller is standing in.
120
+ join(cwd, 'node_modules', BUNDLE),
121
+ // 2. The hoisted scope every profile under DSH_HOME shares.
122
+ join(dshHome, 'profiles', 'node_modules', BUNDLE),
123
+ // 3. `@deepseek-ai` scope hoisted beside the running node binary.
124
+ join(execDir, 'node_modules', BUNDLE),
125
+ // 4. The same scope nested inside the `dsh` package itself.
126
+ join(execDir, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', BUNDLE),
127
+ ]
128
+ }
129
+
130
+ /**
131
+ * In-page string constants (`const FULL_ACCESS = "danger-full-access"`), as
132
+ * name -> value. First declaration wins, so a later reassignment cannot shadow
133
+ * the spelling the map was built with above it.
134
+ *
135
+ * The bundle may key a map entry by such a name rather than by a literal, which
136
+ * is the whole reason the anchor is resolved instead of concatenated.
137
+ *
138
+ * @param {string} source
139
+ * @returns {Map<string, string>} constant name -> its string value
140
+ */
141
+ export function pageConstants(source) {
142
+ const constants = new Map()
143
+ for (const match of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*"([^"]*)"/g)) {
144
+ if (!constants.has(match[1])) constants.set(match[1], match[2])
145
+ }
146
+ return constants
147
+ }
148
+
149
+ /**
150
+ * The `permissionGlyphs` map's text, brackets included.
151
+ *
152
+ * Parsed rather than assumed: the map is a `new Map([ … ])` closure variable, so
153
+ * its extent is found by bracket depth from its `[`. Reading the map (instead of
154
+ * only probing for one anchor inside it) is what lets a refusal report the keys
155
+ * the host actually renders.
156
+ *
157
+ * @param {string} source
158
+ * @returns {string} the map array text, from `[` to the matching `]`
159
+ */
160
+ export function glyphMapText(source) {
161
+ const start = source.search(/permissionGlyphs\s*=\s*new Map\(\s*\[/)
162
+ if (start === -1) throw new GlyphPatchError('permissionGlyphs map not found in this bundle')
163
+ const open = source.indexOf('[', start)
164
+ let depth = 0
165
+ for (let i = open; i < source.length; i += 1) {
166
+ const ch = source[i]
167
+ if (ch === '[' || ch === '(' || ch === '{') depth += 1
168
+ else if (ch === ']' || ch === ')' || ch === '}') {
169
+ depth -= 1
170
+ if (depth === 0) return source.slice(open, i + 1)
171
+ }
172
+ }
173
+ throw new GlyphPatchError('unterminated permissionGlyphs map')
174
+ }
175
+
176
+ /**
177
+ * The preset values the map renders a glyph for — literals as-is, and entries
178
+ * keyed by an in-page constant resolved to that constant's value.
179
+ *
180
+ * @param {string} source
181
+ * @returns {string[]} e.g. `['read-only', 'workspace-write', 'danger-full-access']`
182
+ */
183
+ export function glyphKeys(source) {
184
+ const constants = pageConstants(source)
185
+ const keys = []
186
+ for (const match of glyphMapText(source).matchAll(/\[\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*,/g)) {
187
+ if (match[1] !== undefined) keys.push(match[1])
188
+ else keys.push(constants.get(match[2]) ?? match[2])
189
+ }
190
+ return keys
191
+ }
192
+
193
+ /**
194
+ * Resolve the anchor text of one map entry, literal first, constant alias second.
195
+ *
196
+ * @param {string} source the bundle's text
197
+ * @param {string} key the preset value, e.g. `danger-full-access`
198
+ * @returns {{ anchor: string, index: number }}
199
+ * @throws {GlyphPatchError} when the host renders no glyph for `key`
200
+ */
201
+ export function resolveAnchor(source, key) {
202
+ for (const anchor of [`["${key}",`, `['${key}',`]) {
203
+ const index = source.indexOf(anchor)
204
+ if (index !== -1) return { anchor, index }
205
+ }
206
+ const name = [...pageConstants(source)].find(([, value]) => value === key)?.[0]
207
+ if (name !== undefined) {
208
+ const anchor = `[${name},`
209
+ const index = source.indexOf(anchor)
210
+ if (index !== -1) return { anchor, index }
211
+ throw new GlyphPatchError(
212
+ `anchor not found: ${anchor} (the alias for "${key}")`,
213
+ )
214
+ }
215
+ throw new GlyphPatchError(
216
+ `this host renders no glyph for "${key}" — the map carries: ` +
217
+ `${glyphKeys(source).map((k) => `"${k}"`).join(', ')}\n` +
218
+ ' Re-point GLYPH_TARGETS at a key the map does carry.',
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Extract the full `["<key>", …]` (or `[CONST, …]`) map entry, by bracket depth.
224
+ *
225
+ * The scan starts ON the entry's opening `[` (the one immediately before the
226
+ * key), so depth returns to zero exactly at that entry's closing `]`. Starting
227
+ * anywhere else — e.g. at the first `(` of the value expression — ends the
228
+ * slice in the wrong place and yields a broken bundle.
229
+ *
230
+ * Safe here because the entry's only string literals (svg path `d` attributes)
231
+ * contain no brackets.
232
+ *
233
+ * @param {string} source
234
+ * @param {string} anchor the text returned by {@link resolveAnchor}
235
+ * @returns {string} the entry, brackets included
236
+ */
237
+ export function sliceEntry(source, anchor) {
238
+ const start = source.indexOf(anchor)
239
+ if (start === -1) throw new GlyphPatchError(`anchor not found: ${anchor}`)
240
+ let depth = 0
241
+ for (let i = start; i < source.length; i += 1) {
242
+ const ch = source[i]
243
+ if (ch === '[' || ch === '(' || ch === '{') depth += 1
244
+ else if (ch === ']' || ch === ')' || ch === '}') {
245
+ depth -= 1
246
+ if (depth === 0) return source.slice(start, i + 1)
247
+ }
248
+ }
249
+ throw new GlyphPatchError(`unterminated entry for ${anchor}`)
250
+ }
251
+
252
+ /**
253
+ * Splice every declared glyph into the map.
254
+ *
255
+ * All or nothing: a source key this host does not carry throws before the caller
256
+ * can write anything, so a partially-glyphed bundle is impossible. Idempotent
257
+ * per target — a target already present is left untouched and reported as such.
258
+ *
259
+ * @param {string} source the bundle's text
260
+ * @returns {{ changed: boolean, source: string, added: string[], already: string[] }}
261
+ */
262
+ export function applyGlyphPatch(source) {
263
+ let next = source
264
+ const added = []
265
+ const already = []
266
+
267
+ for (const { target, source: sourceKey } of GLYPH_TARGETS) {
268
+ if (next.includes(`["${target}",`)) {
269
+ already.push(target)
270
+ continue
271
+ }
272
+ const { anchor } = resolveAnchor(next, sourceKey)
273
+ const sourceEntry = sliceEntry(next, anchor)
274
+ if (!sourceEntry.includes('svg')) {
275
+ throw new GlyphPatchError(`the ${anchor} entry does not look like a glyph`)
276
+ }
277
+ const clonedEntry = sourceEntry.replace(anchor, `["${target}",`)
278
+ // A function replacement: the entry is spliced verbatim, `$` never expanded.
279
+ next = next.replace(sourceEntry, () => `${sourceEntry},\n\t\t\t${clonedEntry}`)
280
+ added.push(target)
281
+ }
282
+
283
+ return { changed: added.length > 0, source: next, added, already }
284
+ }
285
+
286
+ /** Resolve the bundle to patch, or throw with an explanation. */
287
+ export function findBundle(argv, where) {
288
+ const explicit = argv.slice(2).find((arg) => !arg.startsWith('-'))
289
+ if (explicit !== undefined && explicit !== '') {
290
+ const p = resolve(explicit)
291
+ if (!existsSync(p)) throw new GlyphPatchError(`no such file: ${p}`)
292
+ return p
293
+ }
294
+ for (const candidate of candidateBundles(where)) {
295
+ if (existsSync(candidate)) return candidate
296
+ }
297
+ throw new GlyphPatchError(
298
+ 'could not locate @deepseek-ai/dsh-client-ui-conversation.\n' +
299
+ 'Pass the bundle path explicitly:\n' +
300
+ ` ${argv[1] ?? 'dsh-perm-gate-patch-glyph'} <…>/dsh-client-ui-conversation/lib/client.js`,
301
+ )
302
+ }
303
+
304
+ /**
305
+ * A warning block naming the other DSH installs that carry this bundle.
306
+ *
307
+ * Reported rather than patched: on a host with a global CLI install and a
308
+ * launcher-managed runtime, only one of them serves the UI in front of the user,
309
+ * and picking the wrong one is invisible — the patch succeeds and the picker
310
+ * stays icon-free. Naming the others turns that into a one-line fix (pass the
311
+ * path explicitly).
312
+ *
313
+ * @param {string[]} others bundle paths that exist but were not chosen
314
+ * @returns {string} an empty string when there is nothing to report
315
+ */
316
+ function describeOtherInstalls(others) {
317
+ if (others.length === 0) return ''
318
+ return ` note: ${others.length} other DSH install(s) carry this bundle — NOT patched:\n` +
319
+ others.map((path) => ` ${path}\n`).join('') +
320
+ ' If the tier still has no icon, the UI is served by one of those; re-run with\n' +
321
+ ' that path as the argument.\n'
322
+ }
323
+
324
+ function main(argv) {
325
+ const check = argv.includes('--check')
326
+ const explicit = argv.slice(2).find((arg) => !arg.startsWith('-'))
327
+ const where = {
328
+ execDir: dirname(process.execPath),
329
+ cwd: process.cwd(),
330
+ dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
331
+ }
332
+
333
+ const bundle = findBundle(argv, where)
334
+ const others = explicit === undefined
335
+ ? candidateBundles(where).filter((candidate) => candidate !== bundle && existsSync(candidate))
336
+ : []
337
+ const otherNote = describeOtherInstalls(others)
338
+ const original = readFileSync(bundle, 'utf8')
339
+ const { changed, source: patched, added, already } = applyGlyphPatch(original)
340
+
341
+ if (!changed) {
342
+ process.stdout.write(
343
+ `patch-permission-glyph: all glyphs already present in\n ${bundle}\n` +
344
+ ` present: ${already.map((key) => `["${key}",]`).join(' ')}\n` +
345
+ otherNote,
346
+ )
347
+ return 0
348
+ }
349
+ if (check) {
350
+ process.stdout.write(
351
+ `patch-permission-glyph: MISSING ${added.map((key) => `["${key}",]`).join(' ')} from\n ${bundle}\n` +
352
+ ` present: ${already.length > 0 ? already.map((key) => `["${key}",]`).join(' ') : '(none)'}\n` +
353
+ ' (--check: nothing written; re-run without --check to apply)\n' +
354
+ otherNote,
355
+ )
356
+ return 1
357
+ }
358
+
359
+ // Back up once, then write.
360
+ const backup = `${bundle}.bak-permgate-glyph`
361
+ if (!existsSync(backup)) copyFileSync(bundle, backup)
362
+ writeFileSync(bundle, patched, 'utf8')
363
+
364
+ // Fail loudly rather than leaving a broken bundle behind.
365
+ try {
366
+ execFileSync(process.execPath, ['--check', bundle], { stdio: 'pipe' })
367
+ } catch (error) {
368
+ copyFileSync(backup, bundle)
369
+ throw new GlyphPatchError(`syntax check failed; restored the backup.\n${String(error?.stderr ?? error)}`)
370
+ }
371
+
372
+ process.stdout.write(
373
+ `patch-permission-glyph: added ${added.map((key) => `["${key}",]`).join(' ')} to\n ${bundle}\n` +
374
+ already.map((key) => ` already present: ["${key}",]\n`).join('') +
375
+ ` backup: ${backup}\n` +
376
+ ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n' +
377
+ otherNote,
378
+ )
379
+ return 0
380
+ }
381
+
382
+ const invokedDirectly = process.argv[1] !== undefined
383
+ && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
384
+
385
+ if (invokedDirectly) {
386
+ try {
387
+ process.exitCode = main(process.argv)
388
+ } catch (error) {
389
+ process.stderr.write(`patch-permission-glyph: ${error instanceof Error ? error.message : String(error)}\n`)
390
+ process.exitCode = 1
391
+ }
392
+ }