dsh-perm-gate 2.1.2 → 3.0.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 (58) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +210 -0
  4. package/INSTALL.ja.md +14 -8
  5. package/INSTALL.ko.md +15 -9
  6. package/INSTALL.md +16 -8
  7. package/INSTALL.zh.md +12 -7
  8. package/README.ja.md +19 -6
  9. package/README.ko.md +20 -7
  10. package/README.md +35 -4
  11. package/README.zh.md +13 -4
  12. package/docs/baseline-guard.md +56 -0
  13. package/docs/plan-rules-to-settings.md +217 -0
  14. package/docs/rules-format.en.md +294 -0
  15. package/docs/rules-format.md +279 -0
  16. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  17. package/dsh.plugin.json +2 -2
  18. package/examples/permissions.example.yaml +16 -1
  19. package/lib/allowlist.d.ts +27 -0
  20. package/lib/allowlist.js +103 -1
  21. package/lib/cli.js +11 -14
  22. package/lib/client.js +484 -6
  23. package/lib/client.js.map +1 -1
  24. package/lib/command-dispatcher.d.ts +22 -0
  25. package/lib/command-dispatcher.js +97 -0
  26. package/lib/command-semantics.d.ts +62 -0
  27. package/lib/command-semantics.js +6 -0
  28. package/lib/compiler.d.ts +11 -0
  29. package/lib/compiler.js +16 -0
  30. package/lib/config.d.ts +73 -12
  31. package/lib/config.js +56 -14
  32. package/lib/dry-run.d.ts +87 -0
  33. package/lib/dry-run.js +110 -0
  34. package/lib/engine.js +52 -2
  35. package/lib/evaluate.js +76 -2
  36. package/lib/events.d.ts +56 -2
  37. package/lib/events.js +92 -0
  38. package/lib/index.js +106 -26
  39. package/lib/parsers/git.d.ts +43 -0
  40. package/lib/parsers/git.js +404 -0
  41. package/lib/parsers/shell-cmds.d.ts +32 -0
  42. package/lib/parsers/shell-cmds.js +293 -0
  43. package/lib/preset.d.ts +13 -0
  44. package/lib/preset.js +17 -0
  45. package/lib/risk.d.ts +17 -3
  46. package/lib/risk.js +17 -3
  47. package/lib/rule-chain.js +13 -22
  48. package/lib/rule-dims.d.ts +35 -0
  49. package/lib/rule-dims.js +36 -0
  50. package/lib/rule.d.ts +36 -1
  51. package/lib/rule.js +56 -8
  52. package/lib/rules-view.d.ts +52 -0
  53. package/lib/rules-view.js +126 -0
  54. package/lib/runtime.d.ts +121 -7
  55. package/lib/runtime.js +204 -25
  56. package/lib/shell.d.ts +14 -0
  57. package/lib/shell.js +17 -0
  58. package/package.json +19 -2
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Shell dangerous commands parser - rm, mv, cp, chmod, dd, diskpart, etc.
3
+ * P1 priority - detects destructive filesystem operations.
4
+ *
5
+ * Handles:
6
+ * - rm -rf / recursive deletion
7
+ * - mv overwriting existing files
8
+ * - cp -r with destructive patterns
9
+ * - chmod/chown with system directories
10
+ * - dd writing to disk
11
+ * - diskpart on Windows
12
+ * - format disk operations
13
+ * - PowerShell Remove-Item with dangerous patterns
14
+ */
15
+ /** Commands that are inherently destructive (only dangerous ones trigger canParse) */
16
+ const DESTRUCTIVE_COMMANDS = {
17
+ // Level 5: Extreme (system-wide, data loss)
18
+ 'dd': 5,
19
+ 'diskpart': 5,
20
+ 'format': 5,
21
+ 'fdisk': 5,
22
+ 'mkfs': 5,
23
+ 'rm-sudo': 5,
24
+ // Level 4: High (significant file deletion/modification)
25
+ 'rm': 4,
26
+ 'rmdir': 4,
27
+ 'shred': 4,
28
+ 'wipefs': 4,
29
+ // Level 3: Medium (file operations with potential data loss)
30
+ 'mv': 3,
31
+ // Level 2: Low (potentially risky)
32
+ 'cp': 2,
33
+ 'chmod': 2,
34
+ 'chown': 2,
35
+ 'chgrp': 2,
36
+ 'install': 2,
37
+ 'rsync': 2,
38
+ 'tar': 2,
39
+ };
40
+ /** System directories that shouldn't be modified */
41
+ const SYSTEM_DIRS = /^(\/bin|\/sbin|\/usr|\/lib|\/etc|\/boot|\/dev|\/proc|\/sys|\/var|C:\\Windows|C:\\Program Files)/i;
42
+ /** Patterns that indicate destructive operations */
43
+ const DESTRUCTIVE_PATTERNS = [
44
+ /rm\s+(-\w*\s+)*[^-\w]*\//i, // rm with /
45
+ /rm\s+(-\w*\s+)*\*/i, // rm with *
46
+ /rm\s+(-\w*\s+)*~/i, // rm with ~
47
+ /rm\s+(-\w*\s+)*\/[a-z]/i, // rm /anything
48
+ /rmdir\s+/i,
49
+ /mv\s+.*\//i, // mv to absolute path
50
+ /chmod\s+[0-7]*\s*\//i, // chmod on root paths
51
+ /chown\s+.*\//i, // chown on root paths
52
+ /dd\s+.*of=/i, // dd writing
53
+ /mkfs\./i, // format filesystem
54
+ />\s*\//i, // redirect to /
55
+ />>\s*\//i, // append to /
56
+ ];
57
+ export class ShellDangerousParser {
58
+ name = 'shell-dangerous';
59
+ priority = 20;
60
+ canParse(command) {
61
+ const cmd = command.trim().toLowerCase();
62
+ const baseName = this.extractBaseName(cmd);
63
+ // Check if it's a known destructive command
64
+ if (DESTRUCTIVE_COMMANDS[baseName] !== undefined) {
65
+ return true;
66
+ }
67
+ // Check for PowerShell equivalents
68
+ if (cmd.startsWith('remove-item') || cmd.startsWith('ri ')) {
69
+ return true;
70
+ }
71
+ // Check for destructive patterns
72
+ return DESTRUCTIVE_PATTERNS.some(p => p.test(command));
73
+ }
74
+ parse(command) {
75
+ const trimmed = command.trim();
76
+ const cmd = trimmed.toLowerCase();
77
+ const baseName = this.extractBaseName(cmd);
78
+ let destructiveness = DESTRUCTIVE_COMMANDS[baseName] || 1;
79
+ let operation = baseName;
80
+ let targets = [];
81
+ let destructiveFlags = false;
82
+ // Parse based on command type
83
+ if (baseName === 'rm' || baseName === 'rmdir') {
84
+ const parsed = this.parseRm(trimmed);
85
+ targets = parsed.targets;
86
+ destructiveFlags = parsed.recursive || parsed.force;
87
+ if (parsed.recursive && parsed.force) {
88
+ destructiveness = 5;
89
+ operation = 'recursive-force-delete';
90
+ }
91
+ else if (parsed.recursive) {
92
+ destructiveness = 4;
93
+ operation = 'recursive-delete';
94
+ }
95
+ else if (parsed.force) {
96
+ destructiveness = 3;
97
+ operation = 'force-delete';
98
+ }
99
+ // Check if targeting system directories
100
+ if (targets.some(t => SYSTEM_DIRS.test(t))) {
101
+ destructiveness = 5;
102
+ }
103
+ }
104
+ else if (baseName === 'mv') {
105
+ const parsed = this.parseMv(trimmed);
106
+ targets = parsed.targets;
107
+ destructiveFlags = parsed.force;
108
+ if (parsed.force) {
109
+ destructiveness = 4;
110
+ operation = 'force-move';
111
+ }
112
+ // Check if overwriting system files
113
+ if (targets.some(t => SYSTEM_DIRS.test(t))) {
114
+ destructiveness = 4;
115
+ }
116
+ }
117
+ else if (baseName === 'chmod' || baseName === 'chown' || baseName === 'chgrp') {
118
+ const parsed = this.parseChmod(trimmed);
119
+ targets = parsed.targets;
120
+ if (targets.some(t => SYSTEM_DIRS.test(t))) {
121
+ destructiveness = 4;
122
+ }
123
+ }
124
+ else if (baseName === 'dd') {
125
+ const parsed = this.parseDd(trimmed);
126
+ targets = parsed.targets;
127
+ destructiveFlags = parsed.force;
128
+ }
129
+ else if (baseName === 'diskpart' || baseName === 'format') {
130
+ const parsed = this.parseDiskpart(trimmed);
131
+ targets = parsed.targets;
132
+ destructiveFlags = true;
133
+ }
134
+ else if (baseName.startsWith('remove-item') || baseName.startsWith('ri ')) {
135
+ const parsed = this.parsePowerShellRemove(trimmed);
136
+ targets = parsed.targets;
137
+ destructiveFlags = parsed.recursive || parsed.force;
138
+ if (parsed.recursive && parsed.force) {
139
+ destructiveness = 5;
140
+ operation = 'recursive-force-delete';
141
+ }
142
+ else if (parsed.recursive) {
143
+ destructiveness = 4;
144
+ operation = 'recursive-delete';
145
+ }
146
+ }
147
+ else if (baseName === 'cp') {
148
+ const parsed = this.parseCp(trimmed);
149
+ targets = parsed.targets;
150
+ destructiveFlags = parsed.force;
151
+ if (parsed.recursive) {
152
+ operation = 'recursive-copy';
153
+ destructiveness = 3;
154
+ }
155
+ }
156
+ else if (baseName === 'tar' || baseName === 'rsync') {
157
+ const parsed = this.parseArchive(trimmed);
158
+ targets = parsed.targets;
159
+ destructiveFlags = parsed.delete;
160
+ if (parsed.delete) {
161
+ destructiveness = 4;
162
+ operation = 'archive-delete-target';
163
+ }
164
+ }
165
+ const flags = this.extractFlags(trimmed);
166
+ return {
167
+ family: 'shell-dangerous',
168
+ subcommand: baseName,
169
+ operation,
170
+ destructiveness: destructiveness,
171
+ destructiveFlags,
172
+ targets,
173
+ flags,
174
+ requiresConfirmation: destructiveness >= 4,
175
+ compound: false,
176
+ gitOperation: false,
177
+ targetsSharedBranch: false,
178
+ rawCommand: trimmed,
179
+ };
180
+ }
181
+ extractBaseName(command) {
182
+ // Handle sh -c "..." patterns
183
+ const shMatch = command.match(/(?:sh|bash|pwsh)(?:\s+-c\s+['"]?)(.+?)(?:['"]?)$/);
184
+ const actual = shMatch ? shMatch[1] : command;
185
+ // Extract first word
186
+ const parts = actual.trim().split(/\s+/);
187
+ return parts[0]?.toLowerCase() || '';
188
+ }
189
+ parseRm(command) {
190
+ const args = this.extractArgs(command);
191
+ const recursive = args.some(a => a === '-r' || a === '-rf' || a === '-fr' || a === '--recursive');
192
+ const force = args.some(a => a === '-f' || a === '-rf' || a === '-fr' || a === '--force');
193
+ const targets = args.filter(a => !a.startsWith('-'));
194
+ return { targets, recursive, force };
195
+ }
196
+ parseMv(command) {
197
+ const args = this.extractArgs(command);
198
+ const force = args.some(a => a === '-f' || a === '--force');
199
+ const targets = args.filter(a => !a.startsWith('-'));
200
+ return { targets, force };
201
+ }
202
+ parseCp(command) {
203
+ const args = this.extractArgs(command);
204
+ const recursive = args.some(a => a === '-r' || a === '-R' || a === '--recursive');
205
+ const force = args.some(a => a === '-f' || a === '--force');
206
+ const targets = args.filter(a => !a.startsWith('-'));
207
+ return { targets, recursive, force };
208
+ }
209
+ parseChmod(command) {
210
+ const args = this.extractArgs(command);
211
+ const targets = args.filter(a => !a.startsWith('-') && !/^[0-7]+$/.test(a));
212
+ return { targets };
213
+ }
214
+ parseDd(command) {
215
+ const args = this.extractArgs(command);
216
+ const targets = [];
217
+ for (const arg of args) {
218
+ if (arg.startsWith('of=')) {
219
+ targets.push(arg.slice(3));
220
+ }
221
+ if (arg.startsWith('if=')) {
222
+ targets.push(arg.slice(3));
223
+ }
224
+ }
225
+ const force = command.includes('conv=') || command.includes('oflag=');
226
+ return { targets, force };
227
+ }
228
+ parseDiskpart(command) {
229
+ // Diskpart commands are always dangerous
230
+ const args = this.extractArgs(command);
231
+ const targets = args.filter(a => !a.startsWith('-'));
232
+ return { targets };
233
+ }
234
+ parsePowerShellRemove(command) {
235
+ const args = this.extractArgs(command);
236
+ const recursive = args.some(a => a === '-Recurse' || a === '-r');
237
+ const force = args.some(a => a === '-Force' || a === '-f');
238
+ const targets = args.filter(a => !a.startsWith('-'));
239
+ return { targets, recursive, force };
240
+ }
241
+ parseArchive(command) {
242
+ const args = this.extractArgs(command);
243
+ const deleteFlag = args.some(a => a === '--delete' || a === '-d');
244
+ const targets = args.filter(a => !a.startsWith('-'));
245
+ return { targets, delete: deleteFlag };
246
+ }
247
+ extractArgs(command) {
248
+ const args = [];
249
+ let current = '';
250
+ let inQuote = false;
251
+ let quoteChar = '';
252
+ let escape = false;
253
+ for (const char of command) {
254
+ if (escape) {
255
+ current += char;
256
+ escape = false;
257
+ continue;
258
+ }
259
+ if (char === '\\') {
260
+ escape = true;
261
+ continue;
262
+ }
263
+ if (inQuote) {
264
+ if (char === quoteChar) {
265
+ inQuote = false;
266
+ continue;
267
+ }
268
+ current += char;
269
+ continue;
270
+ }
271
+ if (char === '"' || char === "'") {
272
+ inQuote = true;
273
+ quoteChar = char;
274
+ continue;
275
+ }
276
+ if (/\s/.test(char)) {
277
+ if (current) {
278
+ args.push(current);
279
+ current = '';
280
+ }
281
+ continue;
282
+ }
283
+ current += char;
284
+ }
285
+ if (current)
286
+ args.push(current);
287
+ return args;
288
+ }
289
+ extractFlags(command) {
290
+ const args = this.extractArgs(command);
291
+ return args.filter(a => a.startsWith('-'));
292
+ }
293
+ }
package/lib/preset.d.ts CHANGED
@@ -28,3 +28,16 @@ export interface SessionEventLike {
28
28
  export declare function permissionPresetOf(events: readonly SessionEventLike[] | undefined): string | undefined;
29
29
  /** Whether one preset name is covered by a configured scope list. */
30
30
  export declare function presetInScope(preset: string | undefined, scope: readonly string[]): boolean;
31
+ /**
32
+ * Presets in which the gate is active. It owns both 自动审查 tiers — the plain
33
+ * one (file sandbox kept) and the full-access one (sandbox restriction lifted,
34
+ * same approval behaviour) — so a user can have the gate without inheriting a
35
+ * sandbox that breaks `git` / Cygwin tools. `'*'` makes it global (every
36
+ * preset, including the hard-deny layer).
37
+ *
38
+ * Lives in this dependency-free module (not `config.ts`) so the browser half can
39
+ * render the scope without pulling schemastery into the client bundle.
40
+ */
41
+ export declare const DEFAULT_GATE_PRESETS: readonly string[];
42
+ /** Normalize the gate scope: an unset/empty list means the default. */
43
+ export declare function resolveGatePresets(configured?: readonly string[]): readonly string[];
package/lib/preset.js CHANGED
@@ -39,3 +39,20 @@ export function presetInScope(preset, scope) {
39
39
  return false;
40
40
  return scope.includes(preset);
41
41
  }
42
+ /**
43
+ * Presets in which the gate is active. It owns both 自动审查 tiers — the plain
44
+ * one (file sandbox kept) and the full-access one (sandbox restriction lifted,
45
+ * same approval behaviour) — so a user can have the gate without inheriting a
46
+ * sandbox that breaks `git` / Cygwin tools. `'*'` makes it global (every
47
+ * preset, including the hard-deny layer).
48
+ *
49
+ * Lives in this dependency-free module (not `config.ts`) so the browser half can
50
+ * render the scope without pulling schemastery into the client bundle.
51
+ */
52
+ export const DEFAULT_GATE_PRESETS = ['permissive', 'permissive-full'];
53
+ /** Normalize the gate scope: an unset/empty list means the default. */
54
+ export function resolveGatePresets(configured) {
55
+ if (configured === undefined || configured.length === 0)
56
+ return DEFAULT_GATE_PRESETS;
57
+ return configured.filter((name) => typeof name === 'string' && name !== '');
58
+ }
package/lib/risk.d.ts CHANGED
@@ -6,8 +6,13 @@
6
6
  *
7
7
  * safe → the gate may auto-allow the ask
8
8
  * risky:<hard category> → deletion / credential / remote / system / bulk —
9
- * auto-deny: the operation is clearly dangerous,
10
- * no popup or human review needed
9
+ * the operation looks dangerous; the gate KEEPS the
10
+ * human ask and never learns these into an
11
+ * auto-allow. It is NOT an auto-deny: denying is
12
+ * reserved for the deterministic layers (P0,
13
+ * deny-keywords, `deny:` rules), because a
14
+ * probabilistic verdict must not be able to hand
15
+ * down an unappealable block.
11
16
  * risky:neutral → no hard-risk signal but not clearly safe; the
12
17
  * verdict-learning path may auto-allow after
13
18
  * enough human confirmations
@@ -25,9 +30,18 @@ export type RiskCategory = 'deletion' | 'credential' | 'remote' | 'system' | 'bu
25
30
  /**
26
31
  * Categories that always route to the human seam regardless of learning state
27
32
  * or any later LLM opinion. Order-stable for prompts and event payloads.
33
+ *
34
+ * "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
35
+ * It does not mean auto-denied: the deny path belongs to the deterministic
36
+ * layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
37
+ * rules), so a misgraded category can always be negotiated instead of becoming
38
+ * an unappealable block.
28
39
  */
29
40
  export declare const HARD_RISK_CATEGORIES: readonly RiskCategory[];
30
- /** Whether a category must never be auto-allowed. Unknown categories are hard too (fail-closed). */
41
+ /**
42
+ * Whether a category must never be auto-allowed — and never learned into one.
43
+ * Unknown categories are hard too (fail-closed).
44
+ */
31
45
  export declare function isHardRisk(category: string): boolean;
32
46
  /**
33
47
  * A graded verdict for one tool call. `unresolved` covers every failure mode —
package/lib/risk.js CHANGED
@@ -6,8 +6,13 @@
6
6
  *
7
7
  * safe → the gate may auto-allow the ask
8
8
  * risky:<hard category> → deletion / credential / remote / system / bulk —
9
- * auto-deny: the operation is clearly dangerous,
10
- * no popup or human review needed
9
+ * the operation looks dangerous; the gate KEEPS the
10
+ * human ask and never learns these into an
11
+ * auto-allow. It is NOT an auto-deny: denying is
12
+ * reserved for the deterministic layers (P0,
13
+ * deny-keywords, `deny:` rules), because a
14
+ * probabilistic verdict must not be able to hand
15
+ * down an unappealable block.
11
16
  * risky:neutral → no hard-risk signal but not clearly safe; the
12
17
  * verdict-learning path may auto-allow after
13
18
  * enough human confirmations
@@ -23,10 +28,19 @@ import { chatCompletion, withLlmRetry } from './classifier.js';
23
28
  /**
24
29
  * Categories that always route to the human seam regardless of learning state
25
30
  * or any later LLM opinion. Order-stable for prompts and event payloads.
31
+ *
32
+ * "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
33
+ * It does not mean auto-denied: the deny path belongs to the deterministic
34
+ * layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
35
+ * rules), so a misgraded category can always be negotiated instead of becoming
36
+ * an unappealable block.
26
37
  */
27
38
  export const HARD_RISK_CATEGORIES = ['deletion', 'credential', 'remote', 'system', 'bulk'];
28
39
  const RISK_CATEGORIES = [...HARD_RISK_CATEGORIES, 'neutral'];
29
- /** Whether a category must never be auto-allowed. Unknown categories are hard too (fail-closed). */
40
+ /**
41
+ * Whether a category must never be auto-allowed — and never learned into one.
42
+ * Unknown categories are hard too (fail-closed).
43
+ */
30
44
  export function isHardRisk(category) {
31
45
  if (HARD_RISK_CATEGORIES.includes(category))
32
46
  return true;
package/lib/rule-chain.js CHANGED
@@ -17,7 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { existsSync, statSync } from 'node:fs';
20
- import { parsePermissionsDocument, compileDocument, documentHash } from './rule.js';
20
+ import { parsePermissionsDocument, compileDocument, compileRuleEntry, documentHash } from './rule.js';
21
21
  import { hashText } from './compiler.js';
22
22
  /** Compile-cache entry: hash → compiled ruleset. */
23
23
  const compileCache = new Map();
@@ -136,11 +136,11 @@ function mergeChain(entries, badFilePolicy, opts) {
136
136
  // Flatten: deny entries from all files, then allow, then ask.
137
137
  // Index numbering is sequential across the entire chain.
138
138
  let index = 0;
139
- const deny = flattenEntries(denyDocs, 'deny', index);
139
+ const deny = flattenEntries(denyDocs, 'deny', index, opts);
140
140
  index += deny.length;
141
- const allow = flattenEntries(allowDocs, 'allow', index);
141
+ const allow = flattenEntries(allowDocs, 'allow', index, opts);
142
142
  index += allow.length;
143
- const ask = flattenEntries(askDocs, 'ask', index);
143
+ const ask = flattenEntries(askDocs, 'ask', index, opts);
144
144
  return {
145
145
  defaultAction: entries.length > 0 ? entries[0].doc.defaultAction : 'ask',
146
146
  deny,
@@ -152,30 +152,21 @@ function mergeChain(entries, badFilePolicy, opts) {
152
152
  /**
153
153
  * Flatten a list of PermissionDocs into a single list of CompiledRuleEntry
154
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.
155
162
  */
156
- function flattenEntries(docs, action, startIndex) {
163
+ function flattenEntries(docs, action, startIndex, opts) {
157
164
  const result = [];
158
165
  let idx = startIndex;
159
166
  for (const doc of docs) {
160
167
  const list = action === 'deny' ? doc.deny : action === 'allow' ? doc.allow : doc.ask;
161
168
  for (const entry of list) {
162
- result.push({
163
- index: idx++,
164
- action,
165
- reason: entry.reason,
166
- enabled: entry.enabled,
167
- tools: [], // Will be compiled in the final merge step
168
- command: [],
169
- args: [],
170
- paths: [],
171
- params: entry.params,
172
- absent: entry.absent,
173
- agents: entry.agents,
174
- when: entry.when,
175
- argv: entry.argv,
176
- network: entry.network,
177
- source: entry,
178
- });
169
+ result.push(compileRuleEntry(entry, action, idx++, opts));
179
170
  }
180
171
  }
181
172
  return result;
@@ -137,3 +137,38 @@ export interface NetworkDimension {
137
137
  * ```
138
138
  */
139
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;
package/lib/rule-dims.js CHANGED
@@ -174,6 +174,42 @@ export function parseNetworkDimension(raw, at) {
174
174
  };
175
175
  return result;
176
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
+ }
177
213
  // ─── Helpers ───────────────────────────────────────────────────────────────
178
214
  /** Convert an unknown value to a string list (string → [string], array → filtered). */
179
215
  function toStringList(value, at) {
package/lib/rule.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type CompiledPattern } from './compiler.js';
2
- import { type ParamsDimension, type AbsentDimension, type AgentsDimension, type WhenDimension, type ArgvDimension, type NetworkDimension } from './rule-dims.js';
2
+ import { type ParamsDimension, type AbsentDimension, type AgentsDimension, type WhenDimension, type ArgvDimension, type NetworkDimension, type BranchDimension } from './rule-dims.js';
3
3
  export type RuleAction = 'allow' | 'ask' | 'deny';
4
4
  /** A parsed, shape-validated permissions document (patterns not yet compiled). */
5
5
  export interface PermissionsDoc {
@@ -33,6 +33,8 @@ export interface RuleEntryDoc {
33
33
  readonly argv: ArgvDimension | undefined;
34
34
  /** Network dimension (domain / IP / port / scheme). */
35
35
  readonly network: NetworkDimension | undefined;
36
+ /** Git branch / remote / protected-branch dimension. */
37
+ readonly branch: BranchDimension | undefined;
36
38
  readonly action: RuleAction;
37
39
  readonly reason: string;
38
40
  readonly enabled: boolean;
@@ -59,8 +61,16 @@ export interface CompiledRuleEntry {
59
61
  readonly argv: ArgvDimension | undefined;
60
62
  /** Parsed network dimension (raw patterns). */
61
63
  readonly network: NetworkDimension | undefined;
64
+ /** Compiled branch dimension (patterns compiled; absent = no constraint). */
65
+ readonly branch: CompiledBranchDimension | undefined;
62
66
  readonly source: RuleEntryDoc;
63
67
  }
68
+ /** Compiled `branch` dimension: `target`/`remote` globs, raw `shared` flag. */
69
+ export interface CompiledBranchDimension {
70
+ readonly target: readonly CompiledPattern[];
71
+ readonly remote: readonly CompiledPattern[];
72
+ readonly shared: boolean;
73
+ }
64
74
  export interface CommandSpec {
65
75
  readonly word: CompiledPattern;
66
76
  readonly flag?: 'recursive' | 'force';
@@ -81,8 +91,33 @@ export declare class RuleError extends Error {
81
91
  }
82
92
  /** Parse a raw YAML permissions document; malformed files fail loud at load. */
83
93
  export declare function parsePermissionsDocument(text: string): PermissionsDoc;
94
+ /**
95
+ * Parse a permissions document given as an already-parsed object — the shape
96
+ * the DSH settings namespace stores (the JSON twin of the YAML file). Accepts
97
+ * both the bare `{ defaultAction, deny, allow, ask }` form and the file's
98
+ * `permissions:`-wrapped form. Malformed input fails loud, exactly like the
99
+ * YAML path, so a settings doc that cannot compile is a state the operator
100
+ * sees, never a silent ruleset change.
101
+ */
102
+ export declare function parsePermissionsObject(raw: unknown): PermissionsDoc;
103
+ /**
104
+ * Compile one parsed entry into a hot-path rule.
105
+ *
106
+ * Exported because the multi-file rule chain (`rule-chain.ts`) merges entries
107
+ * from several documents into one ruleset and must produce the SAME shape here
108
+ * — an entry built by hand would silently drop every compiled dimension, and an
109
+ * empty dimension means "no constraint", i.e. "matches everything".
110
+ */
111
+ export declare function compileRuleEntry(entry: RuleEntryDoc, action: RuleAction, index: number, opts?: CompileOptions): CompiledRuleEntry;
84
112
  /** Compile a validated document into hot-path rules. */
85
113
  export declare function compileDocument(doc: PermissionsDoc, opts?: CompileOptions): CompiledRuleset;
114
+ /**
115
+ * Compile rules given as a structured settings object (already parsed — no
116
+ * YAML in the loop). The settings-first half of the dual-source read path:
117
+ * `parsePermissionsObject` validates the JSON form exactly as strictly as the
118
+ * YAML path, then the standard `compileDocument` runs.
119
+ */
120
+ export declare function compileRulesObject(root: unknown, opts?: CompileOptions): CompiledRuleset;
86
121
  /** SHA-256 hash of the raw document (compile-cache key without recompiling). */
87
122
  export declare function documentHash(text: string): string;
88
123
  export declare function extractPathCandidates(args: Record<string, unknown>): string[];