dsh-perm-gate 2.1.0 → 2.1.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 (49) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/INSTALL.ja.md +57 -1
  3. package/INSTALL.ko.md +54 -1
  4. package/INSTALL.md +53 -1
  5. package/INSTALL.zh.md +48 -1
  6. package/README.ja.md +84 -10
  7. package/README.ko.md +81 -10
  8. package/README.md +118 -22
  9. package/README.zh.md +86 -8
  10. package/cordis.patch.yml +34 -3
  11. package/dsh.plugin.json +22 -0
  12. package/lib/agent-identity.d.ts +43 -0
  13. package/lib/agent-identity.js +95 -0
  14. package/lib/client.js +114 -0
  15. package/lib/client.js.map +1 -1
  16. package/lib/compiler.d.ts +39 -0
  17. package/lib/compiler.js +106 -0
  18. package/lib/config.d.ts +95 -2
  19. package/lib/config.js +28 -3
  20. package/lib/engine.js +9 -2
  21. package/lib/evaluate.d.ts +9 -0
  22. package/lib/evaluate.js +189 -0
  23. package/lib/events.d.ts +13 -0
  24. package/lib/events.js +31 -0
  25. package/lib/index.d.ts +1 -1
  26. package/lib/index.js +235 -6
  27. package/lib/network-lifecycle.d.ts +107 -0
  28. package/lib/network-lifecycle.js +180 -0
  29. package/lib/network.d.ts +109 -0
  30. package/lib/network.js +196 -0
  31. package/lib/proxy.d.ts +160 -0
  32. package/lib/proxy.js +529 -0
  33. package/lib/risk.js +2 -3
  34. package/lib/rule-chain.d.ts +39 -0
  35. package/lib/rule-chain.js +222 -0
  36. package/lib/rule-dims.d.ts +139 -0
  37. package/lib/rule-dims.js +194 -0
  38. package/lib/rule.d.ts +25 -0
  39. package/lib/rule.js +15 -1
  40. package/lib/runtime.d.ts +87 -0
  41. package/lib/runtime.js +229 -2
  42. package/lib/session-sweep.d.ts +45 -0
  43. package/lib/session-sweep.js +153 -0
  44. package/lib/shadow.d.ts +34 -0
  45. package/lib/shadow.js +143 -0
  46. package/lib/watch.d.ts +42 -0
  47. package/lib/watch.js +170 -0
  48. package/package.json +8 -2
  49. package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/shadow.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Detect shadowed rules within a compiled ruleset.
3
+ *
4
+ * For each action partition (deny, allow, ask), check if any rule is
5
+ * dominated by an earlier rule in the same partition. A rule is dominated
6
+ * when:
7
+ * 1. Its tools pattern is a subset (or equal) of an earlier rule's tools
8
+ * 2. Its command pattern is a subset (or equal)
9
+ * 3. Its args/paths dimensions are subsets
10
+ * 4. Its new dimensions (params/absent/agents/when/network) are subsets
11
+ *
12
+ * This is conservative: it only flags rules that are DEFINITELY unreachable,
13
+ * not rules that MIGHT be unreachable under certain argument combinations.
14
+ */
15
+ export function detectShadows(ruleset) {
16
+ const shadowed = [];
17
+ const reasons = new Map();
18
+ for (const partition of [ruleset.deny, ruleset.allow, ruleset.ask]) {
19
+ for (let i = 0; i < partition.length; i++) {
20
+ const rule = partition[i];
21
+ if (!rule.enabled)
22
+ continue;
23
+ for (let j = 0; j < i; j++) {
24
+ const earlier = partition[j];
25
+ if (!earlier.enabled)
26
+ continue;
27
+ const dominatedBy = isDominated(earlier, rule);
28
+ if (dominatedBy !== undefined) {
29
+ shadowed.push(rule.index);
30
+ reasons.set(rule.index, `shadowed by rule #${earlier.index}: ${dominatedBy}`);
31
+ break; // already flagged by earliest dominator
32
+ }
33
+ }
34
+ }
35
+ }
36
+ return { shadowed, reasons };
37
+ }
38
+ /**
39
+ * Check if `candidate` is dominated by `dominator` (dominator always
40
+ * matches a superset of what candidate matches).
41
+ *
42
+ * Returns a reason string if dominated, undefined otherwise.
43
+ */
44
+ function isDominated(dominator, candidate) {
45
+ // tools: dominator's tools must be a superset (fewer or equal patterns = wider match).
46
+ // If dominator has no tools constraint (empty = every tool), it's a superset.
47
+ if (dominator.tools.length > 0 && candidate.tools.length > 0) {
48
+ // Both have tools: conservatively check if dominator's patterns are broader.
49
+ // Simple heuristic: if dominator has fewer patterns, it's likely broader.
50
+ // Full subset analysis would require pattern comparison — too expensive for hot path.
51
+ // We only flag when dominator has NO tools constraint (empty = every tool).
52
+ // For non-empty/non-empty, we conservatively don't flag.
53
+ if (dominator.tools.length <= candidate.tools.length) {
54
+ // Could be broader — but we need actual pattern comparison.
55
+ // Conservative: don't flag unless we can prove subset.
56
+ // Skip this dimension for now.
57
+ }
58
+ else {
59
+ return undefined; // dominator is stricter on tools
60
+ }
61
+ }
62
+ // If dominator has no tools and candidate has tools → dominator is broader ✓
63
+ // command: same logic — dominator with no command constraint is broader.
64
+ if (dominator.command.length > 0 && candidate.command.length > 0) {
65
+ if (dominator.command.length > candidate.command.length)
66
+ return undefined;
67
+ }
68
+ // args: same logic.
69
+ if (dominator.args.length > 0 && candidate.args.length > 0) {
70
+ if (dominator.args.length > candidate.args.length)
71
+ return undefined;
72
+ }
73
+ // paths: same logic.
74
+ if (dominator.paths.length > 0 && candidate.paths.length > 0) {
75
+ if (dominator.paths.length > candidate.paths.length)
76
+ return undefined;
77
+ }
78
+ // New dimensions: if dominator has fewer constraints, it's broader.
79
+ // params
80
+ if (dominator.params.length > 0 && candidate.params.length > 0) {
81
+ if (dominator.params.length > candidate.params.length)
82
+ return undefined;
83
+ }
84
+ // absent
85
+ if (dominator.absent.length > 0 && candidate.absent.length > 0) {
86
+ if (dominator.absent.length > candidate.absent.length)
87
+ return undefined;
88
+ }
89
+ // agents
90
+ if (dominator.agents.length > 0 && candidate.agents.length > 0) {
91
+ if (dominator.agents.length > candidate.agents.length)
92
+ return undefined;
93
+ }
94
+ // when: dominator with no when = broader
95
+ if (dominator.when !== undefined && candidate.when === undefined) {
96
+ return undefined; // dominator is stricter
97
+ }
98
+ // network: dominator with no network = broader
99
+ if (dominator.network !== undefined && candidate.network === undefined) {
100
+ return undefined; // dominator is stricter
101
+ }
102
+ // If we get here, dominator has no constraints that candidate doesn't also
103
+ // have (or has fewer/equal constraints). This is a potential shadow.
104
+ // Only flag if dominator is strictly broader on at least one dimension.
105
+ const dominatorBroad = isStrictlyBroader(dominator, candidate);
106
+ if (dominatorBroad !== undefined)
107
+ return dominatorBroad;
108
+ return undefined;
109
+ }
110
+ /**
111
+ * Check if dominator is strictly broader than candidate on at least one dimension.
112
+ */
113
+ function isStrictlyBroader(dominator, candidate) {
114
+ // tools: empty = every tool (broader)
115
+ if (dominator.tools.length === 0 && candidate.tools.length > 0) {
116
+ return `dominator has no tools constraint (matches all tools)`;
117
+ }
118
+ // command: empty = no command constraint (broader)
119
+ if (dominator.command.length === 0 && candidate.command.length > 0) {
120
+ return `dominator has no command constraint`;
121
+ }
122
+ // args: empty = no args constraint (broader)
123
+ if (dominator.args.length === 0 && candidate.args.length > 0) {
124
+ return `dominator has no args constraint`;
125
+ }
126
+ // paths: empty = no paths constraint (broader)
127
+ if (dominator.paths.length === 0 && candidate.paths.length > 0) {
128
+ return `dominator has no paths constraint`;
129
+ }
130
+ // params: empty = no params constraint (broader)
131
+ if (dominator.params.length === 0 && candidate.params.length > 0) {
132
+ return `dominator has no params constraint`;
133
+ }
134
+ // absent: empty = no absent constraint (broader)
135
+ if (dominator.absent.length === 0 && candidate.absent.length > 0) {
136
+ return `dominator has no absent constraint`;
137
+ }
138
+ // agents: empty = no agents constraint (broader)
139
+ if (dominator.agents.length === 0 && candidate.agents.length > 0) {
140
+ return `dominator has no agents constraint`;
141
+ }
142
+ return undefined;
143
+ }
package/lib/watch.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface WatcherOptions {
2
+ /** Debounce interval in ms. Default 300. */
3
+ readonly debounceMs?: number;
4
+ /** Maximum number of workspace watchers to keep. Default 10. */
5
+ readonly maxWatchers?: number;
6
+ /** Logger sink. */
7
+ readonly logger: {
8
+ warn(message: string): void;
9
+ };
10
+ }
11
+ /**
12
+ * Manages file watchers for rule files across workspaces.
13
+ * Each workspace gets its own watcher set; LRU eviction caps total count.
14
+ */
15
+ export declare class RuleWatcher {
16
+ private readonly watchers;
17
+ private readonly debounceMs;
18
+ private readonly maxWatchers;
19
+ private readonly logger;
20
+ constructor(options?: WatcherOptions);
21
+ /**
22
+ * Start watching rule files for a workspace.
23
+ *
24
+ * @param cwd - The workspace root.
25
+ * @param ruleFiles - Absolute paths to the effective rule files (chain).
26
+ * @param reload - Callback to invoke (debounced) on any change.
27
+ */
28
+ watch(cwd: string, ruleFiles: readonly string[], reload: () => void): void;
29
+ /**
30
+ * Stop watching a specific workspace.
31
+ */
32
+ unwatch(cwd: string): void;
33
+ /**
34
+ * Stop all watchers and clear state.
35
+ */
36
+ closeAll(): void;
37
+ /** Number of active workspace watchers. */
38
+ activeWatcherCount(): number;
39
+ /** Number of pending (debounced) reloads. */
40
+ pendingReloadCount(): number;
41
+ private evictIfNeeded;
42
+ }
package/lib/watch.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * File watching for dsh-perm-gate rule hot-reload.
3
+ *
4
+ * Watches the effective rules files (the chain resolved for each workspace)
5
+ * and triggers a debounced `runtime.reload()` when any file changes. Also
6
+ * monitors candidate files (expected-but-absent files) through their deepest
7
+ * existing ancestor directory, so mid-session file creation is adopted.
8
+ *
9
+ * Design:
10
+ * - Chokidar for cross-platform reliability (PerryLink-validated).
11
+ * - Debounce window: multiple rapid writes within `debounceMs` merge into
12
+ * one reload.
13
+ * - Chain-level: any file change triggers a full chain recompilation.
14
+ * - LRU eviction: workspace watchers are capped; least-recently-used are
15
+ * closed when the cap is exceeded.
16
+ * - All registrations go through `ctx.effect()` for clean teardown.
17
+ */
18
+ import { watch as chokidarWatch } from 'chokidar';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { statSync } from 'node:fs';
21
+ // ─── RuleWatcher ───────────────────────────────────────────────────────────
22
+ /**
23
+ * Manages file watchers for rule files across workspaces.
24
+ * Each workspace gets its own watcher set; LRU eviction caps total count.
25
+ */
26
+ export class RuleWatcher {
27
+ watchers = new Map();
28
+ debounceMs;
29
+ maxWatchers;
30
+ logger;
31
+ constructor(options = { logger: console }) {
32
+ this.debounceMs = options.debounceMs ?? 300;
33
+ this.maxWatchers = options.maxWatchers ?? 10;
34
+ this.logger = options.logger;
35
+ }
36
+ /**
37
+ * Start watching rule files for a workspace.
38
+ *
39
+ * @param cwd - The workspace root.
40
+ * @param ruleFiles - Absolute paths to the effective rule files (chain).
41
+ * @param reload - Callback to invoke (debounced) on any change.
42
+ */
43
+ watch(cwd, ruleFiles, reload) {
44
+ const key = resolve(cwd).toLowerCase();
45
+ // If already watching this workspace, just update the access time.
46
+ const existing = this.watchers.get(key);
47
+ if (existing !== undefined) {
48
+ existing.lastAccess = Date.now();
49
+ return;
50
+ }
51
+ // Evict LRU if at capacity.
52
+ this.evictIfNeeded();
53
+ // Collect all paths to watch: effective files + candidate file ancestors.
54
+ const paths = new Set();
55
+ for (const file of ruleFiles) {
56
+ paths.add(file);
57
+ // T3.3: Watch deepest existing ancestor for candidate files.
58
+ const ancestor = deepestExistingAncestor(file);
59
+ if (ancestor !== undefined)
60
+ paths.add(ancestor);
61
+ }
62
+ const watcher = chokidarWatch([...paths], {
63
+ ignoreInitial: true,
64
+ persistent: true,
65
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
66
+ });
67
+ let timer = null;
68
+ const debouncedReload = () => {
69
+ if (timer !== null)
70
+ clearTimeout(timer);
71
+ timer = setTimeout(() => {
72
+ timer = null;
73
+ try {
74
+ reload();
75
+ }
76
+ catch (error) {
77
+ this.logger.warn(`[dsh-perm-gate] rule reload failed: ${String(error)}`);
78
+ }
79
+ }, this.debounceMs);
80
+ };
81
+ watcher.on('change', debouncedReload);
82
+ watcher.on('add', debouncedReload);
83
+ watcher.on('unlink', debouncedReload);
84
+ watcher.on('error', (error) => {
85
+ this.logger.warn(`[dsh-perm-gate] watcher error: ${String(error)}`);
86
+ });
87
+ const entry = { cwd, watcher, timer, lastAccess: Date.now() };
88
+ this.watchers.set(key, entry);
89
+ }
90
+ /**
91
+ * Stop watching a specific workspace.
92
+ */
93
+ unwatch(cwd) {
94
+ const key = resolve(cwd).toLowerCase();
95
+ const entry = this.watchers.get(key);
96
+ if (entry === undefined)
97
+ return;
98
+ if (entry.timer !== null)
99
+ clearTimeout(entry.timer);
100
+ void entry.watcher.close();
101
+ this.watchers.delete(key);
102
+ }
103
+ /**
104
+ * Stop all watchers and clear state.
105
+ */
106
+ closeAll() {
107
+ for (const entry of this.watchers.values()) {
108
+ if (entry.timer !== null)
109
+ clearTimeout(entry.timer);
110
+ void entry.watcher.close();
111
+ }
112
+ this.watchers.clear();
113
+ }
114
+ /** Number of active workspace watchers. */
115
+ activeWatcherCount() {
116
+ return this.watchers.size;
117
+ }
118
+ /** Number of pending (debounced) reloads. */
119
+ pendingReloadCount() {
120
+ let count = 0;
121
+ for (const entry of this.watchers.values()) {
122
+ if (entry.timer !== null)
123
+ count++;
124
+ }
125
+ return count;
126
+ }
127
+ // ─── LRU eviction ──────────────────────────────────────────────────
128
+ evictIfNeeded() {
129
+ if (this.watchers.size < this.maxWatchers)
130
+ return;
131
+ // Find the least-recently-used entry.
132
+ let oldestKey;
133
+ let oldestTime = Infinity;
134
+ for (const [key, entry] of this.watchers) {
135
+ if (entry.lastAccess < oldestTime) {
136
+ oldestTime = entry.lastAccess;
137
+ oldestKey = key;
138
+ }
139
+ }
140
+ if (oldestKey !== undefined) {
141
+ this.unwatch(oldestKey);
142
+ }
143
+ }
144
+ }
145
+ // ─── Helpers ───────────────────────────────────────────────────────────────
146
+ /**
147
+ * Find the deepest existing ancestor directory of a file path.
148
+ * Used to watch for candidate files (expected-but-absent) through their
149
+ * closest existing parent.
150
+ */
151
+ function deepestExistingAncestor(filePath) {
152
+ let dir = dirname(resolve(filePath));
153
+ let prev = '';
154
+ while (dir !== prev) {
155
+ if (existsSafe(dir))
156
+ return dir;
157
+ prev = dir;
158
+ dir = dirname(dir);
159
+ }
160
+ return undefined;
161
+ }
162
+ function existsSafe(path) {
163
+ try {
164
+ const stat = statSync(path);
165
+ return stat.isDirectory();
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "DSH permission-gate for the DeepSeek Harness 0.1.2+ line: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "lib",
8
+ "scripts/patch-permission-glyph.mjs",
8
9
  "cordis.patch.yml",
10
+ "dsh.plugin.json",
9
11
  "assets",
10
12
  "examples",
11
13
  "README.md",
@@ -54,7 +56,8 @@
54
56
  "./package.json": "./package.json"
55
57
  },
56
58
  "bin": {
57
- "dsh-perm-gate": "./lib/cli.js"
59
+ "dsh-perm-gate": "./lib/cli.js",
60
+ "dsh-perm-gate-patch-glyph": "./scripts/patch-permission-glyph.mjs"
58
61
  },
59
62
  "engines": {
60
63
  "node": ">=20",
@@ -65,6 +68,7 @@
65
68
  },
66
69
  "dependencies": {
67
70
  "@deepseek-ai/schemastery": "^3.18.0",
71
+ "chokidar": "^5.0.0",
68
72
  "yaml": "^2.5.0"
69
73
  },
70
74
  "devDependencies": {
@@ -89,6 +93,8 @@
89
93
  "typecheck": "tsc --noEmit && tsc -p tsconfig.client.json --noEmit",
90
94
  "lint": "eslint src test scripts tsdown.config.ts eslint.config.mjs",
91
95
  "test": "vitest run",
96
+ "patch:glyph": "node scripts/patch-permission-glyph.mjs",
97
+ "patch:glyph:check": "node scripts/patch-permission-glyph.mjs --check",
92
98
  "verify:line": "node scripts/verify-line.mjs new",
93
99
  "verify:lines": "node scripts/verify-line.mjs both",
94
100
  "release:latest": "npm publish --tag latest",
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Add `permissive-full` to the DSH composer's permission-glyph map.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * The composer's permission picker renders an icon per preset, looked up in a
8
+ * CLOSED map inside `@deepseek-ai/dsh-client-ui-conversation`:
9
+ *
10
+ * const permissionGlyphs = new Map([
11
+ * ["read-only", <svg …/>],
12
+ * ["permissive", <svg …/>], // the 自动审查 tier's shield+eye
13
+ * ["workspace-write", <svg …/>],
14
+ * ["danger-full-access", <svg …/>],
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 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.
24
+ *
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.
28
+ *
29
+ * VOLUNTARY, NOT AUTOMATIC
30
+ * ------------------------
31
+ * This edits a **host** package. It is deliberately NOT wired to `postinstall`:
32
+ * a plugin must not rewrite the harness it is installed into without being
33
+ * asked. Run it yourself, and re-run it after every DSH upgrade — a DSH
34
+ * upgrade replaces the file and the patch is gone. Reinstalling this plugin
35
+ * does NOT bring it back: `dsh plugin --profile web add …` writes only the
36
+ * profile's own `node_modules`, and on a stock install the DSH package, the
37
+ * profile's `node_modules` entry and this bundle are three paths to ONE file
38
+ * (a symlink and a junction into the same inode).
39
+ *
40
+ * USAGE
41
+ * npx dsh-perm-gate-patch-glyph # patch the bundle it finds
42
+ * npx dsh-perm-gate-patch-glyph --check # report only; exit 1 if missing
43
+ * node scripts/patch-permission-glyph.mjs <path/to/client.js>
44
+ *
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
47
+ * write a bundle it cannot slice correctly, so it can never half-apply.
48
+ */
49
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
50
+ import { execFileSync } from 'node:child_process'
51
+ import { homedir } from 'node:os'
52
+ import { dirname, join, resolve } from 'node:path'
53
+ import { pathToFileURL } from 'node:url'
54
+
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
+ /** Relative path from a package root to the bundle holding the map. */
60
+ const BUNDLE = join('@deepseek-ai', 'dsh-client-ui-conversation', 'lib', 'client.js')
61
+
62
+ /** Thrown for every refusal; the CLI turns it into an exit code 1. */
63
+ export class GlyphPatchError extends Error {}
64
+
65
+ /**
66
+ * Candidate bundle paths, most specific first. Pure: no filesystem access, so
67
+ * the probe order is unit-testable.
68
+ *
69
+ * `execDir` is `dirname(process.execPath)`. Deriving from the running node
70
+ * binary — never from a hardcoded nvm version or install path — is what keeps
71
+ * the script working across node upgrades and re-pointed install symlinks.
72
+ *
73
+ * @param {{ execDir: string, cwd: string, dshHome: string }} where
74
+ * @returns {string[]} absolute candidate paths
75
+ */
76
+ export function candidateBundles({ execDir, cwd, dshHome }) {
77
+ 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.
83
+ join(cwd, 'node_modules', BUNDLE),
84
+ // 4. The hoisted scope shared by every profile under DSH_HOME.
85
+ join(dshHome, 'profiles', 'node_modules', BUNDLE),
86
+ ]
87
+ }
88
+
89
+ /**
90
+ * Extract the full `["<key>", …]` map entry, by bracket depth.
91
+ *
92
+ * The scan starts ON the entry's opening `[` (the one immediately before the
93
+ * key), so depth returns to zero exactly at that entry's closing `]`. Starting
94
+ * anywhere else — e.g. at the first `(` of the value expression — ends the
95
+ * slice in the wrong place and yields a broken bundle.
96
+ *
97
+ * Safe here because the entry's only string literals (svg path `d` attributes)
98
+ * contain no brackets.
99
+ *
100
+ * @param {string} source
101
+ * @param {string} key
102
+ * @returns {string} the entry, brackets included
103
+ */
104
+ export function sliceEntry(source, key) {
105
+ const start = source.indexOf(`["${key}",`)
106
+ if (start === -1) throw new GlyphPatchError(`anchor not found: ["${key}",`)
107
+ let depth = 0
108
+ for (let i = start; i < source.length; i += 1) {
109
+ const ch = source[i]
110
+ if (ch === '[' || ch === '(' || ch === '{') depth += 1
111
+ else if (ch === ']' || ch === ')' || ch === '}') {
112
+ depth -= 1
113
+ if (depth === 0) return source.slice(start, i + 1)
114
+ }
115
+ }
116
+ throw new GlyphPatchError(`unterminated entry for ["${key}",`)
117
+ }
118
+
119
+ /**
120
+ * Clone the `permissive` glyph entry onto the `permissive-full` key.
121
+ *
122
+ * @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).
125
+ */
126
+ 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`)
131
+ }
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 }
134
+ }
135
+
136
+ /** Resolve the bundle to patch, or throw with an explanation. */
137
+ export function findBundle(argv, where) {
138
+ const explicit = argv.slice(2).find((arg) => !arg.startsWith('-'))
139
+ if (explicit !== undefined && explicit !== '') {
140
+ const p = resolve(explicit)
141
+ if (!existsSync(p)) throw new GlyphPatchError(`no such file: ${p}`)
142
+ return p
143
+ }
144
+ for (const candidate of candidateBundles(where)) {
145
+ if (existsSync(candidate)) return candidate
146
+ }
147
+ throw new GlyphPatchError(
148
+ 'could not locate @deepseek-ai/dsh-client-ui-conversation.\n' +
149
+ 'Pass the bundle path explicitly:\n' +
150
+ ` ${argv[1] ?? 'dsh-perm-gate-patch-glyph'} <…>/dsh-client-ui-conversation/lib/client.js`,
151
+ )
152
+ }
153
+
154
+ function main(argv) {
155
+ const check = argv.includes('--check')
156
+ const where = {
157
+ execDir: dirname(process.execPath),
158
+ cwd: process.cwd(),
159
+ dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
160
+ }
161
+
162
+ const bundle = findBundle(argv, where)
163
+ const original = readFileSync(bundle, 'utf8')
164
+ const { changed, source: patched } = applyGlyphPatch(original)
165
+
166
+ if (!changed) {
167
+ process.stdout.write(`patch-permission-glyph: ["${TARGET_KEY}",] already present in\n ${bundle}\n`)
168
+ return 0
169
+ }
170
+ if (check) {
171
+ 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',
174
+ )
175
+ return 1
176
+ }
177
+
178
+ // Back up once, then write.
179
+ const backup = `${bundle}.bak-permgate-glyph`
180
+ if (!existsSync(backup)) copyFileSync(bundle, backup)
181
+ writeFileSync(bundle, patched, 'utf8')
182
+
183
+ // Fail loudly rather than leaving a broken bundle behind.
184
+ try {
185
+ execFileSync(process.execPath, ['--check', bundle], { stdio: 'pipe' })
186
+ } catch (error) {
187
+ copyFileSync(backup, bundle)
188
+ throw new GlyphPatchError(`syntax check failed; restored the backup.\n${String(error?.stderr ?? error)}`)
189
+ }
190
+
191
+ process.stdout.write(
192
+ `patch-permission-glyph: added ["${TARGET_KEY}",] to\n ${bundle}\n` +
193
+ ` backup: ${backup}\n` +
194
+ ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n',
195
+ )
196
+ return 0
197
+ }
198
+
199
+ const invokedDirectly = process.argv[1] !== undefined
200
+ && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
201
+
202
+ if (invokedDirectly) {
203
+ try {
204
+ process.exitCode = main(process.argv)
205
+ } catch (error) {
206
+ process.stderr.write(`patch-permission-glyph: ${error instanceof Error ? error.message : String(error)}\n`)
207
+ process.exitCode = 1
208
+ }
209
+ }