dsh-permission-rules 0.6.21 → 0.7.0

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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.es.md +6 -1
  3. package/README.hi.md +6 -1
  4. package/README.md +6 -1
  5. package/README.pt.md +6 -1
  6. package/README.zh.md +6 -1
  7. package/docs/rules-format.en.md +2 -1
  8. package/docs/rules-format.md +2 -1
  9. package/lib/client.js +6924 -37
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +671 -28
  12. package/lib/typert.host.js +74 -3
  13. package/lib/types/allow-host.d.ts +151 -0
  14. package/lib/types/allow-host.d.ts.map +1 -0
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +1 -1
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/remote.d.ts +54 -1
  19. package/lib/types/client/remote.d.ts.map +1 -1
  20. package/lib/types/client/section.d.ts +3 -1
  21. package/lib/types/client/section.d.ts.map +1 -1
  22. package/lib/types/config.d.ts +22 -0
  23. package/lib/types/config.d.ts.map +1 -1
  24. package/lib/types/prose.d.ts +1 -0
  25. package/lib/types/prose.d.ts.map +1 -1
  26. package/lib/types/proxy.d.ts +73 -0
  27. package/lib/types/proxy.d.ts.map +1 -1
  28. package/lib/types/remote-service.d.ts +9 -1
  29. package/lib/types/remote-service.d.ts.map +1 -1
  30. package/lib/types/rules.d.ts +11 -0
  31. package/lib/types/rules.d.ts.map +1 -1
  32. package/lib/types/runtime.d.ts +76 -11
  33. package/lib/types/runtime.d.ts.map +1 -1
  34. package/lib/types/typert.host.d.ts +50 -0
  35. package/lib/types/typert.host.d.ts.map +1 -1
  36. package/lib/types/wire.d.ts +191 -0
  37. package/lib/types/wire.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/allow-host.ts +307 -0
  40. package/src/client/index.ts +7 -1
  41. package/src/client/locales.ts +36 -0
  42. package/src/client/remote.ts +4 -1
  43. package/src/client/section.ts +83 -10
  44. package/src/config.ts +56 -0
  45. package/src/prose.ts +6 -0
  46. package/src/proxy.ts +202 -16
  47. package/src/remote-service.ts +33 -1
  48. package/src/rules.ts +11 -2
  49. package/src/runtime.ts +243 -9
  50. package/src/wire.ts +119 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The settings-page "allow this host" action, as pure pieces: normalizing
3
+ * and validating a blocked host, inserting ONE minimal
4
+ * `match.network.domains` allow rule at the HEAD of a rule document, choosing
5
+ * which rule file the rule belongs in, and mapping the action's result onto
6
+ * the notice the page renders.
7
+ *
8
+ * Three invariants hold here and are asserted by `test/allow-host.spec.ts`:
9
+ *
10
+ * 1. the rule is inserted at index 0 — rules are first-match-wins, so a rule
11
+ * appended after an existing `deny` would be dead text;
12
+ * 2. a document the yaml parser reports an error for, or whose `rules` is not
13
+ * a list, is REFUSED (a {@link RuleError}) — the caller never overwrites a
14
+ * file it could not read; and
15
+ * 3. the insertion goes through the yaml Document API (`parseDocument` +
16
+ * `createNode` + `items.unshift` + `String(doc)`), so comments and the
17
+ * existing formatting of the untouched rules survive.
18
+ *
19
+ * Nothing here reads the filesystem or the process cwd: the runtime injects
20
+ * `exists`, `processCwd`, and the source lists it already computed, which is
21
+ * what makes the target choice testable without touching a real host.
22
+ * @module dsh-permission-rules/allow-host
23
+ */
24
+
25
+ import { isAbsolute, join, resolve } from 'node:path'
26
+ import { isIP } from 'node:net'
27
+ import { isMap, isSeq, parseDocument } from 'yaml'
28
+ import type { YAMLSeq } from 'yaml'
29
+ import { RuleError, normalizeHost } from './rules.ts'
30
+ import type { AllowHostResult, RuleSourceView } from './wire.ts'
31
+
32
+ /**
33
+ * Why a generated rule may legitimately live in a rule file the parser would
34
+ * reject: it is not one — every write goes back through the same
35
+ * `parseRulesDocument` + `compileRules` gate as a hand edit.
36
+ */
37
+ const RULE_REASON_SUFFIX = '(added from the settings page)'
38
+
39
+ /**
40
+ * A DNS-ish host: dot-separated labels of ASCII letters/digits/`-`/`_`, each
41
+ * starting and ending with an alphanumeric. Deliberately narrow — the action
42
+ * writes an EXACT host, so glob metacharacters (`*`), whitespace, `#`, `:`,
43
+ * `[`, quotes and anything else that could make the generated scalar mean
44
+ * something other than "this one host" are refused instead of escaped.
45
+ */
46
+ const HOSTNAME_SHAPE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)*$/
47
+
48
+ /** Longest a host name (or an IP literal) may be; also bounds the generated rule. */
49
+ const MAX_HOST_LENGTH = 253
50
+
51
+ /**
52
+ * Canonical spelling of a host for the allow action, matching target parsing
53
+ * exactly (see `normalizeHost`): lowercase, IPv6 brackets stripped, one
54
+ * trailing dot stripped.
55
+ * @param host - the host as the block record reported it.
56
+ * @returns the canonical spelling.
57
+ */
58
+ export function normalizeAllowHost(host: string): string {
59
+ return normalizeHost(host)
60
+ }
61
+
62
+ /**
63
+ * Whether a normalized host may be written into a rule: a host name or an IP
64
+ * literal, nothing else. This is the syntactic half of the action's safety —
65
+ * a newline, a `rules:` fragment, a glob, a URL, or a `host:port` authority is
66
+ * refused here rather than relying on the YAML emitter to quote it. The IP
67
+ * check is `node:net`'s `isIP`, so a `host:port` string (which merely contains
68
+ * a colon) is not mistaken for an IPv6 literal.
69
+ * @param host - the normalized host.
70
+ * @returns true when the host may be allowed.
71
+ */
72
+ export function isWritableHost(host: string): boolean {
73
+ if (host.length === 0 || host.length > MAX_HOST_LENGTH) return false
74
+ if (isIP(host) !== 0) return true
75
+ return HOSTNAME_SHAPE.test(host)
76
+ }
77
+
78
+ /**
79
+ * The `reason` carried by every generated rule. English on purpose: rule
80
+ * `reason`s are never translated (they are quoted verbatim in denial messages
81
+ * and in the session audit), so a localized reason would make one rule read
82
+ * differently per UI language.
83
+ * @param host - the allowed host.
84
+ * @returns the reason line.
85
+ */
86
+ export function allowHostReason(host: string): string {
87
+ return `Allow ${host} ${RULE_REASON_SUFFIX}`
88
+ }
89
+
90
+ /** The outcome of one insertion attempt. */
91
+ export interface AllowHostInsertion {
92
+ /** The document text to write; identical to the input when `inserted` is false. */
93
+ readonly text: string
94
+ /** Whether the rule was added (false = the file already leads with this exact allow). */
95
+ readonly inserted: boolean
96
+ }
97
+
98
+ /**
99
+ * Insert the minimal allow rule for `host` at the HEAD of a rule document.
100
+ *
101
+ * `inserted: false` (with the input text returned unchanged) means the first
102
+ * rule of the file is ALREADY this exact allow — first-match semantics make a
103
+ * second copy do nothing, so the caller must not rewrite the file (repeated
104
+ * clicks would otherwise pile up duplicate lines in the user's git diff).
105
+ *
106
+ * @param text - the current file text (empty for a file to be created).
107
+ * @param host - the normalized, validated host.
108
+ * @returns the text to write plus whether anything was added.
109
+ * @throws RuleError when the document cannot be parsed or has no `rules` list.
110
+ */
111
+ export function insertAllowRule(text: string, host: string): AllowHostInsertion {
112
+ // Normalizing here as well as at the call site keeps the idempotence check
113
+ // (and the generated rule) independent of the caller's spelling.
114
+ const normalized = normalizeAllowHost(host)
115
+ const doc = parseDocument(text)
116
+ // `parseDocument` collects syntax errors instead of throwing, and `String(doc)`
117
+ // would happily re-emit a partially-understood document — refusing here is what
118
+ // keeps "unparseable file" from ever meaning "overwritten file".
119
+ if (doc.errors.length > 0) {
120
+ throw new RuleError(`cannot edit the rule file: ${doc.errors[0]?.message ?? 'invalid YAML'}`)
121
+ }
122
+ const rule = { match: { network: { domains: [normalized] } }, action: 'allow', reason: allowHostReason(normalized) }
123
+ if (doc.contents === null) {
124
+ // An empty or comment-only file: the comments stay ahead of the new key.
125
+ doc.set('rules', doc.createNode([rule]))
126
+ return { text: String(doc), inserted: true }
127
+ }
128
+ if (!isMap(doc.contents)) {
129
+ throw new RuleError('cannot edit the rule file: the document root must be a mapping with a "rules" list')
130
+ }
131
+ if (!doc.has('rules')) throw new RuleError('cannot edit the rule file: the document has no "rules" list')
132
+ const rules = doc.get('rules')
133
+ if (!isSeq(rules)) throw new RuleError('cannot edit the rule file: "rules" must be a list')
134
+ if (headAllowHost(rules) === normalized) return { text, inserted: false }
135
+ rules.items.unshift(doc.createNode(rule))
136
+ return { text: String(doc), inserted: true }
137
+ }
138
+
139
+ /**
140
+ * The host of the first rule, when that rule is exactly the allow rule this
141
+ * action generates: `action: allow`, enabled, and a `match` whose only
142
+ * dimension is a single `network.domains` entry. Anything else — a different
143
+ * action, a disabled rule, or a rule narrowed by ports/schemes/tools/agents —
144
+ * returns undefined, so an existing narrower rule can never make the action
145
+ * skip the write that would actually unblock the connection.
146
+ * @param rules - the parsed `rules` sequence.
147
+ * @returns the normalized head host, or undefined.
148
+ */
149
+ function headAllowHost(rules: YAMLSeq): string | undefined {
150
+ const first = rules.items[0]
151
+ if (!isMap(first)) return undefined
152
+ const record: unknown = first.toJSON()
153
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined
154
+ const rule = record as Record<string, unknown>
155
+ if (rule['action'] !== 'allow' || rule['enabled'] === false) return undefined
156
+ const match = rule['match']
157
+ if (typeof match !== 'object' || match === null || Array.isArray(match)) return undefined
158
+ const dimensions = Object.keys(match)
159
+ if (dimensions.length !== 1 || dimensions[0] !== 'network') return undefined
160
+ const network = (match as Record<string, unknown>)['network']
161
+ if (typeof network !== 'object' || network === null || Array.isArray(network)) return undefined
162
+ const scopes = Object.keys(network)
163
+ if (scopes.length !== 1 || scopes[0] !== 'domains') return undefined
164
+ const domains = (network as Record<string, unknown>)['domains']
165
+ if (!Array.isArray(domains) || domains.length !== 1) return undefined
166
+ const only: unknown = domains[0]
167
+ return typeof only === 'string' ? normalizeAllowHost(only) : undefined
168
+ }
169
+
170
+ /** Everything {@link resolveAllowHostTarget} needs, injected so the choice is pure and testable. */
171
+ export interface AllowHostTargetInput {
172
+ /** The workspace the request named, or null for a host-level (session-less) block. */
173
+ readonly cwd: string | null
174
+ /** The absolute workspace roots whose rule chains are currently loaded (the cwd whitelist). */
175
+ readonly loadedCwds: readonly string[]
176
+ /** The paths the editor/action may touch (`PermissionRulesRuntime.knownRuleSources()`). */
177
+ readonly knownSources: readonly string[]
178
+ /** The configured `rulesFile`: relative to a workspace, or absolute for every one. */
179
+ readonly rulesFile: string
180
+ /** The configured `fallbackPath`, or undefined when unset. */
181
+ readonly fallbackPath: string | undefined
182
+ /** The host process's working directory (the session-less workspace). */
183
+ readonly processCwd: string
184
+ /** The shipped baseline path when the baseline is enabled; never writable. */
185
+ readonly builtinPath: string | undefined
186
+ /** Existence probe for the host-level project file. */
187
+ readonly exists: (path: string) => boolean
188
+ }
189
+
190
+ /**
191
+ * The one rule file this action may write for a request: the nearest file of
192
+ * the chain that will actually judge the connection.
193
+ *
194
+ * With a workspace cwd the target is that workspace's project file (the
195
+ * absolute `rulesFile` when one is configured), and the cwd must be a
196
+ * workspace whose chain is loaded — an unknown cwd is refused instead of being
197
+ * turned into a path.
198
+ *
199
+ * Without one, the order mirrors `resolveUserSources(process.cwd())` exactly,
200
+ * so the file written is the file the host chain reads: the absolute
201
+ * `rulesFile`, else `<processCwd>/<rulesFile>` when it already exists, else
202
+ * the configured fallback, else `<processCwd>/<rulesFile>` (created — the
203
+ * host chain picks it up on the next load).
204
+ *
205
+ * The result must be a known rule source and must never be the built-in
206
+ * baseline.
207
+ *
208
+ * @param input - the injected config, cwd, and source state.
209
+ * @returns the absolute path to write.
210
+ * @throws RuleError when the request names an unknown workspace or resolves to
211
+ * a path outside the writable set.
212
+ */
213
+ export function resolveAllowHostTarget(input: AllowHostTargetInput): string {
214
+ const target = input.cwd === null ? hostTarget(input) : workspaceTarget(input, input.cwd)
215
+ if (input.builtinPath !== undefined && samePath(target, input.builtinPath)) {
216
+ throw new RuleError(`refusing to write ${target}: the built-in ruleset is read-only`)
217
+ }
218
+ if (!input.knownSources.some(source => samePath(source, target))) {
219
+ throw new RuleError(`refusing to write ${target}: not a known rule source`)
220
+ }
221
+ return target
222
+ }
223
+
224
+ /** The project file of one workspace (the absolute `rulesFile` when one is configured). */
225
+ function projectFile(rulesFile: string, cwd: string): string {
226
+ return isAbsolute(rulesFile) ? rulesFile : join(cwd, rulesFile)
227
+ }
228
+
229
+ /** The target for a request that named a workspace. */
230
+ function workspaceTarget(input: AllowHostTargetInput, cwd: string): string {
231
+ const owner = input.loadedCwds.find(loaded => samePath(loaded, cwd))
232
+ if (owner === undefined) {
233
+ throw new RuleError(`refusing to allow for ${JSON.stringify(cwd)}: not a workspace whose rules are loaded`)
234
+ }
235
+ return projectFile(input.rulesFile, owner)
236
+ }
237
+
238
+ /** The target for a host-level (session-less) request — the same order the host chain resolves in. */
239
+ function hostTarget(input: AllowHostTargetInput): string {
240
+ if (isAbsolute(input.rulesFile)) return input.rulesFile
241
+ const project = join(input.processCwd, input.rulesFile)
242
+ if (input.exists(project)) return project
243
+ const fallback = input.fallbackPath
244
+ if (fallback === undefined) return project
245
+ return isAbsolute(fallback) ? fallback : resolve(fallback)
246
+ }
247
+
248
+ /** Path equality as the per-workspace cache key treats it: resolved, and case-folded on Windows. */
249
+ function samePath(a: string, b: string): boolean {
250
+ const left = resolve(a)
251
+ const right = resolve(b)
252
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
253
+ }
254
+
255
+ /**
256
+ * The workspaces a host-level block may be allowed in: the distinct non-null
257
+ * `cwd`s of the loaded rule sources.
258
+ *
259
+ * A block with no cwd was judged by the proxy's session-less host chain, which
260
+ * any loaded workspace chain outranks the moment one exists — so writing the
261
+ * host file can leave the connection blocked while the rule sits in a file
262
+ * nothing consults. The settings page therefore offers these workspaces and
263
+ * sends the chosen one as the request's cwd; when there is exactly one (or
264
+ * none), there is nothing to choose between.
265
+ * @param sources - the snapshot's rule sources.
266
+ * @returns the distinct workspace roots, in source order.
267
+ */
268
+ export function allowHostWorkspaces(sources: readonly RuleSourceView[]): string[] {
269
+ const seen = new Set<string>()
270
+ const workspaces: string[] = []
271
+ for (const source of sources) {
272
+ if (source.cwd === null || seen.has(source.cwd)) continue
273
+ seen.add(source.cwd)
274
+ workspaces.push(source.cwd)
275
+ }
276
+ return workspaces
277
+ }
278
+
279
+ /** The locale keys the allow action reports through. */
280
+ export type AllowHostNoticeKey = 'allowHostSaved' | 'allowHostAlready' | 'allowHostStillBlocked' | 'allowHostFailed'
281
+
282
+ /** The notice one action result maps onto: a locale key plus its interpolation values. */
283
+ export interface AllowHostNotice {
284
+ readonly ok: boolean
285
+ readonly key: AllowHostNoticeKey
286
+ readonly vars: Record<string, string | number>
287
+ }
288
+
289
+ /**
290
+ * Map an action result onto the page notice.
291
+ *
292
+ * A non-`allow` outcome outranks `alreadyAllowed`: the whole point of
293
+ * returning the RECOMPUTED decision is that the page can never say "allowed"
294
+ * while the connection is still blocked (a nearer chain with an earlier deny,
295
+ * or a mode default, still wins). `alreadyAllowed` therefore only ever reads
296
+ * as success when the decision really is `allow`.
297
+ * @param result - the `permissionRules/allowHost` result.
298
+ * @returns the notice to render.
299
+ */
300
+ export function allowHostNotice(result: AllowHostResult): AllowHostNotice {
301
+ if (!result.ok) return { ok: false, key: 'allowHostFailed', vars: { error: result.error ?? 'unknown error' } }
302
+ if (result.outcome !== 'allow') {
303
+ return { ok: false, key: 'allowHostStillBlocked', vars: { outcome: result.outcome ?? 'unknown' } }
304
+ }
305
+ if (result.alreadyAllowed) return { ok: true, key: 'allowHostAlready', vars: {} }
306
+ return { ok: true, key: 'allowHostSaved', vars: { path: result.path ?? '', reloaded: result.reloaded } }
307
+ }
@@ -19,7 +19,7 @@ import { PERMISSION_RULES_REMOTE } from './remote.ts'
19
19
  import { PermissionRulesSection } from './section.ts'
20
20
  import type { PermissionRulesSectionInjected } from './section.ts'
21
21
  import { en, zh, type PermissionRulesLocaleKey } from './locales.ts'
22
- import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
22
+ import type { AllowHostRequest, AllowHostResult, PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
23
23
 
24
24
  /** Dictionary namespace owned by this plugin. */
25
25
  export const NS = 'settings.permissionRules'
@@ -74,6 +74,7 @@ export async function apply(ctx: Context): Promise<void> {
74
74
  rulesRead: (path: string) => Promise<RemoteResult<RulesReadResult>>
75
75
  rulesSave: (path: string, text: string) => Promise<RemoteResult<RulesSaveResult>>
76
76
  reload: () => Promise<RemoteResult<RulesReloadResult>>
77
+ allowHost: (request: AllowHostRequest) => Promise<RemoteResult<AllowHostResult>>
77
78
  } | undefined
78
79
  if (bridge === undefined) return
79
80
  const injected = (): PermissionRulesSectionInjected => ({
@@ -97,6 +98,11 @@ export async function apply(ctx: Context): Promise<void> {
97
98
  if (!result.ok) throw new Error(`reload failed: ${result.error.code}: ${result.error.message}`)
98
99
  return result.value
99
100
  },
101
+ allowHost: async (request) => {
102
+ const result = await bridge.allowHost(request)
103
+ if (!result.ok) throw new Error(`allowHost failed: ${result.error.code}: ${result.error.message}`)
104
+ return result.value
105
+ },
100
106
  t,
101
107
  })
102
108
  slots.inject('settings.section', () => slots.register({
@@ -14,6 +14,11 @@ export type PermissionRulesLocaleKey =
14
14
  | 'proxyActive'
15
15
  | 'proxyInactive'
16
16
  | 'proxyDisabled'
17
+ | 'upstream'
18
+ | 'upstreamOff'
19
+ | 'upstreamInherit'
20
+ | 'upstreamInactive'
21
+ | 'upstreamChained'
17
22
  | 'counters'
18
23
  | 'denied'
19
24
  | 'askBlocked'
@@ -22,6 +27,13 @@ export type PermissionRulesLocaleKey =
22
27
  | 'blockBy'
23
28
  | 'blockDefault'
24
29
  | 'blockUnattributed'
30
+ | 'allowHost'
31
+ | 'allowHostPickWorkspace'
32
+ | 'allowHostSaved'
33
+ | 'allowHostAlready'
34
+ | 'allowHostStillBlocked'
35
+ | 'allowHostFailed'
36
+ | 'allowHostDisabled'
25
37
  | 'editorTitle'
26
38
  | 'editorPath'
27
39
  | 'editorLoad'
@@ -43,6 +55,11 @@ export const en: Record<PermissionRulesLocaleKey, string> = {
43
55
  proxyActive: 'active on 127.0.0.1:{port}',
44
56
  proxyInactive: 'INACTIVE (bind failed — shell network policy is not enforced)',
45
57
  proxyDisabled: 'network policy disabled',
58
+ upstream: 'Upstream',
59
+ upstreamOff: 'off — allowed connections dial directly',
60
+ upstreamInherit: 'inherit from the launch environment',
61
+ upstreamInactive: 'inactive',
62
+ upstreamChained: '{count} chained',
46
63
  counters: 'Blocks',
47
64
  denied: 'denied',
48
65
  askBlocked: 'ask-blocked',
@@ -51,6 +68,13 @@ export const en: Record<PermissionRulesLocaleKey, string> = {
51
68
  blockBy: 'rule {index}',
52
69
  blockDefault: 'mode default',
53
70
  blockUnattributed: 'unattributed',
71
+ allowHost: 'Allow',
72
+ allowHostPickWorkspace: 'Choose a workspace…',
73
+ allowHostSaved: 'Allowed — wrote {path} ({reloaded} chain(s) re-read).',
74
+ allowHostAlready: 'Already allowed — nothing was written.',
75
+ allowHostStillBlocked: 'Rule written, but the target is still "{outcome}" — a nearer chain or the mode default still blocks it.',
76
+ allowHostFailed: 'Allow failed: {error}',
77
+ allowHostDisabled: 'Allow action disabled (network.allowHostAction)',
54
78
  editorTitle: 'Rule editor',
55
79
  editorPath: 'Rule file',
56
80
  editorLoad: 'Load',
@@ -73,6 +97,11 @@ export const zh: Record<PermissionRulesLocaleKey, string> = {
73
97
  proxyActive: '运行于 127.0.0.1:{port}',
74
98
  proxyInactive: '未激活(绑定失败——shell 网络策略未生效)',
75
99
  proxyDisabled: '网络策略已停用',
100
+ upstream: '上游',
101
+ upstreamOff: '关闭 —— 已放行连接直连',
102
+ upstreamInherit: '继承启动环境',
103
+ upstreamInactive: '未生效',
104
+ upstreamChained: '已链式 {count} 条',
76
105
  counters: '拦截',
77
106
  denied: '拒绝',
78
107
  askBlocked: '待审批阻断',
@@ -81,6 +110,13 @@ export const zh: Record<PermissionRulesLocaleKey, string> = {
81
110
  blockBy: '规则 {index}',
82
111
  blockDefault: '模式默认',
83
112
  blockUnattributed: '未归属',
113
+ allowHost: '放行',
114
+ allowHostPickWorkspace: '选择工作区…',
115
+ allowHostSaved: '已放行——写入 {path}(重读 {reloaded} 条链)。',
116
+ allowHostAlready: '已放行——未写入任何文件。',
117
+ allowHostStillBlocked: '规则已写入,但目标仍为「{outcome}」——更靠前的规则链或模式默认仍在拦截。',
118
+ allowHostFailed: '放行失败:{error}',
119
+ allowHostDisabled: '放行动作已关闭(network.allowHostAction)',
84
120
  editorTitle: '规则编辑器',
85
121
  editorPath: '规则文件',
86
122
  editorLoad: '载入',
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
12
12
  import { PERMISSION_RULES_INVOCATIONS } from '../wire.ts'
13
- import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
13
+ import type { AllowHostRequest, AllowHostResult, PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
14
14
 
15
15
  declare module '@deepseek-ai/dsh-typert-protocol' {
16
16
  interface TypertRemoteNamespace$permissionRules {
@@ -22,12 +22,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
22
22
  rulesSave: (path: string, text: string) => Promise<RemoteResult<RulesSaveResult>>
23
23
  /** Re-read every cached workspace chain. */
24
24
  reload: () => Promise<RemoteResult<RulesReloadResult>>
25
+ /** Write one minimal allow rule for a blocked host into the nearest effective rule file. */
26
+ allowHost: (request: AllowHostRequest) => Promise<RemoteResult<AllowHostResult>>
25
27
  }
26
28
  interface TypertRemoteMap {
27
29
  'permissionRules/networkStatus': () => Promise<RemoteResult<PermissionRulesSnapshot>>
28
30
  'permissionRules/rulesRead': (path: string) => Promise<RemoteResult<RulesReadResult>>
29
31
  'permissionRules/rulesSave': (path: string, text: string) => Promise<RemoteResult<RulesSaveResult>>
30
32
  'permissionRules/reload': () => Promise<RemoteResult<RulesReloadResult>>
33
+ 'permissionRules/allowHost': (request: AllowHostRequest) => Promise<RemoteResult<AllowHostResult>>
31
34
  }
32
35
  interface TypertRemoteNamespaceMap {
33
36
  permissionRules: TypertRemoteNamespace$permissionRules
@@ -11,8 +11,9 @@
11
11
 
12
12
  import * as React from 'react'
13
13
  import type { ReactElement } from 'react'
14
+ import { allowHostNotice, allowHostWorkspaces } from '../allow-host.ts'
14
15
  import type { PermissionRulesLocaleKey } from './locales.ts'
15
- import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
16
+ import type { AllowHostRequest, AllowHostResult, NetworkBlockView, PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from '../wire.ts'
16
17
 
17
18
  /** Functions the registration injects (wired to the Remote namespace in `./index.ts`). */
18
19
  export interface PermissionRulesSectionInjected {
@@ -24,6 +25,8 @@ export interface PermissionRulesSectionInjected {
24
25
  rulesSave: (path: string, text: string) => Promise<RulesSaveResult>
25
26
  /** Re-read every cached workspace chain. */
26
27
  reload: () => Promise<RulesReloadResult>
28
+ /** Write one minimal allow rule for one blocked host. */
29
+ allowHost: (request: AllowHostRequest) => Promise<AllowHostResult>
27
30
  /** The locale-bound translator. */
28
31
  t: T
29
32
  }
@@ -53,6 +56,11 @@ const NOTICE_ERR: React.CSSProperties = { fontSize: 13, color: 'var(--dsh-color-
53
56
  const TABLE: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', fontSize: 12 }
54
57
  const CELL: React.CSSProperties = { borderBottom: '1px solid var(--dsh-color-border, #ddd)', padding: '4px 6px', textAlign: 'left', verticalAlign: 'top' }
55
58
 
59
+ /** The stable identity of one block row (the same key the table uses). */
60
+ function blockKey(block: NetworkBlockView): string {
61
+ return `${block.time}-${block.domain}`
62
+ }
63
+
56
64
  /** The settings section component. */
57
65
  export function PermissionRulesSection({ close, t, ...injected }: PermissionRulesSectionProps): ReactElement {
58
66
  void close
@@ -63,6 +71,10 @@ export function PermissionRulesSection({ close, t, ...injected }: PermissionRule
63
71
  const [text, setText] = React.useState('')
64
72
  const [dirty, setDirty] = React.useState(false)
65
73
  const [notice, setNotice] = React.useState<{ ok: boolean; text: string } | undefined>(undefined)
74
+ // The allow action renders its own notice next to the block list (the editor
75
+ // notice above is only visible once a rule file is selected).
76
+ const [allowNotice, setAllowNotice] = React.useState<{ ok: boolean; text: string } | undefined>(undefined)
77
+ const [allowPick, setAllowPick] = React.useState<Record<string, string>>({})
66
78
 
67
79
  const refresh = React.useCallback(() => {
68
80
  void injected.status().then(next => {
@@ -118,6 +130,24 @@ export function PermissionRulesSection({ close, t, ...injected }: PermissionRule
118
130
  })
119
131
  }, [injected, t, refresh])
120
132
 
133
+ /**
134
+ * Run the per-block allow action. The runtime recomputes the decision for
135
+ * the target before returning, so the notice reports the REAL outcome: a
136
+ * successful write whose target stays blocked reads as a failure, never as
137
+ * a success.
138
+ */
139
+ const allowHost = React.useCallback((block: NetworkBlockView, cwd: string | null) => {
140
+ setAllowNotice(undefined)
141
+ const request: AllowHostRequest = { host: block.domain, scheme: block.scheme, port: block.port, cwd }
142
+ void injected.allowHost(request).then(result => {
143
+ const mapped = allowHostNotice(result)
144
+ setAllowNotice({ ok: mapped.ok, text: t(mapped.key, mapped.vars) })
145
+ if (result.ok) refresh()
146
+ }).catch((error: unknown) => {
147
+ setAllowNotice({ ok: false, text: t('allowHostFailed', { error: error instanceof Error ? error.message : String(error) }) })
148
+ })
149
+ }, [injected, t, refresh])
150
+
121
151
  const picker = React.createElement('select', {
122
152
  style: { ...MONO, maxWidth: 420 },
123
153
  value: selected ?? '',
@@ -144,6 +174,12 @@ export function PermissionRulesSection({ close, t, ...injected }: PermissionRule
144
174
  children.push(React.createElement('div', { key: 'proxy', style: ROW },
145
175
  React.createElement('span', { style: LABEL }, t('proxy')),
146
176
  React.createElement('span', { style: VALUE }, proxyText)))
177
+ const upstreamHead = snapshot.upstream.mode === 'off'
178
+ ? t('upstreamOff')
179
+ : `${snapshot.upstream.mode === 'inherit' ? t('upstreamInherit') : (snapshot.upstream.https ?? snapshot.upstream.http ?? '')}${snapshot.upstream.active ? '' : ` (${t('upstreamInactive')})`}`
180
+ children.push(React.createElement('div', { key: 'upstream', style: ROW },
181
+ React.createElement('span', { style: LABEL }, t('upstream')),
182
+ React.createElement('span', { style: VALUE }, `${upstreamHead} · ${t('upstreamChained', { count: snapshot.upstream.chained })}`)))
147
183
  children.push(React.createElement('div', { key: 'counters', style: ROW },
148
184
  React.createElement('span', { style: LABEL }, t('counters')),
149
185
  React.createElement('span', { style: VALUE }, `${snapshot.denied} ${t('denied')} · ${snapshot.askBlocked} ${t('askBlocked')}`)))
@@ -154,17 +190,54 @@ export function PermissionRulesSection({ close, t, ...injected }: PermissionRule
154
190
  if (snapshot === undefined || snapshot.recent.length === 0) {
155
191
  children.push(React.createElement('div', { key: 'no-blocks', style: VALUE }, t('noBlocks')))
156
192
  } 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 ?? '')))
193
+ const choices = allowHostWorkspaces(snapshot.sources)
194
+ const allowEnabled = snapshot.allowHostAction !== false
195
+ const rows = snapshot.recent.map(block => {
196
+ const key = blockKey(block)
197
+ const picked = allowPick[key] ?? ''
198
+ // A block with no cwd is judged by the session-less host chain, which any
199
+ // loaded workspace chain outranks; with several sources the operator must
200
+ // pick the workspace the rule belongs in before the action can run.
201
+ const needsPick = block.cwd === null && snapshot.sources.length > 1 && choices.length > 0
202
+ const cwd = block.cwd ?? (needsPick ? (picked === '' ? null : picked) : null)
203
+ const decide = (): void => allowHost(block, cwd)
204
+ const action: React.ReactNode = !allowEnabled
205
+ ? React.createElement('span', { style: LABEL }, t('allowHostDisabled'))
206
+ : React.createElement('span', { style: ROW },
207
+ needsPick
208
+ ? React.createElement('select', {
209
+ style: MONO,
210
+ value: picked,
211
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) => {
212
+ const value = event.target.value
213
+ setAllowPick(current => ({ ...current, [key]: value }))
214
+ },
215
+ }, [
216
+ React.createElement('option', { key: '', value: '' }, t('allowHostPickWorkspace')),
217
+ ...choices.map(choice => React.createElement('option', { key: choice, value: choice }, choice)),
218
+ ])
219
+ : null,
220
+ React.createElement('button', {
221
+ type: 'button',
222
+ disabled: needsPick && (picked === '' || cwd === null),
223
+ onClick: decide,
224
+ }, t('allowHost')))
225
+ return React.createElement('tr', { key },
226
+ React.createElement('td', { style: CELL }, new Date(block.time).toLocaleTimeString()),
227
+ React.createElement('td', { style: CELL }, block.tool + (block.attributed ? '' : ` (${t('blockUnattributed')})`)),
228
+ React.createElement('td', { style: CELL }, `${block.scheme ?? '?'}://${block.domain}${block.port === null ? '' : `:${block.port}`}`),
229
+ React.createElement('td', { style: CELL }, block.matched
230
+ ? t('blockBy', { index: (block.ruleIndex ?? 0) + 1 })
231
+ : t('blockDefault')),
232
+ React.createElement('td', { style: CELL }, block.action),
233
+ React.createElement('td', { style: CELL }, block.reason ?? ''),
234
+ React.createElement('td', { style: CELL }, action))
235
+ })
166
236
  children.push(React.createElement('table', { key: 'recent-table', style: TABLE },
167
237
  React.createElement('tbody', undefined, rows)))
238
+ if (allowNotice !== undefined) {
239
+ children.push(React.createElement('div', { key: 'allow-notice', style: allowNotice.ok ? NOTICE_OK : NOTICE_ERR }, allowNotice.text))
240
+ }
168
241
  }
169
242
 
170
243
  // Rule editor.