dsh-perm-gate 2.1.2 → 2.6.2

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 (67) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +239 -0
  4. package/INSTALL.ja.md +41 -21
  5. package/INSTALL.ko.md +37 -19
  6. package/INSTALL.md +46 -23
  7. package/INSTALL.zh.md +33 -17
  8. package/README.ja.md +52 -12
  9. package/README.ko.md +53 -13
  10. package/README.md +68 -10
  11. package/README.zh.md +41 -10
  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 +11 -8
  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 +2 -2
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/allowlist.d.ts +27 -0
  28. package/lib/allowlist.js +103 -1
  29. package/lib/cli.js +11 -14
  30. package/lib/client.js +484 -6
  31. package/lib/client.js.map +1 -1
  32. package/lib/command-dispatcher.d.ts +22 -0
  33. package/lib/command-dispatcher.js +97 -0
  34. package/lib/command-semantics.d.ts +62 -0
  35. package/lib/command-semantics.js +6 -0
  36. package/lib/compiler.d.ts +11 -0
  37. package/lib/compiler.js +16 -0
  38. package/lib/config.d.ts +73 -12
  39. package/lib/config.js +56 -14
  40. package/lib/dry-run.d.ts +87 -0
  41. package/lib/dry-run.js +110 -0
  42. package/lib/engine.js +52 -2
  43. package/lib/evaluate.js +76 -2
  44. package/lib/events.d.ts +56 -2
  45. package/lib/events.js +92 -0
  46. package/lib/index.js +106 -26
  47. package/lib/parsers/git.d.ts +43 -0
  48. package/lib/parsers/git.js +404 -0
  49. package/lib/parsers/shell-cmds.d.ts +32 -0
  50. package/lib/parsers/shell-cmds.js +293 -0
  51. package/lib/preset.d.ts +13 -0
  52. package/lib/preset.js +17 -0
  53. package/lib/risk.d.ts +17 -3
  54. package/lib/risk.js +17 -3
  55. package/lib/rule-chain.js +13 -22
  56. package/lib/rule-dims.d.ts +35 -0
  57. package/lib/rule-dims.js +36 -0
  58. package/lib/rule.d.ts +36 -1
  59. package/lib/rule.js +56 -8
  60. package/lib/rules-view.d.ts +52 -0
  61. package/lib/rules-view.js +126 -0
  62. package/lib/runtime.d.ts +121 -7
  63. package/lib/runtime.js +204 -25
  64. package/lib/shell.d.ts +14 -0
  65. package/lib/shell.js +17 -0
  66. package/package.json +19 -2
  67. package/scripts/patch-permission-glyph.mjs +225 -42
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Add `permissive-full` to the DSH composer's permission-glyph map.
3
+ * Give this plugin's own permission tiers the composer glyphs the host will not
4
+ * draw for them.
4
5
  *
5
6
  * WHY THIS EXISTS
6
7
  * ---------------
@@ -9,22 +10,40 @@
9
10
  *
10
11
  * const permissionGlyphs = new Map([
11
12
  * ["read-only", <svg …/>],
12
- * ["permissive", <svg …/>], // the 自动审查 tier's shield+eye
13
13
  * ["workspace-write", <svg …/>],
14
- * ["danger-full-access", <svg …/>],
14
+ * [FULL_ACCESS, <svg …/>], // const FULL_ACCESS = "danger-full-access"
15
15
  * ]);
16
16
  * function permissionGlyph(value) { return permissionGlyphs.get(value); }
17
17
  *
18
18
  * The source comment states the rule: "host-configured names outside the design
19
19
  * set get none." There is no plugin-facing seam — the option objects the host
20
20
  * supplies carry only `{value, name, description}`, and the map itself is a
21
- * closure variable. So a plugin-contributed tier such as `permissive-full`
22
- * renders with no icon in the dropdown and falls back to plain text in the
23
- * trigger, while the built-in `permissive` tier shows the shield+eye.
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.
24
23
  *
25
- * The only way to give our tier the same glyph is to add an entry to that map.
26
- * This script does exactly that: it duplicates the `permissive` entry under the
27
- * `permissive-full` key, so the two tiers of one plugin look alike.
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.
28
47
  *
29
48
  * VOLUNTARY, NOT AUTOMATIC
30
49
  * ------------------------
@@ -42,8 +61,11 @@
42
61
  * npx dsh-perm-gate-patch-glyph --check # report only; exit 1 if missing
43
62
  * node scripts/patch-permission-glyph.mjs <path/to/client.js>
44
63
  *
45
- * With no argument it probes the usual global-install locations. The script is
46
- * idempotent (a second run is a no-op), backs the file up once, and refuses to
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
47
69
  * write a bundle it cannot slice correctly, so it can never half-apply.
48
70
  */
49
71
  import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
@@ -52,13 +74,23 @@ import { homedir } from 'node:os'
52
74
  import { dirname, join, resolve } from 'node:path'
53
75
  import { pathToFileURL } from 'node:url'
54
76
 
55
- /** The map key whose entry is cloned, and the key it is cloned onto. */
56
- export const SOURCE_KEY = 'permissive'
57
- export const TARGET_KEY = 'permissive-full'
58
-
59
77
  /** Relative path from a package root to the bundle holding the map. */
60
78
  const BUNDLE = join('@deepseek-ai', 'dsh-client-ui-conversation', 'lib', 'client.js')
61
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
+
62
94
  /** Thrown for every refusal; the CLI turns it into an exit code 1. */
63
95
  export class GlyphPatchError extends Error {}
64
96
 
@@ -66,6 +98,15 @@ export class GlyphPatchError extends Error {}
66
98
  * Candidate bundle paths, most specific first. Pure: no filesystem access, so
67
99
  * the probe order is unit-testable.
68
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
+ *
69
110
  * `execDir` is `dirname(process.execPath)`. Deriving from the running node
70
111
  * binary — never from a hardcoded nvm version or install path — is what keeps
71
112
  * the script working across node upgrades and re-pointed install symlinks.
@@ -75,19 +116,111 @@ export class GlyphPatchError extends Error {}
75
116
  */
76
117
  export function candidateBundles({ execDir, cwd, dshHome }) {
77
118
  return [
78
- // 1. `@deepseek-ai` scope hoisted beside the running node binary.
79
- join(execDir, 'node_modules', BUNDLE),
80
- // 2. The same scope nested inside the `dsh` package itself.
81
- join(execDir, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', BUNDLE),
82
- // 3. Whatever profile the caller is standing in.
119
+ // 1. Whatever profile the caller is standing in.
83
120
  join(cwd, 'node_modules', BUNDLE),
84
- // 4. The hoisted scope shared by every profile under DSH_HOME.
121
+ // 2. The hoisted scope every profile under DSH_HOME shares.
85
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),
86
127
  ]
87
128
  }
88
129
 
89
130
  /**
90
- * Extract the full `["<key>", …]` map entry, by bracket depth.
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.
91
224
  *
92
225
  * The scan starts ON the entry's opening `[` (the one immediately before the
93
226
  * key), so depth returns to zero exactly at that entry's closing `]`. Starting
@@ -98,12 +231,12 @@ export function candidateBundles({ execDir, cwd, dshHome }) {
98
231
  * contain no brackets.
99
232
  *
100
233
  * @param {string} source
101
- * @param {string} key
234
+ * @param {string} anchor the text returned by {@link resolveAnchor}
102
235
  * @returns {string} the entry, brackets included
103
236
  */
104
- export function sliceEntry(source, key) {
105
- const start = source.indexOf(`["${key}",`)
106
- if (start === -1) throw new GlyphPatchError(`anchor not found: ["${key}",`)
237
+ export function sliceEntry(source, anchor) {
238
+ const start = source.indexOf(anchor)
239
+ if (start === -1) throw new GlyphPatchError(`anchor not found: ${anchor}`)
107
240
  let depth = 0
108
241
  for (let i = start; i < source.length; i += 1) {
109
242
  const ch = source[i]
@@ -113,24 +246,41 @@ export function sliceEntry(source, key) {
113
246
  if (depth === 0) return source.slice(start, i + 1)
114
247
  }
115
248
  }
116
- throw new GlyphPatchError(`unterminated entry for ["${key}",`)
249
+ throw new GlyphPatchError(`unterminated entry for ${anchor}`)
117
250
  }
118
251
 
119
252
  /**
120
- * Clone the `permissive` glyph entry onto the `permissive-full` key.
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.
121
258
  *
122
259
  * @param {string} source the bundle's text
123
- * @returns {{ changed: boolean, source: string, entry: string | null }}
124
- * `changed: false` means the target key was already present (idempotent no-op).
260
+ * @returns {{ changed: boolean, source: string, added: string[], already: string[] }}
125
261
  */
126
262
  export function applyGlyphPatch(source) {
127
- if (source.includes(`["${TARGET_KEY}",`)) return { changed: false, source, entry: null }
128
- const sourceEntry = sliceEntry(source, SOURCE_KEY)
129
- if (!sourceEntry.includes('svg')) {
130
- throw new GlyphPatchError(`the ["${SOURCE_KEY}",] entry does not look like a glyph`)
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)
131
281
  }
132
- const clonedEntry = sourceEntry.replace(`["${SOURCE_KEY}",`, `["${TARGET_KEY}",`)
133
- return { changed: true, source: source.replace(sourceEntry, `${sourceEntry},\n\t\t\t${clonedEntry}`), entry: clonedEntry }
282
+
283
+ return { changed: added.length > 0, source: next, added, already }
134
284
  }
135
285
 
136
286
  /** Resolve the bundle to patch, or throw with an explanation. */
@@ -151,8 +301,29 @@ export function findBundle(argv, where) {
151
301
  )
152
302
  }
153
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
+
154
324
  function main(argv) {
155
325
  const check = argv.includes('--check')
326
+ const explicit = argv.slice(2).find((arg) => !arg.startsWith('-'))
156
327
  const where = {
157
328
  execDir: dirname(process.execPath),
158
329
  cwd: process.cwd(),
@@ -160,17 +331,27 @@ function main(argv) {
160
331
  }
161
332
 
162
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)
163
338
  const original = readFileSync(bundle, 'utf8')
164
- const { changed, source: patched } = applyGlyphPatch(original)
339
+ const { changed, source: patched, added, already } = applyGlyphPatch(original)
165
340
 
166
341
  if (!changed) {
167
- process.stdout.write(`patch-permission-glyph: ["${TARGET_KEY}",] already present in\n ${bundle}\n`)
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
+ )
168
347
  return 0
169
348
  }
170
349
  if (check) {
171
350
  process.stdout.write(
172
- `patch-permission-glyph: ["${TARGET_KEY}",] is MISSING from\n ${bundle}\n` +
173
- ' (--check: nothing written; re-run without --check to apply)\n',
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,
174
355
  )
175
356
  return 1
176
357
  }
@@ -189,9 +370,11 @@ function main(argv) {
189
370
  }
190
371
 
191
372
  process.stdout.write(
192
- `patch-permission-glyph: added ["${TARGET_KEY}",] to\n ${bundle}\n` +
373
+ `patch-permission-glyph: added ${added.map((key) => `["${key}",]`).join(' ')} to\n ${bundle}\n` +
374
+ already.map((key) => ` already present: ["${key}",]\n`).join('') +
193
375
  ` backup: ${backup}\n` +
194
- ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n',
376
+ ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n' +
377
+ otherNote,
195
378
  )
196
379
  return 0
197
380
  }