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.
- package/CHANGELOG.ja.md +54 -1
- package/CHANGELOG.ko.md +54 -1
- package/CHANGELOG.md +134 -4
- package/README.ja.md +43 -0
- package/README.ko.md +43 -1
- package/README.md +73 -0
- package/README.zh.md +41 -0
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/cordis.patch.yml +16 -1
- package/dsh.plugin.json +1 -1
- package/lib/ask-context.d.ts +49 -1
- package/lib/ask-context.js +89 -5
- package/lib/client.js +714 -86
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +243 -83
- package/lib/config.js +84 -41
- package/lib/credential-paths.d.ts +35 -0
- package/lib/credential-paths.js +119 -0
- package/lib/engine.d.ts +4 -6
- package/lib/engine.js +53 -42
- package/lib/evaluate.d.ts +20 -0
- package/lib/evaluate.js +41 -13
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +143 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -0
- package/lib/preset-rules.d.ts +70 -0
- package/lib/preset-rules.js +388 -0
- package/lib/preset.d.ts +8 -5
- package/lib/preset.js +15 -6
- package/lib/risk.d.ts +29 -1
- package/lib/risk.js +36 -2
- package/lib/runtime.d.ts +164 -0
- package/lib/runtime.js +429 -55
- package/lib/scan-text.d.ts +69 -0
- package/lib/scan-text.js +162 -0
- package/lib/script-review.d.ts +50 -0
- package/lib/script-review.js +202 -0
- package/lib/shell.d.ts +18 -0
- package/lib/shell.js +103 -1
- package/lib/unattended.d.ts +61 -0
- package/lib/unattended.js +77 -0
- package/package.json +4 -3
- 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
|
-
//
|
|
144
|
-
if
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/irreversible.js
CHANGED
|
@@ -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
|
+
};
|