dsh-perm-gate 5.2.0 → 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/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 +152 -1
- package/lib/config.js +20 -1
- 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 +34 -11
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +119 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -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 +423 -54
- 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 +1 -1
- package/scripts/patch-permission-glyph.mjs +27 -13
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`)
|
|
146
|
-
/(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
};
|
package/lib/feedback.js
ADDED
|
@@ -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'
|
|
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
|
-
//
|
|
145
|
-
if
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
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();
|