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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Preset deny rule table for dsh-perm-gate.
3
+ *
4
+ * Ported from cc-safety-net (MIT, https://github.com/kenryu42/cc-safety-net)
5
+ * `src/core/rules/destructive.ts` + `secret.ts`, translated into this plugin's
6
+ * own `permissions` YAML vocabulary (see rule.ts / rule-dims.ts). The catalog
7
+ * is ONE SHOT-SEEDED preset: on first activation the entries are appended to
8
+ * the tail of the live rules document's deny/ask lists — the settings `rules`
9
+ * field when it is configured, otherwise the rules file. Revocation is
10
+ * user-owned: every entry carries a `[deny-rule:<id>]` tag and can be flipped
11
+ * to `enabled: false` or deleted in the settings card or the file; the
12
+ * `presetRulesSeeded` config flag keeps the seed from ever re-adding them.
13
+ *
14
+ * Policy decisions baked into this table (auditable history in
15
+ * .agents/plans/compat-cc-safety-net-deny-table/):
16
+ * - `git push --force` → ask, not deny (user decision 2026-10-13; also sidesteps
17
+ * the glob negation gap for --force-with-lease).
18
+ * - Dynamic/unverifiable targets (cc's strict-mode fail-closed family) → ask,
19
+ * aligning with this gate's existing "undecidable → ask" philosophy.
20
+ * - Secret protection keeps this gate's ruling: read = ask, write/delete = deny,
21
+ * read+egress combos stay in the prompt layer (layered-defense decision).
22
+ * - Root/home wipes and full-drive Remove-Item are NOT duplicated here — the P0
23
+ * irreversible layer runs first and owns them.
24
+ * - The legacy deny-keyword blacklist (deny-defaults.ts) stays as a parallel
25
+ * layer; nothing here replaces it.
26
+ *
27
+ * Pure + import-light (yaml + rule.ts only): the client bundle may render the
28
+ * table from `presetEntriesForView()` without pulling in the node filesystem.
29
+ */
30
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { parse, stringify } from 'yaml';
32
+ import { parsePermissionsDocument } from './rule.js';
33
+ /** Tag prefix carried by every preset entry's reason; locates an entry for revocation. */
34
+ export const PRESET_RULE_MARKER = '[deny-rule:';
35
+ /** Extract the `[deny-rule:<id>]` tag from one reason string (undefined when untagged). */
36
+ export function presetRuleId(reason) {
37
+ if (typeof reason !== 'string')
38
+ return undefined;
39
+ const m = /\[deny-rule:([a-z0-9._-]+)\]/.exec(reason);
40
+ return m?.[1];
41
+ }
42
+ /**
43
+ * The preset document in the file's bare YAML form. `defaultAction: ask`
44
+ * matches the empty-ruleset default, so seeding cannot widen anything.
45
+ * Patterns obey the compile limits: ≤2 glob stars, ordered subcommand+flag
46
+ * forms through argv.pipeline (evaluated per simple command as well as over
47
+ * the joined pipeline — see evaluate.ts), token co-occurrence NEVER through
48
+ * args (args is any-token OR).
49
+ */
50
+ export const PRESET_RULES_YAML = `defaultAction: ask
51
+
52
+ deny:
53
+ # ── Git ──────────────────────────────────────────────────────────────────
54
+ - command: [git]
55
+ argv: { pipeline: ["git reset --hard*", "git reset *--hard*"] }
56
+ reason: "[deny-rule:git.reset-hard] Hard reset discards uncommitted changes. Use git reset --soft/--mixed, or git stash first."
57
+ - command: [git]
58
+ argv: { pipeline: ["git reset --merge*", "git reset --abort*"] }
59
+ reason: "[deny-rule:git.reset-merge] Merge reset discards in-progress merge work. Stash first, then reset --soft/--mixed."
60
+ - command: [git]
61
+ argv: { pipeline: ["git clean *-f*", "git clean *--force*"] }
62
+ reason: "[deny-rule:git.clean-force] git clean permanently deletes untracked files. Dry-run first: git clean -nd."
63
+ - command: [git]
64
+ argv: { pipeline: ["git push origin *--delete*", "git push * --delete *", "git push --delete*"] }
65
+ reason: "[deny-rule:git.push-delete] Deleting remote refs is visible to collaborators and needs explicit consent."
66
+ - command: [git]
67
+ argv: { pipeline: ["git push --mirror*", "git push * --mirror"] }
68
+ reason: "[deny-rule:git.push-mirror] Mirror push force-updates and deletes remote refs wholesale."
69
+ - command: [git]
70
+ argv: { pipeline: ["git branch -D*", "git branch * -D", "git branch *--force-delete*"] }
71
+ reason: "[deny-rule:git.branch-force-delete] git branch -D drops unmerged commits. Prefer merged-safe git branch -d."
72
+ - command: [git]
73
+ argv: { pipeline: ["git stash drop*"] }
74
+ reason: "[deny-rule:git.stash-drop] Dropping a stash entry is unrecoverable after GC. Verify with git stash list."
75
+ - command: [git]
76
+ argv: { pipeline: ["git stash clear*"] }
77
+ reason: "[deny-rule:git.stash-clear] Clearing all stashes destroys every saved worktree state."
78
+ - command: [git]
79
+ argv: { pipeline: ["git tag -d*", "git tag --delete*"] }
80
+ reason: "[deny-rule:git.tag-delete] Tag deletion removes release anchors."
81
+ - command: [git]
82
+ argv: { pipeline: ["git reflog delete*", "git reflog expire*"] }
83
+ reason: "[deny-rule:git.reflog-delete] Reflog deletion removes the last recovery path for lost commits."
84
+ - command: [git]
85
+ argv: { pipeline: ["git rebase --abort*"] }
86
+ reason: "[deny-rule:git.rebase-abort] Aborting a rebase discards resolution work. Prefer --continue/--skip."
87
+ - command: [git]
88
+ argv: { pipeline: ["git merge --abort*"] }
89
+ reason: "[deny-rule:git.merge-abort] Aborting a merge discards conflict resolution."
90
+ - command: [git]
91
+ argv: { pipeline: ["git rm -rf *", "git rm *-rf *", "git rm * -rf", "git rm -fr *", "git rm --force *", "git rm * --force", "git rm -f *", "git rm * -f"] }
92
+ reason: "[deny-rule:git.rm-force] git rm -f deletes working-tree files even when modified. Stash them first."
93
+ - command: [git]
94
+ argv: { pipeline: ["git checkout --force *", "git checkout * --force", "git checkout -f *", "git checkout * -f", "git checkout -f", "git checkout * --force *"] }
95
+ reason: "[deny-rule:git.checkout-force] Forced checkout overwrites local changes. git stash, then checkout."
96
+ - command: [git]
97
+ argv: { pipeline: ["git checkout -- *"] }
98
+ reason: "[deny-rule:git.checkout-double-dash] Path restore from checkout discards local edits. Use git stash push -- <path>."
99
+ - command: [git]
100
+ argv: { pipeline: ["git checkout * -- *"] }
101
+ reason: "[deny-rule:git.checkout-ref-path] Checking out a ref with a path overwrites working-tree files. Stash the paths first."
102
+ - command: [git]
103
+ argv: { pipeline: ["git checkout *--pathspec-from-file*"] }
104
+ reason: "[deny-rule:git.checkout-pathspec-from-file] Bulk path restore from a file can silently overwrite edits. List the pathspec, stash, then restore."
105
+ - command: [git]
106
+ argv: { pipeline: ["git switch *--discard-changes*"] }
107
+ reason: "[deny-rule:git.switch-discard-changes] --discard-changes throws away edits. git stash first."
108
+ - command: [git]
109
+ argv: { pipeline: ["git switch --force *", "git switch * --force", "git switch -f *", "git switch * -f"] }
110
+ reason: "[deny-rule:git.switch-force] Forced switch overwrites local changes. git stash first."
111
+ - command: [git]
112
+ # git restore <bare path> (the plain unstaged discard) is NOT ported here:
113
+ # any glob broad enough to catch it also catches the harmless
114
+ # git restore --staged <path> (G6) — only the explicit discard forms are
115
+ # denied, the bare-path case stays in the prompt layer.
116
+ argv: { pipeline: ["git restore --worktree *", "git restore * --worktree *", "git restore * --worktree", "git restore -W *"] }
117
+ reason: "[deny-rule:git.restore-worktree] Worktree restore discards local edits. git stash push -- <path> first."
118
+ - command: [git]
119
+ argv: { pipeline: ["git worktree remove --force *", "git worktree remove * --force", "git worktree remove -f *", "git worktree remove * -f"] }
120
+ reason: "[deny-rule:git.worktree-remove-force] Forced worktree removal discards uncommitted work in that tree."
121
+ - command: [git]
122
+ args: ["alias.*"]
123
+ reason: "[deny-rule:git.alias-config] Command-line Git aliases run arbitrary shell and cannot be analyzed."
124
+ # params matches the RAW command string: the env-assignment word lands as the
125
+ # simple command's lowercased word, so a command:[git] dimension would never
126
+ # see this form — match on params alone.
127
+ - params: { command: ["*GIT_SSH_COMMAND=*", "*git_ssh_command=*", "*GIT_SSH=*", "*git_ssh=*"] }
128
+ reason: "[deny-rule:git.ssh-env] Git network ops with an SSH env override execute unanalyzable code."
129
+
130
+ # ── Filesystem ───────────────────────────────────────────────────────────
131
+ - command: ["rm#recursive", "rm#force"]
132
+ args: [".git", ".git/*", "*/.git"]
133
+ reason: "[deny-rule:rm.git-metadata] Removing Git metadata destroys history and the gate's audit anchors."
134
+ - command: [find]
135
+ argv: { pipeline: ["find .git*"] }
136
+ reason: "[deny-rule:find.delete-git-metadata] Selecting .git for deletion destroys repository state."
137
+ - command: [find]
138
+ argv: { pipeline: ["find * -delete"] }
139
+ reason: "[deny-rule:find.delete] find -delete removes everything selected. Enumerate with plain find first."
140
+ - command: [find]
141
+ argv: { pipeline: ["find *-exec rm*", "find *-delete*"] }
142
+ reason: "[deny-rule:find.exec-rm-recursive-force] find -exec rm removes the whole tree per match. Dry-run plain find, delete explicit paths."
143
+ - command: [dd]
144
+ argv: { pipeline: ["dd * of=/dev/sd*", "dd * of=/dev/nvme*", "dd * of=/dev/mmcblk*"] }
145
+ reason: "[deny-rule:dd.device-write] dd writing to a block device destroys the disk."
146
+ - command: [mkfs, mke2fs, newfs, "mkfs.*"]
147
+ reason: "[deny-rule:mkfs.device] Formatting a device erases it."
148
+ - command: [diskutil, wipefs, sgdisk, parted]
149
+ reason: "[deny-rule:disk.erase] Disk erase/partition/wipe operations destroy data."
150
+ - command: [shred]
151
+ reason: "[deny-rule:shred.target] shred irreversibly destroys files (even --help/--version reach it)."
152
+ - command: [cat, head, tail, dd]
153
+ args: ["*/dev/sd*", "*/dev/nvme*", "*/dev/disk*", "*/dev/mmcblk*"]
154
+ reason: "[deny-rule:redirect.block-device] Redirect target resolving to a disk device path."
155
+
156
+ # ── PowerShell ───────────────────────────────────────────────────────────
157
+ # Command words are lowercased by the decomposer — command dimensions must
158
+ # carry the lowercased verb (Remove-Item → remove-item).
159
+ - command: ["remove-item#recursive"]
160
+ args: [".git", ".git/*", "*/.git"]
161
+ reason: "[deny-rule:powershell.remove-item-git-metadata] Never target .git."
162
+ - command: [powershell, pwsh]
163
+ argv: { pipeline: ["pwsh *Remove-Item*", "powershell *Remove-Item*", "pwsh *remove-item*", "powershell *remove-item*"] }
164
+ reason: "[deny-rule:powershell.nested-recursive-delete-unread] A delete handed to pwsh/powershell in a non-literal form cannot be verified before it runs."
165
+ - command: ["remove-item#recursive"]
166
+ args: [".", "./"]
167
+ reason: "[deny-rule:powershell.remove-item-recursive-force-cwd-self] Deleting the current directory removes the project."
168
+ - command: ["remove-item#recursive"]
169
+ args: ["../*", "..\\\\*"]
170
+ reason: "[deny-rule:powershell.remove-item-recursive-force-outside-cwd] Deleting above the cwd escapes task scope."
171
+
172
+ # ── Command Prompt ───────────────────────────────────────────────────────
173
+ # (The cc escaped-quote-collapse rule cannot be expressed faithfully with
174
+ # these globs — every cmd /c recursion is nested behind a second parser,
175
+ # so its true target is undecidable: it moves to the ask section instead.)
176
+ - command: [cmd, rmdir, del]
177
+ args: [".git", ".git/*", "*/.git"]
178
+ reason: "[deny-rule:cmd.recursive-delete-git-metadata] Never target .git."
179
+ - command: [rmdir, del]
180
+ args: [".", "./"]
181
+ reason: "[deny-rule:cmd.recursive-delete-cwd-self] Deleting the current directory removes the project."
182
+ - command: [rmdir, del]
183
+ args: ["../*", "..\\\\*"]
184
+ reason: "[deny-rule:cmd.recursive-delete-outside-cwd] Delete above cwd escapes task scope."
185
+
186
+ # ── Secret targets (write/delete deny; reads are the ask section below) ──
187
+ - command: ["rm#recursive", "remove-item#recursive", rmdir, del]
188
+ args: ["*/.ssh", "*/.aws", "*/.gnupg", ".ssh", ".aws"]
189
+ reason: "[deny-rule:secret.delete-credential-path] Deleting credential stores destroys keys you cannot regenerate."
190
+ - command: [echo, "set-content", "out-file"]
191
+ args: [".env", ".env.*"]
192
+ reason: "[deny-rule:secret.write-overwrite-env] Overwriting .env destroys existing secrets. Edit specific lines."
193
+ - command: ["rm#recursive", "remove-item#recursive", rmdir, del]
194
+ args: ["*/.git-credentials", ".git-credentials"]
195
+ reason: "[deny-rule:secret.delete-git-credentials] Deleting the git credential store breaks push/pull auth."
196
+
197
+ ask:
198
+ # Force push: ask, not deny (revoked from cc's deny ruling 2026-10-13).
199
+ - command: [git]
200
+ argv: { pipeline: ["git push *--force*", "git push *-f*"] }
201
+ reason: "[deny-rule:git.push-force] Force push rewrites shared history. Prefer git push --force-with-lease."
202
+
203
+ # cmd /c recursion hides the real target behind a second parser (and quoting):
204
+ # undecidable → ask, where cc denied only the drive-root-collapse variant.
205
+ - command: [cmd]
206
+ argv: { pipeline: ["cmd /c *rmdir /s*", "cmd /c *del /s*"] }
207
+ reason: "[deny-rule:cmd.recursive-delete-nested] cmd /c recursion nests a second parser; the real target cannot be resolved. Run rmdir/del directly so the gate can see the target."
208
+
209
+ # Dynamic targets: this gate's ruling is "undecidable → ask", not fail-closed deny.
210
+ - command: ["rm#recursive", "remove-item#recursive"]
211
+ args: ["$*"]
212
+ reason: "[deny-rule:rm.recursive-force-dynamic-target] Recursive delete of a substitution-assembled target — verify the variable yourself."
213
+ # The wrapper word xargs is stripped by the decomposer, so pipeline patterns
214
+ # never see it — match the raw command string through the params dimension.
215
+ - params: { command: ["*xargs rm*", "*xargs del*"] }
216
+ reason: "[deny-rule:xargs.rm-recursive-force-dynamic] xargs-fed removals have unverifiable target sets."
217
+ - command: [parallel]
218
+ argv: { pipeline: ["parallel *rm*", "*| parallel*"] }
219
+ reason: "[deny-rule:parallel.rm-recursive-force-dynamic] parallel-fed rm has unresolvable targets."
220
+ - command: [awk]
221
+ args: ["*system(*"]
222
+ reason: "[deny-rule:awk.system-dynamic] awk program text containing system() cannot be analyzed."
223
+ # NOTE: cc's git checkout <ref> <path> ambiguity (G5) is NOT ported — a glob
224
+ # cannot tell ref candidates from paths, and a broad pattern would prompt on
225
+ # every git checkout -b new-branch. Documented residual gap.
226
+
227
+ # Credential reads: ask (A2 ruling kept; egress combos stay in the prompt layer).
228
+ - command: [cat, head, tail, type, "get-content", ls, dir, "get-childitem", "select-string"]
229
+ args: [".env", ".env.*", "*.env", ".npmrc", ".pypirc", ".netrc", ".git-credentials", "id_rsa", "id_ed25519", "id_ecdsa"]
230
+ reason: "[deny-rule:secret.read-credential-file] Reading a credential file requires confirmation. Echo only the keys you need via a filtered command."
231
+ - command: [cat, head, tail, type, "get-content", ls, dir, "get-childitem"]
232
+ args: ["*/.ssh", "*/.ssh/*", "*/.aws", "*/.aws/*", "*/.gnupg", "*/.gnupg/*", "*/.config/gcloud/*", "*/.claude/*", "*/.codex/*"]
233
+ reason: "[deny-rule:secret.read-home-credential-dirs] Browsing ssh/cloud/coding-CLI credential stores requires confirmation."
234
+
235
+ # Self-protection: editing the gate's own rule sources needs confirmation —
236
+ # revocation must be a human act, not something an agent can slide through.
237
+ # (Settings-namespace edits go through the host UI, not a tool call, and are
238
+ # out of reach of this entry; documented gap.)
239
+ - args: ["*rules.yml", "*rules.yaml", "*deny-defaults*"]
240
+ reason: "[deny-rule:gate.self-protection] This call touches the permission gate's own rule sources; changes here alter what the gate enforces."
241
+ `;
242
+ /** Parsed preset document (module-level cache; throws on a malformed table — never silent). */
243
+ let presetDoc;
244
+ export function parsePresetDoc() {
245
+ if (presetDoc === undefined)
246
+ presetDoc = parsePermissionsDocument(PRESET_RULES_YAML);
247
+ return presetDoc;
248
+ }
249
+ /** Every preset id, in table order. Client view + tests use this as the row key. */
250
+ export function presetRuleIds() {
251
+ const doc = parsePresetDoc();
252
+ return [...doc.deny, ...doc.ask].map((e) => presetRuleId(e.reason) ?? '');
253
+ }
254
+ /**
255
+ * A rule entry as it is stored in a raw document (settings JSON / file YAML):
256
+ * only the fields the author wrote — parse fills every dimension with empty
257
+ * arrays, and an empty dimension means "no constraint", so round-tripping the
258
+ * parsed shape verbatim would be noise (still valid, just bloated).
259
+ */
260
+ function rawEntry(e) {
261
+ const out = {};
262
+ if (e.tools.length > 0)
263
+ out.tools = e.tools;
264
+ if (e.command.length > 0)
265
+ out.command = e.command;
266
+ if (e.args.length > 0)
267
+ out.args = e.args;
268
+ if (e.paths.length > 0)
269
+ out.paths = e.paths;
270
+ // `params` 是唯一「源形态 = 映射、编译形态 = ParamCondition[]」的维度(其余维度
271
+ // 两者一致),所以不能直写 e.params:那会产出 `params: [{key,patterns,negated}]`,
272
+ // 下次启动被 parseParamsDimension 拒(`params must be a mapping`)⇒ 插件不激活。
273
+ // 反向映射时把 negation 还原成源头的 `!glob` 前缀写法(parse 就是这么判的)。
274
+ if (e.params.length > 0) {
275
+ const map = {};
276
+ for (const cond of e.params) {
277
+ map[cond.key] = cond.patterns.map((p) => (cond.negated && !p.startsWith('!') ? `!${p}` : p));
278
+ }
279
+ out.params = map;
280
+ }
281
+ if (e.absent.length > 0)
282
+ out.absent = e.absent;
283
+ if (e.agents.length > 0)
284
+ out.agents = e.agents;
285
+ if (e.when !== undefined)
286
+ out.when = e.when;
287
+ if (e.argv !== undefined)
288
+ out.argv = e.argv;
289
+ if (e.network !== undefined)
290
+ out.network = e.network;
291
+ if (e.branch !== undefined)
292
+ out.branch = e.branch;
293
+ out.reason = e.reason;
294
+ out.enabled = e.enabled;
295
+ return out;
296
+ }
297
+ function idsPresentIn(section) {
298
+ const out = new Set();
299
+ if (Array.isArray(section)) {
300
+ for (const item of section) {
301
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
302
+ const id = presetRuleId(item.reason);
303
+ if (id !== undefined)
304
+ out.add(id);
305
+ }
306
+ }
307
+ }
308
+ return out;
309
+ }
310
+ /**
311
+ * Append missing preset entries to a RAW rules document object (bare or
312
+ * `permissions:`-wrapped form). Idempotent and revocation-preserving: an id
313
+ * already present — enabled or not — is never re-added. Mutates nothing;
314
+ * returns the merged copy and the added count.
315
+ */
316
+ export function mergePresetIntoRaw(rawDoc) {
317
+ const root = rawDoc !== null && typeof rawDoc === 'object' && !Array.isArray(rawDoc)
318
+ ? { ...rawDoc }
319
+ : {};
320
+ const wrapper = root['permissions'] !== null && typeof root['permissions'] === 'object' && !Array.isArray(root['permissions'])
321
+ ? root['permissions']
322
+ : root;
323
+ const doc = root['permissions'] !== undefined ? root : { ...wrapper };
324
+ const target = root['permissions'] !== undefined ? root['permissions'] : doc;
325
+ let added = 0;
326
+ const preset = parsePresetDoc();
327
+ for (const [key, list] of [['deny', preset.deny], ['ask', preset.ask]]) {
328
+ const existing = Array.isArray(target[key]) ? [...target[key]] : [];
329
+ const have = idsPresentIn(existing);
330
+ for (const entry of list) {
331
+ const id = presetRuleId(entry.reason);
332
+ if (id !== undefined && have.has(id))
333
+ continue;
334
+ existing.push(rawEntry(entry));
335
+ added += 1;
336
+ }
337
+ target[key] = existing;
338
+ }
339
+ if (typeof doc.defaultAction !== 'string' && typeof target.defaultAction !== 'string') {
340
+ target.defaultAction = 'ask';
341
+ }
342
+ return { doc, added };
343
+ }
344
+ /**
345
+ * One-shot file seed: create the rules file from the preset when absent, or
346
+ * append missing preset entries when present (comments in a hand-written file
347
+ * are lost on rewrite — the file is the plugin's data dir, not user-authored
348
+ * by default). A sibling marker file records that the seed ran: after a full
349
+ * revocation (user deletes every preset entry) the seed must never re-add
350
+ * them, and the marker works on hosts without the settings service where the
351
+ * `presetRulesSeeded` config flag cannot be persisted. Returns what happened
352
+ * for the log line.
353
+ */
354
+ export function ensurePresetRulesFile(rulesFile, markerFile) {
355
+ try {
356
+ if (markerFile !== undefined && existsSync(markerFile))
357
+ return { action: 'already-seeded', added: 0 };
358
+ let result;
359
+ if (!existsSync(rulesFile)) {
360
+ writeFileSync(rulesFile, PRESET_RULES_YAML, 'utf8');
361
+ result = { action: 'created', added: presetRuleIds().length };
362
+ }
363
+ else {
364
+ const text = readFileSync(rulesFile, 'utf8');
365
+ const root = text.trim() === '' ? {} : parse(text);
366
+ const { doc, added } = mergePresetIntoRaw(root);
367
+ if (added === 0) {
368
+ result = { action: 'skipped', added: 0 };
369
+ }
370
+ else {
371
+ writeFileSync(rulesFile, stringify(doc, { lineWidth: 0 }), 'utf8');
372
+ result = { action: 'merged', added };
373
+ }
374
+ }
375
+ if (markerFile !== undefined && result.action !== 'skipped') {
376
+ try {
377
+ writeFileSync(markerFile, new Date().toISOString(), 'utf8');
378
+ }
379
+ catch { /* marker is best-effort */ }
380
+ }
381
+ return result;
382
+ }
383
+ catch {
384
+ // A broken user file must NOT be rewritten by the seed (fail-open on the
385
+ // seed path only: the loader's own badFilePolicy still governs decisions).
386
+ return { action: 'skipped', added: 0 };
387
+ }
388
+ }
@@ -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
+ }