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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command normalization — decode the layers a call hides behind, as a scan signal.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every deterministic layer reads the command *as written*.
|
|
5
|
+
* `deny-defaults` is a word list, the parsers switch on `baseName`, and
|
|
6
|
+
* `isCleanupSafe` matches path segments. All three are blind to
|
|
7
|
+
* `powershell -EncodedCommand UgBlAG0A...`, to `R="-rf"; rm $R /`, and to a
|
|
8
|
+
* function wrapper around a wipe. Measured on the eval set, the encoded cases are
|
|
9
|
+
* exactly where a graded model ships an unrecoverable act past the gate
|
|
10
|
+
* (`adv_013` decodes to `Remove-Item -Recurse -Force C:\`, `adv_032` to
|
|
11
|
+
* `DROP DATABASE orders_prod`).
|
|
12
|
+
*
|
|
13
|
+
* Two rules keep this from becoming its own vulnerability:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Additive only.** The caller feeds these strings to *stricter* scans
|
|
16
|
+
* (deny-keyword, P0 wipe detection, tier grading). Nothing here can widen a
|
|
17
|
+
* decision, and no result is written back to `exec.arguments` or the event
|
|
18
|
+
* log, so fingerprints, grants and audit text stay about the call as received.
|
|
19
|
+
* 2. **Bounded.** Length caps, depth cap, variant cap and a printable-ratio gate,
|
|
20
|
+
* because decoding is attacker-controlled input on the decision path. Oversized
|
|
21
|
+
* or undecodable input yields nothing, which leaves the call
|
|
22
|
+
* `targetResolvable=false` — opaque, never optimistically safe.
|
|
23
|
+
*
|
|
24
|
+
* On the plugin's own `--exec-b64` launcher wrapper: it is decoded like anything
|
|
25
|
+
* else rather than exempted. An exemption would hand the agent a disguise
|
|
26
|
+
* (`ac-launcher --config x --exec-b64 <rm -rf ...>`), while decoding it can only
|
|
27
|
+
* ever escalate — so the cost is at most a second confirmation, never a block
|
|
28
|
+
* that could not have been reached honestly.
|
|
29
|
+
*/
|
|
30
|
+
/** Where a decoded string came from, and whether the original runs it as code. */
|
|
31
|
+
export interface NormalizedText {
|
|
32
|
+
readonly text: string;
|
|
33
|
+
readonly via: 'base64' | 'hex' | 'varsub';
|
|
34
|
+
/**
|
|
35
|
+
* True when the payload is handed to an execution primitive
|
|
36
|
+
* (`-EncodedCommand`, `FromBase64String`, `Invoke-Expression`, `eval`,
|
|
37
|
+
* `bash -c`, `xxd -r -p` …), i.e. it will really run. Only these variants may
|
|
38
|
+
* feed P0 hard-deny; a stray base64 blob in a comment must not.
|
|
39
|
+
*/
|
|
40
|
+
readonly executed: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface NormalizeOptions {
|
|
43
|
+
/** Refuse to touch a command longer than this (DoS + false-positive guard). */
|
|
44
|
+
readonly maxInputChars?: number;
|
|
45
|
+
/** How many decode layers to peel. */
|
|
46
|
+
readonly maxDepth?: number;
|
|
47
|
+
/** Shortest base64 blob considered (below this, too noisy). */
|
|
48
|
+
readonly minBlobChars?: number;
|
|
49
|
+
/** Longest decoded output kept. */
|
|
50
|
+
readonly maxOutputChars?: number;
|
|
51
|
+
/** Hard cap on returned variants. */
|
|
52
|
+
readonly maxVariants?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Wall-clock budget for the whole peel. On expiry **all** partial work is
|
|
55
|
+
* dropped, not just the remaining layers: a decoded fragment found late could
|
|
56
|
+
* turn into a P0 deny, and the guardrail this plugin cannot trade away is that
|
|
57
|
+
* the normalizer never decides — an expired peel leaves the call opaque, so the
|
|
58
|
+
* decision falls back to the ask it was already heading for.
|
|
59
|
+
*/
|
|
60
|
+
readonly maxMs?: number;
|
|
61
|
+
/** Injected clock (tests drive the deadline without sleeping). */
|
|
62
|
+
readonly now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Peel encoding layers off one command and return every readable variant.
|
|
66
|
+
*
|
|
67
|
+
* Never throws: any failure yields whatever decoded fine, and an empty result
|
|
68
|
+
* simply means the call stays opaque to the deterministic layers. Exceeding the
|
|
69
|
+
* time budget returns **nothing** rather than the partial set (see `maxMs`).
|
|
70
|
+
*/
|
|
71
|
+
export declare function normalizeCommandText(text: string, options?: NormalizeOptions): NormalizedText[];
|
|
72
|
+
/**
|
|
73
|
+
* Whether a command is the plugin's own AppContainer launcher wrapper.
|
|
74
|
+
* Informational only — the payload is still decoded and judged on its merits.
|
|
75
|
+
*/
|
|
76
|
+
export declare function isOwnLauncher(text: string): boolean;
|
|
77
|
+
/** Variants strict enough to feed a deterministic deny: the payload actually runs. */
|
|
78
|
+
export declare function executableVariants(variants: readonly NormalizedText[]): string[];
|
|
79
|
+
/** All decoded variants, for the additive-only keyword/tier scans. */
|
|
80
|
+
export declare function allVariants(variants: readonly NormalizedText[]): string[];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command normalization — decode the layers a call hides behind, as a scan signal.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every deterministic layer reads the command *as written*.
|
|
5
|
+
* `deny-defaults` is a word list, the parsers switch on `baseName`, and
|
|
6
|
+
* `isCleanupSafe` matches path segments. All three are blind to
|
|
7
|
+
* `powershell -EncodedCommand UgBlAG0A...`, to `R="-rf"; rm $R /`, and to a
|
|
8
|
+
* function wrapper around a wipe. Measured on the eval set, the encoded cases are
|
|
9
|
+
* exactly where a graded model ships an unrecoverable act past the gate
|
|
10
|
+
* (`adv_013` decodes to `Remove-Item -Recurse -Force C:\`, `adv_032` to
|
|
11
|
+
* `DROP DATABASE orders_prod`).
|
|
12
|
+
*
|
|
13
|
+
* Two rules keep this from becoming its own vulnerability:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Additive only.** The caller feeds these strings to *stricter* scans
|
|
16
|
+
* (deny-keyword, P0 wipe detection, tier grading). Nothing here can widen a
|
|
17
|
+
* decision, and no result is written back to `exec.arguments` or the event
|
|
18
|
+
* log, so fingerprints, grants and audit text stay about the call as received.
|
|
19
|
+
* 2. **Bounded.** Length caps, depth cap, variant cap and a printable-ratio gate,
|
|
20
|
+
* because decoding is attacker-controlled input on the decision path. Oversized
|
|
21
|
+
* or undecodable input yields nothing, which leaves the call
|
|
22
|
+
* `targetResolvable=false` — opaque, never optimistically safe.
|
|
23
|
+
*
|
|
24
|
+
* On the plugin's own `--exec-b64` launcher wrapper: it is decoded like anything
|
|
25
|
+
* else rather than exempted. An exemption would hand the agent a disguise
|
|
26
|
+
* (`ac-launcher --config x --exec-b64 <rm -rf ...>`), while decoding it can only
|
|
27
|
+
* ever escalate — so the cost is at most a second confirmation, never a block
|
|
28
|
+
* that could not have been reached honestly.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULTS = {
|
|
31
|
+
maxInputChars: 4096,
|
|
32
|
+
maxDepth: 3,
|
|
33
|
+
minBlobChars: 16,
|
|
34
|
+
maxOutputChars: 2048,
|
|
35
|
+
maxVariants: 8,
|
|
36
|
+
maxMs: 25,
|
|
37
|
+
now: Date.now,
|
|
38
|
+
};
|
|
39
|
+
/** Primitives that run a following encoded string / blob as code. */
|
|
40
|
+
const EXEC_PRIMITIVE = /(?:-EncodedCommand|FromBase64String|Invoke-Expression|\biex\b|\beval\b|bash\s+-c|sh\s+-c|pwsh\s+-c|powershell\s+-c|xxd\s+-r|--decode\b)/i;
|
|
41
|
+
/** base64 blobs long enough to be a real payload, quoted or bare. */
|
|
42
|
+
const B64_RE = /["']?([A-Za-z0-9+/]{16,}={0,2})["']?/g;
|
|
43
|
+
/** `\x72\x6d` escapes, or a spaced hex dump. */
|
|
44
|
+
const HEX_ESCAPE_RE = /\\x([0-9a-fA-F]{2})/g;
|
|
45
|
+
const HEX_PAIR_RE = /[0-9a-fA-F]{2}/g;
|
|
46
|
+
/**
|
|
47
|
+
* Simple assignments we can substitute textually: `X="v"`, `$X='v'`, `${X}: v`.
|
|
48
|
+
*
|
|
49
|
+
* The value is matched lazily without escape handling, because a Windows path is
|
|
50
|
+
* the common case and ends in a backslash right before the closing quote
|
|
51
|
+
* (`$t="C:\"`): an escape-aware pattern cannot find that terminator and silently
|
|
52
|
+
* yields nothing, which is the exact blind spot this folding exists to close.
|
|
53
|
+
* Truncating a value that contains a literal quote is acceptable here — variants
|
|
54
|
+
* only ever feed stricter scans.
|
|
55
|
+
*/
|
|
56
|
+
const ASSIGN_RE = /\$?\{?([A-Za-z_][A-Za-z0-9_]*)\}?\s*=\s*(["'])(.*?)\2/g;
|
|
57
|
+
/** Share of decoded bytes that must be printable before we trust it as a command. */
|
|
58
|
+
const PRINTABLE_MIN = 0.85;
|
|
59
|
+
function printableRatio(text) {
|
|
60
|
+
if (text === '')
|
|
61
|
+
return 0;
|
|
62
|
+
let ok = 0;
|
|
63
|
+
for (const ch of text) {
|
|
64
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
65
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f))
|
|
66
|
+
ok += 1;
|
|
67
|
+
}
|
|
68
|
+
return ok / text.length;
|
|
69
|
+
}
|
|
70
|
+
function clip(text, max) {
|
|
71
|
+
return text.length <= max ? text : text.slice(0, max);
|
|
72
|
+
}
|
|
73
|
+
/** Decoded text is only useful if it plausibly is a command, not binary noise. */
|
|
74
|
+
function looksLikeText(text, minPrintable = PRINTABLE_MIN) {
|
|
75
|
+
return text !== '' && printableRatio(text) >= minPrintable && /[A-Za-z]{3}/.test(text);
|
|
76
|
+
}
|
|
77
|
+
/** Decode a blob as one encoding; '' when it is not valid base64 at all. */
|
|
78
|
+
function decodeB64(blob, encoding) {
|
|
79
|
+
try {
|
|
80
|
+
return Buffer.from(blob, 'base64').toString(encoding);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decode one base64 blob, or undefined when it is not decodable text.
|
|
88
|
+
*
|
|
89
|
+
* Two readings are attempted because they look identical from outside and both
|
|
90
|
+
* are common:
|
|
91
|
+
* - **utf16le** — `powershell/pwsh -EncodedCommand` and
|
|
92
|
+
* `[Convert]::ToBase64String([Text.Encoding]::Unicode…)` carry UTF-16LE. Read
|
|
93
|
+
* as UTF-8 the result is `R\0e\0m\0o\0v\0e…`, which a printable check alone
|
|
94
|
+
* rejects: a silent false negative on exactly the highest-value case, caught
|
|
95
|
+
* only once the test asserted the decoded text.
|
|
96
|
+
* - **utf8** — POSIX `base64 -d` and generic payloads.
|
|
97
|
+
*/
|
|
98
|
+
function tryB64(blob, opts) {
|
|
99
|
+
if (blob.length < opts.minBlobChars)
|
|
100
|
+
return undefined;
|
|
101
|
+
const utf8 = decodeB64(blob, 'utf8');
|
|
102
|
+
const interleavedNull = /\x00/.test(utf8);
|
|
103
|
+
if (!interleavedNull && looksLikeText(utf8))
|
|
104
|
+
return clip(utf8, opts.maxOutputChars);
|
|
105
|
+
const utf16 = decodeB64(blob, 'utf16le');
|
|
106
|
+
if (looksLikeText(utf16))
|
|
107
|
+
return clip(utf16, opts.maxOutputChars);
|
|
108
|
+
if (looksLikeText(utf8))
|
|
109
|
+
return clip(utf8, opts.maxOutputChars);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/** Decode `\x72\x6d` escapes or a spaced hex dump into text. */
|
|
113
|
+
function tryHex(blob, opts) {
|
|
114
|
+
const escaped = [];
|
|
115
|
+
HEX_ESCAPE_RE.lastIndex = 0;
|
|
116
|
+
let e = HEX_ESCAPE_RE.exec(blob);
|
|
117
|
+
while (e !== null) {
|
|
118
|
+
escaped.push(parseInt(e[1] ?? '0', 16));
|
|
119
|
+
e = HEX_ESCAPE_RE.exec(blob);
|
|
120
|
+
}
|
|
121
|
+
const bytes = escaped.length > 0 ? escaped : [];
|
|
122
|
+
if (bytes.length === 0) {
|
|
123
|
+
const compact = blob.replace(/\s+/g, '');
|
|
124
|
+
if (compact.length % 2 !== 0)
|
|
125
|
+
return undefined;
|
|
126
|
+
HEX_PAIR_RE.lastIndex = 0;
|
|
127
|
+
let m = HEX_PAIR_RE.exec(compact);
|
|
128
|
+
while (m !== null) {
|
|
129
|
+
bytes.push(parseInt(m[0], 16));
|
|
130
|
+
m = HEX_PAIR_RE.exec(compact);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (bytes.length === 0)
|
|
134
|
+
return undefined;
|
|
135
|
+
let hex;
|
|
136
|
+
try {
|
|
137
|
+
hex = Buffer.from(bytes).toString('utf8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (!looksLikeText(hex))
|
|
143
|
+
return undefined;
|
|
144
|
+
return clip(hex, opts.maxOutputChars);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Substitute `X="rm -rf"` / `$X` pairs so `rm $X /` becomes visible as one
|
|
148
|
+
* string. Only literal assignments are folded; nothing is evaluated.
|
|
149
|
+
*/
|
|
150
|
+
function expandVars(text, opts) {
|
|
151
|
+
const table = new Map();
|
|
152
|
+
ASSIGN_RE.lastIndex = 0;
|
|
153
|
+
let m = ASSIGN_RE.exec(text);
|
|
154
|
+
while (m !== null) {
|
|
155
|
+
const name = m[1];
|
|
156
|
+
const value = m[3] ?? '';
|
|
157
|
+
if (name !== undefined && value !== '')
|
|
158
|
+
table.set(name, value);
|
|
159
|
+
m = ASSIGN_RE.exec(text);
|
|
160
|
+
}
|
|
161
|
+
if (table.size === 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
let out = text;
|
|
164
|
+
for (const [name, value] of table) {
|
|
165
|
+
// Skip the assignment's own left-hand side (`$t=` …), which would otherwise
|
|
166
|
+
// be rewritten into noise like `C:\="C:\"`; only uses are folded.
|
|
167
|
+
const use = new RegExp(`\\$\\{?${name}\\}?(?!\\s*=)`, 'g');
|
|
168
|
+
out = out.replace(use, () => value);
|
|
169
|
+
}
|
|
170
|
+
return out === text ? undefined : clip(out, opts.maxOutputChars);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Peel encoding layers off one command and return every readable variant.
|
|
174
|
+
*
|
|
175
|
+
* Never throws: any failure yields whatever decoded fine, and an empty result
|
|
176
|
+
* simply means the call stays opaque to the deterministic layers. Exceeding the
|
|
177
|
+
* time budget returns **nothing** rather than the partial set (see `maxMs`).
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeCommandText(text, options = {}) {
|
|
180
|
+
const opts = { ...DEFAULTS, ...options };
|
|
181
|
+
if (typeof text !== 'string' || text.trim() === '' || text.length > opts.maxInputChars)
|
|
182
|
+
return [];
|
|
183
|
+
const startedAt = opts.now();
|
|
184
|
+
const spent = () => opts.now() - startedAt;
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const out = [];
|
|
187
|
+
const push = (variant) => {
|
|
188
|
+
const trimmed = variant.text.trim();
|
|
189
|
+
if (trimmed === '' || seen.has(trimmed))
|
|
190
|
+
return;
|
|
191
|
+
seen.add(trimmed);
|
|
192
|
+
out.push({ ...variant, text: trimmed });
|
|
193
|
+
};
|
|
194
|
+
let frontier = [{ text, executed: false }];
|
|
195
|
+
for (let depth = 0; depth < opts.maxDepth && frontier.length > 0 && out.length < opts.maxVariants; depth += 1) {
|
|
196
|
+
if (spent() > opts.maxMs)
|
|
197
|
+
return [];
|
|
198
|
+
const next = [];
|
|
199
|
+
for (const item of frontier) {
|
|
200
|
+
const runsCode = item.executed || EXEC_PRIMITIVE.test(item.text);
|
|
201
|
+
B64_RE.lastIndex = 0;
|
|
202
|
+
let b = B64_RE.exec(item.text);
|
|
203
|
+
while (b !== null) {
|
|
204
|
+
const decoded = tryB64(b[1] ?? '', opts);
|
|
205
|
+
if (decoded !== undefined) {
|
|
206
|
+
push({ text: decoded, via: 'base64', executed: runsCode });
|
|
207
|
+
next.push({ text: decoded, executed: runsCode });
|
|
208
|
+
}
|
|
209
|
+
b = B64_RE.exec(item.text);
|
|
210
|
+
}
|
|
211
|
+
if (/\\x[0-9a-fA-F]{2}/.test(item.text) || /(?:\b[0-9a-fA-F]{2}\b){6,}/.test(item.text)) {
|
|
212
|
+
const decoded = tryHex(item.text, opts);
|
|
213
|
+
if (decoded !== undefined) {
|
|
214
|
+
push({ text: decoded, via: 'hex', executed: runsCode });
|
|
215
|
+
next.push({ text: decoded, executed: runsCode });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const expanded = expandVars(item.text, opts);
|
|
219
|
+
if (expanded !== undefined) {
|
|
220
|
+
// A folded variable is the same command the shell will run, not a hidden
|
|
221
|
+
// payload, so it always counts as executed.
|
|
222
|
+
push({ text: expanded, via: 'varsub', executed: true });
|
|
223
|
+
next.push({ text: expanded, executed: true });
|
|
224
|
+
}
|
|
225
|
+
if (out.length >= opts.maxVariants)
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
frontier = next;
|
|
229
|
+
}
|
|
230
|
+
return out.slice(0, opts.maxVariants);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Whether a command is the plugin's own AppContainer launcher wrapper.
|
|
234
|
+
* Informational only — the payload is still decoded and judged on its merits.
|
|
235
|
+
*/
|
|
236
|
+
export function isOwnLauncher(text) {
|
|
237
|
+
return /--exec-b64\s+[A-Za-z0-9+/=]{8,}/.test(text) && /--config\s+/.test(text);
|
|
238
|
+
}
|
|
239
|
+
/** Variants strict enough to feed a deterministic deny: the payload actually runs. */
|
|
240
|
+
export function executableVariants(variants) {
|
|
241
|
+
return variants.filter((v) => v.executed).map((v) => v.text);
|
|
242
|
+
}
|
|
243
|
+
/** All decoded variants, for the additive-only keyword/tier scans. */
|
|
244
|
+
export function allVariants(variants) {
|
|
245
|
+
return variants.map((v) => v.text);
|
|
246
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -9,57 +9,15 @@
|
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
11
|
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// `
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
12
|
+
import { commandIrreversibleReason, irreversibleReason } from './irreversible.js';
|
|
13
|
+
import { executableVariants, normalizeCommandText } from './command-normalize.js';
|
|
14
|
+
// 工具名名单归 `tool-names.ts` 所有(不得在此处再抄一份)。P0 **不看工具名白名单**:
|
|
15
|
+
// 它看 `executableCommandOf`——已知非执行器(read/write/内部工具)的 `command` 参数是数据,
|
|
16
|
+
// 其余任何带命令串的调用都按命令对待,所以工具叫什么(包括模型打错的 `pash`)都不是绕过面。
|
|
17
|
+
import { INTERNAL_TOOLS, READ_TOOLS, executableCommandOf } from './tool-names.js';
|
|
18
|
+
import { decomposeShellCommand } from './shell.js';
|
|
19
19
|
import { dispatchCommand } from './command-dispatcher.js';
|
|
20
20
|
const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
|
|
21
|
-
/**
|
|
22
|
-
* Read-only tools that can never modify the workspace or execute anything. P0
|
|
23
|
-
* still protects sensitive path reads outside the workspace root, and the
|
|
24
|
-
* deny-keyword layer still runs first, so a search whose query contains a
|
|
25
|
-
* blacklisted phrase is still vetoed.
|
|
26
|
-
*/
|
|
27
|
-
const READ_TOOLS = new Set([
|
|
28
|
-
'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
|
|
29
|
-
// Search / lookup: every one of these only READS. `web_search` and
|
|
30
|
-
// `modlens_read_image` also reach the network, but through DSH's own client
|
|
31
|
-
// rather than a subprocess — see the network note below.
|
|
32
|
-
'web_search', 'modlens_read_image',
|
|
33
|
-
'advanced_search', 'platform_search', 'free_search_test',
|
|
34
|
-
// Session-scoped reads: they disclose already-recorded state, never mutate.
|
|
35
|
-
'context_compression_retrieve', 'memory_search_graph', 'memory_expand_graph_node',
|
|
36
|
-
]);
|
|
37
|
-
/** DSH internal coordination / management tools — not workspace-modifying. */
|
|
38
|
-
const INTERNAL_TOOLS = new Set([
|
|
39
|
-
// AgentTeams coordination
|
|
40
|
-
'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member',
|
|
41
|
-
'agent_teams_delete', 'agent_teams_edit_plan', 'agent_teams_reassign_task',
|
|
42
|
-
'agent_teams_claim_task', 'agent_teams_create_task', 'agent_teams_send_message',
|
|
43
|
-
'agent_teams_approve', 'agent_teams_resume',
|
|
44
|
-
// AgentTeams status reads
|
|
45
|
-
'agent_teams_status', 'agent_teams_update_task',
|
|
46
|
-
// DSH session/memory/goal/taskboard management. The memory store is the
|
|
47
|
-
// agent's own state, not the workspace: writing it is not a workspace write.
|
|
48
|
-
'conversation_search',
|
|
49
|
-
'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
|
|
50
|
-
'memory_import', 'memory_ruminate', 'memory_ruminate_cancel', 'memory_ruminate_status',
|
|
51
|
-
'get_goal', 'update_goal', 'create_goal',
|
|
52
|
-
'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
|
|
53
|
-
'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
|
|
54
|
-
'taskboard_relate',
|
|
55
|
-
// Job/agent management
|
|
56
|
-
'job_list', 'job_output', 'job_kill',
|
|
57
|
-
'list_agents', 'interrupt_agent', 'send_message',
|
|
58
|
-
// Session-local UI / plan / todo state (no workspace write, no execution)
|
|
59
|
-
'todo_write', 'render_ui', 'validate_dsh_ui', 'ask_user_question', 'exit_plan_mode',
|
|
60
|
-
// Background / planning delegation (the delegated work is gated separately)
|
|
61
|
-
'subagent', 'subagent_fork', 'ralph', 'workflow', 'skill',
|
|
62
|
-
]);
|
|
63
21
|
/** No user-configured extra auto-allow names. */
|
|
64
22
|
const NO_EXTRA_AUTO_ALLOW = new Set();
|
|
65
23
|
/** The built-in read-only / internal classification, for diagnostics and docs. */
|
|
@@ -102,9 +60,41 @@ function operationText(args, depth = 0) {
|
|
|
102
60
|
}
|
|
103
61
|
return parts.join(' ');
|
|
104
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Credential or private-key material a call carries.
|
|
65
|
+
*
|
|
66
|
+
* Two clauses were removed on purpose, both because this predicate reads one flat
|
|
67
|
+
* string and therefore cannot tell *using* a secret from *moving* one — and P0 is
|
|
68
|
+
* the layer nobody can appeal:
|
|
69
|
+
*
|
|
70
|
+
* - `Bearer <token>`: putting a token in its own `Authorization` header is how every
|
|
71
|
+
* API is called. The benign-encoding corpus measured it as a false hard-deny
|
|
72
|
+
* (`curl -H "Authorization: Bearer …" https://api.internal/v1/me`); a command that
|
|
73
|
+
* cannot be shown to anyone is also a command that never reaches the popup.
|
|
74
|
+
* - a read-only look at a key file: `Test-Path ~/.ssh/id_rsa` moves nothing. The
|
|
75
|
+
* plan's own grading rule says so ("reading a local credential is not itself the
|
|
76
|
+
* act"), and the deny layer must not contradict the prompt it hands the model.
|
|
77
|
+
*
|
|
78
|
+
* What is left is the theft shape: a secret *artifact* plus a channel out, or any
|
|
79
|
+
* non-readonly verb touching one. That pairing lives in `irreversible.ts`, so both
|
|
80
|
+
* layers agree.
|
|
81
|
+
*/
|
|
82
|
+
const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/](?:id_|config)|credentials\.yaml)/i;
|
|
83
|
+
/** Verbs that only look. A call made of nothing else cannot have leaked anything. */
|
|
84
|
+
const READ_ONLY_HEAD = /^(?:test-path|test|ls|dir|get-childitem|get-content|get-item|cat|type|more|head|tail|stat|file|select-string|where|which|exists)$/i;
|
|
85
|
+
/** Whether every segment of this command text is a read-only look. */
|
|
86
|
+
function readonlyProbe(text) {
|
|
87
|
+
const segments = text.split(/[;&|\n]+/).map((s) => s.trim()).filter((s) => s !== '');
|
|
88
|
+
if (segments.length === 0)
|
|
89
|
+
return false;
|
|
90
|
+
return segments.every((seg) => {
|
|
91
|
+
const head = (seg.replace(/^["'`]+/, '').split(/[\s/]+/)[0] ?? '').toLowerCase();
|
|
92
|
+
return READ_ONLY_HEAD.test(head);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
105
95
|
function containsCredentialMaterial(args) {
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
const text = operationText(args);
|
|
97
|
+
return CREDENTIAL_MATERIAL.test(text) && !readonlyProbe(text);
|
|
108
98
|
}
|
|
109
99
|
function pathArgument(args) {
|
|
110
100
|
for (const key of ['file_path', 'path', 'cwd', 'workdir']) {
|
|
@@ -116,6 +106,14 @@ function pathArgument(args) {
|
|
|
116
106
|
}
|
|
117
107
|
/** 廉价前缀过滤:绝大多数 shell 调用不是 push,先过一遍正则再动用解析器。 */
|
|
118
108
|
const GIT_PUSH_TEXT = /\bgit\s+push\b/;
|
|
109
|
+
/**
|
|
110
|
+
* Irreversibility itself is **not** defined here: it lives in `irreversible.ts`,
|
|
111
|
+
* shared with the tier grading in `ask-context.ts`. Two copies of that fact drift,
|
|
112
|
+
* and the drift was observed — `Optimize-VHD` was known to no rule while no graded
|
|
113
|
+
* model hard-denied it either. P0 consumes the shared judgement, and only ever
|
|
114
|
+
* turns it into a deny for the command as received plus the decoded variants the
|
|
115
|
+
* original will actually execute.
|
|
116
|
+
*/
|
|
119
117
|
/**
|
|
120
118
|
* 远端历史改写:`git push` 强制覆盖或删除**保护分支**。
|
|
121
119
|
*
|
|
@@ -158,23 +156,45 @@ export function hardDenyReason(ctx) {
|
|
|
158
156
|
const args = ctx.args;
|
|
159
157
|
if (containsCredentialMaterial(args))
|
|
160
158
|
return 'call contains credential or private-key material';
|
|
161
|
-
|
|
159
|
+
// **Shape, not a name whitelist** (see tool-names.ts): a call that carries a
|
|
160
|
+
// command string and is not a known non-executor is a command call whatever the
|
|
161
|
+
// tool is called, or an unfamiliar name would skip P0 with `diskpart /s x.txt`
|
|
162
|
+
// sitting right there in `args`.
|
|
163
|
+
const commandText = executableCommandOf(ctx.tool, args);
|
|
164
|
+
if (commandText !== undefined) {
|
|
165
|
+
// Encoded layers are resolved **first**: `-EncodedCommand` hides the act from
|
|
166
|
+
// every word-boundary rule below, and the visible shell form can itself be
|
|
167
|
+
// undecodable to the parser (unbalanced quotes), whose early exit must not
|
|
168
|
+
// also skip this check. Only variants the original hands to an execution
|
|
169
|
+
// primitive are trusted, and they can only ever add a deny — nothing here
|
|
170
|
+
// widens, and no decoded text is written back to the call or the log.
|
|
171
|
+
let decodedReason;
|
|
172
|
+
for (const variant of executableVariants(normalizeCommandText(commandText))) {
|
|
173
|
+
const hidden = irreversibleReason(variant);
|
|
174
|
+
if (hidden !== undefined) {
|
|
175
|
+
decodedReason = `${hidden} (decoded from an encoded layer)`;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
162
179
|
let commands = [];
|
|
163
180
|
try {
|
|
164
|
-
commands = decomposeShellCommand(
|
|
181
|
+
commands = decomposeShellCommand(commandText).commands;
|
|
165
182
|
}
|
|
166
183
|
catch {
|
|
167
|
-
|
|
184
|
+
// Undecidable syntax routes to ask (P4), never allow — unless a decode
|
|
185
|
+
// already proved an irreversible act.
|
|
186
|
+
return decodedReason;
|
|
168
187
|
}
|
|
169
188
|
for (const cmd of commands) {
|
|
170
|
-
if (cmd.command === 'rm' && (isRecursiveDeletion(cmd) || isForceDeletion(cmd))) {
|
|
171
|
-
// Guarded further by path rules; root-level deletion denied below only
|
|
172
|
-
// when the explicit target is a protected root.
|
|
173
|
-
}
|
|
174
189
|
const redir = cmd.redirects.find((r) => isProtectedDestructiveTarget(r, ctx.dshHome));
|
|
175
190
|
if (redir !== undefined)
|
|
176
191
|
return `shell redirect targets protected path: ${redir}`;
|
|
192
|
+
const wipe = commandIrreversibleReason(cmd);
|
|
193
|
+
if (wipe !== undefined)
|
|
194
|
+
return wipe;
|
|
177
195
|
}
|
|
196
|
+
if (decodedReason !== undefined)
|
|
197
|
+
return decodedReason;
|
|
178
198
|
const rewrite = gitRemoteRewriteReason(commands);
|
|
179
199
|
if (rewrite !== undefined)
|
|
180
200
|
return rewrite;
|
package/lib/evaluate.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface Decision {
|
|
|
32
32
|
* command-content inspection below for the primary tool — every `git push`,
|
|
33
33
|
* `chmod`, redirect and `tee` would fall through to `defaultAction`.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Executor names, re-exported for the rule layer's `tools` dimension and for
|
|
37
|
+
* learning/appcontainer consumers. The list itself lives in `tool-names.ts` so the
|
|
38
|
+
* "is this an executed command?" question has one answer in the codebase.
|
|
39
|
+
*/
|
|
35
40
|
export declare const SHELL_TOOLS: Set<string>;
|
|
36
41
|
/** Whether one compiled rule matches the given call context. */
|
|
37
42
|
export declare function ruleMatches(rule: CompiledRuleEntry, ctx: ToolCallContext, commands: readonly SimpleCommand[], pathCands: readonly string[], urlCands: readonly string[]): boolean;
|
package/lib/evaluate.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
|
|
|
8
8
|
import { commandArgv, decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
9
9
|
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
10
10
|
import { dispatchCommand } from './command-dispatcher.js';
|
|
11
|
+
import { SHELL_TOOLS as IMPORTED_SHELL_TOOLS, WRITE_TOOLS as IMPORTED_WRITE_TOOLS, executableCommandOf, } from './tool-names.js';
|
|
11
12
|
/**
|
|
12
13
|
* Tool names that execute a shell command string. `shell` and `terminal` are
|
|
13
14
|
* DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
|
|
@@ -16,7 +17,12 @@ import { dispatchCommand } from './command-dispatcher.js';
|
|
|
16
17
|
* command-content inspection below for the primary tool — every `git push`,
|
|
17
18
|
* `chmod`, redirect and `tee` would fall through to `defaultAction`.
|
|
18
19
|
*/
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Executor names, re-exported for the rule layer's `tools` dimension and for
|
|
22
|
+
* learning/appcontainer consumers. The list itself lives in `tool-names.ts` so the
|
|
23
|
+
* "is this an executed command?" question has one answer in the codebase.
|
|
24
|
+
*/
|
|
25
|
+
export const SHELL_TOOLS = IMPORTED_SHELL_TOOLS;
|
|
20
26
|
/** Whether one compiled rule matches the given call context. */
|
|
21
27
|
export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
22
28
|
if (!rule.enabled)
|
|
@@ -118,7 +124,7 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
118
124
|
}
|
|
119
125
|
return true;
|
|
120
126
|
}
|
|
121
|
-
const WRITE_TOOLS =
|
|
127
|
+
const WRITE_TOOLS = IMPORTED_WRITE_TOOLS;
|
|
122
128
|
/**
|
|
123
129
|
* Shell write/execution patterns. When NONE of these match, the command is
|
|
124
130
|
* treated as read-only and follows the defaultAction (typically `allow`).
|
|
@@ -172,11 +178,9 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
|
|
|
172
178
|
return defaultAction;
|
|
173
179
|
}
|
|
174
180
|
// shell/pwsh/terminal: command-content-aware (write pattern → ask)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return 'ask';
|
|
179
|
-
}
|
|
181
|
+
const commandText = commandTextOf(ctx);
|
|
182
|
+
if (commandText !== undefined && isWriteCommand(commandText))
|
|
183
|
+
return 'ask';
|
|
180
184
|
return defaultAction;
|
|
181
185
|
}
|
|
182
186
|
/**
|
|
@@ -189,12 +193,14 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
|
|
|
189
193
|
function commandTextOf(ctx) {
|
|
190
194
|
if (typeof ctx.commandText === 'string')
|
|
191
195
|
return ctx.commandText;
|
|
192
|
-
|
|
193
|
-
return typeof raw === 'string' ? raw : undefined;
|
|
196
|
+
return executableCommandOf(ctx.tool, ctx.args);
|
|
194
197
|
}
|
|
195
198
|
/** Compute the deterministic first-match decision (P2 chain). */
|
|
196
199
|
export function decideRules(ruleset, ctx) {
|
|
197
|
-
|
|
200
|
+
// Command-bearing calls are decomposed by **shape**, not by whether the tool's
|
|
201
|
+
// name is in SHELL_TOOLS: an unfamiliar executor name must not make the command
|
|
202
|
+
// dimensions of every rule silently stop matching.
|
|
203
|
+
const shellText = commandTextOf(ctx);
|
|
198
204
|
const commands = shellText !== undefined ? decomposeSafe(shellText) : [];
|
|
199
205
|
const pathCands = extractPathCandidates(ctx.args);
|
|
200
206
|
const urlCands = extractUrlCandidates(ctx.args);
|
|
@@ -213,7 +219,7 @@ export function decideRules(ruleset, ctx) {
|
|
|
213
219
|
// shell/pwsh/terminal tools check the command for write patterns.
|
|
214
220
|
// Outside safety checks → ask; inside or read-only → defaultAction.
|
|
215
221
|
let action = ruleset.defaultAction;
|
|
216
|
-
if (WRITE_TOOLS.has(ctx.tool) ||
|
|
222
|
+
if (WRITE_TOOLS.has(ctx.tool) || shellText !== undefined) {
|
|
217
223
|
action = pathAndCommandDefault(ctx, pathCands, action);
|
|
218
224
|
}
|
|
219
225
|
if (action === 'allow' || action === 'ask') {
|
package/lib/events.d.ts
CHANGED
|
@@ -32,6 +32,27 @@ export interface GateEvent {
|
|
|
32
32
|
readonly learningCount?: number;
|
|
33
33
|
/** Confirmations required before that key auto-allows (manual-approve events). */
|
|
34
34
|
readonly threshold?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Salted digest of the redacted input behind this decision.
|
|
37
|
+
*
|
|
38
|
+
* Lives as its own field rather than riding in `reason`, because `reason` is
|
|
39
|
+
* truncated to REASON_MAX on write — attribution data must not be the part
|
|
40
|
+
* that gets cut. Raw arguments are never stored.
|
|
41
|
+
*/
|
|
42
|
+
readonly inputDigest?: string;
|
|
43
|
+
/** Which grading prompt produced this verdict, so wording changes are traceable. */
|
|
44
|
+
readonly promptVersion?: string;
|
|
45
|
+
/** Machine keys of "why this asked", so the panel can localize them later. */
|
|
46
|
+
readonly doubtKinds?: readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* The card the human actually decided from (why it asks / what it touches / what
|
|
49
|
+
* each button costs). Stored because a review list that shows only the audit
|
|
50
|
+
* `reason` cannot be read back: the person sees `no rule matched` where they
|
|
51
|
+
* once read a full explanation.
|
|
52
|
+
*/
|
|
53
|
+
readonly humanBrief?: string;
|
|
54
|
+
/** The redacted command, stored apart from the prose so a list can render it as code. */
|
|
55
|
+
readonly humanCommand?: string;
|
|
35
56
|
}
|
|
36
57
|
export interface GateEventInput {
|
|
37
58
|
readonly sessionId?: string;
|
|
@@ -46,6 +67,11 @@ export interface GateEventInput {
|
|
|
46
67
|
readonly files?: readonly string[];
|
|
47
68
|
readonly learningCount?: number;
|
|
48
69
|
readonly threshold?: number;
|
|
70
|
+
readonly inputDigest?: string;
|
|
71
|
+
readonly promptVersion?: string;
|
|
72
|
+
readonly doubtKinds?: readonly string[];
|
|
73
|
+
readonly humanBrief?: string;
|
|
74
|
+
readonly humanCommand?: string;
|
|
49
75
|
/** Session working directory, used to resolve relative snapshot paths. */
|
|
50
76
|
readonly baseDir?: string;
|
|
51
77
|
}
|
package/lib/events.js
CHANGED
|
@@ -264,6 +264,13 @@ export class EventLog {
|
|
|
264
264
|
...(files === undefined ? {} : { files }),
|
|
265
265
|
...(input.learningCount === undefined ? {} : { learningCount: input.learningCount }),
|
|
266
266
|
...(input.threshold === undefined ? {} : { threshold: input.threshold }),
|
|
267
|
+
...(input.inputDigest === undefined ? {} : { inputDigest: input.inputDigest }),
|
|
268
|
+
...(input.promptVersion === undefined ? {} : { promptVersion: input.promptVersion }),
|
|
269
|
+
...(input.doubtKinds === undefined || input.doubtKinds.length === 0 ? {} : { doubtKinds: input.doubtKinds.slice(0, 4) }),
|
|
270
|
+
// Bounded: this is a card, not a transcript, and the event file is read back
|
|
271
|
+
// into a UI list where a long entry pushes the rest off screen.
|
|
272
|
+
...(input.humanBrief === undefined ? {} : { humanBrief: String(input.humanBrief).slice(0, 600) }),
|
|
273
|
+
...(input.humanCommand === undefined || input.humanCommand === '' ? {} : { humanCommand: String(input.humanCommand).slice(0, 400) }),
|
|
267
274
|
};
|
|
268
275
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
269
276
|
appendFileSync(this.filePath, JSON.stringify(ev) + '\n', 'utf8');
|