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.
- 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 +86 -12
- package/README.ko.md +83 -12
- package/README.md +120 -24
- package/README.zh.md +88 -10
- package/cordis.patch.yml +34 -2
- 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 +25 -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 +327 -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/runtime.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { RiskLearning } from './learning.js';
|
|
|
6
6
|
import { type RiskRequest, type RiskVerdict } from './risk.js';
|
|
7
7
|
import { type HostLlmLike, type HostModelSelection } from './host-llm.js';
|
|
8
8
|
import { type CompiledRuleset } from './rule.js';
|
|
9
|
+
import type { NetworkTarget } from './network.js';
|
|
9
10
|
import { type SessionEventLike } from './preset.js';
|
|
10
11
|
export interface PreToolDecisionLike {
|
|
11
12
|
kind: 'deny' | 'ask';
|
|
@@ -73,6 +74,8 @@ interface PendingAsk {
|
|
|
73
74
|
readonly learningKey?: string;
|
|
74
75
|
}
|
|
75
76
|
export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
77
|
+
/** Workspace root for chain resolution. Defaults to process.cwd(). */
|
|
78
|
+
readonly cwd?: string;
|
|
76
79
|
readonly onRulesChanged?: (ruleset: CompiledRuleset) => void;
|
|
77
80
|
readonly now?: () => number;
|
|
78
81
|
/**
|
|
@@ -132,6 +135,24 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
132
135
|
* review page's diff/revert data plane (events are still recorded).
|
|
133
136
|
*/
|
|
134
137
|
readonly snapshotsDir?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Raise one interactive approval request on behalf of a subprocess network
|
|
140
|
+
* connection. Wired by `apply` to the DSH `approval` service
|
|
141
|
+
* (`approval.request`), which routes the prompt to the agent, appends the
|
|
142
|
+
* `approval/asked` + `approval/decided` audit pair, and applies the session
|
|
143
|
+
* policy before any answerer runs.
|
|
144
|
+
*
|
|
145
|
+
* Absent (or throwing) means the connection cannot ride the seam and the
|
|
146
|
+
* gate fails closed to `deny`. Only `'allowed-once'` is treated as an allow.
|
|
147
|
+
*/
|
|
148
|
+
readonly requestApproval?: (req: NetworkApprovalRequest) => Promise<ApprovalOutcomeLike>;
|
|
149
|
+
/**
|
|
150
|
+
* How long one approved network target stays approved for the session (ms).
|
|
151
|
+
* A single shell command routinely opens many connections to the same host;
|
|
152
|
+
* without this the human would be prompted once per connection.
|
|
153
|
+
* Default 30 minutes.
|
|
154
|
+
*/
|
|
155
|
+
readonly networkGrantTtlMs?: number;
|
|
135
156
|
}
|
|
136
157
|
export type CallDecision = 'allow' | 'deny' | 'ask';
|
|
137
158
|
/** The effective Permissive tier state read per tool call. */
|
|
@@ -152,6 +173,30 @@ export interface ApprovalRequestLike {
|
|
|
152
173
|
}
|
|
153
174
|
/** A closed approval outcome, structurally the DSH seam's `ApprovalOutcome`. */
|
|
154
175
|
export type ApprovalOutcomeLike = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
176
|
+
/**
|
|
177
|
+
* One approval ask the gate raises on behalf of a subprocess network
|
|
178
|
+
* connection. Structurally the subset of the DSH `ApprovalRequest` the gate
|
|
179
|
+
* supplies — the host service adds the borrowed session/log handling.
|
|
180
|
+
*/
|
|
181
|
+
export interface NetworkApprovalRequest {
|
|
182
|
+
/** The host agent the question is asked on behalf of (routes the prompt). */
|
|
183
|
+
readonly agent: unknown;
|
|
184
|
+
/** The tool whose subprocess opened the connection. */
|
|
185
|
+
readonly toolName: string;
|
|
186
|
+
/** The exact tool call, when the host gave one — lets the UI attach the prompt. */
|
|
187
|
+
readonly callId?: string;
|
|
188
|
+
readonly reason: string;
|
|
189
|
+
readonly signal?: AbortSignal;
|
|
190
|
+
}
|
|
191
|
+
/** The attribution the proxy layer receives for one connection. */
|
|
192
|
+
export interface ShellAttribution {
|
|
193
|
+
readonly tool: string;
|
|
194
|
+
readonly callId?: string;
|
|
195
|
+
/** The host agent, needed to route an approval request. */
|
|
196
|
+
readonly agent?: unknown;
|
|
197
|
+
readonly sessionId: string;
|
|
198
|
+
readonly signal?: AbortSignal;
|
|
199
|
+
}
|
|
155
200
|
export declare class PermGateRuntime {
|
|
156
201
|
private readonly options;
|
|
157
202
|
private ruleset;
|
|
@@ -165,6 +210,14 @@ export declare class PermGateRuntime {
|
|
|
165
210
|
private readonly gatePresets;
|
|
166
211
|
/** Extra tool names the user classified as safe (config `autoAllowTools`). */
|
|
167
212
|
private readonly autoAllowExtra;
|
|
213
|
+
/** In-flight shell executions, for proxy-layer network attribution. */
|
|
214
|
+
private readonly inFlightShells;
|
|
215
|
+
/** Stable key per execution object (the host call id is not always present). */
|
|
216
|
+
private readonly shellKeys;
|
|
217
|
+
private shellSeq;
|
|
218
|
+
/** Session-scoped network grants: grantKey -> expiry epoch ms. */
|
|
219
|
+
private readonly networkGrants;
|
|
220
|
+
private readonly networkGrantTtlMs;
|
|
168
221
|
/**
|
|
169
222
|
* Cache of the permission-preset fold. The key is the log's length plus the
|
|
170
223
|
* identity of its LAST event: a session's log only appends, so "same length
|
|
@@ -213,6 +266,40 @@ export declare class PermGateRuntime {
|
|
|
213
266
|
/** Deep read-only view of the learning state (diagnostics/tests). */
|
|
214
267
|
learningSnapshot(): ReturnType<RiskLearning['snapshot']>;
|
|
215
268
|
private ctxFor;
|
|
269
|
+
/**
|
|
270
|
+
* Register a shell execution as in-flight so a proxy-layer block made by its
|
|
271
|
+
* child process can be attributed back to the session — and therefore ride
|
|
272
|
+
* the interactive approval seam. Called when the gate lets a shell call run;
|
|
273
|
+
* {@link endShellExecution} removes it.
|
|
274
|
+
*/
|
|
275
|
+
beginShellExecution(exec: ToolExecutionLike): void;
|
|
276
|
+
/** Remove a shell execution from the in-flight table (its tool call settled). */
|
|
277
|
+
endShellExecution(exec: ToolExecutionLike): void;
|
|
278
|
+
/** Drop every in-flight entry (dispose / session teardown). */
|
|
279
|
+
clearShellExecutions(): void;
|
|
280
|
+
/**
|
|
281
|
+
* The newest live in-flight shell execution, for proxy-layer attribution.
|
|
282
|
+
* Stale entries (a shell that never reported a result) are expired here so a
|
|
283
|
+
* long-dead call can never authorize a later connection.
|
|
284
|
+
*/
|
|
285
|
+
currentAttribution(): ShellAttribution | undefined;
|
|
286
|
+
/** A stable per-execution key: the host call id, else an object-keyed fallback. */
|
|
287
|
+
private shellKeyOf;
|
|
288
|
+
/**
|
|
289
|
+
* Ask the human to allow one network target, on behalf of the attributed
|
|
290
|
+
* shell execution. Returns `'allow'` only for an explicit `allowed-once`;
|
|
291
|
+
* every other outcome (rejection, cancellation, timeout, no answerer)
|
|
292
|
+
* fails closed to `'deny'`.
|
|
293
|
+
*
|
|
294
|
+
* The approval seam requires an open turn — the durable audit pair must be
|
|
295
|
+
* enclosed by the log's commit boundary — which holds here because the ask
|
|
296
|
+
* happens *while* the shell tool call is executing.
|
|
297
|
+
*/
|
|
298
|
+
askNetwork(target: NetworkTarget, reason: string, timeoutMs: number): Promise<'allow' | 'deny'>;
|
|
299
|
+
/** Record one network escalation outcome in the audit mirror + event feed. */
|
|
300
|
+
private auditNetworkAsk;
|
|
301
|
+
/** The compiled ruleset (read-only view for network module). */
|
|
302
|
+
get compiledRuleset(): CompiledRuleset;
|
|
216
303
|
private liveRiskLearning;
|
|
217
304
|
private liveRiskSediment;
|
|
218
305
|
private liveClassifySource;
|
package/lib/runtime.js
CHANGED
|
@@ -22,6 +22,9 @@ import { classifyRisk, classifyRiskWith } from './risk.js';
|
|
|
22
22
|
import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
|
|
23
23
|
import { chatCompletion } from './classifier.js';
|
|
24
24
|
import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
|
|
25
|
+
import { decomposeShellCommand } from './shell.js';
|
|
26
|
+
import { extractAgentCandidates } from './agent-identity.js';
|
|
27
|
+
import { resolveRuleChain } from './rule-chain.js';
|
|
25
28
|
import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
|
|
26
29
|
import { permissionPresetOf, presetInScope } from './preset.js';
|
|
27
30
|
import { resolveGatePresets } from './config.js';
|
|
@@ -99,6 +102,82 @@ const CLEARED_CALL_TTL_MS = 5 * 60_000;
|
|
|
99
102
|
* unrecognized wording fails closed (the request is forwarded to the human).
|
|
100
103
|
*/
|
|
101
104
|
const SANDBOX_ESCALATION_REASON = /^escalate sandbox to (workspace-write|danger-full-access): /;
|
|
105
|
+
// --- Deterministic cleanup pre-screen ----------------------------------------
|
|
106
|
+
// Pattern for shell deletion commands (rm, rmdir, Remove-Item) with flags.
|
|
107
|
+
const SHELL_DELETE_RE = /^(?:rm|rmdir|Remove-Item)\b/i;
|
|
108
|
+
// Temp/build artifact directory name patterns (case-insensitive match on each path segment).
|
|
109
|
+
const REGENERABLE_SEGMENT = /(?:^|[\\/])(?:temp|tmp|test[-_]clone|node_modules|\.cache|dist|build|__pycache__|\.pytest_cache|\.git|\.next|\.nuxt|coverage|\.tox|\.venv|venv|env|\.mypy_cache|\.parcel-cache)(?:[\\/]|$)/i;
|
|
110
|
+
// Workspace-relative path tokens that are always regenerable.
|
|
111
|
+
const REGENERABLE_TOKENS = /^(?:node_modules|\.next|__pycache__|\.cache|dist|build|\.git|coverage|\.pytest_cache|\.tox|\.venv|venv|env)$/i;
|
|
112
|
+
/**
|
|
113
|
+
* Deterministic pre-screen for deletion commands. Returns a reason string when
|
|
114
|
+
* the deletion is clearly safe (targeting a regenerable/temp artifact inside the
|
|
115
|
+
* workspace), or undefined when the LLM should decide.
|
|
116
|
+
*
|
|
117
|
+
* This runs BEFORE the LLM risk grader to avoid the LLM's unconditional
|
|
118
|
+
* "Remove-Item → risky:deletion" bias. The LLM is still the fallback for
|
|
119
|
+
* ambiguous cases.
|
|
120
|
+
*/
|
|
121
|
+
function isCleanupSafe(exec) {
|
|
122
|
+
const tool = exec.name;
|
|
123
|
+
const args = exec.arguments ?? {};
|
|
124
|
+
// Only applies to shell/pwsh tools.
|
|
125
|
+
if (!/^(?:pwsh|bash|sh|cmd|shell|terminal)$/i.test(tool))
|
|
126
|
+
return undefined;
|
|
127
|
+
const commandText = typeof args.command === 'string' ? args.command : undefined;
|
|
128
|
+
if (commandText === undefined)
|
|
129
|
+
return undefined;
|
|
130
|
+
let commands = [];
|
|
131
|
+
try {
|
|
132
|
+
commands = decomposeShellCommand(commandText).commands;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const cwd = typeof args.cwd === 'string' ? args.cwd : cwdOf(exec) ?? '';
|
|
138
|
+
const posixCwd = cwd.replace(/\\/g, '/').toLowerCase();
|
|
139
|
+
for (const cmd of commands) {
|
|
140
|
+
if (!SHELL_DELETE_RE.test(cmd.command))
|
|
141
|
+
continue;
|
|
142
|
+
// Collect all non-flag argument tokens as potential targets.
|
|
143
|
+
const targets = cmd.args.filter((a) => !a.startsWith('-'));
|
|
144
|
+
if (targets.length === 0)
|
|
145
|
+
continue;
|
|
146
|
+
let allSafe = true;
|
|
147
|
+
for (const target of targets) {
|
|
148
|
+
const posixTarget = target.replace(/\\/g, '/').toLowerCase();
|
|
149
|
+
// Resolve relative to cwd.
|
|
150
|
+
const fullPath = posixTarget.startsWith('/') || /^[a-z]:\//i.test(posixTarget)
|
|
151
|
+
? posixTarget
|
|
152
|
+
: posixCwd !== '' ? `${posixCwd}/${posixTarget}` : posixTarget;
|
|
153
|
+
// Check if the target path contains a regenerable segment.
|
|
154
|
+
if (REGENERABLE_SEGMENT.test(fullPath))
|
|
155
|
+
continue;
|
|
156
|
+
// Check bare token (e.g. "node_modules" without path separators).
|
|
157
|
+
if (REGENERABLE_TOKENS.test(target))
|
|
158
|
+
continue;
|
|
159
|
+
// Not clearly regenerable — let the LLM decide.
|
|
160
|
+
allSafe = false;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (allSafe) {
|
|
164
|
+
return `deletion targets regenerable/temp artifacts: ${targets.join(', ')}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** A shell whose result never arrived is dropped after this long (ms). */
|
|
170
|
+
const IN_FLIGHT_SHELL_TTL_MS = 30 * 60_000;
|
|
171
|
+
/** Tool names that execute a command string (mirrors evaluate's shell roster). */
|
|
172
|
+
const SHELL_TOOL_NAMES = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
|
|
173
|
+
/** Whether a tool name runs a shell command. */
|
|
174
|
+
function isShellToolName(name) {
|
|
175
|
+
return SHELL_TOOL_NAMES.has(name);
|
|
176
|
+
}
|
|
177
|
+
/** The session-grant key for one network target (scheme/host/port). */
|
|
178
|
+
function networkGrantKey(sessionId, target) {
|
|
179
|
+
return `${sessionId}|${target.scheme ?? ''}|${target.host}|${target.port ?? ''}`;
|
|
180
|
+
}
|
|
102
181
|
export class PermGateRuntime {
|
|
103
182
|
options;
|
|
104
183
|
ruleset;
|
|
@@ -112,6 +191,15 @@ export class PermGateRuntime {
|
|
|
112
191
|
gatePresets;
|
|
113
192
|
/** Extra tool names the user classified as safe (config `autoAllowTools`). */
|
|
114
193
|
autoAllowExtra;
|
|
194
|
+
// ─── Network seam (T2.11) ───────────────────────────────────────────
|
|
195
|
+
/** In-flight shell executions, for proxy-layer network attribution. */
|
|
196
|
+
inFlightShells = new Map();
|
|
197
|
+
/** Stable key per execution object (the host call id is not always present). */
|
|
198
|
+
shellKeys = new WeakMap();
|
|
199
|
+
shellSeq = 0;
|
|
200
|
+
/** Session-scoped network grants: grantKey -> expiry epoch ms. */
|
|
201
|
+
networkGrants = new Map();
|
|
202
|
+
networkGrantTtlMs;
|
|
115
203
|
/**
|
|
116
204
|
* Cache of the permission-preset fold. The key is the log's length plus the
|
|
117
205
|
* identity of its LAST event: a session's log only appends, so "same length
|
|
@@ -148,6 +236,7 @@ export class PermGateRuntime {
|
|
|
148
236
|
this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
|
|
149
237
|
this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
|
|
150
238
|
this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
|
|
239
|
+
this.networkGrantTtlMs = options.networkGrantTtlMs ?? 30 * 60_000;
|
|
151
240
|
this.learning = new RiskLearning(options.learningFile, {
|
|
152
241
|
threshold: () => this.liveRiskLearning().threshold,
|
|
153
242
|
now: options.now,
|
|
@@ -158,12 +247,33 @@ export class PermGateRuntime {
|
|
|
158
247
|
this.ruleset = this.compileInline(options);
|
|
159
248
|
}
|
|
160
249
|
compileInline(options) {
|
|
161
|
-
const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
|
|
162
250
|
const empty = {
|
|
163
251
|
defaultAction: options.defaultAction ?? 'ask',
|
|
164
252
|
deny: [], allow: [], ask: [],
|
|
165
253
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
166
254
|
};
|
|
255
|
+
// Chain mode: when searchUp is enabled, use the multi-file chain resolver.
|
|
256
|
+
if (options.searchUp === true) {
|
|
257
|
+
const cwd = options.cwd ?? process.cwd();
|
|
258
|
+
const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
|
|
259
|
+
try {
|
|
260
|
+
return resolveRuleChain(cwd, {
|
|
261
|
+
rulesFile,
|
|
262
|
+
searchUp: true,
|
|
263
|
+
fallbackPath: options.fallbackPath,
|
|
264
|
+
badFilePolicy: options.badFilePolicy ?? 'fail',
|
|
265
|
+
maxChainLength: options.maxChainLength ?? 10,
|
|
266
|
+
}, {
|
|
267
|
+
maxGlobStars: 2,
|
|
268
|
+
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return empty;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Single-file mode (legacy): read and compile one rules file.
|
|
276
|
+
const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
|
|
167
277
|
if (text === '')
|
|
168
278
|
return empty;
|
|
169
279
|
const hash = documentHash(text);
|
|
@@ -242,8 +352,190 @@ export class PermGateRuntime {
|
|
|
242
352
|
home: cwdOf(exec),
|
|
243
353
|
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
244
354
|
dshHome: this.options.dshHome,
|
|
355
|
+
agentCandidates: extractAgentCandidates(exec),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// ─── Network seam (T2.11) ───────────────────────────────────────────
|
|
359
|
+
/**
|
|
360
|
+
* Register a shell execution as in-flight so a proxy-layer block made by its
|
|
361
|
+
* child process can be attributed back to the session — and therefore ride
|
|
362
|
+
* the interactive approval seam. Called when the gate lets a shell call run;
|
|
363
|
+
* {@link endShellExecution} removes it.
|
|
364
|
+
*/
|
|
365
|
+
beginShellExecution(exec) {
|
|
366
|
+
if (!isShellToolName(exec.name))
|
|
367
|
+
return;
|
|
368
|
+
const key = this.shellKeyOf(exec);
|
|
369
|
+
this.inFlightShells.set(key, {
|
|
370
|
+
key,
|
|
371
|
+
tool: exec.name,
|
|
372
|
+
callId: exec.callId,
|
|
373
|
+
agent: exec.agent,
|
|
374
|
+
sessionId: sessionIdOf(exec),
|
|
375
|
+
signal: exec.signal,
|
|
376
|
+
at: Date.now(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/** Remove a shell execution from the in-flight table (its tool call settled). */
|
|
380
|
+
endShellExecution(exec) {
|
|
381
|
+
if (!isShellToolName(exec.name))
|
|
382
|
+
return;
|
|
383
|
+
this.inFlightShells.delete(this.shellKeyOf(exec));
|
|
384
|
+
}
|
|
385
|
+
/** Drop every in-flight entry (dispose / session teardown). */
|
|
386
|
+
clearShellExecutions() {
|
|
387
|
+
this.inFlightShells.clear();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The newest live in-flight shell execution, for proxy-layer attribution.
|
|
391
|
+
* Stale entries (a shell that never reported a result) are expired here so a
|
|
392
|
+
* long-dead call can never authorize a later connection.
|
|
393
|
+
*/
|
|
394
|
+
currentAttribution() {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
let newest;
|
|
397
|
+
for (const entry of this.inFlightShells.values()) {
|
|
398
|
+
if (now - entry.at > IN_FLIGHT_SHELL_TTL_MS) {
|
|
399
|
+
this.inFlightShells.delete(entry.key);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
// An aborted call was cancelled or rejected: it will never run, so it
|
|
403
|
+
// must not authorize a connection made by some later command.
|
|
404
|
+
if (entry.signal?.aborted === true) {
|
|
405
|
+
this.inFlightShells.delete(entry.key);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (newest === undefined || entry.at >= newest.at)
|
|
409
|
+
newest = entry;
|
|
410
|
+
}
|
|
411
|
+
if (newest === undefined)
|
|
412
|
+
return undefined;
|
|
413
|
+
return {
|
|
414
|
+
tool: newest.tool,
|
|
415
|
+
...(newest.callId !== undefined ? { callId: newest.callId } : {}),
|
|
416
|
+
...(newest.agent !== undefined ? { agent: newest.agent } : {}),
|
|
417
|
+
sessionId: newest.sessionId,
|
|
418
|
+
...(newest.signal !== undefined ? { signal: newest.signal } : {}),
|
|
245
419
|
};
|
|
246
420
|
}
|
|
421
|
+
/** A stable per-execution key: the host call id, else an object-keyed fallback. */
|
|
422
|
+
shellKeyOf(exec) {
|
|
423
|
+
const cached = this.shellKeys.get(exec);
|
|
424
|
+
if (cached !== undefined)
|
|
425
|
+
return cached;
|
|
426
|
+
const key = typeof exec.callId === 'string' && exec.callId !== ''
|
|
427
|
+
? `call:${exec.callId}`
|
|
428
|
+
: `exec:${sessionIdOf(exec)}:${exec.name}:${++this.shellSeq}`;
|
|
429
|
+
this.shellKeys.set(exec, key);
|
|
430
|
+
return key;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Ask the human to allow one network target, on behalf of the attributed
|
|
434
|
+
* shell execution. Returns `'allow'` only for an explicit `allowed-once`;
|
|
435
|
+
* every other outcome (rejection, cancellation, timeout, no answerer)
|
|
436
|
+
* fails closed to `'deny'`.
|
|
437
|
+
*
|
|
438
|
+
* The approval seam requires an open turn — the durable audit pair must be
|
|
439
|
+
* enclosed by the log's commit boundary — which holds here because the ask
|
|
440
|
+
* happens *while* the shell tool call is executing.
|
|
441
|
+
*/
|
|
442
|
+
async askNetwork(target, reason, timeoutMs) {
|
|
443
|
+
const attribution = this.currentAttribution();
|
|
444
|
+
if (attribution === undefined || attribution.agent === undefined) {
|
|
445
|
+
// Unattributable traffic cannot ride the approval seam — fail closed.
|
|
446
|
+
this.auditNetworkAsk(target, 'deny', 'no in-flight shell to attribute the connection to', undefined);
|
|
447
|
+
return 'deny';
|
|
448
|
+
}
|
|
449
|
+
// A session already allowed this exact target: honour it without asking.
|
|
450
|
+
const grantKey = networkGrantKey(attribution.sessionId, target);
|
|
451
|
+
if (this.networkGrants.has(grantKey)) {
|
|
452
|
+
return 'allow';
|
|
453
|
+
}
|
|
454
|
+
const requester = this.options.requestApproval;
|
|
455
|
+
if (requester === undefined) {
|
|
456
|
+
this.auditNetworkAsk(target, 'deny', 'no approval service available', attribution);
|
|
457
|
+
return 'deny';
|
|
458
|
+
}
|
|
459
|
+
const controller = new AbortController();
|
|
460
|
+
let timer;
|
|
461
|
+
// The timeout is raced, not merely signalled. `abort()` only *asks* the
|
|
462
|
+
// callee to withdraw the question; a callee that ignores the signal would
|
|
463
|
+
// otherwise leave this await pending forever and hang the CONNECT. The
|
|
464
|
+
// race guarantees the wait settles no matter what the seam does.
|
|
465
|
+
const timedOut = new Promise((resolve) => {
|
|
466
|
+
timer = setTimeout(() => {
|
|
467
|
+
controller.abort();
|
|
468
|
+
resolve('unavailable');
|
|
469
|
+
}, Math.max(1, timeoutMs));
|
|
470
|
+
});
|
|
471
|
+
let outcome = 'unavailable';
|
|
472
|
+
try {
|
|
473
|
+
outcome = await Promise.race([
|
|
474
|
+
requester({
|
|
475
|
+
agent: attribution.agent,
|
|
476
|
+
toolName: attribution.tool,
|
|
477
|
+
...(attribution.callId !== undefined ? { callId: attribution.callId } : {}),
|
|
478
|
+
reason,
|
|
479
|
+
signal: controller.signal,
|
|
480
|
+
}),
|
|
481
|
+
timedOut,
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// A seam failure (e.g. no open turn) is never an allow.
|
|
486
|
+
outcome = 'unavailable';
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
if (timer !== undefined)
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
}
|
|
492
|
+
if (outcome === 'allowed-once') {
|
|
493
|
+
// Remember it for this session so one command making many connections
|
|
494
|
+
// does not prompt once per connection.
|
|
495
|
+
this.networkGrants.set(grantKey, Date.now() + this.networkGrantTtlMs);
|
|
496
|
+
this.auditNetworkAsk(target, 'allow', 'approved by the human (session grant)', attribution);
|
|
497
|
+
return 'allow';
|
|
498
|
+
}
|
|
499
|
+
this.auditNetworkAsk(target, 'deny', `approval seam returned ${outcome}`, attribution);
|
|
500
|
+
return 'deny';
|
|
501
|
+
}
|
|
502
|
+
/** Record one network escalation outcome in the audit mirror + event feed. */
|
|
503
|
+
auditNetworkAsk(target, verdict, reason, attribution) {
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
const tool = attribution?.tool ?? 'subprocess';
|
|
506
|
+
const detail = `network ${verdict === 'allow' ? 'allowed' : 'denied'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} — ${reason}`;
|
|
507
|
+
try {
|
|
508
|
+
this.audit.append(makeEntry({
|
|
509
|
+
callId: randomId(),
|
|
510
|
+
tool,
|
|
511
|
+
outcome: verdict === 'allow' ? 'allow' : 'deny',
|
|
512
|
+
source: 'rule',
|
|
513
|
+
reason: detail,
|
|
514
|
+
at: now,
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Auditing must never influence the verdict.
|
|
519
|
+
}
|
|
520
|
+
if (this.events !== undefined && attribution?.sessionId !== undefined) {
|
|
521
|
+
try {
|
|
522
|
+
this.events.append({
|
|
523
|
+
sessionId: attribution.sessionId,
|
|
524
|
+
tool,
|
|
525
|
+
kind: verdict === 'allow' ? 'auto' : 'deny',
|
|
526
|
+
reason: detail,
|
|
527
|
+
verdict: 'network-ask',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// Same: the feed is best-effort.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** The compiled ruleset (read-only view for network module). */
|
|
536
|
+
get compiledRuleset() {
|
|
537
|
+
return this.ruleset;
|
|
538
|
+
}
|
|
247
539
|
liveRiskLearning() {
|
|
248
540
|
const read = this.options.readRiskLearning;
|
|
249
541
|
if (read !== undefined)
|
|
@@ -538,6 +830,19 @@ export class PermGateRuntime {
|
|
|
538
830
|
};
|
|
539
831
|
const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
|
|
540
832
|
const decision = this.applyPermissive(raw);
|
|
833
|
+
// Deterministic cleanup pre-screen: if a deletion command targets a
|
|
834
|
+
// regenerable/temporary artifact inside the workspace, allow it directly.
|
|
835
|
+
// This runs BEFORE the ask/passthrough logic so it works even under
|
|
836
|
+
// `approval: never` (where refineAsk is never called).
|
|
837
|
+
if (decision.action === 'ask') {
|
|
838
|
+
const cleanupSafe = isCleanupSafe(exec);
|
|
839
|
+
if (cleanupSafe !== undefined) {
|
|
840
|
+
const reason = `cleanup-safe: ${cleanupSafe}`;
|
|
841
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
842
|
+
this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
|
|
843
|
+
return undefined; // passthrough (allow)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
541
846
|
let outcome;
|
|
542
847
|
let source;
|
|
543
848
|
switch (decision.stage) {
|
|
@@ -644,6 +949,19 @@ export class PermGateRuntime {
|
|
|
644
949
|
return undefined;
|
|
645
950
|
}
|
|
646
951
|
}
|
|
952
|
+
// Defensive cleanup pre-screen: normally this is handled in decideExecution
|
|
953
|
+
// before refineAsk is ever called, but refineAsk is public and may be called
|
|
954
|
+
// directly by tests or future embedders. Keep the same deterministic check
|
|
955
|
+
// here as a safety net — identical logic to decideExecution's pre-screen.
|
|
956
|
+
const cleanupSafe = isCleanupSafe(exec);
|
|
957
|
+
if (cleanupSafe !== undefined) {
|
|
958
|
+
const reason = `cleanup-safe: ${cleanupSafe}`;
|
|
959
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
960
|
+
this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
|
|
961
|
+
this.untrackAsk(exec);
|
|
962
|
+
this.clearCall(exec, reason, 'cleanup-safe', 'classifier');
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
647
965
|
if (!permissive.strategies.llmAssist)
|
|
648
966
|
return decision;
|
|
649
967
|
// One live read of the classifier setup. Without a usable receiver (a
|
|
@@ -656,7 +974,14 @@ export class PermGateRuntime {
|
|
|
656
974
|
&& (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
|
|
657
975
|
return decision;
|
|
658
976
|
}
|
|
659
|
-
|
|
977
|
+
// Inject cwd into the risk request so the LLM can see the full path context
|
|
978
|
+
// (e.g. that a Remove-Item targets a temp directory, not user data).
|
|
979
|
+
const riskArgs = { ...(exec.arguments ?? {}) };
|
|
980
|
+
const cwd = cwdOf(exec);
|
|
981
|
+
if (typeof cwd === 'string' && cwd !== '' && typeof riskArgs.cwd !== 'string') {
|
|
982
|
+
riskArgs.cwd = cwd;
|
|
983
|
+
}
|
|
984
|
+
const req = { tool: exec.name, args: riskArgs, reason: decision.reason };
|
|
660
985
|
let risk;
|
|
661
986
|
if (this.options.riskHook !== undefined) {
|
|
662
987
|
risk = await this.options.riskHook(req);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Session ids grouped by their lifecycle class, all prefix-stripped (see {@link normalizeSessionId}). */
|
|
2
|
+
export interface SessionClassification {
|
|
3
|
+
/** Sessions referenced by some workspace's `sessionIds`. */
|
|
4
|
+
readonly live: ReadonlySet<string>;
|
|
5
|
+
/** Sessions listed in the store's `global.archivedSessionIds`. */
|
|
6
|
+
readonly archived: ReadonlySet<string>;
|
|
7
|
+
}
|
|
8
|
+
/** A session id whose gate data is stale and can be swept. */
|
|
9
|
+
export interface SweepReport {
|
|
10
|
+
/** Decision-event lines removed from events.jsonl. */
|
|
11
|
+
readonly eventsRemoved: number;
|
|
12
|
+
/** Snapshot files deleted from the snapshots directory. */
|
|
13
|
+
readonly snapshotsRemoved: number;
|
|
14
|
+
}
|
|
15
|
+
/** Strip the `session-` prefix so ids from different sources compare equal. */
|
|
16
|
+
export declare function normalizeSessionId(id: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* Classify sessions from the raw text of the workspace store. Returns `null`
|
|
19
|
+
* on unparsable or structurally unexpected content (torn write, format bump) —
|
|
20
|
+
* the caller skips the round. Missing `global`/`tables` sections degrade to
|
|
21
|
+
* empty sets rather than failing: a fresh install has neither.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifySessions(raw: string): SessionClassification | null;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a gate-side session id belongs to an archived or deleted session.
|
|
26
|
+
* An empty id (unattributable legacy row) never matches — the same semantics
|
|
27
|
+
* `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStaleSession(id: string, cls: SessionClassification): boolean;
|
|
30
|
+
/** Options for {@link sweepSessionData}; all paths are plugin-owned except the store. */
|
|
31
|
+
export interface SweepOptions {
|
|
32
|
+
readonly classification: SessionClassification;
|
|
33
|
+
/** The decision-event JSONL feed (plugin data dir). */
|
|
34
|
+
readonly eventsFile: string;
|
|
35
|
+
/** The per-event pre-change snapshot directory (plugin data dir). */
|
|
36
|
+
readonly snapshotsDir: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
|
|
40
|
+
* rewrite, only when something is actually removed) and delete their snapshot
|
|
41
|
+
* files. Both halves are independent: a failure in one still lets the other
|
|
42
|
+
* run. I/O errors are swallowed with a warn — best-effort cleanup, never a
|
|
43
|
+
* gating concern.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sweepSessionData(opts: SweepOptions): SweepReport;
|