dsh-perm-gate 0.2.1-beta.2 → 0.2.1-beta.4
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.ja.md +35 -1
- package/CHANGELOG.ko.md +34 -1
- package/CHANGELOG.md +60 -2
- package/INSTALL.ja.md +18 -3
- package/INSTALL.ko.md +18 -3
- package/INSTALL.md +17 -3
- package/INSTALL.zh.md +16 -3
- package/README.ja.md +59 -8
- package/README.ko.md +58 -8
- package/README.md +58 -22
- package/README.zh.md +44 -18
- package/lib/client.js +27 -0
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +16 -0
- package/lib/config.js +6 -0
- package/lib/index.d.ts +32 -6
- package/lib/index.js +79 -36
- package/lib/runtime.d.ts +43 -0
- package/lib/runtime.js +123 -0
- package/package.json +17 -8
package/lib/config.d.ts
CHANGED
|
@@ -109,6 +109,22 @@ export interface PermissiveStrategies {
|
|
|
109
109
|
readonly alwaysConfirm: boolean;
|
|
110
110
|
/** LLM-assist classify first, human fallback on uncertainty or classifier failure. */
|
|
111
111
|
readonly llmAssist: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Answer a sandbox-escalation approval for a call the gate already cleared.
|
|
114
|
+
*
|
|
115
|
+
* The escalation ask is raised from inside the tool body (`ctx.approval.request`)
|
|
116
|
+
* after `tools/pre-execute` has finished, so the gate's own allow never reaches
|
|
117
|
+
* it: without this strategy, a call the gate auto-allowed still prompts the human
|
|
118
|
+
* for the privilege widening. With it on, an `approval/request` whose reason is
|
|
119
|
+
* `escalate sandbox to <mode>: …` and whose `callId` the gate positively cleared
|
|
120
|
+
* is answered `allowed-once` here instead of being forwarded to the answerers.
|
|
121
|
+
*
|
|
122
|
+
* Only a call the gate *allowed* qualifies, the target must be a known sandbox
|
|
123
|
+
* mode, and any unrecognized request still delegates to the human — fail-closed.
|
|
124
|
+
* Turn it off to keep sandbox widening human-gated while other allows stay
|
|
125
|
+
* automatic.
|
|
126
|
+
*/
|
|
127
|
+
readonly trustEscalation: boolean;
|
|
112
128
|
}
|
|
113
129
|
export declare const DEFAULT_PERMISSIVE_STRATEGIES: Readonly<PermissiveStrategies>;
|
|
114
130
|
/**
|
package/lib/config.js
CHANGED
|
@@ -37,6 +37,10 @@ export const DEFAULT_PERMISSIVE_STRATEGIES = {
|
|
|
37
37
|
trustAutoAllow: true,
|
|
38
38
|
alwaysConfirm: false,
|
|
39
39
|
llmAssist: false,
|
|
40
|
+
// On inside the tier: the tier already owns the allow decision for the call, and
|
|
41
|
+
// the classifier's hard categories (deletion / credential / remote / system /
|
|
42
|
+
// bulk) — the escalation-worthy ones — auto-deny rather than allow.
|
|
43
|
+
trustEscalation: true,
|
|
40
44
|
};
|
|
41
45
|
/**
|
|
42
46
|
* Presets in which the gate is active. It owns the `permissive` tier; `'*'`
|
|
@@ -55,6 +59,7 @@ export function resolvePermissiveStrategies(bag = {}) {
|
|
|
55
59
|
trustAutoAllow: bag.trustAutoAllow ?? DEFAULT_PERMISSIVE_STRATEGIES.trustAutoAllow,
|
|
56
60
|
alwaysConfirm: bag.alwaysConfirm ?? DEFAULT_PERMISSIVE_STRATEGIES.alwaysConfirm,
|
|
57
61
|
llmAssist: bag.llmAssist ?? DEFAULT_PERMISSIVE_STRATEGIES.llmAssist,
|
|
62
|
+
trustEscalation: bag.trustEscalation ?? DEFAULT_PERMISSIVE_STRATEGIES.trustEscalation,
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
export const Config = z.object({
|
|
@@ -82,6 +87,7 @@ export const Config = z.object({
|
|
|
82
87
|
trustAutoAllow: z.boolean().default(true),
|
|
83
88
|
alwaysConfirm: z.boolean().default(false),
|
|
84
89
|
llmAssist: z.boolean().default(false),
|
|
90
|
+
trustEscalation: z.boolean().default(true),
|
|
85
91
|
}),
|
|
86
92
|
allowlist: z.array(z.string()),
|
|
87
93
|
denyKeywords: z.array(z.string()),
|
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Config } from './config.js';
|
|
10
|
-
import { PermGateRuntime } from './runtime.js';
|
|
10
|
+
import { PermGateRuntime, type ToolExecutionLike } from './runtime.js';
|
|
11
11
|
export declare const name = "dsh-perm-gate";
|
|
12
12
|
/**
|
|
13
13
|
* The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
|
|
@@ -21,11 +21,37 @@ export { Config };
|
|
|
21
21
|
/** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
|
|
22
22
|
export declare const PERMISSIVE_NAMESPACE = "dsh-perm-gate";
|
|
23
23
|
/**
|
|
24
|
-
* Build the
|
|
24
|
+
* Build the `approval/request` listener (exported for tests).
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Two jobs, in this order:
|
|
27
|
+
*
|
|
28
|
+
* 1. **Answer an escalation the gate already decided.** A sandbox escalation is
|
|
29
|
+
* raised by `approveEscalation` from *inside* a shell/filesystem tool body —
|
|
30
|
+
* after `tools/pre-execute` settled — so the gate's own allow never reaches it
|
|
31
|
+
* and a call it auto-allowed would still prompt the human for the privilege
|
|
32
|
+
* widening. {@link PermGateRuntime.answerEscalation} returns `allowed-once` for
|
|
33
|
+
* exactly that case (the Permissive tier on, `trustEscalation` on, a positively
|
|
34
|
+
* cleared `callId` with a matching tool, a recognized escalation reason and
|
|
35
|
+
* target mode) and `undefined` for every other request, which then delegates
|
|
36
|
+
* unchanged.
|
|
37
|
+
* 2. **Record the terminal outcome** of an ask the gate did raise. This is
|
|
38
|
+
* best-effort: a throw here would be normalized by the approval service to
|
|
39
|
+
* `unavailable` — a rejection on the human's behalf.
|
|
40
|
+
*/
|
|
41
|
+
export declare function makeApprovalAnswerer(runtime: Pick<PermGateRuntime, 'settleAskOutcome' | 'answerEscalation'>): (req: unknown, next: () => Promise<unknown>) => Promise<unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Build the `tools/pre-execute` waterfall listener (exported for tests).
|
|
44
|
+
*
|
|
45
|
+
* The llmAssist refinement is awaited **before** the decision leaves the
|
|
46
|
+
* waterfall. An ask that has been returned to the host is already on its way to
|
|
47
|
+
* the approval answerers, and DSH offers no API to retract it — the request's
|
|
48
|
+
* own `signal` can only settle it `cancelled` — so a `safe` verdict learned
|
|
49
|
+
* afterwards could be *recorded* but never acted on: the human still had to
|
|
50
|
+
* click. Grading here is what turns `safe` into a real auto-allow and keeps the
|
|
51
|
+
* prompt for the genuinely uncertain verdicts only (`risky:neutral`,
|
|
52
|
+
* `unresolved`, transport failure). The wait is bounded by the classifier's own
|
|
53
|
+
* `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
|
|
54
|
+
* (fail-closed).
|
|
29
55
|
*/
|
|
30
|
-
export declare function
|
|
56
|
+
export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk'>): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
|
|
31
57
|
export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;
|
package/lib/index.js
CHANGED
|
@@ -78,14 +78,36 @@ function buildSessionSender(get) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
|
-
* Build the
|
|
81
|
+
* Build the `approval/request` listener (exported for tests).
|
|
82
82
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* Two jobs, in this order:
|
|
84
|
+
*
|
|
85
|
+
* 1. **Answer an escalation the gate already decided.** A sandbox escalation is
|
|
86
|
+
* raised by `approveEscalation` from *inside* a shell/filesystem tool body —
|
|
87
|
+
* after `tools/pre-execute` settled — so the gate's own allow never reaches it
|
|
88
|
+
* and a call it auto-allowed would still prompt the human for the privilege
|
|
89
|
+
* widening. {@link PermGateRuntime.answerEscalation} returns `allowed-once` for
|
|
90
|
+
* exactly that case (the Permissive tier on, `trustEscalation` on, a positively
|
|
91
|
+
* cleared `callId` with a matching tool, a recognized escalation reason and
|
|
92
|
+
* target mode) and `undefined` for every other request, which then delegates
|
|
93
|
+
* unchanged.
|
|
94
|
+
* 2. **Record the terminal outcome** of an ask the gate did raise. This is
|
|
95
|
+
* best-effort: a throw here would be normalized by the approval service to
|
|
96
|
+
* `unavailable` — a rejection on the human's behalf.
|
|
86
97
|
*/
|
|
87
|
-
export function
|
|
98
|
+
export function makeApprovalAnswerer(runtime) {
|
|
88
99
|
return async (req, next) => {
|
|
100
|
+
let answered;
|
|
101
|
+
try {
|
|
102
|
+
answered = runtime.answerEscalation(req);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// A gate failure must never reject on the human's behalf: fall through to
|
|
106
|
+
// the ordinary answerers.
|
|
107
|
+
answered = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (answered !== undefined)
|
|
110
|
+
return answered;
|
|
89
111
|
const outcome = await next();
|
|
90
112
|
try {
|
|
91
113
|
const callId = req?.callId;
|
|
@@ -97,6 +119,42 @@ export function makeApprovalObserver(runtime) {
|
|
|
97
119
|
return outcome;
|
|
98
120
|
};
|
|
99
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Build the `tools/pre-execute` waterfall listener (exported for tests).
|
|
124
|
+
*
|
|
125
|
+
* The llmAssist refinement is awaited **before** the decision leaves the
|
|
126
|
+
* waterfall. An ask that has been returned to the host is already on its way to
|
|
127
|
+
* the approval answerers, and DSH offers no API to retract it — the request's
|
|
128
|
+
* own `signal` can only settle it `cancelled` — so a `safe` verdict learned
|
|
129
|
+
* afterwards could be *recorded* but never acted on: the human still had to
|
|
130
|
+
* click. Grading here is what turns `safe` into a real auto-allow and keeps the
|
|
131
|
+
* prompt for the genuinely uncertain verdicts only (`risky:neutral`,
|
|
132
|
+
* `unresolved`, transport failure). The wait is bounded by the classifier's own
|
|
133
|
+
* `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
|
|
134
|
+
* (fail-closed).
|
|
135
|
+
*/
|
|
136
|
+
export function makePreExecuteListener(runtime) {
|
|
137
|
+
return async (exec, next) => {
|
|
138
|
+
const decision = runtime.decideExecution(exec);
|
|
139
|
+
// Passthrough (allow / stand-down) and a hard-deny need no grading.
|
|
140
|
+
if (decision === undefined)
|
|
141
|
+
return next();
|
|
142
|
+
if (decision.kind !== 'ask')
|
|
143
|
+
return decision;
|
|
144
|
+
// A cancelled call is never worth a model round-trip; the registry rechecks
|
|
145
|
+
// cancellation after this gate settles.
|
|
146
|
+
if (exec.signal?.aborted === true)
|
|
147
|
+
return decision;
|
|
148
|
+
let refined;
|
|
149
|
+
try {
|
|
150
|
+
refined = await runtime.refineAsk(exec, decision);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return decision; // fail-closed: keep the human ask
|
|
154
|
+
}
|
|
155
|
+
return refined ?? next();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
100
158
|
export function apply(ctx, config = {}) {
|
|
101
159
|
// Runtime-adjustable config: the composition entry is the base; the settings
|
|
102
160
|
// namespace layers on top and `current()` always reads the active section.
|
|
@@ -244,7 +302,7 @@ export function apply(ctx, config = {}) {
|
|
|
244
302
|
if (surface !== undefined && !Array.isArray(surface.allowlist)) {
|
|
245
303
|
const patterns = runtime.allowlist();
|
|
246
304
|
if (patterns.length > 0)
|
|
247
|
-
void scope.
|
|
305
|
+
void scope.update({ allowlist: patterns.slice() });
|
|
248
306
|
}
|
|
249
307
|
// Card edits -> namespace -> rulesFile (mirror + reload).
|
|
250
308
|
scope.watch(() => {
|
|
@@ -329,28 +387,7 @@ export function apply(ctx, config = {}) {
|
|
|
329
387
|
catch {
|
|
330
388
|
// webServer unavailable: the feed remains disk-only, sediment is view-only in files
|
|
331
389
|
}
|
|
332
|
-
const listener =
|
|
333
|
-
const decision = runtime.decideExecution(exec);
|
|
334
|
-
if (decision === undefined)
|
|
335
|
-
return next();
|
|
336
|
-
// llmAssist: the panel MUST appear immediately after pre-execute returns.
|
|
337
|
-
// We NEVER await refineAsk here — return the original ask immediately so
|
|
338
|
-
// the approval panel pops up. The LLM classifier runs in the background
|
|
339
|
-
// and may auto-allow later via settleExecution.
|
|
340
|
-
if (decision.kind === 'ask') {
|
|
341
|
-
// Fire-and-forget: background LLM grading
|
|
342
|
-
runtime.refineAsk(exec, decision)
|
|
343
|
-
.then((refined) => {
|
|
344
|
-
if (refined === undefined) {
|
|
345
|
-
// classifier said "safe" → auto-allow
|
|
346
|
-
runtime.settleExecution(exec, { isError: false });
|
|
347
|
-
}
|
|
348
|
-
})
|
|
349
|
-
.catch(() => { });
|
|
350
|
-
return decision;
|
|
351
|
-
}
|
|
352
|
-
return decision;
|
|
353
|
-
};
|
|
390
|
+
const listener = makePreExecuteListener(runtime);
|
|
354
391
|
// `tools/pre-execute` / `tools/result` / `approval/request` are service events,
|
|
355
392
|
// not part of cordis core's typed `Events`, so they are registered through the
|
|
356
393
|
// string overload.
|
|
@@ -362,22 +399,28 @@ export function apply(ctx, config = {}) {
|
|
|
362
399
|
// and settle a pending learning candidate (a result for one means the human
|
|
363
400
|
// approved it and it executed — one confirmation).
|
|
364
401
|
host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// the
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
402
|
+
// Approval channel: answer an escalation the gate already cleared, and settle
|
|
403
|
+
// the outcome of an ask the gate did raise. Registered `prepend` so it sits at
|
|
404
|
+
// the head of the `approval/request` waterfall, ahead of the remote bridge that
|
|
405
|
+
// renders the browser prompt (`packages/api/remotes` registers a plain `ctx.on`).
|
|
406
|
+
// A listener behind that bridge could only ever record a prompt that was already
|
|
407
|
+
// shown. The `'never'` policy is unaffected: `ApprovalService.decide` resolves it
|
|
408
|
+
// before dispatching, so this gate is never consulted, and a rogue return value is
|
|
409
|
+
// normalized to `'unavailable'` by the service.
|
|
410
|
+
//
|
|
411
|
+
// This listener MUST return `next()`'s value unchanged when it does not answer —
|
|
412
|
+
// a throw is normalized to `unavailable`, i.e. a rejection on the human's behalf.
|
|
413
|
+
// Requests we never tracked (other agents, other sources) are ignored.
|
|
371
414
|
const approvalAware = ctx;
|
|
372
415
|
approvalAware.inject(['approval'], (actx) => {
|
|
373
416
|
// Capture the live service: the gate reads `effectivePolicy` per call to
|
|
374
417
|
// learn whether an ask can reach a human (`approval: never` cannot).
|
|
375
418
|
approvalService = actx.approval;
|
|
376
|
-
const off = actx.on('approval/request',
|
|
419
|
+
const off = actx.on('approval/request', makeApprovalAnswerer(runtime), { prepend: true });
|
|
377
420
|
actx.effect(() => () => {
|
|
378
421
|
off();
|
|
379
422
|
approvalService = undefined;
|
|
380
|
-
}, 'dsh-perm-gate: approval
|
|
423
|
+
}, 'dsh-perm-gate: approval answerer');
|
|
381
424
|
});
|
|
382
425
|
return runtime;
|
|
383
426
|
}
|
package/lib/runtime.d.ts
CHANGED
|
@@ -136,6 +136,14 @@ export interface RiskLearningState {
|
|
|
136
136
|
readonly enabled: boolean;
|
|
137
137
|
readonly threshold: number;
|
|
138
138
|
}
|
|
139
|
+
/** The minimal approval-request face the answering gate inspects. */
|
|
140
|
+
export interface ApprovalRequestLike {
|
|
141
|
+
readonly toolName?: string;
|
|
142
|
+
readonly callId?: string;
|
|
143
|
+
readonly reason?: string;
|
|
144
|
+
}
|
|
145
|
+
/** A closed approval outcome, structurally the DSH seam's `ApprovalOutcome`. */
|
|
146
|
+
export type ApprovalOutcomeLike = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
139
147
|
export declare class PermGateRuntime {
|
|
140
148
|
private readonly options;
|
|
141
149
|
private ruleset;
|
|
@@ -162,6 +170,12 @@ export declare class PermGateRuntime {
|
|
|
162
170
|
* missing `callId` or an untriggered observer can never lose a record.
|
|
163
171
|
*/
|
|
164
172
|
private readonly pendingAsks;
|
|
173
|
+
/**
|
|
174
|
+
* Calls the gate positively allowed, keyed by the host execution id. An approval
|
|
175
|
+
* raised from *inside* such a call (the sandbox escalation) is answered here
|
|
176
|
+
* instead of prompting the human — see {@link answerEscalation}.
|
|
177
|
+
*/
|
|
178
|
+
private readonly cleared;
|
|
165
179
|
constructor(options?: PermGateRuntimeOptions);
|
|
166
180
|
private compileInline;
|
|
167
181
|
/** Reload rules from disk (HMR); on failure keeps the previous rules. */
|
|
@@ -246,6 +260,35 @@ export declare class PermGateRuntime {
|
|
|
246
260
|
private untrackAsk;
|
|
247
261
|
/** Asks currently awaiting a terminal answer (diagnostics/tests). */
|
|
248
262
|
pendingAskCount(): number;
|
|
263
|
+
/**
|
|
264
|
+
* Remember that the gate positively allowed one call, so an approval raised from
|
|
265
|
+
* inside that same call can be answered from this verdict.
|
|
266
|
+
*
|
|
267
|
+
* Only real allow decisions reach here: a `gateActive` stand-down returns before
|
|
268
|
+
* any decision, and the `approval: never` passthrough is a degradation rather
|
|
269
|
+
* than an approval, so neither may widen the call's privilege.
|
|
270
|
+
*/
|
|
271
|
+
private clearCall;
|
|
272
|
+
/** Drop one clearance (the call settled; nothing more can be asked from it). */
|
|
273
|
+
private forgetCleared;
|
|
274
|
+
/** Clearances retained right now (diagnostics/tests). */
|
|
275
|
+
clearedCallCount(): number;
|
|
276
|
+
/**
|
|
277
|
+
* Answer an in-call approval from the verdict that already cleared the call.
|
|
278
|
+
*
|
|
279
|
+
* The sandbox escalation is raised by `approveEscalation` from *inside* a shell
|
|
280
|
+
* or filesystem tool body — after `tools/pre-execute` settled — so the gate's
|
|
281
|
+
* allow never reaches it and a call the gate auto-allowed would still prompt the
|
|
282
|
+
* human for the privilege widening. Answering it here keeps the tier's contract:
|
|
283
|
+
* one decision per call.
|
|
284
|
+
*
|
|
285
|
+
* Returns `undefined` for every request that is not an escalation of a call this
|
|
286
|
+
* gate positively cleared, which delegates to the human unchanged (fail-closed).
|
|
287
|
+
*
|
|
288
|
+
* @param req - the `approval/request` payload (tool name, call id, reason).
|
|
289
|
+
* @returns `allowed-once` when the gate owns the answer, else `undefined`.
|
|
290
|
+
*/
|
|
291
|
+
answerEscalation(req: ApprovalRequestLike | null | undefined): ApprovalOutcomeLike | undefined;
|
|
249
292
|
/** Get one pending ask by its key (diagnostics/tests only). */
|
|
250
293
|
getPendingAsk(key: string): PendingAsk | undefined;
|
|
251
294
|
/**
|
package/lib/runtime.js
CHANGED
|
@@ -41,6 +41,25 @@ function cwdOf(exec) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Upper bound on in-flight learning candidates awaiting `tools/result`. */
|
|
43
43
|
const PENDING_CAP = 100;
|
|
44
|
+
/**
|
|
45
|
+
* Bound on retained call clearances. An in-call approval is raised microseconds
|
|
46
|
+
* after the gate's decision, so the store only ever holds the calls currently
|
|
47
|
+
* executing; the cap keeps a pathological producer from growing it.
|
|
48
|
+
*/
|
|
49
|
+
const CLEARED_CALL_CAP = 256;
|
|
50
|
+
/**
|
|
51
|
+
* How long a clearance may answer an in-call approval. Generous next to one tool
|
|
52
|
+
* call's lifetime, short enough that a stale entry cannot outlive its call.
|
|
53
|
+
*/
|
|
54
|
+
const CLEARED_CALL_TTL_MS = 5 * 60_000;
|
|
55
|
+
/**
|
|
56
|
+
* The sandbox-escalation ask marker. `@deepseek-ai/dsh-sandbox`'s
|
|
57
|
+
* `approveEscalation` builds `escalate sandbox to <mode>: <justification>` for the
|
|
58
|
+
* approval request it raises from inside a shell/fs tool body. The request carries
|
|
59
|
+
* no structural kind field, so this reason text is the only discriminator; an
|
|
60
|
+
* unrecognized wording fails closed (the request is forwarded to the human).
|
|
61
|
+
*/
|
|
62
|
+
const SANDBOX_ESCALATION_REASON = /^escalate sandbox to (workspace-write|danger-full-access): /;
|
|
44
63
|
export class PermGateRuntime {
|
|
45
64
|
options;
|
|
46
65
|
ruleset;
|
|
@@ -67,6 +86,12 @@ export class PermGateRuntime {
|
|
|
67
86
|
* missing `callId` or an untriggered observer can never lose a record.
|
|
68
87
|
*/
|
|
69
88
|
pendingAsks = new Map();
|
|
89
|
+
/**
|
|
90
|
+
* Calls the gate positively allowed, keyed by the host execution id. An approval
|
|
91
|
+
* raised from *inside* such a call (the sandbox escalation) is answered here
|
|
92
|
+
* instead of prompting the human — see {@link answerEscalation}.
|
|
93
|
+
*/
|
|
94
|
+
cleared = new Map();
|
|
70
95
|
constructor(options = {}) {
|
|
71
96
|
this.options = options;
|
|
72
97
|
this.grants = new GrantRegistry('__session__', options.now);
|
|
@@ -339,6 +364,94 @@ export class PermGateRuntime {
|
|
|
339
364
|
pendingAskCount() {
|
|
340
365
|
return this.pendingAsks.size;
|
|
341
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Remember that the gate positively allowed one call, so an approval raised from
|
|
369
|
+
* inside that same call can be answered from this verdict.
|
|
370
|
+
*
|
|
371
|
+
* Only real allow decisions reach here: a `gateActive` stand-down returns before
|
|
372
|
+
* any decision, and the `approval: never` passthrough is a degradation rather
|
|
373
|
+
* than an approval, so neither may widen the call's privilege.
|
|
374
|
+
*/
|
|
375
|
+
clearCall(exec, reason, verdict, source) {
|
|
376
|
+
const callId = typeof exec.callId === 'string' ? exec.callId : '';
|
|
377
|
+
if (callId === '')
|
|
378
|
+
return;
|
|
379
|
+
if (this.cleared.size >= CLEARED_CALL_CAP) {
|
|
380
|
+
const oldest = this.cleared.keys().next();
|
|
381
|
+
if (!oldest.done)
|
|
382
|
+
this.cleared.delete(oldest.value);
|
|
383
|
+
}
|
|
384
|
+
const now = typeof this.options.now === 'function' ? this.options.now : Date.now;
|
|
385
|
+
this.cleared.set(callId, {
|
|
386
|
+
tool: exec.name,
|
|
387
|
+
reason,
|
|
388
|
+
verdict,
|
|
389
|
+
source,
|
|
390
|
+
sessionId: sessionIdOf(exec),
|
|
391
|
+
files: eventFiles(exec),
|
|
392
|
+
baseDir: cwdOf(exec),
|
|
393
|
+
at: now(),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Drop one clearance (the call settled; nothing more can be asked from it). */
|
|
397
|
+
forgetCleared(callId) {
|
|
398
|
+
if (callId !== '')
|
|
399
|
+
this.cleared.delete(callId);
|
|
400
|
+
}
|
|
401
|
+
/** Clearances retained right now (diagnostics/tests). */
|
|
402
|
+
clearedCallCount() {
|
|
403
|
+
return this.cleared.size;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Answer an in-call approval from the verdict that already cleared the call.
|
|
407
|
+
*
|
|
408
|
+
* The sandbox escalation is raised by `approveEscalation` from *inside* a shell
|
|
409
|
+
* or filesystem tool body — after `tools/pre-execute` settled — so the gate's
|
|
410
|
+
* allow never reaches it and a call the gate auto-allowed would still prompt the
|
|
411
|
+
* human for the privilege widening. Answering it here keeps the tier's contract:
|
|
412
|
+
* one decision per call.
|
|
413
|
+
*
|
|
414
|
+
* Returns `undefined` for every request that is not an escalation of a call this
|
|
415
|
+
* gate positively cleared, which delegates to the human unchanged (fail-closed).
|
|
416
|
+
*
|
|
417
|
+
* @param req - the `approval/request` payload (tool name, call id, reason).
|
|
418
|
+
* @returns `allowed-once` when the gate owns the answer, else `undefined`.
|
|
419
|
+
*/
|
|
420
|
+
answerEscalation(req) {
|
|
421
|
+
const permissive = this.livePermissive();
|
|
422
|
+
if (!permissive.enabled || !permissive.strategies.trustEscalation)
|
|
423
|
+
return undefined;
|
|
424
|
+
const callId = typeof req?.callId === 'string' ? req.callId : '';
|
|
425
|
+
if (callId === '')
|
|
426
|
+
return undefined;
|
|
427
|
+
const target = SANDBOX_ESCALATION_REASON.exec(typeof req?.reason === 'string' ? req.reason : '')?.[1];
|
|
428
|
+
if (target === undefined)
|
|
429
|
+
return undefined;
|
|
430
|
+
const cleared = this.cleared.get(callId);
|
|
431
|
+
if (cleared === undefined)
|
|
432
|
+
return undefined;
|
|
433
|
+
if (typeof req?.toolName === 'string' && req.toolName !== '' && req.toolName !== cleared.tool)
|
|
434
|
+
return undefined;
|
|
435
|
+
const now = typeof this.options.now === 'function' ? this.options.now : Date.now;
|
|
436
|
+
if (now() - cleared.at > CLEARED_CALL_TTL_MS) {
|
|
437
|
+
this.cleared.delete(callId);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const reason = `sandbox escalation to ${target} auto-allowed: the gate cleared this call (${cleared.verdict})`;
|
|
441
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: cleared.tool, outcome: 'allow', source: cleared.source, reason, at: now() }));
|
|
442
|
+
this.events?.append({
|
|
443
|
+
sessionId: cleared.sessionId,
|
|
444
|
+
tool: cleared.tool,
|
|
445
|
+
kind: 'auto',
|
|
446
|
+
reason,
|
|
447
|
+
verdict: 'escalation-auto',
|
|
448
|
+
justification: cleared.reason,
|
|
449
|
+
mode: target,
|
|
450
|
+
files: cleared.files,
|
|
451
|
+
baseDir: cleared.baseDir,
|
|
452
|
+
});
|
|
453
|
+
return 'allowed-once';
|
|
454
|
+
}
|
|
342
455
|
/** Get one pending ask by its key (diagnostics/tests only). */
|
|
343
456
|
getPendingAsk(key) {
|
|
344
457
|
return this.pendingAsks.get(key);
|
|
@@ -425,6 +538,10 @@ export class PermGateRuntime {
|
|
|
425
538
|
this.trackAsk(exec, decision.reason);
|
|
426
539
|
return { kind: 'ask', reason: decision.reason };
|
|
427
540
|
}
|
|
541
|
+
// A genuine allow (grant / rule / classifier / permissive default): remember it
|
|
542
|
+
// so a sandbox escalation raised from inside this same call is answered here
|
|
543
|
+
// rather than prompted. Reached only after the two passthrough early-returns.
|
|
544
|
+
this.clearCall(exec, decision.reason, source, source);
|
|
428
545
|
return undefined;
|
|
429
546
|
}
|
|
430
547
|
/**
|
|
@@ -474,6 +591,7 @@ export class PermGateRuntime {
|
|
|
474
591
|
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
475
592
|
this.recordEvent(exec, 'learned', reason, { risk: 'neutral', verdict: 'learned-sediment', category: 'neutral' });
|
|
476
593
|
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
594
|
+
this.clearCall(exec, reason, 'learned-sediment', 'classifier');
|
|
477
595
|
return undefined;
|
|
478
596
|
}
|
|
479
597
|
}
|
|
@@ -506,6 +624,7 @@ export class PermGateRuntime {
|
|
|
506
624
|
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
507
625
|
this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'llm-safe' });
|
|
508
626
|
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
627
|
+
this.clearCall(exec, reason, 'llm-safe', 'classifier');
|
|
509
628
|
return undefined;
|
|
510
629
|
}
|
|
511
630
|
if (risk.kind === 'risky') {
|
|
@@ -527,6 +646,7 @@ export class PermGateRuntime {
|
|
|
527
646
|
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
528
647
|
this.recordEvent(exec, 'auto', reason, { risk: risk.category, verdict: 'llm-learned', category: risk.category });
|
|
529
648
|
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
649
|
+
this.clearCall(exec, reason, 'llm-learned', 'classifier');
|
|
530
650
|
return undefined;
|
|
531
651
|
}
|
|
532
652
|
if (learning.enabled) {
|
|
@@ -575,6 +695,9 @@ export class PermGateRuntime {
|
|
|
575
695
|
* approved it and it actually executed — one confirmation.
|
|
576
696
|
*/
|
|
577
697
|
settleExecution(exec, result) {
|
|
698
|
+
// The call is over: no further in-call approval can arrive for it, so the
|
|
699
|
+
// clearance must not linger.
|
|
700
|
+
this.forgetCleared(typeof exec.callId === 'string' ? exec.callId : '');
|
|
578
701
|
this.settleAskFromResult(exec, result);
|
|
579
702
|
const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
|
|
580
703
|
const candidate = this.pending.get(callKey);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-perm-gate",
|
|
3
|
-
"version": "0.2.1-beta.
|
|
3
|
+
"version": "0.2.1-beta.4",
|
|
4
4
|
"description": "DSH permission-gate: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"engines": {
|
|
60
60
|
"node": ">=20",
|
|
61
|
-
"dsh": ">=0.1.
|
|
61
|
+
"dsh": ">=0.1.2-alpha.1 <0.2.0-0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"@deepseek-ai/cordis": "^4.0.1"
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
72
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
73
|
-
"@deepseek-ai/dsh-client-
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
72
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
|
76
76
|
"@eslint/js": "^10.0.1",
|
|
77
77
|
"@types/node": "^22.0.0",
|
|
78
78
|
"@types/react": "~18.3.1",
|
|
@@ -87,10 +87,19 @@
|
|
|
87
87
|
"build": "tsc -p tsconfig.build.json && tsdown",
|
|
88
88
|
"prepublishOnly": "npm run build",
|
|
89
89
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
90
|
-
"lint": "eslint src test tsdown.config.ts eslint.config.mjs",
|
|
91
|
-
"test": "vitest run"
|
|
90
|
+
"lint": "eslint src test scripts tsdown.config.ts eslint.config.mjs",
|
|
91
|
+
"test": "vitest run",
|
|
92
|
+
"verify:line": "node scripts/verify-line.mjs new",
|
|
93
|
+
"verify:lines": "node scripts/verify-line.mjs both",
|
|
94
|
+
"release:latest": "npm publish --tag latest",
|
|
95
|
+
"release:legacy": "npm publish --tag legacy"
|
|
92
96
|
},
|
|
93
97
|
"license": "MIT",
|
|
98
|
+
"repository": {
|
|
99
|
+
"type": "git",
|
|
100
|
+
"url": "git+https://github.com/drscrewdriver/dsh-perm-gate.git"
|
|
101
|
+
},
|
|
102
|
+
"homepage": "https://github.com/drscrewdriver/dsh-perm-gate#readme",
|
|
94
103
|
"keywords": [
|
|
95
104
|
"dsh",
|
|
96
105
|
"dsh-plugin",
|