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.
- 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 +86 -12
- package/README.ko.md +83 -12
- package/README.md +120 -24
- package/README.zh.md +88 -10
- package/cordis.patch.yml +34 -2
- 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 +25 -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 +327 -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
|
@@ -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
|
+
}
|
package/lib/shadow.d.ts
ADDED
|
@@ -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.
|
|
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",
|