dsh-perm-gate 2.1.2 → 2.6.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.ja.md +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +239 -0
- package/INSTALL.ja.md +41 -21
- package/INSTALL.ko.md +37 -19
- package/INSTALL.md +46 -23
- package/INSTALL.zh.md +33 -17
- package/README.ja.md +52 -12
- package/README.ko.md +53 -13
- package/README.md +68 -10
- package/README.zh.md +41 -10
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +11 -8
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -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 +484 -6
- 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 +56 -2
- package/lib/events.js +92 -0
- package/lib/index.js +106 -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 +19 -2
- package/scripts/patch-permission-glyph.mjs +225 -42
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command dispatcher - routes commands to appropriate parsers.
|
|
3
|
+
* Runs parsers in priority order, returns first match.
|
|
4
|
+
*/
|
|
5
|
+
import type { CommandParser, ClassificationResult } from './command-semantics.js';
|
|
6
|
+
/**
|
|
7
|
+
* Register a parser manually (for testing or custom parsers).
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerParser(parser: CommandParser): void;
|
|
10
|
+
/**
|
|
11
|
+
* Clear all registered parsers (for testing).
|
|
12
|
+
*/
|
|
13
|
+
export declare function clearParsers(): void;
|
|
14
|
+
/**
|
|
15
|
+
* Dispatch a command to the appropriate parser.
|
|
16
|
+
* Returns classification result or null if no parser matches.
|
|
17
|
+
*/
|
|
18
|
+
export declare function dispatchCommand(command: string): ClassificationResult | null;
|
|
19
|
+
/**
|
|
20
|
+
* Get all registered parsers (for inspection/testing).
|
|
21
|
+
*/
|
|
22
|
+
export declare function getParsers(): readonly CommandParser[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command dispatcher - routes commands to appropriate parsers.
|
|
3
|
+
* Runs parsers in priority order, returns first match.
|
|
4
|
+
*/
|
|
5
|
+
import { GitParser } from './parsers/git.js';
|
|
6
|
+
import { ShellDangerousParser } from './parsers/shell-cmds.js';
|
|
7
|
+
/** Registered parsers (loaded lazily) */
|
|
8
|
+
const parsers = [];
|
|
9
|
+
/** Parser registry flag */
|
|
10
|
+
let initialized = false;
|
|
11
|
+
/**
|
|
12
|
+
* Lazy-load all parsers.
|
|
13
|
+
* Called once on first dispatch.
|
|
14
|
+
*/
|
|
15
|
+
function initParsers() {
|
|
16
|
+
if (initialized)
|
|
17
|
+
return;
|
|
18
|
+
initialized = true;
|
|
19
|
+
// P0: Git parser (highest priority)
|
|
20
|
+
parsers.push(new GitParser());
|
|
21
|
+
// P1: Shell dangerous commands
|
|
22
|
+
parsers.push(new ShellDangerousParser());
|
|
23
|
+
// P2: Package managers (TODO: implement)
|
|
24
|
+
// parsers.push(new PkgManagerParser())
|
|
25
|
+
// P3: Docker (TODO: implement)
|
|
26
|
+
// parsers.push(new DockerParser())
|
|
27
|
+
// P4: SQL (TODO: implement)
|
|
28
|
+
// parsers.push(new SqlParser())
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Register a parser manually (for testing or custom parsers).
|
|
32
|
+
*/
|
|
33
|
+
export function registerParser(parser) {
|
|
34
|
+
// Insert in priority order (lower priority number = higher priority)
|
|
35
|
+
const idx = parsers.findIndex(p => p.priority > parser.priority);
|
|
36
|
+
if (idx === -1) {
|
|
37
|
+
parsers.push(parser);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
parsers.splice(idx, 0, parser);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clear all registered parsers (for testing).
|
|
45
|
+
*/
|
|
46
|
+
export function clearParsers() {
|
|
47
|
+
parsers.length = 0;
|
|
48
|
+
initialized = false;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Dispatch a command to the appropriate parser.
|
|
52
|
+
* Returns classification result or null if no parser matches.
|
|
53
|
+
*/
|
|
54
|
+
export function dispatchCommand(command) {
|
|
55
|
+
initParsers();
|
|
56
|
+
const startTime = performance.now();
|
|
57
|
+
for (const parser of parsers) {
|
|
58
|
+
if (parser.canParse(command)) {
|
|
59
|
+
const semantics = parser.parse(command);
|
|
60
|
+
if (semantics) {
|
|
61
|
+
const parseTimeMs = performance.now() - startTime;
|
|
62
|
+
return {
|
|
63
|
+
semantics,
|
|
64
|
+
confidence: 1.0, // Static parsers have high confidence
|
|
65
|
+
parser: parser.name,
|
|
66
|
+
parseTimeMs,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// No parser matched - return unknown semantics
|
|
72
|
+
const parseTimeMs = performance.now() - startTime;
|
|
73
|
+
return {
|
|
74
|
+
semantics: {
|
|
75
|
+
family: 'unknown',
|
|
76
|
+
destructiveness: 0,
|
|
77
|
+
destructiveFlags: false,
|
|
78
|
+
targets: [],
|
|
79
|
+
flags: [],
|
|
80
|
+
requiresConfirmation: false,
|
|
81
|
+
compound: false,
|
|
82
|
+
gitOperation: false,
|
|
83
|
+
targetsSharedBranch: false,
|
|
84
|
+
rawCommand: command,
|
|
85
|
+
},
|
|
86
|
+
confidence: 0,
|
|
87
|
+
parser: 'none',
|
|
88
|
+
parseTimeMs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get all registered parsers (for inspection/testing).
|
|
93
|
+
*/
|
|
94
|
+
export function getParsers() {
|
|
95
|
+
initParsers();
|
|
96
|
+
return parsers;
|
|
97
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified command semantics output from all classifiers.
|
|
3
|
+
* Every parser (git, shell-dangerous, docker, sql, pkg-manager)
|
|
4
|
+
* returns this interface — the evaluator does one matching pass.
|
|
5
|
+
*/
|
|
6
|
+
/** Command families supported by static parsers */
|
|
7
|
+
export type CommandFamily = 'git' | 'shell-dangerous' | 'docker' | 'sql' | 'pkg-manager' | 'unknown';
|
|
8
|
+
/** Destructiveness rating (0=safe, 5=extremely destructive) */
|
|
9
|
+
export type DestructivenessLevel = 0 | 1 | 2 | 3 | 4 | 5;
|
|
10
|
+
/** Unified semantics output */
|
|
11
|
+
export interface CommandSemantics {
|
|
12
|
+
/** Command family (git, shell-dangerous, docker, sql, pkg-manager, unknown) */
|
|
13
|
+
family: CommandFamily;
|
|
14
|
+
/** Subcommand (e.g. 'push', 'rm', 'run') */
|
|
15
|
+
subcommand?: string;
|
|
16
|
+
/** Specific operation (e.g. 'force-push', 'recursive-delete') */
|
|
17
|
+
operation?: string;
|
|
18
|
+
/** Destructiveness level 0-5 */
|
|
19
|
+
destructiveness: DestructivenessLevel;
|
|
20
|
+
/** Whether destructive flags are present (--force, --recursive, etc.) */
|
|
21
|
+
destructiveFlags: boolean;
|
|
22
|
+
/** Target identifiers (branch names, file paths, container IDs, table names, package names) */
|
|
23
|
+
targets: string[];
|
|
24
|
+
/** All parsed flags (normalized) */
|
|
25
|
+
flags: string[];
|
|
26
|
+
/** Whether the command requires explicit confirmation */
|
|
27
|
+
requiresConfirmation: boolean;
|
|
28
|
+
/** Whether the command is compound (contains &&, ||, |) */
|
|
29
|
+
compound: boolean;
|
|
30
|
+
/** Whether the command involves git operations */
|
|
31
|
+
gitOperation: boolean;
|
|
32
|
+
/** Branch name (extracted from git commands) */
|
|
33
|
+
branch?: string;
|
|
34
|
+
/** Remote name (for git push/fetch) */
|
|
35
|
+
remote?: string;
|
|
36
|
+
/** Whether the command targets shared/protected branches */
|
|
37
|
+
targetsSharedBranch: boolean;
|
|
38
|
+
/** Raw command string */
|
|
39
|
+
rawCommand?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Classification result with confidence */
|
|
42
|
+
export interface ClassificationResult {
|
|
43
|
+
/** The semantics object */
|
|
44
|
+
semantics: CommandSemantics;
|
|
45
|
+
/** Parser confidence (0-1) */
|
|
46
|
+
confidence: number;
|
|
47
|
+
/** Parser used */
|
|
48
|
+
parser: string;
|
|
49
|
+
/** Parse time in milliseconds */
|
|
50
|
+
parseTimeMs: number;
|
|
51
|
+
}
|
|
52
|
+
/** Parser interface - all parsers implement this */
|
|
53
|
+
export interface CommandParser {
|
|
54
|
+
/** Parser name */
|
|
55
|
+
readonly name: string;
|
|
56
|
+
/** Priority (lower = runs first) */
|
|
57
|
+
readonly priority: number;
|
|
58
|
+
/** Try to parse the command. Return null if not applicable. */
|
|
59
|
+
parse(command: string): CommandSemantics | null;
|
|
60
|
+
/** Check if this parser can handle the command */
|
|
61
|
+
canParse(command: string): boolean;
|
|
62
|
+
}
|
package/lib/compiler.d.ts
CHANGED
|
@@ -56,6 +56,17 @@ export declare function compilePortSpec(spec: string): (port: number) => boolean
|
|
|
56
56
|
* Returns a {@link CompiledPattern} whose `.re` is the anchored RegExp.
|
|
57
57
|
*/
|
|
58
58
|
export declare function compileDomainPattern(pattern: string): CompiledPattern;
|
|
59
|
+
/**
|
|
60
|
+
* Compile a git branch-name pattern into a matcher.
|
|
61
|
+
*
|
|
62
|
+
* Branch names are path-like (`release/1.0`) but a rule author thinks in
|
|
63
|
+
* segments-with-globs terms (`release*`), so this is a **non-segment** glob:
|
|
64
|
+
* `*` crosses `/` on purpose, matching how `args` patterns behave.
|
|
65
|
+
*
|
|
66
|
+
* Rejects a pattern that is nothing but a separator so a typo can not degrade
|
|
67
|
+
* into "matches everything".
|
|
68
|
+
*/
|
|
69
|
+
export declare function compileBranchPattern(pattern: string): CompiledPattern;
|
|
59
70
|
/**
|
|
60
71
|
* Compile a list of patterns (possibly `!`-prefixed) into param matchers.
|
|
61
72
|
* Returns `{ pattern, negated, compiled }` entries for the params dimension.
|
package/lib/compiler.js
CHANGED
|
@@ -217,6 +217,22 @@ export function compileDomainPattern(pattern) {
|
|
|
217
217
|
const re = new RegExp(`^(?:.*\\.)?${escaped}$`, 'u');
|
|
218
218
|
return { source: pattern, re };
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Compile a git branch-name pattern into a matcher.
|
|
222
|
+
*
|
|
223
|
+
* Branch names are path-like (`release/1.0`) but a rule author thinks in
|
|
224
|
+
* segments-with-globs terms (`release*`), so this is a **non-segment** glob:
|
|
225
|
+
* `*` crosses `/` on purpose, matching how `args` patterns behave.
|
|
226
|
+
*
|
|
227
|
+
* Rejects a pattern that is nothing but a separator so a typo can not degrade
|
|
228
|
+
* into "matches everything".
|
|
229
|
+
*/
|
|
230
|
+
export function compileBranchPattern(pattern) {
|
|
231
|
+
if (!/[A-Za-z0-9_*?[\]]/.test(pattern)) {
|
|
232
|
+
throw new PatternError(`branch pattern ${JSON.stringify(pattern)} has no matchable character`);
|
|
233
|
+
}
|
|
234
|
+
return compileGlob(pattern, { segments: false, maxStars: DEFAULT_MAX_STARS });
|
|
235
|
+
}
|
|
220
236
|
/**
|
|
221
237
|
* Compile a list of patterns (possibly `!`-prefixed) into param matchers.
|
|
222
238
|
* Returns `{ pattern, negated, compiled }` entries for the params dimension.
|
package/lib/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import type { RuleAction } from './rule.js';
|
|
3
|
+
import { DEFAULT_GATE_PRESETS, resolveGatePresets } from './preset.js';
|
|
3
4
|
/**
|
|
4
5
|
* The DSH home directory: an explicit `dshHome`, else `$DSH_HOME`, else
|
|
5
6
|
* `~/.dsh`. A profile entry that omits `config` must still get a writable data
|
|
@@ -186,9 +187,9 @@ export interface PermGateConfig {
|
|
|
186
187
|
* Default 1800000 (30 min).
|
|
187
188
|
*/
|
|
188
189
|
readonly networkGrantTtlMs?: number;
|
|
189
|
-
/**
|
|
190
|
+
/** @deprecated Rules now live in the `dsh-perm-gate-rules` settings namespace; file watching was removed. Accepted (ignored) for composition compatibility. */
|
|
190
191
|
readonly watch?: boolean;
|
|
191
|
-
/**
|
|
192
|
+
/** @deprecated See {@link watch}. */
|
|
192
193
|
readonly watchDebounceMs?: number;
|
|
193
194
|
}
|
|
194
195
|
/** Backend combinable approval strategies for the Permissive tier (all opt-in). */
|
|
@@ -217,18 +218,78 @@ export interface PermissiveStrategies {
|
|
|
217
218
|
readonly trustEscalation: boolean;
|
|
218
219
|
}
|
|
219
220
|
export declare const DEFAULT_PERMISSIVE_STRATEGIES: Readonly<PermissiveStrategies>;
|
|
220
|
-
|
|
221
|
-
* Presets in which the gate is active. It owns both 自动审查 tiers — the plain
|
|
222
|
-
* one (file sandbox kept) and the full-access one (sandbox restriction lifted,
|
|
223
|
-
* same approval behaviour) — so a user can have the gate without inheriting a
|
|
224
|
-
* sandbox that breaks `git` / Cygwin tools. `'*'` makes it global (every
|
|
225
|
-
* preset, including the hard-deny layer).
|
|
226
|
-
*/
|
|
227
|
-
export declare const DEFAULT_GATE_PRESETS: readonly string[];
|
|
228
|
-
/** Normalize the gate scope: an unset/empty list means the default. */
|
|
229
|
-
export declare function resolveGatePresets(configured?: readonly string[]): readonly string[];
|
|
221
|
+
export { DEFAULT_GATE_PRESETS, resolveGatePresets };
|
|
230
222
|
/** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
|
|
231
223
|
export declare function resolvePermissiveStrategies(bag?: Partial<PermissiveStrategies>): PermissiveStrategies;
|
|
224
|
+
/**
|
|
225
|
+
* One rule entry as stored in settings. Deliberately permissive (`z.any()`
|
|
226
|
+
* fields): strict validation happens in `parseRuleEntry` at compile time (fail
|
|
227
|
+
* loud), and the stored form must round-trip BOTH the raw YAML shape (string
|
|
228
|
+
* scalars, `params` as a key→patterns mapping) and the parsed document shape
|
|
229
|
+
* (parsed dimensions), so a file migration can be seeded without lossy
|
|
230
|
+
* normalization. Schemastery preserves unknown keys in non-strict object mode.
|
|
231
|
+
*/
|
|
232
|
+
export declare const RuleEntrySchema: z<unknown>;
|
|
233
|
+
/**
|
|
234
|
+
* The rules structure stored in the DSH settings namespace — the JSON form of
|
|
235
|
+
* rules.yml. Unknown extra keys on the document itself are preserved too.
|
|
236
|
+
*/
|
|
237
|
+
export declare const RulesSchema: z<RulesConfig>;
|
|
238
|
+
/** One rule entry in the settings-stored rules document (JSON form; all optional). */
|
|
239
|
+
export interface RulesEntryConfig {
|
|
240
|
+
readonly tools?: readonly string[];
|
|
241
|
+
readonly command?: readonly string[];
|
|
242
|
+
readonly args?: readonly string[];
|
|
243
|
+
readonly paths?: readonly string[];
|
|
244
|
+
readonly params?: unknown;
|
|
245
|
+
readonly absent?: readonly string[];
|
|
246
|
+
readonly agents?: readonly string[];
|
|
247
|
+
readonly when?: unknown;
|
|
248
|
+
readonly argv?: unknown;
|
|
249
|
+
readonly network?: {
|
|
250
|
+
readonly domains?: readonly string[];
|
|
251
|
+
readonly ips?: readonly string[];
|
|
252
|
+
readonly ports?: readonly string[];
|
|
253
|
+
readonly schemes?: readonly string[];
|
|
254
|
+
};
|
|
255
|
+
readonly branch?: {
|
|
256
|
+
readonly target?: readonly string[];
|
|
257
|
+
readonly remote?: readonly string[];
|
|
258
|
+
readonly shared?: boolean;
|
|
259
|
+
};
|
|
260
|
+
readonly reason?: string;
|
|
261
|
+
readonly enabled?: boolean;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The rules document stored in the settings namespace. The action lists are
|
|
265
|
+
* `unknown[]` on purpose: entries may be the raw YAML shape or the parsed
|
|
266
|
+
* document shape, and strict validation belongs to `parseRuleEntry` at compile
|
|
267
|
+
* time — see {@link RulesEntryConfig} for the documented JSON form. (Mutable
|
|
268
|
+
* arrays: the type doubles as the Schemastery input face of {@link RulesSchema},
|
|
269
|
+
* which validates in place.)
|
|
270
|
+
*/
|
|
271
|
+
export interface RulesConfig {
|
|
272
|
+
readonly defaultAction?: RuleAction;
|
|
273
|
+
readonly deny?: unknown[];
|
|
274
|
+
readonly allow?: unknown[];
|
|
275
|
+
readonly ask?: unknown[];
|
|
276
|
+
}
|
|
277
|
+
/** Settings namespace for rules (separate from the main perm-gate namespace). */
|
|
278
|
+
export declare const RULES_NAMESPACE = "dsh-perm-gate-rules";
|
|
279
|
+
/**
|
|
280
|
+
* Whether a settings-sourced rules document carries a REAL configuration —
|
|
281
|
+
* entries or a non-default `defaultAction`. A namespace still holding bare
|
|
282
|
+
* schema defaults is "not configured" and must not shadow the rules file
|
|
283
|
+
* (the dual-source contract: settings first, file fallback).
|
|
284
|
+
*/
|
|
285
|
+
export declare function isRulesConfigured(value: unknown): boolean;
|
|
286
|
+
/**
|
|
287
|
+
* Read the rules from a settings scope: the document when the namespace is
|
|
288
|
+
* configured, otherwise `undefined` so the caller falls back to the rules file.
|
|
289
|
+
*/
|
|
290
|
+
export declare function readRulesFromSettings(scope: {
|
|
291
|
+
get(): unknown;
|
|
292
|
+
} | undefined): RulesConfig | undefined;
|
|
232
293
|
export declare const Config: z<PermGateConfig>;
|
|
233
294
|
export type ResolvedPermGateConfig = Required<Pick<PermGateConfig, 'caseInsensitivePaths' | 'grantTtlMs' | 'grantMaxUses' | 'permissive' | 'riskTimeoutMs' | 'riskLearning' | 'riskThreshold'>> & Pick<PermGateConfig, 'rulesFile' | 'dshHome' | 'defaultAction' | 'classifierEnabled' | 'classifierEndpoint' | 'classifierModel' | 'classifierApiKey' | 'learningFile' | 'eventsFile'> & {
|
|
234
295
|
readonly permissiveStrategies: PermissiveStrategies;
|
package/lib/config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { DEFAULT_GATE_PRESETS, resolveGatePresets } from './preset.js';
|
|
8
9
|
/**
|
|
9
10
|
* The DSH home directory: an explicit `dshHome`, else `$DSH_HOME`, else
|
|
10
11
|
* `~/.dsh`. A profile entry that omits `config` must still get a writable data
|
|
@@ -42,20 +43,10 @@ export const DEFAULT_PERMISSIVE_STRATEGIES = {
|
|
|
42
43
|
// bulk) — the escalation-worthy ones — auto-deny rather than allow.
|
|
43
44
|
trustEscalation: true,
|
|
44
45
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* sandbox that breaks `git` / Cygwin tools. `'*'` makes it global (every
|
|
50
|
-
* preset, including the hard-deny layer).
|
|
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
|
-
}
|
|
46
|
+
// The scope constants live in the dependency-free `preset` module (the browser
|
|
47
|
+
// half renders them); bound locally AND re-exported so every existing
|
|
48
|
+
// `./config.js` import keeps resolving.
|
|
49
|
+
export { DEFAULT_GATE_PRESETS, resolveGatePresets };
|
|
59
50
|
/** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
|
|
60
51
|
export function resolvePermissiveStrategies(bag = {}) {
|
|
61
52
|
return {
|
|
@@ -65,6 +56,57 @@ export function resolvePermissiveStrategies(bag = {}) {
|
|
|
65
56
|
trustEscalation: bag.trustEscalation ?? DEFAULT_PERMISSIVE_STRATEGIES.trustEscalation,
|
|
66
57
|
};
|
|
67
58
|
}
|
|
59
|
+
// ─── Rules Schema (for settings namespace) ──────────────────────────────────
|
|
60
|
+
// The JSON twin of the rules.yml permissions document, stored in its own DSH
|
|
61
|
+
// settings namespace so the gate loads without any file on disk.
|
|
62
|
+
/**
|
|
63
|
+
* One rule entry as stored in settings. Deliberately permissive (`z.any()`
|
|
64
|
+
* fields): strict validation happens in `parseRuleEntry` at compile time (fail
|
|
65
|
+
* loud), and the stored form must round-trip BOTH the raw YAML shape (string
|
|
66
|
+
* scalars, `params` as a key→patterns mapping) and the parsed document shape
|
|
67
|
+
* (parsed dimensions), so a file migration can be seeded without lossy
|
|
68
|
+
* normalization. Schemastery preserves unknown keys in non-strict object mode.
|
|
69
|
+
*/
|
|
70
|
+
export const RuleEntrySchema = z.any();
|
|
71
|
+
/**
|
|
72
|
+
* The rules structure stored in the DSH settings namespace — the JSON form of
|
|
73
|
+
* rules.yml. Unknown extra keys on the document itself are preserved too.
|
|
74
|
+
*/
|
|
75
|
+
export const RulesSchema = z.object({
|
|
76
|
+
defaultAction: z.union(['allow', 'ask', 'deny']).default('ask'),
|
|
77
|
+
deny: z.array(RuleEntrySchema).default([]),
|
|
78
|
+
allow: z.array(RuleEntrySchema).default([]),
|
|
79
|
+
ask: z.array(RuleEntrySchema).default([]),
|
|
80
|
+
});
|
|
81
|
+
/** Settings namespace for rules (separate from the main perm-gate namespace). */
|
|
82
|
+
export const RULES_NAMESPACE = 'dsh-perm-gate-rules';
|
|
83
|
+
/**
|
|
84
|
+
* Whether a settings-sourced rules document carries a REAL configuration —
|
|
85
|
+
* entries or a non-default `defaultAction`. A namespace still holding bare
|
|
86
|
+
* schema defaults is "not configured" and must not shadow the rules file
|
|
87
|
+
* (the dual-source contract: settings first, file fallback).
|
|
88
|
+
*/
|
|
89
|
+
export function isRulesConfigured(value) {
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
91
|
+
return false;
|
|
92
|
+
const doc = value;
|
|
93
|
+
if (doc.defaultAction !== undefined && doc.defaultAction !== 'ask')
|
|
94
|
+
return true;
|
|
95
|
+
for (const key of ['deny', 'allow', 'ask']) {
|
|
96
|
+
const list = doc[key];
|
|
97
|
+
if (Array.isArray(list) && list.length > 0)
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Read the rules from a settings scope: the document when the namespace is
|
|
104
|
+
* configured, otherwise `undefined` so the caller falls back to the rules file.
|
|
105
|
+
*/
|
|
106
|
+
export function readRulesFromSettings(scope) {
|
|
107
|
+
const value = scope?.get();
|
|
108
|
+
return isRulesConfigured(value) ? value : undefined;
|
|
109
|
+
}
|
|
68
110
|
export const Config = z.object({
|
|
69
111
|
rulesFile: z.string(),
|
|
70
112
|
dshHome: z.string(),
|
package/lib/dry-run.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RuleAction } from './rule.js';
|
|
2
|
+
import { PermGateRuntime } from './runtime.js';
|
|
3
|
+
export interface DryRunInput {
|
|
4
|
+
/** Tool name to evaluate, e.g. `shell`, `read`, `write`. */
|
|
5
|
+
readonly tool: string;
|
|
6
|
+
/** The tool call's arguments; `command` is what the shell dimensions read. */
|
|
7
|
+
readonly args?: Record<string, unknown>;
|
|
8
|
+
/** Rules file to load. Unset = the default chain (cwd upward, then the data home). */
|
|
9
|
+
readonly rulesFile?: string;
|
|
10
|
+
/** Working directory the call is evaluated in. Defaults to `process.cwd()`. */
|
|
11
|
+
readonly cwd?: string;
|
|
12
|
+
/** Evaluate with the independent Permissive tier on. */
|
|
13
|
+
readonly permissive?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Also run the **host-facing** decision path and report its own view.
|
|
16
|
+
*
|
|
17
|
+
* That path appends audit entries, records decision events, tracks asks and
|
|
18
|
+
* clears call clearances — so it is opt-in, and a read-only caller must never
|
|
19
|
+
* request it against a live runtime. Only the CLI asks for it, to keep its
|
|
20
|
+
* historic output byte for byte.
|
|
21
|
+
*/
|
|
22
|
+
readonly hostView?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The host-facing decision path's own view (`decideExecution`) and its audit count. */
|
|
25
|
+
export interface DryRunHostView {
|
|
26
|
+
readonly verdict: RuleAction;
|
|
27
|
+
/** The waterfall's reason, or the historic `(default/passthrough)` placeholder. */
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
readonly audited: number;
|
|
30
|
+
}
|
|
31
|
+
/** The P2 rule chain's own verdict, with the rule that produced it. */
|
|
32
|
+
export interface DryRunRuleLayer {
|
|
33
|
+
readonly action: RuleAction;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
readonly ruleIndex?: number;
|
|
36
|
+
/**
|
|
37
|
+
* The dimensions the matched rule *constrains* — not "the dimension that
|
|
38
|
+
* caused the match". Dimensions are ANDed and an empty one is no constraint at
|
|
39
|
+
* all, so a multi-dimension rule cannot be attributed to a single dimension
|
|
40
|
+
* without lying about which one did the work.
|
|
41
|
+
*/
|
|
42
|
+
readonly matchedDimensions: readonly string[];
|
|
43
|
+
/** The raw rule entry as written in the YAML, for display. */
|
|
44
|
+
readonly source?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
export interface DryRunResult {
|
|
47
|
+
readonly tool: string;
|
|
48
|
+
readonly rulesFile?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The **policy** verdict: what the chain decides for this call, independent of
|
|
51
|
+
* the session a caller does not have. See {@link PermGateRuntime.explainCall}
|
|
52
|
+
* for why the session layers are excluded rather than guessed.
|
|
53
|
+
*/
|
|
54
|
+
readonly verdict: RuleAction;
|
|
55
|
+
/** Why, in the chain's own words — never a bare placeholder. */
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
/** Which layer produced `verdict`. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
readonly defaultAction: RuleAction;
|
|
60
|
+
readonly ruleCount: number;
|
|
61
|
+
readonly permissive: boolean;
|
|
62
|
+
readonly ruleLayer: DryRunRuleLayer;
|
|
63
|
+
/** Present only when `input.hostView` was set. */
|
|
64
|
+
readonly host?: DryRunHostView;
|
|
65
|
+
}
|
|
66
|
+
export interface DryRunOptions {
|
|
67
|
+
readonly rulesFile?: string;
|
|
68
|
+
readonly permissive?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Workspace root for the rule CHAIN resolution (cwd upward, then the data
|
|
71
|
+
* home). `undefined` keeps the runtime default (`process.cwd()`).
|
|
72
|
+
*
|
|
73
|
+
* The CLI deliberately does not pass its `--cwd` here: that flag scopes the
|
|
74
|
+
* evaluated call, and feeding it into chain resolution as well would change
|
|
75
|
+
* what an existing `--cwd` invocation resolves. A host caller (the rule-test
|
|
76
|
+
* route) does pass its session workspace, because for a live session "which
|
|
77
|
+
* rules file applies" is exactly the question being asked.
|
|
78
|
+
*/
|
|
79
|
+
readonly cwd?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the standalone runtime a dry-run evaluates against. Exported because
|
|
83
|
+
* `--list` needs the loaded ruleset without deciding anything.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createDryRunRuntime(options?: DryRunOptions): PermGateRuntime;
|
|
86
|
+
/** Evaluate one call against a rules file and report the verdict. */
|
|
87
|
+
export declare function runDryRun(input: DryRunInput, runtime?: PermGateRuntime): DryRunResult;
|
package/lib/dry-run.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule dry-run: evaluate one would-be tool call against a rules file and report
|
|
3
|
+
* the verdict in a shape both the CLI and the settings-card "rule test" panel can
|
|
4
|
+
* use.
|
|
5
|
+
*
|
|
6
|
+
* The evaluator itself lives in {@link PermGateRuntime}; this module only owns
|
|
7
|
+
* the standalone construction (no session, no audit feed, no event log, no
|
|
8
|
+
* persistence paths) and the reporting shape. Nothing here writes to disk or to
|
|
9
|
+
* the live gate — the runtime it builds is thrown away when the call returns.
|
|
10
|
+
*
|
|
11
|
+
* Two verdicts are reported, and they are not the same thing:
|
|
12
|
+
*
|
|
13
|
+
* - `verdict` is the *effective* result of the whole chain the live gate runs
|
|
14
|
+
* (P0 hard-deny → P1 grant → P2 rules → P3 classifier → P4 ask), minus the
|
|
15
|
+
* session-scoped state a dry-run has none of. This is what the user sees.
|
|
16
|
+
* - `ruleLayer` is what the `permissions` chain decides *on its own*. Only this
|
|
17
|
+
* layer can name a rule, so `ruleIndex` belongs to it. A P0 hard-deny or a
|
|
18
|
+
* preset deny-keyword fires before it and reports no index; when the two
|
|
19
|
+
* differ, that difference is the answer, not an error.
|
|
20
|
+
*/
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { PermGateRuntime } from './runtime.js';
|
|
23
|
+
/**
|
|
24
|
+
* The eleven match dimensions, in the order `docs/rules-format.md` documents
|
|
25
|
+
* them. Used purely for reporting which dimensions a matched rule constrains.
|
|
26
|
+
*/
|
|
27
|
+
const DIMENSION_KEYS = [
|
|
28
|
+
'tools',
|
|
29
|
+
'command',
|
|
30
|
+
'args',
|
|
31
|
+
'paths',
|
|
32
|
+
'params',
|
|
33
|
+
'absent',
|
|
34
|
+
'agents',
|
|
35
|
+
'when',
|
|
36
|
+
'argv',
|
|
37
|
+
'network',
|
|
38
|
+
'branch',
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Build the standalone runtime a dry-run evaluates against. Exported because
|
|
42
|
+
* `--list` needs the loaded ruleset without deciding anything.
|
|
43
|
+
*/
|
|
44
|
+
export function createDryRunRuntime(options = {}) {
|
|
45
|
+
return new PermGateRuntime({
|
|
46
|
+
rulesFile: options.rulesFile,
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
caseInsensitivePaths: true,
|
|
49
|
+
permissive: options.permissive || undefined,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Which of the eleven dimensions this rule actually constrains. */
|
|
53
|
+
function constrainedDimensions(source) {
|
|
54
|
+
return DIMENSION_KEYS.filter((key) => isConstrained(source[key]));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A dimension constrains when it carries at least one condition. Every shape the
|
|
58
|
+
* parser produces is covered: pattern lists, key→value maps, condition objects.
|
|
59
|
+
*/
|
|
60
|
+
function isConstrained(value) {
|
|
61
|
+
if (value === undefined || value === null)
|
|
62
|
+
return false;
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return value.length > 0;
|
|
65
|
+
if (typeof value === 'object')
|
|
66
|
+
return Object.keys(value).length > 0;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Evaluate one call against a rules file and report the verdict. */
|
|
70
|
+
export function runDryRun(input, runtime) {
|
|
71
|
+
const rulesFile = input.rulesFile ? resolve(input.rulesFile) : undefined;
|
|
72
|
+
const gate = runtime ?? createDryRunRuntime({ rulesFile, permissive: input.permissive, cwd: input.cwd });
|
|
73
|
+
const exec = {
|
|
74
|
+
name: input.tool,
|
|
75
|
+
arguments: input.args ?? {},
|
|
76
|
+
cwd: input.cwd ? resolve(input.cwd) : process.cwd(),
|
|
77
|
+
};
|
|
78
|
+
// Pure policy first (the panel's answer); the side-effecting host view only
|
|
79
|
+
// when explicitly asked for, and it must run last so its audit count is its own.
|
|
80
|
+
const policy = gate.explainCall(exec);
|
|
81
|
+
const layer = gate.explainRules(exec);
|
|
82
|
+
const source = layer.rule?.source;
|
|
83
|
+
let host;
|
|
84
|
+
if (input.hostView === true) {
|
|
85
|
+
const decision = gate.decideExecution(exec);
|
|
86
|
+
host = {
|
|
87
|
+
verdict: decision === undefined ? 'allow' : decision.kind,
|
|
88
|
+
reason: decision?.reason ?? '(default/passthrough)',
|
|
89
|
+
audited: gate.auditEntries.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
tool: input.tool,
|
|
94
|
+
rulesFile,
|
|
95
|
+
verdict: policy.action,
|
|
96
|
+
reason: policy.reason,
|
|
97
|
+
source: policy.source,
|
|
98
|
+
defaultAction: layer.defaultAction,
|
|
99
|
+
ruleCount: gate.ruleCount(),
|
|
100
|
+
permissive: gate.permissive,
|
|
101
|
+
ruleLayer: {
|
|
102
|
+
action: layer.action,
|
|
103
|
+
reason: layer.reason,
|
|
104
|
+
ruleIndex: layer.ruleIndex,
|
|
105
|
+
matchedDimensions: source === undefined ? [] : constrainedDimensions(source),
|
|
106
|
+
source,
|
|
107
|
+
},
|
|
108
|
+
...(host !== undefined ? { host } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|