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.
- package/CHANGELOG.md +27 -0
- package/{README.es.md → README-es.md} +8 -3
- package/{README.hi.md → README-hi.md} +8 -3
- package/{README.pt.md → README-pt.md} +8 -3
- package/{README.zh.md → README-zh.md} +8 -3
- package/README.md +8 -3
- 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 +23 -23
- 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
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
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
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
|
])
|