dsh-perm-gate 5.1.2-compat.7 → 5.2.0

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/risk.d.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  * asks the LLM to grade ONE tool call as either safe or risky with a category:
6
6
  *
7
7
  * safe → the gate may auto-allow the ask
8
- * risky:<hard category> → deletion / credential / remote / system / bulk —
9
- * the operation looks dangerous; the gate KEEPS the
8
+ * risky:<hard category> → deletion / credential / remote / system / bulk /
9
+ * unknown-target — the operation looks dangerous or
10
+ * cannot be seen; the gate KEEPS the
10
11
  * human ask and never learns these into an
11
12
  * auto-allow. It is NOT an auto-deny: denying is
12
13
  * reserved for the deterministic layers (P0,
@@ -26,16 +27,22 @@
26
27
  */
27
28
  import { type ClassifierConfig } from './classifier.js';
28
29
  /** Risk categories the LLM may report for a risky verdict. */
29
- export type RiskCategory = 'deletion' | 'credential' | 'remote' | 'system' | 'bulk' | 'neutral';
30
+ export type RiskCategory = 'deletion' | 'credential' | 'remote' | 'system' | 'bulk' | 'unknown-target' | 'neutral';
30
31
  /**
31
32
  * Categories that always route to the human seam regardless of learning state
32
33
  * or any later LLM opinion. Order-stable for prompts and event payloads.
33
34
  *
34
35
  * "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
35
36
  * It does not mean auto-denied: the deny path belongs to the deterministic
36
- * layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
37
+ * layers (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
37
38
  * rules), so a misgraded category can always be negotiated instead of becoming
38
39
  * an unappealable block.
40
+ *
41
+ * `unknown-target` is the U tier — the payload could not be read (encoded blob,
42
+ * script outside the workspace, empty-args tool). It is deliberately **not** in
43
+ * any deny wordlist: "I cannot see it" is not evidence that it is bad, and a
44
+ * category the model is invited to guess would hand out unappealable blocks for
45
+ * opacity alone. Being hard is enough — ask, forever, learn nothing.
39
46
  */
40
47
  export declare const HARD_RISK_CATEGORIES: readonly RiskCategory[];
41
48
  /**
@@ -57,13 +64,33 @@ export type RiskVerdict = {
57
64
  } | {
58
65
  kind: 'unresolved';
59
66
  };
60
- /** The bounded call graded by {@link classifyRisk}. */
67
+ /**
68
+ * The bounded call graded by {@link classifyRisk}.
69
+ *
70
+ * `args` may be passed raw: {@link riskUserText} redacts and bounds it before it
71
+ * leaves the process. The two scope fields exist because the prompt's own rules
72
+ * ("deletion inside the workspace is SAFE") were previously unenforceable — the
73
+ * grader was told to use a scope it was never given.
74
+ */
61
75
  export interface RiskRequest {
62
76
  readonly tool: string;
63
77
  readonly args: unknown;
64
78
  /** The deterministic reason the call reached the LLM-assist seam. */
65
79
  readonly reason: string;
80
+ /** Workspace root, so "inside the workspace" means something to the grader. */
81
+ readonly workspaceRoot?: string;
82
+ /** False for an encoded or unreadable payload: the U tier, never grade it safe. */
83
+ readonly targetResolvable?: boolean;
66
84
  }
85
+ /**
86
+ * Prompt identity, recorded with every verdict.
87
+ *
88
+ * Measured on the eval set: one and the same wording halved disturbance on one
89
+ * model while costing another four to five irreversible misses. A grading prompt
90
+ * is therefore a versioned, falsifiable artifact — without this line, a wording
91
+ * change and a model swap are indistinguishable in the event log.
92
+ */
93
+ export declare const RISK_SYSTEM_PROMPT_VERSION = "v7-terse-budget";
67
94
  /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
68
95
  export declare function parseRiskVerdict(content: string): RiskVerdict | undefined;
69
96
  /**
package/lib/risk.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * asks the LLM to grade ONE tool call as either safe or risky with a category:
6
6
  *
7
7
  * safe → the gate may auto-allow the ask
8
- * risky:<hard category> → deletion / credential / remote / system / bulk —
9
- * the operation looks dangerous; the gate KEEPS the
8
+ * risky:<hard category> → deletion / credential / remote / system / bulk /
9
+ * unknown-target — the operation looks dangerous or
10
+ * cannot be seen; the gate KEEPS the
10
11
  * human ask and never learns these into an
11
12
  * auto-allow. It is NOT an auto-deny: denying is
12
13
  * reserved for the deterministic layers (P0,
@@ -25,17 +26,25 @@
25
26
  * demonstrated by dsh-approval-gate.
26
27
  */
27
28
  import { chatCompletion, withLlmRetry } from './classifier.js';
29
+ import { graderRulesText } from './grader-rules.js';
30
+ import { redactArgs } from './redact.js';
28
31
  /**
29
32
  * Categories that always route to the human seam regardless of learning state
30
33
  * or any later LLM opinion. Order-stable for prompts and event payloads.
31
34
  *
32
35
  * "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
33
36
  * It does not mean auto-denied: the deny path belongs to the deterministic
34
- * layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
37
+ * layers (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
35
38
  * rules), so a misgraded category can always be negotiated instead of becoming
36
39
  * an unappealable block.
40
+ *
41
+ * `unknown-target` is the U tier — the payload could not be read (encoded blob,
42
+ * script outside the workspace, empty-args tool). It is deliberately **not** in
43
+ * any deny wordlist: "I cannot see it" is not evidence that it is bad, and a
44
+ * category the model is invited to guess would hand out unappealable blocks for
45
+ * opacity alone. Being hard is enough — ask, forever, learn nothing.
37
46
  */
38
- export const HARD_RISK_CATEGORIES = ['deletion', 'credential', 'remote', 'system', 'bulk'];
47
+ export const HARD_RISK_CATEGORIES = ['deletion', 'credential', 'remote', 'system', 'bulk', 'unknown-target'];
39
48
  const RISK_CATEGORIES = [...HARD_RISK_CATEGORIES, 'neutral'];
40
49
  /**
41
50
  * Whether a category must never be auto-allowed — and never learned into one.
@@ -47,9 +56,18 @@ export function isHardRisk(category) {
47
56
  // An unknown category means the verdict is unreliable — treat it as hard.
48
57
  return !RISK_CATEGORIES.includes(category);
49
58
  }
59
+ /**
60
+ * Prompt identity, recorded with every verdict.
61
+ *
62
+ * Measured on the eval set: one and the same wording halved disturbance on one
63
+ * model while costing another four to five irreversible misses. A grading prompt
64
+ * is therefore a versioned, falsifiable artifact — without this line, a wording
65
+ * change and a model swap are indistinguishable in the event log.
66
+ */
67
+ export const RISK_SYSTEM_PROMPT_VERSION = 'v7-terse-budget';
50
68
  const RISK_SYSTEM_PROMPT = [
51
69
  'You are the risk grader inside a permission gate for a coding agent.',
52
- 'You receive one tool call (tool name, arguments, and the deterministic reason it needs human-grade review).',
70
+ 'You receive one tool call (tool, arguments, and the deterministic reason it needs human review).',
53
71
  'Grade whether executing it could cause irreversible damage or touch sensitive resources.',
54
72
  '',
55
73
  'Reply with ONLY one JSON object and nothing else:',
@@ -57,28 +75,15 @@ const RISK_SYSTEM_PROMPT = [
57
75
  ' {"risk":"risky","category":"<category>","reason":"short"}',
58
76
  '',
59
77
  'category must be exactly one of:',
60
- ' deletion — deletes or overwrites data that cannot be regenerated (IMPORTANT: see exclusion rules below)',
78
+ ' deletion — destroys or overwrites data that cannot be regenerated (see the exclusion rules below)',
61
79
  ' credential — creates/modifies/exposes credentials, keys, tokens, auth config',
62
- ' remote — WRITES to or MODIFIES remote systems (git push, git deploy, kubectl apply, terraform apply, production database writes, messaging, billing, publishing). Must have a write/modify/delete effect on the remote side.',
80
+ ' remote — WRITES to or MODIFIES remote systems (git push, deploy, kubectl/terraform apply, production database writes, messaging, billing, publishing): the effect must land on the remote side.',
63
81
  ' system — system-level paths or configuration (/etc, /usr, boot items), shutdown/reboot',
64
82
  ' bulk — mass overwrite of many files, formatting, dd-style irreversible writes',
65
- ' neutral — none of the above clearly applies (ordinary edits outside the obvious scope, or you are unsure but see no hard-risk sign)',
83
+ ' unknown-target — the call acts on something unreadable (encoded payload, script outside the workspace, empty arguments). Ask; assume nothing.',
84
+ ' neutral — none of the above clearly applies, or you are unsure but see no hard-risk sign',
66
85
  '',
67
- 'Rules:',
68
- '- safe is only for reversible, in-scope operations that touch no sensitive resource.',
69
- '- When unsure between safe and neutral, answer neutral; never guess safe.',
70
- '- Judge the operation content, not merely its location.',
71
- '',
72
- 'DELETION exclusion rules (these override the deletion category):',
73
- '- Cleanup of temporary files, build outputs, or failed clone directories is SAFE — even if using rm/rmdir/Remove-Item. Look for temp paths: paths containing "temp", "tmp", "test-clone", "node_modules", ".cache", "dist", "build", or numbered/timestamped directories.',
74
- '- Removal of session-local artifacts (files the session itself created) is SAFE.',
75
- '- rm -rf on node_modules, .next, __pycache__, .pytest_cache, .git, dist, build is SAFE — these are always regenerable.',
76
- '- Deletion inside the workspace that targets non-existent or already-empty paths is SAFE (idempotent cleanup).',
77
- '- Only flag as deletion when the target is: (a) user data with no backup, (b) configuration files, (c) database records, (d) files outside the workspace that were not created by this session, or (e) a destructive rm -rf on a directory containing unique, irreplaceable content.',
78
- '',
79
- 'READ-ONLY remote operations are safe: git fetch, git pull, git clone, git log, git diff, git status, git show, npm install (read-only), pip install (read-only), apt-get update, curl/wget (read, no write flag).',
80
- '- Operations that only READ from remote systems without modifying them are safe, even if they involve network traffic.',
81
- '- Only flag as remote when the operation WRITES, DEPLOYS, PUSHES, or DELETES on the remote side.',
86
+ ...graderRulesText().split('\n'),
82
87
  ].join('\n');
83
88
  /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
84
89
  export function parseRiskVerdict(content) {
@@ -117,23 +122,22 @@ export async function classifyRisk(cfg, req, nowFetch = fetch) {
117
122
  }
118
123
  /** The bounded user prompt for one risk grading (shared by every transport). */
119
124
  export function riskUserText(req) {
120
- // Enrich the prompt with full path context so the LLM can judge
121
- // whether a deletion targets a temporary/build artifact vs real data.
122
- const args = (req.args ?? {});
123
- const enriched = { ...args };
124
- // Surface the working directory if present (helps LLM see temp paths).
125
- if (typeof enriched.cwd === 'string' && enriched.cwd !== '') {
126
- enriched.__cwd = enriched.cwd;
127
- }
128
- if (typeof enriched.workdir === 'string' && enriched.workdir !== '') {
129
- enriched.__cwd = enriched.workdir;
130
- }
131
- return JSON.stringify({
132
- tool: req.tool,
133
- args: enriched,
134
- reason: req.reason,
135
- instruction: 'Reply strictly as one JSON object: {"risk":"safe"} or {"risk":"risky","category":"..."} plus a short "reason".',
136
- });
125
+ // Nothing here leaves the process unredacted: `args` used to be serialized
126
+ // whole — file bodies and inline credentials included — to a user-configured
127
+ // third-party endpoint, while `classifier.ts` documented that secrets had been
128
+ // stripped upstream. They had not.
129
+ const args = redactArgs(req.args ?? {});
130
+ // Surface the working directory so the LLM can see temp/build paths, and give
131
+ // it the scope anchor its own prompt already relies on.
132
+ if (typeof args.cwd !== 'string' && typeof args.workdir === 'string')
133
+ args.__cwd = args.workdir;
134
+ const payload = { tool: req.tool, args, reason: req.reason };
135
+ if (req.workspaceRoot !== undefined && req.workspaceRoot !== '')
136
+ payload.workspace_root = req.workspaceRoot;
137
+ if (req.targetResolvable !== undefined)
138
+ payload.target_resolvable = req.targetResolvable;
139
+ payload.instruction = 'Reply strictly as one JSON object: {"risk":"safe"} or {"risk":"risky","category":"..."} plus a short "reason".';
140
+ return JSON.stringify(payload);
137
141
  }
138
142
  /**
139
143
  * Grade one tool call through any transport. Up to two attempts (one retry);
package/lib/runtime.d.ts CHANGED
@@ -8,9 +8,22 @@ import { type HostLlmLike, type HostModelSelection } from './host-llm.js';
8
8
  import { type CompiledRuleEntry, type CompiledRuleset, type RuleAction } from './rule.js';
9
9
  import type { NetworkTarget } from './network.js';
10
10
  import { type SessionEventLike } from './preset.js';
11
+ /**
12
+ * The gate's answer to the host's pre-execute waterfall.
13
+ *
14
+ * `displayReason` is part of the host's own ask contract: the registry forwards it
15
+ * to `approval/request` for presentation only, while `reason` remains the durable,
16
+ * machine-readable text that rules, audit and grants key off. Keeping the human-
17
+ * facing explanation in a separate field is what lets the popup say *what is about
18
+ * to happen* without polluting the record used for decisions.
19
+ */
11
20
  export interface PreToolDecisionLike {
12
21
  kind: 'deny' | 'ask';
13
22
  reason: string;
23
+ displayReason?: {
24
+ readonly en: string;
25
+ readonly [locale: string]: string;
26
+ };
14
27
  }
15
28
  /** The sandbox hook's verdict for one allowed shell call (appcontainer seam). */
16
29
  export type SandboxRewriteOutcome = {
@@ -105,6 +118,15 @@ interface PendingAsk {
105
118
  readonly snapshotId: number;
106
119
  /** Set when this ask also registered a learning candidate (drives the progress chip). */
107
120
  readonly learningKey?: string;
121
+ /**
122
+ * The card the human was shown, kept so the terminal event can carry it.
123
+ * Without this the approval list shows only the audit `reason` and a past
124
+ * decision cannot be read back — the popup's explanation existed for seconds
125
+ * and then vanished.
126
+ */
127
+ readonly humanBrief?: string;
128
+ readonly humanCommand?: string;
129
+ readonly doubtKinds?: readonly string[];
108
130
  }
109
131
  export interface PermGateRuntimeOptions extends PermGateConfig {
110
132
  /** Workspace root for chain resolution. Defaults to process.cwd(). */
@@ -228,6 +250,11 @@ export type ApprovalOutcomeLike = 'allowed-once' | 'rejected' | 'cancelled' | 'u
228
250
  * One approval ask the gate raises on behalf of a subprocess network
229
251
  * connection. Structurally the subset of the DSH `ApprovalRequest` the gate
230
252
  * supplies — the host service adds the borrowed session/log handling.
253
+ *
254
+ * `displayReason` is the host's own field (`ApprovalRequestEvent`), for localized
255
+ * presentation that is deliberately never persisted into the audit pair. It exists
256
+ * so a human can be shown *what is about to happen* rather than one opaque
257
+ * internal sentence; `reason` stays the durable, machine-readable record.
231
258
  */
232
259
  export interface NetworkApprovalRequest {
233
260
  /** The host agent the question is asked on behalf of (routes the prompt). */
@@ -237,6 +264,11 @@ export interface NetworkApprovalRequest {
237
264
  /** The exact tool call, when the host gave one — lets the UI attach the prompt. */
238
265
  readonly callId?: string;
239
266
  readonly reason: string;
267
+ /** Presentation-only text, keyed by locale (`en` required). */
268
+ readonly displayReason?: {
269
+ readonly en: string;
270
+ readonly [locale: string]: string;
271
+ };
240
272
  readonly signal?: AbortSignal;
241
273
  }
242
274
  /** The attribution the proxy layer receives for one connection. */
@@ -437,6 +469,13 @@ export declare class PermGateRuntime {
437
469
  * allow path (deny wins over allow). Purely additive to P0 — it can only ever
438
470
  * deny, never widen. Matching is boundary-aware and skips document bodies, so
439
471
  * a keyword can no longer veto an unrelated identifier or a file's text.
472
+ *
473
+ * Decoded layers are scanned too (B6 wiring). An `-EncodedCommand` blob is
474
+ * invisible to every keyword below, and this layer — unlike P0 — is a blacklist
475
+ * the user edits, so "the text you told me to refuse is in there" is exactly
476
+ * what it should be able to say. Only variants the visible command really hands
477
+ * to an execution primitive qualify, and no decoded text is written back to the
478
+ * call, the reason, or the event: the reason names the keyword, never the blob.
440
479
  */
441
480
  private denyKeywordHit;
442
481
  /**
@@ -451,8 +490,32 @@ export declare class PermGateRuntime {
451
490
  private trackAsk;
452
491
  /** Drop one tracked ask without recording it (the call never needed a human). */
453
492
  private untrackAsk;
493
+ /**
494
+ * Record that a pending ask was evicted by capacity, so "the popup never came
495
+ * back" is diagnosable instead of silent. Audited rather than fed to the event
496
+ * feed: no new event `kind` is introduced, so nothing can be mis-rendered as an
497
+ * allow by `presentation()`'s default branch.
498
+ */
499
+ private recordAskEviction;
454
500
  /** Asks currently awaiting a terminal answer (diagnostics/tests). */
455
501
  pendingAskCount(): number;
502
+ /**
503
+ * Pending-ask capacity health: how many asks are in flight, the cap, and how
504
+ * many have been evicted. Surfaces the case where the gate asks more than it can
505
+ * track, which otherwise looks identical to a lost approval.
506
+ */
507
+ pendingAskStats(): {
508
+ size: number;
509
+ cap: number;
510
+ evictions: number;
511
+ lastEviction?: {
512
+ key: string;
513
+ tool: string;
514
+ reason: string;
515
+ };
516
+ };
517
+ private askEvictions;
518
+ private lastAskEviction;
456
519
  /**
457
520
  * Remember that the gate positively allowed one call, so an approval raised from
458
521
  * inside that same call can be answered from this verdict.
@@ -552,6 +615,25 @@ export declare class PermGateRuntime {
552
615
  * settled by {@link settleExecution} when the call actually executes.
553
616
  */
554
617
  refineAsk(exec: ToolExecutionLike, decision: PreToolDecisionLike): Promise<PreToolDecisionLike | undefined>;
618
+ /**
619
+ * One frozen view of a call, built the same way for every ask path so the
620
+ * popup cannot disagree with what the grader was shown.
621
+ */
622
+ private contextFor;
623
+ /**
624
+ * An ask plus the card the human actually needs, in the host's presentation-only field.
625
+ *
626
+ * The plan's B4 clause lists five things for the popup — the redacted command,
627
+ * the files with their true total, the category, the reversibility and the tier
628
+ * knobs. `reason` alone cannot carry them: it is the audited string, and `REASON_MAX`
629
+ * truncates at 200 characters. `displayReason` is the host's own channel for
630
+ * presentation that never enters the audit pair, so the card goes there — starting
631
+ * with the audited reason (why the gate is asking at all), then the redacted
632
+ * command, the file list, the language-neutral tokens and the sentence about what
633
+ * each button costs. Without the command line a person is asked to approve an
634
+ * operation they cannot read, which is the complaint this task exists to fix.
635
+ */
636
+ private askDecision;
555
637
  /**
556
638
  * Record the human's terminal answer for one tracked ask. This is the primary
557
639
  * channel, driven by the `approval/request` observer, which sees the closed