dsh-perm-gate 2.0.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 +86 -12
  7. package/README.ko.md +83 -12
  8. package/README.md +120 -24
  9. package/README.zh.md +88 -10
  10. package/cordis.patch.yml +34 -2
  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 +25 -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 +327 -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
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Session-lifecycle sweep for dsh-perm-gate: DSH's workspace store
3
+ * (`<dshHome>/storages/workspace.json`) is the authority on which sessions are
4
+ * live and which were archived. On plugin start and hourly, the gate classifies
5
+ * every session id it holds gate data for — live (in a workspace), archived
6
+ * (in `global.archivedSessionIds`), or gone (neither) — and drops the
7
+ * authorization-chain data of archived/gone sessions: their decision events in
8
+ * `events.jsonl` and their pre-change snapshots in `snapshots/`.
9
+ *
10
+ * Fail-open like every other side effect in this plugin: any I/O or parse
11
+ * error is swallowed with a single warn and the round is skipped — the sweep
12
+ * must never influence gating. The workspace store is read-only here (DSH owns
13
+ * it and rewrites it continuously); a torn read simply defers to the next round.
14
+ */
15
+ import { readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ /** Strip the `session-` prefix so ids from different sources compare equal. */
18
+ export function normalizeSessionId(id) {
19
+ const s = String(id ?? '');
20
+ return s.startsWith('session-') ? s.slice('session-'.length) : s;
21
+ }
22
+ /**
23
+ * Classify sessions from the raw text of the workspace store. Returns `null`
24
+ * on unparsable or structurally unexpected content (torn write, format bump) —
25
+ * the caller skips the round. Missing `global`/`tables` sections degrade to
26
+ * empty sets rather than failing: a fresh install has neither.
27
+ */
28
+ export function classifySessions(raw) {
29
+ let doc;
30
+ try {
31
+ doc = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc))
37
+ return null;
38
+ const root = doc;
39
+ const live = new Set();
40
+ const workspaces = root.tables?.workspaces;
41
+ if (typeof workspaces === 'object' && workspaces !== null) {
42
+ for (const record of Object.values(workspaces)) {
43
+ const ids = record?.sessionIds;
44
+ if (!Array.isArray(ids))
45
+ continue;
46
+ for (const id of ids)
47
+ live.add(normalizeSessionId(id));
48
+ }
49
+ }
50
+ const archived = new Set();
51
+ if (Array.isArray(root.global?.archivedSessionIds)) {
52
+ for (const id of root.global.archivedSessionIds)
53
+ archived.add(normalizeSessionId(id));
54
+ }
55
+ return { live, archived };
56
+ }
57
+ /**
58
+ * Whether a gate-side session id belongs to an archived or deleted session.
59
+ * An empty id (unattributable legacy row) never matches — the same semantics
60
+ * `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
61
+ */
62
+ export function isStaleSession(id, cls) {
63
+ const norm = normalizeSessionId(id);
64
+ return norm !== '' && !cls.live.has(norm);
65
+ }
66
+ /**
67
+ * Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
68
+ * rewrite, only when something is actually removed) and delete their snapshot
69
+ * files. Both halves are independent: a failure in one still lets the other
70
+ * run. I/O errors are swallowed with a warn — best-effort cleanup, never a
71
+ * gating concern.
72
+ */
73
+ export function sweepSessionData(opts) {
74
+ const eventsRemoved = sweepEvents(opts);
75
+ const snapshotsRemoved = sweepSnapshots(opts);
76
+ return { eventsRemoved, snapshotsRemoved };
77
+ }
78
+ function sweepEvents({ classification, eventsFile }) {
79
+ let raw;
80
+ try {
81
+ raw = readFileSync(eventsFile, 'utf8');
82
+ }
83
+ catch {
84
+ return 0; // no feed yet (or unreadable) — nothing to sweep
85
+ }
86
+ const lines = raw.split('\n');
87
+ const kept = [];
88
+ let removed = 0;
89
+ for (const line of lines) {
90
+ if (line.trim() === '')
91
+ continue;
92
+ let stale = false;
93
+ try {
94
+ const ev = JSON.parse(line);
95
+ stale = isStaleSession(String(ev.sessionId ?? ''), classification);
96
+ }
97
+ catch {
98
+ // An unparsable line is unattributable — keep it (never destroy evidence).
99
+ }
100
+ if (stale)
101
+ removed += 1;
102
+ else
103
+ kept.push(line);
104
+ }
105
+ if (removed === 0)
106
+ return 0;
107
+ try {
108
+ const tmp = `${eventsFile}.sweep-tmp`;
109
+ writeFileSync(tmp, kept.length > 0 ? kept.join('\n') + '\n' : '', 'utf8');
110
+ renameSync(tmp, eventsFile);
111
+ return removed;
112
+ }
113
+ catch (e) {
114
+ warn('events rewrite failed', e);
115
+ return 0;
116
+ }
117
+ }
118
+ function sweepSnapshots({ classification, snapshotsDir }) {
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(snapshotsDir);
122
+ }
123
+ catch {
124
+ return 0; // no snapshot dir yet
125
+ }
126
+ let removed = 0;
127
+ for (const name of entries) {
128
+ if (!/^\d+\.json$/.test(name))
129
+ continue;
130
+ const abs = join(snapshotsDir, name);
131
+ let stale = false;
132
+ try {
133
+ const doc = JSON.parse(readFileSync(abs, 'utf8'));
134
+ stale = isStaleSession(String(doc.sessionId ?? ''), classification);
135
+ }
136
+ catch {
137
+ // Unparsable snapshot (torn write) has no attributable session — keep it.
138
+ }
139
+ if (!stale)
140
+ continue;
141
+ try {
142
+ rmSync(abs);
143
+ removed += 1;
144
+ }
145
+ catch (e) {
146
+ warn(`snapshot ${name} delete failed`, e);
147
+ }
148
+ }
149
+ return removed;
150
+ }
151
+ function warn(what, e) {
152
+ console.warn(`[dsh-perm-gate] session sweep: ${what}:`, e instanceof Error ? e.message : e);
153
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shadow detection for dsh-perm-gate rule chains.
3
+ *
4
+ * A rule is "shadowed" when an earlier rule in the same action partition
5
+ * always matches the same (or a superset of) tool calls, making the later
6
+ * rule unreachable. Shadowed rules are dead weight — they can never fire
7
+ * and may confuse users who think they are effective.
8
+ *
9
+ * This module performs a structural (conservative) shadow check. It does NOT
10
+ * do full semantic analysis — it flags potential shadows based on pattern
11
+ * structure, not on actual runtime argument values.
12
+ */
13
+ import type { CompiledRuleset } from './rule.js';
14
+ export interface ShadowReport {
15
+ /** Rule indices that are shadowed (unreachable). */
16
+ readonly shadowed: readonly number[];
17
+ /** Human-readable reasons per shadowed rule. */
18
+ readonly reasons: ReadonlyMap<number, string>;
19
+ }
20
+ /**
21
+ * Detect shadowed rules within a compiled ruleset.
22
+ *
23
+ * For each action partition (deny, allow, ask), check if any rule is
24
+ * dominated by an earlier rule in the same partition. A rule is dominated
25
+ * when:
26
+ * 1. Its tools pattern is a subset (or equal) of an earlier rule's tools
27
+ * 2. Its command pattern is a subset (or equal)
28
+ * 3. Its args/paths dimensions are subsets
29
+ * 4. Its new dimensions (params/absent/agents/when/network) are subsets
30
+ *
31
+ * This is conservative: it only flags rules that are DEFINITELY unreachable,
32
+ * not rules that MIGHT be unreachable under certain argument combinations.
33
+ */
34
+ export declare function detectShadows(ruleset: CompiledRuleset): ShadowReport;
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.0.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",