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
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The "Permission Rules" settings page: network-policy summary (mode
3
+ * mapping, proxy liveness), block counters, the recent interception list
4
+ * (source tool + target domain), and the rule editor over known rule
5
+ * sources. All data arrives through the `permissionRules` Remote
6
+ * namespace; the page holds no state beyond the selected file and the
7
+ * draft text. Written with `React.createElement` only — the client
8
+ * bundle is plain JavaScript.
9
+ * @module dsh-permission-rules/client/section
10
+ */
11
+
12
+ import * as React from 'react'
13
+ import type { ReactElement } from 'react'
14
+ import type { PermissionRulesLocaleKey } from './locales.ts'
15
+ import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
16
+
17
+ /** Functions the registration injects (wired to the Remote namespace in `./index.ts`). */
18
+ export interface PermissionRulesSectionInjected {
19
+ /** Read the network-policy snapshot. */
20
+ status: () => Promise<PermissionRulesSnapshot>
21
+ /** Read one known rule file. */
22
+ rulesRead: (path: string) => Promise<RulesReadResult>
23
+ /** Validate and write one known rule file. */
24
+ rulesSave: (path: string, text: string) => Promise<RulesSaveResult>
25
+ /** Re-read every cached workspace chain. */
26
+ reload: () => Promise<RulesReloadResult>
27
+ /** The locale-bound translator. */
28
+ t: T
29
+ }
30
+
31
+ /** One bound dictionary. */
32
+ export type T = (key: PermissionRulesLocaleKey, vars?: Record<string, string | number>) => string
33
+
34
+ /** The shell supplies `close`; everything else arrives through the slot injection. */
35
+ export type PermissionRulesSectionProps = PermissionRulesSectionInjected & {
36
+ close: () => void
37
+ }
38
+
39
+ /** One select option for the rule-file picker. */
40
+ interface SourceOption {
41
+ path: string
42
+ label: string
43
+ }
44
+
45
+ const HEADER: React.CSSProperties = { fontSize: 14, fontWeight: 700, margin: '16px 0 8px' }
46
+ const ROW: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }
47
+ const LABEL: React.CSSProperties = { fontSize: 13, color: 'var(--dsh-color-text-secondary, #666)', minWidth: 110 }
48
+ const VALUE: React.CSSProperties = { fontSize: 13 }
49
+ const MONO: React.CSSProperties = { fontFamily: 'var(--dsh-font-mono, monospace)', fontSize: 12 }
50
+ const TEXTAREA: React.CSSProperties = { width: '100%', minHeight: 260, fontFamily: 'var(--dsh-font-mono, monospace)', fontSize: 12, whiteSpace: 'pre', overflow: 'auto' }
51
+ const NOTICE_OK: React.CSSProperties = { fontSize: 13, color: 'var(--dsh-color-success, #1a7f37)' }
52
+ const NOTICE_ERR: React.CSSProperties = { fontSize: 13, color: 'var(--dsh-color-error, #c62828)' }
53
+ const TABLE: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', fontSize: 12 }
54
+ const CELL: React.CSSProperties = { borderBottom: '1px solid var(--dsh-color-border, #ddd)', padding: '4px 6px', textAlign: 'left', verticalAlign: 'top' }
55
+
56
+ /** The settings section component. */
57
+ export function PermissionRulesSection({ close, t, ...injected }: PermissionRulesSectionProps): ReactElement {
58
+ void close
59
+ const [snapshot, setSnapshot] = React.useState<PermissionRulesSnapshot | undefined>(undefined)
60
+ const [loadError, setLoadError] = React.useState<string | undefined>(undefined)
61
+ const [sources, setSources] = React.useState<SourceOption[]>([])
62
+ const [selected, setSelected] = React.useState<string | undefined>(undefined)
63
+ const [text, setText] = React.useState('')
64
+ const [dirty, setDirty] = React.useState(false)
65
+ const [notice, setNotice] = React.useState<{ ok: boolean; text: string } | undefined>(undefined)
66
+
67
+ const refresh = React.useCallback(() => {
68
+ void injected.status().then(next => {
69
+ setSnapshot(next)
70
+ setLoadError(undefined)
71
+ setSources(next.sources.map(source => ({
72
+ path: source.path,
73
+ label: source.cwd === null ? source.path : source.path.replace(source.cwd, '.'),
74
+ })))
75
+ setSelected(current => current === undefined && next.sources.length > 0 ? next.sources[0]?.path : current)
76
+ }).catch(error => {
77
+ setLoadError(error instanceof Error ? error.message : String(error))
78
+ })
79
+ }, [injected.status])
80
+
81
+ React.useEffect(() => {
82
+ refresh()
83
+ const timer = setInterval(refresh, 5000)
84
+ return () => clearInterval(timer)
85
+ }, [refresh])
86
+
87
+ const loadFile = React.useCallback((path: string) => {
88
+ setSelected(path)
89
+ setDirty(false)
90
+ setNotice(undefined)
91
+ void injected.rulesRead(path).then(result => {
92
+ setText(result.error ?? result.text)
93
+ if (result.error !== null && result.error !== undefined) setNotice({ ok: false, text: result.error })
94
+ })
95
+ }, [injected])
96
+
97
+ const saveFile = React.useCallback(() => {
98
+ if (selected === undefined || !dirty) return
99
+ setNotice(undefined)
100
+ void injected.rulesSave(selected, text).then(result => {
101
+ setNotice(result.ok
102
+ ? { ok: true, text: t('editorSaved', { reloaded: result.reloaded ?? 0 }) }
103
+ : { ok: false, text: t('editorFailed', { error: result.error ?? t('unknown') }) })
104
+ if (result.ok) {
105
+ setDirty(false)
106
+ refresh()
107
+ }
108
+ })
109
+ }, [injected, selected, text, dirty, t, refresh])
110
+
111
+ const reloadAll = React.useCallback(() => {
112
+ setNotice(undefined)
113
+ void injected.reload().then(result => {
114
+ setNotice(result.ok
115
+ ? { ok: true, text: t('editorSaved', { reloaded: 0 }) }
116
+ : { ok: false, text: t('editorFailed', { error: result.error ?? t('unknown') }) })
117
+ refresh()
118
+ })
119
+ }, [injected, t, refresh])
120
+
121
+ const picker = React.createElement('select', {
122
+ style: { ...MONO, maxWidth: 420 },
123
+ value: selected ?? '',
124
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) => {
125
+ const path = event.target.value
126
+ if (path !== '') loadFile(path)
127
+ },
128
+ }, sources.map(source => React.createElement('option', { key: source.path, value: source.path }, source.label)))
129
+
130
+ const children: React.ReactNode[] = []
131
+
132
+ // Policy summary.
133
+ children.push(React.createElement('div', { key: 'policy-title', style: HEADER }, t('policyTitle')))
134
+ if (snapshot === undefined) {
135
+ children.push(React.createElement('div', { key: 'loading', style: VALUE }, loadError === undefined ? '…' : loadError))
136
+ } else {
137
+ const modeText = `${snapshot.mode}${snapshot.sandboxMode === null ? '' : ` (${t('preset')}: ${snapshot.sandboxMode})`}${snapshot.configuredMode === 'auto' ? '' : ` — ${t('configured')}: ${snapshot.configuredMode}`}`
138
+ children.push(React.createElement('div', { key: 'mode', style: ROW },
139
+ React.createElement('span', { style: LABEL }, t('mode')),
140
+ React.createElement('span', { style: VALUE }, modeText)))
141
+ const proxyText = snapshot.enabled
142
+ ? snapshot.proxyActive ? t('proxyActive', { port: snapshot.proxyPort }) : t('proxyInactive')
143
+ : t('proxyDisabled')
144
+ children.push(React.createElement('div', { key: 'proxy', style: ROW },
145
+ React.createElement('span', { style: LABEL }, t('proxy')),
146
+ React.createElement('span', { style: VALUE }, proxyText)))
147
+ children.push(React.createElement('div', { key: 'counters', style: ROW },
148
+ React.createElement('span', { style: LABEL }, t('counters')),
149
+ React.createElement('span', { style: VALUE }, `${snapshot.denied} ${t('denied')} · ${snapshot.askBlocked} ${t('askBlocked')}`)))
150
+ }
151
+
152
+ // Recent blocks.
153
+ children.push(React.createElement('div', { key: 'recent-title', style: HEADER }, t('recentTitle')))
154
+ if (snapshot === undefined || snapshot.recent.length === 0) {
155
+ children.push(React.createElement('div', { key: 'no-blocks', style: VALUE }, t('noBlocks')))
156
+ } else {
157
+ const rows = snapshot.recent.map(block => React.createElement('tr', { key: `${block.time}-${block.domain}` },
158
+ React.createElement('td', { style: CELL }, new Date(block.time).toLocaleTimeString()),
159
+ React.createElement('td', { style: CELL }, block.tool + (block.attributed ? '' : ` (${t('blockUnattributed')})`)),
160
+ React.createElement('td', { style: CELL }, `${block.scheme ?? '?'}://${block.domain}${block.port === null ? '' : `:${block.port}`}`),
161
+ React.createElement('td', { style: CELL }, block.matched
162
+ ? t('blockBy', { index: (block.ruleIndex ?? 0) + 1 })
163
+ : t('blockDefault')),
164
+ React.createElement('td', { style: CELL }, block.action),
165
+ React.createElement('td', { style: CELL }, block.reason ?? '')))
166
+ children.push(React.createElement('table', { key: 'recent-table', style: TABLE },
167
+ React.createElement('tbody', undefined, rows)))
168
+ }
169
+
170
+ // Rule editor.
171
+ children.push(React.createElement('div', { key: 'editor-title', style: HEADER }, t('editorTitle')))
172
+ children.push(React.createElement('div', { key: 'editor-picker', style: ROW },
173
+ React.createElement('span', { style: LABEL }, t('editorPath')),
174
+ picker,
175
+ React.createElement('button', { type: 'button', onClick: () => { if (selected !== undefined) loadFile(selected) } }, t('editorLoad')),
176
+ React.createElement('button', { type: 'button', onClick: reloadAll }, t('editorReload'))))
177
+ if (selected === undefined) {
178
+ children.push(React.createElement('div', { key: 'editor-empty', style: VALUE }, t('editorEmpty')))
179
+ } else {
180
+ children.push(React.createElement('textarea', {
181
+ key: 'editor-text',
182
+ style: TEXTAREA,
183
+ value: text,
184
+ spellCheck: false,
185
+ onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => {
186
+ setText(event.target.value)
187
+ setDirty(true)
188
+ setNotice(undefined)
189
+ },
190
+ }))
191
+ children.push(React.createElement('div', { key: 'editor-actions', style: { ...ROW, marginTop: 8 } },
192
+ React.createElement('button', { type: 'button', disabled: !dirty, onClick: saveFile }, t('editorSave')),
193
+ dirty === true ? React.createElement('span', { style: VALUE }, '●') : null,
194
+ notice === undefined ? null : React.createElement('span', {
195
+ key: 'notice',
196
+ style: notice.ok ? NOTICE_OK : NOTICE_ERR,
197
+ }, notice.text)))
198
+ }
199
+
200
+ return React.createElement('div', { style: { padding: '0 4px 24px' } }, ...children)
201
+ }
package/src/config.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import { isAbsolute } from 'node:path'
10
10
  import z from '@deepseek-ai/schemastery'
11
11
  import type { PatternMode } from './rules.ts'
12
+ import type { NetworkMode, UnlistedAction } from './network.ts'
13
+ import { NETWORK_MODES } from './network.ts'
12
14
  import type { UiLanguage } from './prose.ts'
13
15
 
14
16
  /** What happens when a discovered rule file exists but cannot be parsed or compiled. */
@@ -17,6 +19,44 @@ export type BadFilePolicy = 'fail' | 'ignore-with-warning'
17
19
  /** Which decisions are audit-logged: every call, or only rule hits. */
18
20
  export type AuditGranularity = 'all' | 'hits'
19
21
 
22
+ /** Loopback-target handling at the proxy layer. */
23
+ export type LoopbackPolicy = 'allow' | 'policy'
24
+
25
+ /** NO_PROXY handling for policy-injected subprocess environment. */
26
+ export type NoProxyPolicy = 'clear' | 'preserve'
27
+
28
+ /**
29
+ * The process-level network policy block. Every field is optional —
30
+ * {@link ResolvedConfig} supplies the defaults. `mode: 'auto'` maps the
31
+ * official sandbox preset onto the three network modes (read-only →
32
+ * deny-all, workspace-write → whitelist, danger-full-access → allow-all);
33
+ * on hosts without the sandbox-policy service `auto` resolves to
34
+ * `autoFallback` (`allow-all` by default, so pre-network hosts keep their
35
+ * permissive behavior until configured).
36
+ */
37
+ export interface NetworkConfig {
38
+ /** Master switch: `false` disables the proxy, the env injection, and the web-tool mode defaults. */
39
+ enabled?: boolean
40
+ /** Policy mode: `auto` follows the sandbox preset, or an explicit mode. */
41
+ mode?: 'auto' | NetworkMode
42
+ /** Mode used when `mode: 'auto'` and the sandbox-policy service is absent or unknown. */
43
+ autoFallback?: NetworkMode
44
+ /** Whitelist-mode handling of targets no rule matched: `ask` (default) or `deny`. */
45
+ unlisted?: UnlistedAction
46
+ /** Local proxy bind address (loopback only — the proxy never listens publicly). */
47
+ proxyBind?: string
48
+ /** Local proxy port; `0` picks a free ephemeral port. */
49
+ proxyPort?: number
50
+ /** Cap on recent-block records kept for the settings page. */
51
+ proxyMaxRecent?: number
52
+ /** Loopback targets: `allow` (default, Codex parity) or `policy` (evaluated like any target). */
53
+ loopback?: LoopbackPolicy
54
+ /** Whether proxy environment variables are injected for subprocesses. */
55
+ injectEnv?: boolean
56
+ /** Subprocess NO_PROXY handling: `clear` enforces the policy (default) or `preserve` keeps ambient values. */
57
+ noProxy?: NoProxyPolicy
58
+ }
59
+
20
60
  /** Raw plugin config — every field optional; {@link Config} supplies the defaults. */
21
61
  export interface Config {
22
62
  /**
@@ -75,6 +115,22 @@ export interface Config {
75
115
  * `scripts/repair-session-logs.mjs` before loading on a newer harness).
76
116
  */
77
117
  allowUnmarkedAudit?: boolean
118
+ /** Process-level network policy (all optional; defaults inside). */
119
+ network?: NetworkConfig
120
+ }
121
+
122
+ /** Network config after {@link resolveConfig}: every optional field has its explicit default. */
123
+ export interface ResolvedNetworkConfig {
124
+ readonly enabled: boolean
125
+ readonly mode: 'auto' | NetworkMode
126
+ readonly autoFallback: NetworkMode
127
+ readonly unlisted: UnlistedAction
128
+ readonly proxyBind: string
129
+ readonly proxyPort: number
130
+ readonly proxyMaxRecent: number
131
+ readonly loopback: LoopbackPolicy
132
+ readonly injectEnv: boolean
133
+ readonly noProxy: NoProxyPolicy
78
134
  }
79
135
 
80
136
  /** Config after {@link resolveConfig}: every optional field has its explicit default. */
@@ -94,6 +150,7 @@ export interface ResolvedConfig {
94
150
  readonly maxGlobStars: number
95
151
  readonly enforce: boolean
96
152
  readonly allowUnmarkedAudit: boolean
153
+ readonly network: ResolvedNetworkConfig
97
154
  }
98
155
 
99
156
  /** Schemastery schema: the loader validates and fills defaults before `apply`. */
@@ -113,6 +170,18 @@ export const Config: z<Config> = z.object({
113
170
  maxGlobStars: z.number().default(2),
114
171
  enforce: z.boolean().default(true),
115
172
  allowUnmarkedAudit: z.boolean().default(false),
173
+ network: z.object({
174
+ enabled: z.boolean().default(true),
175
+ mode: z.union(['auto', ...NETWORK_MODES] as const).default('auto'),
176
+ autoFallback: z.union(NETWORK_MODES as [NetworkMode, ...NetworkMode[]]).default('allow-all'),
177
+ unlisted: z.union(['ask', 'deny'] as const).default('ask'),
178
+ proxyBind: z.string().default('127.0.0.1'),
179
+ proxyPort: z.number().default(0),
180
+ proxyMaxRecent: z.number().default(100),
181
+ loopback: z.union(['allow', 'policy'] as const).default('allow'),
182
+ injectEnv: z.boolean().default(true),
183
+ noProxy: z.union(['clear', 'preserve'] as const).default('clear'),
184
+ }),
116
185
  })
117
186
 
118
187
  /**
@@ -156,6 +225,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
156
225
  assertBoolean('caseInsensitivePaths', config.caseInsensitivePaths ?? process.platform === 'win32')
157
226
  assertBoolean('enforce', config.enforce ?? true)
158
227
  assertBoolean('allowUnmarkedAudit', config.allowUnmarkedAudit ?? false)
228
+ const network = resolveNetworkConfig(config.network)
159
229
  return {
160
230
  rulesFile,
161
231
  fallbackPath: config.fallbackPath,
@@ -172,6 +242,47 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
172
242
  maxGlobStars,
173
243
  enforce: config.enforce ?? true,
174
244
  allowUnmarkedAudit: config.allowUnmarkedAudit ?? false,
245
+ network,
246
+ }
247
+ }
248
+
249
+ /** Validate and default the optional `network` block; bad values fail the mount loudly. */
250
+ function resolveNetworkConfig(raw: NetworkConfig | undefined): ResolvedNetworkConfig {
251
+ const mode = raw?.mode ?? 'auto'
252
+ if (mode !== 'auto') assertEnum('network.mode', mode, NETWORK_MODES)
253
+ const autoFallback = raw?.autoFallback ?? 'allow-all'
254
+ assertEnum('network.autoFallback', autoFallback, NETWORK_MODES)
255
+ const unlisted = raw?.unlisted ?? 'ask'
256
+ assertEnum('network.unlisted', unlisted, ['ask', 'deny'])
257
+ const loopback = raw?.loopback ?? 'allow'
258
+ assertEnum('network.loopback', loopback, ['allow', 'policy'])
259
+ const noProxy = raw?.noProxy ?? 'clear'
260
+ assertEnum('network.noProxy', noProxy, ['clear', 'preserve'])
261
+ assertBoolean('network.enabled', raw?.enabled ?? true)
262
+ assertBoolean('network.injectEnv', raw?.injectEnv ?? true)
263
+ const proxyBind = raw?.proxyBind ?? '127.0.0.1'
264
+ if (typeof proxyBind !== 'string' || proxyBind.trim().length === 0) {
265
+ throw new TypeError(`network.proxyBind must be a non-empty string, got ${typeof proxyBind}`)
266
+ }
267
+ const proxyPort = raw?.proxyPort ?? 0
268
+ if (!Number.isSafeInteger(proxyPort) || proxyPort < 0 || proxyPort > 65535) {
269
+ throw new TypeError(`network.proxyPort must be an integer 0-65535, got ${String(raw?.proxyPort)}`)
270
+ }
271
+ const proxyMaxRecent = raw?.proxyMaxRecent ?? 100
272
+ if (!Number.isSafeInteger(proxyMaxRecent) || proxyMaxRecent <= 0) {
273
+ throw new TypeError(`network.proxyMaxRecent must be a positive safe integer, got ${String(raw?.proxyMaxRecent)}`)
274
+ }
275
+ return {
276
+ enabled: raw?.enabled ?? true,
277
+ mode: mode as 'auto' | NetworkMode,
278
+ autoFallback,
279
+ unlisted,
280
+ proxyBind: proxyBind.trim(),
281
+ proxyPort,
282
+ proxyMaxRecent,
283
+ loopback,
284
+ injectEnv: raw?.injectEnv ?? true,
285
+ noProxy,
175
286
  }
176
287
  }
177
288
 
package/src/events.ts CHANGED
@@ -57,6 +57,34 @@ declare module '@deepseek-ai/dsh-session/types' {
57
57
  cwd: string
58
58
  dryRun?: true
59
59
  }
60
+ /**
61
+ * One process-level network block (proxy layer), appended when the
62
+ * block can be attributed to a session — log-only audit with the same
63
+ * `ignorable` marker discipline as `permissionRules/decision`. `tool`
64
+ * is the attributed shell tool (`bash`/`pwsh`) or `'subprocess'` when
65
+ * no in-flight shell execution could be named; `attributed` records
66
+ * which case. `domain`/`scheme`/`port` name the blocked target,
67
+ * `action` is `deny` or `ask` (an ask at the proxy cannot ride the
68
+ * approval seam — no session context — and blocks with a message),
69
+ * `mode` is the network policy mode in effect, and `ruleIndex`/`reason`
70
+ * appear only on rule hits.
71
+ */
72
+ 'permissionRules/network': {
73
+ kind: 'block'
74
+ tool: string
75
+ attributed: boolean
76
+ callId?: CallId
77
+ domain: string
78
+ scheme?: 'http' | 'https'
79
+ port?: number
80
+ action: 'deny' | 'ask'
81
+ mode: string
82
+ matched: boolean
83
+ source: string
84
+ ruleIndex?: number
85
+ reason?: string
86
+ time: number
87
+ }
60
88
  }
61
89
  }
62
90
 
@@ -81,6 +109,24 @@ export interface AuditDecision {
81
109
  dryRun?: true
82
110
  }
83
111
 
112
+ /** The payload of one `permissionRules/network` audit record. */
113
+ export interface AuditNetworkBlock {
114
+ kind: 'block'
115
+ tool: string
116
+ attributed: boolean
117
+ callId?: CallId
118
+ domain: string
119
+ scheme?: 'http' | 'https'
120
+ port?: number
121
+ action: 'deny' | 'ask'
122
+ mode: string
123
+ matched: boolean
124
+ source: string
125
+ ruleIndex?: number
126
+ reason?: string
127
+ time: number
128
+ }
129
+
84
130
  /**
85
131
  * `Session.append` narrowed to this plugin's audit event. The options bag
86
132
  * exists only on host builds that expose the `ignorable` envelope-marker
package/src/index.ts CHANGED
@@ -27,3 +27,5 @@ export * from './config.ts'
27
27
  export * from './events.ts'
28
28
  export * from './rules.ts'
29
29
  export * from './glob.ts'
30
+ export * from './network.ts'
31
+ export * from './proxy.ts'
package/src/network.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Pure network-policy orchestration shared by the `tools/pre-execute` gate
3
+ * and the local HTTP/CONNECT proxy: the three policy modes mapped onto the
4
+ * official sandbox presets, proxy-layer first-match evaluation over the
5
+ * loaded rule chains, and the mode-default fallback decision. No I/O, no
6
+ * process state — unit-testable and replayable.
7
+ * @module dsh-permission-rules/network
8
+ */
9
+
10
+ import type { CompiledRule, CompiledRuleset, NetworkTarget } from './rules.ts'
11
+ import { matchWhen, matchTools, targetMatchesNetwork } from './rules.ts'
12
+ import type { MatchContext } from './rules.ts'
13
+
14
+ /** The three network policy modes. */
15
+ export type NetworkMode = 'deny-all' | 'whitelist' | 'allow-all'
16
+
17
+ /** Closed mode list for runtime normalization of untrusted values. */
18
+ export const NETWORK_MODES: readonly NetworkMode[] = ['deny-all', 'whitelist', 'allow-all']
19
+
20
+ /** The official file-sandbox presets the `auto` mode maps onto. */
21
+ export type SandboxModeName = 'read-only' | 'workspace-write' | 'danger-full-access'
22
+
23
+ /** How a whitelist-mode unlisted target is handled: ask (blocked at the proxy, approval seam for web tools) or deny. */
24
+ export type UnlistedAction = 'ask' | 'deny'
25
+
26
+ /**
27
+ * Map one official sandbox preset onto its network mode, Codex-style:
28
+ * `read-only` defaults to no network (whitelist only), `workspace-write`
29
+ * keeps a whitelist with ask/deny for unlisted targets, and
30
+ * `danger-full-access` opens everything (rules can still narrow it). Any
31
+ * other value falls back to `fallback` — hosts without the sandbox-policy
32
+ * service therefore stay permissive, preserving pre-network behavior.
33
+ * @param sandbox - the resolved sandbox mode, or undefined.
34
+ * @param fallback - the mode used when the preset is unknown/absent.
35
+ * @returns the effective network mode.
36
+ */
37
+ export function networkModeForSandbox(sandbox: string | undefined, fallback: NetworkMode): NetworkMode {
38
+ switch (sandbox) {
39
+ case 'read-only': return 'deny-all'
40
+ case 'workspace-write': return 'whitelist'
41
+ case 'danger-full-access': return 'allow-all'
42
+ default: return fallback
43
+ }
44
+ }
45
+
46
+ /** One rule chain the proxy evaluates (ruleset + its source-file paths for attribution). */
47
+ export interface NetworkChain {
48
+ readonly ruleset: CompiledRuleset
49
+ readonly sources: readonly string[]
50
+ }
51
+
52
+ /** Proxy-layer evaluation options. */
53
+ export interface NetworkDecisionOptions {
54
+ /** The policy mode in effect (already resolved by the runtime). */
55
+ readonly mode: NetworkMode
56
+ /** Whitelist-mode handling of unlisted targets. */
57
+ readonly unlisted: UnlistedAction
58
+ /** `allow` short-circuits loopback targets before rules; `policy` evaluates them normally. */
59
+ readonly loopback: 'allow' | 'policy'
60
+ /** Host facts for `when` evaluation (proxy has no agent identity). */
61
+ readonly context?: MatchContext
62
+ }
63
+
64
+ /** One network decision: a rule hit or the mode-default fallback. */
65
+ export interface NetworkDecision {
66
+ readonly action: 'allow' | 'deny' | 'ask'
67
+ /** Whether a rule (true) or the mode default (false) produced the decision. */
68
+ readonly matched: boolean
69
+ /** The mode the fallback was computed from. */
70
+ readonly mode: NetworkMode
71
+ readonly ruleIndex?: number
72
+ readonly rule?: CompiledRule
73
+ /** Source file of the matched rule, for audit attribution. */
74
+ readonly source?: string
75
+ }
76
+
77
+ /** The default decision a mode yields for a target no rule matched. */
78
+ export function defaultDecision(mode: NetworkMode, unlisted: UnlistedAction): NetworkDecision {
79
+ switch (mode) {
80
+ case 'deny-all':
81
+ return { action: 'deny', matched: false, mode }
82
+ case 'whitelist':
83
+ return { action: unlisted, matched: false, mode }
84
+ case 'allow-all':
85
+ return { action: 'allow', matched: false, mode }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Evaluate the network policy for one target across the loaded rule
91
+ * chains (nearest workspace first, first match wins). Proxy-layer tool
92
+ * attribution: a shell subprocess connection counts as `bash`/`pwsh`
93
+ * candidates, so a rule scoped to either shell tool can fire and one
94
+ * scoped to web tools never does. Agent identity is unknown at the proxy,
95
+ * so agent-scoped network rules fail closed there (the pre-execute path
96
+ * is where agents are known). Loopback targets short-circuit before any
97
+ * rule when `loopback: 'allow'` (Codex parity: local dev servers stay
98
+ * reachable in the read-only default).
99
+ * @param chains - the loaded chains, insertion (nearest) order first.
100
+ * @param target - the parsed connection/URL target.
101
+ * @param options - mode, unlisted handling, loopback policy, host facts.
102
+ * @returns the decision.
103
+ */
104
+ export function decideNetworkTarget(chains: readonly NetworkChain[], target: NetworkTarget, options: NetworkDecisionOptions): NetworkDecision {
105
+ if (options.loopback === 'allow' && isLoopbackTarget(target)) {
106
+ return { action: 'allow', matched: false, mode: options.mode }
107
+ }
108
+ const context = options.context ?? {}
109
+ for (const chain of chains) {
110
+ for (const rule of chain.ruleset.rules) {
111
+ if (!rule.enabled) continue
112
+ if (rule.network === undefined) continue
113
+ if (!matchToolsProxy(rule)) continue
114
+ if (rule.when !== undefined && !matchWhen(rule, context)) continue
115
+ if (!targetMatchesNetwork(target, rule.network)) continue
116
+ return {
117
+ action: rule.action,
118
+ matched: true,
119
+ mode: options.mode,
120
+ ruleIndex: rule.index,
121
+ rule,
122
+ source: chain.sources[rule.sourceIndex] ?? '',
123
+ }
124
+ }
125
+ }
126
+ return defaultDecision(options.mode, options.unlisted)
127
+ }
128
+
129
+ /**
130
+ * Tool-scope check for proxy traffic: shell subprocess connections carry
131
+ * the `bash`/`pwsh` identity candidates (ANY match satisfies, like the
132
+ * agents dimension); rules scoped to other tools never fire here.
133
+ */
134
+ function matchToolsProxy(rule: CompiledRule): boolean {
135
+ if (rule.tools.length === 0) return true
136
+ return matchTools(rule, 'bash') || matchTools(rule, 'pwsh')
137
+ }
138
+
139
+ /** Whether a target addresses the loopback range. */
140
+ export function isLoopbackTarget(target: NetworkTarget): boolean {
141
+ const host = target.host
142
+ if (host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1') return true
143
+ const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
144
+ if (parts === null) return false
145
+ return parts.slice(1).every(octet => Number(octet) <= 255) && parts[1] === '127'
146
+ }
147
+
148
+ /**
149
+ * The short structured message a blocked proxy connection receives (403
150
+ * body for plain HTTP, CONNECT error body for tunnels). Clients such as
151
+ * curl print it verbatim, so the model sees the reason through the shell
152
+ * tool output. An `ask` at the proxy layer is worded as a pending-approval
153
+ * block because the proxy has no session context for the interactive
154
+ * approval seam.
155
+ * @param decision - the decision that blocked the connection.
156
+ * @returns a short text/plain message.
157
+ */
158
+ export function blockMessage(decision: NetworkDecision): string {
159
+ const reason = decision.rule?.reason ?? ''
160
+ if (decision.matched) {
161
+ const rulePart = decision.ruleIndex !== undefined ? ` by rule ${decision.ruleIndex + 1}` : ''
162
+ if (decision.action === 'ask') {
163
+ return `[network: blocked pending approval${rulePart}] ${reason}\n(subprocess connections cannot ride the interactive approval seam — ask your user or add an allow rule for this target)`
164
+ }
165
+ return `[network: denied${rulePart}] ${reason}`
166
+ }
167
+ switch (decision.mode) {
168
+ case 'deny-all':
169
+ return '[network: denied] network mode deny-all (read-only sandbox preset): only whitelisted targets are reachable'
170
+ case 'whitelist':
171
+ return decision.action === 'ask'
172
+ ? '[network: blocked pending approval] whitelist mode: the target is not matched by an allow rule'
173
+ : '[network: denied] whitelist mode: the target is not matched by an allow rule'
174
+ case 'allow-all':
175
+ return '[network: denied]'
176
+ }
177
+ }