dsh-perm-gate 5.1.2-compat.7 → 5.1.2
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.md +104 -0
- package/dsh.plugin.json +1 -1
- package/lib/ask-context.d.ts +207 -0
- package/lib/ask-context.js +504 -0
- package/lib/bridge.d.ts +9 -2
- package/lib/bridge.js +7 -4
- package/lib/classifier.d.ts +8 -14
- package/lib/classifier.js +24 -2
- package/lib/client.js +126 -126
- package/lib/client.js.map +1 -1
- package/lib/command-normalize.d.ts +80 -0
- package/lib/command-normalize.js +246 -0
- package/lib/engine.js +78 -58
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.js +17 -11
- package/lib/events.d.ts +26 -0
- package/lib/events.js +7 -0
- package/lib/grader-rules.d.ts +32 -0
- package/lib/grader-rules.js +48 -0
- package/lib/index.js +5 -0
- package/lib/irreversible.d.ts +54 -0
- package/lib/irreversible.js +376 -0
- package/lib/parsers/shell-cmds.d.ts +9 -0
- package/lib/parsers/shell-cmds.js +37 -3
- package/lib/redact.d.ts +55 -0
- package/lib/redact.js +183 -0
- package/lib/risk.d.ts +32 -5
- package/lib/risk.js +44 -40
- package/lib/runtime.d.ts +82 -0
- package/lib/runtime.js +216 -26
- package/lib/tool-names.d.ts +43 -0
- package/lib/tool-names.js +85 -0
- package/package.json +2 -2
package/lib/redact.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction primitives for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Before this module existed, the grader payload (`risk.ts riskUserText`)
|
|
5
|
+
* serialized the *whole* `args` object — including file bodies and inline
|
|
6
|
+
* credentials — and POSTed it to a user-configured third-party endpoint, while
|
|
7
|
+
* `classifier.ts` claimed secrets had already been stripped upstream. They had
|
|
8
|
+
* not. This module is the single place that makes that claim true.
|
|
9
|
+
*
|
|
10
|
+
* Three properties are load-bearing, and each has a test:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Judgment-preserving.** Only secret *values* are masked; verbs, path
|
|
13
|
+
* segments, flags and remote hosts survive, because the deterministic layers
|
|
14
|
+
* (deny-keywords, `isCleanupSafe`, the parsers) and the reversibility tier
|
|
15
|
+
* all read those. A redaction that hides `rm -rf /` would be a security
|
|
16
|
+
* regression, so `adv_040/043/046/047` must still read as credential/remote.
|
|
17
|
+
* 2. **Idempotent.** Every replacement is a `[redacted:<kind>:<len>]` marker
|
|
18
|
+
* that the patterns themselves refuse to re-match. Without this, piping an
|
|
19
|
+
* already-redacted value through a second layer (grader payload + event
|
|
20
|
+
* digest) silently rewrites lengths and breaks fingerprint stability.
|
|
21
|
+
* 3. **Bounded.** Long bodies collapse to a byte count plus a cheap hash, so
|
|
22
|
+
* redaction also caps prompt size — grading latency is prefill-dominated.
|
|
23
|
+
*
|
|
24
|
+
* The hash here is FNV-1a, deliberately **not** a cryptographic digest: it only
|
|
25
|
+
* needs to identify "the same big body" across events. Security-sensitive
|
|
26
|
+
* digests (event `inputDigest`) use sha256 on the node half.
|
|
27
|
+
*/
|
|
28
|
+
/** Default ceiling for a redacted command string. */
|
|
29
|
+
export declare const DEFAULT_MAX_COMMAND_CHARS = 800;
|
|
30
|
+
/** Default ceiling for a single argument string before it collapses to a hash. */
|
|
31
|
+
export declare const DEFAULT_MAX_ARG_CHARS = 200;
|
|
32
|
+
/** Options for {@link redactCommandText}. */
|
|
33
|
+
export interface RedactOptions {
|
|
34
|
+
/** Hard ceiling on the returned string; overflow is cut with a `…[+N]` tail. */
|
|
35
|
+
readonly maxChars?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Redact one command/text string, preserving the structure the deterministic
|
|
39
|
+
* layers and the grader depend on. Idempotent by construction.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactCommandText(text: string, options?: RedactOptions): string;
|
|
42
|
+
/** Options for {@link redactArgs}. */
|
|
43
|
+
export interface RedactArgsOptions {
|
|
44
|
+
readonly maxStringChars?: number;
|
|
45
|
+
/** Cap on array length and object keys, so a huge payload cannot bloat the prompt. */
|
|
46
|
+
readonly maxItems?: number;
|
|
47
|
+
readonly maxDepth?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Redact a tool-call argument bag for egress (grader payload, event digest,
|
|
51
|
+
* approval card). Keys are preserved — several are authority-bearing (host,
|
|
52
|
+
* path, command) — and values go through {@link redactCommandText}, with long
|
|
53
|
+
* bodies collapsed to a byte count plus a cheap identity hash.
|
|
54
|
+
*/
|
|
55
|
+
export declare function redactArgs(args: unknown, options?: RedactArgsOptions): unknown;
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction primitives for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Before this module existed, the grader payload (`risk.ts riskUserText`)
|
|
5
|
+
* serialized the *whole* `args` object — including file bodies and inline
|
|
6
|
+
* credentials — and POSTed it to a user-configured third-party endpoint, while
|
|
7
|
+
* `classifier.ts` claimed secrets had already been stripped upstream. They had
|
|
8
|
+
* not. This module is the single place that makes that claim true.
|
|
9
|
+
*
|
|
10
|
+
* Three properties are load-bearing, and each has a test:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Judgment-preserving.** Only secret *values* are masked; verbs, path
|
|
13
|
+
* segments, flags and remote hosts survive, because the deterministic layers
|
|
14
|
+
* (deny-keywords, `isCleanupSafe`, the parsers) and the reversibility tier
|
|
15
|
+
* all read those. A redaction that hides `rm -rf /` would be a security
|
|
16
|
+
* regression, so `adv_040/043/046/047` must still read as credential/remote.
|
|
17
|
+
* 2. **Idempotent.** Every replacement is a `[redacted:<kind>:<len>]` marker
|
|
18
|
+
* that the patterns themselves refuse to re-match. Without this, piping an
|
|
19
|
+
* already-redacted value through a second layer (grader payload + event
|
|
20
|
+
* digest) silently rewrites lengths and breaks fingerprint stability.
|
|
21
|
+
* 3. **Bounded.** Long bodies collapse to a byte count plus a cheap hash, so
|
|
22
|
+
* redaction also caps prompt size — grading latency is prefill-dominated.
|
|
23
|
+
*
|
|
24
|
+
* The hash here is FNV-1a, deliberately **not** a cryptographic digest: it only
|
|
25
|
+
* needs to identify "the same big body" across events. Security-sensitive
|
|
26
|
+
* digests (event `inputDigest`) use sha256 on the node half.
|
|
27
|
+
*/
|
|
28
|
+
/** Marker prefix; a value that already starts with it is left untouched. */
|
|
29
|
+
const MARK_PREFIX = '[redacted:';
|
|
30
|
+
/**
|
|
31
|
+
* One marker, as `[redacted:<len>:<kind>]`.
|
|
32
|
+
*
|
|
33
|
+
* Length comes BEFORE the kind name on purpose: a marker written as
|
|
34
|
+
* `[redacted:token:24]` contains the literal `token:` + payload, which the
|
|
35
|
+
* generic key=value rule happily matches again on the second pass - breaking
|
|
36
|
+
* the idempotency this module promises. Putting the kind last (followed only by
|
|
37
|
+
* `]`) means no rule can find a `key`+separator pair inside a marker.
|
|
38
|
+
*/
|
|
39
|
+
function mark(kind, len) {
|
|
40
|
+
return `${MARK_PREFIX}${len}:${kind}]`;
|
|
41
|
+
}
|
|
42
|
+
/** Whether a captured payload is already one of our markers. */
|
|
43
|
+
function alreadyRedacted(payload) {
|
|
44
|
+
return payload.trimStart().startsWith(MARK_PREFIX);
|
|
45
|
+
}
|
|
46
|
+
/** Default ceiling for a redacted command string. */
|
|
47
|
+
export const DEFAULT_MAX_COMMAND_CHARS = 800;
|
|
48
|
+
/** Default ceiling for a single argument string before it collapses to a hash. */
|
|
49
|
+
export const DEFAULT_MAX_ARG_CHARS = 200;
|
|
50
|
+
const rules = [
|
|
51
|
+
{
|
|
52
|
+
// PEM / OpenSSH / PGP private key blocks: mask the whole blob.
|
|
53
|
+
kind: 'pem',
|
|
54
|
+
re: /-----BEGIN(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----[\s\S]*?-----END(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----/gi,
|
|
55
|
+
pick: (m) => ({ before: '', secret: m, after: '' }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
// Auth headers: keep the header name (a remote-write signal) + the scheme.
|
|
59
|
+
kind: 'token',
|
|
60
|
+
re: /\b(authorization|proxy-authorization|x-api-key|api-key|x-amz-security-token|x-goog-api-key|cookie)\b(\s*[:=]\s*)((?:(?:bearer|basic|token)\s+)?)([^\s"',;]+)/gi,
|
|
61
|
+
pick: (_m, g) => ({ before: `${g[0]}${g[1]}${g[2]}`, secret: g[3] ?? '', after: '' }),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
// URL userinfo: keep scheme + user + host, mask only the password.
|
|
65
|
+
kind: 'pw',
|
|
66
|
+
re: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/?#@]*):([^\s@/?#]+)@/gi,
|
|
67
|
+
pick: (_m, g) => ({ before: `${g[0]}${g[1] ?? ''}:`, secret: g[2] ?? '', after: '@' }),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
// Generic key = value secrets, quoted or bare.
|
|
71
|
+
kind: 'token',
|
|
72
|
+
re: /\b(password|passwd|pwd|secret|secret[_-]?key|client[_-]?secret|api[_-]?key|apikey|access[_-]?token|access[_-]?key|refresh[_-]?token|security[_-]?token|id[_-]?token|private[_-]?token|token)(\s*[:=]\s*)(["']?)([^\s"',;)]+)(\3)?/gi,
|
|
73
|
+
pick: (_m, g) => ({ before: `${g[0]}${g[1]}${g[2]}`, secret: g[3] ?? '', after: g[4] ?? '' }),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// Bare scheme + credential as a standalone value (e.g. an `authorization`
|
|
77
|
+
// object member whose value is `Bearer ...` - no header name in the string).
|
|
78
|
+
kind: 'token',
|
|
79
|
+
re: /\b(bearer|basic|token)\s+([A-Za-z0-9._~+/=-]{12,})/gi,
|
|
80
|
+
pick: (_m, g) => ({ before: `${g[0]} `, secret: g[1] ?? '', after: '' }),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
// Provider-shaped tokens.
|
|
84
|
+
kind: 'token',
|
|
85
|
+
re: /\b(?:sk|ghp|gho|ghu|ghs|ghr|github_pat|xox[abprs]|glpat|dckr_pat|dpo_|sqat|ya29)\b[-_.][A-Za-z0-9_~-]{8,}|\bAKIA[0-9A-Z]{12,}|\bAIza[0-9A-Za-z_-]{20,}/g,
|
|
86
|
+
pick: (m) => ({ before: '', secret: m, after: '' }),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
// Base64 / hex blobs: masked, but the *shape* (charset + length) is kept so
|
|
90
|
+
// the grader still sees "an opaque encoded payload is being shipped here".
|
|
91
|
+
kind: 'b64',
|
|
92
|
+
re: /(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{24,}={0,2}(?![A-Za-z0-9+/=])/g,
|
|
93
|
+
pick: (m) => ({ before: '', secret: m, after: '' }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
kind: 'hex',
|
|
97
|
+
re: /(?<![0-9a-fA-F])[0-9a-fA-F]{32,}(?![0-9a-fA-F])/g,
|
|
98
|
+
pick: (m) => ({ before: '', secret: m, after: '' }),
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
/** Collapse to a bounded length, keeping a visible `…[+N]` tail. */
|
|
102
|
+
function clamp(text, maxChars) {
|
|
103
|
+
if (text.length <= maxChars)
|
|
104
|
+
return text;
|
|
105
|
+
const kept = text.slice(0, maxChars);
|
|
106
|
+
return `${kept}…[+${text.length - maxChars}]`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Redact one command/text string, preserving the structure the deterministic
|
|
110
|
+
* layers and the grader depend on. Idempotent by construction.
|
|
111
|
+
*/
|
|
112
|
+
export function redactCommandText(text, options = {}) {
|
|
113
|
+
const maxChars = options.maxChars ?? DEFAULT_MAX_COMMAND_CHARS;
|
|
114
|
+
if (text === '')
|
|
115
|
+
return text;
|
|
116
|
+
let out = text;
|
|
117
|
+
for (const rule of rules) {
|
|
118
|
+
rule.re.lastIndex = 0;
|
|
119
|
+
out = out.replace(rule.re, (whole, ...rest) => {
|
|
120
|
+
// `rest` is the capture groups, then offset and the source string.
|
|
121
|
+
const groups = rest.slice(0, Math.max(0, rest.length - 2))
|
|
122
|
+
.map((g) => (typeof g === 'string' ? g : ''));
|
|
123
|
+
const { before, secret, after } = rule.pick(whole, groups);
|
|
124
|
+
// Skip anything already carrying a marker, so re-passing is a no-op.
|
|
125
|
+
if (whole.includes(MARK_PREFIX) || secret === '' || alreadyRedacted(secret))
|
|
126
|
+
return whole;
|
|
127
|
+
return `${before}${mark(rule.kind, secret.length)}${after}`;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return clamp(out, maxChars);
|
|
131
|
+
}
|
|
132
|
+
/** FNV-1a, 32-bit, hex. Identity only — never a security digest. */
|
|
133
|
+
function fnv1a(text) {
|
|
134
|
+
let h = 0x811c9dc5;
|
|
135
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
136
|
+
h ^= text.charCodeAt(i);
|
|
137
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
138
|
+
}
|
|
139
|
+
return h.toString(16).padStart(8, '0');
|
|
140
|
+
}
|
|
141
|
+
/** Collapse a long body to a marker that still identifies it. */
|
|
142
|
+
function collapseBody(text) {
|
|
143
|
+
return `${MARK_PREFIX}${text.length}B:text#${fnv1a(text)}]`;
|
|
144
|
+
}
|
|
145
|
+
function redactValue(value, opts, depth) {
|
|
146
|
+
const { maxStringChars, maxItems, maxDepth } = opts;
|
|
147
|
+
if (typeof value === 'string') {
|
|
148
|
+
if (alreadyRedacted(value))
|
|
149
|
+
return value;
|
|
150
|
+
if (value.length > maxStringChars)
|
|
151
|
+
return collapseBody(value);
|
|
152
|
+
return redactCommandText(value, { maxChars: maxStringChars });
|
|
153
|
+
}
|
|
154
|
+
if (value === null || typeof value !== 'object')
|
|
155
|
+
return value;
|
|
156
|
+
if (depth >= maxDepth)
|
|
157
|
+
return mark('depth', Array.isArray(value) ? value.length : Object.keys(value).length);
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
const kept = value.slice(0, maxItems).map((v) => redactValue(v, opts, depth + 1));
|
|
160
|
+
if (value.length > maxItems)
|
|
161
|
+
kept.push(mark('more', value.length - maxItems));
|
|
162
|
+
return kept;
|
|
163
|
+
}
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [k, v] of Object.entries(value).slice(0, maxItems)) {
|
|
166
|
+
out[k] = redactValue(v, opts, depth + 1);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Redact a tool-call argument bag for egress (grader payload, event digest,
|
|
172
|
+
* approval card). Keys are preserved — several are authority-bearing (host,
|
|
173
|
+
* path, command) — and values go through {@link redactCommandText}, with long
|
|
174
|
+
* bodies collapsed to a byte count plus a cheap identity hash.
|
|
175
|
+
*/
|
|
176
|
+
export function redactArgs(args, options = {}) {
|
|
177
|
+
const opts = {
|
|
178
|
+
maxStringChars: options.maxStringChars ?? DEFAULT_MAX_ARG_CHARS,
|
|
179
|
+
maxItems: options.maxItems ?? 50,
|
|
180
|
+
maxDepth: options.maxDepth ?? 6,
|
|
181
|
+
};
|
|
182
|
+
return redactValue(args, opts, 0);
|
|
183
|
+
}
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
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,
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|