dsh-perm-gate 5.4.2 → 5.4.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/runtime.js CHANGED
@@ -24,11 +24,12 @@ import { ArtifactRegistry } from './path.js';
24
24
  import { classifyRisk, classifyRiskWith, graderNote, RISK_SYSTEM_PROMPT_VERSION } from './risk.js';
25
25
  import { askDoubtKinds, buildAskContext, humanBrief, humanCardLine, humanCommand, toEventFields, toGraderPayload } from './ask-context.js';
26
26
  import { readScriptContents, scriptRefs, scriptsFullyCovered } from './script-review.js';
27
+ import { pathsRealInsideWorkspace } from './workspace-bound.js';
27
28
  import { redactCommandText } from './redact.js';
28
29
  import { executableVariants, normalizeCommandText } from './command-normalize.js';
29
30
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
30
31
  import { chatCompletion } from './classifier.js';
31
- import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
32
+ import { coerceRuleAction, compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
32
33
  import { decomposeShellCommand } from './shell.js';
33
34
  import { extractAgentCandidates } from './agent-identity.js';
34
35
  import { resolveRuleChain } from './rule-chain.js';
@@ -289,10 +290,17 @@ export class PermGateRuntime {
289
290
  }
290
291
  compileInline(options) {
291
292
  const empty = {
292
- defaultAction: options.defaultAction ?? 'ask',
293
+ defaultAction: this.effectiveDefaultAction(options),
293
294
  deny: [], allow: [], ask: [],
294
295
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
295
296
  };
297
+ // Every other path yields a ruleset whose default came from a document; a
298
+ // document is validated, but the action is still coerced so one bad source
299
+ // can never hand the runtime an action it has no branch for.
300
+ const settle = (rs) => {
301
+ const action = coerceRuleAction(rs.defaultAction);
302
+ return action === rs.defaultAction ? rs : { ...rs, defaultAction: action };
303
+ };
296
304
  // Settings-first (dual-source): a configured `dsh-perm-gate-rules`
297
305
  // namespace overrides every file path — the gate then loads with zero
298
306
  // filesystem dependency. Bare defaults (nothing configured) fall through
@@ -303,20 +311,20 @@ export class PermGateRuntime {
303
311
  const hash = documentHash(JSON.stringify(settingsDoc));
304
312
  const hit = this.cache.get(hash);
305
313
  if (hit !== undefined)
306
- return hit;
314
+ return settle(hit);
307
315
  const compiled = compileRulesObject(settingsDoc, {
308
316
  maxGlobStars: 2,
309
317
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
310
318
  });
311
319
  this.cache.set(hash, compiled);
312
- return compiled;
320
+ return settle(compiled);
313
321
  }
314
322
  // Chain mode: when searchUp is enabled, use the multi-file chain resolver.
315
323
  if (options.searchUp === true) {
316
324
  const cwd = options.cwd ?? process.cwd();
317
325
  const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
318
326
  try {
319
- return resolveRuleChain(cwd, {
327
+ return settle(resolveRuleChain(cwd, {
320
328
  rulesFile,
321
329
  searchUp: true,
322
330
  fallbackPath: options.fallbackPath,
@@ -325,7 +333,7 @@ export class PermGateRuntime {
325
333
  }, {
326
334
  maxGlobStars: 2,
327
335
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
328
- });
336
+ }));
329
337
  }
330
338
  catch {
331
339
  return empty;
@@ -338,14 +346,27 @@ export class PermGateRuntime {
338
346
  const hash = documentHash(text);
339
347
  const hit = this.cache.get(hash);
340
348
  if (hit !== undefined)
341
- return hit;
349
+ return settle(hit);
342
350
  const doc = parsePermissionsDocument(text);
343
351
  const compiled = compileDocument(doc, {
344
352
  maxGlobStars: 2,
345
353
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
346
354
  });
347
355
  this.cache.set(hash, compiled);
348
- return compiled;
356
+ return settle(compiled);
357
+ }
358
+ /**
359
+ * The root default action, read live (the config field is volatile) and
360
+ * validated. An unrecognised value — most often a `.volatile()` ref that
361
+ * reached the options un-unwrapped — fails **closed** to `ask`: previously it
362
+ * became the ruleset default verbatim, both the deny and ask branches missed
363
+ * it, and every unmatched call was allowed without review (issue #6).
364
+ */
365
+ effectiveDefaultAction(options) {
366
+ const raw = options.readDefaultAction !== undefined
367
+ ? options.readDefaultAction()
368
+ : options.defaultAction;
369
+ return coerceRuleAction(raw);
349
370
  }
350
371
  /** Reload rules from disk (HMR); on failure keeps the previous rules. */
351
372
  reload() {
@@ -659,6 +680,15 @@ export class PermGateRuntime {
659
680
  const sel = this.options.readHostModel?.();
660
681
  return sel !== undefined && sel.provider !== '' && sel.model !== '' ? sel : undefined;
661
682
  }
683
+ /**
684
+ * Reasoning effort for host reviews. `undefined` **omits** the field so the host
685
+ * model configuration decides; the old hardcoded `'off'` was rejected by host
686
+ * validation for models whose advertised efforts exclude it (issue #8).
687
+ */
688
+ hostEffort() {
689
+ const effort = this.options.readClassifyEffort?.();
690
+ return typeof effort === 'string' && effort !== '' ? effort : undefined;
691
+ }
662
692
  livePermissive() {
663
693
  return this.options.readPermissive !== undefined
664
694
  ? this.options.readPermissive()
@@ -668,6 +698,34 @@ export class PermGateRuntime {
668
698
  isYolo() {
669
699
  return this.options.readYolo !== undefined ? this.options.readYolo() : (this.options.yolo ?? false);
670
700
  }
701
+ /**
702
+ * Whether the YOLO tier should stop blocking on "target unpinnable" alone.
703
+ * Default false: 5.4.3 behaviour, one switch away.
704
+ */
705
+ liveYoloWorkspaceExempt() {
706
+ return this.options.readYoloWorkspaceExempt !== undefined
707
+ ? this.options.readYoloWorkspaceExempt()
708
+ : (this.options.yoloWorkspaceExempt ?? false);
709
+ }
710
+ /**
711
+ * The one narrowing the exemption is allowed to make: the call is ordinary and visible,
712
+ * only its *location* was unpinnable, and everything it names is inside the workspace.
713
+ * Delete, remote write, invisible content, P0/irreversible and unlearnable acts are all
714
+ * refused here — this method can only *skip* the opaque signal, never clear a danger.
715
+ */
716
+ yoloExemptsLocation(ctx) {
717
+ return this.liveYoloWorkspaceExempt()
718
+ && ctx.opaqueKind === 'location'
719
+ && ctx.targetKind !== 'delete'
720
+ && ctx.targetKind !== 'remote-write'
721
+ && !ctx.irreversible
722
+ && !ctx.unlearnable
723
+ && ctx.workspaceBound === true
724
+ // `workspaceBound` is lexical (the shared module cannot touch the filesystem); the
725
+ // relaxation also has to survive link resolution, so a link inside the workspace
726
+ // that points outside is still refused here.
727
+ && pathsRealInsideWorkspace(ctx.workspaceRoot, ctx.files ?? []);
728
+ }
671
729
  /** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
672
730
  isRulesFallback() {
673
731
  return this.options.readRulesFallback !== undefined
@@ -1422,7 +1480,7 @@ export class PermGateRuntime {
1422
1480
  }
1423
1481
  else if (useHost) {
1424
1482
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
1425
- risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
1483
+ risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
1426
1484
  }
1427
1485
  else {
1428
1486
  risk = await classifyRisk(cfg ?? {}, req);
@@ -1540,7 +1598,11 @@ export class PermGateRuntime {
1540
1598
  async resolveYolo(exec, decision) {
1541
1599
  const callId = randomId();
1542
1600
  const askCtx = this.unattendedContextFor(exec, decision);
1543
- const judged = this.mechanicalJudge(askCtx);
1601
+ // The workspace exemption narrows exactly one signal: "the target could not be pinned
1602
+ // down". It is decided before the screen so an exempted call reaches the reviewer (or
1603
+ // the mechanical conclusion) as an ordinary one; every other danger signal still fires.
1604
+ const exempt = this.yoloExemptsLocation(askCtx);
1605
+ const judged = exempt ? null : this.mechanicalJudge(askCtx);
1544
1606
  let riskCategory;
1545
1607
  // Whether the reviewer actually spoke. YOLO advertises "the model answers in the
1546
1608
  // person's place"; when there is no grader, or it gave up, the tier still refuses to ask
@@ -1565,11 +1627,15 @@ export class PermGateRuntime {
1565
1627
  }
1566
1628
  const danger = judged !== null || riskCategory !== undefined;
1567
1629
  if (!danger) {
1568
- const reason = reviewerAnswered
1630
+ const exemptTag = exempt ? ' [exempt:opaque-location]' : '';
1631
+ const reason = (reviewerAnswered
1569
1632
  ? `yolo: allow (no danger signal) — ${decision.reason}`
1570
- : `yolo: allow (no reviewer answered; mechanical screen found nothing) — ${decision.reason}`;
1633
+ : `yolo: allow (no reviewer answered; mechanical screen found nothing) — ${decision.reason}`) + exemptTag;
1634
+ const verdict = exempt
1635
+ ? (reviewerAnswered ? 'yolo-allow-opaque-location-exempt' : 'yolo-allow-opaque-location-exempt-no-reviewer')
1636
+ : (reviewerAnswered ? 'yolo-allow' : 'yolo-allow-no-reviewer');
1571
1637
  this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
1572
- this.recordEvent(exec, 'auto', reason, { verdict: reviewerAnswered ? 'yolo-allow' : 'yolo-allow-no-reviewer', risk: 'safe' });
1638
+ this.recordEvent(exec, 'auto', reason, { verdict, risk: 'safe' });
1573
1639
  this.untrackAsk(exec);
1574
1640
  return undefined;
1575
1641
  }
@@ -1624,7 +1690,7 @@ export class PermGateRuntime {
1624
1690
  }
1625
1691
  else if (useHost) {
1626
1692
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
1627
- risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
1693
+ risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
1628
1694
  }
1629
1695
  else {
1630
1696
  risk = await classifyRisk(cfg ?? {}, req);
@@ -2010,7 +2076,7 @@ export class PermGateRuntime {
2010
2076
  if (this.options.hostLlm === undefined)
2011
2077
  return done(false, 'host llm service unavailable');
2012
2078
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
2013
- const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000);
2079
+ const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() });
2014
2080
  return r.ok ? done(true, `${selection.provider}/${selection.model} → ${r.content.trim().slice(0, 60) || 'ok'}`) : done(false, r.error ?? 'host llm call failed');
2015
2081
  }
2016
2082
  if (cfg?.endpoint === undefined || cfg.endpoint === '' || cfg?.model === undefined || cfg.model === '') {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Whether every named path resolves inside `root`, after following links.
3
+ *
4
+ * An empty list is `true` (the call named nothing), a missing root is `false`, and any
5
+ * path that cannot be resolved is `false`.
6
+ */
7
+ export declare function pathsRealInsideWorkspace(root: string | undefined, paths: readonly string[]): boolean;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Node-side containment check for the YOLO workspace exemption.
3
+ *
4
+ * `ask-context.ts` stays dependency-free (the browser half imports its types), so its
5
+ * `workspaceBound` is a **lexical** prefix test. That is the right answer for the fact
6
+ * layer but not sufficient to *relax* a safety screen: a link inside the workspace can
7
+ * point anywhere. This module resolves the paths the call actually names and answers the
8
+ * same question through the filesystem, and it is deliberately pessimistic — a path that
9
+ * cannot be resolved (missing, unreadable, permission denied) answers `false`, because
10
+ * "I could not prove it stays inside" must never read as "it stays inside".
11
+ *
12
+ * Node half only: never imported by the client bundle.
13
+ */
14
+ import { realpathSync } from 'node:fs';
15
+ import { isAbsolute, resolve, sep } from 'node:path';
16
+ /** Windows paths are case-insensitive; the other supported lines are not. */
17
+ function fold(path) {
18
+ return process.platform === 'win32' ? path.toLowerCase() : path;
19
+ }
20
+ /**
21
+ * Whether every named path resolves inside `root`, after following links.
22
+ *
23
+ * An empty list is `true` (the call named nothing), a missing root is `false`, and any
24
+ * path that cannot be resolved is `false`.
25
+ */
26
+ export function pathsRealInsideWorkspace(root, paths) {
27
+ if (root === undefined || root === '')
28
+ return false;
29
+ if (paths.length === 0)
30
+ return true;
31
+ let realRoot;
32
+ try {
33
+ realRoot = fold(realpathSync.native(root));
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ return paths.every((candidate) => {
39
+ try {
40
+ const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate);
41
+ const real = fold(realpathSync.native(absolute));
42
+ return real === realRoot || real.startsWith(`${realRoot}${sep}`);
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ });
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "5.4.2",
3
+ "version": "5.4.4",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "tag": "latest"