dsh-permission-rules 0.6.21 → 0.7.1

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 +27 -0
  2. package/{README.es.md → README-es.md} +8 -3
  3. package/{README.hi.md → README-hi.md} +8 -3
  4. package/{README.pt.md → README-pt.md} +8 -3
  5. package/{README.zh.md → README-zh.md} +8 -3
  6. package/README.md +8 -3
  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 +23 -23
  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
package/src/runtime.ts CHANGED
@@ -23,6 +23,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
23
23
  import { resolveConfig } from './config.ts'
24
24
  import type { Config, ResolvedConfig } from './config.ts'
25
25
  import type { CallId } from './call-id.ts'
26
+ import { insertAllowRule, isWritableHost, normalizeAllowHost, resolveAllowHostTarget } from './allow-host.ts'
27
+ import type { AllowHostInsertion } from './allow-host.ts'
28
+ import type { AllowHostRequest, AllowHostResult } from './wire.ts'
26
29
  import { compileRules, compileSourceRules, describeRule, findUnreachableRules, isShellTool, matchRules, mergeCompiledRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
27
30
  import type { CompileOptions, CompiledRule, CompiledRuleset, MatchContext, NetworkTarget, RuleHit } from './rules.ts'
28
31
  import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
@@ -35,8 +38,8 @@ function readSessionEvents(session: { snapshotEvents?: () => readonly SessionEve
35
38
  if (typeof session.snapshotEvents === 'function') return session.snapshotEvents()
36
39
  return session.events ?? []
37
40
  }
38
- import { injectProxyEnv, NetworkProxy } from './proxy.ts'
39
- import type { NetworkBlockRecord, ProxyAttribution } from './proxy.ts'
41
+ import { injectProxyEnv, NetworkProxy, readAmbientProxy, redactProxyUrl } from './proxy.ts'
42
+ import type { AmbientUpstream, NetworkBlockRecord, ProxyAttribution } from './proxy.ts'
40
43
  import { PermissionRulesRemoteService } from './remote-service.ts'
41
44
  import { attachSettingsSection } from './settings.ts'
42
45
  import { isMarkedAuditEvent } from './events.ts'
@@ -137,6 +140,23 @@ const POLLING_WATCH_OPTIONS = { usePolling: true, interval: POLLING_WATCH_INTERV
137
140
  * State and behavior. One instance per plugin mount; disposals are owned by
138
141
  * the watcher/timer/proxy effects registered in {@link apply}.
139
142
  */
143
+ /**
144
+ * Whether a configured upstream addresses this proxy's own bind address and
145
+ * port, which would make every chained connection recurse into this proxy.
146
+ */
147
+ function isSelfUpstream(value: string, bind: string, port: number): boolean {
148
+ try {
149
+ const parsed = new URL(value)
150
+ const bindIsLocal = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1' || bind === '0.0.0.0'
151
+ const host = parsed.hostname
152
+ const valueIsLocal = host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]'
153
+ const valuePort = parsed.port === '' ? (parsed.protocol === 'https:' ? 443 : 80) : Number(parsed.port)
154
+ return bindIsLocal && valueIsLocal && valuePort === port
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
140
160
  export class PermissionRulesRuntime {
141
161
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
142
162
  private readonly byCwd = new Map<string, LoadedRules>()
@@ -164,6 +184,11 @@ export class PermissionRulesRuntime {
164
184
 
165
185
  /** The network proxy, when the policy is enabled and mounted. */
166
186
  private networkProxy: NetworkProxy | undefined
187
+ /** Ambient upstream candidates, captured ONCE before this plugin injects its own (see {@link captureAmbientUpstream}). */
188
+ private ambientUpstream: AmbientUpstream | undefined
189
+ private ambientCaptured = false
190
+ private upstreamWarned = false
191
+ private selfLoopWarned = false
167
192
 
168
193
  /** Restores the injected proxy environment (set after a successful bind). */
169
194
  private envRestore: (() => void) | undefined
@@ -721,6 +746,9 @@ export class PermissionRulesRuntime {
721
746
  */
722
747
  async attachNetworkProxy(): Promise<void> {
723
748
  const cfg = this.config.network
749
+ // Capture the ambient proxy BEFORE injectProxyEnv() rewrites the
750
+ // environment: afterwards the only proxy this process can still see is ours.
751
+ this.captureAmbientUpstream()
724
752
  const proxy = new NetworkProxy({
725
753
  bind: cfg.proxyBind,
726
754
  port: cfg.proxyPort,
@@ -729,6 +757,7 @@ export class PermissionRulesRuntime {
729
757
  attribution: () => this.proxyAttribution(),
730
758
  onBlock: (record, attribution) => this.auditNetworkBlock(record, attribution),
731
759
  logger: this.ctx.logger,
760
+ upstream: () => this.upstreamFor(),
732
761
  })
733
762
  this.networkProxy = proxy
734
763
  this.ctx.effect(() => () => {
@@ -769,6 +798,91 @@ export class PermissionRulesRuntime {
769
798
  await this.attachNetworkProxy()
770
799
  }
771
800
 
801
+ /**
802
+ * Capture the launch environment's proxy names exactly ONCE, before this
803
+ * plugin injects its own. `onNetworkConfigChanged()` closes the old proxy and
804
+ * restores the environment around a rebind, so a second capture could read
805
+ * this plugin's own address back as if the operator had exported it — hence
806
+ * the memo. The launch snapshot is immutable and preferred; `process.env` is
807
+ * the fallback for hosts that expose no snapshot.
808
+ */
809
+ private captureAmbientUpstream(): void {
810
+ if (this.ambientCaptured) return
811
+ this.ambientCaptured = true
812
+ const launch = this.ctx.get('launchEnvironment') as { get(name: string): { value: string } | undefined } | undefined
813
+ const lookup = (name: string): string | undefined => launch?.get(name)?.value ?? process.env[name]
814
+ this.ambientUpstream = readAmbientProxy(lookup)
815
+ this.warnAboutUpstream()
816
+ }
817
+
818
+ /** The one-shot upstream diagnostics: a discarded ambient proxy, or an `inherit` with nothing to inherit. */
819
+ private warnAboutUpstream(): void {
820
+ if (this.upstreamWarned) return
821
+ this.upstreamWarned = true
822
+ const cfg = this.config.network
823
+ const ambient = this.ambientUpstream
824
+ const has = ambient !== undefined && (ambient.http !== undefined || ambient.https !== undefined)
825
+ if (cfg.upstreamProxy === 'off' && cfg.injectEnv && has) {
826
+ this.ctx.logger.warn(`permission-rules: an ambient proxy (${redactProxyUrl(ambient.https ?? ambient.http ?? '')}) is discarded for traffic through this proxy; set network.upstreamProxy to "inherit" to chain through it, or leave it to subprocesses only`)
827
+ }
828
+ if (cfg.upstreamProxy === 'inherit' && !cfg.injectEnv) {
829
+ this.ctx.logger.warn('permission-rules: network.upstreamProxy is only consulted for connections that reach this proxy; with injectEnv false, subprocess traffic keeps its own route')
830
+ }
831
+ if (cfg.upstreamProxy === 'inherit' && !has) {
832
+ this.ctx.logger.warn('permission-rules: network.upstreamProxy is "inherit" but the launch environment named no usable http(s) proxy (a SOCKS or malformed value is ignored; Node has no SOCKS client)')
833
+ }
834
+ }
835
+
836
+ /**
837
+ * The upstream getter the proxy consults per connection. Undefined when the
838
+ * setting is `off`, when no candidate is usable, or when the candidate points
839
+ * back into this very proxy — a self-loop would recurse forever, so chaining
840
+ * is disabled for it and warned about once.
841
+ */
842
+ private upstreamFor(): AmbientUpstream | undefined {
843
+ if (this.config.network.upstreamProxy === 'off') return undefined
844
+ const ambient = this.ambientUpstream
845
+ if (ambient === undefined) return undefined
846
+ const selfPort = this.networkProxy?.port ?? 0
847
+ const selfHost = this.config.network.proxyBind
848
+ const usable = (value: string | undefined): string | undefined => {
849
+ if (value === undefined) return undefined
850
+ if (selfPort > 0 && isSelfUpstream(value, selfHost, selfPort)) {
851
+ if (!this.selfLoopWarned) {
852
+ this.selfLoopWarned = true
853
+ this.ctx.logger.warn(`permission-rules: network.upstreamProxy points at this proxy itself (${redactProxyUrl(value)}); chaining is disabled for it`)
854
+ }
855
+ return undefined
856
+ }
857
+ return value
858
+ }
859
+ const http = usable(ambient.http)
860
+ const https = usable(ambient.https)
861
+ if (http === undefined && https === undefined) return undefined
862
+ return { ...(http === undefined ? {} : { http }), ...(https === undefined ? {} : { https }) }
863
+ }
864
+
865
+ /** The upstream block of the network snapshot, already redacted. */
866
+ private upstreamSnapshot(): {
867
+ readonly mode: 'off' | 'inherit' | 'url'
868
+ readonly http: string | null
869
+ readonly https: string | null
870
+ readonly active: boolean
871
+ readonly chained: number
872
+ } {
873
+ const setting = this.config.network.upstreamProxy
874
+ const mode: 'off' | 'inherit' | 'url' = setting === 'off' ? 'off' : setting === 'inherit' ? 'inherit' : 'url'
875
+ const http = mode === 'off' ? undefined : mode === 'url' ? setting : this.ambientUpstream?.http
876
+ const https = mode === 'off' ? undefined : mode === 'url' ? setting : this.ambientUpstream?.https
877
+ return {
878
+ mode,
879
+ http: http === undefined ? null : redactProxyUrl(http),
880
+ https: https === undefined ? null : redactProxyUrl(https),
881
+ active: mode !== 'off' && this.upstreamFor() !== undefined,
882
+ chained: this.networkProxy?.chainedConnections() ?? 0,
883
+ }
884
+ }
885
+
772
886
  /** The network snapshot the settings page and `/rules network` render. */
773
887
  networkSnapshot(): {
774
888
  readonly enabled: boolean
@@ -780,6 +894,15 @@ export class PermissionRulesRuntime {
780
894
  readonly denied: number
781
895
  readonly askBlocked: number
782
896
  readonly recent: readonly NetworkBlockRecord[]
897
+ readonly upstream: {
898
+ readonly mode: 'off' | 'inherit' | 'url'
899
+ readonly http: string | null
900
+ readonly https: string | null
901
+ readonly active: boolean
902
+ readonly chained: number
903
+ }
904
+ /** Whether the settings page may offer the per-block allow action. */
905
+ readonly allowHostAction: boolean
783
906
  } {
784
907
  const cfg = this.config.network
785
908
  const { mode, sandboxMode } = this.resolveNetworkMode()
@@ -795,16 +918,18 @@ export class PermissionRulesRuntime {
795
918
  denied: stats.denied,
796
919
  askBlocked: stats.askBlocked,
797
920
  recent: proxy?.recentBlocks() ?? [],
921
+ upstream: this.upstreamSnapshot(),
922
+ allowHostAction: cfg.allowHostAction,
798
923
  }
799
924
  }
800
925
 
801
- /**
802
- * The rule-file paths the settings-page editor may read or write: every
803
- * currently loaded source plus the per-workspace project files and the
804
- * configured fallback (so a not-yet-existing project file can be
805
- * created). The editor never touches arbitrary paths — and the built-in
806
- * baseline is excluded entirely (it is a shipped, read-only baseline,
807
- * shown via `/rules` attribution instead).
926
+ /** The rule-file paths the settings-page editor may read or write: every
927
+ * currently loaded source plus the per-workspace project files, the
928
+ * configured fallback, and the host-level project file the session-less
929
+ * chain resolves (so a not-yet-existing project file can be created). The
930
+ * editor never touches arbitrary paths — and the built-in baseline is
931
+ * excluded entirely (it is a shipped, read-only baseline, shown via
932
+ * `/rules` attribution instead).
808
933
  */
809
934
  knownRuleSources(): readonly string[] {
810
935
  const cfg = this.config
@@ -819,6 +944,11 @@ export class PermissionRulesRuntime {
819
944
  }
820
945
  if (cfg.fallbackPath !== undefined) set.add(isAbsolute(cfg.fallbackPath) ? cfg.fallbackPath : resolve(cfg.fallbackPath))
821
946
  if (isAbsolute(cfg.rulesFile)) set.add(cfg.rulesFile)
947
+ // The host-level chain (see hostChain) resolves its project file against
948
+ // the process cwd; it must be editable and allow-able even while no
949
+ // workspace chain is loaded, or the settings page could show a block the
950
+ // operator can see but not act on.
951
+ else set.add(join(process.cwd(), cfg.rulesFile))
822
952
  return [...set]
823
953
  }
824
954
 
@@ -894,6 +1024,109 @@ export class PermissionRulesRuntime {
894
1024
  return { ok: true, reloaded }
895
1025
  }
896
1026
 
1027
+ /**
1028
+ * The settings-page "allow this host" action (issue #19 item 3): write ONE
1029
+ * minimal `match.network.domains` allow rule for a blocked target into the
1030
+ * nearest effective rule file, at index 0, then report the decision the
1031
+ * runtime makes for that target afterwards.
1032
+ *
1033
+ * Why index 0: rules are first-match-wins, so appending the rule after an
1034
+ * existing `deny` would leave the connection blocked — the rule would be
1035
+ * dead text the operator cannot see the reason for.
1036
+ *
1037
+ * Why `domains` only: that dimension is subdomain-inclusive and
1038
+ * port/scheme-agnostic; narrowing the generated rule to the blocked port
1039
+ * would re-block the same host on its next connection from another port.
1040
+ * The rule can never widen TOOL permissions either — an allow hit on
1041
+ * `tools/pre-execute` is delegated downstream, never claimed.
1042
+ *
1043
+ * Failure modes are fail-closed and leave the disk untouched: the action is
1044
+ * disabled, the host is not a host name/IP literal, the request names an
1045
+ * unknown workspace, the target resolves outside the known rule sources or
1046
+ * onto the built-in baseline, the file cannot be read, the document does
1047
+ * not parse / has no `rules` list, or the written text fails the same
1048
+ * `parseRulesDocument` + `compileRules` gate every hand edit passes.
1049
+ *
1050
+ * The write goes through {@link saveRuleFile}, so the per-workspace chains
1051
+ * AND the session-less host chain are re-read — no restart, no
1052
+ * `/rules reload` — and the returned `outcome` is the REAL decision
1053
+ * recomputed after the write (with the target as given, no DNS: an
1054
+ * `ips`-scoped allow rule cannot be seen here). Cross-process concurrent
1055
+ * writes to one rule file are out of scope: last writer wins.
1056
+ *
1057
+ * @param request - the blocked target plus the workspace it belongs to.
1058
+ * @returns the action result, never a throw.
1059
+ */
1060
+ allowHost(request: AllowHostRequest): AllowHostResult {
1061
+ const fail = (error: string, path: string | null = null): AllowHostResult => ({
1062
+ ok: false,
1063
+ path,
1064
+ created: false,
1065
+ reloaded: 0,
1066
+ outcome: null,
1067
+ alreadyAllowed: false,
1068
+ error,
1069
+ })
1070
+ if (!this.config.network.allowHostAction) {
1071
+ return fail('the allow-host action is disabled by network.allowHostAction')
1072
+ }
1073
+ const host = normalizeAllowHost(request.host)
1074
+ if (!isWritableHost(host)) {
1075
+ return fail(`refusing to allow ${JSON.stringify(request.host)}: not a host name or IP literal`)
1076
+ }
1077
+ const target: NetworkTarget = { scheme: request.scheme ?? undefined, host, port: request.port ?? undefined, ips: [] }
1078
+ if (this.decideProxyTarget(target).action === 'allow') {
1079
+ return { ok: true, path: null, created: false, reloaded: 0, outcome: 'allow', alreadyAllowed: true, error: null }
1080
+ }
1081
+ let path: string
1082
+ try {
1083
+ path = resolveAllowHostTarget({
1084
+ cwd: request.cwd,
1085
+ loadedCwds: [...this.byCwd.values()].map(loaded => loaded.cwd),
1086
+ knownSources: this.knownRuleSources(),
1087
+ rulesFile: this.config.rulesFile,
1088
+ fallbackPath: this.config.fallbackPath,
1089
+ processCwd: process.cwd(),
1090
+ builtinPath: this.builtinSource(),
1091
+ exists: existsSync,
1092
+ })
1093
+ } catch (error: unknown) {
1094
+ return fail(error instanceof Error ? error.message : String(error))
1095
+ }
1096
+ const existed = existsSync(path)
1097
+ let existing = ''
1098
+ if (existed) {
1099
+ try {
1100
+ existing = readFileSync(path, 'utf8')
1101
+ } catch (error: unknown) {
1102
+ return fail(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, path)
1103
+ }
1104
+ }
1105
+ let edit: AllowHostInsertion
1106
+ try {
1107
+ edit = insertAllowRule(existing, host)
1108
+ } catch (error: unknown) {
1109
+ return fail(error instanceof Error ? error.message : String(error), path)
1110
+ }
1111
+ if (!edit.inserted) {
1112
+ // The file already leads with this exact allow rule: first-match
1113
+ // semantics make a second copy a no-op, so the file stays untouched.
1114
+ return { ok: true, path, created: false, reloaded: 0, outcome: this.decideProxyTarget(target).action, alreadyAllowed: true, error: null }
1115
+ }
1116
+ const hadHostChain = this.hostLoaded !== undefined
1117
+ const saved = this.saveRuleFile(path, edit.text)
1118
+ if (!saved.ok) return fail(saved.error ?? `refusing to write ${path}`, path)
1119
+ return {
1120
+ ok: true,
1121
+ path,
1122
+ created: !existed,
1123
+ reloaded: (saved.reloaded ?? 0) + (hadHostChain ? 1 : 0),
1124
+ outcome: this.decideProxyTarget(target).action,
1125
+ alreadyAllowed: false,
1126
+ error: null,
1127
+ }
1128
+ }
1129
+
897
1130
  /** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
898
1131
  private peerVersion(): string | null {
899
1132
  try {
@@ -1090,6 +1323,7 @@ export class PermissionRulesRuntime {
1090
1323
  }
1091
1324
  lines.push(prose.networkHeader(snapshot.mode, snapshot.sandboxMode, snapshot.configuredMode, snapshot.proxyActive, snapshot.proxyPort))
1092
1325
  lines.push(prose.networkCounters(snapshot.denied, snapshot.askBlocked))
1326
+ lines.push(prose.networkUpstream(snapshot.upstream.mode, snapshot.upstream.http, snapshot.upstream.https, snapshot.upstream.active, snapshot.upstream.chained))
1093
1327
  if (snapshot.recent.length === 0) {
1094
1328
  lines.push(prose.noNetworkBlocks)
1095
1329
  } else {
package/src/wire.ts CHANGED
@@ -33,6 +33,12 @@ export interface NetworkBlockView {
33
33
  source: string
34
34
  ruleIndex: number | null
35
35
  reason: string | null
36
+ /**
37
+ * The workspace of the agent the block was attributed to, or null when the
38
+ * connection carried no session attribution (a host-level fetch). The
39
+ * allow-host action needs it to pick the rule file it writes.
40
+ */
41
+ cwd: string | null
36
42
  }
37
43
 
38
44
  /** One rule file the editor may read/write (a known rule source only). */
@@ -56,7 +62,20 @@ export interface PermissionRulesSnapshot {
56
62
  denied: number
57
63
  askBlocked: number
58
64
  recent: readonly NetworkBlockView[]
65
+ /** Upstream chaining state; any credential in a proxy URL is already masked. */
66
+ upstream: {
67
+ mode: 'off' | 'inherit' | 'url'
68
+ http: string | null
69
+ https: string | null
70
+ active: boolean
71
+ chained: number
72
+ }
59
73
  sources: readonly RuleSourceView[]
74
+ /**
75
+ * Whether the settings page may run its per-block "allow this host" action
76
+ * (`network.allowHostAction`); false hides the button.
77
+ */
78
+ allowHostAction: boolean
60
79
  }
61
80
 
62
81
  /** Strict wire schema for {@link PermissionRulesSnapshot}. */
@@ -82,12 +101,21 @@ export const PERMISSION_RULES_SNAPSHOT_SCHEMA = z.object({
82
101
  source: z.string(),
83
102
  ruleIndex: z.number().int().nullable(),
84
103
  reason: z.string().nullable(),
104
+ cwd: z.string().nullable(),
85
105
  })),
86
106
  sources: z.array(z.object({
87
107
  path: z.string(),
88
108
  exists: z.boolean(),
89
109
  cwd: z.string().nullable(),
90
110
  })),
111
+ allowHostAction: z.boolean(),
112
+ upstream: z.object({
113
+ mode: z.union([z.literal('off'), z.literal('inherit'), z.literal('url')]),
114
+ http: z.string().nullable(),
115
+ https: z.string().nullable(),
116
+ active: z.boolean(),
117
+ chained: z.number().int(),
118
+ }),
91
119
  })
92
120
 
93
121
  /** Result of `permissionRules/rulesRead`. */
@@ -132,6 +160,68 @@ export const RULES_RELOAD_SCHEMA = z.object({
132
160
  error: z.string().nullable(),
133
161
  })
134
162
 
163
+ /**
164
+ * The `permissionRules/allowHost` request: the blocked target exactly as the
165
+ * block view reported it, plus the workspace the rule belongs in (`null` for
166
+ * a host-level block). The action never accepts a PATH — the runtime maps the
167
+ * cwd onto its own known rule sources, so the wire cannot widen the write set.
168
+ */
169
+ export interface AllowHostRequest {
170
+ /** The blocked host (host name or IP literal). */
171
+ host: string
172
+ /** The blocked scheme, or null when the block record had none. */
173
+ scheme: 'http' | 'https' | null
174
+ /** The blocked port, or null when the block record had none. */
175
+ port: number | null
176
+ /** The workspace whose rule file should receive the rule, or null. */
177
+ cwd: string | null
178
+ }
179
+
180
+ /** Strict wire schema for {@link AllowHostRequest}. */
181
+ export const ALLOW_HOST_REQUEST_SCHEMA = z.object({
182
+ host: z.string(),
183
+ scheme: z.union([z.literal('http'), z.literal('https'), z.null()]),
184
+ port: z.number().int().nullable(),
185
+ cwd: z.string().nullable(),
186
+ })
187
+
188
+ /**
189
+ * The `permissionRules/allowHost` result. `ok` reports whether the WRITE
190
+ * succeeded; `outcome` is the decision recomputed after it, so a page can
191
+ * never claim success while the connection is still blocked.
192
+ */
193
+ export interface AllowHostResult {
194
+ ok: boolean
195
+ /** The rule file written, or null when nothing was written. */
196
+ path: string | null
197
+ /** Whether the file did not exist before this action created it. */
198
+ created: boolean
199
+ /**
200
+ * Rule chains that were re-read (the per-workspace chains, plus the
201
+ * session-less host chain when one was cached and had to be dropped).
202
+ */
203
+ reloaded: number
204
+ /** The decision for the target AFTER the action, or null when nothing was written. */
205
+ outcome: 'allow' | 'deny' | 'ask' | null
206
+ /**
207
+ * Nothing was written because the target was already allowed by the current
208
+ * decision, or because the file already leads with this exact allow rule.
209
+ */
210
+ alreadyAllowed: boolean
211
+ error: string | null
212
+ }
213
+
214
+ /** Strict wire schema for {@link AllowHostResult}. */
215
+ export const ALLOW_HOST_RESULT_SCHEMA = z.object({
216
+ ok: z.boolean(),
217
+ path: z.string().nullable(),
218
+ created: z.boolean(),
219
+ reloaded: z.number().int(),
220
+ outcome: z.union([z.literal('allow'), z.literal('deny'), z.literal('ask'), z.null()]),
221
+ alreadyAllowed: z.boolean(),
222
+ error: z.string().nullable(),
223
+ })
224
+
135
225
  /** The `permissionRules/networkStatus` invocation descriptor (no parameters). */
136
226
  export const NETWORK_STATUS_DESCRIPTOR = Object.freeze({
137
227
  id: 'dsh-permission-rules#permissionRules/networkStatus',
@@ -226,6 +316,34 @@ export const RULES_RELOAD_DESCRIPTOR = Object.freeze({
226
316
  sourceLocation: Object.freeze({ file: 'src/wire.ts', line: 1, column: 1 }),
227
317
  } as const) satisfies InvocationDescriptor
228
318
 
319
+ /**
320
+ * The `permissionRules/allowHost` invocation descriptor: one request object in,
321
+ * one result object out.
322
+ */
323
+ export const ALLOW_HOST_DESCRIPTOR = Object.freeze({
324
+ id: 'dsh-permission-rules#permissionRules/allowHost',
325
+ service: 'permissionRules',
326
+ namespace: 'permissionRules',
327
+ method: 'allowHost',
328
+ invocation: Object.freeze({ kind: 'direct' }),
329
+ parameters: Object.freeze([Object.freeze({
330
+ name: 'request',
331
+ wire: 'request',
332
+ source: 'json',
333
+ codec: Object.freeze({
334
+ mode: 'strict',
335
+ typeSymbol: 'dsh-permission-rules/types#AllowHostRequest',
336
+ schema: ALLOW_HOST_REQUEST_SCHEMA,
337
+ }),
338
+ } satisfies InvocationDescriptor['parameters'][number])]),
339
+ result: Object.freeze({
340
+ mode: 'strict',
341
+ typeSymbol: 'dsh-permission-rules/types#AllowHostResult',
342
+ schema: ALLOW_HOST_RESULT_SCHEMA,
343
+ }),
344
+ sourceLocation: Object.freeze({ file: 'src/wire.ts', line: 1, column: 1 }),
345
+ } as const) satisfies InvocationDescriptor
346
+
229
347
  /**
230
348
  * The canonical invocation list both Typert faces register — the host
231
349
  * manifest and the client contribution share these exact descriptor
@@ -236,4 +354,5 @@ export const PERMISSION_RULES_INVOCATIONS = Object.freeze([
236
354
  RULES_READ_DESCRIPTOR,
237
355
  RULES_SAVE_DESCRIPTOR,
238
356
  RULES_RELOAD_DESCRIPTOR,
357
+ ALLOW_HOST_DESCRIPTOR,
239
358
  ])