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.
- package/CHANGELOG.md +111 -0
- package/INSTALL.ja.md +57 -1
- package/INSTALL.ko.md +54 -1
- package/INSTALL.md +53 -1
- package/INSTALL.zh.md +48 -1
- package/README.ja.md +84 -10
- package/README.ko.md +81 -10
- package/README.md +118 -22
- package/README.zh.md +86 -8
- package/cordis.patch.yml +34 -3
- package/dsh.plugin.json +22 -0
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/client.js +114 -0
- package/lib/client.js.map +1 -1
- package/lib/compiler.d.ts +39 -0
- package/lib/compiler.js +106 -0
- package/lib/config.d.ts +95 -2
- package/lib/config.js +28 -3
- package/lib/engine.js +9 -2
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +189 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +31 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +235 -6
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.js +2 -3
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +222 -0
- package/lib/rule-dims.d.ts +139 -0
- package/lib/rule-dims.js +194 -0
- package/lib/rule.d.ts +25 -0
- package/lib/rule.js +15 -1
- package/lib/runtime.d.ts +87 -0
- package/lib/runtime.js +229 -2
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +8 -2
- 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.
|
|
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
|
+
}
|