dsh-perm-gate 0.2.1-beta.3 → 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 +22 -0
- package/CHANGELOG.ko.md +22 -0
- package/CHANGELOG.md +47 -1
- package/INSTALL.ja.md +17 -2
- package/INSTALL.ko.md +17 -2
- package/INSTALL.md +16 -2
- package/INSTALL.zh.md +15 -2
- package/README.ja.md +54 -3
- package/README.ko.md +53 -3
- package/README.md +49 -16
- package/README.zh.md +39 -13
- 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 +16 -5
- package/lib/index.js +42 -14
- 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
|
@@ -21,13 +21,24 @@ 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.
|
|
29
40
|
*/
|
|
30
|
-
export declare function
|
|
41
|
+
export declare function makeApprovalAnswerer(runtime: Pick<PermGateRuntime, 'settleAskOutcome' | 'answerEscalation'>): (req: unknown, next: () => Promise<unknown>) => Promise<unknown>;
|
|
31
42
|
/**
|
|
32
43
|
* Build the `tools/pre-execute` waterfall listener (exported for tests).
|
|
33
44
|
*
|
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;
|
|
@@ -280,7 +302,7 @@ export function apply(ctx, config = {}) {
|
|
|
280
302
|
if (surface !== undefined && !Array.isArray(surface.allowlist)) {
|
|
281
303
|
const patterns = runtime.allowlist();
|
|
282
304
|
if (patterns.length > 0)
|
|
283
|
-
void scope.
|
|
305
|
+
void scope.update({ allowlist: patterns.slice() });
|
|
284
306
|
}
|
|
285
307
|
// Card edits -> namespace -> rulesFile (mirror + reload).
|
|
286
308
|
scope.watch(() => {
|
|
@@ -377,22 +399,28 @@ export function apply(ctx, config = {}) {
|
|
|
377
399
|
// and settle a pending learning candidate (a result for one means the human
|
|
378
400
|
// approved it and it executed — one confirmation).
|
|
379
401
|
host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// the
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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.
|
|
386
414
|
const approvalAware = ctx;
|
|
387
415
|
approvalAware.inject(['approval'], (actx) => {
|
|
388
416
|
// Capture the live service: the gate reads `effectivePolicy` per call to
|
|
389
417
|
// learn whether an ask can reach a human (`approval: never` cannot).
|
|
390
418
|
approvalService = actx.approval;
|
|
391
|
-
const off = actx.on('approval/request',
|
|
419
|
+
const off = actx.on('approval/request', makeApprovalAnswerer(runtime), { prepend: true });
|
|
392
420
|
actx.effect(() => () => {
|
|
393
421
|
off();
|
|
394
422
|
approvalService = undefined;
|
|
395
|
-
}, 'dsh-perm-gate: approval
|
|
423
|
+
}, 'dsh-perm-gate: approval answerer');
|
|
396
424
|
});
|
|
397
425
|
return runtime;
|
|
398
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",
|