dsh-permission-rules 0.4.2 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.es.md +160 -117
  3. package/README.hi.md +162 -119
  4. package/README.md +133 -90
  5. package/README.pt.md +161 -118
  6. package/README.zh.md +162 -119
  7. package/docs/rule-test-vectors/README.md +28 -0
  8. package/docs/rule-test-vectors/corpus.json +109 -0
  9. package/lib/client.js +4693 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1530 -179
  12. package/lib/typert.host.js +4305 -0
  13. package/lib/types/client/index.d.ts +28 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/locales.d.ts +9 -0
  16. package/lib/types/client/locales.d.ts.map +1 -0
  17. package/lib/types/client/remote.d.ts +181 -0
  18. package/lib/types/client/remote.d.ts.map +1 -0
  19. package/lib/types/client/section.d.ts +35 -0
  20. package/lib/types/client/section.d.ts.map +1 -0
  21. package/lib/types/config.d.ts +54 -2
  22. package/lib/types/config.d.ts.map +1 -1
  23. package/lib/types/events.d.ts +45 -0
  24. package/lib/types/events.d.ts.map +1 -1
  25. package/lib/types/index.d.ts +8 -6
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/network.d.ts +90 -0
  28. package/lib/types/network.d.ts.map +1 -0
  29. package/lib/types/prose.d.ts +15 -0
  30. package/lib/types/prose.d.ts.map +1 -1
  31. package/lib/types/proxy.d.ts +128 -0
  32. package/lib/types/proxy.d.ts.map +1 -0
  33. package/lib/types/remote-service.d.ts +45 -0
  34. package/lib/types/remote-service.d.ts.map +1 -0
  35. package/lib/types/rules.d.ts +131 -0
  36. package/lib/types/rules.d.ts.map +1 -1
  37. package/lib/types/runtime.d.ts +153 -21
  38. package/lib/types/runtime.d.ts.map +1 -1
  39. package/lib/types/settings.d.ts +28 -0
  40. package/lib/types/settings.d.ts.map +1 -0
  41. package/lib/types/typert.host.d.ts +166 -0
  42. package/lib/types/typert.host.d.ts.map +1 -0
  43. package/lib/types/wire.d.ts +421 -0
  44. package/lib/types/wire.d.ts.map +1 -0
  45. package/package.json +182 -144
  46. package/scripts/repair-session-logs.mjs +7 -2
  47. package/src/client/index.ts +111 -0
  48. package/src/client/locales.ts +94 -0
  49. package/src/client/remote.ts +41 -0
  50. package/src/client/section.ts +201 -0
  51. package/src/config.ts +111 -0
  52. package/src/events.ts +46 -0
  53. package/src/index.ts +2 -0
  54. package/src/network.ts +177 -0
  55. package/src/prose.ts +85 -10
  56. package/src/proxy.ts +327 -0
  57. package/src/remote-service.ts +109 -0
  58. package/src/rules.ts +424 -5
  59. package/src/runtime.ts +464 -53
  60. package/src/settings.ts +74 -0
  61. package/src/typert.host.ts +25 -0
  62. package/src/wire.ts +239 -0
  63. package/lib/types/config.js +0 -99
  64. package/lib/types/config.js.map +0 -1
  65. package/lib/types/events.js +0 -44
  66. package/lib/types/events.js.map +0 -1
  67. package/lib/types/glob.js +0 -307
  68. package/lib/types/glob.js.map +0 -1
  69. package/lib/types/index.js +0 -27
  70. package/lib/types/index.js.map +0 -1
  71. package/lib/types/prose.js +0 -173
  72. package/lib/types/prose.js.map +0 -1
  73. package/lib/types/rules.js +0 -610
  74. package/lib/types/rules.js.map +0 -1
  75. package/lib/types/runtime.js +0 -818
  76. package/lib/types/runtime.js.map +0 -1
package/src/runtime.ts CHANGED
@@ -11,21 +11,28 @@
11
11
  * @module dsh-permission-rules/runtime
12
12
  */
13
13
 
14
- import { existsSync, readFileSync } from 'node:fs'
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
15
  import { createRequire } from 'node:module'
16
16
  import { dirname, isAbsolute, join, resolve } from 'node:path'
17
17
  import chokidar from 'chokidar'
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
20
+ import type { CallId } from '@deepseek-ai/dsh-llm'
20
21
  import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
21
22
  import { resolveConfig } from './config.ts'
22
23
  import type { Config, ResolvedConfig } from './config.ts'
23
- import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
24
- import type { CompileOptions, CompiledRuleset, MatchContext, RuleHit } from './rules.ts'
24
+ import { compileRules, compileRulesChain, describeRule, findUnreachableRules, isShellTool, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
25
+ import type { CompileOptions, CompiledRuleset, MatchContext, NetworkTarget, RuleHit } from './rules.ts'
25
26
  import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
26
27
  import type { UiProse } from './prose.ts'
28
+ import { blockMessage, decideNetworkTarget, defaultDecision, networkModeForSandbox } from './network.ts'
29
+ import type { NetworkChain, NetworkDecision, NetworkMode } from './network.ts'
30
+ import { injectProxyEnv, NetworkProxy } from './proxy.ts'
31
+ import type { NetworkBlockRecord, ProxyAttribution } from './proxy.ts'
32
+ import { PermissionRulesRemoteService } from './remote-service.ts'
33
+ import { attachSettingsSection } from './settings.ts'
27
34
  import { isMarkedAuditEvent } from './events.ts'
28
- import type { AuditAppend, AuditDecision, DecisionOutcome } from './events.ts'
35
+ import type { AuditAppend, AuditDecision, AuditNetworkBlock, DecisionOutcome } from './events.ts'
29
36
 
30
37
  export const name = 'permission-rules'
31
38
 
@@ -52,9 +59,23 @@ interface WatcherEntry {
52
59
  readonly candidates?: Map<string, string>
53
60
  }
54
61
 
62
+ /** One in-flight shell execution, for proxy-block attribution (newest first). */
63
+ interface InFlightShell {
64
+ readonly tool: string
65
+ readonly callId: unknown
66
+ readonly startedAt: number
67
+ readonly agent?: ToolExecution['agent']
68
+ }
69
+
70
+ /** The network-capable tools whose unlisted calls fall back to the mode default at `tools/pre-execute`. */
71
+ const WEB_TOOLS: readonly string[] = ['web_fetch', 'web_search']
72
+
73
+ /** Stale in-flight entries are dropped after this many milliseconds (attribution is best-effort). */
74
+ const IN_FLIGHT_TTL_MS = 10 * 60 * 1000
75
+
55
76
  /**
56
77
  * State and behavior. One instance per plugin mount; disposals are owned by
57
- * the watcher/timer effects registered in {@link apply}.
78
+ * the watcher/timer/proxy effects registered in {@link apply}.
58
79
  */
59
80
  export class PermissionRulesRuntime {
60
81
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
@@ -69,18 +90,43 @@ export class PermissionRulesRuntime {
69
90
  /** Whether the host honors the audit envelope's `ignorable` marker: unknown until the first decision (or peer-version check). */
70
91
  private auditSupport: 'unknown' | 'supported' | 'unsupported' = 'unknown'
71
92
 
93
+ /** In-flight bash/pwsh executions (insertion order = start order), for proxy-block attribution. */
94
+ private readonly inFlight = new Map<string, InFlightShell>()
95
+
96
+ /** The network proxy, when the policy is enabled and mounted. */
97
+ private networkProxy: NetworkProxy | undefined
98
+
99
+ /** Restores the injected proxy environment (set after a successful bind). */
100
+ private envRestore: (() => void) | undefined
101
+
102
+ /** The authoritative config source: the composition entry, replaced by the settings scope while attached. */
103
+ private configSource: () => ResolvedConfig
104
+
72
105
  constructor(
73
106
  private readonly ctx: Context,
74
- readonly config: ResolvedConfig,
75
- ) {}
107
+ config: ResolvedConfig,
108
+ ) {
109
+ this.configSource = () => config
110
+ }
111
+
112
+ /** The currently authoritative config (settings scope or composition entry). */
113
+ get config(): ResolvedConfig {
114
+ return this.configSource()
115
+ }
116
+
117
+ /** Rebind the config source (the settings section hooks call this on attach/detach/change). */
118
+ setConfigSource(source: () => ResolvedConfig): void {
119
+ this.configSource = source
120
+ }
76
121
 
77
122
  /** The shared compile options derived from config. */
78
123
  private compileOptions(): CompileOptions {
124
+ const config = this.configSource()
79
125
  return {
80
- patternMode: this.config.patternMode,
81
- maxRules: this.config.maxRules,
82
- maxGlobStars: this.config.maxGlobStars,
83
- caseInsensitivePaths: this.config.caseInsensitivePaths,
126
+ patternMode: config.patternMode,
127
+ maxRules: config.maxRules,
128
+ maxGlobStars: config.maxGlobStars,
129
+ caseInsensitivePaths: config.caseInsensitivePaths,
84
130
  }
85
131
  }
86
132
 
@@ -236,11 +282,16 @@ export class PermissionRulesRuntime {
236
282
  * The `tools/pre-execute` listener. A deny/ask hit returns the decision
237
283
  * (first match wins, short-circuiting downstream listeners); an allow hit
238
284
  * and a passthrough MUST delegate via `next()` so later listeners keep
239
- * their say. Under `enforce: false` (dry-run) a deny/ask hit also
240
- * delegates — the record keeps the would-be action with `dryRun: true`
241
- * and the actual downstream outcome. Audit is appended once the final
242
- * outcome is known, so the recorded `outcome` matches what the waterfall
243
- * settled on.
285
+ * their say. Network-scoped rule hits on tool calls carry the structured
286
+ * `[network: …]` marker; when NO rule matches a web tool
287
+ * (`web_fetch`/`web_search`) the network mode default applies (deny-all
288
+ * denies, whitelist asks/denies, allow-all passes) — shell tools are
289
+ * NOT gated here, the proxy enforces their traffic per connection.
290
+ * Under `enforce: false` (dry-run) a deny/ask hit also delegates — the
291
+ * record keeps the would-be action with `dryRun: true` and the actual
292
+ * downstream outcome. Audit is appended once the final outcome is
293
+ * known, so the recorded `outcome` matches what the waterfall settled
294
+ * on.
244
295
  * @param exec - the pending call (name, parsed arguments, caller agent).
245
296
  * @param next - the downstream chain.
246
297
  * @returns the pre-execute decision.
@@ -249,21 +300,49 @@ export class PermissionRulesRuntime {
249
300
  const cwd = exec.agent?.session.header.cwd ?? process.cwd()
250
301
  const loaded = this.rulesFor(cwd)
251
302
  const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec))
252
- if (hit === undefined || hit.rule.action === 'allow') {
253
- const decision = await next()
254
- this.audit(exec, loaded, hit, decision.kind)
255
- return decision
303
+ if (hit !== undefined && hit.rule.action !== 'allow') {
304
+ const structured = hit.rule.network !== undefined
305
+ const reason = structured ? `${hit.rule.action === 'deny' ? '[network: denied]' : '[network: approval required]'} ${hit.rule.reason}` : hit.rule.reason
306
+ if (!this.config.enforce) {
307
+ // Dry-run: match the rule, log what it WOULD do, and delegate.
308
+ const decision = await next()
309
+ this.audit(exec, loaded, hit, decision.kind, true)
310
+ return decision
311
+ }
312
+ const outcome: DecisionOutcome = hit.rule.action
313
+ this.audit(exec, loaded, hit, outcome)
314
+ return { kind: outcome, reason }
256
315
  }
257
- if (!this.config.enforce) {
258
- // Dry-run: match the rule, log what it WOULD do, and delegate.
259
- const decision = await next()
260
- this.audit(exec, loaded, hit, decision.kind, true)
261
- return decision
316
+ if (hit === undefined) {
317
+ const fallback = this.webToolFallback(exec)
318
+ if (fallback !== undefined) {
319
+ if (!this.config.enforce) {
320
+ const decision = await next()
321
+ this.audit(exec, loaded, undefined, decision.kind, true, fallback)
322
+ return decision
323
+ }
324
+ this.audit(exec, loaded, undefined, fallback, false, fallback)
325
+ return { kind: fallback, reason: blockMessage(defaultDecision(this.resolveNetworkMode(exec.agent?.session).mode, this.config.network.unlisted)) }
326
+ }
262
327
  }
263
- const outcome: DecisionOutcome = hit.rule.action
264
- this.audit(exec, loaded, hit, outcome)
265
- if (outcome === 'deny') return { kind: 'deny', reason: hit.rule.reason }
266
- return { kind: 'ask', reason: hit.rule.reason }
328
+ if (isShellTool(exec.name)) this.markShell(exec)
329
+ const decision = await next()
330
+ this.audit(exec, loaded, hit, decision.kind)
331
+ return decision
332
+ }
333
+
334
+ /**
335
+ * The mode-default decision for an unlisted web-tool call, or undefined
336
+ * when the policy is disabled, the tool is not a web tool, or the mode
337
+ * allows. Shell tools always yield undefined here (the proxy decides
338
+ * their traffic per connection).
339
+ */
340
+ private webToolFallback(exec: ToolExecution): 'deny' | 'ask' | undefined {
341
+ const network = this.config.network
342
+ if (!network.enabled || isShellTool(exec.name) || !WEB_TOOLS.includes(exec.name)) return undefined
343
+ const { mode } = this.resolveNetworkMode(exec.agent?.session)
344
+ const fallback = defaultDecision(mode, network.unlisted)
345
+ return fallback.action === 'allow' ? undefined : fallback.action
267
346
  }
268
347
 
269
348
  /**
@@ -278,17 +357,19 @@ export class PermissionRulesRuntime {
278
357
  * `allowUnmarkedAudit: true` opts back in. `source` names the matched
279
358
  * rule's file, or the nearest effective file on a passthrough; `cwd`
280
359
  * 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.
360
+ * would-be deny/ask hits under `enforce: false`; `modeDefault` records a
361
+ * network mode-default decision on a web tool (no rule fired).
362
+ * Agentless calls have no session to audit; append failures are
363
+ * contained so an audit hiccup can never change a permission decision.
284
364
  * @param exec - the pending call.
285
365
  * @param loaded - the rules in effect.
286
366
  * @param hit - the first matching rule, or undefined for passthrough.
287
367
  * @param outcome - the final pre-execute decision.
288
368
  * @param dryRun - mark the record as a would-be decision (dry-run mode).
369
+ * @param modeDefault - the network mode-default action when no rule fired.
289
370
  */
290
- audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome, dryRun = false): void {
291
- if (this.config.audit === 'hits' && hit === undefined) return
371
+ audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome, dryRun = false, modeDefault?: 'deny' | 'ask'): void {
372
+ if (this.config.audit === 'hits' && hit === undefined && modeDefault === undefined) return
292
373
  const agent = exec.agent
293
374
  if (agent === undefined) return
294
375
  if (this.auditSupport === 'unsupported') return
@@ -301,34 +382,321 @@ export class PermissionRulesRuntime {
301
382
  }
302
383
  }
303
384
  try {
385
+ const action = hit === undefined ? (modeDefault ?? 'passthrough') : hit.rule.action
304
386
  const result = this.appendAudit(agent, {
305
387
  toolName: exec.name,
306
388
  callId: exec.callId,
307
389
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
308
- action: hit === undefined ? 'passthrough' : hit.rule.action,
390
+ action,
309
391
  outcome,
310
392
  cwd: loaded.cwd,
311
393
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
394
+ ...modeDefault !== undefined ? { reason: `${modeDefault === 'deny' ? '[network: denied]' : '[network: approval required]'} network mode default (no rule matched)` } : {},
312
395
  ...dryRun ? { dryRun: true as const } : {},
313
396
  })
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
- }
397
+ this.probeAuditResult(result)
322
398
  } catch (error: unknown) {
323
399
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
324
400
  }
325
401
  }
326
402
 
403
+ /** After the first append, probe the returned envelope for the ignorable marker (host capability detection). */
404
+ private probeAuditResult(result: unknown): void {
405
+ if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
406
+ if (isMarkedAuditEvent(result)) {
407
+ this.auditSupport = 'supported'
408
+ } else {
409
+ this.auditSupport = 'unsupported'
410
+ this.warnUnmarkedAuditHost()
411
+ }
412
+ }
413
+ }
414
+
327
415
  /** Append one audit event through the session surface; the probe seam for host-capability detection. */
328
416
  private appendAudit(agent: NonNullable<ToolExecution['agent']>, data: AuditDecision): unknown {
329
417
  return (agent.session.append as unknown as AuditAppend)('permissionRules/decision', data, { ignorable: true })
330
418
  }
331
419
 
420
+ // --- Network policy ------------------------------------------------------
421
+
422
+ /**
423
+ * Resolve the network policy mode. An explicit config mode wins; `auto`
424
+ * maps the official sandbox preset (`read-only` → deny-all,
425
+ * `workspace-write` → whitelist, `danger-full-access` → allow-all) with
426
+ * `network.autoFallback` covering hosts without the sandbox-policy
427
+ * service (rc.6 and friends stay permissive until configured). For web
428
+ * tools the SESSION's resolved mode is used; the proxy resolves without
429
+ * a session (its connections carry no session context).
430
+ * @param session - optional session whose override outranks the default.
431
+ * @returns the resolved mode plus the sandbox preset it came from.
432
+ */
433
+ resolveNetworkMode(session?: { events?: unknown }): { mode: NetworkMode; sandboxMode: string | undefined } {
434
+ const cfg = this.config.network
435
+ if (cfg.mode !== 'auto') return { mode: cfg.mode, sandboxMode: undefined }
436
+ const policy = this.ctx.get('sandboxPolicy') as { defaultMode?: string; resolve?: (request?: { session?: unknown }) => { mode?: string } } | undefined
437
+ if (policy === undefined) return { mode: cfg.autoFallback, sandboxMode: undefined }
438
+ const sandboxMode = session === undefined ? policy.defaultMode : policy.resolve?.({ session })?.mode ?? policy.defaultMode
439
+ return { mode: networkModeForSandbox(sandboxMode, cfg.autoFallback), sandboxMode }
440
+ }
441
+
442
+ /**
443
+ * The proxy-layer decision for one connection target: first-match
444
+ * network rules across every loaded workspace chain (insertion order),
445
+ * then the mode default. Shell subprocess traffic counts as `bash`/
446
+ * `pwsh` tool candidates; loopback handling follows config.
447
+ */
448
+ private decideProxyTarget(target: NetworkTarget): NetworkDecision {
449
+ const { mode } = this.resolveNetworkMode()
450
+ return decideNetworkTarget(this.proxyChains(), target, {
451
+ mode,
452
+ unlisted: this.config.network.unlisted,
453
+ loopback: this.config.network.loopback,
454
+ })
455
+ }
456
+
457
+ /** The loaded rule chains in cache order, nearest workspace first. */
458
+ private proxyChains(): NetworkChain[] {
459
+ const chains: NetworkChain[] = []
460
+ for (const loaded of this.byCwd.values()) {
461
+ chains.push({ ruleset: loaded.compiled, sources: loaded.sources })
462
+ }
463
+ return chains
464
+ }
465
+
466
+ /** Mark one delegated shell execution as in-flight (newest attribution wins). */
467
+ private markShell(exec: ToolExecution): void {
468
+ this.inFlight.set(String(exec.callId), { tool: exec.name, callId: exec.callId, startedAt: Date.now(), agent: exec.agent })
469
+ }
470
+
471
+ /** Drop one shell execution when it settles. */
472
+ unmarkShell(exec: ToolExecution): void {
473
+ this.inFlight.delete(String(exec.callId))
474
+ }
475
+
476
+ /** The newest in-flight shell execution within the TTL, or undefined (best-effort attribution). */
477
+ private proxyAttribution(): ProxyAttribution | undefined {
478
+ const cutoff = Date.now() - IN_FLIGHT_TTL_MS
479
+ let newest: InFlightShell | undefined
480
+ for (const entry of this.inFlight.values()) {
481
+ if (entry.startedAt < cutoff) {
482
+ this.inFlight.delete(String(entry.callId))
483
+ continue
484
+ }
485
+ if (newest === undefined || entry.startedAt > newest.startedAt) newest = entry
486
+ }
487
+ if (newest === undefined) return undefined
488
+ return {
489
+ tool: newest.tool,
490
+ ...(newest.callId !== undefined ? { callId: newest.callId as CallId } : {}),
491
+ ...(newest.agent !== undefined ? { agent: newest.agent } : {}),
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Audit one proxy-layer block: the in-memory record is always kept
497
+ * (settings page), the logger always warns, and when the block can be
498
+ * attributed to an agent the `permissionRules/network` event is appended
499
+ * with the same ignorable-marker discipline as the decision audit.
500
+ */
501
+ private auditNetworkBlock(record: NetworkBlockRecord, attribution: ProxyAttribution | undefined): void {
502
+ const agent = attribution?.agent
503
+ if (agent === undefined || this.auditSupport === 'unsupported') return
504
+ if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
505
+ const version = this.peerVersion()
506
+ if (version !== null && isUnmarkedHostVersion(version)) {
507
+ this.auditSupport = 'unsupported'
508
+ this.warnUnmarkedAuditHost()
509
+ return
510
+ }
511
+ }
512
+ const data: AuditNetworkBlock = {
513
+ kind: 'block',
514
+ tool: record.tool,
515
+ attributed: record.attributed,
516
+ ...(record.callId !== undefined ? { callId: record.callId } : {}),
517
+ domain: record.domain,
518
+ ...(record.scheme !== undefined ? { scheme: record.scheme } : {}),
519
+ ...(record.port !== undefined ? { port: record.port } : {}),
520
+ action: record.action,
521
+ mode: record.mode,
522
+ matched: record.matched,
523
+ source: record.source,
524
+ ...(record.ruleIndex !== undefined ? { ruleIndex: record.ruleIndex } : {}),
525
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
526
+ time: record.time,
527
+ }
528
+ try {
529
+ const result = (agent.session.append as unknown as (type: 'permissionRules/network', data: AuditNetworkBlock, options?: { ignorable?: true }) => unknown)('permissionRules/network', data, { ignorable: true })
530
+ this.probeAuditResult(result)
531
+ } catch (error: unknown) {
532
+ this.ctx.logger.warn(`permission-rules: network audit append failed: ${String(error)}`)
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Mount the network proxy and the subprocess environment injection.
538
+ * Called from {@link apply}; the proxy binds an ephemeral (or
539
+ * configured) loopback port, the env injection only happens after a
540
+ * successful bind, and every side effect is owned by an effect
541
+ * disposer. A bind failure degrades loudly (file rules stay active,
542
+ * the settings page and `/rules network` show the inactive proxy)
543
+ * instead of taking the permission plugin down.
544
+ */
545
+ async attachNetworkProxy(): Promise<void> {
546
+ const cfg = this.config.network
547
+ const proxy = new NetworkProxy({
548
+ bind: cfg.proxyBind,
549
+ port: cfg.proxyPort,
550
+ maxRecent: cfg.proxyMaxRecent,
551
+ decide: target => this.decideProxyTarget(target),
552
+ attribution: () => this.proxyAttribution(),
553
+ onBlock: (record, attribution) => this.auditNetworkBlock(record, attribution),
554
+ logger: this.ctx.logger,
555
+ })
556
+ this.networkProxy = proxy
557
+ this.ctx.effect(() => () => {
558
+ void proxy.close()
559
+ this.networkProxy = undefined
560
+ })
561
+ try {
562
+ const port = await proxy.start()
563
+ if (cfg.injectEnv) {
564
+ this.envRestore = injectProxyEnv(port, cfg.noProxy)
565
+ this.ctx.effect(() => {
566
+ const restore = this.envRestore
567
+ this.envRestore = undefined
568
+ return () => restore?.()
569
+ })
570
+ }
571
+ const { mode, sandboxMode } = this.resolveNetworkMode()
572
+ this.ctx.logger.info(`permission-rules: network proxy listening on ${cfg.proxyBind}:${port} (mode ${mode}${sandboxMode !== undefined ? `, sandbox ${sandboxMode}` : ''})`)
573
+ } catch (error: unknown) {
574
+ this.ctx.logger.warn(`permission-rules: network proxy failed to bind on ${cfg.proxyBind}:${cfg.proxyPort} (${String(error)}) — shell network policy is INACTIVE; file/command rules stay active`)
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Apply a live settings change to the network policy: rebind the proxy
580
+ * when a bind/env-relevant knob changed (old proxy closed, old env
581
+ * restored, new proxy + env installed). Web-tool gating and the decision
582
+ * path read the config per call, so they need no rebind.
583
+ */
584
+ async onNetworkConfigChanged(): Promise<void> {
585
+ if (!this.config.network.enabled) return
586
+ await this.networkProxy?.close()
587
+ this.envRestore?.()
588
+ this.envRestore = undefined
589
+ await this.attachNetworkProxy()
590
+ }
591
+
592
+ /** The network snapshot the settings page and `/rules network` render. */
593
+ networkSnapshot(): {
594
+ readonly enabled: boolean
595
+ readonly mode: NetworkMode
596
+ readonly configuredMode: 'auto' | NetworkMode
597
+ readonly sandboxMode: string | undefined
598
+ readonly proxyPort: number
599
+ readonly proxyActive: boolean
600
+ readonly denied: number
601
+ readonly askBlocked: number
602
+ readonly recent: readonly NetworkBlockRecord[]
603
+ } {
604
+ const cfg = this.config.network
605
+ const { mode, sandboxMode } = this.resolveNetworkMode()
606
+ const proxy = this.networkProxy
607
+ const stats = proxy?.blockStats() ?? { denied: 0, askBlocked: 0 }
608
+ return {
609
+ enabled: cfg.enabled,
610
+ mode,
611
+ configuredMode: cfg.mode,
612
+ sandboxMode,
613
+ proxyPort: proxy?.port ?? 0,
614
+ proxyActive: proxy !== undefined && proxy.port > 0,
615
+ denied: stats.denied,
616
+ askBlocked: stats.askBlocked,
617
+ recent: proxy?.recentBlocks() ?? [],
618
+ }
619
+ }
620
+
621
+ /**
622
+ * The rule-file paths the settings-page editor may read or write: every
623
+ * currently loaded source plus the per-workspace project files and the
624
+ * configured fallback (so a not-yet-existing project file can be
625
+ * created). The editor never touches arbitrary paths.
626
+ */
627
+ knownRuleSources(): readonly string[] {
628
+ const cfg = this.config
629
+ const set = new Set<string>()
630
+ for (const loaded of this.byCwd.values()) {
631
+ for (const source of loaded.sources) set.add(source)
632
+ if (!isAbsolute(cfg.rulesFile)) set.add(join(loaded.cwd, cfg.rulesFile))
633
+ }
634
+ if (cfg.fallbackPath !== undefined) set.add(isAbsolute(cfg.fallbackPath) ? cfg.fallbackPath : resolve(cfg.fallbackPath))
635
+ if (isAbsolute(cfg.rulesFile)) set.add(cfg.rulesFile)
636
+ return [...set]
637
+ }
638
+
639
+ /** Read one known rule file for the editor: `{ exists, text, error }`. */
640
+ readRuleFile(path: string): { path: string; exists: boolean; text: string; error?: string } {
641
+ if (!this.knownRuleSources().includes(path)) {
642
+ return { path, exists: false, text: '', error: `refusing to read ${path}: not a known rule source` }
643
+ }
644
+ if (!existsSync(path)) return { path, exists: false, text: '' }
645
+ try {
646
+ return { path, exists: true, text: readFileSync(path, 'utf8') }
647
+ } catch (error: unknown) {
648
+ return { path, exists: true, text: '', error: error instanceof Error ? error.message : String(error) }
649
+ }
650
+ }
651
+
652
+ /** The workspace a rule source belongs to (for the editor's file list), or undefined for the fallback. */
653
+ sourceOwner(path: string): string | undefined {
654
+ for (const loaded of this.byCwd.values()) {
655
+ if (loaded.sources.includes(path) || (!isAbsolute(this.config.rulesFile) && join(loaded.cwd, this.config.rulesFile) === path)) return loaded.cwd
656
+ }
657
+ return undefined
658
+ }
659
+
660
+ /** Re-read every cached workspace chain (the settings-page reload action). */
661
+ reloadAll(): void {
662
+ for (const cwd of [...this.byCwd.keys()]) this.reload(cwd)
663
+ }
664
+
665
+ /**
666
+ * Validate and write one known rule file. The document must parse and
667
+ * compile (same checks as a load) BEFORE anything is written — an
668
+ * invalid edit is rejected with its error and the file stays untouched.
669
+ * After a successful write every cached workspace reloads so the edit
670
+ * is in effect immediately.
671
+ */
672
+ saveRuleFile(path: string, text: string): { ok: boolean; error?: string; reloaded?: number } {
673
+ if (!this.knownRuleSources().includes(path)) {
674
+ return { ok: false, error: `refusing to write ${path}: not a known rule source` }
675
+ }
676
+ try {
677
+ const doc = parseRulesDocument(text)
678
+ compileRules(doc, this.compileOptions())
679
+ } catch (error: unknown) {
680
+ const message = error instanceof Error ? error.message : String(error)
681
+ return { ok: false, error: message }
682
+ }
683
+ try {
684
+ // A not-yet-existing project file lives in a possibly missing parent
685
+ // directory (a fresh workspace has no `.dsh/`); create it so the first
686
+ // save from the settings editor succeeds.
687
+ mkdirSync(dirname(path), { recursive: true })
688
+ writeFileSync(path, text, 'utf8')
689
+ } catch (error: unknown) {
690
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
691
+ }
692
+ let reloaded = 0
693
+ for (const cwd of [...this.byCwd.keys()]) {
694
+ this.reload(cwd)
695
+ reloaded += 1
696
+ }
697
+ return { ok: true, reloaded }
698
+ }
699
+
332
700
  /** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
333
701
  private peerVersion(): string | null {
334
702
  try {
@@ -372,6 +740,10 @@ export class PermissionRulesRuntime {
372
740
  return { kind: 'success', text: prose.reloaded(rules.length, source) }
373
741
  }
374
742
  if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
743
+ if (verb === 'network') {
744
+ if (rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
745
+ return this.networkCommand(prose)
746
+ }
375
747
  if (verb === 'decisions') {
376
748
  if (rest.length > 1) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
377
749
  let count = 10
@@ -511,6 +883,26 @@ export class PermissionRulesRuntime {
511
883
  return { kind: 'success', text: lines.join('\n') }
512
884
  }
513
885
 
886
+ /** Render the network policy state: mode mapping, proxy liveness, counters, recent blocks. */
887
+ private networkCommand(prose: UiProse): CommandResult {
888
+ const snapshot = this.networkSnapshot()
889
+ const lines: string[] = []
890
+ if (!snapshot.enabled) {
891
+ lines.push(prose.networkDisabled)
892
+ return { kind: 'success', text: lines.join('\n') }
893
+ }
894
+ lines.push(prose.networkHeader(snapshot.mode, snapshot.sandboxMode, snapshot.configuredMode, snapshot.proxyActive, snapshot.proxyPort))
895
+ lines.push(prose.networkCounters(snapshot.denied, snapshot.askBlocked))
896
+ if (snapshot.recent.length === 0) {
897
+ lines.push(prose.noNetworkBlocks)
898
+ } else {
899
+ for (const block of snapshot.recent.slice(0, 10)) {
900
+ lines.push(prose.networkBlockLine(block.time, block.tool, block.attributed, block.domain, block.scheme, block.port, block.action, block.matched, block.ruleIndex, block.reason))
901
+ }
902
+ }
903
+ return { kind: 'success', text: lines.join('\n') }
904
+ }
905
+
514
906
  /**
515
907
  * Validate deployment-level file references at mount — the earliest
516
908
  * resolvable point. An absolute `rulesFile` or a configured
@@ -749,34 +1141,53 @@ export class PermissionRulesRuntime {
749
1141
  * @param ctx - the host context.
750
1142
  * @param config - raw plugin config.
751
1143
  */
752
- export function apply(ctx: Context, config: Config): void {
1144
+ export async function apply(ctx: Context, config: Config): Promise<void> {
753
1145
  const resolved = resolveConfig(config)
754
1146
  const runtime = new PermissionRulesRuntime(ctx, resolved)
755
1147
  runtime.validateDeploymentFiles()
756
1148
  ctx.provide('permissionRulesRuntime', runtime)
757
1149
  ctx.on('tools/pre-execute', (exec, next) => runtime.preExecute(exec, next))
1150
+ ctx.on('tools/post-execute', (exec, _result, next) => {
1151
+ runtime.unmarkShell(exec)
1152
+ return next()
1153
+ })
1154
+ attachSettingsSection(ctx, runtime, config)
1155
+ if (resolved.network.enabled) await runtime.attachNetworkProxy()
1156
+ ctx.inject(['systemPrompt'], (scope) => {
1157
+ const systemPrompt = scope.get('systemPrompt') as { context?: (entry: { name: string; order?: number; text: string }) => void } | undefined
1158
+ if (systemPrompt?.context === undefined) return
1159
+ systemPrompt.context({
1160
+ name: 'network:policy',
1161
+ order: 115,
1162
+ text: 'Network policy (permission-rules): shell commands reach the network only through a local policy proxy, and web tools are gated the same way — every target is allowed or blocked per the active rules and sandbox mode, and a blocked connection fails with a [network: …] message. Follow the denial messages and do not attempt to bypass the proxy.',
1163
+ })
1164
+ })
1165
+ await ctx.plugin(PermissionRulesRemoteService, { runtime })
758
1166
  ctx.commands.register({
759
1167
  name: 'rules',
760
- description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
761
- input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
1168
+ description: 'list, reload, audit, dry-test, or inspect the network policy of the active permission rules for this workspace',
1169
+ input: { hint: '[list | reload | network | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
762
1170
  handler: invocation => runtime.command(invocation),
763
1171
  })
764
1172
  }
765
1173
 
766
1174
  /**
767
1175
  * 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.
1176
+ * `ignorable` envelope-marker surface: every released rc line through
1177
+ * `0.1.0-rc.7` silently drops the marker from `Session.append` options
1178
+ * (the stamping fix exists on harness master only — no release carries it
1179
+ * yet), so audit events written by those builds land unmarked and break
1180
+ * resume on stricter hosts. Extend the bound when a new rc line ships that
1181
+ * still drops the marker. Non-matching (later rc, stable, or unresolvable)
1182
+ * versions are treated as possibly-marker-aware and verified by the
1183
+ * append probe.
773
1184
  * @param version - the installed peer version string.
774
- * @returns true for the known-unmarked rc.1–rc.6 lines.
1185
+ * @returns true for the known-unmarked rc.1–rc.7 lines.
775
1186
  */
776
1187
  export function isUnmarkedHostVersion(version: string): boolean {
777
1188
  const match = /^0\.1\.0-rc\.(\d+)$/.exec(version.trim())
778
1189
  if (match === null) return false
779
- return Number(match[1]) <= 6
1190
+ return Number(match[1]) <= 7
780
1191
  }
781
1192
 
782
1193
  /**