dsh-permission-rules 0.4.2 → 0.5.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 +34 -0
- package/README.es.md +160 -117
- package/README.hi.md +162 -119
- package/README.md +133 -90
- package/README.pt.md +161 -118
- package/README.zh.md +162 -119
- package/docs/rule-test-vectors/README.md +28 -0
- package/docs/rule-test-vectors/corpus.json +109 -0
- package/lib/client.js +4693 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1530 -179
- package/lib/typert.host.js +4305 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +9 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +181 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/section.d.ts +35 -0
- package/lib/types/client/section.d.ts.map +1 -0
- package/lib/types/config.d.ts +54 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/events.d.ts +45 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/network.d.ts +90 -0
- package/lib/types/network.d.ts.map +1 -0
- package/lib/types/prose.d.ts +15 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +128 -0
- package/lib/types/proxy.d.ts.map +1 -0
- package/lib/types/remote-service.d.ts +45 -0
- package/lib/types/remote-service.d.ts.map +1 -0
- package/lib/types/rules.d.ts +131 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +153 -21
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +166 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +421 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +182 -144
- package/scripts/repair-session-logs.mjs +7 -2
- package/src/client/index.ts +111 -0
- package/src/client/locales.ts +94 -0
- package/src/client/remote.ts +41 -0
- package/src/client/section.ts +201 -0
- package/src/config.ts +111 -0
- package/src/events.ts +46 -0
- package/src/index.ts +2 -0
- package/src/network.ts +177 -0
- package/src/prose.ts +85 -10
- package/src/proxy.ts +327 -0
- package/src/remote-service.ts +109 -0
- package/src/rules.ts +424 -5
- package/src/runtime.ts +464 -53
- package/src/settings.ts +74 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +239 -0
- package/lib/types/config.js +0 -99
- package/lib/types/config.js.map +0 -1
- package/lib/types/events.js +0 -44
- package/lib/types/events.js.map +0 -1
- package/lib/types/glob.js +0 -307
- package/lib/types/glob.js.map +0 -1
- package/lib/types/index.js +0 -27
- package/lib/types/index.js.map +0 -1
- package/lib/types/prose.js +0 -173
- package/lib/types/prose.js.map +0 -1
- package/lib/types/rules.js +0 -610
- package/lib/types/rules.js.map +0 -1
- package/lib/types/runtime.js +0 -818
- package/lib/types/runtime.js.map +0 -1
package/lib/types/runtime.js
DELETED
|
@@ -1,818 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runtime of `dsh-permission-rules`: per-workspace rule loading (project
|
|
3
|
-
* file chain by session cwd → fallback path → empty set), the
|
|
4
|
-
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
5
|
-
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
6
|
-
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
7
|
-
* (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
|
|
8
|
-
* Chokidar-driven reloads (effective rule files plus candidate watches on
|
|
9
|
-
* expected-but-absent files, so mid-session creation is adopted). Every
|
|
10
|
-
* registration is an effect.
|
|
11
|
-
* @module dsh-permission-rules/runtime
|
|
12
|
-
*/
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import { createRequire } from 'node:module';
|
|
15
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
16
|
-
import chokidar from 'chokidar';
|
|
17
|
-
import { resolveConfig } from "./config.js";
|
|
18
|
-
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from "./rules.js";
|
|
19
|
-
import { DESCRIBE_TOKENS, UI_PROSE } from "./prose.js";
|
|
20
|
-
import { isMarkedAuditEvent } from "./events.js";
|
|
21
|
-
export const name = 'permission-rules';
|
|
22
|
-
/** Services required before the plugin mounts. */
|
|
23
|
-
export const inject = ['commands', 'tools'];
|
|
24
|
-
/**
|
|
25
|
-
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
26
|
-
* the watcher/timer effects registered in {@link apply}.
|
|
27
|
-
*/
|
|
28
|
-
export class PermissionRulesRuntime {
|
|
29
|
-
ctx;
|
|
30
|
-
config;
|
|
31
|
-
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
32
|
-
byCwd = new Map();
|
|
33
|
-
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
34
|
-
watchers = new Map();
|
|
35
|
-
/** Debounce timers per rule-file path. */
|
|
36
|
-
timers = new Map();
|
|
37
|
-
/** Whether the host honors the audit envelope's `ignorable` marker: unknown until the first decision (or peer-version check). */
|
|
38
|
-
auditSupport = 'unknown';
|
|
39
|
-
constructor(ctx, config) {
|
|
40
|
-
this.ctx = ctx;
|
|
41
|
-
this.config = config;
|
|
42
|
-
}
|
|
43
|
-
/** The shared compile options derived from config. */
|
|
44
|
-
compileOptions() {
|
|
45
|
-
return {
|
|
46
|
-
patternMode: this.config.patternMode,
|
|
47
|
-
maxRules: this.config.maxRules,
|
|
48
|
-
maxGlobStars: this.config.maxGlobStars,
|
|
49
|
-
caseInsensitivePaths: this.config.caseInsensitivePaths,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Resolve which files serve a workspace, nearest first: the project file
|
|
54
|
-
* under the session cwd (or an absolute `rulesFile`), with `searchUp`
|
|
55
|
-
* also merging every parent directory's file on the way to the root,
|
|
56
|
-
* else the configured fallback, else `[]` (empty rule set). Nearer files
|
|
57
|
-
* evaluate first, so a child can override a parent rule.
|
|
58
|
-
* @param cwd - the session's absolute workspace root.
|
|
59
|
-
* @returns the absolute rule-file paths in effect, nearest first.
|
|
60
|
-
*/
|
|
61
|
-
resolveSources(cwd) {
|
|
62
|
-
if (this.config.searchUp) {
|
|
63
|
-
const sources = [];
|
|
64
|
-
let dir = cwd;
|
|
65
|
-
for (;;) {
|
|
66
|
-
const candidate = join(dir, this.config.rulesFile);
|
|
67
|
-
if (existsSync(candidate))
|
|
68
|
-
sources.push(candidate);
|
|
69
|
-
const parent = dirname(dir);
|
|
70
|
-
if (parent === dir)
|
|
71
|
-
break;
|
|
72
|
-
dir = parent;
|
|
73
|
-
}
|
|
74
|
-
return sources.length === 0 ? this.resolveFallback() : sources;
|
|
75
|
-
}
|
|
76
|
-
const projectPath = isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile);
|
|
77
|
-
if (existsSync(projectPath))
|
|
78
|
-
return [projectPath];
|
|
79
|
-
return this.resolveFallback();
|
|
80
|
-
}
|
|
81
|
-
/** The configured fallback file when it exists, else `[]`. */
|
|
82
|
-
resolveFallback() {
|
|
83
|
-
const fallback = this.config.fallbackPath;
|
|
84
|
-
if (fallback !== undefined) {
|
|
85
|
-
const fallbackPath = isAbsolute(fallback) ? fallback : resolve(fallback);
|
|
86
|
-
if (existsSync(fallbackPath))
|
|
87
|
-
return [fallbackPath];
|
|
88
|
-
}
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Read, parse, and compile the rule-file chain serving `cwd`. Under
|
|
93
|
-
* `badFilePolicy: 'ignore-with-warning'` a bad file degrades to an empty
|
|
94
|
-
* rule set with a warning and keeps its source paths (so a later fix is
|
|
95
|
-
* watched and adopted); under `'fail'` it throws.
|
|
96
|
-
* @param cwd - the workspace root.
|
|
97
|
-
* @returns the loaded state.
|
|
98
|
-
*/
|
|
99
|
-
load(cwd) {
|
|
100
|
-
const sources = this.resolveSources(cwd);
|
|
101
|
-
if (sources.length === 0)
|
|
102
|
-
return { cwd, sources: [], compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths } };
|
|
103
|
-
try {
|
|
104
|
-
const entries = sources.map(path => ({ path, text: readFileSync(path, 'utf8') }));
|
|
105
|
-
const { ruleset } = compileRulesChain(entries, this.compileOptions());
|
|
106
|
-
return { cwd, sources, compiled: ruleset };
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
-
if (this.config.badFilePolicy === 'ignore-with-warning') {
|
|
111
|
-
this.ctx.logger.warn(`permission-rules: ignoring ${sources.join(', ')}: ${message} (empty rule set)`);
|
|
112
|
-
return { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message };
|
|
113
|
-
}
|
|
114
|
-
throw error instanceof RuleError ? error : new RuleError(`cannot load ${sources.join(', ')}: ${message}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Canonical per-workspace cache key: the resolved root, case-folded on
|
|
119
|
-
* Windows so differently-spelled paths to the same workspace share one
|
|
120
|
-
* cache entry and one watcher set instead of doubling both.
|
|
121
|
-
* @param cwd - the session's absolute workspace root.
|
|
122
|
-
* @returns the canonical key.
|
|
123
|
-
*/
|
|
124
|
-
cacheKey(cwd) {
|
|
125
|
-
const resolved = resolve(cwd);
|
|
126
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* The rules in effect for one cwd, loading on first use. Under
|
|
130
|
-
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
131
|
-
* pending tool call errors loudly) while the watcher keeps observing the
|
|
132
|
-
* files so a fix reloads into active rules. Cache hits refresh the
|
|
133
|
-
* least-recently-used position, so eviction drops the workspace that
|
|
134
|
-
* went longest without a decision.
|
|
135
|
-
* @param cwd - the workspace root.
|
|
136
|
-
* @returns the loaded rules.
|
|
137
|
-
*/
|
|
138
|
-
rulesFor(cwd) {
|
|
139
|
-
const key = this.cacheKey(cwd);
|
|
140
|
-
const existing = this.byCwd.get(key);
|
|
141
|
-
if (existing !== undefined) {
|
|
142
|
-
if (existing.failed === true) {
|
|
143
|
-
throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
|
|
144
|
-
}
|
|
145
|
-
this.byCwd.delete(key);
|
|
146
|
-
this.byCwd.set(key, existing);
|
|
147
|
-
return existing;
|
|
148
|
-
}
|
|
149
|
-
this.evictIfFull(key);
|
|
150
|
-
try {
|
|
151
|
-
const loaded = this.load(cwd);
|
|
152
|
-
this.byCwd.set(key, loaded);
|
|
153
|
-
this.reconcileWatch(key, loaded.sources);
|
|
154
|
-
return loaded;
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
-
const sources = this.resolveSources(cwd);
|
|
159
|
-
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
160
|
-
this.reconcileWatch(key, sources);
|
|
161
|
-
throw error;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Re-read the rule-file chain for one cwd (watch-driven or
|
|
166
|
-
* `/rules reload`). A bad file NEVER crashes the process: the previous
|
|
167
|
-
* rules stay active, the error is logged and reported on the next
|
|
168
|
-
* `/rules` output.
|
|
169
|
-
* @param cwd - the workspace root.
|
|
170
|
-
*/
|
|
171
|
-
reload(cwd) {
|
|
172
|
-
const key = this.cacheKey(cwd);
|
|
173
|
-
const previous = this.byCwd.get(key);
|
|
174
|
-
try {
|
|
175
|
-
const loaded = this.load(cwd);
|
|
176
|
-
this.byCwd.set(key, loaded);
|
|
177
|
-
this.reconcileWatch(key, loaded.sources);
|
|
178
|
-
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`);
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
-
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
|
|
183
|
-
if (previous !== undefined)
|
|
184
|
-
this.byCwd.set(key, { ...previous, lastError: message });
|
|
185
|
-
else {
|
|
186
|
-
const sources = this.resolveSources(cwd);
|
|
187
|
-
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
188
|
-
this.reconcileWatch(key, sources);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* The match context `when`/`agents` conditions evaluate against: host
|
|
194
|
-
* facts plus the caller's agent-identity candidates derived from the
|
|
195
|
-
* session header (`main` for top-level sessions, `subagent` for
|
|
196
|
-
* subagent children, `preset:<name>` when a preset composed the agent).
|
|
197
|
-
* @param exec - the pending call whose caller supplies the identity.
|
|
198
|
-
*/
|
|
199
|
-
matchContext(exec) {
|
|
200
|
-
return { platform: process.platform, env: process.env, agents: agentCandidates(exec?.agent) };
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
204
|
-
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
205
|
-
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
206
|
-
* their say. Under `enforce: false` (dry-run) a deny/ask hit also
|
|
207
|
-
* delegates — the record keeps the would-be action with `dryRun: true`
|
|
208
|
-
* and the actual downstream outcome. Audit is appended once the final
|
|
209
|
-
* outcome is known, so the recorded `outcome` matches what the waterfall
|
|
210
|
-
* settled on.
|
|
211
|
-
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
212
|
-
* @param next - the downstream chain.
|
|
213
|
-
* @returns the pre-execute decision.
|
|
214
|
-
*/
|
|
215
|
-
async preExecute(exec, next) {
|
|
216
|
-
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
217
|
-
const loaded = this.rulesFor(cwd);
|
|
218
|
-
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
|
|
219
|
-
if (hit === undefined || hit.rule.action === 'allow') {
|
|
220
|
-
const decision = await next();
|
|
221
|
-
this.audit(exec, loaded, hit, decision.kind);
|
|
222
|
-
return decision;
|
|
223
|
-
}
|
|
224
|
-
if (!this.config.enforce) {
|
|
225
|
-
// Dry-run: match the rule, log what it WOULD do, and delegate.
|
|
226
|
-
const decision = await next();
|
|
227
|
-
this.audit(exec, loaded, hit, decision.kind, true);
|
|
228
|
-
return decision;
|
|
229
|
-
}
|
|
230
|
-
const outcome = hit.rule.action;
|
|
231
|
-
this.audit(exec, loaded, hit, outcome);
|
|
232
|
-
if (outcome === 'deny')
|
|
233
|
-
return { kind: 'deny', reason: hit.rule.reason };
|
|
234
|
-
return { kind: 'ask', reason: hit.rule.reason };
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Append the log-only `permissionRules/decision` audit event for every
|
|
238
|
-
* decision (passthrough included unless `audit: 'hits'`), requesting the
|
|
239
|
-
* envelope's `ignorable: true` marker so any harness build can load the
|
|
240
|
-
* log. Hosts whose `Session.append` predates the marker (the rc.6 line)
|
|
241
|
-
* silently drop it, leaving sessions unresumable on stricter hosts — the
|
|
242
|
-
* runtime therefore detects such hosts BEFORE the first append (peer
|
|
243
|
-
* version) and re-checks after the first append (returned envelope), then
|
|
244
|
-
* degrades: session-log audit is disabled with a one-time warning unless
|
|
245
|
-
* `allowUnmarkedAudit: true` opts back in. `source` names the matched
|
|
246
|
-
* rule's file, or the nearest effective file on a passthrough; `cwd`
|
|
247
|
-
* names the workspace the rules were resolved for; `dryRun` marks
|
|
248
|
-
* would-be deny/ask hits under `enforce: false`. Agentless calls have no
|
|
249
|
-
* session to audit; append failures are contained so an audit hiccup can
|
|
250
|
-
* never change a permission decision.
|
|
251
|
-
* @param exec - the pending call.
|
|
252
|
-
* @param loaded - the rules in effect.
|
|
253
|
-
* @param hit - the first matching rule, or undefined for passthrough.
|
|
254
|
-
* @param outcome - the final pre-execute decision.
|
|
255
|
-
* @param dryRun - mark the record as a would-be decision (dry-run mode).
|
|
256
|
-
*/
|
|
257
|
-
audit(exec, loaded, hit, outcome, dryRun = false) {
|
|
258
|
-
if (this.config.audit === 'hits' && hit === undefined)
|
|
259
|
-
return;
|
|
260
|
-
const agent = exec.agent;
|
|
261
|
-
if (agent === undefined)
|
|
262
|
-
return;
|
|
263
|
-
if (this.auditSupport === 'unsupported')
|
|
264
|
-
return;
|
|
265
|
-
if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
|
|
266
|
-
const version = this.peerVersion();
|
|
267
|
-
if (version !== null && isUnmarkedHostVersion(version)) {
|
|
268
|
-
this.auditSupport = 'unsupported';
|
|
269
|
-
this.warnUnmarkedAuditHost();
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
try {
|
|
274
|
-
const result = this.appendAudit(agent, {
|
|
275
|
-
toolName: exec.name,
|
|
276
|
-
callId: exec.callId,
|
|
277
|
-
source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
|
|
278
|
-
action: hit === undefined ? 'passthrough' : hit.rule.action,
|
|
279
|
-
outcome,
|
|
280
|
-
cwd: loaded.cwd,
|
|
281
|
-
...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
|
|
282
|
-
...dryRun ? { dryRun: true } : {},
|
|
283
|
-
});
|
|
284
|
-
if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
|
|
285
|
-
if (isMarkedAuditEvent(result)) {
|
|
286
|
-
this.auditSupport = 'supported';
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
this.auditSupport = 'unsupported';
|
|
290
|
-
this.warnUnmarkedAuditHost();
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
/** Append one audit event through the session surface; the probe seam for host-capability detection. */
|
|
299
|
-
appendAudit(agent, data) {
|
|
300
|
-
return agent.session.append('permissionRules/decision', data, { ignorable: true });
|
|
301
|
-
}
|
|
302
|
-
/** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
|
|
303
|
-
peerVersion() {
|
|
304
|
-
try {
|
|
305
|
-
const pkg = createRequire(import.meta.url)('@deepseek-ai/dsh-session/package.json');
|
|
306
|
-
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
/** One-time warning that session-log audit was disabled to keep session logs loadable. */
|
|
313
|
-
warnUnmarkedAuditHost() {
|
|
314
|
-
this.ctx.logger.warn('permission-rules: this host drops the ignorable marker on audit events (Session.append predates it), which would make sessions unresumable on stricter harness builds — session-log audit is disabled; set allowUnmarkedAudit: true to opt back in, and repair existing logs with scripts/repair-session-logs.mjs (see https://github.com/PerryLink/dsh-permission-rules/issues/2)');
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
318
|
-
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
319
|
-
* [n]` shows the session's audit trail; `/rules test [flags] <tool>
|
|
320
|
-
* <json>` dry-evaluates the rules against a hypothetical call (flags
|
|
321
|
-
* override the workspace, host env, and agent identity). Command output
|
|
322
|
-
* stays in the UI — nothing here is injected into the model context.
|
|
323
|
-
* @param invocation - the received command invocation.
|
|
324
|
-
* @returns the command result shown to the user.
|
|
325
|
-
*/
|
|
326
|
-
command(invocation) {
|
|
327
|
-
const prose = UI_PROSE[this.config.language];
|
|
328
|
-
const raw = invocation.rawInput.trim();
|
|
329
|
-
const [verbRaw, ...rest] = raw.split(/\s+/);
|
|
330
|
-
const verb = (verbRaw ?? '').toLowerCase();
|
|
331
|
-
const cwd = invocation.agent.session.header.cwd ?? process.cwd();
|
|
332
|
-
if (verb === 'reload') {
|
|
333
|
-
if (rest.length > 0)
|
|
334
|
-
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
335
|
-
this.reload(cwd);
|
|
336
|
-
const reloaded = this.byCwd.get(this.cacheKey(cwd));
|
|
337
|
-
if (reloaded?.lastError !== undefined)
|
|
338
|
-
return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) };
|
|
339
|
-
const rules = reloaded?.compiled.rules ?? [];
|
|
340
|
-
const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ');
|
|
341
|
-
return { kind: 'success', text: prose.reloaded(rules.length, source) };
|
|
342
|
-
}
|
|
343
|
-
if (verb === 'list' && rest.length > 0)
|
|
344
|
-
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
345
|
-
if (verb === 'decisions') {
|
|
346
|
-
if (rest.length > 1)
|
|
347
|
-
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
348
|
-
let count = 10;
|
|
349
|
-
if (rest.length === 1) {
|
|
350
|
-
const parsed = Number(rest[0]);
|
|
351
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
352
|
-
return { kind: 'error', text: prose.invalidDecisionsCount(rest[0]) };
|
|
353
|
-
count = parsed;
|
|
354
|
-
}
|
|
355
|
-
return this.decisionsCommand(invocation, count, prose);
|
|
356
|
-
}
|
|
357
|
-
if (verb === 'test') {
|
|
358
|
-
return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose);
|
|
359
|
-
}
|
|
360
|
-
if (verb !== '' && verb !== 'list')
|
|
361
|
-
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
362
|
-
let loaded;
|
|
363
|
-
try {
|
|
364
|
-
loaded = this.rulesFor(cwd);
|
|
365
|
-
}
|
|
366
|
-
catch (error) {
|
|
367
|
-
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
368
|
-
}
|
|
369
|
-
const lines = [];
|
|
370
|
-
if (loaded.sources.length === 0) {
|
|
371
|
-
lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''));
|
|
372
|
-
}
|
|
373
|
-
else {
|
|
374
|
-
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
|
|
375
|
-
const multiSource = loaded.sources.length > 1;
|
|
376
|
-
lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)));
|
|
377
|
-
const unreachable = findUnreachableRules(loaded.compiled);
|
|
378
|
-
if (unreachable.length > 0)
|
|
379
|
-
lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)));
|
|
380
|
-
}
|
|
381
|
-
if (loaded.lastError !== undefined) {
|
|
382
|
-
lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
383
|
-
}
|
|
384
|
-
if (!this.config.enforce) {
|
|
385
|
-
lines.push(prose.dryRunNotice);
|
|
386
|
-
}
|
|
387
|
-
lines.push(prose.usage);
|
|
388
|
-
return { kind: 'success', text: lines.join('\n') };
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Execute `/rules test` with optional leading flags: `--cwd <dir>`
|
|
392
|
-
* evaluates against that workspace (rule discovery AND path
|
|
393
|
-
* normalization), `--env KEY=VALUE` (repeatable) overrides host env for
|
|
394
|
-
* `when.env` matching, `--agent <selector>` (repeatable) supplies
|
|
395
|
-
* agent-identity candidates for the `agents` dimension, and
|
|
396
|
-
* `--platform <name>` overrides the host platform for `when.platform`.
|
|
397
|
-
* The JSON argument tail is kept verbatim, so quoted JSON survives
|
|
398
|
-
* unchanged.
|
|
399
|
-
* @param raw - the full raw command input.
|
|
400
|
-
* @param verbRaw - the verb as typed.
|
|
401
|
-
* @param invocation - the received command invocation (session agent).
|
|
402
|
-
* @param sessionCwd - the session's workspace root.
|
|
403
|
-
* @param prose - localized output vocabulary.
|
|
404
|
-
* @returns the dry-evaluation result.
|
|
405
|
-
*/
|
|
406
|
-
testCommand(raw, verbRaw, invocation, sessionCwd, prose) {
|
|
407
|
-
let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : '';
|
|
408
|
-
const envOverrides = {};
|
|
409
|
-
const agentSelectors = [];
|
|
410
|
-
let testCwd;
|
|
411
|
-
let testPlatform;
|
|
412
|
-
let parsed = nextToken(rest);
|
|
413
|
-
while (parsed !== undefined && parsed.token.startsWith('--')) {
|
|
414
|
-
const flag = parsed.token;
|
|
415
|
-
const value = nextToken(parsed.rest);
|
|
416
|
-
if (value === undefined || value.token.startsWith('--')) {
|
|
417
|
-
return { kind: 'error', text: prose.testBadFlag(flag) };
|
|
418
|
-
}
|
|
419
|
-
if (flag === '--cwd') {
|
|
420
|
-
testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token);
|
|
421
|
-
}
|
|
422
|
-
else if (flag === '--env') {
|
|
423
|
-
const equals = value.token.indexOf('=');
|
|
424
|
-
if (equals <= 0)
|
|
425
|
-
return { kind: 'error', text: prose.testBadFlag(`${flag} ${value.token}`) };
|
|
426
|
-
envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1);
|
|
427
|
-
}
|
|
428
|
-
else if (flag === '--agent') {
|
|
429
|
-
agentSelectors.push(value.token);
|
|
430
|
-
}
|
|
431
|
-
else if (flag === '--platform') {
|
|
432
|
-
if (!PLATFORMS.includes(value.token))
|
|
433
|
-
return { kind: 'error', text: prose.testBadPlatform(value.token) };
|
|
434
|
-
testPlatform = value.token;
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
return { kind: 'error', text: prose.testUnknownFlag(flag) };
|
|
438
|
-
}
|
|
439
|
-
rest = value.rest;
|
|
440
|
-
parsed = nextToken(rest);
|
|
441
|
-
}
|
|
442
|
-
const tool = parsed?.token;
|
|
443
|
-
const jsonText = parsed === undefined ? '' : parsed.rest.trim();
|
|
444
|
-
if (tool === undefined || tool.length === 0)
|
|
445
|
-
return { kind: 'error', text: prose.testUsage };
|
|
446
|
-
let args;
|
|
447
|
-
try {
|
|
448
|
-
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
return { kind: 'error', text: prose.testBadJson(jsonText) };
|
|
452
|
-
}
|
|
453
|
-
const evalCwd = testCwd ?? sessionCwd;
|
|
454
|
-
let loaded;
|
|
455
|
-
try {
|
|
456
|
-
loaded = this.rulesFor(evalCwd);
|
|
457
|
-
}
|
|
458
|
-
catch (error) {
|
|
459
|
-
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
460
|
-
}
|
|
461
|
-
const context = {
|
|
462
|
-
platform: testPlatform ?? process.platform,
|
|
463
|
-
env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
|
|
464
|
-
agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
|
|
465
|
-
};
|
|
466
|
-
const hit = matchRules(loaded.compiled, tool, args, evalCwd, context);
|
|
467
|
-
return {
|
|
468
|
-
kind: 'success',
|
|
469
|
-
text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
/** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
|
|
473
|
-
displaySource(source, cwd) {
|
|
474
|
-
if (source.length === 0)
|
|
475
|
-
return '';
|
|
476
|
-
const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths);
|
|
477
|
-
return relative.length > 0 ? relative : source;
|
|
478
|
-
}
|
|
479
|
-
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
480
|
-
decisionsCommand(invocation, count, prose) {
|
|
481
|
-
const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision');
|
|
482
|
-
const lines = [];
|
|
483
|
-
if (decisions.length === 0) {
|
|
484
|
-
lines.push(prose.noDecisions);
|
|
485
|
-
}
|
|
486
|
-
else {
|
|
487
|
-
const shown = decisions.slice(-count);
|
|
488
|
-
lines.push(prose.decisionsHeader(shown.length, decisions.length));
|
|
489
|
-
for (const event of shown) {
|
|
490
|
-
const data = event.data;
|
|
491
|
-
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome));
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
if (this.auditSupport === 'unsupported')
|
|
495
|
-
lines.push(prose.auditDisabledNotice);
|
|
496
|
-
return { kind: 'success', text: lines.join('\n') };
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Validate deployment-level file references at mount — the earliest
|
|
500
|
-
* resolvable point. An absolute `rulesFile` or a configured
|
|
501
|
-
* `fallbackPath` must exist and parse; a missing referent fails the mount
|
|
502
|
-
* loudly instead of silently degrading later.
|
|
503
|
-
* @throws when a mandated file is missing or invalid.
|
|
504
|
-
*/
|
|
505
|
-
validateDeploymentFiles() {
|
|
506
|
-
if (isAbsolute(this.config.rulesFile)) {
|
|
507
|
-
this.loadForValidation(this.config.rulesFile);
|
|
508
|
-
}
|
|
509
|
-
if (this.config.fallbackPath !== undefined) {
|
|
510
|
-
const fallbackPath = isAbsolute(this.config.fallbackPath) ? this.config.fallbackPath : resolve(this.config.fallbackPath);
|
|
511
|
-
if (!existsSync(fallbackPath)) {
|
|
512
|
-
throw new RuleError(`permission-rules: fallbackPath ${JSON.stringify(this.config.fallbackPath)} does not exist`);
|
|
513
|
-
}
|
|
514
|
-
this.loadForValidation(fallbackPath);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
/** Parse and compile one mandated file, rethrowing as a mount failure. */
|
|
518
|
-
loadForValidation(filePath) {
|
|
519
|
-
try {
|
|
520
|
-
const doc = parseRulesDocument(readFileSync(filePath, 'utf8'));
|
|
521
|
-
compileRules(doc, this.compileOptions());
|
|
522
|
-
}
|
|
523
|
-
catch (error) {
|
|
524
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
525
|
-
throw error instanceof RuleError ? error : new RuleError(`permission-rules: cannot load ${filePath}: ${message}`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
/** Attach (once per file path) the Chokidar watcher feeding {@link reload}. */
|
|
529
|
-
attachWatch(cwd, source) {
|
|
530
|
-
if (!this.config.watch || source === '')
|
|
531
|
-
return;
|
|
532
|
-
const existing = this.watchers.get(source);
|
|
533
|
-
if (existing !== undefined) {
|
|
534
|
-
existing.cwds.add(cwd);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const cwds = new Set([cwd]);
|
|
538
|
-
const watcher = chokidar.watch(source, { persistent: true, ignoreInitial: true });
|
|
539
|
-
const onEvent = () => this.scheduleReload(source);
|
|
540
|
-
watcher.on('add', onEvent);
|
|
541
|
-
watcher.on('change', onEvent);
|
|
542
|
-
watcher.on('unlink', onEvent);
|
|
543
|
-
watcher.on('error', (error) => {
|
|
544
|
-
this.ctx.logger.warn(`permission-rules: watcher error on ${source}: ${String(error)}`);
|
|
545
|
-
});
|
|
546
|
-
this.watchers.set(source, {
|
|
547
|
-
cwds,
|
|
548
|
-
close: () => {
|
|
549
|
-
void watcher.close().catch((error) => {
|
|
550
|
-
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${source}: ${String(error)}`);
|
|
551
|
-
});
|
|
552
|
-
},
|
|
553
|
-
});
|
|
554
|
-
this.ctx.effect(() => () => {
|
|
555
|
-
this.watchers.get(source)?.close();
|
|
556
|
-
this.watchers.delete(source);
|
|
557
|
-
const timer = this.timers.get(source);
|
|
558
|
-
if (timer !== undefined) {
|
|
559
|
-
clearTimeout(timer);
|
|
560
|
-
this.timers.delete(source);
|
|
561
|
-
}
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
566
|
-
* left the chain (deleted file, fallback switch), detach the workspace
|
|
567
|
-
* from watchers serving other sources and close the ones left empty, so
|
|
568
|
-
* long-running hosts cannot accumulate stale watchers. Expected-but-absent
|
|
569
|
-
* rule files (the project file when it is not effective, and a configured
|
|
570
|
-
* fallback that does not exist) are covered by candidate watchers on
|
|
571
|
-
* their deepest existing ancestor directory, so a file created
|
|
572
|
-
* mid-session is adopted without a manual `/rules reload`. With
|
|
573
|
-
* `searchUp`, only the immediate cwd-level candidate is watched —
|
|
574
|
-
* deeper ancestors are discovered on the next load.
|
|
575
|
-
*/
|
|
576
|
-
reconcileWatch(cwd, sources) {
|
|
577
|
-
for (const [watchedSource, entry] of this.watchers) {
|
|
578
|
-
if (!sources.includes(watchedSource)) {
|
|
579
|
-
entry.cwds.delete(cwd);
|
|
580
|
-
entry.candidates?.delete(cwd);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
this.pruneWatchers();
|
|
584
|
-
for (const source of sources)
|
|
585
|
-
this.attachWatch(cwd, source);
|
|
586
|
-
for (const candidate of this.candidateSources(cwd)) {
|
|
587
|
-
if (sources.includes(candidate) || existsSync(candidate))
|
|
588
|
-
continue;
|
|
589
|
-
this.attachCandidateWatch(cwd, candidate);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* The rule-file paths a workspace COULD be served by: the project file
|
|
594
|
-
* (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
|
|
595
|
-
* only under `searchUp`) plus the configured fallback.
|
|
596
|
-
*/
|
|
597
|
-
candidateSources(cwd) {
|
|
598
|
-
const candidates = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)];
|
|
599
|
-
const fallback = this.config.fallbackPath;
|
|
600
|
-
if (fallback !== undefined)
|
|
601
|
-
candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback));
|
|
602
|
-
return candidates;
|
|
603
|
-
}
|
|
604
|
-
/**
|
|
605
|
-
* Watch one expected-but-absent rule file through its deepest existing
|
|
606
|
-
* ancestor directory. Chokidar cannot reliably watch a missing path when
|
|
607
|
-
* its parent is also missing, but directory watching is dependable; every
|
|
608
|
-
* relevant event re-checks existence and only triggers a reload once the
|
|
609
|
-
* candidate actually appeared, so unrelated workspace activity while the
|
|
610
|
-
* file is absent costs a stat, not a reload.
|
|
611
|
-
*/
|
|
612
|
-
attachCandidateWatch(cwd, candidate) {
|
|
613
|
-
if (!this.config.watch)
|
|
614
|
-
return;
|
|
615
|
-
const dir = this.deepestExistingDir(dirname(candidate));
|
|
616
|
-
const existing = this.watchers.get(dir);
|
|
617
|
-
if (existing !== undefined) {
|
|
618
|
-
existing.cwds.add(cwd);
|
|
619
|
-
existing.candidates?.set(cwd, candidate);
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
const cwds = new Set([cwd]);
|
|
623
|
-
const candidates = new Map([[cwd, candidate]]);
|
|
624
|
-
const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true });
|
|
625
|
-
const onEvent = () => this.scheduleReload(dir);
|
|
626
|
-
watcher.on('add', onEvent);
|
|
627
|
-
watcher.on('addDir', onEvent);
|
|
628
|
-
watcher.on('change', onEvent);
|
|
629
|
-
watcher.on('unlink', onEvent);
|
|
630
|
-
watcher.on('unlinkDir', onEvent);
|
|
631
|
-
watcher.on('error', (error) => {
|
|
632
|
-
this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`);
|
|
633
|
-
});
|
|
634
|
-
this.watchers.set(dir, {
|
|
635
|
-
cwds,
|
|
636
|
-
candidates,
|
|
637
|
-
close: () => {
|
|
638
|
-
void watcher.close().catch((error) => {
|
|
639
|
-
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`);
|
|
640
|
-
});
|
|
641
|
-
},
|
|
642
|
-
});
|
|
643
|
-
this.ctx.effect(() => () => {
|
|
644
|
-
this.watchers.get(dir)?.close();
|
|
645
|
-
this.watchers.delete(dir);
|
|
646
|
-
const timer = this.timers.get(dir);
|
|
647
|
-
if (timer !== undefined) {
|
|
648
|
-
clearTimeout(timer);
|
|
649
|
-
this.timers.delete(dir);
|
|
650
|
-
}
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
/** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
|
|
654
|
-
deepestExistingDir(dir) {
|
|
655
|
-
let current = dir;
|
|
656
|
-
for (;;) {
|
|
657
|
-
if (existsSync(current))
|
|
658
|
-
return current;
|
|
659
|
-
const parent = dirname(current);
|
|
660
|
-
if (parent === current)
|
|
661
|
-
return current;
|
|
662
|
-
current = parent;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
666
|
-
pruneWatchers() {
|
|
667
|
-
for (const [source, entry] of this.watchers) {
|
|
668
|
-
if (entry.cwds.size > 0)
|
|
669
|
-
continue;
|
|
670
|
-
entry.close();
|
|
671
|
-
this.watchers.delete(source);
|
|
672
|
-
const timer = this.timers.get(source);
|
|
673
|
-
if (timer !== undefined) {
|
|
674
|
-
clearTimeout(timer);
|
|
675
|
-
this.timers.delete(source);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Bound the per-workspace cache: when a NEW workspace would exceed
|
|
681
|
-
* `maxCachedWorkspaces`, evict the least-recently-used entry (the Map
|
|
682
|
-
* head, refreshed by every {@link rulesFor} hit) and release its watcher
|
|
683
|
-
* slots. Long-lived hosts that visit many workspaces therefore keep a
|
|
684
|
-
* bounded memory footprint.
|
|
685
|
-
*/
|
|
686
|
-
evictIfFull(cwd) {
|
|
687
|
-
if (this.byCwd.size < this.config.maxCachedWorkspaces || this.byCwd.has(cwd))
|
|
688
|
-
return;
|
|
689
|
-
const oldest = this.byCwd.keys().next().value;
|
|
690
|
-
if (oldest === undefined)
|
|
691
|
-
return;
|
|
692
|
-
this.byCwd.delete(oldest);
|
|
693
|
-
for (const entry of this.watchers.values()) {
|
|
694
|
-
entry.cwds.delete(oldest);
|
|
695
|
-
entry.candidates?.delete(oldest);
|
|
696
|
-
}
|
|
697
|
-
this.pruneWatchers();
|
|
698
|
-
}
|
|
699
|
-
/** Number of live watchers, for observability and tests. */
|
|
700
|
-
activeWatcherCount() {
|
|
701
|
-
return this.watchers.size;
|
|
702
|
-
}
|
|
703
|
-
/** Number of pending debounce timers, for observability and tests. */
|
|
704
|
-
pendingReloadCount() {
|
|
705
|
-
return this.timers.size;
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Debounce watch events into one reload per stability window. A
|
|
709
|
-
* candidate (directory) watcher only reloads a cwd once its expected
|
|
710
|
-
* file actually exists — unrelated events while the file is absent are
|
|
711
|
-
* dropped before any re-read.
|
|
712
|
-
*/
|
|
713
|
-
scheduleReload(source) {
|
|
714
|
-
const existing = this.timers.get(source);
|
|
715
|
-
if (existing !== undefined)
|
|
716
|
-
clearTimeout(existing);
|
|
717
|
-
const timer = setTimeout(() => {
|
|
718
|
-
this.timers.delete(source);
|
|
719
|
-
const entry = this.watchers.get(source);
|
|
720
|
-
if (entry === undefined)
|
|
721
|
-
return;
|
|
722
|
-
for (const cwd of entry.cwds) {
|
|
723
|
-
const candidate = entry.candidates?.get(cwd);
|
|
724
|
-
if (candidate !== undefined && !existsSync(candidate))
|
|
725
|
-
continue;
|
|
726
|
-
this.reload(cwd);
|
|
727
|
-
}
|
|
728
|
-
}, this.config.watchStabilityThresholdMs);
|
|
729
|
-
this.timers.set(source, timer);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Mount the plugin: resolve config, validate deployment-level rule files,
|
|
734
|
-
* register the pre-execute listener and the `/rules` command.
|
|
735
|
-
* @param ctx - the host context.
|
|
736
|
-
* @param config - raw plugin config.
|
|
737
|
-
*/
|
|
738
|
-
export function apply(ctx, config) {
|
|
739
|
-
const resolved = resolveConfig(config);
|
|
740
|
-
const runtime = new PermissionRulesRuntime(ctx, resolved);
|
|
741
|
-
runtime.validateDeploymentFiles();
|
|
742
|
-
ctx.provide('permissionRulesRuntime', runtime);
|
|
743
|
-
ctx.on('tools/pre-execute', (exec, next) => runtime.preExecute(exec, next));
|
|
744
|
-
ctx.commands.register({
|
|
745
|
-
name: 'rules',
|
|
746
|
-
description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
|
|
747
|
-
input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
|
|
748
|
-
handler: invocation => runtime.command(invocation),
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Whether a `@deepseek-ai/dsh-session` version line predates the
|
|
753
|
-
* `ignorable` envelope-marker surface: the `0.1.0-rc.6` line and earlier
|
|
754
|
-
* silently drop the marker from `Session.append` options, so audit events
|
|
755
|
-
* written by those builds land unmarked and break resume on stricter
|
|
756
|
-
* hosts. Non-matching (post-rc.6, stable, or unresolvable) versions are
|
|
757
|
-
* treated as possibly-marker-aware and verified by the append probe.
|
|
758
|
-
* @param version - the installed peer version string.
|
|
759
|
-
* @returns true for the known-unmarked rc.1–rc.6 lines.
|
|
760
|
-
*/
|
|
761
|
-
export function isUnmarkedHostVersion(version) {
|
|
762
|
-
const match = /^0\.1\.0-rc\.(\d+)$/.exec(version.trim());
|
|
763
|
-
if (match === null)
|
|
764
|
-
return false;
|
|
765
|
-
return Number(match[1]) <= 6;
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Identity candidates for the `agents` match dimension, derived from the
|
|
769
|
-
* caller agent's session header: `main` for top-level sessions, `subagent`
|
|
770
|
-
* for subagent children (`header.origin === 'subagent'`), and
|
|
771
|
-
* `preset:<name>` when a preset composed the agent. No agent (or an
|
|
772
|
-
* unidentifiable one) yields no candidates, so agent-scoped rules fail
|
|
773
|
-
* closed instead of matching an unknown caller.
|
|
774
|
-
* @param agent - the calling agent, when the call has one.
|
|
775
|
-
* @returns the candidate strings, in a stable order.
|
|
776
|
-
*/
|
|
777
|
-
function agentCandidates(agent) {
|
|
778
|
-
if (agent === undefined)
|
|
779
|
-
return [];
|
|
780
|
-
const header = agent.session.header;
|
|
781
|
-
const candidates = [header.origin === 'subagent' ? 'subagent' : 'main'];
|
|
782
|
-
if (typeof header.agentPreset === 'string' && header.agentPreset.length > 0)
|
|
783
|
-
candidates.push(`preset:${header.agentPreset}`);
|
|
784
|
-
return candidates;
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Pull the next whitespace-delimited argument from a command tail,
|
|
788
|
-
* honoring single/double quotes (with backslash escapes) so JSON blobs and
|
|
789
|
-
* paths with spaces survive. The remainder is returned verbatim — the JSON
|
|
790
|
-
* argument tail of `/rules test` is never re-tokenized.
|
|
791
|
-
* @param text - the remaining command tail.
|
|
792
|
-
* @returns the token plus the untouched remainder, or undefined when only
|
|
793
|
-
* whitespace remains.
|
|
794
|
-
*/
|
|
795
|
-
function nextToken(text) {
|
|
796
|
-
let i = 0;
|
|
797
|
-
while (i < text.length && /\s/.test(text[i]))
|
|
798
|
-
i += 1;
|
|
799
|
-
if (i >= text.length)
|
|
800
|
-
return undefined;
|
|
801
|
-
const quote = text[i];
|
|
802
|
-
if (quote === '"' || quote === "'") {
|
|
803
|
-
let end = i + 1;
|
|
804
|
-
while (end < text.length && text[end] !== quote) {
|
|
805
|
-
if (text[end] === '\\')
|
|
806
|
-
end += 1;
|
|
807
|
-
end += 1;
|
|
808
|
-
}
|
|
809
|
-
if (end >= text.length)
|
|
810
|
-
return { token: text.slice(i + 1), rest: '' };
|
|
811
|
-
return { token: text.slice(i + 1, end), rest: text.slice(end + 1) };
|
|
812
|
-
}
|
|
813
|
-
let end = i;
|
|
814
|
-
while (end < text.length && !/\s/.test(text[end]))
|
|
815
|
-
end += 1;
|
|
816
|
-
return { token: text.slice(i, end), rest: text.slice(end) };
|
|
817
|
-
}
|
|
818
|
-
//# sourceMappingURL=runtime.js.map
|