dsh-permission-rules 0.2.0 → 0.4.0
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 +38 -0
- package/README.es.md +11 -6
- package/README.hi.md +11 -6
- package/README.md +11 -6
- package/README.pt.md +11 -6
- package/README.zh.md +11 -6
- package/docs/rules-format.en.md +7 -2
- package/docs/rules-format.md +7 -2
- package/docs/rules-format.schema.json +5 -0
- package/lib/index.js +467 -128
- package/lib/types/config.d.ts +12 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +26 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/events.d.ts +14 -1
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js +5 -0
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +8 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/prose.d.ts +13 -2
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +50 -25
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +25 -2
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +37 -8
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +84 -17
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +326 -71
- package/lib/types/runtime.js.map +1 -1
- package/package.json +103 -103
- package/src/config.ts +36 -2
- package/src/events.ts +14 -1
- package/src/index.ts +8 -6
- package/src/prose.ts +63 -27
- package/src/rules.ts +53 -8
- package/src/runtime.ts +320 -67
package/lib/types/runtime.js
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
5
5
|
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
6
6
|
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
7
|
-
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
8
|
-
* Chokidar-driven reloads
|
|
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.
|
|
9
11
|
* @module dsh-permission-rules/runtime
|
|
10
12
|
*/
|
|
11
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
14
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
15
|
import chokidar from 'chokidar';
|
|
14
16
|
import { resolveConfig } from "./config.js";
|
|
15
|
-
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from "./rules.js";
|
|
17
|
+
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from "./rules.js";
|
|
16
18
|
import { DESCRIBE_TOKENS, UI_PROSE } from "./prose.js";
|
|
17
19
|
export const name = 'permission-rules';
|
|
18
20
|
/** Services required before the plugin mounts. */
|
|
@@ -26,7 +28,7 @@ export class PermissionRulesRuntime {
|
|
|
26
28
|
config;
|
|
27
29
|
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
28
30
|
byCwd = new Map();
|
|
29
|
-
/** Live watchers per rule
|
|
31
|
+
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
30
32
|
watchers = new Map();
|
|
31
33
|
/** Debounce timers per rule-file path. */
|
|
32
34
|
timers = new Map();
|
|
@@ -108,6 +110,17 @@ export class PermissionRulesRuntime {
|
|
|
108
110
|
throw error instanceof RuleError ? error : new RuleError(`cannot load ${sources.join(', ')}: ${message}`);
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Canonical per-workspace cache key: the resolved root, case-folded on
|
|
115
|
+
* Windows so differently-spelled paths to the same workspace share one
|
|
116
|
+
* cache entry and one watcher set instead of doubling both.
|
|
117
|
+
* @param cwd - the session's absolute workspace root.
|
|
118
|
+
* @returns the canonical key.
|
|
119
|
+
*/
|
|
120
|
+
cacheKey(cwd) {
|
|
121
|
+
const resolved = resolve(cwd);
|
|
122
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
123
|
+
}
|
|
111
124
|
/**
|
|
112
125
|
* The rules in effect for one cwd, loading on first use. Under
|
|
113
126
|
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
@@ -119,27 +132,28 @@ export class PermissionRulesRuntime {
|
|
|
119
132
|
* @returns the loaded rules.
|
|
120
133
|
*/
|
|
121
134
|
rulesFor(cwd) {
|
|
122
|
-
const
|
|
135
|
+
const key = this.cacheKey(cwd);
|
|
136
|
+
const existing = this.byCwd.get(key);
|
|
123
137
|
if (existing !== undefined) {
|
|
124
138
|
if (existing.failed === true) {
|
|
125
139
|
throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
|
|
126
140
|
}
|
|
127
|
-
this.byCwd.delete(
|
|
128
|
-
this.byCwd.set(
|
|
141
|
+
this.byCwd.delete(key);
|
|
142
|
+
this.byCwd.set(key, existing);
|
|
129
143
|
return existing;
|
|
130
144
|
}
|
|
131
|
-
this.evictIfFull(
|
|
145
|
+
this.evictIfFull(key);
|
|
132
146
|
try {
|
|
133
147
|
const loaded = this.load(cwd);
|
|
134
|
-
this.byCwd.set(
|
|
135
|
-
this.reconcileWatch(
|
|
148
|
+
this.byCwd.set(key, loaded);
|
|
149
|
+
this.reconcileWatch(key, loaded.sources);
|
|
136
150
|
return loaded;
|
|
137
151
|
}
|
|
138
152
|
catch (error) {
|
|
139
153
|
const message = error instanceof Error ? error.message : String(error);
|
|
140
154
|
const sources = this.resolveSources(cwd);
|
|
141
|
-
this.byCwd.set(
|
|
142
|
-
this.reconcileWatch(
|
|
155
|
+
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
156
|
+
this.reconcileWatch(key, sources);
|
|
143
157
|
throw error;
|
|
144
158
|
}
|
|
145
159
|
}
|
|
@@ -151,32 +165,45 @@ export class PermissionRulesRuntime {
|
|
|
151
165
|
* @param cwd - the workspace root.
|
|
152
166
|
*/
|
|
153
167
|
reload(cwd) {
|
|
154
|
-
const
|
|
168
|
+
const key = this.cacheKey(cwd);
|
|
169
|
+
const previous = this.byCwd.get(key);
|
|
155
170
|
try {
|
|
156
171
|
const loaded = this.load(cwd);
|
|
157
|
-
this.byCwd.set(
|
|
158
|
-
this.reconcileWatch(
|
|
172
|
+
this.byCwd.set(key, loaded);
|
|
173
|
+
this.reconcileWatch(key, loaded.sources);
|
|
159
174
|
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`);
|
|
160
175
|
}
|
|
161
176
|
catch (error) {
|
|
162
177
|
const message = error instanceof Error ? error.message : String(error);
|
|
163
178
|
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
|
|
164
179
|
if (previous !== undefined)
|
|
165
|
-
this.byCwd.set(
|
|
166
|
-
else
|
|
167
|
-
|
|
180
|
+
this.byCwd.set(key, { ...previous, lastError: message });
|
|
181
|
+
else {
|
|
182
|
+
const sources = this.resolveSources(cwd);
|
|
183
|
+
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
184
|
+
this.reconcileWatch(key, sources);
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The match context `when`/`agents` conditions evaluate against: host
|
|
190
|
+
* facts plus the caller's agent-identity candidates derived from the
|
|
191
|
+
* session header (`main` for top-level sessions, `subagent` for
|
|
192
|
+
* subagent children, `preset:<name>` when a preset composed the agent).
|
|
193
|
+
* @param exec - the pending call whose caller supplies the identity.
|
|
194
|
+
*/
|
|
195
|
+
matchContext(exec) {
|
|
196
|
+
return { platform: process.platform, env: process.env, agents: agentCandidates(exec?.agent) };
|
|
173
197
|
}
|
|
174
198
|
/**
|
|
175
199
|
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
176
200
|
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
177
201
|
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
178
|
-
* their say.
|
|
179
|
-
*
|
|
202
|
+
* their say. Under `enforce: false` (dry-run) a deny/ask hit also
|
|
203
|
+
* delegates — the record keeps the would-be action with `dryRun: true`
|
|
204
|
+
* and the actual downstream outcome. Audit is appended once the final
|
|
205
|
+
* outcome is known, so the recorded `outcome` matches what the waterfall
|
|
206
|
+
* settled on.
|
|
180
207
|
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
181
208
|
* @param next - the downstream chain.
|
|
182
209
|
* @returns the pre-execute decision.
|
|
@@ -184,12 +211,18 @@ export class PermissionRulesRuntime {
|
|
|
184
211
|
async preExecute(exec, next) {
|
|
185
212
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
186
213
|
const loaded = this.rulesFor(cwd);
|
|
187
|
-
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
|
|
214
|
+
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
|
|
188
215
|
if (hit === undefined || hit.rule.action === 'allow') {
|
|
189
216
|
const decision = await next();
|
|
190
217
|
this.audit(exec, loaded, hit, decision.kind);
|
|
191
218
|
return decision;
|
|
192
219
|
}
|
|
220
|
+
if (!this.config.enforce) {
|
|
221
|
+
// Dry-run: match the rule, log what it WOULD do, and delegate.
|
|
222
|
+
const decision = await next();
|
|
223
|
+
this.audit(exec, loaded, hit, decision.kind, true);
|
|
224
|
+
return decision;
|
|
225
|
+
}
|
|
193
226
|
const outcome = hit.rule.action;
|
|
194
227
|
this.audit(exec, loaded, hit, outcome);
|
|
195
228
|
if (outcome === 'deny')
|
|
@@ -202,15 +235,18 @@ export class PermissionRulesRuntime {
|
|
|
202
235
|
* envelope's `ignorable: true` marker so any harness build can load the
|
|
203
236
|
* log (readers that do not know the type skip the audit record instead of
|
|
204
237
|
* refusing the session). `source` names the matched rule's file, or the
|
|
205
|
-
* nearest effective file on a passthrough
|
|
206
|
-
*
|
|
207
|
-
*
|
|
238
|
+
* nearest effective file on a passthrough; `cwd` names the workspace the
|
|
239
|
+
* rules were resolved for; `dryRun` marks would-be deny/ask hits under
|
|
240
|
+
* `enforce: false`. Agentless calls have no session to audit; append
|
|
241
|
+
* failures are contained so an audit hiccup can never change a
|
|
242
|
+
* permission decision.
|
|
208
243
|
* @param exec - the pending call.
|
|
209
244
|
* @param loaded - the rules in effect.
|
|
210
245
|
* @param hit - the first matching rule, or undefined for passthrough.
|
|
211
246
|
* @param outcome - the final pre-execute decision.
|
|
247
|
+
* @param dryRun - mark the record as a would-be decision (dry-run mode).
|
|
212
248
|
*/
|
|
213
|
-
audit(exec, loaded, hit, outcome) {
|
|
249
|
+
audit(exec, loaded, hit, outcome, dryRun = false) {
|
|
214
250
|
if (this.config.audit === 'hits' && hit === undefined)
|
|
215
251
|
return;
|
|
216
252
|
const agent = exec.agent;
|
|
@@ -224,7 +260,9 @@ export class PermissionRulesRuntime {
|
|
|
224
260
|
source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
|
|
225
261
|
action: hit === undefined ? 'passthrough' : hit.rule.action,
|
|
226
262
|
outcome,
|
|
263
|
+
cwd: loaded.cwd,
|
|
227
264
|
...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
|
|
265
|
+
...dryRun ? { dryRun: true } : {},
|
|
228
266
|
}, { ignorable: true });
|
|
229
267
|
}
|
|
230
268
|
catch (error) {
|
|
@@ -234,8 +272,9 @@ export class PermissionRulesRuntime {
|
|
|
234
272
|
/**
|
|
235
273
|
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
236
274
|
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
237
|
-
* [n]` shows the session's audit trail; `/rules test <tool>
|
|
238
|
-
* dry-evaluates the rules against a hypothetical call
|
|
275
|
+
* [n]` shows the session's audit trail; `/rules test [flags] <tool>
|
|
276
|
+
* <json>` dry-evaluates the rules against a hypothetical call (flags
|
|
277
|
+
* override the workspace, host env, and agent identity). Command output
|
|
239
278
|
* stays in the UI — nothing here is injected into the model context.
|
|
240
279
|
* @param invocation - the received command invocation.
|
|
241
280
|
* @returns the command result shown to the user.
|
|
@@ -250,13 +289,15 @@ export class PermissionRulesRuntime {
|
|
|
250
289
|
if (rest.length > 0)
|
|
251
290
|
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
252
291
|
this.reload(cwd);
|
|
253
|
-
const reloaded = this.byCwd.get(cwd);
|
|
292
|
+
const reloaded = this.byCwd.get(this.cacheKey(cwd));
|
|
254
293
|
if (reloaded?.lastError !== undefined)
|
|
255
294
|
return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) };
|
|
256
295
|
const rules = reloaded?.compiled.rules ?? [];
|
|
257
296
|
const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ');
|
|
258
297
|
return { kind: 'success', text: prose.reloaded(rules.length, source) };
|
|
259
298
|
}
|
|
299
|
+
if (verb === 'list' && rest.length > 0)
|
|
300
|
+
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
260
301
|
if (verb === 'decisions') {
|
|
261
302
|
if (rest.length > 1)
|
|
262
303
|
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
@@ -270,34 +311,9 @@ export class PermissionRulesRuntime {
|
|
|
270
311
|
return this.decisionsCommand(invocation, count, prose);
|
|
271
312
|
}
|
|
272
313
|
if (verb === 'test') {
|
|
273
|
-
|
|
274
|
-
const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : '';
|
|
275
|
-
const match = /^(\S+)\s*(.*)$/s.exec(restLine);
|
|
276
|
-
const tool = match?.[1];
|
|
277
|
-
const jsonText = match?.[2] ?? '';
|
|
278
|
-
if (tool === undefined || tool.length === 0)
|
|
279
|
-
return { kind: 'error', text: prose.testUsage };
|
|
280
|
-
let args;
|
|
281
|
-
try {
|
|
282
|
-
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
return { kind: 'error', text: prose.testBadJson(jsonText) };
|
|
286
|
-
}
|
|
287
|
-
let loaded;
|
|
288
|
-
try {
|
|
289
|
-
loaded = this.rulesFor(cwd);
|
|
290
|
-
}
|
|
291
|
-
catch (error) {
|
|
292
|
-
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
293
|
-
}
|
|
294
|
-
const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext());
|
|
295
|
-
return {
|
|
296
|
-
kind: 'success',
|
|
297
|
-
text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
|
|
298
|
-
};
|
|
314
|
+
return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose);
|
|
299
315
|
}
|
|
300
|
-
if (verb !== '')
|
|
316
|
+
if (verb !== '' && verb !== 'list')
|
|
301
317
|
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
302
318
|
let loaded;
|
|
303
319
|
try {
|
|
@@ -312,7 +328,8 @@ export class PermissionRulesRuntime {
|
|
|
312
328
|
}
|
|
313
329
|
else {
|
|
314
330
|
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
|
|
315
|
-
|
|
331
|
+
const multiSource = loaded.sources.length > 1;
|
|
332
|
+
lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)));
|
|
316
333
|
const unreachable = findUnreachableRules(loaded.compiled);
|
|
317
334
|
if (unreachable.length > 0)
|
|
318
335
|
lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)));
|
|
@@ -320,9 +337,101 @@ export class PermissionRulesRuntime {
|
|
|
320
337
|
if (loaded.lastError !== undefined) {
|
|
321
338
|
lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
322
339
|
}
|
|
340
|
+
if (!this.config.enforce) {
|
|
341
|
+
lines.push(prose.dryRunNotice);
|
|
342
|
+
}
|
|
323
343
|
lines.push(prose.usage);
|
|
324
344
|
return { kind: 'success', text: lines.join('\n') };
|
|
325
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Execute `/rules test` with optional leading flags: `--cwd <dir>`
|
|
348
|
+
* evaluates against that workspace (rule discovery AND path
|
|
349
|
+
* normalization), `--env KEY=VALUE` (repeatable) overrides host env for
|
|
350
|
+
* `when.env` matching, `--agent <selector>` (repeatable) supplies
|
|
351
|
+
* agent-identity candidates for the `agents` dimension, and
|
|
352
|
+
* `--platform <name>` overrides the host platform for `when.platform`.
|
|
353
|
+
* The JSON argument tail is kept verbatim, so quoted JSON survives
|
|
354
|
+
* unchanged.
|
|
355
|
+
* @param raw - the full raw command input.
|
|
356
|
+
* @param verbRaw - the verb as typed.
|
|
357
|
+
* @param invocation - the received command invocation (session agent).
|
|
358
|
+
* @param sessionCwd - the session's workspace root.
|
|
359
|
+
* @param prose - localized output vocabulary.
|
|
360
|
+
* @returns the dry-evaluation result.
|
|
361
|
+
*/
|
|
362
|
+
testCommand(raw, verbRaw, invocation, sessionCwd, prose) {
|
|
363
|
+
let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : '';
|
|
364
|
+
const envOverrides = {};
|
|
365
|
+
const agentSelectors = [];
|
|
366
|
+
let testCwd;
|
|
367
|
+
let testPlatform;
|
|
368
|
+
let parsed = nextToken(rest);
|
|
369
|
+
while (parsed !== undefined && parsed.token.startsWith('--')) {
|
|
370
|
+
const flag = parsed.token;
|
|
371
|
+
const value = nextToken(parsed.rest);
|
|
372
|
+
if (value === undefined || value.token.startsWith('--')) {
|
|
373
|
+
return { kind: 'error', text: prose.testBadFlag(flag) };
|
|
374
|
+
}
|
|
375
|
+
if (flag === '--cwd') {
|
|
376
|
+
testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token);
|
|
377
|
+
}
|
|
378
|
+
else if (flag === '--env') {
|
|
379
|
+
const equals = value.token.indexOf('=');
|
|
380
|
+
if (equals <= 0)
|
|
381
|
+
return { kind: 'error', text: prose.testBadFlag(`${flag} ${value.token}`) };
|
|
382
|
+
envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1);
|
|
383
|
+
}
|
|
384
|
+
else if (flag === '--agent') {
|
|
385
|
+
agentSelectors.push(value.token);
|
|
386
|
+
}
|
|
387
|
+
else if (flag === '--platform') {
|
|
388
|
+
if (!PLATFORMS.includes(value.token))
|
|
389
|
+
return { kind: 'error', text: prose.testBadPlatform(value.token) };
|
|
390
|
+
testPlatform = value.token;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
return { kind: 'error', text: prose.testUnknownFlag(flag) };
|
|
394
|
+
}
|
|
395
|
+
rest = value.rest;
|
|
396
|
+
parsed = nextToken(rest);
|
|
397
|
+
}
|
|
398
|
+
const tool = parsed?.token;
|
|
399
|
+
const jsonText = parsed === undefined ? '' : parsed.rest.trim();
|
|
400
|
+
if (tool === undefined || tool.length === 0)
|
|
401
|
+
return { kind: 'error', text: prose.testUsage };
|
|
402
|
+
let args;
|
|
403
|
+
try {
|
|
404
|
+
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { kind: 'error', text: prose.testBadJson(jsonText) };
|
|
408
|
+
}
|
|
409
|
+
const evalCwd = testCwd ?? sessionCwd;
|
|
410
|
+
let loaded;
|
|
411
|
+
try {
|
|
412
|
+
loaded = this.rulesFor(evalCwd);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
416
|
+
}
|
|
417
|
+
const context = {
|
|
418
|
+
platform: testPlatform ?? process.platform,
|
|
419
|
+
env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
|
|
420
|
+
agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
|
|
421
|
+
};
|
|
422
|
+
const hit = matchRules(loaded.compiled, tool, args, evalCwd, context);
|
|
423
|
+
return {
|
|
424
|
+
kind: 'success',
|
|
425
|
+
text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
|
|
429
|
+
displaySource(source, cwd) {
|
|
430
|
+
if (source.length === 0)
|
|
431
|
+
return '';
|
|
432
|
+
const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths);
|
|
433
|
+
return relative.length > 0 ? relative : source;
|
|
434
|
+
}
|
|
326
435
|
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
327
436
|
decisionsCommand(invocation, count, prose) {
|
|
328
437
|
const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision');
|
|
@@ -332,7 +441,7 @@ export class PermissionRulesRuntime {
|
|
|
332
441
|
const lines = [prose.decisionsHeader(shown.length, decisions.length)];
|
|
333
442
|
for (const event of shown) {
|
|
334
443
|
const data = event.data;
|
|
335
|
-
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
|
|
444
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome));
|
|
336
445
|
}
|
|
337
446
|
return { kind: 'success', text: lines.join('\n') };
|
|
338
447
|
}
|
|
@@ -406,21 +515,102 @@ export class PermissionRulesRuntime {
|
|
|
406
515
|
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
407
516
|
* left the chain (deleted file, fallback switch), detach the workspace
|
|
408
517
|
* from watchers serving other sources and close the ones left empty, so
|
|
409
|
-
* long-running hosts cannot accumulate stale watchers.
|
|
410
|
-
* rule
|
|
411
|
-
*
|
|
412
|
-
*
|
|
518
|
+
* long-running hosts cannot accumulate stale watchers. Expected-but-absent
|
|
519
|
+
* rule files (the project file when it is not effective, and a configured
|
|
520
|
+
* fallback that does not exist) are covered by candidate watchers on
|
|
521
|
+
* their deepest existing ancestor directory, so a file created
|
|
522
|
+
* mid-session is adopted without a manual `/rules reload`. With
|
|
523
|
+
* `searchUp`, only the immediate cwd-level candidate is watched —
|
|
524
|
+
* deeper ancestors are discovered on the next load.
|
|
413
525
|
*/
|
|
414
526
|
reconcileWatch(cwd, sources) {
|
|
415
|
-
if (sources.length === 0)
|
|
416
|
-
return;
|
|
417
527
|
for (const [watchedSource, entry] of this.watchers) {
|
|
418
|
-
if (!sources.includes(watchedSource))
|
|
528
|
+
if (!sources.includes(watchedSource)) {
|
|
419
529
|
entry.cwds.delete(cwd);
|
|
530
|
+
entry.candidates?.delete(cwd);
|
|
531
|
+
}
|
|
420
532
|
}
|
|
421
533
|
this.pruneWatchers();
|
|
422
534
|
for (const source of sources)
|
|
423
535
|
this.attachWatch(cwd, source);
|
|
536
|
+
for (const candidate of this.candidateSources(cwd)) {
|
|
537
|
+
if (sources.includes(candidate) || existsSync(candidate))
|
|
538
|
+
continue;
|
|
539
|
+
this.attachCandidateWatch(cwd, candidate);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The rule-file paths a workspace COULD be served by: the project file
|
|
544
|
+
* (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
|
|
545
|
+
* only under `searchUp`) plus the configured fallback.
|
|
546
|
+
*/
|
|
547
|
+
candidateSources(cwd) {
|
|
548
|
+
const candidates = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)];
|
|
549
|
+
const fallback = this.config.fallbackPath;
|
|
550
|
+
if (fallback !== undefined)
|
|
551
|
+
candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback));
|
|
552
|
+
return candidates;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Watch one expected-but-absent rule file through its deepest existing
|
|
556
|
+
* ancestor directory. Chokidar cannot reliably watch a missing path when
|
|
557
|
+
* its parent is also missing, but directory watching is dependable; every
|
|
558
|
+
* relevant event re-checks existence and only triggers a reload once the
|
|
559
|
+
* candidate actually appeared, so unrelated workspace activity while the
|
|
560
|
+
* file is absent costs a stat, not a reload.
|
|
561
|
+
*/
|
|
562
|
+
attachCandidateWatch(cwd, candidate) {
|
|
563
|
+
if (!this.config.watch)
|
|
564
|
+
return;
|
|
565
|
+
const dir = this.deepestExistingDir(dirname(candidate));
|
|
566
|
+
const existing = this.watchers.get(dir);
|
|
567
|
+
if (existing !== undefined) {
|
|
568
|
+
existing.cwds.add(cwd);
|
|
569
|
+
existing.candidates?.set(cwd, candidate);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const cwds = new Set([cwd]);
|
|
573
|
+
const candidates = new Map([[cwd, candidate]]);
|
|
574
|
+
const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true });
|
|
575
|
+
const onEvent = () => this.scheduleReload(dir);
|
|
576
|
+
watcher.on('add', onEvent);
|
|
577
|
+
watcher.on('addDir', onEvent);
|
|
578
|
+
watcher.on('change', onEvent);
|
|
579
|
+
watcher.on('unlink', onEvent);
|
|
580
|
+
watcher.on('unlinkDir', onEvent);
|
|
581
|
+
watcher.on('error', (error) => {
|
|
582
|
+
this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`);
|
|
583
|
+
});
|
|
584
|
+
this.watchers.set(dir, {
|
|
585
|
+
cwds,
|
|
586
|
+
candidates,
|
|
587
|
+
close: () => {
|
|
588
|
+
void watcher.close().catch((error) => {
|
|
589
|
+
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`);
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
this.ctx.effect(() => () => {
|
|
594
|
+
this.watchers.get(dir)?.close();
|
|
595
|
+
this.watchers.delete(dir);
|
|
596
|
+
const timer = this.timers.get(dir);
|
|
597
|
+
if (timer !== undefined) {
|
|
598
|
+
clearTimeout(timer);
|
|
599
|
+
this.timers.delete(dir);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
|
|
604
|
+
deepestExistingDir(dir) {
|
|
605
|
+
let current = dir;
|
|
606
|
+
for (;;) {
|
|
607
|
+
if (existsSync(current))
|
|
608
|
+
return current;
|
|
609
|
+
const parent = dirname(current);
|
|
610
|
+
if (parent === current)
|
|
611
|
+
return current;
|
|
612
|
+
current = parent;
|
|
613
|
+
}
|
|
424
614
|
}
|
|
425
615
|
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
426
616
|
pruneWatchers() {
|
|
@@ -450,8 +640,10 @@ export class PermissionRulesRuntime {
|
|
|
450
640
|
if (oldest === undefined)
|
|
451
641
|
return;
|
|
452
642
|
this.byCwd.delete(oldest);
|
|
453
|
-
for (const entry of this.watchers.values())
|
|
643
|
+
for (const entry of this.watchers.values()) {
|
|
454
644
|
entry.cwds.delete(oldest);
|
|
645
|
+
entry.candidates?.delete(oldest);
|
|
646
|
+
}
|
|
455
647
|
this.pruneWatchers();
|
|
456
648
|
}
|
|
457
649
|
/** Number of live watchers, for observability and tests. */
|
|
@@ -462,15 +654,27 @@ export class PermissionRulesRuntime {
|
|
|
462
654
|
pendingReloadCount() {
|
|
463
655
|
return this.timers.size;
|
|
464
656
|
}
|
|
465
|
-
/**
|
|
657
|
+
/**
|
|
658
|
+
* Debounce watch events into one reload per stability window. A
|
|
659
|
+
* candidate (directory) watcher only reloads a cwd once its expected
|
|
660
|
+
* file actually exists — unrelated events while the file is absent are
|
|
661
|
+
* dropped before any re-read.
|
|
662
|
+
*/
|
|
466
663
|
scheduleReload(source) {
|
|
467
664
|
const existing = this.timers.get(source);
|
|
468
665
|
if (existing !== undefined)
|
|
469
666
|
clearTimeout(existing);
|
|
470
667
|
const timer = setTimeout(() => {
|
|
471
668
|
this.timers.delete(source);
|
|
472
|
-
|
|
669
|
+
const entry = this.watchers.get(source);
|
|
670
|
+
if (entry === undefined)
|
|
671
|
+
return;
|
|
672
|
+
for (const cwd of entry.cwds) {
|
|
673
|
+
const candidate = entry.candidates?.get(cwd);
|
|
674
|
+
if (candidate !== undefined && !existsSync(candidate))
|
|
675
|
+
continue;
|
|
473
676
|
this.reload(cwd);
|
|
677
|
+
}
|
|
474
678
|
}, this.config.watchStabilityThresholdMs);
|
|
475
679
|
this.timers.set(source, timer);
|
|
476
680
|
}
|
|
@@ -490,8 +694,59 @@ export function apply(ctx, config) {
|
|
|
490
694
|
ctx.commands.register({
|
|
491
695
|
name: 'rules',
|
|
492
696
|
description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
|
|
493
|
-
input: { hint: '[reload | decisions [n] | test <tool> <json-args>]' },
|
|
697
|
+
input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
|
|
494
698
|
handler: invocation => runtime.command(invocation),
|
|
495
699
|
});
|
|
496
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* Identity candidates for the `agents` match dimension, derived from the
|
|
703
|
+
* caller agent's session header: `main` for top-level sessions, `subagent`
|
|
704
|
+
* for subagent children (`header.origin === 'subagent'`), and
|
|
705
|
+
* `preset:<name>` when a preset composed the agent. No agent (or an
|
|
706
|
+
* unidentifiable one) yields no candidates, so agent-scoped rules fail
|
|
707
|
+
* closed instead of matching an unknown caller.
|
|
708
|
+
* @param agent - the calling agent, when the call has one.
|
|
709
|
+
* @returns the candidate strings, in a stable order.
|
|
710
|
+
*/
|
|
711
|
+
function agentCandidates(agent) {
|
|
712
|
+
if (agent === undefined)
|
|
713
|
+
return [];
|
|
714
|
+
const header = agent.session.header;
|
|
715
|
+
const candidates = [header.origin === 'subagent' ? 'subagent' : 'main'];
|
|
716
|
+
if (typeof header.agentPreset === 'string' && header.agentPreset.length > 0)
|
|
717
|
+
candidates.push(`preset:${header.agentPreset}`);
|
|
718
|
+
return candidates;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Pull the next whitespace-delimited argument from a command tail,
|
|
722
|
+
* honoring single/double quotes (with backslash escapes) so JSON blobs and
|
|
723
|
+
* paths with spaces survive. The remainder is returned verbatim — the JSON
|
|
724
|
+
* argument tail of `/rules test` is never re-tokenized.
|
|
725
|
+
* @param text - the remaining command tail.
|
|
726
|
+
* @returns the token plus the untouched remainder, or undefined when only
|
|
727
|
+
* whitespace remains.
|
|
728
|
+
*/
|
|
729
|
+
function nextToken(text) {
|
|
730
|
+
let i = 0;
|
|
731
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
732
|
+
i += 1;
|
|
733
|
+
if (i >= text.length)
|
|
734
|
+
return undefined;
|
|
735
|
+
const quote = text[i];
|
|
736
|
+
if (quote === '"' || quote === "'") {
|
|
737
|
+
let end = i + 1;
|
|
738
|
+
while (end < text.length && text[end] !== quote) {
|
|
739
|
+
if (text[end] === '\\')
|
|
740
|
+
end += 1;
|
|
741
|
+
end += 1;
|
|
742
|
+
}
|
|
743
|
+
if (end >= text.length)
|
|
744
|
+
return { token: text.slice(i + 1), rest: '' };
|
|
745
|
+
return { token: text.slice(i + 1, end), rest: text.slice(end + 1) };
|
|
746
|
+
}
|
|
747
|
+
let end = i;
|
|
748
|
+
while (end < text.length && !/\s/.test(text[end]))
|
|
749
|
+
end += 1;
|
|
750
|
+
return { token: text.slice(i, end), rest: text.slice(end) };
|
|
751
|
+
}
|
|
497
752
|
//# sourceMappingURL=runtime.js.map
|