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