dsh-perm-gate 2.1.0 → 3.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.
Files changed (77) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +321 -0
  4. package/INSTALL.ja.md +70 -8
  5. package/INSTALL.ko.md +68 -9
  6. package/INSTALL.md +68 -8
  7. package/INSTALL.zh.md +59 -7
  8. package/README.ja.md +103 -16
  9. package/README.ko.md +101 -17
  10. package/README.md +152 -25
  11. package/README.zh.md +99 -12
  12. package/cordis.patch.yml +34 -3
  13. package/docs/baseline-guard.md +56 -0
  14. package/docs/plan-rules-to-settings.md +217 -0
  15. package/docs/rules-format.en.md +294 -0
  16. package/docs/rules-format.md +279 -0
  17. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  18. package/dsh.plugin.json +22 -0
  19. package/examples/permissions.example.yaml +16 -1
  20. package/lib/agent-identity.d.ts +43 -0
  21. package/lib/agent-identity.js +95 -0
  22. package/lib/allowlist.d.ts +27 -0
  23. package/lib/allowlist.js +103 -1
  24. package/lib/cli.js +11 -14
  25. package/lib/client.js +598 -6
  26. package/lib/client.js.map +1 -1
  27. package/lib/command-dispatcher.d.ts +22 -0
  28. package/lib/command-dispatcher.js +97 -0
  29. package/lib/command-semantics.d.ts +62 -0
  30. package/lib/command-semantics.js +6 -0
  31. package/lib/compiler.d.ts +50 -0
  32. package/lib/compiler.js +122 -0
  33. package/lib/config.d.ts +161 -7
  34. package/lib/config.js +78 -11
  35. package/lib/dry-run.d.ts +87 -0
  36. package/lib/dry-run.js +110 -0
  37. package/lib/engine.js +61 -4
  38. package/lib/evaluate.d.ts +9 -0
  39. package/lib/evaluate.js +264 -1
  40. package/lib/events.d.ts +69 -2
  41. package/lib/events.js +123 -0
  42. package/lib/index.d.ts +1 -1
  43. package/lib/index.js +320 -11
  44. package/lib/network-lifecycle.d.ts +107 -0
  45. package/lib/network-lifecycle.js +180 -0
  46. package/lib/network.d.ts +109 -0
  47. package/lib/network.js +196 -0
  48. package/lib/parsers/git.d.ts +43 -0
  49. package/lib/parsers/git.js +404 -0
  50. package/lib/parsers/shell-cmds.d.ts +32 -0
  51. package/lib/parsers/shell-cmds.js +293 -0
  52. package/lib/preset.d.ts +13 -0
  53. package/lib/preset.js +17 -0
  54. package/lib/proxy.d.ts +160 -0
  55. package/lib/proxy.js +529 -0
  56. package/lib/risk.d.ts +17 -3
  57. package/lib/risk.js +19 -6
  58. package/lib/rule-chain.d.ts +39 -0
  59. package/lib/rule-chain.js +213 -0
  60. package/lib/rule-dims.d.ts +174 -0
  61. package/lib/rule-dims.js +230 -0
  62. package/lib/rule.d.ts +60 -0
  63. package/lib/rule.js +69 -7
  64. package/lib/rules-view.d.ts +52 -0
  65. package/lib/rules-view.js +126 -0
  66. package/lib/runtime.d.ts +208 -7
  67. package/lib/runtime.js +433 -27
  68. package/lib/session-sweep.d.ts +45 -0
  69. package/lib/session-sweep.js +153 -0
  70. package/lib/shadow.d.ts +34 -0
  71. package/lib/shadow.js +143 -0
  72. package/lib/shell.d.ts +14 -0
  73. package/lib/shell.js +17 -0
  74. package/lib/watch.d.ts +42 -0
  75. package/lib/watch.js +170 -0
  76. package/package.json +15 -2
  77. package/scripts/patch-permission-glyph.mjs +209 -0
@@ -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
@@ -28,3 +28,53 @@ export declare function compileGlob(pattern: string, options?: {
28
28
  export declare function compileLiteral(pattern: string): CompiledPattern;
29
29
  /** Stable SHA-256 content hash (used for rule-file compile caching). */
30
30
  export declare function hashText(text: string): string;
31
+ /**
32
+ * Compile a CIDR notation string (e.g. `10.0.0.0/8`) into a matcher that
33
+ * tests whether an IPv4 address falls within the range.
34
+ *
35
+ * Returns a pure function `(ip: string) => boolean`. Non-IPv4 inputs always
36
+ * return false. Invalid CIDR throws at compile time (fail-loud).
37
+ */
38
+ export declare function compileCidr(cidr: string): (ip: string) => boolean;
39
+ /**
40
+ * Compile a port specification into a matcher function.
41
+ *
42
+ * Accepts:
43
+ * - A single port: `"443"` → matches exactly 443
44
+ * - A port range: `"8000-9000"` → matches 8000–9000 inclusive
45
+ *
46
+ * Returns `(port: number) => boolean`. Invalid specs throw at compile time.
47
+ */
48
+ export declare function compilePortSpec(spec: string): (port: number) => boolean;
49
+ /**
50
+ * Compile a domain pattern into a matcher that supports subdomain inclusion.
51
+ *
52
+ * - `"github.com"` matches `github.com` AND any subdomain (`api.github.com`)
53
+ * - `"*.example.com"` matches only subdomains, not `example.com` itself
54
+ * - Glob characters (`*`, `?`, `[...]`) are handled by {@link compileGlob}.
55
+ *
56
+ * Returns a {@link CompiledPattern} whose `.re` is the anchored RegExp.
57
+ */
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;
70
+ /**
71
+ * Compile a list of patterns (possibly `!`-prefixed) into param matchers.
72
+ * Returns `{ pattern, negated, compiled }` entries for the params dimension.
73
+ */
74
+ export declare function compileParamPatterns(patterns: readonly string[], opts?: {
75
+ maxStars?: number;
76
+ }): Array<{
77
+ readonly pattern: string;
78
+ readonly negated: boolean;
79
+ readonly compiled: CompiledPattern;
80
+ }>;
package/lib/compiler.js CHANGED
@@ -124,3 +124,125 @@ function escapeRegexChar(ch) {
124
124
  export function hashText(text) {
125
125
  return createHash('sha256').update(text, 'utf8').digest('hex');
126
126
  }
127
+ // ─── Extended matchers for new dimensions ──────────────────────────────────
128
+ /**
129
+ * Compile a CIDR notation string (e.g. `10.0.0.0/8`) into a matcher that
130
+ * tests whether an IPv4 address falls within the range.
131
+ *
132
+ * Returns a pure function `(ip: string) => boolean`. Non-IPv4 inputs always
133
+ * return false. Invalid CIDR throws at compile time (fail-loud).
134
+ */
135
+ export function compileCidr(cidr) {
136
+ const slash = cidr.indexOf('/');
137
+ if (slash <= 0 || slash >= cidr.length - 1) {
138
+ throw new PatternError(`CIDR "${cidr}" must be in address/prefix format`);
139
+ }
140
+ const addrStr = cidr.slice(0, slash);
141
+ const prefixStr = cidr.slice(slash + 1);
142
+ const prefix = Number.parseInt(prefixStr, 10);
143
+ if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) {
144
+ throw new PatternError(`CIDR "${cidr}" prefix must be 0–32`);
145
+ }
146
+ const addr = ipv4ToNumber(addrStr);
147
+ if (addr === undefined) {
148
+ throw new PatternError(`CIDR "${cidr}" has invalid IPv4 address`);
149
+ }
150
+ const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
151
+ const network = (addr & mask) >>> 0;
152
+ return (ip) => {
153
+ const n = ipv4ToNumber(ip);
154
+ if (n === undefined)
155
+ return false;
156
+ return ((n & mask) >>> 0) === network;
157
+ };
158
+ }
159
+ /** Parse an IPv4 dotted-decimal string to a 32-bit unsigned integer, or undefined. */
160
+ function ipv4ToNumber(addr) {
161
+ const parts = addr.split('.');
162
+ if (parts.length !== 4)
163
+ return undefined;
164
+ let result = 0;
165
+ for (const part of parts) {
166
+ const n = Number.parseInt(part, 10);
167
+ if (!Number.isFinite(n) || n < 0 || n > 255)
168
+ return undefined;
169
+ if (part.length > 1 && part.startsWith('0'))
170
+ return undefined; // no leading zeros
171
+ result = (result * 256 + n) >>> 0;
172
+ }
173
+ return result;
174
+ }
175
+ /**
176
+ * Compile a port specification into a matcher function.
177
+ *
178
+ * Accepts:
179
+ * - A single port: `"443"` → matches exactly 443
180
+ * - A port range: `"8000-9000"` → matches 8000–9000 inclusive
181
+ *
182
+ * Returns `(port: number) => boolean`. Invalid specs throw at compile time.
183
+ */
184
+ export function compilePortSpec(spec) {
185
+ const dash = spec.indexOf('-');
186
+ if (dash === -1) {
187
+ const n = Number.parseInt(spec, 10);
188
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
189
+ throw new PatternError(`port "${spec}" must be 0–65535`);
190
+ }
191
+ return (port) => port === n;
192
+ }
193
+ const lo = Number.parseInt(spec.slice(0, dash), 10);
194
+ const hi = Number.parseInt(spec.slice(dash + 1), 10);
195
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo < 0 || hi > 65535 || lo > hi) {
196
+ throw new PatternError(`port range "${spec}" must be low–high within 0–65535`);
197
+ }
198
+ return (port) => port >= lo && port <= hi;
199
+ }
200
+ /**
201
+ * Compile a domain pattern into a matcher that supports subdomain inclusion.
202
+ *
203
+ * - `"github.com"` matches `github.com` AND any subdomain (`api.github.com`)
204
+ * - `"*.example.com"` matches only subdomains, not `example.com` itself
205
+ * - Glob characters (`*`, `?`, `[...]`) are handled by {@link compileGlob}.
206
+ *
207
+ * Returns a {@link CompiledPattern} whose `.re` is the anchored RegExp.
208
+ */
209
+ export function compileDomainPattern(pattern) {
210
+ // If the pattern already contains glob chars, use glob compilation.
211
+ if (/[*?[\]]/.test(pattern)) {
212
+ return compileGlob(pattern, { segments: false, maxStars: DEFAULT_MAX_STARS });
213
+ }
214
+ // Literal domain: match exactly OR as a parent of a subdomain.
215
+ // e.g. "github.com" matches "github.com" and "api.github.com"
216
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
217
+ const re = new RegExp(`^(?:.*\\.)?${escaped}$`, 'u');
218
+ return { source: pattern, re };
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
+ }
236
+ /**
237
+ * Compile a list of patterns (possibly `!`-prefixed) into param matchers.
238
+ * Returns `{ pattern, negated, compiled }` entries for the params dimension.
239
+ */
240
+ export function compileParamPatterns(patterns, opts = {}) {
241
+ const maxStars = opts.maxStars ?? DEFAULT_MAX_STARS;
242
+ return patterns.map((p) => {
243
+ if (p.startsWith('!') && p.length > 1) {
244
+ return { pattern: p, negated: true, compiled: compileGlob(p.slice(1), { segments: false, maxStars }) };
245
+ }
246
+ return { pattern: p, negated: false, compiled: compileGlob(p, { segments: false, maxStars }) };
247
+ });
248
+ }
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
@@ -100,6 +101,96 @@ export interface PermGateConfig {
100
101
  * never widen authority for a destructive or credential-bearing call.
101
102
  */
102
103
  readonly autoAllowTools?: string[];
104
+ /**
105
+ * Session-lifecycle sweep: on startup and hourly, drop the authorization
106
+ * chain's decision events and pre-change snapshots of sessions DSH has
107
+ * archived or no longer tracks. Fail-open, best-effort. Default true.
108
+ */
109
+ readonly sessionSweep?: boolean;
110
+ /** Path to DSH's workspace store; defaults to `<dshHome>/storages/workspace.json`. Read-only to the gate. */
111
+ readonly workspaceStoreFile?: string;
112
+ /**
113
+ * Whether to enable multi-file rule chain resolution. When true, the gate
114
+ * searches up directory ancestors for the rules file. Default false.
115
+ */
116
+ readonly searchUp?: boolean;
117
+ /** Fallback rules file path when no file is found in the chain search. */
118
+ readonly fallbackPath?: string;
119
+ /** Error policy for malformed files in the chain: `fail` (throw) or `warn` (skip). Default fail. */
120
+ readonly badFilePolicy?: 'fail' | 'warn';
121
+ /** Maximum number of files in the rule chain. Default 10. */
122
+ readonly maxChainLength?: number;
123
+ /**
124
+ * Master network switch. **Default false** — the network proxy is an
125
+ * opt-in capability, not part of the baseline gate.
126
+ *
127
+ * When false: no proxy is started, no environment variables are injected,
128
+ * and no network interception occurs — zero behavior change from the
129
+ * pre-network baseline.
130
+ *
131
+ * Enable it from the settings card only after verifying it in your
132
+ * environment. The proxy binds a loopback port and rewrites proxy env
133
+ * vars for subprocesses, so it must never be enabled implicitly.
134
+ */
135
+ readonly networkEnabled?: boolean;
136
+ /** Network policy mode when auto-mapping from sandbox is not used. Default 'whitelist'. */
137
+ readonly networkMode?: import('./network.js').NetworkMode;
138
+ /** How unlisted targets are handled in whitelist mode.
139
+ *
140
+ * - `'deny'` — block outright, never prompt.
141
+ * - `'ask'` — raise an interactive approval for the attributed shell command;
142
+ * approve to let THIS target through for this session.
143
+ *
144
+ * A `deny` rule always wins: approval can widen reach for a target no rule
145
+ * allows, but it can never override a rule that says no.
146
+ */
147
+ readonly networkUnlisted?: import('./network.js').UnlistedAction;
148
+ /**
149
+ * How traffic with **no shell attribution** is handled. Default `'allow'`.
150
+ *
151
+ * A connection that cannot be tied to an in-flight shell execution did not
152
+ * come from a subprocess this gate manages — it is DSH's own client (a
153
+ * built-in network tool, the LLM transport). The proxy is a *subprocess*
154
+ * policy surface, so reviewing the host's own traffic risks the host
155
+ * blocking itself, which is far worse than a missed block. `'deny'` reviews
156
+ * it anyway, and would break DSH if a built-in client ever honors the proxy
157
+ * environment (e.g. Node 24+ with `NODE_USE_ENV_PROXY=1`).
158
+ */
159
+ readonly networkUnattributed?: import('./network.js').UnattributedAction;
160
+ /** Loopback handling: 'allow' short-circuits before rules; 'policy' evaluates normally. Default 'allow'. */
161
+ readonly networkLoopback?: 'allow' | 'policy';
162
+ /** Proxy bind address. Default '127.0.0.1'. */
163
+ readonly networkBind?: string;
164
+ /** Proxy bind port. Default 0 (ephemeral). */
165
+ readonly networkPort?: number;
166
+ /** NO_PROXY handling: 'clear' empties it so policy cannot be bypassed; 'preserve' keeps ambient values. Default 'clear'. */
167
+ readonly networkNoProxy?: 'clear' | 'preserve';
168
+ /**
169
+ * Rewrite `HTTP(S)_PROXY` / `ALL_PROXY` for subprocesses so their traffic
170
+ * reaches the proxy. Default true.
171
+ *
172
+ * Turn it off to run the proxy WITHOUT touching `process.env` — the
173
+ * listener still adjudicates whatever is explicitly pointed at it, but no
174
+ * ambient state is rewritten. Useful when the environment is managed
175
+ * elsewhere or when verifying the proxy in isolation.
176
+ */
177
+ readonly networkInjectEnv?: boolean;
178
+ /**
179
+ * How long an unlisted-target approval waits for a human before failing
180
+ * closed to a block (ms). Default 120000 (2 min).
181
+ */
182
+ readonly networkAskTimeoutMs?: number;
183
+ /**
184
+ * How long one approved network target stays approved for the session (ms).
185
+ * One shell command routinely opens many connections to the same host, so
186
+ * without this the human would be prompted once per connection.
187
+ * Default 1800000 (30 min).
188
+ */
189
+ readonly networkGrantTtlMs?: number;
190
+ /** @deprecated Rules now live in the `dsh-perm-gate-rules` settings namespace; file watching was removed. Accepted (ignored) for composition compatibility. */
191
+ readonly watch?: boolean;
192
+ /** @deprecated See {@link watch}. */
193
+ readonly watchDebounceMs?: number;
103
194
  }
104
195
  /** Backend combinable approval strategies for the Permissive tier (all opt-in). */
105
196
  export interface PermissiveStrategies {
@@ -127,15 +218,78 @@ export interface PermissiveStrategies {
127
218
  readonly trustEscalation: boolean;
128
219
  }
129
220
  export declare const DEFAULT_PERMISSIVE_STRATEGIES: Readonly<PermissiveStrategies>;
130
- /**
131
- * Presets in which the gate is active. It owns the `permissive` tier; `'*'`
132
- * makes it global (every preset, including the hard-deny layer).
133
- */
134
- export declare const DEFAULT_GATE_PRESETS: readonly string[];
135
- /** Normalize the gate scope: an unset/empty list means the default. */
136
- export declare function resolveGatePresets(configured?: readonly string[]): readonly string[];
221
+ export { DEFAULT_GATE_PRESETS, resolveGatePresets };
137
222
  /** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
138
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;
139
293
  export declare const Config: z<PermGateConfig>;
140
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'> & {
141
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,17 +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 the `permissive` tier; `'*'`
47
- * makes it global (every preset, including the hard-deny layer).
48
- */
49
- export const DEFAULT_GATE_PRESETS = ['permissive'];
50
- /** Normalize the gate scope: an unset/empty list means the default. */
51
- export function resolveGatePresets(configured) {
52
- if (configured === undefined || configured.length === 0)
53
- return DEFAULT_GATE_PRESETS;
54
- return configured.filter((name) => typeof name === 'string' && name !== '');
55
- }
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 };
56
50
  /** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
57
51
  export function resolvePermissiveStrategies(bag = {}) {
58
52
  return {
@@ -62,6 +56,57 @@ export function resolvePermissiveStrategies(bag = {}) {
62
56
  trustEscalation: bag.trustEscalation ?? DEFAULT_PERMISSIVE_STRATEGIES.trustEscalation,
63
57
  };
64
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
+ }
65
110
  export const Config = z.object({
66
111
  rulesFile: z.string(),
67
112
  dshHome: z.string(),
@@ -92,6 +137,28 @@ export const Config = z.object({
92
137
  allowlist: z.array(z.string()),
93
138
  denyKeywords: z.array(z.string()),
94
139
  autoAllowTools: z.array(z.string()),
140
+ sessionSweep: z.boolean().default(true),
141
+ workspaceStoreFile: z.string(),
142
+ // Rule chain (T1.9)
143
+ searchUp: z.boolean().default(false),
144
+ fallbackPath: z.string(),
145
+ badFilePolicy: z.union(['fail', 'warn']).default('fail'),
146
+ maxChainLength: z.number().min(1).max(50).default(10),
147
+ // Network (Phase 2)
148
+ networkEnabled: z.boolean().default(false),
149
+ networkMode: z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist'),
150
+ networkUnlisted: z.union(['ask', 'deny']).default('ask'),
151
+ networkUnattributed: z.union(['allow', 'deny']).default('allow'),
152
+ networkLoopback: z.union(['allow', 'policy']).default('allow'),
153
+ networkBind: z.string().default('127.0.0.1'),
154
+ networkPort: z.number().min(0).max(65535).default(0),
155
+ networkNoProxy: z.union(['clear', 'preserve']).default('clear'),
156
+ networkInjectEnv: z.boolean().default(true),
157
+ networkAskTimeoutMs: z.number().min(1000).max(600_000).default(120_000),
158
+ networkGrantTtlMs: z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000),
159
+ // Hot reload (Phase 3)
160
+ watch: z.boolean().default(true),
161
+ watchDebounceMs: z.number().min(50).max(5000).default(300),
95
162
  });
96
163
  export function resolveConfig(config = {}) {
97
164
  const parsed = Config(config);