dsh-perm-gate 2.1.2 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +236 -0
  4. package/INSTALL.ja.md +41 -21
  5. package/INSTALL.ko.md +37 -19
  6. package/INSTALL.md +46 -23
  7. package/INSTALL.zh.md +33 -17
  8. package/README.ja.md +52 -12
  9. package/README.ko.md +53 -13
  10. package/README.md +68 -10
  11. package/README.zh.md +41 -10
  12. package/assets/allow1.png +0 -0
  13. package/assets/allow2.png +0 -0
  14. package/assets/gateoff.png +0 -0
  15. package/assets/llms.png +0 -0
  16. package/assets/permlist.png +0 -0
  17. package/assets/setting-head.png +0 -0
  18. package/assets/setting-rules.png +0 -0
  19. package/cordis.patch.yml +11 -8
  20. package/docs/baseline-guard.md +56 -0
  21. package/docs/plan-rules-to-settings.md +217 -0
  22. package/docs/rules-format.en.md +294 -0
  23. package/docs/rules-format.md +279 -0
  24. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  25. package/dsh.plugin.json +2 -2
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/allowlist.d.ts +27 -0
  28. package/lib/allowlist.js +103 -1
  29. package/lib/cli.js +11 -14
  30. package/lib/client.js +484 -6
  31. package/lib/client.js.map +1 -1
  32. package/lib/command-dispatcher.d.ts +22 -0
  33. package/lib/command-dispatcher.js +97 -0
  34. package/lib/command-semantics.d.ts +62 -0
  35. package/lib/command-semantics.js +6 -0
  36. package/lib/compiler.d.ts +11 -0
  37. package/lib/compiler.js +16 -0
  38. package/lib/config.d.ts +73 -12
  39. package/lib/config.js +56 -14
  40. package/lib/dry-run.d.ts +87 -0
  41. package/lib/dry-run.js +110 -0
  42. package/lib/engine.js +52 -2
  43. package/lib/evaluate.js +76 -2
  44. package/lib/events.d.ts +56 -2
  45. package/lib/events.js +92 -0
  46. package/lib/index.js +106 -26
  47. package/lib/parsers/git.d.ts +43 -0
  48. package/lib/parsers/git.js +404 -0
  49. package/lib/parsers/shell-cmds.d.ts +32 -0
  50. package/lib/parsers/shell-cmds.js +293 -0
  51. package/lib/preset.d.ts +13 -0
  52. package/lib/preset.js +17 -0
  53. package/lib/risk.d.ts +17 -3
  54. package/lib/risk.js +17 -3
  55. package/lib/rule-chain.js +13 -22
  56. package/lib/rule-dims.d.ts +35 -0
  57. package/lib/rule-dims.js +36 -0
  58. package/lib/rule.d.ts +36 -1
  59. package/lib/rule.js +56 -8
  60. package/lib/rules-view.d.ts +52 -0
  61. package/lib/rules-view.js +126 -0
  62. package/lib/runtime.d.ts +121 -7
  63. package/lib/runtime.js +204 -25
  64. package/lib/shell.d.ts +14 -0
  65. package/lib/shell.js +17 -0
  66. package/package.json +19 -2
  67. 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
+ }
@@ -0,0 +1,6 @@
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
+ export {};
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
- /** Enable file watching for rule hot-reload. Default true. */
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
- /** Debounce interval for rule file changes in ms. Default 300. */
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
- * Presets in which the gate is active. It owns both 自动审查 tiers — the plain
47
- * one (file sandbox kept) and the full-access one (sandbox restriction lifted,
48
- * same approval behaviour) — so a user can have the gate without inheriting a
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(),
@@ -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
+ }