dsh-permission-rules 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, readFileSync } from 'node:fs'
15
+ import { createRequire } from 'node:module'
15
16
  import { dirname, isAbsolute, join, resolve } from 'node:path'
16
17
  import chokidar from 'chokidar'
17
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -23,6 +24,7 @@ import { compileRules, compileRulesChain, describeRule, findUnreachableRules, ma
23
24
  import type { CompileOptions, CompiledRuleset, MatchContext, RuleHit } from './rules.ts'
24
25
  import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
25
26
  import type { UiProse } from './prose.ts'
27
+ import { isMarkedAuditEvent } from './events.ts'
26
28
  import type { AuditAppend, AuditDecision, DecisionOutcome } from './events.ts'
27
29
 
28
30
  export const name = 'permission-rules'
@@ -64,6 +66,9 @@ export class PermissionRulesRuntime {
64
66
  /** Debounce timers per rule-file path. */
65
67
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
66
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
+
67
72
  constructor(
68
73
  private readonly ctx: Context,
69
74
  readonly config: ResolvedConfig,
@@ -265,13 +270,17 @@ export class PermissionRulesRuntime {
265
270
  * Append the log-only `permissionRules/decision` audit event for every
266
271
  * decision (passthrough included unless `audit: 'hits'`), requesting the
267
272
  * envelope's `ignorable: true` marker so any harness build can load the
268
- * log (readers that do not know the type skip the audit record instead of
269
- * refusing the session). `source` names the matched rule's file, or the
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.
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.
275
284
  * @param exec - the pending call.
276
285
  * @param loaded - the rules in effect.
277
286
  * @param hit - the first matching rule, or undefined for passthrough.
@@ -282,8 +291,17 @@ export class PermissionRulesRuntime {
282
291
  if (this.config.audit === 'hits' && hit === undefined) return
283
292
  const agent = exec.agent
284
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
+ }
285
303
  try {
286
- ;(agent.session.append as unknown as AuditAppend)('permissionRules/decision', {
304
+ const result = this.appendAudit(agent, {
287
305
  toolName: exec.name,
288
306
  callId: exec.callId,
289
307
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
@@ -292,12 +310,42 @@ export class PermissionRulesRuntime {
292
310
  cwd: loaded.cwd,
293
311
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
294
312
  ...dryRun ? { dryRun: true as const } : {},
295
- }, { ignorable: true })
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
+ }
296
322
  } catch (error: unknown) {
297
323
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
298
324
  }
299
325
  }
300
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
+
301
349
  /**
302
350
  * Execute the `/rules` command: bare `/rules` lists the active rules and
303
351
  * their sources; `/rules reload` re-reads the chain; `/rules decisions
@@ -448,13 +496,18 @@ export class PermissionRulesRuntime {
448
496
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
449
497
  private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
450
498
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
451
- if (decisions.length === 0) return { kind: 'success', text: prose.noDecisions }
452
- const shown = decisions.slice(-count)
453
- const lines = [prose.decisionsHeader(shown.length, decisions.length)]
454
- for (const event of shown) {
455
- const data = event.data as AuditDecision
456
- lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
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
+ }
457
509
  }
510
+ if (this.auditSupport === 'unsupported') lines.push(prose.auditDisabledNotice)
458
511
  return { kind: 'success', text: lines.join('\n') }
459
512
  }
460
513
 
@@ -710,6 +763,22 @@ export function apply(ctx: Context, config: Config): void {
710
763
  })
711
764
  }
712
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
+
713
782
  /**
714
783
  * Identity candidates for the `agents` match dimension, derived from the
715
784
  * caller agent's session header: `main` for top-level sessions, `subagent`