dsh-perm-gate 2.1.2 → 4.0.0
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.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +225 -0
- package/INSTALL.ja.md +15 -9
- package/INSTALL.ko.md +16 -10
- package/INSTALL.md +17 -9
- package/INSTALL.zh.md +13 -8
- package/README.ja.md +17 -5
- package/README.ko.md +18 -6
- package/README.md +33 -3
- package/README.zh.md +11 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +2 -2
- package/examples/permissions.example.yaml +16 -1
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +646 -95
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +11 -0
- package/lib/compiler.js +16 -0
- package/lib/config.d.ts +73 -12
- package/lib/config.js +56 -14
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +52 -2
- package/lib/evaluate.js +76 -2
- package/lib/events.d.ts +91 -2
- package/lib/events.js +220 -0
- package/lib/index.js +111 -26
- package/lib/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +17 -3
- package/lib/rule-chain.js +13 -22
- package/lib/rule-dims.d.ts +35 -0
- package/lib/rule-dims.js +36 -0
- package/lib/rule.d.ts +36 -1
- package/lib/rule.js +56 -8
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +121 -7
- package/lib/runtime.js +204 -25
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/package.json +13 -5
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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;
|
package/lib/rule-dims.d.ts
CHANGED
|
@@ -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[];
|