dsh-perm-gate 5.1.2 → 5.3.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.
Files changed (47) hide show
  1. package/CHANGELOG.ja.md +54 -1
  2. package/CHANGELOG.ko.md +54 -1
  3. package/CHANGELOG.md +134 -4
  4. package/README.ja.md +43 -0
  5. package/README.ko.md +43 -1
  6. package/README.md +73 -0
  7. package/README.zh.md +41 -0
  8. package/THIRD_PARTY_NOTICES.md +44 -0
  9. package/cordis.patch.yml +16 -1
  10. package/dsh.plugin.json +1 -1
  11. package/lib/ask-context.d.ts +49 -1
  12. package/lib/ask-context.js +89 -5
  13. package/lib/client.js +714 -86
  14. package/lib/client.js.map +1 -1
  15. package/lib/config.d.ts +243 -83
  16. package/lib/config.js +84 -41
  17. package/lib/credential-paths.d.ts +35 -0
  18. package/lib/credential-paths.js +119 -0
  19. package/lib/engine.d.ts +4 -6
  20. package/lib/engine.js +53 -42
  21. package/lib/evaluate.d.ts +20 -0
  22. package/lib/evaluate.js +41 -13
  23. package/lib/feedback.d.ts +42 -0
  24. package/lib/feedback.js +85 -0
  25. package/lib/index.d.ts +2 -2
  26. package/lib/index.js +143 -17
  27. package/lib/irreversible.js +109 -1
  28. package/lib/path.d.ts +31 -0
  29. package/lib/path.js +35 -0
  30. package/lib/preset-rules.d.ts +70 -0
  31. package/lib/preset-rules.js +388 -0
  32. package/lib/preset.d.ts +8 -5
  33. package/lib/preset.js +15 -6
  34. package/lib/risk.d.ts +29 -1
  35. package/lib/risk.js +36 -2
  36. package/lib/runtime.d.ts +164 -0
  37. package/lib/runtime.js +429 -55
  38. package/lib/scan-text.d.ts +69 -0
  39. package/lib/scan-text.js +162 -0
  40. package/lib/script-review.d.ts +50 -0
  41. package/lib/script-review.js +202 -0
  42. package/lib/shell.d.ts +18 -0
  43. package/lib/shell.js +103 -1
  44. package/lib/unattended.d.ts +61 -0
  45. package/lib/unattended.js +77 -0
  46. package/package.json +4 -3
  47. package/scripts/patch-permission-glyph.mjs +27 -13
package/lib/index.js CHANGED
@@ -3,14 +3,18 @@ import { join } from 'node:path';
3
3
  import { SandboxController } from './appcontainer/index.js';
4
4
  import { Config, ensureDataDir, readRulesFromSettings, readVolatileValue, resolveVolatileConfig, resolveConfig, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
5
5
  import { appendAllowToSettings, readRulesFileDoc, replaceAllowInSettings } from './allowlist.js';
6
+ import { mergePresetIntoRaw } from './preset-rules.js';
6
7
  import { runDryRun } from './dry-run.js';
7
8
  import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
8
9
  // T12 设置数据桥(describe/mutate;client BridgeDocHandle 消费)
9
10
  import { registerSettingsBridgeRoutes } from './bridge.js';
10
11
  import { buildReceiverInfo } from './receiver-info.js';
12
+ import { composeReviewOpinion } from './feedback.js';
13
+ import { redactCommandText } from './redact.js';
11
14
  import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
12
15
  import { PermGateRuntime } from './runtime.js';
13
16
  import { classifySessions, sweepSessionData } from './session-sweep.js';
17
+ import { isHumanFree } from './unattended.js';
14
18
  import { decideNetworkTarget } from './network.js';
15
19
  import { NetworkLifecycle } from './network-lifecycle.js';
16
20
  import { RuleWatcher } from './watch.js';
@@ -137,19 +141,13 @@ export function makeApprovalAnswerer(runtime) {
137
141
  * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
138
142
  * (fail-closed).
139
143
  */
140
- export function makePreExecuteListener(runtime) {
144
+ export function makePreExecuteListener(runtime, feedback) {
141
145
  return async (exec, next) => {
142
146
  const decision = runtime.decideExecution(exec);
143
- // Passthrough (allow / stand-down) and a hard-deny need no grading.
144
- if (decision === undefined) {
145
- // The call proceeds: register it so a network connection made by its
146
- // child process can be attributed back to this session.
147
+ // The sanctioned "let it run" seam: register for network attribution, apply the
148
+ // AppContainer rewrite if enabled, then hand to the next waterfall listener.
149
+ const allowAndRun = async () => {
147
150
  runtime.beginShellExecution(exec);
148
- // AppContainer sandbox (goal 2): rewrite the allowed shell call into
149
- // the launcher invocation, or fail closed. The host executes THIS exec
150
- // object after the waterfall resolves, so the in-place arguments swap
151
- // is the sanctioned seam; the base64 wrapper keeps every pipe/&&
152
- // inside the container (outer shell sees a plain exe call).
153
151
  if (typeof runtime.sandboxRewrite === 'function') {
154
152
  const rewrite = await runtime.sandboxRewrite(exec);
155
153
  if (rewrite !== undefined) {
@@ -162,9 +160,49 @@ export function makePreExecuteListener(runtime) {
162
160
  }
163
161
  }
164
162
  return next();
163
+ };
164
+ // Human-free tiers — never a human prompt. A bare allow runs as usual; a
165
+ // deterministic deny blocks and feeds back; an ask is settled by whoever the tier put
166
+ // in the human's seat: YOLO hands what the mechanical floor could not name to the
167
+ // review model (and a grader throw fails open to allow), while the mechanical tier
168
+ // stops AT the floor and calls no model at all. Both block and feed the opinion back
169
+ // on a hit. `safety-net-ask` is NOT human-free — it is the explicit keep-the-person
170
+ // answer (and the one entry that beats a global yolo) — so it falls through to the
171
+ // ordinary waterfall below. Probed via `?.` so a stub/embedded runtime without the
172
+ // tier surface behaves as non-unattended.
173
+ const mode = runtime.unattendedModeOf?.(exec) ?? null;
174
+ if (mode !== null && isHumanFree(mode)) {
175
+ if (decision === undefined)
176
+ return allowAndRun();
177
+ if (decision.kind === 'deny') {
178
+ feedback?.(exec, decision);
179
+ return decision;
180
+ }
181
+ let resolved;
182
+ try {
183
+ resolved = mode === 'safety-net-passthrough'
184
+ ? runtime.resolveSafetyNet?.(exec, decision)
185
+ : await runtime.resolveYolo?.(exec, decision);
186
+ }
187
+ catch {
188
+ resolved = undefined; // fail-open: could not grade → allow ("其余放行")
189
+ }
190
+ if (resolved === undefined)
191
+ return allowAndRun();
192
+ feedback?.(exec, resolved);
193
+ return resolved;
194
+ }
195
+ // Passthrough (allow / stand-down) and a hard-deny need no grading.
196
+ if (decision === undefined) {
197
+ return allowAndRun();
198
+ }
199
+ if (decision.kind !== 'ask') {
200
+ // A deterministic deny (P0 hard-deny / deny-keyword / rule) never reaches a
201
+ // human; feed the structured opinion back so the main LLM stops retrying
202
+ // variants — the same guidance cc-safety-net attaches to its BLOCKED message.
203
+ feedback?.(exec, decision);
204
+ return decision;
165
205
  }
166
- if (decision.kind !== 'ask')
167
- return decision; // deny: it never runs
168
206
  // A cancelled call is never worth a model round-trip; the registry rechecks
169
207
  // cancellation after this gate settles.
170
208
  if (exec.signal?.aborted === true)
@@ -176,14 +214,16 @@ export function makePreExecuteListener(runtime) {
176
214
  catch {
177
215
  return decision; // fail-closed: keep the human ask
178
216
  }
179
- if (refined === undefined) {
180
- runtime.beginShellExecution(exec);
181
- return next();
182
- }
217
+ if (refined === undefined)
218
+ return allowAndRun();
183
219
  // Still an ask: the human decides. Register optimistically so a call they
184
220
  // approve is attributable; a rejection is dropped by the abort/settle path.
185
221
  if (refined.kind === 'ask')
186
222
  runtime.beginShellExecution(exec);
223
+ // A degraded-review block (`rulesFallback` with no reviewer) is a deny nobody
224
+ // ever saw a popup for — the same opinion the YOLO path owes the main LLM.
225
+ if (refined.kind === 'deny')
226
+ feedback?.(exec, refined);
187
227
  return refined;
188
228
  };
189
229
  }
@@ -206,6 +246,25 @@ export function apply(ctx, config = {}) {
206
246
  // the ref unset and the runtime falls back to its rules-file write paths.
207
247
  const ENTRY_ID = 'dsh-perm-gate';
208
248
  const settingsRef = {};
249
+ // 预设拒绝表播种:一次性、只走宿主设置面(写回落进 profile patch 的本插件
250
+ // config 条目)。刻意不再写插件自有的 `perm-gate/rules.yml` —— 那是用户手维护的
251
+ // 规则源,混进 51 条预设会让「我的规则」与「插件注入的规则」不可区分。
252
+ // 两条经实测定下的约束:① 必须在各分支「注册完成之后」调(0.1.5 上 ns 要等
253
+ // installSection 才注册,提前写报 `settings namespace ... is not registered`);
254
+ // ② 规则与幂等位必须合成一次 update(拆两次时 0.1.7 第二次报 `Plugin entry ... is
255
+ // no longer configurable`,幂等位永远落不下去 ⇒ 用户全删后会被重新塞回)。
256
+ // 写失败不阻断启动也不置幂等位 ⇒ 下次激活重试。
257
+ const seedPresetDenyTable = (svc) => {
258
+ void (async () => {
259
+ if (current().presetRulesSeeded === true)
260
+ return;
261
+ const merged = mergePresetIntoRaw(readVolatileValue(config.rules));
262
+ await svc.update(ENTRY_ID, { rules: merged.doc, presetRulesSeeded: true });
263
+ console.log(`[dsh-perm-gate] preset deny table seeded via settings (+${merged.added} rules)`);
264
+ })().catch((e) => {
265
+ console.warn('[dsh-perm-gate] preset deny table 播种失败(不阻断启动,下次激活重试):', e);
266
+ });
267
+ };
209
268
  ctx.inject(['settings'], (child) => {
210
269
  const svc = child.settings;
211
270
  settingsRef.svc = svc;
@@ -253,6 +312,7 @@ export function apply(ctx, config = {}) {
253
312
  console.warn('[dsh-perm-gate] legacy seed update failed:', e);
254
313
  });
255
314
  }
315
+ seedPresetDenyTable(svc);
256
316
  return;
257
317
  }
258
318
  void (async () => {
@@ -279,6 +339,7 @@ export function apply(ctx, config = {}) {
279
339
  console.warn('[dsh-perm-gate] legacy seed update failed:', e);
280
340
  });
281
341
  }
342
+ seedPresetDenyTable(svc);
282
343
  return;
283
344
  }
284
345
  }
@@ -306,6 +367,7 @@ export function apply(ctx, config = {}) {
306
367
  console.warn('[dsh-perm-gate] rules seed write failed:', e);
307
368
  });
308
369
  }
370
+ seedPresetDenyTable(svc);
309
371
  })();
310
372
  });
311
373
  const settingsWrite = (write) => {
@@ -326,6 +388,7 @@ export function apply(ctx, config = {}) {
326
388
  // entry that omits `config` still records events, snapshots and learning.
327
389
  const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
328
390
  const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
391
+ // 预设拒绝表的播种在上面的 settings inject 回调里(那里才有 svc)。
329
392
  // Host model-group services (typed minimally; supplied by the dsh runtime
330
393
  // through the loader `inject` — dsh-approval-gate demonstrates the same
331
394
  // contract). The llm service backs the `host` receiver; agentDefaultModel
@@ -421,6 +484,21 @@ export function apply(ctx, config = {}) {
421
484
  strategies: resolvePermissiveStrategies(c.permissiveStrategies),
422
485
  };
423
486
  },
487
+ // Read the YOLO switch live so the settings-card toggle takes effect on the next tool
488
+ // call. It is the switch behind the `permissive-yolo` tier only — never a flow added to
489
+ // sessions that did not select it.
490
+ readYolo: () => current().yolo ?? false,
491
+ // The two `safenet` switches, read live: each controls whether one implementation
492
+ // exists, and each implementation owns one preset, so presets never cross.
493
+ readSafenetPass: () => current().safenetPass ?? true,
494
+ readSafenetAsk: () => current().safenetAsk ?? false,
495
+ // Degraded review: when no reviewer can grade an ask (none configured, or the
496
+ // endpoint gave up), the deterministic nets decide instead of a popup nobody
497
+ // answers — a cc-safety-net-class gate that blocks named danger and runs the rest.
498
+ readRulesFallback: () => current().rulesFallback ?? false,
499
+ // Script review (on by default): the body of every script a call runs rides the
500
+ // review, so neither the grader nor the human is judging a bare filename.
501
+ readScriptReview: () => current().scriptReview ?? true,
424
502
  // Read the llmAssist receiver (endpoint/model/secret/timeout) live from the
425
503
  // same namespace, so editing the settings card applies to the next tool call.
426
504
  readClassifyConfig: () => {
@@ -445,6 +523,28 @@ export function apply(ctx, config = {}) {
445
523
  const c = current();
446
524
  return Array.isArray(c.denyKeywords) ? c.denyKeywords : undefined;
447
525
  },
526
+ // Appended blacklist entries (long-tail secrets of the user's own) and the declared
527
+ // credential paths: both read live, so an edit in the settings card applies to the
528
+ // next tool call without a reload.
529
+ readDenyKeywordsExtra: () => {
530
+ const c = current();
531
+ return Array.isArray(c.denyKeywordsExtra) ? c.denyKeywordsExtra : [];
532
+ },
533
+ readCredentialPaths: () => {
534
+ const c = current();
535
+ return Array.isArray(c.credentialPaths) ? c.credentialPaths : [];
536
+ },
537
+ // Which argument fields count as prose is a per-deployment fact (every host ships new
538
+ // document keys); read both lists live so an edit in the settings card applies to the
539
+ // next call rather than to the next restart.
540
+ readExtraContentArgKeys: () => {
541
+ const c = current();
542
+ return Array.isArray(c.extraContentArgKeys) ? c.extraContentArgKeys : [];
543
+ },
544
+ readExtraOperationArgKeys: () => {
545
+ const c = current();
546
+ return Array.isArray(c.extraOperationArgKeys) ? c.extraOperationArgKeys : [];
547
+ },
448
548
  // Learning sedimentation: threshold-reached samples become deterministic allows.
449
549
  readRiskSediment: () => current().riskSediment ?? true,
450
550
  // llmAssist receiver source: custom OpenAI-compatible endpoint or the host llm service.
@@ -718,7 +818,33 @@ export function apply(ctx, config = {}) {
718
818
  catch {
719
819
  // webServer unavailable: the feed remains disk-only, sediment is view-only in files
720
820
  }
721
- const listener = makePreExecuteListener(runtime);
821
+ // YOLO feedback: when a call is blocked (YOLO block **or** a deterministic deny in
822
+ // any mode), append a user-role "工具审核意见" message to the owning session so the
823
+ // main LLM sees why + which rule + a disposition and self-corrects — modeled on
824
+ // cc-safety-net's BLOCKED message. Best-effort; a failed delivery never un-blocks.
825
+ const yoloSender = buildSessionSender(fallbackGet);
826
+ const blockedSubject = (exec) => {
827
+ const args = exec.arguments ?? {};
828
+ const cmd = (typeof exec.commandText === 'string' && exec.commandText !== ''
829
+ ? exec.commandText
830
+ : (typeof args.command === 'string' ? args.command : undefined));
831
+ if (cmd !== undefined)
832
+ return redactCommandText(cmd);
833
+ const p = ['file_path', 'path', 'cwd', 'workdir'].map((k) => args[k]).find((v) => typeof v === 'string');
834
+ return `${exec.name}${typeof p === 'string' ? ': ' + p : ''}`;
835
+ };
836
+ const yoloFeedback = (exec, decision) => {
837
+ const sessionId = (typeof exec.sessionId === 'string' && exec.sessionId !== ''
838
+ ? exec.sessionId
839
+ : (exec.agent?.sessionId ?? exec.agent?.session?.id) ?? '');
840
+ if (sessionId === '')
841
+ return;
842
+ const { text } = composeReviewOpinion({ command: blockedSubject(exec), reason: decision.reason, locale: 'zh' });
843
+ void yoloSender(sessionId, `工具审核意见: "${text}"`).catch(() => {
844
+ // no delivery channel — the block still stands, the LLM just gets no note.
845
+ });
846
+ };
847
+ const listener = makePreExecuteListener(runtime, yoloFeedback);
722
848
  // `tools/pre-execute` / `tools/result` / `approval/request` are service events,
723
849
  // not part of cordis core's typed `Events`, so they are registered through the
724
850
  // string overload.
@@ -178,7 +178,87 @@ function isRootish(token) {
178
178
  if (t === '--no-preserve-root' || t.endsWith('--no-preserve-root'))
179
179
  return true;
180
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, ''));
181
+ || FLAT_DRIVE_ROOT.test(t.replace(/[\\/]+/g, ''))
182
+ || isHomeVariableRoot(t);
183
+ }
184
+ /**
185
+ * The user profile / home expressed as a **variable or glob**, not a literal path.
186
+ *
187
+ * `$env:USERPROFILE` (cmd `%USERPROFILE%`), the PowerShell automatic `$HOME` /
188
+ * `${HOME}`, `$PSHOME`, `$env:HOME`/`$HOMEPATH`/`$env:HOMEPATH`, the bare shell
189
+ * home `~`, and any dotfile sweep of those (`~/*`, `~/.*`, `~/?*`, `$env:USERPROFILE\.*`)
190
+ * all resolve at run time to `C:\Users\<name>` — the exact target `WIN_USERS` already
191
+ * hard-denies in literal form. The incident this closes is the *variable* spelling:
192
+ * `Remove-Item $home -Recurse -Force` (where `$home=` assignment silently no-ops) or
193
+ * `Get-ChildItem $env:USERPROFILE -Force | Remove-Item` reach the grader as an
194
+ * unresolvable token, are not root-shaped, so a probabilistic `safe` cleared them.
195
+ * Matching the root itself (or an unbounded `*`/`.?` sweep of it) — never a specific
196
+ * child like `~/.cache` — keeps legitimate scoped cleanup clearable while making the
197
+ * profile-root delete deterministic, no-model, and immune to an `llmAssist`/YOLO
198
+ * `safe` downgrade.
199
+ */
200
+ const HOME_ROOT_VAR = /^(?:\$\{?(?:home|homeroot|userprofile|profile|pshome|env:userprofile|env:home|env:homeroot|env:profile)\}?|%userprofile%)$/i;
201
+ // A home/profile root variable optionally followed by an unbounded sweep (`/.*`,
202
+ // `/?*`, `/*`, or just `.*`). Separators are normalized before this test, so the
203
+ // quoted `"$env:USERPROFILE\.*"` (arrives with a backslash) and the stripped-flat
204
+ // `$env:USERPROFILE.*` both land here — the decomposer removes `\` from unquoted
205
+ // args, so demanding a literal `/` would miss the more common Windows form.
206
+ const HOME_ROOT_PREFIX = /^(?:\$\{?(?:home|homeroot|userprofile|pshome|env:userprofile|env:home|env:homeroot|env:profile)\}?|%userprofile%|~)/i;
207
+ /** Whether one token names the home/profile root (or an unbounded sweep of it). */
208
+ function isHomeVariableRoot(token) {
209
+ const t = token.trim().replace(/[\\/]+/g, '/').replace(/\/$/, '');
210
+ if (t === '~')
211
+ return true;
212
+ if (HOME_ROOT_VAR.test(t))
213
+ return true;
214
+ // `<home-var>/<unbounded glob>` — `~/.*`, `~/*`, `$env:USERPROFILE/.*`.
215
+ if (HOME_ROOT_PREFIX.test(t)) {
216
+ const rest = t.slice(t.indexOf('/') + 1);
217
+ return /^(?:\.?\*|\.\?\*|\.\?\?\*)$/.test(rest);
218
+ }
219
+ return false;
220
+ }
221
+ /**
222
+ * Other common Windows roots an agent reaches for **by environment variable**, which
223
+ * the literal-path rules (`WIN_SYSTEM` / `WIN_USERS`) cannot see because the token is
224
+ * a `$env:`/`%VAR%` name, not `C:\Windows`. Deleting one of these recursively destroys
225
+ * either the whole system (`SystemRoot`/`ProgramFiles`/`ProgramData`) or every app's
226
+ * configuration and user data (`APPDATA`/`LOCALAPPDATA`/`OneDrive`/`Public`) in one
227
+ * shot.
228
+ *
229
+ * These grade **never-clear** (permanent ask), not P0 deny: unlike `$HOME` (the profile
230
+ * root itself, already denied), a whole-`LOCALAPPDATA`/`ProgramData` wipe is sometimes
231
+ * deliberate maintenance the human should still decide on — so no grader `safe` may
232
+ * clear it and no run of confirmations may sediment it, but appeal stays open.
233
+ *
234
+ * Only the **bare root or an unbounded sweep** (`$env:APPDATA`, `"$env:LOCALAPPDATA\.*"`)
235
+ * qualifies — a named child (`$env:LOCALAPPDATA\Foo\bar`) is normal scoped cleanup and
236
+ * must stay clearable. `TEMP`/`TMP`/`TMPDIR` are explicitly **excluded**: deleting the
237
+ * temp root is legitimate. Separators are normalized (`\`→`/`) and matching is
238
+ * case-insensitive, so `$Env:AppData`, `${env:appdata}`, and `%APPDATA%` all land here.
239
+ */
240
+ const PROTECTED_ENV_SYSTEM = new Set(['systemroot', 'windir', 'systemdrive', 'programfiles', 'programfiles(x86)', 'programdata']);
241
+ const PROTECTED_ENV_USERDATA = new Set(['appdata', 'localappdata', 'onedrive', 'public', 'homedrive', 'homepath']);
242
+ const EXCLUDED_ENV_ROOTS = new Set(['temp', 'tmp', 'tmpdir', 'tempdir']);
243
+ const ENV_ROOT_TOKEN = /^(?:\$\{?env:([^/}]+)\}?|%([^%/]+)%)(.*)$/i;
244
+ /** Which protected root (if any) one token names — the bare root or an unbounded sweep. */
245
+ function protectedEnvRoot(token) {
246
+ const t = token.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
247
+ const m = ENV_ROOT_TOKEN.exec(t);
248
+ if (m === null)
249
+ return undefined;
250
+ const name = (m[1] ?? m[2] ?? '').trim();
251
+ if (name === '' || EXCLUDED_ENV_ROOTS.has(name))
252
+ return undefined;
253
+ const rest = (m[3] ?? '').replace(/^\/+/, '');
254
+ // bare root (empty tail) or an unbounded glob sweep; anything else is a named child.
255
+ if (rest !== '' && !/^(?:\.?\*|\.\?\*|\.\?\?\*)$/.test(rest))
256
+ return undefined;
257
+ if (PROTECTED_ENV_SYSTEM.has(name))
258
+ return 'system';
259
+ if (PROTECTED_ENV_USERDATA.has(name))
260
+ return 'userdata';
261
+ return undefined;
182
262
  }
183
263
  /**
184
264
  * Forced recursive deletion aimed at a root or system tree.
@@ -274,6 +354,20 @@ export function irreversibleReason(text, opts = {}) {
274
354
  }
275
355
  if (forcedDeleteOfRoot(text, programs))
276
356
  return 'forced recursive deletion targets a system root';
357
+ // Forced/recursive delete aimed at the profile expressed as a home **variable**
358
+ // or a dotfile sweep of it (`$home`/`$HOME`/`$PSHOME`/`$env:USERPROFILE`/`~`/`~/.*`).
359
+ // Checked independently of `forcedDeleteOfRoot` because the incident shape nests the
360
+ // delete inside `if (Test-Path $home) { Remove-Item … }`, where the shell parser
361
+ // never surfaces `Remove-Item` as a command word, so the program-scanning path is
362
+ // blind to it. Requiring (recurse AND force AND a home-root token) keeps scoped
363
+ // cleanup of a named child (`~/.cache`, `$buildDir`) clearable — only the profile
364
+ // root or an unbounded sweep of it lands here. This makes it irreversible, which
365
+ // the ask-context grades `I` and `refineAsk` refuses to clear on a grader `safe`.
366
+ if (/\b(?:remove-item|rmdir|erase|unlink|shred)\b|\brm\b|\bdel\b/i.test(text)
367
+ && /-(?:recurse|r\b|rf|fr|\w*r\w*)/i.test(text) && /-(?:force|fo|f\b|rf|fr|\w*f\w*)/i.test(text)
368
+ && text.split(/[\s"',;{}()&|]+/).some(isHomeVariableRoot)) {
369
+ return 'forced recursive deletion targets the user profile via a home variable or a dotfile sweep of it';
370
+ }
277
371
  // Deleting the gate's own state directory is unrecoverable and disarms the gate:
278
372
  // no recurse flag required, because the WAL/journal files are the point.
279
373
  if (programs.some((p) => /^(?:remove-item|rmdir|rm|del|erase|unlink|shred)$/.test(p)) && OWN_STATE_PATH.test(text)) {
@@ -372,5 +466,19 @@ export function neverClearReason(text) {
372
466
  return 'keys are swept off this machine and uploaded';
373
467
  if (WIPE_IN_BODY.test(text))
374
468
  return 'a wipe is hidden behind a function body expanded at runtime';
469
+ // Forced/recursive delete aimed at a protected Windows root reached **by environment
470
+ // variable** (`$env:APPDATA`/`%APPDATA%`/`$env:SystemRoot`/`%WINDIR%`/`$env:ProgramFiles`
471
+ // /`$env:ProgramData`/`$env:LOCALAPPDATA`/`$env:OneDrive`/`$env:Public`/`$env:HOMEDRIVE`
472
+ // /`$env:HOMEPATH`), or an unbounded sweep of one. Same shape as the home-variable net
473
+ // (delete verb + loose recurse/force + a root token), but tiered to **never-clear ask**,
474
+ // not P0: TEMP/TMP and named children (`$env:APPDATA\Foo`) never match, and the human
475
+ // keeps the appealable decision. Tokenize on whitespace/quotes only, so `${env:x}` and
476
+ // `$env:x\.*` stay one token. Delete-verb comes from `programs` (command position), so
477
+ // a commit message merely *mentioning* `rm -rf $env:APPDATA` stays clearable.
478
+ if (programs.some((p) => /^(?:remove-item|rmdir|rm|del|erase|unlink|shred)$/.test(p))
479
+ && /(?:-\w*r\w*|(?:^|\s)\/s(?:\s|$))/i.test(text) && /(?:-\w*f\w*|(?:^|\s)\/(?:q|f)(?:\s|$))/i.test(text)
480
+ && text.split(/[\s"',;]+/).some((tok) => protectedEnvRoot(tok) !== undefined)) {
481
+ return 'forced recursive deletion targets a protected Windows root via an environment variable — verify the resolved path before proceeding';
482
+ }
375
483
  return undefined;
376
484
  }
package/lib/path.d.ts CHANGED
@@ -16,6 +16,37 @@ export declare function normalizeWorkspacePath(cwd: string, candidate: string, c
16
16
  export declare function isWithin(base: string, target: string): boolean;
17
17
  /** Sensitive credential-ish path roots that reads/writes should route to review. */
18
18
  export declare function isSensitivePath(p: string): boolean;
19
+ /**
20
+ * Credential / auth stores whose **mutation** grants or destroys access and must
21
+ * never be silently allowed. Narrower than {@link isSensitivePath}: `.env` is
22
+ * deliberately excluded — editing a project's own `.env` is normal dev work (a
23
+ * secret-reading *display* concern, not a mutation backdoor), while dropping a key
24
+ * into `~/.ssh/authorized_keys`, flipping `git config credential.helper store`, or
25
+ * rewriting `.git-credentials`/`.npmrc`/cloud cred files *creates or leaks authority*.
26
+ *
27
+ * Also excluded on purpose, because a P0 deny cannot be appealed and these are both
28
+ * routine *and* regenerable: the bare `~/.ssh/` directory (its `config` only holds host
29
+ * aliases), `known_hosts` (rebuilt with `ssh-keyscan`), and the `*.pub` half of a
30
+ * keypair — a hand-carried key restore writes `id_ed25519.pub` and `known_hosts` in the
31
+ * same breath, so blocking them would deny the owner their own credentials. Private key
32
+ * material itself stays denied: overwriting it destroys access with no way back, and a
33
+ * deliberate restore can still be done by the human outside the gate.
34
+ *
35
+ * Symmetry with the shell layer: `preset-rules` already denies deletion of these
36
+ * stores and asks on reading them, but the file-mutation tools (write/edit) were
37
+ * auto-allowed past the credential path because the P0 mutation guard only covered
38
+ * system roots — this is the set that guard now also checks.
39
+ */
40
+ export declare function isCredentialStorePath(p: string): boolean;
41
+ /**
42
+ * Whether one path is a credential worth P0 attention — the built-in store list, plus
43
+ * whatever the user declared in `credentialPaths` (compiled by `credential-paths.ts`).
44
+ *
45
+ * The extension point is a predicate rather than a second list so the *tier* cannot be
46
+ * chosen by the person adding an entry: a declared long-tail secret gets exactly what
47
+ * `.ssh` gets, and cannot accidentally land in a downgradable `ask` instead.
48
+ */
49
+ export declare function isProtectedCredentialPath(p: string, extra?: (path: string) => boolean): boolean;
19
50
  /** Root-level / DSH_HOME / home/system paths that must never be mutated. */
20
51
  export declare function isProtectedDestructiveTarget(p: string, dshHome?: string): boolean;
21
52
  export interface ArtifactRecord {
package/lib/path.js CHANGED
@@ -54,6 +54,41 @@ export function isWithin(base, target) {
54
54
  export function isSensitivePath(p) {
55
55
  return /(?:^|[\\/])(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.config[\\/]gh|\.docker)(?:[\\/]|$)|(?:^|[\\/])(?:id_rsa|id_ed25519|credentials\.yaml|config\.json|\.env)(?:$|[.\\/])/i.test(p);
56
56
  }
57
+ /**
58
+ * Credential / auth stores whose **mutation** grants or destroys access and must
59
+ * never be silently allowed. Narrower than {@link isSensitivePath}: `.env` is
60
+ * deliberately excluded — editing a project's own `.env` is normal dev work (a
61
+ * secret-reading *display* concern, not a mutation backdoor), while dropping a key
62
+ * into `~/.ssh/authorized_keys`, flipping `git config credential.helper store`, or
63
+ * rewriting `.git-credentials`/`.npmrc`/cloud cred files *creates or leaks authority*.
64
+ *
65
+ * Also excluded on purpose, because a P0 deny cannot be appealed and these are both
66
+ * routine *and* regenerable: the bare `~/.ssh/` directory (its `config` only holds host
67
+ * aliases), `known_hosts` (rebuilt with `ssh-keyscan`), and the `*.pub` half of a
68
+ * keypair — a hand-carried key restore writes `id_ed25519.pub` and `known_hosts` in the
69
+ * same breath, so blocking them would deny the owner their own credentials. Private key
70
+ * material itself stays denied: overwriting it destroys access with no way back, and a
71
+ * deliberate restore can still be done by the human outside the gate.
72
+ *
73
+ * Symmetry with the shell layer: `preset-rules` already denies deletion of these
74
+ * stores and asks on reading them, but the file-mutation tools (write/edit) were
75
+ * auto-allowed past the credential path because the P0 mutation guard only covered
76
+ * system roots — this is the set that guard now also checks.
77
+ */
78
+ export function isCredentialStorePath(p) {
79
+ return /(?:^|[\\/])authorized_keys2?(?:$|[.\\/])|(?:^|[\\/])(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:_sk)?(?!\.pub)(?:$|[.\\/])|(?:^|[\\/])(?:\.gnupg|\.aws|\.azure|\.kube|credentials\.yaml)(?:[\\/]|$)|(?:^|[\\/])(?:\.gitconfig|\.git-credentials|\.npmrc|\.netrc|\.pypirc)(?:$|[.\\/])/i.test(p);
80
+ }
81
+ /**
82
+ * Whether one path is a credential worth P0 attention — the built-in store list, plus
83
+ * whatever the user declared in `credentialPaths` (compiled by `credential-paths.ts`).
84
+ *
85
+ * The extension point is a predicate rather than a second list so the *tier* cannot be
86
+ * chosen by the person adding an entry: a declared long-tail secret gets exactly what
87
+ * `.ssh` gets, and cannot accidentally land in a downgradable `ask` instead.
88
+ */
89
+ export function isProtectedCredentialPath(p, extra) {
90
+ return isCredentialStorePath(p) || (extra !== undefined && extra(p));
91
+ }
57
92
  /** Root-level / DSH_HOME / home/system paths that must never be mutated. */
58
93
  export function isProtectedDestructiveTarget(p, dshHome) {
59
94
  const posix = toPosix(p).toLowerCase();
@@ -0,0 +1,70 @@
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 { type PermissionsDoc } from './rule.js';
31
+ /** Tag prefix carried by every preset entry's reason; locates an entry for revocation. */
32
+ export declare const PRESET_RULE_MARKER = "[deny-rule:";
33
+ /** Extract the `[deny-rule:<id>]` tag from one reason string (undefined when untagged). */
34
+ export declare function presetRuleId(reason: unknown): string | undefined;
35
+ /**
36
+ * The preset document in the file's bare YAML form. `defaultAction: ask`
37
+ * matches the empty-ruleset default, so seeding cannot widen anything.
38
+ * Patterns obey the compile limits: ≤2 glob stars, ordered subcommand+flag
39
+ * forms through argv.pipeline (evaluated per simple command as well as over
40
+ * the joined pipeline — see evaluate.ts), token co-occurrence NEVER through
41
+ * args (args is any-token OR).
42
+ */
43
+ export declare const PRESET_RULES_YAML = "defaultAction: ask\n\ndeny:\n # \u2500\u2500 Git \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n - command: [git]\n argv: { pipeline: [\"git reset --hard*\", \"git reset *--hard*\"] }\n reason: \"[deny-rule:git.reset-hard] Hard reset discards uncommitted changes. Use git reset --soft/--mixed, or git stash first.\"\n - command: [git]\n argv: { pipeline: [\"git reset --merge*\", \"git reset --abort*\"] }\n reason: \"[deny-rule:git.reset-merge] Merge reset discards in-progress merge work. Stash first, then reset --soft/--mixed.\"\n - command: [git]\n argv: { pipeline: [\"git clean *-f*\", \"git clean *--force*\"] }\n reason: \"[deny-rule:git.clean-force] git clean permanently deletes untracked files. Dry-run first: git clean -nd.\"\n - command: [git]\n argv: { pipeline: [\"git push origin *--delete*\", \"git push * --delete *\", \"git push --delete*\"] }\n reason: \"[deny-rule:git.push-delete] Deleting remote refs is visible to collaborators and needs explicit consent.\"\n - command: [git]\n argv: { pipeline: [\"git push --mirror*\", \"git push * --mirror\"] }\n reason: \"[deny-rule:git.push-mirror] Mirror push force-updates and deletes remote refs wholesale.\"\n - command: [git]\n argv: { pipeline: [\"git branch -D*\", \"git branch * -D\", \"git branch *--force-delete*\"] }\n reason: \"[deny-rule:git.branch-force-delete] git branch -D drops unmerged commits. Prefer merged-safe git branch -d.\"\n - command: [git]\n argv: { pipeline: [\"git stash drop*\"] }\n reason: \"[deny-rule:git.stash-drop] Dropping a stash entry is unrecoverable after GC. Verify with git stash list.\"\n - command: [git]\n argv: { pipeline: [\"git stash clear*\"] }\n reason: \"[deny-rule:git.stash-clear] Clearing all stashes destroys every saved worktree state.\"\n - command: [git]\n argv: { pipeline: [\"git tag -d*\", \"git tag --delete*\"] }\n reason: \"[deny-rule:git.tag-delete] Tag deletion removes release anchors.\"\n - command: [git]\n argv: { pipeline: [\"git reflog delete*\", \"git reflog expire*\"] }\n reason: \"[deny-rule:git.reflog-delete] Reflog deletion removes the last recovery path for lost commits.\"\n - command: [git]\n argv: { pipeline: [\"git rebase --abort*\"] }\n reason: \"[deny-rule:git.rebase-abort] Aborting a rebase discards resolution work. Prefer --continue/--skip.\"\n - command: [git]\n argv: { pipeline: [\"git merge --abort*\"] }\n reason: \"[deny-rule:git.merge-abort] Aborting a merge discards conflict resolution.\"\n - command: [git]\n 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\"] }\n reason: \"[deny-rule:git.rm-force] git rm -f deletes working-tree files even when modified. Stash them first.\"\n - command: [git]\n argv: { pipeline: [\"git checkout --force *\", \"git checkout * --force\", \"git checkout -f *\", \"git checkout * -f\", \"git checkout -f\", \"git checkout * --force *\"] }\n reason: \"[deny-rule:git.checkout-force] Forced checkout overwrites local changes. git stash, then checkout.\"\n - command: [git]\n argv: { pipeline: [\"git checkout -- *\"] }\n reason: \"[deny-rule:git.checkout-double-dash] Path restore from checkout discards local edits. Use git stash push -- <path>.\"\n - command: [git]\n argv: { pipeline: [\"git checkout * -- *\"] }\n reason: \"[deny-rule:git.checkout-ref-path] Checking out a ref with a path overwrites working-tree files. Stash the paths first.\"\n - command: [git]\n argv: { pipeline: [\"git checkout *--pathspec-from-file*\"] }\n reason: \"[deny-rule:git.checkout-pathspec-from-file] Bulk path restore from a file can silently overwrite edits. List the pathspec, stash, then restore.\"\n - command: [git]\n argv: { pipeline: [\"git switch *--discard-changes*\"] }\n reason: \"[deny-rule:git.switch-discard-changes] --discard-changes throws away edits. git stash first.\"\n - command: [git]\n argv: { pipeline: [\"git switch --force *\", \"git switch * --force\", \"git switch -f *\", \"git switch * -f\"] }\n reason: \"[deny-rule:git.switch-force] Forced switch overwrites local changes. git stash first.\"\n - command: [git]\n # git restore <bare path> (the plain unstaged discard) is NOT ported here:\n # any glob broad enough to catch it also catches the harmless\n # git restore --staged <path> (G6) \u2014 only the explicit discard forms are\n # denied, the bare-path case stays in the prompt layer.\n argv: { pipeline: [\"git restore --worktree *\", \"git restore * --worktree *\", \"git restore * --worktree\", \"git restore -W *\"] }\n reason: \"[deny-rule:git.restore-worktree] Worktree restore discards local edits. git stash push -- <path> first.\"\n - command: [git]\n argv: { pipeline: [\"git worktree remove --force *\", \"git worktree remove * --force\", \"git worktree remove -f *\", \"git worktree remove * -f\"] }\n reason: \"[deny-rule:git.worktree-remove-force] Forced worktree removal discards uncommitted work in that tree.\"\n - command: [git]\n args: [\"alias.*\"]\n reason: \"[deny-rule:git.alias-config] Command-line Git aliases run arbitrary shell and cannot be analyzed.\"\n # params matches the RAW command string: the env-assignment word lands as the\n # simple command's lowercased word, so a command:[git] dimension would never\n # see this form \u2014 match on params alone.\n - params: { command: [\"*GIT_SSH_COMMAND=*\", \"*git_ssh_command=*\", \"*GIT_SSH=*\", \"*git_ssh=*\"] }\n reason: \"[deny-rule:git.ssh-env] Git network ops with an SSH env override execute unanalyzable code.\"\n\n # \u2500\u2500 Filesystem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n - command: [\"rm#recursive\", \"rm#force\"]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:rm.git-metadata] Removing Git metadata destroys history and the gate's audit anchors.\"\n - command: [find]\n argv: { pipeline: [\"find .git*\"] }\n reason: \"[deny-rule:find.delete-git-metadata] Selecting .git for deletion destroys repository state.\"\n - command: [find]\n argv: { pipeline: [\"find * -delete\"] }\n reason: \"[deny-rule:find.delete] find -delete removes everything selected. Enumerate with plain find first.\"\n - command: [find]\n argv: { pipeline: [\"find *-exec rm*\", \"find *-delete*\"] }\n reason: \"[deny-rule:find.exec-rm-recursive-force] find -exec rm removes the whole tree per match. Dry-run plain find, delete explicit paths.\"\n - command: [dd]\n argv: { pipeline: [\"dd * of=/dev/sd*\", \"dd * of=/dev/nvme*\", \"dd * of=/dev/mmcblk*\"] }\n reason: \"[deny-rule:dd.device-write] dd writing to a block device destroys the disk.\"\n - command: [mkfs, mke2fs, newfs, \"mkfs.*\"]\n reason: \"[deny-rule:mkfs.device] Formatting a device erases it.\"\n - command: [diskutil, wipefs, sgdisk, parted]\n reason: \"[deny-rule:disk.erase] Disk erase/partition/wipe operations destroy data.\"\n - command: [shred]\n reason: \"[deny-rule:shred.target] shred irreversibly destroys files (even --help/--version reach it).\"\n - command: [cat, head, tail, dd]\n args: [\"*/dev/sd*\", \"*/dev/nvme*\", \"*/dev/disk*\", \"*/dev/mmcblk*\"]\n reason: \"[deny-rule:redirect.block-device] Redirect target resolving to a disk device path.\"\n\n # \u2500\u2500 PowerShell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n # Command words are lowercased by the decomposer \u2014 command dimensions must\n # carry the lowercased verb (Remove-Item \u2192 remove-item).\n - command: [\"remove-item#recursive\"]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:powershell.remove-item-git-metadata] Never target .git.\"\n - command: [powershell, pwsh]\n argv: { pipeline: [\"pwsh *Remove-Item*\", \"powershell *Remove-Item*\", \"pwsh *remove-item*\", \"powershell *remove-item*\"] }\n 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.\"\n - command: [\"remove-item#recursive\"]\n args: [\".\", \"./\"]\n reason: \"[deny-rule:powershell.remove-item-recursive-force-cwd-self] Deleting the current directory removes the project.\"\n - command: [\"remove-item#recursive\"]\n args: [\"../*\", \"..\\\\*\"]\n reason: \"[deny-rule:powershell.remove-item-recursive-force-outside-cwd] Deleting above the cwd escapes task scope.\"\n\n # \u2500\u2500 Command Prompt \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n # (The cc escaped-quote-collapse rule cannot be expressed faithfully with\n # these globs \u2014 every cmd /c recursion is nested behind a second parser,\n # so its true target is undecidable: it moves to the ask section instead.)\n - command: [cmd, rmdir, del]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:cmd.recursive-delete-git-metadata] Never target .git.\"\n - command: [rmdir, del]\n args: [\".\", \"./\"]\n reason: \"[deny-rule:cmd.recursive-delete-cwd-self] Deleting the current directory removes the project.\"\n - command: [rmdir, del]\n args: [\"../*\", \"..\\\\*\"]\n reason: \"[deny-rule:cmd.recursive-delete-outside-cwd] Delete above cwd escapes task scope.\"\n\n # \u2500\u2500 Secret targets (write/delete deny; reads are the ask section below) \u2500\u2500\n - command: [\"rm#recursive\", \"remove-item#recursive\", rmdir, del]\n args: [\"*/.ssh\", \"*/.aws\", \"*/.gnupg\", \".ssh\", \".aws\"]\n reason: \"[deny-rule:secret.delete-credential-path] Deleting credential stores destroys keys you cannot regenerate.\"\n - command: [echo, \"set-content\", \"out-file\"]\n args: [\".env\", \".env.*\"]\n reason: \"[deny-rule:secret.write-overwrite-env] Overwriting .env destroys existing secrets. Edit specific lines.\"\n - command: [\"rm#recursive\", \"remove-item#recursive\", rmdir, del]\n args: [\"*/.git-credentials\", \".git-credentials\"]\n reason: \"[deny-rule:secret.delete-git-credentials] Deleting the git credential store breaks push/pull auth.\"\n\nask:\n # Force push: ask, not deny (revoked from cc's deny ruling 2026-10-13).\n - command: [git]\n argv: { pipeline: [\"git push *--force*\", \"git push *-f*\"] }\n reason: \"[deny-rule:git.push-force] Force push rewrites shared history. Prefer git push --force-with-lease.\"\n\n # cmd /c recursion hides the real target behind a second parser (and quoting):\n # undecidable \u2192 ask, where cc denied only the drive-root-collapse variant.\n - command: [cmd]\n argv: { pipeline: [\"cmd /c *rmdir /s*\", \"cmd /c *del /s*\"] }\n 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.\"\n\n # Dynamic targets: this gate's ruling is \"undecidable \u2192 ask\", not fail-closed deny.\n - command: [\"rm#recursive\", \"remove-item#recursive\"]\n args: [\"$*\"]\n reason: \"[deny-rule:rm.recursive-force-dynamic-target] Recursive delete of a substitution-assembled target \u2014 verify the variable yourself.\"\n # The wrapper word xargs is stripped by the decomposer, so pipeline patterns\n # never see it \u2014 match the raw command string through the params dimension.\n - params: { command: [\"*xargs rm*\", \"*xargs del*\"] }\n reason: \"[deny-rule:xargs.rm-recursive-force-dynamic] xargs-fed removals have unverifiable target sets.\"\n - command: [parallel]\n argv: { pipeline: [\"parallel *rm*\", \"*| parallel*\"] }\n reason: \"[deny-rule:parallel.rm-recursive-force-dynamic] parallel-fed rm has unresolvable targets.\"\n - command: [awk]\n args: [\"*system(*\"]\n reason: \"[deny-rule:awk.system-dynamic] awk program text containing system() cannot be analyzed.\"\n # NOTE: cc's git checkout <ref> <path> ambiguity (G5) is NOT ported \u2014 a glob\n # cannot tell ref candidates from paths, and a broad pattern would prompt on\n # every git checkout -b new-branch. Documented residual gap.\n\n # Credential reads: ask (A2 ruling kept; egress combos stay in the prompt layer).\n - command: [cat, head, tail, type, \"get-content\", ls, dir, \"get-childitem\", \"select-string\"]\n args: [\".env\", \".env.*\", \"*.env\", \".npmrc\", \".pypirc\", \".netrc\", \".git-credentials\", \"id_rsa\", \"id_ed25519\", \"id_ecdsa\"]\n reason: \"[deny-rule:secret.read-credential-file] Reading a credential file requires confirmation. Echo only the keys you need via a filtered command.\"\n - command: [cat, head, tail, type, \"get-content\", ls, dir, \"get-childitem\"]\n args: [\"*/.ssh\", \"*/.ssh/*\", \"*/.aws\", \"*/.aws/*\", \"*/.gnupg\", \"*/.gnupg/*\", \"*/.config/gcloud/*\", \"*/.claude/*\", \"*/.codex/*\"]\n reason: \"[deny-rule:secret.read-home-credential-dirs] Browsing ssh/cloud/coding-CLI credential stores requires confirmation.\"\n\n # Self-protection: editing the gate's own rule sources needs confirmation \u2014\n # revocation must be a human act, not something an agent can slide through.\n # (Settings-namespace edits go through the host UI, not a tool call, and are\n # out of reach of this entry; documented gap.)\n - args: [\"*rules.yml\", \"*rules.yaml\", \"*deny-defaults*\"]\n reason: \"[deny-rule:gate.self-protection] This call touches the permission gate's own rule sources; changes here alter what the gate enforces.\"\n";
44
+ export declare function parsePresetDoc(): PermissionsDoc;
45
+ /** Every preset id, in table order. Client view + tests use this as the row key. */
46
+ export declare function presetRuleIds(): string[];
47
+ /**
48
+ * Append missing preset entries to a RAW rules document object (bare or
49
+ * `permissions:`-wrapped form). Idempotent and revocation-preserving: an id
50
+ * already present — enabled or not — is never re-added. Mutates nothing;
51
+ * returns the merged copy and the added count.
52
+ */
53
+ export declare function mergePresetIntoRaw(rawDoc: unknown): {
54
+ doc: Record<string, unknown>;
55
+ added: number;
56
+ };
57
+ /**
58
+ * One-shot file seed: create the rules file from the preset when absent, or
59
+ * append missing preset entries when present (comments in a hand-written file
60
+ * are lost on rewrite — the file is the plugin's data dir, not user-authored
61
+ * by default). A sibling marker file records that the seed ran: after a full
62
+ * revocation (user deletes every preset entry) the seed must never re-add
63
+ * them, and the marker works on hosts without the settings service where the
64
+ * `presetRulesSeeded` config flag cannot be persisted. Returns what happened
65
+ * for the log line.
66
+ */
67
+ export declare function ensurePresetRulesFile(rulesFile: string, markerFile?: string): {
68
+ action: 'created' | 'merged' | 'skipped' | 'already-seeded';
69
+ added: number;
70
+ };