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/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 passive `approval/request` observer (exported for tests).
24
+ * Build the `approval/request` listener (exported for tests).
25
25
  *
26
- * It forwards the waterfall untouched and records the closed outcome for a
27
- * tracked ask. Recording is best-effort: a throw here would be normalized by
28
- * the approval service to `unavailable` — a rejection on the human's behalf.
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 makeApprovalObserver(runtime: Pick<PermGateRuntime, 'settleAskOutcome'>): (req: unknown, next: () => Promise<unknown>) => Promise<unknown>;
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 passive `approval/request` observer (exported for tests).
81
+ * Build the `approval/request` listener (exported for tests).
82
82
  *
83
- * It forwards the waterfall untouched and records the closed outcome for a
84
- * tracked ask. Recording is best-effort: a throw here would be normalized by
85
- * the approval service to `unavailable` — a rejection on the human's behalf.
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 makeApprovalObserver(runtime) {
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.set('allowlist', patterns.slice());
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 = async (exec, next) => {
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
- // Primary terminal-answer channel: passively observe the approval waterfall.
366
- // The ask we vetoed is forwarded by dsh-tools to `approval.request(...)`, so
367
- // the closed outcome is observable here. This listener MUST return `next()`'s
368
- // value unchanged — a throw is normalized to `unavailable`, i.e. a rejection
369
- // on the human's behalf. Requests we never tracked (other agents, other
370
- // sources) are ignored.
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', makeApprovalObserver(runtime));
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 observer');
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.2",
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.0-rc.7 <0.2.0-0"
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.0-rc.7",
73
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
74
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
75
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
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",