dsh-permission-rules 0.3.0 → 0.4.1
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 +31 -0
- package/README.es.md +13 -7
- package/README.hi.md +13 -7
- package/README.md +13 -7
- package/README.pt.md +13 -7
- package/README.zh.md +13 -7
- package/cordis.patch.yml +3 -0
- package/docs/rules-format.en.md +4 -2
- package/docs/rules-format.md +4 -2
- package/lib/index.js +308 -81
- package/lib/types/config.d.ts +12 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +3 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/events.d.ts +25 -6
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js +22 -4
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/index.js.map +1 -1
- package/lib/types/prose.d.ts +6 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +25 -15
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +8 -2
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +8 -3
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +79 -17
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +249 -52
- package/lib/types/runtime.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +15 -0
- package/src/events.ts +28 -6
- package/src/index.ts +1 -1
- package/src/prose.ts +31 -15
- package/src/rules.ts +10 -3
- package/src/runtime.ts +255 -49
package/src/runtime.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
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
7
|
* (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
|
|
8
|
-
* Chokidar-driven reloads
|
|
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
|
|
|
12
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
13
16
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
14
17
|
import chokidar from 'chokidar'
|
|
15
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -17,10 +20,11 @@ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands
|
|
|
17
20
|
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
18
21
|
import { resolveConfig } from './config.ts'
|
|
19
22
|
import type { Config, ResolvedConfig } from './config.ts'
|
|
20
|
-
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from './rules.ts'
|
|
23
|
+
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
|
|
21
24
|
import type { CompileOptions, CompiledRuleset, MatchContext, RuleHit } from './rules.ts'
|
|
22
25
|
import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
|
|
23
26
|
import type { UiProse } from './prose.ts'
|
|
27
|
+
import { isMarkedAuditEvent } from './events.ts'
|
|
24
28
|
import type { AuditAppend, AuditDecision, DecisionOutcome } from './events.ts'
|
|
25
29
|
|
|
26
30
|
export const name = 'permission-rules'
|
|
@@ -41,6 +45,13 @@ interface LoadedRules {
|
|
|
41
45
|
readonly failed?: true
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** 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. */
|
|
49
|
+
interface WatcherEntry {
|
|
50
|
+
readonly cwds: Set<string>
|
|
51
|
+
readonly close: () => void
|
|
52
|
+
readonly candidates?: Map<string, string>
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
56
|
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
46
57
|
* the watcher/timer effects registered in {@link apply}.
|
|
@@ -49,12 +60,15 @@ export class PermissionRulesRuntime {
|
|
|
49
60
|
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
50
61
|
private readonly byCwd = new Map<string, LoadedRules>()
|
|
51
62
|
|
|
52
|
-
/** Live watchers per rule
|
|
53
|
-
private readonly watchers = new Map<string,
|
|
63
|
+
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
64
|
+
private readonly watchers = new Map<string, WatcherEntry>()
|
|
54
65
|
|
|
55
66
|
/** Debounce timers per rule-file path. */
|
|
56
67
|
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
57
68
|
|
|
69
|
+
/** Whether the host honors the audit envelope's `ignorable` marker: unknown until the first decision (or peer-version check). */
|
|
70
|
+
private auditSupport: 'unknown' | 'supported' | 'unsupported' = 'unknown'
|
|
71
|
+
|
|
58
72
|
constructor(
|
|
59
73
|
private readonly ctx: Context,
|
|
60
74
|
readonly config: ResolvedConfig,
|
|
@@ -132,6 +146,18 @@ export class PermissionRulesRuntime {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Canonical per-workspace cache key: the resolved root, case-folded on
|
|
151
|
+
* Windows so differently-spelled paths to the same workspace share one
|
|
152
|
+
* cache entry and one watcher set instead of doubling both.
|
|
153
|
+
* @param cwd - the session's absolute workspace root.
|
|
154
|
+
* @returns the canonical key.
|
|
155
|
+
*/
|
|
156
|
+
private cacheKey(cwd: string): string {
|
|
157
|
+
const resolved = resolve(cwd)
|
|
158
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
/**
|
|
136
162
|
* The rules in effect for one cwd, loading on first use. Under
|
|
137
163
|
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
@@ -143,26 +169,27 @@ export class PermissionRulesRuntime {
|
|
|
143
169
|
* @returns the loaded rules.
|
|
144
170
|
*/
|
|
145
171
|
rulesFor(cwd: string): LoadedRules {
|
|
146
|
-
const
|
|
172
|
+
const key = this.cacheKey(cwd)
|
|
173
|
+
const existing = this.byCwd.get(key)
|
|
147
174
|
if (existing !== undefined) {
|
|
148
175
|
if (existing.failed === true) {
|
|
149
176
|
throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`)
|
|
150
177
|
}
|
|
151
|
-
this.byCwd.delete(
|
|
152
|
-
this.byCwd.set(
|
|
178
|
+
this.byCwd.delete(key)
|
|
179
|
+
this.byCwd.set(key, existing)
|
|
153
180
|
return existing
|
|
154
181
|
}
|
|
155
|
-
this.evictIfFull(
|
|
182
|
+
this.evictIfFull(key)
|
|
156
183
|
try {
|
|
157
184
|
const loaded = this.load(cwd)
|
|
158
|
-
this.byCwd.set(
|
|
159
|
-
this.reconcileWatch(
|
|
185
|
+
this.byCwd.set(key, loaded)
|
|
186
|
+
this.reconcileWatch(key, loaded.sources)
|
|
160
187
|
return loaded
|
|
161
188
|
} catch (error: unknown) {
|
|
162
189
|
const message = error instanceof Error ? error.message : String(error)
|
|
163
190
|
const sources = this.resolveSources(cwd)
|
|
164
|
-
this.byCwd.set(
|
|
165
|
-
this.reconcileWatch(
|
|
191
|
+
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
|
|
192
|
+
this.reconcileWatch(key, sources)
|
|
166
193
|
throw error
|
|
167
194
|
}
|
|
168
195
|
}
|
|
@@ -175,17 +202,22 @@ export class PermissionRulesRuntime {
|
|
|
175
202
|
* @param cwd - the workspace root.
|
|
176
203
|
*/
|
|
177
204
|
reload(cwd: string): void {
|
|
178
|
-
const
|
|
205
|
+
const key = this.cacheKey(cwd)
|
|
206
|
+
const previous = this.byCwd.get(key)
|
|
179
207
|
try {
|
|
180
208
|
const loaded = this.load(cwd)
|
|
181
|
-
this.byCwd.set(
|
|
182
|
-
this.reconcileWatch(
|
|
209
|
+
this.byCwd.set(key, loaded)
|
|
210
|
+
this.reconcileWatch(key, loaded.sources)
|
|
183
211
|
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`)
|
|
184
212
|
} catch (error: unknown) {
|
|
185
213
|
const message = error instanceof Error ? error.message : String(error)
|
|
186
214
|
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`)
|
|
187
|
-
if (previous !== undefined) this.byCwd.set(
|
|
188
|
-
else
|
|
215
|
+
if (previous !== undefined) this.byCwd.set(key, { ...previous, lastError: message })
|
|
216
|
+
else {
|
|
217
|
+
const sources = this.resolveSources(cwd)
|
|
218
|
+
this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
|
|
219
|
+
this.reconcileWatch(key, sources)
|
|
220
|
+
}
|
|
189
221
|
}
|
|
190
222
|
}
|
|
191
223
|
|
|
@@ -238,13 +270,17 @@ export class PermissionRulesRuntime {
|
|
|
238
270
|
* Append the log-only `permissionRules/decision` audit event for every
|
|
239
271
|
* decision (passthrough included unless `audit: 'hits'`), requesting the
|
|
240
272
|
* envelope's `ignorable: true` marker so any harness build can load the
|
|
241
|
-
* log
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
273
|
+
* log. Hosts whose `Session.append` predates the marker (the rc.6 line)
|
|
274
|
+
* silently drop it, leaving sessions unresumable on stricter hosts — the
|
|
275
|
+
* runtime therefore detects such hosts BEFORE the first append (peer
|
|
276
|
+
* version) and re-checks after the first append (returned envelope), then
|
|
277
|
+
* degrades: session-log audit is disabled with a one-time warning unless
|
|
278
|
+
* `allowUnmarkedAudit: true` opts back in. `source` names the matched
|
|
279
|
+
* rule's file, or the nearest effective file on a passthrough; `cwd`
|
|
280
|
+
* names the workspace the rules were resolved for; `dryRun` marks
|
|
281
|
+
* would-be deny/ask hits under `enforce: false`. Agentless calls have no
|
|
282
|
+
* session to audit; append failures are contained so an audit hiccup can
|
|
283
|
+
* never change a permission decision.
|
|
248
284
|
* @param exec - the pending call.
|
|
249
285
|
* @param loaded - the rules in effect.
|
|
250
286
|
* @param hit - the first matching rule, or undefined for passthrough.
|
|
@@ -255,8 +291,17 @@ export class PermissionRulesRuntime {
|
|
|
255
291
|
if (this.config.audit === 'hits' && hit === undefined) return
|
|
256
292
|
const agent = exec.agent
|
|
257
293
|
if (agent === undefined) return
|
|
294
|
+
if (this.auditSupport === 'unsupported') return
|
|
295
|
+
if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
|
|
296
|
+
const version = this.peerVersion()
|
|
297
|
+
if (version !== null && isUnmarkedHostVersion(version)) {
|
|
298
|
+
this.auditSupport = 'unsupported'
|
|
299
|
+
this.warnUnmarkedAuditHost()
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
}
|
|
258
303
|
try {
|
|
259
|
-
|
|
304
|
+
const result = this.appendAudit(agent, {
|
|
260
305
|
toolName: exec.name,
|
|
261
306
|
callId: exec.callId,
|
|
262
307
|
source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
|
|
@@ -265,12 +310,42 @@ export class PermissionRulesRuntime {
|
|
|
265
310
|
cwd: loaded.cwd,
|
|
266
311
|
...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
|
|
267
312
|
...dryRun ? { dryRun: true as const } : {},
|
|
268
|
-
}
|
|
313
|
+
})
|
|
314
|
+
if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
|
|
315
|
+
if (isMarkedAuditEvent(result)) {
|
|
316
|
+
this.auditSupport = 'supported'
|
|
317
|
+
} else {
|
|
318
|
+
this.auditSupport = 'unsupported'
|
|
319
|
+
this.warnUnmarkedAuditHost()
|
|
320
|
+
}
|
|
321
|
+
}
|
|
269
322
|
} catch (error: unknown) {
|
|
270
323
|
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
|
|
271
324
|
}
|
|
272
325
|
}
|
|
273
326
|
|
|
327
|
+
/** Append one audit event through the session surface; the probe seam for host-capability detection. */
|
|
328
|
+
private appendAudit(agent: NonNullable<ToolExecution['agent']>, data: AuditDecision): unknown {
|
|
329
|
+
return (agent.session.append as unknown as AuditAppend)('permissionRules/decision', data, { ignorable: true })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
|
|
333
|
+
private peerVersion(): string | null {
|
|
334
|
+
try {
|
|
335
|
+
const pkg = createRequire(import.meta.url)('@deepseek-ai/dsh-session/package.json') as { version?: unknown }
|
|
336
|
+
return typeof pkg.version === 'string' ? pkg.version : null
|
|
337
|
+
} catch {
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** One-time warning that session-log audit was disabled to keep session logs loadable. */
|
|
343
|
+
private warnUnmarkedAuditHost(): void {
|
|
344
|
+
this.ctx.logger.warn(
|
|
345
|
+
'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)',
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
274
349
|
/**
|
|
275
350
|
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
276
351
|
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
@@ -290,12 +365,13 @@ export class PermissionRulesRuntime {
|
|
|
290
365
|
if (verb === 'reload') {
|
|
291
366
|
if (rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
292
367
|
this.reload(cwd)
|
|
293
|
-
const reloaded = this.byCwd.get(cwd)
|
|
368
|
+
const reloaded = this.byCwd.get(this.cacheKey(cwd))
|
|
294
369
|
if (reloaded?.lastError !== undefined) return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) }
|
|
295
370
|
const rules = reloaded?.compiled.rules ?? []
|
|
296
371
|
const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ')
|
|
297
372
|
return { kind: 'success', text: prose.reloaded(rules.length, source) }
|
|
298
373
|
}
|
|
374
|
+
if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
299
375
|
if (verb === 'decisions') {
|
|
300
376
|
if (rest.length > 1) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
301
377
|
let count = 10
|
|
@@ -309,7 +385,7 @@ export class PermissionRulesRuntime {
|
|
|
309
385
|
if (verb === 'test') {
|
|
310
386
|
return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose)
|
|
311
387
|
}
|
|
312
|
-
if (verb !== '') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
388
|
+
if (verb !== '' && verb !== 'list') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
313
389
|
let loaded: LoadedRules
|
|
314
390
|
try {
|
|
315
391
|
loaded = this.rulesFor(cwd)
|
|
@@ -321,7 +397,8 @@ export class PermissionRulesRuntime {
|
|
|
321
397
|
lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''))
|
|
322
398
|
} else {
|
|
323
399
|
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd))
|
|
324
|
-
|
|
400
|
+
const multiSource = loaded.sources.length > 1
|
|
401
|
+
lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)))
|
|
325
402
|
const unreachable = findUnreachableRules(loaded.compiled)
|
|
326
403
|
if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)))
|
|
327
404
|
}
|
|
@@ -339,9 +416,11 @@ export class PermissionRulesRuntime {
|
|
|
339
416
|
* Execute `/rules test` with optional leading flags: `--cwd <dir>`
|
|
340
417
|
* evaluates against that workspace (rule discovery AND path
|
|
341
418
|
* normalization), `--env KEY=VALUE` (repeatable) overrides host env for
|
|
342
|
-
* `when.env` matching,
|
|
343
|
-
* agent-identity candidates for the `agents` dimension
|
|
344
|
-
*
|
|
419
|
+
* `when.env` matching, `--agent <selector>` (repeatable) supplies
|
|
420
|
+
* agent-identity candidates for the `agents` dimension, and
|
|
421
|
+
* `--platform <name>` overrides the host platform for `when.platform`.
|
|
422
|
+
* The JSON argument tail is kept verbatim, so quoted JSON survives
|
|
423
|
+
* unchanged.
|
|
345
424
|
* @param raw - the full raw command input.
|
|
346
425
|
* @param verbRaw - the verb as typed.
|
|
347
426
|
* @param invocation - the received command invocation (session agent).
|
|
@@ -354,6 +433,7 @@ export class PermissionRulesRuntime {
|
|
|
354
433
|
const envOverrides: Record<string, string> = {}
|
|
355
434
|
const agentSelectors: string[] = []
|
|
356
435
|
let testCwd: string | undefined
|
|
436
|
+
let testPlatform: string | undefined
|
|
357
437
|
let parsed = nextToken(rest)
|
|
358
438
|
while (parsed !== undefined && parsed.token.startsWith('--')) {
|
|
359
439
|
const flag = parsed.token
|
|
@@ -369,6 +449,9 @@ export class PermissionRulesRuntime {
|
|
|
369
449
|
envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1)
|
|
370
450
|
} else if (flag === '--agent') {
|
|
371
451
|
agentSelectors.push(value.token)
|
|
452
|
+
} else if (flag === '--platform') {
|
|
453
|
+
if (!PLATFORMS.includes(value.token)) return { kind: 'error', text: prose.testBadPlatform(value.token) }
|
|
454
|
+
testPlatform = value.token
|
|
372
455
|
} else {
|
|
373
456
|
return { kind: 'error', text: prose.testUnknownFlag(flag) }
|
|
374
457
|
}
|
|
@@ -392,7 +475,7 @@ export class PermissionRulesRuntime {
|
|
|
392
475
|
return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
|
|
393
476
|
}
|
|
394
477
|
const context: MatchContext = {
|
|
395
|
-
platform: process.platform,
|
|
478
|
+
platform: testPlatform ?? process.platform,
|
|
396
479
|
env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
|
|
397
480
|
agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
|
|
398
481
|
}
|
|
@@ -403,16 +486,28 @@ export class PermissionRulesRuntime {
|
|
|
403
486
|
}
|
|
404
487
|
}
|
|
405
488
|
|
|
489
|
+
/** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
|
|
490
|
+
private displaySource(source: string, cwd: string): string {
|
|
491
|
+
if (source.length === 0) return ''
|
|
492
|
+
const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths)
|
|
493
|
+
return relative.length > 0 ? relative : source
|
|
494
|
+
}
|
|
495
|
+
|
|
406
496
|
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
407
497
|
private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
|
|
408
498
|
const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
lines.push(prose.
|
|
499
|
+
const lines: string[] = []
|
|
500
|
+
if (decisions.length === 0) {
|
|
501
|
+
lines.push(prose.noDecisions)
|
|
502
|
+
} else {
|
|
503
|
+
const shown = decisions.slice(-count)
|
|
504
|
+
lines.push(prose.decisionsHeader(shown.length, decisions.length))
|
|
505
|
+
for (const event of shown) {
|
|
506
|
+
const data = event.data as AuditDecision
|
|
507
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
|
|
508
|
+
}
|
|
415
509
|
}
|
|
510
|
+
if (this.auditSupport === 'unsupported') lines.push(prose.auditDisabledNotice)
|
|
416
511
|
return { kind: 'success', text: lines.join('\n') }
|
|
417
512
|
}
|
|
418
513
|
|
|
@@ -487,18 +582,99 @@ export class PermissionRulesRuntime {
|
|
|
487
582
|
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
488
583
|
* left the chain (deleted file, fallback switch), detach the workspace
|
|
489
584
|
* from watchers serving other sources and close the ones left empty, so
|
|
490
|
-
* long-running hosts cannot accumulate stale watchers.
|
|
491
|
-
* rule
|
|
492
|
-
*
|
|
493
|
-
*
|
|
585
|
+
* long-running hosts cannot accumulate stale watchers. Expected-but-absent
|
|
586
|
+
* rule files (the project file when it is not effective, and a configured
|
|
587
|
+
* fallback that does not exist) are covered by candidate watchers on
|
|
588
|
+
* their deepest existing ancestor directory, so a file created
|
|
589
|
+
* mid-session is adopted without a manual `/rules reload`. With
|
|
590
|
+
* `searchUp`, only the immediate cwd-level candidate is watched —
|
|
591
|
+
* deeper ancestors are discovered on the next load.
|
|
494
592
|
*/
|
|
495
593
|
private reconcileWatch(cwd: string, sources: readonly string[]): void {
|
|
496
|
-
if (sources.length === 0) return
|
|
497
594
|
for (const [watchedSource, entry] of this.watchers) {
|
|
498
|
-
if (!sources.includes(watchedSource))
|
|
595
|
+
if (!sources.includes(watchedSource)) {
|
|
596
|
+
entry.cwds.delete(cwd)
|
|
597
|
+
entry.candidates?.delete(cwd)
|
|
598
|
+
}
|
|
499
599
|
}
|
|
500
600
|
this.pruneWatchers()
|
|
501
601
|
for (const source of sources) this.attachWatch(cwd, source)
|
|
602
|
+
for (const candidate of this.candidateSources(cwd)) {
|
|
603
|
+
if (sources.includes(candidate) || existsSync(candidate)) continue
|
|
604
|
+
this.attachCandidateWatch(cwd, candidate)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The rule-file paths a workspace COULD be served by: the project file
|
|
610
|
+
* (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
|
|
611
|
+
* only under `searchUp`) plus the configured fallback.
|
|
612
|
+
*/
|
|
613
|
+
private candidateSources(cwd: string): string[] {
|
|
614
|
+
const candidates: string[] = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)]
|
|
615
|
+
const fallback = this.config.fallbackPath
|
|
616
|
+
if (fallback !== undefined) candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback))
|
|
617
|
+
return candidates
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Watch one expected-but-absent rule file through its deepest existing
|
|
622
|
+
* ancestor directory. Chokidar cannot reliably watch a missing path when
|
|
623
|
+
* its parent is also missing, but directory watching is dependable; every
|
|
624
|
+
* relevant event re-checks existence and only triggers a reload once the
|
|
625
|
+
* candidate actually appeared, so unrelated workspace activity while the
|
|
626
|
+
* file is absent costs a stat, not a reload.
|
|
627
|
+
*/
|
|
628
|
+
private attachCandidateWatch(cwd: string, candidate: string): void {
|
|
629
|
+
if (!this.config.watch) return
|
|
630
|
+
const dir = this.deepestExistingDir(dirname(candidate))
|
|
631
|
+
const existing = this.watchers.get(dir)
|
|
632
|
+
if (existing !== undefined) {
|
|
633
|
+
existing.cwds.add(cwd)
|
|
634
|
+
existing.candidates?.set(cwd, candidate)
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
const cwds = new Set([cwd])
|
|
638
|
+
const candidates = new Map([[cwd, candidate]])
|
|
639
|
+
const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true })
|
|
640
|
+
const onEvent = (): void => this.scheduleReload(dir)
|
|
641
|
+
watcher.on('add', onEvent)
|
|
642
|
+
watcher.on('addDir', onEvent)
|
|
643
|
+
watcher.on('change', onEvent)
|
|
644
|
+
watcher.on('unlink', onEvent)
|
|
645
|
+
watcher.on('unlinkDir', onEvent)
|
|
646
|
+
watcher.on('error', (error: unknown) => {
|
|
647
|
+
this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`)
|
|
648
|
+
})
|
|
649
|
+
this.watchers.set(dir, {
|
|
650
|
+
cwds,
|
|
651
|
+
candidates,
|
|
652
|
+
close: () => {
|
|
653
|
+
void watcher.close().catch((error: unknown) => {
|
|
654
|
+
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`)
|
|
655
|
+
})
|
|
656
|
+
},
|
|
657
|
+
})
|
|
658
|
+
this.ctx.effect(() => () => {
|
|
659
|
+
this.watchers.get(dir)?.close()
|
|
660
|
+
this.watchers.delete(dir)
|
|
661
|
+
const timer = this.timers.get(dir)
|
|
662
|
+
if (timer !== undefined) {
|
|
663
|
+
clearTimeout(timer)
|
|
664
|
+
this.timers.delete(dir)
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
|
|
670
|
+
private deepestExistingDir(dir: string): string {
|
|
671
|
+
let current = dir
|
|
672
|
+
for (;;) {
|
|
673
|
+
if (existsSync(current)) return current
|
|
674
|
+
const parent = dirname(current)
|
|
675
|
+
if (parent === current) return current
|
|
676
|
+
current = parent
|
|
677
|
+
}
|
|
502
678
|
}
|
|
503
679
|
|
|
504
680
|
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
@@ -527,7 +703,10 @@ export class PermissionRulesRuntime {
|
|
|
527
703
|
const oldest = this.byCwd.keys().next().value
|
|
528
704
|
if (oldest === undefined) return
|
|
529
705
|
this.byCwd.delete(oldest)
|
|
530
|
-
for (const entry of this.watchers.values())
|
|
706
|
+
for (const entry of this.watchers.values()) {
|
|
707
|
+
entry.cwds.delete(oldest)
|
|
708
|
+
entry.candidates?.delete(oldest)
|
|
709
|
+
}
|
|
531
710
|
this.pruneWatchers()
|
|
532
711
|
}
|
|
533
712
|
|
|
@@ -541,13 +720,24 @@ export class PermissionRulesRuntime {
|
|
|
541
720
|
return this.timers.size
|
|
542
721
|
}
|
|
543
722
|
|
|
544
|
-
/**
|
|
723
|
+
/**
|
|
724
|
+
* Debounce watch events into one reload per stability window. A
|
|
725
|
+
* candidate (directory) watcher only reloads a cwd once its expected
|
|
726
|
+
* file actually exists — unrelated events while the file is absent are
|
|
727
|
+
* dropped before any re-read.
|
|
728
|
+
*/
|
|
545
729
|
private scheduleReload(source: string): void {
|
|
546
730
|
const existing = this.timers.get(source)
|
|
547
731
|
if (existing !== undefined) clearTimeout(existing)
|
|
548
732
|
const timer = setTimeout(() => {
|
|
549
733
|
this.timers.delete(source)
|
|
550
|
-
|
|
734
|
+
const entry = this.watchers.get(source)
|
|
735
|
+
if (entry === undefined) return
|
|
736
|
+
for (const cwd of entry.cwds) {
|
|
737
|
+
const candidate = entry.candidates?.get(cwd)
|
|
738
|
+
if (candidate !== undefined && !existsSync(candidate)) continue
|
|
739
|
+
this.reload(cwd)
|
|
740
|
+
}
|
|
551
741
|
}, this.config.watchStabilityThresholdMs)
|
|
552
742
|
this.timers.set(source, timer)
|
|
553
743
|
}
|
|
@@ -568,11 +758,27 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
568
758
|
ctx.commands.register({
|
|
569
759
|
name: 'rules',
|
|
570
760
|
description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
|
|
571
|
-
input: { hint: '[reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] <tool> <json-args>]' },
|
|
761
|
+
input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
|
|
572
762
|
handler: invocation => runtime.command(invocation),
|
|
573
763
|
})
|
|
574
764
|
}
|
|
575
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Whether a `@deepseek-ai/dsh-session` version line predates the
|
|
768
|
+
* `ignorable` envelope-marker surface: the `0.1.0-rc.6` line and earlier
|
|
769
|
+
* silently drop the marker from `Session.append` options, so audit events
|
|
770
|
+
* written by those builds land unmarked and break resume on stricter
|
|
771
|
+
* hosts. Non-matching (post-rc.6, stable, or unresolvable) versions are
|
|
772
|
+
* treated as possibly-marker-aware and verified by the append probe.
|
|
773
|
+
* @param version - the installed peer version string.
|
|
774
|
+
* @returns true for the known-unmarked rc.1–rc.6 lines.
|
|
775
|
+
*/
|
|
776
|
+
export function isUnmarkedHostVersion(version: string): boolean {
|
|
777
|
+
const match = /^0\.1\.0-rc\.(\d+)$/.exec(version.trim())
|
|
778
|
+
if (match === null) return false
|
|
779
|
+
return Number(match[1]) <= 6
|
|
780
|
+
}
|
|
781
|
+
|
|
576
782
|
/**
|
|
577
783
|
* Identity candidates for the `agents` match dimension, derived from the
|
|
578
784
|
* caller agent's session header: `main` for top-level sessions, `subagent`
|