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.
- package/CHANGELOG.md +20 -0
- package/README.es.md +6 -1
- package/README.hi.md +6 -1
- package/README.md +6 -1
- package/README.pt.md +6 -1
- package/README.zh.md +6 -1
- package/docs/rules-format.en.md +2 -1
- package/docs/rules-format.md +2 -1
- package/lib/client.js +6924 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +671 -28
- package/lib/typert.host.js +74 -3
- package/lib/types/allow-host.d.ts +151 -0
- package/lib/types/allow-host.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote.d.ts +54 -1
- package/lib/types/client/remote.d.ts.map +1 -1
- package/lib/types/client/section.d.ts +3 -1
- package/lib/types/client/section.d.ts.map +1 -1
- package/lib/types/config.d.ts +22 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/prose.d.ts +1 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +73 -0
- package/lib/types/proxy.d.ts.map +1 -1
- package/lib/types/remote-service.d.ts +9 -1
- package/lib/types/remote-service.d.ts.map +1 -1
- package/lib/types/rules.d.ts +11 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +76 -11
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/typert.host.d.ts +50 -0
- package/lib/types/typert.host.d.ts.map +1 -1
- package/lib/types/wire.d.ts +191 -0
- package/lib/types/wire.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/allow-host.ts +307 -0
- package/src/client/index.ts +7 -1
- package/src/client/locales.ts +36 -0
- package/src/client/remote.ts +4 -1
- package/src/client/section.ts +83 -10
- package/src/config.ts +56 -0
- package/src/prose.ts +6 -0
- package/src/proxy.ts +202 -16
- package/src/remote-service.ts +33 -1
- package/src/rules.ts +11 -2
- package/src/runtime.ts +243 -9
- 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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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({
|
package/src/client/locales.ts
CHANGED
|
@@ -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: '载入',
|
package/src/client/remote.ts
CHANGED
|
@@ -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
|
package/src/client/section.ts
CHANGED
|
@@ -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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|