dsh-perm-gate 2.1.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.
- package/CHANGELOG.md +111 -0
- package/INSTALL.ja.md +57 -1
- package/INSTALL.ko.md +54 -1
- package/INSTALL.md +53 -1
- package/INSTALL.zh.md +48 -1
- package/README.ja.md +84 -10
- package/README.ko.md +81 -10
- package/README.md +118 -22
- package/README.zh.md +86 -8
- package/cordis.patch.yml +34 -3
- package/dsh.plugin.json +22 -0
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/client.js +114 -0
- package/lib/client.js.map +1 -1
- package/lib/compiler.d.ts +39 -0
- package/lib/compiler.js +106 -0
- package/lib/config.d.ts +95 -2
- package/lib/config.js +28 -3
- package/lib/engine.js +9 -2
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +189 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +31 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +235 -6
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.js +2 -3
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +222 -0
- package/lib/rule-dims.d.ts +139 -0
- package/lib/rule-dims.js +194 -0
- package/lib/rule.d.ts +25 -0
- package/lib/rule.js +15 -1
- package/lib/runtime.d.ts +87 -0
- package/lib/runtime.js +229 -2
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +8 -2
- 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
|
|
132
|
-
*
|
|
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
|
|
47
|
-
*
|
|
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
|
-
//
|
|
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;
|