dsh-permission-rules 0.4.2 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.es.md +160 -117
  3. package/README.hi.md +162 -119
  4. package/README.md +133 -90
  5. package/README.pt.md +161 -118
  6. package/README.zh.md +162 -119
  7. package/docs/rule-test-vectors/README.md +28 -0
  8. package/docs/rule-test-vectors/corpus.json +109 -0
  9. package/lib/client.js +4693 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1530 -179
  12. package/lib/typert.host.js +4305 -0
  13. package/lib/types/client/index.d.ts +28 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/locales.d.ts +9 -0
  16. package/lib/types/client/locales.d.ts.map +1 -0
  17. package/lib/types/client/remote.d.ts +181 -0
  18. package/lib/types/client/remote.d.ts.map +1 -0
  19. package/lib/types/client/section.d.ts +35 -0
  20. package/lib/types/client/section.d.ts.map +1 -0
  21. package/lib/types/config.d.ts +54 -2
  22. package/lib/types/config.d.ts.map +1 -1
  23. package/lib/types/events.d.ts +45 -0
  24. package/lib/types/events.d.ts.map +1 -1
  25. package/lib/types/index.d.ts +8 -6
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/network.d.ts +90 -0
  28. package/lib/types/network.d.ts.map +1 -0
  29. package/lib/types/prose.d.ts +15 -0
  30. package/lib/types/prose.d.ts.map +1 -1
  31. package/lib/types/proxy.d.ts +128 -0
  32. package/lib/types/proxy.d.ts.map +1 -0
  33. package/lib/types/remote-service.d.ts +45 -0
  34. package/lib/types/remote-service.d.ts.map +1 -0
  35. package/lib/types/rules.d.ts +131 -0
  36. package/lib/types/rules.d.ts.map +1 -1
  37. package/lib/types/runtime.d.ts +153 -21
  38. package/lib/types/runtime.d.ts.map +1 -1
  39. package/lib/types/settings.d.ts +28 -0
  40. package/lib/types/settings.d.ts.map +1 -0
  41. package/lib/types/typert.host.d.ts +166 -0
  42. package/lib/types/typert.host.d.ts.map +1 -0
  43. package/lib/types/wire.d.ts +421 -0
  44. package/lib/types/wire.d.ts.map +1 -0
  45. package/package.json +182 -144
  46. package/scripts/repair-session-logs.mjs +7 -2
  47. package/src/client/index.ts +111 -0
  48. package/src/client/locales.ts +94 -0
  49. package/src/client/remote.ts +41 -0
  50. package/src/client/section.ts +201 -0
  51. package/src/config.ts +111 -0
  52. package/src/events.ts +46 -0
  53. package/src/index.ts +2 -0
  54. package/src/network.ts +177 -0
  55. package/src/prose.ts +85 -10
  56. package/src/proxy.ts +327 -0
  57. package/src/remote-service.ts +109 -0
  58. package/src/rules.ts +424 -5
  59. package/src/runtime.ts +464 -53
  60. package/src/settings.ts +74 -0
  61. package/src/typert.host.ts +25 -0
  62. package/src/wire.ts +239 -0
  63. package/lib/types/config.js +0 -99
  64. package/lib/types/config.js.map +0 -1
  65. package/lib/types/events.js +0 -44
  66. package/lib/types/events.js.map +0 -1
  67. package/lib/types/glob.js +0 -307
  68. package/lib/types/glob.js.map +0 -1
  69. package/lib/types/index.js +0 -27
  70. package/lib/types/index.js.map +0 -1
  71. package/lib/types/prose.js +0 -173
  72. package/lib/types/prose.js.map +0 -1
  73. package/lib/types/rules.js +0 -610
  74. package/lib/types/rules.js.map +0 -1
  75. package/lib/types/runtime.js +0 -818
  76. package/lib/types/runtime.js.map +0 -1
package/src/rules.ts CHANGED
@@ -19,6 +19,32 @@ export type PatternMode = 'glob' | 'regex'
19
19
  /** Closed action list for runtime normalization of untrusted values. */
20
20
  export const RULE_ACTIONS: readonly RuleAction[] = ['allow', 'deny', 'ask']
21
21
 
22
+ /** URL schemes a network rule may name (the proxy classifies CONNECT tunnels as `https`). */
23
+ export type SchemeName = 'http' | 'https'
24
+
25
+ /** Closed scheme list for runtime normalization of untrusted values. */
26
+ export const SCHEMES: readonly SchemeName[] = ['http', 'https']
27
+
28
+ /** Hostname characters that make a URL candidate parseable as a network target. */
29
+ export const URL_CANDIDATE_KEYS: readonly string[] = [
30
+ 'url',
31
+ 'urls',
32
+ 'uri',
33
+ 'endpoint',
34
+ 'base_url',
35
+ 'baseUrl',
36
+ 'webhook',
37
+ 'link',
38
+ 'href',
39
+ 'origin',
40
+ 'remote',
41
+ 'repository',
42
+ 'repo',
43
+ ]
44
+
45
+ /** Top-level argument keys whose string values hold a shell command (URL-scanned for shell tools). */
46
+ const COMMAND_CANDIDATE_KEYS: readonly string[] = ['command', 'cmd', 'script', 'command_line', 'commandLine']
47
+
22
48
  /** Platform names a `when.platform` entry may name (Node `process.platform` values). */
23
49
  export const PLATFORMS: readonly string[] = ['aix', 'android', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32']
24
50
 
@@ -78,6 +104,11 @@ export interface RuleMatchDoc {
78
104
  agents: string[]
79
105
  /** Environment/platform conditions; every listed env key must be present and match. */
80
106
  when: WhenDoc
107
+ /**
108
+ * Network scope, when this rule is a network rule; absent means the rule
109
+ * never evaluates URL/connection targets.
110
+ */
111
+ network?: NetworkDoc
81
112
  }
82
113
 
83
114
  /** The parsed `when` conditions. */
@@ -88,6 +119,41 @@ export interface WhenDoc {
88
119
  platform: string[]
89
120
  }
90
121
 
122
+ /**
123
+ * The parsed network match dimensions; a rule WITHOUT this block has no
124
+ * network scope (it stays a file/command rule exactly as before). A rule
125
+ * WITH this block is a network rule: it matches a tool call only when the
126
+ * call carries a URL candidate satisfying every listed dimension (AND),
127
+ * and it matches proxy-layer connections (shell subprocess traffic)
128
+ * against the connection target.
129
+ */
130
+ export interface NetworkDoc {
131
+ /**
132
+ * Domain patterns (lowercased; a trailing `.` is stripped). A pattern
133
+ * WITHOUT wildcards is subdomain-inclusive — `example.com` also matches
134
+ * `api.example.com` — while `*.example.com` matches subdomains only and
135
+ * `*`/`**` compile as ordinary globs. ANY pattern matching the target
136
+ * host satisfies the dimension.
137
+ */
138
+ domains: string[]
139
+ /**
140
+ * IP patterns: an exact IPv4/IPv6 literal, a glob (e.g. `10.0.*.*`), or
141
+ * an IPv4 CIDR (`10.0.0.0/8`). A literal IP in the URL is always a
142
+ * candidate; the proxy additionally resolves hostnames and tests their
143
+ * addresses, while `tools/pre-execute` matches literal IPs only (no DNS
144
+ * in the hot path). ANY match satisfies the dimension.
145
+ */
146
+ ips: string[]
147
+ /**
148
+ * Port patterns: `*`, one port (`443`), or an inclusive range
149
+ * (`8000-9000`). Evaluated against the effective port (URL port, else
150
+ * 80/443 by scheme). ANY match satisfies the dimension.
151
+ */
152
+ ports: string[]
153
+ /** Schemes; the target scheme must be one of them (ANY). */
154
+ schemes: SchemeName[]
155
+ }
156
+
91
157
  /** One compiled rule, ready for the `tools/pre-execute` hot path. */
92
158
  export interface CompiledRule {
93
159
  /** 0-based position in the merged rule chain. */
@@ -110,10 +176,34 @@ export interface CompiledRule {
110
176
  readonly env: ReadonlyMap<string, readonly RegExp[]>
111
177
  readonly platform: readonly string[]
112
178
  }
179
+ /** Compiled network dimensions, when this rule has network scope. */
180
+ readonly network?: CompiledNetwork
113
181
  /** The original source strings, kept for the `/rules` display. */
114
182
  readonly source: RuleDocEntry
115
183
  }
116
184
 
185
+ /** One compiled port pattern: an inclusive range; `max: Infinity` is the `*` catch-all. */
186
+ export interface CompiledPortRange {
187
+ readonly min: number
188
+ readonly max: number
189
+ }
190
+
191
+ /** One compiled IP pattern: a glob RegExp, an IPv4 CIDR block, or an exact literal (IPv6/odd shapes). */
192
+ export interface CompiledIpPattern {
193
+ readonly regex?: RegExp
194
+ readonly cidr?: { readonly network: number; readonly prefix: number }
195
+ readonly literal?: string
196
+ }
197
+
198
+ /** The compiled network dimensions of one rule (absent block = no network scope). */
199
+ export interface CompiledNetwork {
200
+ /** Subdomain-inclusive exact domains and glob domains, all anchored + case-insensitive. */
201
+ readonly domains: readonly RegExp[]
202
+ readonly ips: readonly CompiledIpPattern[]
203
+ readonly ports: readonly CompiledPortRange[]
204
+ readonly schemes: readonly SchemeName[]
205
+ }
206
+
117
207
  /** A compiled, size-capped ruleset. */
118
208
  export interface CompiledRuleset {
119
209
  readonly rules: readonly CompiledRule[]
@@ -247,6 +337,7 @@ function compileRule(entry: RuleDocEntry, index: number, sourceIndex: number, op
247
337
  paths,
248
338
  absent: entry.match.absent,
249
339
  ...(env.size === 0 && entry.match.when.platform.length === 0 ? {} : { when: { env, platform: entry.match.when.platform } }),
340
+ ...(entry.match.network === undefined ? {} : { network: compileNetwork(entry.match.network, options.maxGlobStars) }),
250
341
  source: entry,
251
342
  }
252
343
  }
@@ -286,6 +377,7 @@ export function matchRules(
286
377
  if (rule.params.size > 0 && (argRecord === undefined || !matchParams(rule, argRecord))) continue
287
378
  if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue
288
379
  if (rule.paths.length > 0 && (argRecord === undefined || !matchPaths(rule, argRecord, cwd, ruleset.caseInsensitivePaths))) continue
380
+ if (rule.network !== undefined && (argRecord === undefined || !matchNetwork(rule, argRecord))) continue
289
381
  return { ruleIndex: rule.index, rule }
290
382
  }
291
383
  return undefined
@@ -315,7 +407,7 @@ export function findUnreachableRules(ruleset: CompiledRuleset): number[] {
315
407
 
316
408
  /** Whether every match dimension of one rule is empty. */
317
409
  function isCatchAll(rule: CompiledRule): boolean {
318
- return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === undefined
410
+ return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === undefined && rule.network === undefined
319
411
  }
320
412
 
321
413
  /**
@@ -345,6 +437,14 @@ export function describeRule(rule: CompiledRule, tokens: DescribeTokens, source?
345
437
  .join(' ')
346
438
  const platformPart = rule.source.match.when.platform.length > 0 ? `${tokens.platform}:${rule.source.match.when.platform.join(',')}` : ''
347
439
  if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? ' ' : ''}${platformPart}`)
440
+ const networkParts: string[] = []
441
+ if (rule.source.match.network !== undefined) {
442
+ if (rule.source.match.network.domains.length > 0) networkParts.push(`${tokens.domains}:${rule.source.match.network.domains.join(',')}`)
443
+ if (rule.source.match.network.ips.length > 0) networkParts.push(`${tokens.ips}:${rule.source.match.network.ips.join(',')}`)
444
+ if (rule.source.match.network.ports.length > 0) networkParts.push(`${tokens.ports}:${rule.source.match.network.ports.join(',')}`)
445
+ if (rule.source.match.network.schemes.length > 0) networkParts.push(`${tokens.schemes}:${rule.source.match.network.schemes.join(',')}`)
446
+ }
447
+ if (networkParts.length > 0) parts.push(`${tokens.network}:${networkParts.join(' ')}`)
348
448
  const match = parts.length > 0 ? `[${parts.join(' ')}]` : `[${tokens.allTools}]`
349
449
  const disabled = rule.enabled ? '' : ` (${tokens.disabled})`
350
450
  const sourcePart = source !== undefined && source.length > 0 ? ` [${tokens.src}:${source}]` : ''
@@ -363,6 +463,11 @@ export interface DescribeTokens {
363
463
  readonly absent: string
364
464
  readonly when: string
365
465
  readonly platform: string
466
+ readonly network: string
467
+ readonly domains: string
468
+ readonly ips: string
469
+ readonly ports: string
470
+ readonly schemes: string
366
471
  readonly disabled: string
367
472
  readonly tags: string
368
473
  /** Prefix of the per-rule source-file attribution. */
@@ -375,7 +480,7 @@ function truncate(text: string, limit: number): string {
375
480
  }
376
481
 
377
482
  /** Whether a rule's tools dimension selects the tool. */
378
- function matchTools(rule: CompiledRule, toolName: string): boolean {
483
+ export function matchTools(rule: CompiledRule, toolName: string): boolean {
379
484
  if (rule.tools.length === 0) return true
380
485
  return rule.tools.some(regex => regex.test(toolName))
381
486
  }
@@ -415,7 +520,7 @@ function matchAbsent(rule: CompiledRule, argRecord: Record<string, unknown> | un
415
520
  }
416
521
 
417
522
  /** Whether a rule's when dimension holds for the host facts. */
418
- function matchWhen(rule: CompiledRule, context: MatchContext): boolean {
523
+ export function matchWhen(rule: CompiledRule, context: MatchContext): boolean {
419
524
  const when = rule.when
420
525
  if (when === undefined) return true
421
526
  if (when.platform.length > 0 && !when.platform.includes(context.platform ?? process.platform)) return false
@@ -517,9 +622,9 @@ function parseRuleEntry(raw: unknown, index: number): RuleDocEntry {
517
622
  function parseMatch(raw: unknown, at: string): RuleMatchDoc {
518
623
  if (raw === undefined) return { tools: [], agents: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
519
624
  const record = asRecord(raw, `${at}.match`)
520
- const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'agents' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
625
+ const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'agents' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when' && key !== 'network')
521
626
  if (unknownFields.length > 0) {
522
- throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: tools, agents, params, paths, absent, when)`)
627
+ throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: tools, agents, params, paths, absent, when, network)`)
523
628
  }
524
629
  return {
525
630
  tools: parseStringList(record['tools'], `${at}.match.tools`),
@@ -528,6 +633,7 @@ function parseMatch(raw: unknown, at: string): RuleMatchDoc {
528
633
  paths: parseStringList(record['paths'], `${at}.match.paths`),
529
634
  absent: parseStringList(record['absent'], `${at}.match.absent`),
530
635
  when: parseWhen(record['when'], `${at}.match.when`),
636
+ ...(record['network'] === undefined ? {} : { network: parseNetwork(record['network'], `${at}.match.network`) }),
531
637
  }
532
638
  }
533
639
 
@@ -573,6 +679,39 @@ function parseWhen(raw: unknown, at: string): WhenDoc {
573
679
  return { env, platform }
574
680
  }
575
681
 
682
+ /**
683
+ * Validate the `network` block of one rule. Every listed pattern is
684
+ * shape-validated here (port ranges, CIDR strings, closed scheme
685
+ * vocabulary); compilation of globs happens in {@link compileNetwork}.
686
+ * Scalar values (YAML numbers like `443` for ports) are stringified like
687
+ * every other pattern list, so numeric ports parse as their decimal
688
+ * strings. A block with every dimension empty is rejected — a network
689
+ * rule must name at least one target dimension.
690
+ */
691
+ function parseNetwork(raw: unknown, at: string): NetworkDoc {
692
+ const record = asRecord(raw, at)
693
+ const unknownFields = Object.keys(record).filter(key => key !== 'domains' && key !== 'ips' && key !== 'ports' && key !== 'schemes')
694
+ if (unknownFields.length > 0) {
695
+ throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: domains, ips, ports, schemes)`)
696
+ }
697
+ const domains = parsePatternValues(record['domains'], `${at}.domains`)
698
+ const ips = parsePatternValues(record['ips'], `${at}.ips`)
699
+ for (const pattern of ips) parseIpPattern(pattern, `${at}.ips`)
700
+ const ports = parsePatternValues(record['ports'], `${at}.ports`)
701
+ for (const pattern of ports) parsePortPattern(pattern, `${at}.ports`)
702
+ const schemes: SchemeName[] = []
703
+ for (const [index, value] of parsePatternValues(record['schemes'], `${at}.schemes`).entries()) {
704
+ if (!SCHEMES.includes(value as SchemeName)) {
705
+ throw new RuleError(`${at}.schemes[${index}] must be one of ${SCHEMES.map(s => JSON.stringify(s)).join(' | ')}, got ${JSON.stringify(value)}`)
706
+ }
707
+ schemes.push(value as SchemeName)
708
+ }
709
+ if (domains.length === 0 && ips.length === 0 && ports.length === 0 && schemes.length === 0) {
710
+ throw new RuleError(`${at}: a network block must name at least one of domains, ips, ports, schemes (drop the block for a tool-level rule)`)
711
+ }
712
+ return { domains, ips, ports, schemes }
713
+ }
714
+
576
715
  /**
577
716
  * Validate one pattern or a pattern list; scalar strings/numbers/booleans
578
717
  * are single patterns (matching the runtime scalar-stringify semantics).
@@ -675,6 +814,286 @@ export function extractPathCandidates(args: Record<string, unknown>): string[] {
675
814
  return candidates
676
815
  }
677
816
 
817
+ // --- Network targets -------------------------------------------------------
818
+
819
+ /** One parsed network target (a URL candidate or a proxy connection target). */
820
+ export interface NetworkTarget {
821
+ /** `http` or `https`, or undefined for scheme-less host candidates. */
822
+ readonly scheme: SchemeName | undefined
823
+ /** Lowercased hostname (brackets stripped for IPv6) or literal IP, trailing dot stripped. */
824
+ readonly host: string
825
+ /** Effective port: URL port, else 80/443 by scheme; undefined for bare hostnames. */
826
+ readonly port: number | undefined
827
+ /** Literal IP when `host` is one; the proxy appends resolved addresses here. */
828
+ readonly ips: readonly string[]
829
+ }
830
+
831
+ /** One regex the command-scan extracts URL substrings with (embedded URLs in shell command text). */
832
+ const EMBEDDED_URL = /https?:\/\/[^\s"'<>)\]\\]+/g
833
+
834
+ /**
835
+ * Whether the tool is a shell tool: its `command` argument is scanned for
836
+ * embedded URLs. Every other tool contributes URL candidates only through
837
+ * {@link URL_CANDIDATE_KEYS}.
838
+ */
839
+ export function isShellTool(toolName: string): boolean {
840
+ return toolName === 'bash' || toolName === 'pwsh'
841
+ }
842
+
843
+ /**
844
+ * Collect URL-candidate strings from tool arguments: values under the
845
+ * {@link URL_CANDIDATE_KEYS} at ANY nesting depth (capped like path
846
+ * extraction), plus embedded `http(s)://` substrings of command-shaped
847
+ * arguments (shell command text). Deterministic: own key order, duplicates
848
+ * kept.
849
+ * @param args - the parsed tool arguments.
850
+ * @returns raw candidate strings, each already an http(s) URL or a bare host.
851
+ */
852
+ export function extractUrlCandidates(args: Record<string, unknown>): string[] {
853
+ const candidates: string[] = []
854
+ const walk = (node: unknown, depth: number): void => {
855
+ if (depth > MAX_ARGUMENT_DEPTH) return
856
+ if (Array.isArray(node)) {
857
+ for (const element of node) walk(element, depth + 1)
858
+ return
859
+ }
860
+ if (typeof node !== 'object' || node === null) return
861
+ for (const [key, value] of Object.entries(node)) {
862
+ if (URL_CANDIDATE_KEYS.includes(key)) {
863
+ if (typeof value === 'string') candidates.push(value)
864
+ else if (Array.isArray(value)) {
865
+ for (const element of value) {
866
+ if (typeof element === 'string') candidates.push(element)
867
+ }
868
+ }
869
+ }
870
+ if (COMMAND_CANDIDATE_KEYS.includes(key) && typeof value === 'string') {
871
+ for (const match of value.matchAll(EMBEDDED_URL)) candidates.push(match[0])
872
+ }
873
+ walk(value, depth + 1)
874
+ }
875
+ }
876
+ walk(args, 0)
877
+ return candidates
878
+ }
879
+
880
+ /**
881
+ * Parse one raw candidate into a {@link NetworkTarget}. Full `http(s)://`
882
+ * URLs parse through WHATWG URL semantics; a bare `host[:port]` candidate
883
+ * (no spaces, no slashes) becomes a scheme-less target (the `scheme` key
884
+ * is present with value `undefined`). Anything else yields `undefined` —
885
+ * a rule simply cannot match a non-target candidate.
886
+ * @param raw - the candidate string.
887
+ * @returns the parsed target, or undefined.
888
+ */
889
+ export function parseUrlTarget(raw: string): NetworkTarget | undefined {
890
+ const text = raw.trim()
891
+ if (text.length === 0 || /\s/.test(text)) return undefined
892
+ const parsed = tryParseHttpUrl(text)
893
+ if (parsed !== undefined) return parsed
894
+ if (!/^[A-Za-z0-9._[\]-]+(?::\d+)?$/.test(text)) return undefined
895
+ const colon = text.lastIndexOf(':')
896
+ const host = normalizeHost(colon < 0 ? text : text.slice(0, colon))
897
+ if (host.length === 0) return undefined
898
+ const port = colon < 0 ? undefined : parsePortNumber(text.slice(colon + 1))
899
+ if (colon >= 0 && port === undefined) return undefined
900
+ return { scheme: undefined, host, port, ips: literalIpOf(host) }
901
+ }
902
+
903
+ /** Parse a full http(s) URL, or undefined when it is not one. */
904
+ function tryParseHttpUrl(text: string): NetworkTarget | undefined {
905
+ let url: URL
906
+ try {
907
+ url = new URL(text)
908
+ } catch {
909
+ // WHATWG URL rejects UNBRACKETED IPv6 hosts (`http://::1`); rewrite the
910
+ // literal into brackets and re-parse so bare IPv6 targets behave like
911
+ // their bracketed forms.
912
+ const bare = /^(https?):\/\/([0-9a-fA-F:]+)(?::(\d+))?(\/.*)?$/.exec(text)
913
+ if (bare === null || bare[2] === undefined || bare[2].split(':').length < 3) return undefined
914
+ const rewritten = `${bare[1]}://[${bare[2]}]${bare[3] !== undefined ? `:${bare[3]}` : ''}${bare[4] ?? ''}`
915
+ try {
916
+ url = new URL(rewritten)
917
+ } catch {
918
+ return undefined
919
+ }
920
+ }
921
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
922
+ const host = normalizeHost(url.hostname)
923
+ if (host.length === 0) return undefined
924
+ const port = url.port.length > 0 ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
925
+ return { scheme: url.protocol === 'https:' ? 'https' : 'http', host, port, ips: literalIpOf(host) }
926
+ }
927
+
928
+ /** Lowercase, strip IPv6 brackets, strip one trailing dot. */
929
+ function normalizeHost(host: string): string {
930
+ let out = host.toLowerCase().replace(/^\[|\]$/g, '')
931
+ while (out.endsWith('.')) out = out.slice(0, -1)
932
+ return out
933
+ }
934
+
935
+ /** The literal IP of a host, or `[]` when the host is a name. */
936
+ function literalIpOf(host: string): readonly string[] {
937
+ return isIpLiteral(host) ? [host.toLowerCase()] : []
938
+ }
939
+
940
+ /** Whether a host string is an IP literal (IPv4 or IPv6). */
941
+ export function isIpLiteral(host: string): boolean {
942
+ return /^[0-9.]+$/.test(host) ? /^\d{1,3}(\.\d{1,3}){3}$/.test(host) : host.includes(':')
943
+ }
944
+
945
+ /** Parse `80` or `8000-9000` or `*`; invalid shapes throw at load. */
946
+ function parsePortPattern(pattern: string, at: string): void {
947
+ if (pattern === '*') return
948
+ const single = /^\d+$/.exec(pattern)
949
+ if (single !== null) {
950
+ const port = Number(single[0])
951
+ if (port > 65535) throw new RuleError(`${at}: port ${JSON.stringify(pattern)} is out of range (0-65535)`)
952
+ return
953
+ }
954
+ const range = /^(\d+)-(\d+)$/.exec(pattern)
955
+ if (range === null) throw new RuleError(`${at}: port ${JSON.stringify(pattern)} must be "*", a single port, or a low-high range like "8000-9000"`)
956
+ const low = Number(range[1])
957
+ const high = Number(range[2])
958
+ if (low > high || low > 65535 || high > 65535) throw new RuleError(`${at}: port range ${JSON.stringify(pattern)} is invalid (0-65535, low <= high)`)
959
+ }
960
+
961
+ /** Validate one IP pattern shape (exact literal, glob, or IPv4 CIDR). */
962
+ function parseIpPattern(pattern: string, at: string): void {
963
+ if (pattern.length === 0) throw new RuleError(`${at}: IP patterns must be non-empty`)
964
+ const cidr = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(pattern)
965
+ if (cidr !== null) {
966
+ const octets = cidr.slice(1, 5).map(Number)
967
+ if (octets.some(octet => octet > 255) || Number(cidr[5]) > 32) {
968
+ throw new RuleError(`${at}: CIDR ${JSON.stringify(pattern)} is invalid (octets 0-255, prefix 0-32)`)
969
+ }
970
+ }
971
+ }
972
+
973
+ /** Parse one port pattern into its compiled range; shape-validated at parse time. */
974
+ function compilePortPattern(pattern: string): CompiledPortRange {
975
+ if (pattern === '*') return { min: 0, max: Infinity }
976
+ const single = /^\d+$/.exec(pattern)
977
+ if (single !== null) {
978
+ const port = Number(single[0])
979
+ return { min: port, max: port }
980
+ }
981
+ const range = /^(\d+)-(\d+)$/.exec(pattern)
982
+ const low = Number(range?.[1] ?? 0)
983
+ const high = Number(range?.[2] ?? 0)
984
+ return { min: low, max: high }
985
+ }
986
+
987
+ /** Compile one IP pattern: CIDR first, then glob; exact literals glob-compile to themselves. */
988
+ function compileIpPattern(pattern: string, maxGlobStars: number): CompiledIpPattern {
989
+ const cidr = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(pattern)
990
+ if (cidr !== null) {
991
+ const octets = cidr.slice(1, 5).map(Number) as [number, number, number, number]
992
+ const prefix = Number(cidr[5])
993
+ const network = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0
994
+ return { cidr: { network, prefix } }
995
+ }
996
+ if (!/[[*?]/.test(pattern) && !pattern.includes(':')) {
997
+ return { literal: pattern.toLowerCase() }
998
+ }
999
+ return { regex: compileGlob(pattern, { segments: false, maxStars: maxGlobStars, caseInsensitive: true }) }
1000
+ }
1001
+
1002
+ /**
1003
+ * Compile one domain pattern. A pattern without glob metacharacters is
1004
+ * subdomain-inclusive (Codex semantics: `example.com` also matches
1005
+ * `api.example.com`); anything with `*`/`?`/`[` compiles as a
1006
+ * case-insensitive glob (so `*.example.com` matches subdomains only).
1007
+ */
1008
+ function compileDomainPattern(pattern: string, maxGlobStars: number): RegExp {
1009
+ const normalized = normalizeHost(pattern)
1010
+ if (normalized.length === 0) throw new RuleError(`network domain patterns must be non-empty, got ${JSON.stringify(pattern)}`)
1011
+ if (/[*?[\]\\]/.test(normalized)) {
1012
+ return compileGlob(normalized, { segments: false, maxStars: maxGlobStars, caseInsensitive: true })
1013
+ }
1014
+ const literal = normalized.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
1015
+ return new RegExp(`^(?:[A-Za-z0-9_-]+\\.)*${literal}$`, 'iu')
1016
+ }
1017
+
1018
+ /** Compile the network block of one rule; pattern errors throw at load. */
1019
+ function compileNetwork(doc: NetworkDoc, maxGlobStars: number): CompiledNetwork {
1020
+ return {
1021
+ domains: doc.domains.map(pattern => compileDomainPattern(pattern, maxGlobStars)),
1022
+ ips: doc.ips.map(pattern => compileIpPattern(pattern, maxGlobStars)),
1023
+ ports: doc.ports.map(compilePortPattern),
1024
+ schemes: doc.schemes,
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Whether a rule's network dimension holds for a tool call: at least one
1030
+ * URL candidate must parse into a target satisfying EVERY listed
1031
+ * dimension. Calls without any URL candidate can never satisfy a
1032
+ * network-scoped rule (like the paths dimension).
1033
+ */
1034
+ function matchNetwork(rule: CompiledRule, args: Record<string, unknown>): boolean {
1035
+ const network = rule.network
1036
+ if (network === undefined) return true
1037
+ const targets = extractUrlCandidates(args)
1038
+ .map(parseUrlTarget)
1039
+ .filter((target): target is NetworkTarget => target !== undefined)
1040
+ return targets.some(target => targetMatchesNetwork(target, network))
1041
+ }
1042
+
1043
+ /**
1044
+ * Evaluate one target against one compiled network block (pure; shared by
1045
+ * the pre-execute path and the proxy layer). Domain patterns are tested
1046
+ * against the host string as-is — including literal-IP hosts, so a
1047
+ * catch-all glob like `*` matches `127.0.0.1` too (Codex parity keeps
1048
+ * loopback reachable only via the `loopback` short-circuit, never by
1049
+ * name matching). Literal-IP hosts are tested against `ips` patterns
1050
+ * directly; `target.ips` may carry additional resolved addresses
1051
+ * supplied by the proxy.
1052
+ * @param target - the parsed target.
1053
+ * @param network - the compiled network dimensions.
1054
+ * @returns true when EVERY listed dimension holds.
1055
+ */
1056
+ export function targetMatchesNetwork(target: NetworkTarget, network: CompiledNetwork): boolean {
1057
+ if (network.domains.length > 0) {
1058
+ if (!network.domains.some(regex => regex.test(target.host))) return false
1059
+ }
1060
+ if (network.ips.length > 0) {
1061
+ const candidates = [...target.ips]
1062
+ if (!network.ips.some(pattern => candidates.some(candidate => ipMatches(pattern, candidate)))) return false
1063
+ }
1064
+ if (network.ports.length > 0) {
1065
+ const port = target.port
1066
+ if (port === undefined || !network.ports.some(range => port >= range.min && port <= range.max)) return false
1067
+ }
1068
+ if (network.schemes.length > 0) {
1069
+ if (target.scheme === undefined || !network.schemes.includes(target.scheme)) return false
1070
+ }
1071
+ return true
1072
+ }
1073
+
1074
+ /** Whether one compiled IP pattern matches one candidate address (lowercased, unbracketed). */
1075
+ function ipMatches(pattern: CompiledIpPattern, candidate: string): boolean {
1076
+ const normalized = normalizeHost(candidate)
1077
+ if (pattern.cidr !== undefined) {
1078
+ const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized)
1079
+ if (parts === null) return false
1080
+ const octets = parts.slice(1, 5).map(Number) as [number, number, number, number]
1081
+ if (octets.some(octet => octet > 255)) return false
1082
+ const value = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0
1083
+ const mask = pattern.cidr.prefix === 0 ? 0 : (0xffffffff << (32 - pattern.cidr.prefix)) >>> 0
1084
+ return (value & mask) === (pattern.cidr.network & mask)
1085
+ }
1086
+ if (pattern.literal !== undefined) return normalized === pattern.literal
1087
+ return pattern.regex?.test(normalized) ?? false
1088
+ }
1089
+
1090
+ /** Parse one explicit port number, or undefined when out of range. */
1091
+ function parsePortNumber(text: string): number | undefined {
1092
+ if (!/^\d+$/.test(text)) return undefined
1093
+ const port = Number(text)
1094
+ return port <= 65535 ? port : undefined
1095
+ }
1096
+
678
1097
  /**
679
1098
  * Normalize one candidate path against the workspace root: separators
680
1099
  * become `/`, `./` prefixes drop, and in-root paths become workspace