dsh-perm-gate 2.1.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +347 -0
  4. package/INSTALL.ja.md +84 -8
  5. package/INSTALL.ko.md +80 -9
  6. package/INSTALL.md +83 -8
  7. package/INSTALL.zh.md +70 -7
  8. package/README.ja.md +130 -16
  9. package/README.ko.md +128 -17
  10. package/README.md +179 -25
  11. package/README.zh.md +121 -12
  12. package/assets/allow1.png +0 -0
  13. package/assets/allow2.png +0 -0
  14. package/assets/gateoff.png +0 -0
  15. package/assets/llms.png +0 -0
  16. package/assets/permlist.png +0 -0
  17. package/assets/setting-head.png +0 -0
  18. package/assets/setting-rules.png +0 -0
  19. package/cordis.patch.yml +37 -3
  20. package/docs/baseline-guard.md +56 -0
  21. package/docs/plan-rules-to-settings.md +217 -0
  22. package/docs/rules-format.en.md +294 -0
  23. package/docs/rules-format.md +279 -0
  24. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  25. package/dsh.plugin.json +22 -0
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/agent-identity.d.ts +43 -0
  28. package/lib/agent-identity.js +95 -0
  29. package/lib/allowlist.d.ts +27 -0
  30. package/lib/allowlist.js +103 -1
  31. package/lib/cli.js +11 -14
  32. package/lib/client.js +598 -6
  33. package/lib/client.js.map +1 -1
  34. package/lib/command-dispatcher.d.ts +22 -0
  35. package/lib/command-dispatcher.js +97 -0
  36. package/lib/command-semantics.d.ts +62 -0
  37. package/lib/command-semantics.js +6 -0
  38. package/lib/compiler.d.ts +50 -0
  39. package/lib/compiler.js +122 -0
  40. package/lib/config.d.ts +161 -7
  41. package/lib/config.js +78 -11
  42. package/lib/dry-run.d.ts +87 -0
  43. package/lib/dry-run.js +110 -0
  44. package/lib/engine.js +61 -4
  45. package/lib/evaluate.d.ts +9 -0
  46. package/lib/evaluate.js +264 -1
  47. package/lib/events.d.ts +69 -2
  48. package/lib/events.js +123 -0
  49. package/lib/index.d.ts +1 -1
  50. package/lib/index.js +320 -11
  51. package/lib/network-lifecycle.d.ts +107 -0
  52. package/lib/network-lifecycle.js +180 -0
  53. package/lib/network.d.ts +109 -0
  54. package/lib/network.js +196 -0
  55. package/lib/parsers/git.d.ts +43 -0
  56. package/lib/parsers/git.js +404 -0
  57. package/lib/parsers/shell-cmds.d.ts +32 -0
  58. package/lib/parsers/shell-cmds.js +293 -0
  59. package/lib/preset.d.ts +13 -0
  60. package/lib/preset.js +17 -0
  61. package/lib/proxy.d.ts +160 -0
  62. package/lib/proxy.js +529 -0
  63. package/lib/risk.d.ts +17 -3
  64. package/lib/risk.js +19 -6
  65. package/lib/rule-chain.d.ts +39 -0
  66. package/lib/rule-chain.js +213 -0
  67. package/lib/rule-dims.d.ts +174 -0
  68. package/lib/rule-dims.js +230 -0
  69. package/lib/rule.d.ts +60 -0
  70. package/lib/rule.js +69 -7
  71. package/lib/rules-view.d.ts +52 -0
  72. package/lib/rules-view.js +126 -0
  73. package/lib/runtime.d.ts +208 -7
  74. package/lib/runtime.js +433 -27
  75. package/lib/session-sweep.d.ts +45 -0
  76. package/lib/session-sweep.js +153 -0
  77. package/lib/shadow.d.ts +34 -0
  78. package/lib/shadow.js +143 -0
  79. package/lib/shell.d.ts +14 -0
  80. package/lib/shell.js +17 -0
  81. package/lib/watch.d.ts +42 -0
  82. package/lib/watch.js +170 -0
  83. package/package.json +26 -3
  84. package/scripts/patch-permission-glyph.mjs +392 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Multi-file rule chain for dsh-perm-gate.
3
+ *
4
+ * Resolves and merges multiple YAML permissions documents into a single
5
+ * `CompiledRuleset`. The chain follows a search-up strategy: starting from
6
+ * the workspace cwd, walk up directory ancestors looking for the rules file,
7
+ * then fall back to a configured path, then to an empty ruleset.
8
+ *
9
+ * Each file in the chain is independently compiled and cached by content hash.
10
+ * Chain-level merge preserves the deny-first partitioned structure: all deny
11
+ * entries from all files are concatenated (with source file tracking), then
12
+ * all allow entries, then all ask entries.
13
+ *
14
+ * Error policy: `fail` (default) throws on any malformed file; `warn` logs
15
+ * and skips the bad file, keeping the rest of the chain.
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { existsSync, statSync } from 'node:fs';
20
+ import { parsePermissionsDocument, compileDocument, compileRuleEntry, documentHash } from './rule.js';
21
+ import { hashText } from './compiler.js';
22
+ /** Compile-cache entry: hash → compiled ruleset. */
23
+ const compileCache = new Map();
24
+ // ─── Chain resolution ──────────────────────────────────────────────────────
25
+ /**
26
+ * Resolve the rule chain: find all applicable rules files and merge them.
27
+ *
28
+ * @param cwd - The workspace root directory.
29
+ * @param config - Chain configuration.
30
+ * @param opts - Compile options forwarded to each file's compiler.
31
+ * @returns The merged compiled ruleset.
32
+ */
33
+ export function resolveRuleChain(cwd, config, opts = {}) {
34
+ const { rulesFile, searchUp = false, fallbackPath, badFilePolicy = 'fail', maxChainLength = 10, } = config;
35
+ const entries = findChainEntries(cwd, rulesFile, searchUp, fallbackPath, maxChainLength);
36
+ if (entries.length === 0) {
37
+ // Empty chain: return a ruleset with no rules (defaultAction only).
38
+ return {
39
+ defaultAction: 'ask',
40
+ deny: [],
41
+ allow: [],
42
+ ask: [],
43
+ caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
44
+ };
45
+ }
46
+ // Merge all entries into a single ruleset.
47
+ return mergeChain(entries, badFilePolicy, opts);
48
+ }
49
+ /**
50
+ * Walk the chain and collect valid rules file entries.
51
+ */
52
+ function findChainEntries(cwd, rulesFile, searchUp, fallbackPath, maxChainLength) {
53
+ const entries = [];
54
+ const seen = new Set();
55
+ // 1. Start from cwd and optionally search up.
56
+ const candidates = [];
57
+ let dir = resolve(cwd);
58
+ // Always check cwd first.
59
+ candidates.push(join(dir, rulesFile));
60
+ if (searchUp) {
61
+ // Walk up ancestors until root.
62
+ let prev = '';
63
+ while (dir !== prev && candidates.length < maxChainLength) {
64
+ candidates.push(join(dir, rulesFile));
65
+ prev = dir;
66
+ dir = dirname(dir);
67
+ }
68
+ }
69
+ for (const candidate of candidates) {
70
+ if (entries.length >= maxChainLength)
71
+ break;
72
+ const resolved = resolve(candidate);
73
+ if (seen.has(resolved))
74
+ continue;
75
+ seen.add(resolved);
76
+ if (!existsSafe(resolved))
77
+ continue;
78
+ const stat = statSafe(resolved);
79
+ if (stat === undefined || !stat.isFile())
80
+ continue;
81
+ const entry = readEntry(resolved);
82
+ if (entry !== undefined)
83
+ entries.push(entry);
84
+ }
85
+ // 2. Fallback path.
86
+ if (fallbackPath !== undefined && entries.length === 0) {
87
+ const resolved = resolve(fallbackPath);
88
+ if (!seen.has(resolved) && existsSafe(resolved)) {
89
+ const entry = readEntry(resolved);
90
+ if (entry !== undefined)
91
+ entries.push(entry);
92
+ }
93
+ }
94
+ return entries;
95
+ }
96
+ /**
97
+ * Read and parse a single rules file into a ChainEntry.
98
+ */
99
+ function readEntry(filePath) {
100
+ try {
101
+ const text = readFileSync(filePath, 'utf8');
102
+ const hash = documentHash(text);
103
+ const doc = parsePermissionsDocument(text);
104
+ return { path: filePath, hash, doc };
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * Merge a chain of entries into a single CompiledRuleset.
112
+ * Each file's entries are sorted into deny/allow/ask partitions.
113
+ * Chain-level order: deny (all files, in chain order) → allow → ask.
114
+ */
115
+ function mergeChain(entries, badFilePolicy, opts) {
116
+ // Collect entries per action, preserving chain order.
117
+ const denyDocs = [];
118
+ const allowDocs = [];
119
+ const askDocs = [];
120
+ for (const entry of entries) {
121
+ try {
122
+ // Compile to validate patterns (throws on malformed input).
123
+ compileDocument(entry.doc, opts);
124
+ // We use the raw doc lists for the merge (CompiledRuleset structure
125
+ // expects flat lists with source tracking).
126
+ denyDocs.push(entry.doc);
127
+ allowDocs.push(entry.doc);
128
+ askDocs.push(entry.doc);
129
+ }
130
+ catch (err) {
131
+ if (badFilePolicy === 'fail')
132
+ throw err;
133
+ // warn: skip this file silently.
134
+ }
135
+ }
136
+ // Flatten: deny entries from all files, then allow, then ask.
137
+ // Index numbering is sequential across the entire chain.
138
+ let index = 0;
139
+ const deny = flattenEntries(denyDocs, 'deny', index, opts);
140
+ index += deny.length;
141
+ const allow = flattenEntries(allowDocs, 'allow', index, opts);
142
+ index += allow.length;
143
+ const ask = flattenEntries(askDocs, 'ask', index, opts);
144
+ return {
145
+ defaultAction: entries.length > 0 ? entries[0].doc.defaultAction : 'ask',
146
+ deny,
147
+ allow,
148
+ ask,
149
+ caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
150
+ };
151
+ }
152
+ /**
153
+ * Flatten a list of PermissionDocs into a single list of CompiledRuleEntry
154
+ * for a specific action partition.
155
+ *
156
+ * Every entry goes through `compileRuleEntry` — the exact compiler the
157
+ * single-file path uses. Building entries by hand here previously left
158
+ * `tools`/`command`/`args`/`paths` as empty arrays, and an empty dimension
159
+ * means "no constraint", so EVERY entry matched EVERY call: the first deny
160
+ * entry denied everything in its partition and the first allow entry allowed
161
+ * everything before the `ask` partition was ever consulted.
162
+ */
163
+ function flattenEntries(docs, action, startIndex, opts) {
164
+ const result = [];
165
+ let idx = startIndex;
166
+ for (const doc of docs) {
167
+ const list = action === 'deny' ? doc.deny : action === 'allow' ? doc.allow : doc.ask;
168
+ for (const entry of list) {
169
+ result.push(compileRuleEntry(entry, action, idx++, opts));
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ // ─── Cache management ──────────────────────────────────────────────────────
175
+ /**
176
+ * Get or create a compiled ruleset from the cache.
177
+ * Returns the cached version if the hash matches; otherwise compiles fresh.
178
+ */
179
+ export function cachedCompile(text, opts = {}) {
180
+ const hash = hashText(text);
181
+ const cached = compileCache.get(hash);
182
+ if (cached !== undefined)
183
+ return cached;
184
+ const doc = parsePermissionsDocument(text);
185
+ const compiled = compileDocument(doc, opts);
186
+ compileCache.set(hash, compiled);
187
+ return compiled;
188
+ }
189
+ /** Clear the compile cache (used in tests and on reload). */
190
+ export function clearCompileCache() {
191
+ compileCache.clear();
192
+ }
193
+ /** Current compile cache size (diagnostics). */
194
+ export function compileCacheSize() {
195
+ return compileCache.size;
196
+ }
197
+ // ─── Helpers ───────────────────────────────────────────────────────────────
198
+ function existsSafe(path) {
199
+ try {
200
+ return existsSync(path);
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function statSafe(path) {
207
+ try {
208
+ return statSync(path);
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Extended rule dimensions for dsh-perm-gate.
3
+ *
4
+ * These 6 dimensions extend the existing 4 (tools/command/args/paths) and are
5
+ * parsed from each rule entry in the YAML permissions document. All types are
6
+ * pure — no I/O — so parsing and validation are unit-testable.
7
+ *
8
+ * Dimensions:
9
+ * 1. params — key→value glob matching (AND over keys, `!` prefix negates)
10
+ * 2. absent — parameter keys that must NOT be present
11
+ * 3. agents — agent identity candidates (main/subagent/preset:<name>)
12
+ * 4. when — environment / platform conditions
13
+ * 5. argv — extra argv patterns (pipeline, etc.) not covered by command/args
14
+ * 6. network — domain / IP / port / scheme matching
15
+ */
16
+ /**
17
+ * One parameter match condition: a key name mapped to a list of value glob
18
+ * patterns. The key's actual value must match at least one pattern (OR within
19
+ * the list). All keys must match (AND across keys).
20
+ *
21
+ * A pattern prefixed with `!` is negated: the value must NOT match the
22
+ * remainder of the pattern.
23
+ */
24
+ export interface ParamCondition {
25
+ /** The parameter key name (e.g. `command`, `flags.mode`). */
26
+ readonly key: string;
27
+ /** Value glob patterns; `!` prefix = negation. Empty list = key must exist (any value). */
28
+ readonly patterns: readonly string[];
29
+ /** Whether this condition is a negation (derived from `!` prefix on the sole pattern). */
30
+ readonly negated: boolean;
31
+ }
32
+ /**
33
+ * Parsed `params` dimension: a list of key→value conditions evaluated as AND.
34
+ */
35
+ export type ParamsDimension = readonly ParamCondition[];
36
+ /**
37
+ * Parse the `params` field from a rule entry.
38
+ *
39
+ * Format:
40
+ * ```yaml
41
+ * params:
42
+ * command: ["*--force*", "!*--dry-run*"]
43
+ * flags.mode: ["production"]
44
+ * ```
45
+ *
46
+ * Each key maps to a string-or-list of value glob patterns.
47
+ * A single `!`-prefixed pattern sets `negated: true`.
48
+ */
49
+ export declare function parseParamsDimension(raw: unknown, at: string): ParamsDimension;
50
+ /**
51
+ * Parsed `absent` dimension: parameter keys that must NOT be present
52
+ * in the tool call arguments. AND semantics — all listed keys must be absent.
53
+ */
54
+ export type AbsentDimension = readonly string[];
55
+ /**
56
+ * Parse the `absent` field: a list of parameter key names that must be absent.
57
+ */
58
+ export declare function parseAbsentDimension(raw: unknown, at: string): AbsentDimension;
59
+ /**
60
+ * Parsed `agents` dimension: identity candidates the rule applies to.
61
+ * Supported values: `main`, `subagent`, `preset:<name>`.
62
+ * Empty = this rule does not constrain on agent identity.
63
+ */
64
+ export type AgentsDimension = readonly string[];
65
+ /**
66
+ * Parse the `agents` field: a list of agent identity patterns.
67
+ */
68
+ export declare function parseAgentsDimension(raw: unknown, at: string): AgentsDimension;
69
+ /**
70
+ * Parsed `when` dimension: environment and platform conditions.
71
+ * All conditions are AND — every listed condition must be satisfied.
72
+ */
73
+ export interface WhenDimension {
74
+ /** Required environment variable values. Key = env var name, value = allowed values (OR). */
75
+ readonly env?: Readonly<Record<string, readonly string[]>>;
76
+ /** Required platform(s). Values are Node.js `process.platform` strings. */
77
+ readonly platform?: readonly string[];
78
+ /** Required Node.js version range (semver-like). Reserved for future use. */
79
+ readonly nodeVersion?: string;
80
+ }
81
+ /**
82
+ * Parse the `when` field: environment and platform conditions.
83
+ *
84
+ * Format:
85
+ * ```yaml
86
+ * when:
87
+ * env:
88
+ * NODE_ENV: ["production"]
89
+ * CI: ["true", "1"]
90
+ * platform: [linux, win32]
91
+ * ```
92
+ */
93
+ export declare function parseWhenDimension(raw: unknown, at: string): WhenDimension | undefined;
94
+ /**
95
+ * Parsed `argv` dimension: extra argv patterns not covered by command/args.
96
+ * Currently supports `pipeline` patterns (pipe chains like `curl|sh`).
97
+ */
98
+ export interface ArgvDimension {
99
+ /** Pipeline patterns: match the full command pipeline string. */
100
+ readonly pipeline?: readonly string[];
101
+ }
102
+ /**
103
+ * Parse the `argv` field: extra argv patterns.
104
+ *
105
+ * Format:
106
+ * ```yaml
107
+ * argv:
108
+ * pipeline: ["curl|sh", "wget|bash"]
109
+ * ```
110
+ */
111
+ export declare function parseArgvDimension(raw: unknown, at: string): ArgvDimension | undefined;
112
+ /**
113
+ * Parsed `network` dimension: domain / IP / port / scheme matching.
114
+ * All sub-dimensions are AND — every listed sub-dimension must match.
115
+ * Within a sub-dimension, entries are OR.
116
+ */
117
+ export interface NetworkDimension {
118
+ /** Domain patterns (glob-capable, e.g. `*.internal.corp`). */
119
+ readonly domains?: readonly string[];
120
+ /** IP/CIDR patterns (e.g. `10.0.0.0/8`, `192.168.1.1`). */
121
+ readonly ips?: readonly string[];
122
+ /** Port patterns (e.g. `443`, `8000-9000`). */
123
+ readonly ports?: readonly string[];
124
+ /** URL schemes (e.g. `https`, `http`). */
125
+ readonly schemes?: readonly string[];
126
+ }
127
+ /**
128
+ * Parse the `network` field: domain / IP / port / scheme matching.
129
+ *
130
+ * Format:
131
+ * ```yaml
132
+ * network:
133
+ * domains: ["*.internal.corp", "github.com"]
134
+ * ips: ["10.0.0.0/8"]
135
+ * ports: ["443", "8000-9000"]
136
+ * schemes: [https]
137
+ * ```
138
+ */
139
+ export declare function parseNetworkDimension(raw: unknown, at: string): NetworkDimension | undefined;
140
+ /**
141
+ * Parsed `branch` dimension: git branch / remote / shared-branch matching.
142
+ *
143
+ * Sub-dimensions are AND — every present sub-dimension must match. Within a
144
+ * sub-dimension, entries are OR.
145
+ *
146
+ * The candidates come from `dispatchCommand` (see `src/command-dispatcher.ts`)
147
+ * over each decomposed simple command, so `refspec` forms (`HEAD:main`) are
148
+ * already split and a remote name is never mistaken for a branch name.
149
+ *
150
+ * `shared` is **static**: it reflects the parser's protected-branch rule
151
+ * (`PROTECTED_BRANCHES` in `src/parsers/git.ts`: main / master / production /
152
+ * release / stable). It does **not** run git to discover whether a branch is
153
+ * genuinely shared — that would put a subprocess on the decision path.
154
+ */
155
+ export interface BranchDimension {
156
+ /** Branch-name globs (e.g. `main`, `release*`). */
157
+ readonly target?: readonly string[];
158
+ /** Remote-name globs (e.g. `origin`, `upstream`). */
159
+ readonly remote?: readonly string[];
160
+ /** Require the command to target a protected branch. */
161
+ readonly shared?: boolean;
162
+ }
163
+ /**
164
+ * Parse the `branch` field: branch / remote / protected-branch matching.
165
+ *
166
+ * Format:
167
+ * ```yaml
168
+ * branch:
169
+ * target: [main, "release*"]
170
+ * remote: [origin]
171
+ * shared: true
172
+ * ```
173
+ */
174
+ export declare function parseBranchDimension(raw: unknown, at: string): BranchDimension | undefined;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Extended rule dimensions for dsh-perm-gate.
3
+ *
4
+ * These 6 dimensions extend the existing 4 (tools/command/args/paths) and are
5
+ * parsed from each rule entry in the YAML permissions document. All types are
6
+ * pure — no I/O — so parsing and validation are unit-testable.
7
+ *
8
+ * Dimensions:
9
+ * 1. params — key→value glob matching (AND over keys, `!` prefix negates)
10
+ * 2. absent — parameter keys that must NOT be present
11
+ * 3. agents — agent identity candidates (main/subagent/preset:<name>)
12
+ * 4. when — environment / platform conditions
13
+ * 5. argv — extra argv patterns (pipeline, etc.) not covered by command/args
14
+ * 6. network — domain / IP / port / scheme matching
15
+ */
16
+ import { RuleError } from './rule.js';
17
+ /**
18
+ * Parse the `params` field from a rule entry.
19
+ *
20
+ * Format:
21
+ * ```yaml
22
+ * params:
23
+ * command: ["*--force*", "!*--dry-run*"]
24
+ * flags.mode: ["production"]
25
+ * ```
26
+ *
27
+ * Each key maps to a string-or-list of value glob patterns.
28
+ * A single `!`-prefixed pattern sets `negated: true`.
29
+ */
30
+ export function parseParamsDimension(raw, at) {
31
+ if (raw === undefined || raw === null)
32
+ return [];
33
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
34
+ throw new RuleError(`${at}.params must be a mapping`);
35
+ }
36
+ const result = [];
37
+ for (const [key, val] of Object.entries(raw)) {
38
+ if (key.length === 0)
39
+ throw new RuleError(`${at}.params has empty key`);
40
+ const patterns = toStringList(val, `${at}.params.${key}`);
41
+ const negated = patterns.length === 1 && patterns[0].startsWith('!');
42
+ result.push({ key, patterns, negated });
43
+ }
44
+ return result;
45
+ }
46
+ /**
47
+ * Parse the `absent` field: a list of parameter key names that must be absent.
48
+ */
49
+ export function parseAbsentDimension(raw, at) {
50
+ if (raw === undefined || raw === null)
51
+ return [];
52
+ const list = toStringList(raw, `${at}.absent`);
53
+ for (let i = 0; i < list.length; i++) {
54
+ if (list[i].length === 0)
55
+ throw new RuleError(`${at}.absent[${i}] must be a non-empty string`);
56
+ }
57
+ return list;
58
+ }
59
+ const VALID_AGENT_PATTERNS = /^(?:main|subagent|preset:.+)$/i;
60
+ /**
61
+ * Parse the `agents` field: a list of agent identity patterns.
62
+ */
63
+ export function parseAgentsDimension(raw, at) {
64
+ if (raw === undefined || raw === null)
65
+ return [];
66
+ const list = toStringList(raw, `${at}.agents`);
67
+ for (let i = 0; i < list.length; i++) {
68
+ const v = list[i];
69
+ if (v.length === 0)
70
+ throw new RuleError(`${at}.agents[${i}] must be a non-empty string`);
71
+ if (!VALID_AGENT_PATTERNS.test(v)) {
72
+ throw new RuleError(`${at}.agents[${i}] must be "main", "subagent", or "preset:<name>"`);
73
+ }
74
+ }
75
+ return list;
76
+ }
77
+ /**
78
+ * Parse the `when` field: environment and platform conditions.
79
+ *
80
+ * Format:
81
+ * ```yaml
82
+ * when:
83
+ * env:
84
+ * NODE_ENV: ["production"]
85
+ * CI: ["true", "1"]
86
+ * platform: [linux, win32]
87
+ * ```
88
+ */
89
+ export function parseWhenDimension(raw, at) {
90
+ if (raw === undefined || raw === null)
91
+ return undefined;
92
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
93
+ throw new RuleError(`${at}.when must be a mapping`);
94
+ }
95
+ const obj = raw;
96
+ const env_ = (() => {
97
+ if (obj.env === undefined)
98
+ return undefined;
99
+ if (typeof obj.env !== 'object' || Array.isArray(obj.env)) {
100
+ throw new RuleError(`${at}.when.env must be a mapping`);
101
+ }
102
+ const e = {};
103
+ for (const [k, v] of Object.entries(obj.env)) {
104
+ e[k] = toStringList(v, `${at}.when.env.${k}`);
105
+ }
106
+ return e;
107
+ })();
108
+ const platform_ = obj.platform !== undefined ? toStringList(obj.platform, `${at}.when.platform`) : undefined;
109
+ const nodeVersion_ = obj.nodeVersion !== undefined
110
+ ? (typeof obj.nodeVersion === 'string' && obj.nodeVersion.length > 0
111
+ ? obj.nodeVersion
112
+ : (() => { throw new RuleError(`${at}.when.nodeVersion must be a non-empty string`); })())
113
+ : undefined;
114
+ if (env_ === undefined && platform_ === undefined && nodeVersion_ === undefined)
115
+ return undefined;
116
+ const result = {
117
+ ...(env_ !== undefined && { env: env_ }),
118
+ ...(platform_ !== undefined && { platform: platform_ }),
119
+ ...(nodeVersion_ !== undefined && { nodeVersion: nodeVersion_ }),
120
+ };
121
+ return result;
122
+ }
123
+ /**
124
+ * Parse the `argv` field: extra argv patterns.
125
+ *
126
+ * Format:
127
+ * ```yaml
128
+ * argv:
129
+ * pipeline: ["curl|sh", "wget|bash"]
130
+ * ```
131
+ */
132
+ export function parseArgvDimension(raw, at) {
133
+ if (raw === undefined || raw === null)
134
+ return undefined;
135
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
136
+ throw new RuleError(`${at}.argv must be a mapping`);
137
+ }
138
+ const obj = raw;
139
+ const pipeline_ = obj.pipeline !== undefined ? toStringList(obj.pipeline, `${at}.argv.pipeline`) : undefined;
140
+ if (pipeline_ === undefined)
141
+ return undefined;
142
+ return { pipeline: pipeline_ };
143
+ }
144
+ /**
145
+ * Parse the `network` field: domain / IP / port / scheme matching.
146
+ *
147
+ * Format:
148
+ * ```yaml
149
+ * network:
150
+ * domains: ["*.internal.corp", "github.com"]
151
+ * ips: ["10.0.0.0/8"]
152
+ * ports: ["443", "8000-9000"]
153
+ * schemes: [https]
154
+ * ```
155
+ */
156
+ export function parseNetworkDimension(raw, at) {
157
+ if (raw === undefined || raw === null)
158
+ return undefined;
159
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
160
+ throw new RuleError(`${at}.network must be a mapping`);
161
+ }
162
+ const obj = raw;
163
+ const domains_ = obj.domains !== undefined ? toStringList(obj.domains, `${at}.network.domains`) : undefined;
164
+ const ips_ = obj.ips !== undefined ? toStringList(obj.ips, `${at}.network.ips`) : undefined;
165
+ const ports_ = obj.ports !== undefined ? toStringList(obj.ports, `${at}.network.ports`) : undefined;
166
+ const schemes_ = obj.schemes !== undefined ? toStringList(obj.schemes, `${at}.network.schemes`) : undefined;
167
+ if (domains_ === undefined && ips_ === undefined && ports_ === undefined && schemes_ === undefined)
168
+ return undefined;
169
+ const result = {
170
+ ...(domains_ !== undefined && { domains: domains_ }),
171
+ ...(ips_ !== undefined && { ips: ips_ }),
172
+ ...(ports_ !== undefined && { ports: ports_ }),
173
+ ...(schemes_ !== undefined && { schemes: schemes_ }),
174
+ };
175
+ return result;
176
+ }
177
+ /**
178
+ * Parse the `branch` field: branch / remote / protected-branch matching.
179
+ *
180
+ * Format:
181
+ * ```yaml
182
+ * branch:
183
+ * target: [main, "release*"]
184
+ * remote: [origin]
185
+ * shared: true
186
+ * ```
187
+ */
188
+ export function parseBranchDimension(raw, at) {
189
+ if (raw === undefined || raw === null)
190
+ return undefined;
191
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
192
+ throw new RuleError(`${at}.branch must be a mapping`);
193
+ }
194
+ const obj = raw;
195
+ const unknownKeys = Object.keys(obj).filter((k) => k !== 'target' && k !== 'remote' && k !== 'shared');
196
+ if (unknownKeys.length > 0) {
197
+ throw new RuleError(`${at}.branch unknown field${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => JSON.stringify(k)).join(', ')} (expected target / remote / shared)`);
198
+ }
199
+ const target_ = obj.target !== undefined ? toStringList(obj.target, `${at}.branch.target`) : undefined;
200
+ const remote_ = obj.remote !== undefined ? toStringList(obj.remote, `${at}.branch.remote`) : undefined;
201
+ if (obj.shared !== undefined && typeof obj.shared !== 'boolean') {
202
+ throw new RuleError(`${at}.branch.shared must be a boolean`);
203
+ }
204
+ const shared_ = obj.shared;
205
+ if (target_ === undefined && remote_ === undefined && shared_ === undefined)
206
+ return undefined;
207
+ return {
208
+ ...(target_ !== undefined && { target: target_ }),
209
+ ...(remote_ !== undefined && { remote: remote_ }),
210
+ ...(shared_ !== undefined && { shared: shared_ }),
211
+ };
212
+ }
213
+ // ─── Helpers ───────────────────────────────────────────────────────────────
214
+ /** Convert an unknown value to a string list (string → [string], array → filtered). */
215
+ function toStringList(value, at) {
216
+ if (value === undefined || value === null)
217
+ return [];
218
+ const list = typeof value === 'string' ? [value] : value;
219
+ if (!Array.isArray(list))
220
+ throw new RuleError(`${at} must be a string or list of strings`);
221
+ const result = [];
222
+ for (let i = 0; i < list.length; i++) {
223
+ const item = list[i];
224
+ if (typeof item !== 'string' || item.length === 0) {
225
+ throw new RuleError(`${at}[${i}] must be a non-empty string`);
226
+ }
227
+ result.push(item);
228
+ }
229
+ return result;
230
+ }