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.
- package/CHANGELOG.md +34 -0
- package/README.es.md +160 -117
- package/README.hi.md +162 -119
- package/README.md +133 -90
- package/README.pt.md +161 -118
- package/README.zh.md +162 -119
- package/docs/rule-test-vectors/README.md +28 -0
- package/docs/rule-test-vectors/corpus.json +109 -0
- package/lib/client.js +4693 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1530 -179
- package/lib/typert.host.js +4305 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +9 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +181 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/section.d.ts +35 -0
- package/lib/types/client/section.d.ts.map +1 -0
- package/lib/types/config.d.ts +54 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/events.d.ts +45 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/network.d.ts +90 -0
- package/lib/types/network.d.ts.map +1 -0
- package/lib/types/prose.d.ts +15 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +128 -0
- package/lib/types/proxy.d.ts.map +1 -0
- package/lib/types/remote-service.d.ts +45 -0
- package/lib/types/remote-service.d.ts.map +1 -0
- package/lib/types/rules.d.ts +131 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +153 -21
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +166 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +421 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +182 -144
- package/scripts/repair-session-logs.mjs +7 -2
- package/src/client/index.ts +111 -0
- package/src/client/locales.ts +94 -0
- package/src/client/remote.ts +41 -0
- package/src/client/section.ts +201 -0
- package/src/config.ts +111 -0
- package/src/events.ts +46 -0
- package/src/index.ts +2 -0
- package/src/network.ts +177 -0
- package/src/prose.ts +85 -10
- package/src/proxy.ts +327 -0
- package/src/remote-service.ts +109 -0
- package/src/rules.ts +424 -5
- package/src/runtime.ts +464 -53
- package/src/settings.ts +74 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +239 -0
- package/lib/types/config.js +0 -99
- package/lib/types/config.js.map +0 -1
- package/lib/types/events.js +0 -44
- package/lib/types/events.js.map +0 -1
- package/lib/types/glob.js +0 -307
- package/lib/types/glob.js.map +0 -1
- package/lib/types/index.js +0 -27
- package/lib/types/index.js.map +0 -1
- package/lib/types/prose.js +0 -173
- package/lib/types/prose.js.map +0 -1
- package/lib/types/rules.js +0 -610
- package/lib/types/rules.js.map +0 -1
- package/lib/types/runtime.js +0 -818
- 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
|