dsh-perm-gate 5.2.0 → 5.3.1

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/lib/evaluate.js CHANGED
@@ -142,8 +142,13 @@ const SHELL_WRITE_PATTERNS = [
142
142
  /(?:^|\s)(?:mv|cp|install|mkdir|touch|chmod|chown)\b/i,
143
143
  // Redirect into a file: `> f`, `>> f`, `>f`. The leading character class
144
144
  // excludes the stderr idioms a read-only command uses (`2>&1`, `>&2`,
145
- // `2>/dev/null`, `1>&2`), and the trailing class requires an actual target.
146
- /(?:^|[^0-9&])>>?\s*[^\s&|]/,
145
+ // `2>/dev/null`, `1>&2`) AND the two arrow forms that are pure text, not a
146
+ // redirect: `=>` (JS/Rust/arrow-function syntax, e.g. `node -e "(x)=>x+1"`) and
147
+ // `->` (prose / C member access, e.g. `Write-Host 'a -> b'`). Without `=`/`-` in
148
+ // the class, a read-only command merely *containing* an arrow was escalated to
149
+ // `ask` (GitHub issue #4). A real redirect's `>` is still preceded by a space or
150
+ // an ordinary word char (`echo x > f`, `ls>f`), so those keep matching.
151
+ /(?:^|[^0-9&=>-])>>?\s*[^\s&|]/,
147
152
  // `tee` always writes its target; `curl`/`wget` only when they write a file.
148
153
  /(?:^|\s)tee\b/i,
149
154
  /(?:^|\s)(?:curl|wget)\b.*?(?:\s-[oO]\b|>)/i,
@@ -167,26 +172,33 @@ function isWriteCommand(commandText) {
167
172
  * - `shell` / `pwsh` / `terminal`: read-only commands (git status, python -c
168
173
  * fetch, curl without redirect, etc.) follow `defaultAction`; commands that
169
174
  * contain a write/execution pattern escalate to `ask` for review.
175
+ *
176
+ * The escalation names its own cause (GitHub issue #3). Reporting these as
177
+ * `no rule matched; default action` tells a reader the ruleset was silent, when in fact
178
+ * a safety screen just refused to let the default through — and it makes the popup say
179
+ * "no rule covers this" about a call a rule *did* cover in spirit.
170
180
  */
171
181
  function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
172
182
  if (defaultAction !== 'allow')
173
- return defaultAction;
183
+ return undefined;
174
184
  // write/edit: path-aware (outside workspace → ask)
175
185
  if (WRITE_TOOLS.has(ctx.tool)) {
176
186
  const home = ctx.home;
177
187
  if (home !== undefined && home !== '') {
178
188
  for (const candidate of pathCandidates) {
179
- if (normalizeWorkspacePath(home, candidate, ctx.caseInsensitive) === '')
180
- return 'ask';
189
+ if (normalizeWorkspacePath(home, candidate, ctx.caseInsensitive) === '') {
190
+ return { action: 'ask', why: `write target outside the workspace (${candidate})` };
191
+ }
181
192
  }
182
193
  }
183
- return defaultAction;
194
+ return undefined;
184
195
  }
185
196
  // shell/pwsh/terminal: command-content-aware (write pattern → ask)
186
197
  const commandText = commandTextOf(ctx);
187
- if (commandText !== undefined && isWriteCommand(commandText))
188
- return 'ask';
189
- return defaultAction;
198
+ if (commandText !== undefined && isWriteCommand(commandText)) {
199
+ return { action: 'ask', why: 'command carries a file-write or redirect pattern' };
200
+ }
201
+ return undefined;
190
202
  }
191
203
  /**
192
204
  * Command text of a shell call: the explicit `commandText` field when set,
@@ -224,8 +236,15 @@ export function decideRules(ruleset, ctx) {
224
236
  // shell/pwsh/terminal tools check the command for write patterns.
225
237
  // Outside safety checks → ask; inside or read-only → defaultAction.
226
238
  let action = ruleset.defaultAction;
239
+ // Why this layer decided, when it is the one that decided (issue #3): kept separate
240
+ // from `no rule matched` so an audit line cannot impersonate a ruleset gap.
241
+ let screenWhy;
227
242
  if (WRITE_TOOLS.has(ctx.tool) || shellText !== undefined) {
228
- action = pathAndCommandDefault(ctx, pathCands, action);
243
+ const escalated = pathAndCommandDefault(ctx, pathCands, action);
244
+ if (escalated !== undefined) {
245
+ action = escalated.action;
246
+ screenWhy = escalated.why;
247
+ }
229
248
  }
230
249
  if (action === 'allow' || action === 'ask') {
231
250
  const allow = scan(ruleset.allow, 'allow');
@@ -235,7 +254,11 @@ export function decideRules(ruleset, ctx) {
235
254
  if (ask !== undefined)
236
255
  return ask;
237
256
  }
238
- return { action, reason: `no rule matched; default action`, ruleIndex: undefined };
257
+ return {
258
+ action,
259
+ reason: screenWhy === undefined ? 'no rule matched; default action' : `${screenWhy}; escalated by the default safety screen`,
260
+ ruleIndex: undefined,
261
+ };
239
262
  }
240
263
  function decomposeSafe(text) {
241
264
  try {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Deterministic review opinion for a blocked tool call — no LLM.
3
+ *
4
+ * Modeled on cc-safety-net's block message (`BLOCKED + Reason + Rule + intent
5
+ * instruction`): when the gate refuses a call, the main LLM should get a structured
6
+ * note telling it *why*, *which rule/layer* fired, and *what to do instead*, so it
7
+ * stays on task instead of retrying a variant. perm-gate already has the raw
8
+ * material — every preset entry tags its reason `[deny-rule:<id>]` and states a
9
+ * safer alternative in prose, and the deterministic layers emit `[hard-deny]`/
10
+ * `no rule matched` reasons — this module folds that into one classified opinion.
11
+ *
12
+ * Kept dependency-free and pure so it can be unit-tested and reused by both the
13
+ * node decision path and any UI.
14
+ */
15
+ /** The five dispositions, mirroring cc-safety-net's intent taxonomy. */
16
+ export type ReviewIntent = 'hard_stop' | 'use_alternative' | 'scope_down' | 'manual_only' | 'stop_and_explain';
17
+ /** Per-intent guidance to the agent, zh + en (the opinion is fed to the main LLM). */
18
+ export declare const INTENT_ADVICE: Readonly<Record<ReviewIntent, {
19
+ readonly zh: string;
20
+ readonly en: string;
21
+ }>>;
22
+ /** Extract the `[deny-rule:<id>]` tag, a gate-danger tag (YOLO / degraded review /
23
+ * mechanical passthrough), or P0. */
24
+ export declare function ruleIdOf(reason: string): string | undefined;
25
+ /**
26
+ * Classify a block reason into an intent. Ordered from the most specific signal to
27
+ * a conservative default; the phrasing of preset-rule reasons drives the match.
28
+ */
29
+ export declare function intentForDeny(reason: string): ReviewIntent;
30
+ /**
31
+ * The structured opinion for one blocked call. `command` must already be redacted by
32
+ * the caller. Returns the classified intent plus a single line suitable for the
33
+ * `工具审核意见: "<text>"` wrapper the listener delivers to the session.
34
+ */
35
+ export declare function composeReviewOpinion(input: {
36
+ readonly command: string;
37
+ readonly reason: string;
38
+ readonly locale?: 'zh' | 'en';
39
+ }): {
40
+ readonly intent: ReviewIntent;
41
+ readonly text: string;
42
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Deterministic review opinion for a blocked tool call — no LLM.
3
+ *
4
+ * Modeled on cc-safety-net's block message (`BLOCKED + Reason + Rule + intent
5
+ * instruction`): when the gate refuses a call, the main LLM should get a structured
6
+ * note telling it *why*, *which rule/layer* fired, and *what to do instead*, so it
7
+ * stays on task instead of retrying a variant. perm-gate already has the raw
8
+ * material — every preset entry tags its reason `[deny-rule:<id>]` and states a
9
+ * safer alternative in prose, and the deterministic layers emit `[hard-deny]`/
10
+ * `no rule matched` reasons — this module folds that into one classified opinion.
11
+ *
12
+ * Kept dependency-free and pure so it can be unit-tested and reused by both the
13
+ * node decision path and any UI.
14
+ */
15
+ /** Per-intent guidance to the agent, zh + en (the opinion is fed to the main LLM). */
16
+ export const INTENT_ADVICE = {
17
+ hard_stop: {
18
+ zh: '不得重试或绕过此操作;跳过它,继续任务的其余部分。',
19
+ en: 'Do not retry or bypass this operation; skip it and continue the rest of the task.',
20
+ },
21
+ use_alternative: {
22
+ zh: '不要重试被拦的形式;按上面给出的更安全替代继续。',
23
+ en: 'Do not retry the blocked form; continue with the safer alternative named above.',
24
+ },
25
+ scope_down: {
26
+ zh: '请把目标收窄到明确必需的路径再重试;确需大范围操作则上报用户。',
27
+ en: 'Retry with a narrower, explicitly-needed target; escalate to the user if a broad operation is truly required.',
28
+ },
29
+ manual_only: {
30
+ zh: '此命令无法静态核验,请交由用户手动执行。',
31
+ en: 'This command cannot be verified statically; ask the user to run it manually.',
32
+ },
33
+ stop_and_explain: {
34
+ zh: '不要暴力尝试变体;简化或重组命令后再试,或上报此拦截。',
35
+ en: 'Do not brute-force variants; simplify or restate the command, or report this block.',
36
+ },
37
+ };
38
+ /** Extract the `[deny-rule:<id>]` tag, a gate-danger tag (YOLO / degraded review /
39
+ * mechanical passthrough), or P0. */
40
+ export function ruleIdOf(reason) {
41
+ const tag = /\[(deny-rule:[a-z0-9._-]+|(?:yolo|rules-fallback|safety-net)-danger:[a-z0-9._-]+)\]/.exec(reason);
42
+ if (tag?.[1] !== undefined)
43
+ return tag[1];
44
+ if (/\[hard-deny\]|hard-deny/i.test(reason))
45
+ return 'hard-deny';
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Classify a block reason into an intent. Ordered from the most specific signal to
50
+ * a conservative default; the phrasing of preset-rule reasons drives the match.
51
+ */
52
+ export function intentForDeny(reason) {
53
+ const r = reason.toLowerCase();
54
+ // unverifiable / opaque target, or an act that needs the user's own consent → a human runs it
55
+ if (/opaque|cannot be (read|verified|analyzed)|unanalyz|encoded|unread|unresolv|non-literal|dynamic|substitution-assembled|verify the variable|explicit consent|needs? (the )?user/.test(r))
56
+ return 'manual_only';
57
+ // explicit safer alternative offered → use it
58
+ if (/\buse\b|\bprefer\b|\bsafer\b|\binstead\b|\bstash\b|dry-?run|\bfirst\b|\bedit specific\b|narrower/.test(r))
59
+ return 'use_alternative';
60
+ // broad/target-set concerns → narrow it. A P0 `[hard-deny]` or an unattended block is
61
+ // never merely "too broad": those carry an irreversibility the agent may not trade
62
+ // away, so the scope signal must not downgrade them into a retry-with-narrower note.
63
+ if (/targets? (a )?(directory|many|mass|whole|entire)|broad|everything|scope|wildcard|\.\*|recurse/.test(r) && !/never|unrecoverable|irreversib|hard-deny|yolo: block|rules-fallback: block/.test(r))
64
+ return 'scope_down';
65
+ // truly irreversible / protected stores / credential / a graded risk → hard stop
66
+ if (/\bhard-deny\b|-danger:|irreversib|unrecoverable|cannot be (recovered|regenerated|undone)|wipe|destroy|credential|protected path|credential store|shadow|format|risk|delete shadows|force-overwrite|protected branch/.test(r))
67
+ return 'hard_stop';
68
+ return 'stop_and_explain';
69
+ }
70
+ /**
71
+ * The structured opinion for one blocked call. `command` must already be redacted by
72
+ * the caller. Returns the classified intent plus a single line suitable for the
73
+ * `工具审核意见: "<text>"` wrapper the listener delivers to the session.
74
+ */
75
+ export function composeReviewOpinion(input) {
76
+ const intent = intentForDeny(input.reason);
77
+ const rule = ruleIdOf(input.reason);
78
+ const advice = INTENT_ADVICE[intent];
79
+ const locale = input.locale ?? 'zh';
80
+ const cleanReason = input.reason.replace(/\[(?:deny-rule:[a-z0-9._-]+|(?:yolo|rules-fallback|safety-net)-danger:[a-z0-9._-]+|hard-deny)\]\s*/gi, '').replace(/\s*·.*$/, '').trim();
81
+ const text = locale === 'zh'
82
+ ? `已拦截。依据:${rule ?? '默认策略'}。原因:${cleanReason}。处置(${intent}):${advice.zh}`
83
+ : `Blocked. Rule: ${rule ?? 'default policy'}. Reason: ${cleanReason}. Disposition(${intent}): ${advice.en}`;
84
+ return { intent, text };
85
+ }
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
9
  import { Config } from './config.js';
10
- import { PermGateRuntime, type ToolExecutionLike } from './runtime.js';
10
+ import { PermGateRuntime, type PreToolDecisionLike, type ToolExecutionLike } from './runtime.js';
11
11
  export declare const name = "dsh-perm-gate";
12
12
  /**
13
13
  * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
@@ -53,5 +53,5 @@ export declare function makeApprovalAnswerer(runtime: Pick<PermGateRuntime, 'set
53
53
  * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
54
54
  * (fail-closed).
55
55
  */
56
- export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk' | 'beginShellExecution'> & Partial<Pick<PermGateRuntime, 'sandboxRewrite'>>): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
56
+ export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk' | 'beginShellExecution'> & Partial<Pick<PermGateRuntime, 'sandboxRewrite' | 'unattendedModeOf' | 'resolveYolo' | 'resolveSafetyNet'>>, feedback?: (exec: ToolExecutionLike, decision: PreToolDecisionLike) => void): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
57
57
  export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;
package/lib/index.js CHANGED
@@ -9,9 +9,12 @@ import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, register
9
9
  // T12 设置数据桥(describe/mutate;client BridgeDocHandle 消费)
10
10
  import { registerSettingsBridgeRoutes } from './bridge.js';
11
11
  import { buildReceiverInfo } from './receiver-info.js';
12
+ import { composeReviewOpinion } from './feedback.js';
13
+ import { redactCommandText } from './redact.js';
12
14
  import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
13
15
  import { PermGateRuntime } from './runtime.js';
14
16
  import { classifySessions, sweepSessionData } from './session-sweep.js';
17
+ import { isHumanFree } from './unattended.js';
15
18
  import { decideNetworkTarget } from './network.js';
16
19
  import { NetworkLifecycle } from './network-lifecycle.js';
17
20
  import { RuleWatcher } from './watch.js';
@@ -138,19 +141,13 @@ export function makeApprovalAnswerer(runtime) {
138
141
  * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
139
142
  * (fail-closed).
140
143
  */
141
- export function makePreExecuteListener(runtime) {
144
+ export function makePreExecuteListener(runtime, feedback) {
142
145
  return async (exec, next) => {
143
146
  const decision = runtime.decideExecution(exec);
144
- // Passthrough (allow / stand-down) and a hard-deny need no grading.
145
- if (decision === undefined) {
146
- // The call proceeds: register it so a network connection made by its
147
- // 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 () => {
148
150
  runtime.beginShellExecution(exec);
149
- // AppContainer sandbox (goal 2): rewrite the allowed shell call into
150
- // the launcher invocation, or fail closed. The host executes THIS exec
151
- // object after the waterfall resolves, so the in-place arguments swap
152
- // is the sanctioned seam; the base64 wrapper keeps every pipe/&&
153
- // inside the container (outer shell sees a plain exe call).
154
151
  if (typeof runtime.sandboxRewrite === 'function') {
155
152
  const rewrite = await runtime.sandboxRewrite(exec);
156
153
  if (rewrite !== undefined) {
@@ -163,9 +160,49 @@ export function makePreExecuteListener(runtime) {
163
160
  }
164
161
  }
165
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;
166
205
  }
167
- if (decision.kind !== 'ask')
168
- return decision; // deny: it never runs
169
206
  // A cancelled call is never worth a model round-trip; the registry rechecks
170
207
  // cancellation after this gate settles.
171
208
  if (exec.signal?.aborted === true)
@@ -177,14 +214,16 @@ export function makePreExecuteListener(runtime) {
177
214
  catch {
178
215
  return decision; // fail-closed: keep the human ask
179
216
  }
180
- if (refined === undefined) {
181
- runtime.beginShellExecution(exec);
182
- return next();
183
- }
217
+ if (refined === undefined)
218
+ return allowAndRun();
184
219
  // Still an ask: the human decides. Register optimistically so a call they
185
220
  // approve is attributable; a rejection is dropped by the abort/settle path.
186
221
  if (refined.kind === 'ask')
187
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);
188
227
  return refined;
189
228
  };
190
229
  }
@@ -445,6 +484,21 @@ export function apply(ctx, config = {}) {
445
484
  strategies: resolvePermissiveStrategies(c.permissiveStrategies),
446
485
  };
447
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,
448
502
  // Read the llmAssist receiver (endpoint/model/secret/timeout) live from the
449
503
  // same namespace, so editing the settings card applies to the next tool call.
450
504
  readClassifyConfig: () => {
@@ -469,6 +523,28 @@ export function apply(ctx, config = {}) {
469
523
  const c = current();
470
524
  return Array.isArray(c.denyKeywords) ? c.denyKeywords : undefined;
471
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
+ },
472
548
  // Learning sedimentation: threshold-reached samples become deterministic allows.
473
549
  readRiskSediment: () => current().riskSediment ?? true,
474
550
  // llmAssist receiver source: custom OpenAI-compatible endpoint or the host llm service.
@@ -742,7 +818,33 @@ export function apply(ctx, config = {}) {
742
818
  catch {
743
819
  // webServer unavailable: the feed remains disk-only, sediment is view-only in files
744
820
  }
745
- 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);
746
848
  // `tools/pre-execute` / `tools/result` / `approval/request` are service events,
747
849
  // not part of cordis core's typed `Events`, so they are registered through the
748
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();