dsh-perm-gate 2.0.0 → 2.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/INSTALL.ja.md +57 -1
  3. package/INSTALL.ko.md +54 -1
  4. package/INSTALL.md +53 -1
  5. package/INSTALL.zh.md +48 -1
  6. package/README.ja.md +86 -12
  7. package/README.ko.md +83 -12
  8. package/README.md +120 -24
  9. package/README.zh.md +88 -10
  10. package/cordis.patch.yml +34 -2
  11. package/dsh.plugin.json +22 -0
  12. package/lib/agent-identity.d.ts +43 -0
  13. package/lib/agent-identity.js +95 -0
  14. package/lib/client.js +114 -0
  15. package/lib/client.js.map +1 -1
  16. package/lib/compiler.d.ts +39 -0
  17. package/lib/compiler.js +106 -0
  18. package/lib/config.d.ts +95 -2
  19. package/lib/config.js +28 -3
  20. package/lib/engine.js +9 -2
  21. package/lib/evaluate.d.ts +9 -0
  22. package/lib/evaluate.js +189 -0
  23. package/lib/events.d.ts +13 -0
  24. package/lib/events.js +31 -0
  25. package/lib/index.d.ts +1 -1
  26. package/lib/index.js +235 -6
  27. package/lib/network-lifecycle.d.ts +107 -0
  28. package/lib/network-lifecycle.js +180 -0
  29. package/lib/network.d.ts +109 -0
  30. package/lib/network.js +196 -0
  31. package/lib/proxy.d.ts +160 -0
  32. package/lib/proxy.js +529 -0
  33. package/lib/risk.js +25 -3
  34. package/lib/rule-chain.d.ts +39 -0
  35. package/lib/rule-chain.js +222 -0
  36. package/lib/rule-dims.d.ts +139 -0
  37. package/lib/rule-dims.js +194 -0
  38. package/lib/rule.d.ts +25 -0
  39. package/lib/rule.js +15 -1
  40. package/lib/runtime.d.ts +87 -0
  41. package/lib/runtime.js +327 -2
  42. package/lib/session-sweep.d.ts +45 -0
  43. package/lib/session-sweep.js +153 -0
  44. package/lib/shadow.d.ts +34 -0
  45. package/lib/shadow.js +143 -0
  46. package/lib/watch.d.ts +42 -0
  47. package/lib/watch.js +170 -0
  48. package/package.json +8 -2
  49. package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/compiler.d.ts CHANGED
@@ -28,3 +28,42 @@ 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 list of patterns (possibly `!`-prefixed) into param matchers.
61
+ * Returns `{ pattern, negated, compiled }` entries for the params dimension.
62
+ */
63
+ export declare function compileParamPatterns(patterns: readonly string[], opts?: {
64
+ maxStars?: number;
65
+ }): Array<{
66
+ readonly pattern: string;
67
+ readonly negated: boolean;
68
+ readonly compiled: CompiledPattern;
69
+ }>;
package/lib/compiler.js CHANGED
@@ -124,3 +124,109 @@ 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 list of patterns (possibly `!`-prefixed) into param matchers.
222
+ * Returns `{ pattern, negated, compiled }` entries for the params dimension.
223
+ */
224
+ export function compileParamPatterns(patterns, opts = {}) {
225
+ const maxStars = opts.maxStars ?? DEFAULT_MAX_STARS;
226
+ return patterns.map((p) => {
227
+ if (p.startsWith('!') && p.length > 1) {
228
+ return { pattern: p, negated: true, compiled: compileGlob(p.slice(1), { segments: false, maxStars }) };
229
+ }
230
+ return { pattern: p, negated: false, compiled: compileGlob(p, { segments: false, maxStars }) };
231
+ });
232
+ }
package/lib/config.d.ts CHANGED
@@ -100,6 +100,96 @@ export interface PermGateConfig {
100
100
  * never widen authority for a destructive or credential-bearing call.
101
101
  */
102
102
  readonly autoAllowTools?: string[];
103
+ /**
104
+ * Session-lifecycle sweep: on startup and hourly, drop the authorization
105
+ * chain's decision events and pre-change snapshots of sessions DSH has
106
+ * archived or no longer tracks. Fail-open, best-effort. Default true.
107
+ */
108
+ readonly sessionSweep?: boolean;
109
+ /** Path to DSH's workspace store; defaults to `<dshHome>/storages/workspace.json`. Read-only to the gate. */
110
+ readonly workspaceStoreFile?: string;
111
+ /**
112
+ * Whether to enable multi-file rule chain resolution. When true, the gate
113
+ * searches up directory ancestors for the rules file. Default false.
114
+ */
115
+ readonly searchUp?: boolean;
116
+ /** Fallback rules file path when no file is found in the chain search. */
117
+ readonly fallbackPath?: string;
118
+ /** Error policy for malformed files in the chain: `fail` (throw) or `warn` (skip). Default fail. */
119
+ readonly badFilePolicy?: 'fail' | 'warn';
120
+ /** Maximum number of files in the rule chain. Default 10. */
121
+ readonly maxChainLength?: number;
122
+ /**
123
+ * Master network switch. **Default false** — the network proxy is an
124
+ * opt-in capability, not part of the baseline gate.
125
+ *
126
+ * When false: no proxy is started, no environment variables are injected,
127
+ * and no network interception occurs — zero behavior change from the
128
+ * pre-network baseline.
129
+ *
130
+ * Enable it from the settings card only after verifying it in your
131
+ * environment. The proxy binds a loopback port and rewrites proxy env
132
+ * vars for subprocesses, so it must never be enabled implicitly.
133
+ */
134
+ readonly networkEnabled?: boolean;
135
+ /** Network policy mode when auto-mapping from sandbox is not used. Default 'whitelist'. */
136
+ readonly networkMode?: import('./network.js').NetworkMode;
137
+ /** How unlisted targets are handled in whitelist mode.
138
+ *
139
+ * - `'deny'` — block outright, never prompt.
140
+ * - `'ask'` — raise an interactive approval for the attributed shell command;
141
+ * approve to let THIS target through for this session.
142
+ *
143
+ * A `deny` rule always wins: approval can widen reach for a target no rule
144
+ * allows, but it can never override a rule that says no.
145
+ */
146
+ readonly networkUnlisted?: import('./network.js').UnlistedAction;
147
+ /**
148
+ * How traffic with **no shell attribution** is handled. Default `'allow'`.
149
+ *
150
+ * A connection that cannot be tied to an in-flight shell execution did not
151
+ * come from a subprocess this gate manages — it is DSH's own client (a
152
+ * built-in network tool, the LLM transport). The proxy is a *subprocess*
153
+ * policy surface, so reviewing the host's own traffic risks the host
154
+ * blocking itself, which is far worse than a missed block. `'deny'` reviews
155
+ * it anyway, and would break DSH if a built-in client ever honors the proxy
156
+ * environment (e.g. Node 24+ with `NODE_USE_ENV_PROXY=1`).
157
+ */
158
+ readonly networkUnattributed?: import('./network.js').UnattributedAction;
159
+ /** Loopback handling: 'allow' short-circuits before rules; 'policy' evaluates normally. Default 'allow'. */
160
+ readonly networkLoopback?: 'allow' | 'policy';
161
+ /** Proxy bind address. Default '127.0.0.1'. */
162
+ readonly networkBind?: string;
163
+ /** Proxy bind port. Default 0 (ephemeral). */
164
+ readonly networkPort?: number;
165
+ /** NO_PROXY handling: 'clear' empties it so policy cannot be bypassed; 'preserve' keeps ambient values. Default 'clear'. */
166
+ readonly networkNoProxy?: 'clear' | 'preserve';
167
+ /**
168
+ * Rewrite `HTTP(S)_PROXY` / `ALL_PROXY` for subprocesses so their traffic
169
+ * reaches the proxy. Default true.
170
+ *
171
+ * Turn it off to run the proxy WITHOUT touching `process.env` — the
172
+ * listener still adjudicates whatever is explicitly pointed at it, but no
173
+ * ambient state is rewritten. Useful when the environment is managed
174
+ * elsewhere or when verifying the proxy in isolation.
175
+ */
176
+ readonly networkInjectEnv?: boolean;
177
+ /**
178
+ * How long an unlisted-target approval waits for a human before failing
179
+ * closed to a block (ms). Default 120000 (2 min).
180
+ */
181
+ readonly networkAskTimeoutMs?: number;
182
+ /**
183
+ * How long one approved network target stays approved for the session (ms).
184
+ * One shell command routinely opens many connections to the same host, so
185
+ * without this the human would be prompted once per connection.
186
+ * Default 1800000 (30 min).
187
+ */
188
+ readonly networkGrantTtlMs?: number;
189
+ /** Enable file watching for rule hot-reload. Default true. */
190
+ readonly watch?: boolean;
191
+ /** Debounce interval for rule file changes in ms. Default 300. */
192
+ readonly watchDebounceMs?: number;
103
193
  }
104
194
  /** Backend combinable approval strategies for the Permissive tier (all opt-in). */
105
195
  export interface PermissiveStrategies {
@@ -128,8 +218,11 @@ export interface PermissiveStrategies {
128
218
  }
129
219
  export declare const DEFAULT_PERMISSIVE_STRATEGIES: Readonly<PermissiveStrategies>;
130
220
  /**
131
- * Presets in which the gate is active. It owns the `permissive` tier; `'*'`
132
- * makes it global (every preset, including the hard-deny layer).
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).
133
226
  */
134
227
  export declare const DEFAULT_GATE_PRESETS: readonly string[];
135
228
  /** Normalize the gate scope: an unset/empty list means the default. */
package/lib/config.js CHANGED
@@ -43,10 +43,13 @@ export const DEFAULT_PERMISSIVE_STRATEGIES = {
43
43
  trustEscalation: true,
44
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).
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).
48
51
  */
49
- export const DEFAULT_GATE_PRESETS = ['permissive'];
52
+ export const DEFAULT_GATE_PRESETS = ['permissive', 'permissive-full'];
50
53
  /** Normalize the gate scope: an unset/empty list means the default. */
51
54
  export function resolveGatePresets(configured) {
52
55
  if (configured === undefined || configured.length === 0)
@@ -92,6 +95,28 @@ export const Config = z.object({
92
95
  allowlist: z.array(z.string()),
93
96
  denyKeywords: z.array(z.string()),
94
97
  autoAllowTools: z.array(z.string()),
98
+ sessionSweep: z.boolean().default(true),
99
+ workspaceStoreFile: z.string(),
100
+ // Rule chain (T1.9)
101
+ searchUp: z.boolean().default(false),
102
+ fallbackPath: z.string(),
103
+ badFilePolicy: z.union(['fail', 'warn']).default('fail'),
104
+ maxChainLength: z.number().min(1).max(50).default(10),
105
+ // Network (Phase 2)
106
+ networkEnabled: z.boolean().default(false),
107
+ networkMode: z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist'),
108
+ networkUnlisted: z.union(['ask', 'deny']).default('ask'),
109
+ networkUnattributed: z.union(['allow', 'deny']).default('allow'),
110
+ networkLoopback: z.union(['allow', 'policy']).default('allow'),
111
+ networkBind: z.string().default('127.0.0.1'),
112
+ networkPort: z.number().min(0).max(65535).default(0),
113
+ networkNoProxy: z.union(['clear', 'preserve']).default('clear'),
114
+ networkInjectEnv: z.boolean().default(true),
115
+ networkAskTimeoutMs: z.number().min(1000).max(600_000).default(120_000),
116
+ networkGrantTtlMs: z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000),
117
+ // Hot reload (Phase 3)
118
+ watch: z.boolean().default(true),
119
+ watchDebounceMs: z.number().min(50).max(5000).default(300),
95
120
  });
96
121
  export function resolveConfig(config = {}) {
97
122
  const parsed = Config(config);
package/lib/engine.js CHANGED
@@ -19,8 +19,13 @@ const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe
19
19
  */
20
20
  const READ_TOOLS = new Set([
21
21
  'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
22
- // Read-only network / media queries: no write, no exec.
22
+ // Search / lookup: every one of these only READS. `web_search` and
23
+ // `modlens_read_image` also reach the network, but through DSH's own client
24
+ // rather than a subprocess — see the network note below.
23
25
  'web_search', 'modlens_read_image',
26
+ 'advanced_search', 'platform_search', 'free_search_test',
27
+ // Session-scoped reads: they disclose already-recorded state, never mutate.
28
+ 'context_compression_retrieve', 'memory_search_graph', 'memory_expand_graph_node',
24
29
  ]);
25
30
  /** DSH internal coordination / management tools — not workspace-modifying. */
26
31
  const INTERNAL_TOOLS = new Set([
@@ -31,9 +36,11 @@ const INTERNAL_TOOLS = new Set([
31
36
  'agent_teams_approve', 'agent_teams_resume',
32
37
  // AgentTeams status reads
33
38
  'agent_teams_status', 'agent_teams_update_task',
34
- // DSH session/memory/goal/taskboard management
39
+ // DSH session/memory/goal/taskboard management. The memory store is the
40
+ // agent's own state, not the workspace: writing it is not a workspace write.
35
41
  'conversation_search',
36
42
  'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
43
+ 'memory_import', 'memory_ruminate', 'memory_ruminate_cancel', 'memory_ruminate_status',
37
44
  'get_goal', 'update_goal', 'create_goal',
38
45
  'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
39
46
  'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
package/lib/evaluate.d.ts CHANGED
@@ -9,6 +9,15 @@ export interface ToolCallContext {
9
9
  readonly home?: string;
10
10
  readonly dshHome?: string;
11
11
  readonly caseInsensitive?: boolean;
12
+ /** Agent identity candidates from session context. */
13
+ readonly agentCandidates?: readonly string[];
14
+ /** Network request info (for network dimension matching). */
15
+ readonly network?: {
16
+ readonly domain?: string;
17
+ readonly ip?: string;
18
+ readonly port?: number;
19
+ readonly scheme?: string;
20
+ };
12
21
  }
13
22
  export interface Decision {
14
23
  readonly action: RuleAction;
package/lib/evaluate.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { normalizeWorkspacePath } from './path.js';
7
7
  import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
8
8
  import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
9
+ import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
9
10
  /**
10
11
  * Tool names that execute a shell command string. `shell` and `terminal` are
11
12
  * DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
@@ -64,6 +65,46 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
64
65
  if (!rel.some((p) => rule.paths.some((g) => g.re.test(p))))
65
66
  return false;
66
67
  }
68
+ // ─── New dimensions ────────────────────────────────────────────────────
69
+ // params dimension: AND over keys; each key's value must match at least one pattern (OR).
70
+ if (rule.params.length > 0) {
71
+ if (!matchParams(rule.params, ctx.args))
72
+ return false;
73
+ }
74
+ // absent dimension: all listed keys must be absent from args.
75
+ if (rule.absent.length > 0) {
76
+ for (const key of rule.absent) {
77
+ if (key in ctx.args)
78
+ return false;
79
+ }
80
+ }
81
+ // agents dimension: at least one candidate must match an entry.
82
+ if (rule.agents.length > 0) {
83
+ const candidates = ctx.agentCandidates ?? [];
84
+ if (candidates.length === 0)
85
+ return false; // fail-closed
86
+ if (!candidates.some((c) => rule.agents.some((a) => a === c || matchAgentPattern(a, c))))
87
+ return false;
88
+ }
89
+ // when dimension: all conditions must be satisfied.
90
+ if (rule.when !== undefined) {
91
+ if (!matchWhen(rule.when))
92
+ return false;
93
+ }
94
+ // argv dimension: pipeline patterns.
95
+ if (rule.argv?.pipeline !== undefined && rule.argv.pipeline.length > 0) {
96
+ if (commands.length === 0)
97
+ return false;
98
+ const pipelineText = commands.map((c) => c.command).join('|');
99
+ const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
100
+ if (!compiled.some((g) => g.test(pipelineText)))
101
+ return false;
102
+ }
103
+ // network dimension: all present sub-dimensions must match.
104
+ if (rule.network !== undefined) {
105
+ if (!matchNetwork(rule.network, ctx.network))
106
+ return false;
107
+ }
67
108
  return true;
68
109
  }
69
110
  const WRITE_TOOLS = new Set(['write', 'edit']);
@@ -182,3 +223,151 @@ function decomposeSafe(text) {
182
223
  return [];
183
224
  }
184
225
  }
226
+ // ─── New dimension matchers ────────────────────────────────────────────────
227
+ /** Import compileGlob locally to avoid circular dependency with compiler.ts. */
228
+ import { compileGlob } from './compiler.js';
229
+ /** Compile a glob pattern to a RegExp for ad-hoc matching. */
230
+ function compileGlobForMatch(pattern) {
231
+ return compileGlob(pattern, { segments: false }).re;
232
+ }
233
+ /**
234
+ * Match the params dimension: AND over keys, OR within each key's patterns.
235
+ * A negated pattern means the value must NOT match.
236
+ */
237
+ function matchParams(params, args) {
238
+ for (const cond of params) {
239
+ const value = resolveNestedKey(args, cond.key);
240
+ if (cond.patterns.length === 0) {
241
+ // No patterns → key must exist (any value).
242
+ if (value === undefined)
243
+ return false;
244
+ continue;
245
+ }
246
+ if (value === undefined)
247
+ return false;
248
+ const strValue = typeof value === 'string' ? value : JSON.stringify(value);
249
+ const compiled = cond.patterns.map((p) => {
250
+ if (p.startsWith('!') && p.length > 1) {
251
+ return { negated: true, re: compileGlobForMatch(p.slice(1)) };
252
+ }
253
+ return { negated: false, re: compileGlobForMatch(p) };
254
+ });
255
+ if (cond.negated) {
256
+ // Negated: value must NOT match the sole pattern.
257
+ if (compiled[0].re.test(strValue))
258
+ return false;
259
+ }
260
+ else {
261
+ // Normal: value must match at least one pattern (OR).
262
+ if (!compiled.some((c) => !c.negated && c.re.test(strValue)))
263
+ return false;
264
+ }
265
+ }
266
+ return true;
267
+ }
268
+ /**
269
+ * Resolve a dotted key path from a nested object.
270
+ * e.g. `resolveNestedKey({flags: {mode: 'prod'}}, 'flags.mode')` → `'prod'`
271
+ */
272
+ function resolveNestedKey(obj, key) {
273
+ const parts = key.split('.');
274
+ let current = obj;
275
+ for (const part of parts) {
276
+ if (current === null || current === undefined || typeof current !== 'object')
277
+ return undefined;
278
+ current = current[part];
279
+ }
280
+ return current;
281
+ }
282
+ /**
283
+ * Match an agent pattern against an identity candidate.
284
+ * Supports exact match and glob patterns.
285
+ */
286
+ function matchAgentPattern(pattern, candidate) {
287
+ if (pattern === candidate)
288
+ return true;
289
+ // Glob patterns: compile and test.
290
+ if (/[*?[\]]/.test(pattern)) {
291
+ return compileGlobForMatch(pattern).test(candidate);
292
+ }
293
+ return false;
294
+ }
295
+ /**
296
+ * Match the when dimension: all conditions must be satisfied.
297
+ */
298
+ function matchWhen(when) {
299
+ // Platform check.
300
+ if (when.platform !== undefined && when.platform.length > 0) {
301
+ if (!when.platform.includes(process.platform))
302
+ return false;
303
+ }
304
+ // Env var checks.
305
+ if (when.env !== undefined) {
306
+ for (const [varName, allowed] of Object.entries(when.env)) {
307
+ const actual = process.env[varName];
308
+ if (actual === undefined)
309
+ return false;
310
+ if (!allowed.includes(actual))
311
+ return false;
312
+ }
313
+ }
314
+ // Node version (reserved — skip for now).
315
+ return true;
316
+ }
317
+ /**
318
+ * Match the network dimension: all present sub-dimensions must match.
319
+ * Within a sub-dimension, entries are OR.
320
+ */
321
+ function matchNetwork(network, ctx) {
322
+ if (ctx === undefined)
323
+ return false;
324
+ // domains
325
+ if (network.domains !== undefined && network.domains.length > 0) {
326
+ if (ctx.domain === undefined)
327
+ return false;
328
+ const matchers = network.domains.map((d) => compileDomainPattern(d));
329
+ if (!matchers.some((m) => m.re.test(ctx.domain)))
330
+ return false;
331
+ }
332
+ // ips
333
+ if (network.ips !== undefined && network.ips.length > 0) {
334
+ if (ctx.ip === undefined)
335
+ return false;
336
+ const isCidr = (s) => s.includes('/');
337
+ const cidrs = network.ips.filter(isCidr);
338
+ const literals = network.ips.filter((s) => !isCidr(s));
339
+ const ipMatch = literals.includes(ctx.ip) || cidrs.some((c) => {
340
+ try {
341
+ return compileCidr(c)(ctx.ip);
342
+ }
343
+ catch {
344
+ return false;
345
+ }
346
+ });
347
+ if (!ipMatch)
348
+ return false;
349
+ }
350
+ // ports
351
+ if (network.ports !== undefined && network.ports.length > 0) {
352
+ if (ctx.port === undefined)
353
+ return false;
354
+ const matchers = network.ports.map((p) => {
355
+ try {
356
+ return compilePortSpec(p);
357
+ }
358
+ catch {
359
+ return (_) => false;
360
+ }
361
+ });
362
+ if (!matchers.some((m) => m(ctx.port)))
363
+ return false;
364
+ }
365
+ // schemes
366
+ if (network.schemes !== undefined && network.schemes.length > 0) {
367
+ if (ctx.scheme === undefined)
368
+ return false;
369
+ if (!network.schemes.includes(ctx.scheme))
370
+ return false;
371
+ }
372
+ return true;
373
+ }
package/lib/events.d.ts CHANGED
@@ -127,6 +127,7 @@ export interface WebServerLike {
127
127
  export declare const EVENTS_ROUTE = "/api/dsh-perm-gate/events";
128
128
  export declare const LEARNING_ROUTE = "/api/dsh-perm-gate/learning";
129
129
  export declare const HEALTH_ROUTE = "/api/dsh-perm-gate/health";
130
+ export declare const NETWORK_ROUTE = "/api/dsh-perm-gate/network";
130
131
  export declare const RECEIVER_ROUTE = "/api/dsh-perm-gate/receiver";
131
132
  export declare const DIFF_ROUTE = "/api/dsh-perm-gate/diff";
132
133
  export declare const REVERT_ROUTE = "/api/dsh-perm-gate/revert";
@@ -177,6 +178,18 @@ export declare function registerHealthRoute(server: unknown, provider: {
177
178
  detail: string;
178
179
  }>;
179
180
  }): (() => void) | undefined;
181
+ /** The network-state face the settings UI's network section needs. */
182
+ export interface NetworkRouteProvider {
183
+ snapshot(): unknown;
184
+ }
185
+ /**
186
+ * Register the network diagnostics route: `GET /api/dsh-perm-gate/network`
187
+ * returns the live network state (mode, bind, port, proxy liveness, env
188
+ * injection, block counters, recent blocks). Read-only — a policy change goes
189
+ * through the settings namespace, never through HTTP.
190
+ * Returns whether the route was registered.
191
+ */
192
+ export declare function registerNetworkRoute(server: unknown, provider: NetworkRouteProvider): (() => void) | undefined;
180
193
  /** The learning-store face the settings UI's sediment view needs. */
181
194
  export interface LearningRouteProvider {
182
195
  snapshot(): unknown;
package/lib/events.js CHANGED
@@ -351,6 +351,7 @@ function readBody(req, limit = 1024 * 1024) {
351
351
  export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
352
352
  export const LEARNING_ROUTE = '/api/dsh-perm-gate/learning';
353
353
  export const HEALTH_ROUTE = '/api/dsh-perm-gate/health';
354
+ export const NETWORK_ROUTE = '/api/dsh-perm-gate/network';
354
355
  export const RECEIVER_ROUTE = '/api/dsh-perm-gate/receiver';
355
356
  export const DIFF_ROUTE = '/api/dsh-perm-gate/diff';
356
357
  export const REVERT_ROUTE = '/api/dsh-perm-gate/revert';
@@ -651,6 +652,36 @@ export function registerHealthRoute(server, provider) {
651
652
  },
652
653
  });
653
654
  }
655
+ /**
656
+ * Register the network diagnostics route: `GET /api/dsh-perm-gate/network`
657
+ * returns the live network state (mode, bind, port, proxy liveness, env
658
+ * injection, block counters, recent blocks). Read-only — a policy change goes
659
+ * through the settings namespace, never through HTTP.
660
+ * Returns whether the route was registered.
661
+ */
662
+ export function registerNetworkRoute(server, provider) {
663
+ const ws = routeServer(server);
664
+ if (ws === undefined)
665
+ return undefined;
666
+ return ws.register({
667
+ kind: 'exact',
668
+ path: NETWORK_ROUTE,
669
+ handler: (rawReq, rawRes) => {
670
+ const req = rawReq;
671
+ const res = rawRes;
672
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
673
+ json(res, 405, { ok: false, error: 'method not allowed' });
674
+ return;
675
+ }
676
+ try {
677
+ json(res, 200, { ok: true, ...provider.snapshot() });
678
+ }
679
+ catch (e) {
680
+ json(res, 500, { ok: false, error: String(e?.message ?? e) });
681
+ }
682
+ },
683
+ });
684
+ }
654
685
  /**
655
686
  * Register the learning-store routes on the webServer service:
656
687
  * `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
package/lib/index.d.ts CHANGED
@@ -53,5 +53,5 @@ export declare function makeApprovalAnswerer(runtime: Pick<PermGateRuntime, 'set
53
53
  * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
54
54
  * (fail-closed).
55
55
  */
56
- export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk'>): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
56
+ export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk' | 'beginShellExecution'>): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
57
57
  export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;