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.
- package/CHANGELOG.md +34 -0
- package/README.es.md +160 -117
- package/README.hi.md +162 -119
- package/README.md +133 -90
- package/README.pt.md +161 -118
- package/README.zh.md +162 -119
- package/docs/rule-test-vectors/README.md +28 -0
- package/docs/rule-test-vectors/corpus.json +109 -0
- package/lib/client.js +4693 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1530 -179
- package/lib/typert.host.js +4305 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +9 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +181 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/section.d.ts +35 -0
- package/lib/types/client/section.d.ts.map +1 -0
- package/lib/types/config.d.ts +54 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/events.d.ts +45 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/network.d.ts +90 -0
- package/lib/types/network.d.ts.map +1 -0
- package/lib/types/prose.d.ts +15 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +128 -0
- package/lib/types/proxy.d.ts.map +1 -0
- package/lib/types/remote-service.d.ts +45 -0
- package/lib/types/remote-service.d.ts.map +1 -0
- package/lib/types/rules.d.ts +131 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +153 -21
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +166 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +421 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +182 -144
- package/scripts/repair-session-logs.mjs +7 -2
- package/src/client/index.ts +111 -0
- package/src/client/locales.ts +94 -0
- package/src/client/remote.ts +41 -0
- package/src/client/section.ts +201 -0
- package/src/config.ts +111 -0
- package/src/events.ts +46 -0
- package/src/index.ts +2 -0
- package/src/network.ts +177 -0
- package/src/prose.ts +85 -10
- package/src/proxy.ts +327 -0
- package/src/remote-service.ts +109 -0
- package/src/rules.ts +424 -5
- package/src/runtime.ts +464 -53
- package/src/settings.ts +74 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +239 -0
- package/lib/types/config.js +0 -99
- package/lib/types/config.js.map +0 -1
- package/lib/types/events.js +0 -44
- package/lib/types/events.js.map +0 -1
- package/lib/types/glob.js +0 -307
- package/lib/types/glob.js.map +0 -1
- package/lib/types/index.js +0 -27
- package/lib/types/index.js.map +0 -1
- package/lib/types/prose.js +0 -173
- package/lib/types/prose.js.map +0 -1
- package/lib/types/rules.js +0 -610
- package/lib/types/rules.js.map +0 -1
- package/lib/types/runtime.js +0 -818
- 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
|
-
|
|
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:
|
|
81
|
-
maxRules:
|
|
82
|
-
maxGlobStars:
|
|
83
|
-
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.
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
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 (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
769
|
-
* silently
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
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.
|
|
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]) <=
|
|
1190
|
+
return Number(match[1]) <= 7
|
|
780
1191
|
}
|
|
781
1192
|
|
|
782
1193
|
/**
|