@trim21/personal-pi-extensions 0.1.639 → 0.1.642
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/README.md +2 -2
- package/package.json +1 -1
- package/src/claude-code/files.ts +26 -20
- package/src/lib/lsp/rename-tool.ts +4 -1
- package/src/lib/write-guard.ts +52 -48
- package/src/opencode/files.ts +31 -29
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
|
|
|
137
137
|
写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` 的 `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
|
|
138
138
|
|
|
139
139
|
- workspace 内或 `/tmp` 下的路径自动放行
|
|
140
|
-
- 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff
|
|
140
|
+
- 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更;预览由「将要写入的内容」直接算出,批准后写的就是这份内容(批准前若文件被外部改动则拒绝写入)
|
|
141
141
|
- headless(无 UI)会话直接拒绝外部写入;Windows 上不提供审批路径,工作区外写入一律拒绝
|
|
142
142
|
- `/bwrap-deny-request` 生效期间外部写入按无理由拒绝处理(`user deny <tool>: blocked`)
|
|
143
143
|
- 无需配置,随各写工具自动生效
|
|
@@ -253,7 +253,7 @@ opencode 风格工具集,随 `src/opencode/index.ts` 一次加载:`read` / `
|
|
|
253
253
|
|
|
254
254
|
### edit
|
|
255
255
|
|
|
256
|
-
`edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts
|
|
256
|
+
`edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
|
|
257
257
|
|
|
258
258
|
支持的匹配策略:
|
|
259
259
|
|
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -345,17 +345,6 @@ export function registerFileTools(
|
|
|
345
345
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
346
346
|
signal?.throwIfAborted();
|
|
347
347
|
const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
|
|
348
|
-
await guardWriteAccess(ctx, {
|
|
349
|
-
toolName: "Edit",
|
|
350
|
-
absolutePath: filePath,
|
|
351
|
-
change: {
|
|
352
|
-
oldText: params.old_string,
|
|
353
|
-
newText: params.new_string,
|
|
354
|
-
replaceAll: params.replace_all,
|
|
355
|
-
},
|
|
356
|
-
policy,
|
|
357
|
-
signal,
|
|
358
|
-
});
|
|
359
348
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
360
349
|
[string, FileToolDetails, DiagnosticReport]
|
|
361
350
|
>(filePath, async () => {
|
|
@@ -367,11 +356,12 @@ export function registerFileTools(
|
|
|
367
356
|
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
368
357
|
if (oldString === "") {
|
|
369
358
|
let exists = true;
|
|
359
|
+
let contentOld = "";
|
|
370
360
|
try {
|
|
371
361
|
const value = await stat(filePath);
|
|
372
362
|
if (value.isFile()) {
|
|
373
|
-
|
|
374
|
-
if (
|
|
363
|
+
contentOld = await readFile(filePath, "utf8");
|
|
364
|
+
if (contentOld.trim() !== "") {
|
|
375
365
|
throw new Error("Cannot create new file - file already exists.");
|
|
376
366
|
}
|
|
377
367
|
}
|
|
@@ -382,6 +372,14 @@ export function registerFileTools(
|
|
|
382
372
|
throw error;
|
|
383
373
|
}
|
|
384
374
|
}
|
|
375
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
376
|
+
await guardWriteAccess(ctx, {
|
|
377
|
+
toolName: "Edit",
|
|
378
|
+
absolutePath: filePath,
|
|
379
|
+
mutation: { contentOld, contentNew: newString },
|
|
380
|
+
policy,
|
|
381
|
+
signal,
|
|
382
|
+
});
|
|
385
383
|
if (!exists) {
|
|
386
384
|
await mkdir(dirname(filePath), { recursive: true });
|
|
387
385
|
}
|
|
@@ -444,6 +442,13 @@ export function registerFileTools(
|
|
|
444
442
|
signal?.throwIfAborted();
|
|
445
443
|
const original = content.toString("utf8");
|
|
446
444
|
const restored = applyExactEdit(original, oldString, newString, replaceAll);
|
|
445
|
+
await guardWriteAccess(ctx, {
|
|
446
|
+
toolName: "Edit",
|
|
447
|
+
absolutePath: filePath,
|
|
448
|
+
mutation: { contentOld: original, contentNew: restored },
|
|
449
|
+
policy,
|
|
450
|
+
signal,
|
|
451
|
+
});
|
|
447
452
|
await writeFile(filePath, restored, "utf8");
|
|
448
453
|
const snapshot = snapshotOf(restored);
|
|
449
454
|
state.reads.set(key, snapshot);
|
|
@@ -518,13 +523,6 @@ export function registerFileTools(
|
|
|
518
523
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
519
524
|
signal?.throwIfAborted();
|
|
520
525
|
const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
|
|
521
|
-
await guardWriteAccess(ctx, {
|
|
522
|
-
toolName: "Write",
|
|
523
|
-
absolutePath: filePath,
|
|
524
|
-
change: { oldText: "", newText: params.content },
|
|
525
|
-
policy,
|
|
526
|
-
signal,
|
|
527
|
-
});
|
|
528
526
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
529
527
|
[string, FileToolDetails, DiagnosticReport]
|
|
530
528
|
>(filePath, async () => {
|
|
@@ -544,6 +542,14 @@ export function registerFileTools(
|
|
|
544
542
|
}
|
|
545
543
|
}
|
|
546
544
|
signal?.throwIfAborted();
|
|
545
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
546
|
+
await guardWriteAccess(ctx, {
|
|
547
|
+
toolName: "Write",
|
|
548
|
+
absolutePath: filePath,
|
|
549
|
+
mutation: { contentOld: original ?? "", contentNew: params.content },
|
|
550
|
+
policy,
|
|
551
|
+
signal,
|
|
552
|
+
});
|
|
547
553
|
await mkdir(dirname(filePath), { recursive: true });
|
|
548
554
|
await writeFile(filePath, params.content, "utf8");
|
|
549
555
|
const snapshot = snapshotOf(params.content);
|
|
@@ -194,11 +194,14 @@ export function registerLspRenameTool(
|
|
|
194
194
|
if (applied.length === 0) {
|
|
195
195
|
throw new Error("LSP rename returned no edits");
|
|
196
196
|
}
|
|
197
|
+
// 先逐个文件审批、再逐个写盘:用户在第二个文件上拒绝时不该留下半个 rename。
|
|
198
|
+
// 变更前后内容已由 expandWorkspaceEdit 在内存里算好,审批框展示的就是要写入的
|
|
199
|
+
// 内容;对话框停留期间的外部改动由写保护的指纹校验兜住。
|
|
197
200
|
for (const fileEdit of applied) {
|
|
198
201
|
await guardWriteAccess(ctx, {
|
|
199
202
|
toolName: "lsp-rename",
|
|
200
203
|
absolutePath: fileEdit.path,
|
|
201
|
-
|
|
204
|
+
mutation: { contentOld: fileEdit.oldText, contentNew: fileEdit.newText },
|
|
202
205
|
policy: options.policy,
|
|
203
206
|
signal,
|
|
204
207
|
});
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -11,16 +11,17 @@
|
|
|
11
11
|
* - `/bwrap-deny-request` (non-sandbox request policy) refuses outside writes
|
|
12
12
|
* outright, with the same error as the user picking "Block".
|
|
13
13
|
*
|
|
14
|
-
* Callers have already parsed their tool arguments
|
|
15
|
-
* resolved pieces: the raw target path and the
|
|
14
|
+
* Callers have already parsed their tool arguments and computed the result, so the
|
|
15
|
+
* guard takes the resolved pieces: the raw target path and the mutation (the file's
|
|
16
|
+
* content before and after). The preview is rendered from those two strings, so the
|
|
17
|
+
* approved diff is the change that gets written.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
19
20
|
import { basename, isAbsolute, relative, sep } from "node:path";
|
|
20
21
|
|
|
21
22
|
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
+
import { digestIfExists, snapshotOf } from "./file-reads.js";
|
|
24
25
|
import { fenceCodeBlock } from "./markdown.js";
|
|
25
26
|
import type { RequestPolicy } from "./request-policy.js";
|
|
26
27
|
|
|
@@ -57,53 +58,53 @@ function wrapDiff(patch: string): string {
|
|
|
57
58
|
return fenceCodeBlock(body, "diff");
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 待审批的写入:内容由调用方先读盘并算好,批准后按 `contentNew` 落盘。
|
|
63
|
+
*
|
|
64
|
+
* 两份内容都是文件原始内容(保留 BOM 与行尾),不是匹配引擎的中间产物——这样
|
|
65
|
+
* 预览就是磁盘前后的真实对照,批准后的指纹校验也能与磁盘字节逐字节对上。
|
|
66
|
+
*/
|
|
67
|
+
export interface FileMutation {
|
|
68
|
+
/** 变更前的完整文件内容(文件不存在时为 "")。 */
|
|
69
|
+
readonly contentOld: string;
|
|
70
|
+
/** 变更后的完整内容——即批准后写进磁盘的字节。 */
|
|
71
|
+
readonly contentNew: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 预览统一按 LF 渲染:CRLF 文件的 diff 每行都带 \r,在对话框里显示成乱码。 */
|
|
75
|
+
function normalizeToLF(text: string): string {
|
|
76
|
+
return text.replaceAll("\r\n", "\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 待审批写入的 diff 代码块:由将落盘的内容直接算出,不做第二次匹配。 */
|
|
80
|
+
export function renderMutationPreview(resolvedPath: string, mutation: FileMutation): string {
|
|
81
|
+
return wrapDiff(
|
|
82
|
+
generateUnifiedPatch(
|
|
83
|
+
basename(resolvedPath),
|
|
84
|
+
normalizeToLF(mutation.contentOld),
|
|
85
|
+
normalizeToLF(mutation.contentNew),
|
|
86
|
+
2,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
68
89
|
}
|
|
69
90
|
|
|
91
|
+
/** 空内容的指纹:文件不存在与空文件在审批视角下等价。 */
|
|
92
|
+
const EMPTY_DIGEST = snapshotOf("").digest;
|
|
93
|
+
|
|
70
94
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
95
|
+
* 批准后重新取一次磁盘指纹:用户停留在对话框上的这段时间文件可能被外部改动,
|
|
96
|
+
* 此时按审批所用的旧内容算出的 `contentNew` 会覆盖别人的改动。
|
|
73
97
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let oldContent = "";
|
|
79
|
-
try {
|
|
80
|
-
oldContent = await readFile(resolvedPath, "utf8");
|
|
81
|
-
} catch {
|
|
82
|
-
// Unreadable or missing file: treat as empty so writes show as full additions.
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (change.oldText === "") {
|
|
86
|
-
// Whole-file write: show the full addition/replacement patch.
|
|
87
|
-
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, change.newText, 2));
|
|
98
|
+
async function requireApprovedContentStillCurrent(opts: WriteGuardOptions): Promise<void> {
|
|
99
|
+
const { mutation } = opts;
|
|
100
|
+
if (mutation === undefined) {
|
|
101
|
+
return;
|
|
88
102
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const applied = applyEdit(oldContent, change.oldText, change.newText, change.replaceAll);
|
|
95
|
-
return wrapDiff(
|
|
96
|
-
generateUnifiedPatch(
|
|
97
|
-
basename(resolvedPath),
|
|
98
|
-
normalizeToLF(applied.contentOld),
|
|
99
|
-
normalizeToLF(applied.contentNew),
|
|
100
|
-
2,
|
|
101
|
-
),
|
|
103
|
+
const current = (await digestIfExists(opts.absolutePath)) ?? EMPTY_DIGEST;
|
|
104
|
+
if (current !== snapshotOf(mutation.contentOld).digest) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
|
|
102
107
|
);
|
|
103
|
-
} catch {
|
|
104
|
-
const removed = change.oldText.split("\n").map((line) => `-${line}`);
|
|
105
|
-
const added = change.newText.split("\n").map((line) => `+${line}`);
|
|
106
|
-
return wrapDiff([...removed, ...added].join("\n"));
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -130,9 +131,10 @@ export interface WriteGuardOptions {
|
|
|
130
131
|
/** The resolved absolute target path (caller has already parsed its args). */
|
|
131
132
|
absolutePath: string;
|
|
132
133
|
/**
|
|
133
|
-
*
|
|
134
|
+
* 待审批的写入(变更前后的完整内容);缺省时审批对话框不展示 diff 预览,
|
|
135
|
+
* 仅按路径审批(流式落盘的工具用不上它)。
|
|
134
136
|
*/
|
|
135
|
-
|
|
137
|
+
mutation?: FileMutation;
|
|
136
138
|
/** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
|
|
137
139
|
policy: RequestPolicy;
|
|
138
140
|
/** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
|
|
@@ -175,7 +177,8 @@ export async function guardWriteAccess(
|
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
for (;;) {
|
|
178
|
-
const diffPreview =
|
|
180
|
+
const diffPreview =
|
|
181
|
+
opts.mutation === undefined ? undefined : renderMutationPreview(absolutePath, opts.mutation);
|
|
179
182
|
const title =
|
|
180
183
|
`Model requests write access outside workspace:\n\n` +
|
|
181
184
|
` Tool: ${opts.toolName}\n` +
|
|
@@ -191,6 +194,7 @@ export async function guardWriteAccess(
|
|
|
191
194
|
throw new Error(`user deny ${opts.toolName}: cancelled`);
|
|
192
195
|
}
|
|
193
196
|
if (choice === "Approve once") {
|
|
197
|
+
await requireApprovedContentStillCurrent(opts);
|
|
194
198
|
return;
|
|
195
199
|
}
|
|
196
200
|
if (choice === "Block") {
|
package/src/opencode/files.ts
CHANGED
|
@@ -632,14 +632,6 @@ function registerEditTool(
|
|
|
632
632
|
|
|
633
633
|
const absolutePath = isAbsolute(filePath) ? filePath : resolvePath(ctx.cwd, filePath);
|
|
634
634
|
|
|
635
|
-
await guardWriteAccess(ctx, {
|
|
636
|
-
toolName: "edit",
|
|
637
|
-
absolutePath,
|
|
638
|
-
change: { oldText: oldString, newText: newString, replaceAll },
|
|
639
|
-
policy,
|
|
640
|
-
signal,
|
|
641
|
-
});
|
|
642
|
-
|
|
643
635
|
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
644
636
|
absolutePath,
|
|
645
637
|
async () => {
|
|
@@ -664,6 +656,14 @@ function registerEditTool(
|
|
|
664
656
|
);
|
|
665
657
|
}
|
|
666
658
|
// opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
|
|
659
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
660
|
+
await guardWriteAccess(ctx, {
|
|
661
|
+
toolName: "edit",
|
|
662
|
+
absolutePath,
|
|
663
|
+
mutation: { contentOld: "", contentNew: newString },
|
|
664
|
+
policy,
|
|
665
|
+
signal,
|
|
666
|
+
});
|
|
667
667
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
668
668
|
signal?.throwIfAborted();
|
|
669
669
|
await writeFile(absolutePath, newString, "utf8");
|
|
@@ -707,6 +707,13 @@ function registerEditTool(
|
|
|
707
707
|
|
|
708
708
|
const applied = applyEdit(rawContent, oldString, newString, replaceAll);
|
|
709
709
|
signal?.throwIfAborted();
|
|
710
|
+
await guardWriteAccess(ctx, {
|
|
711
|
+
toolName: "edit",
|
|
712
|
+
absolutePath,
|
|
713
|
+
mutation: { contentOld: rawContent, contentNew: applied.finalContent },
|
|
714
|
+
policy,
|
|
715
|
+
signal,
|
|
716
|
+
});
|
|
710
717
|
await writeFile(absolutePath, applied.finalContent, "utf8");
|
|
711
718
|
const snapshot = snapshotOf(applied.finalContent);
|
|
712
719
|
state.reads.set(key, snapshot);
|
|
@@ -797,13 +804,6 @@ function registerWriteTool(
|
|
|
797
804
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
798
805
|
const { filePath: rawPath, content } = params;
|
|
799
806
|
const absolutePath = resolvePath(ctx.cwd, rawPath);
|
|
800
|
-
await guardWriteAccess(ctx, {
|
|
801
|
-
toolName: "write",
|
|
802
|
-
absolutePath,
|
|
803
|
-
change: { oldText: "", newText: content },
|
|
804
|
-
policy,
|
|
805
|
-
signal,
|
|
806
|
-
});
|
|
807
807
|
const dir = dirname(absolutePath);
|
|
808
808
|
|
|
809
809
|
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
@@ -819,29 +819,31 @@ function registerWriteTool(
|
|
|
819
819
|
}
|
|
820
820
|
|
|
821
821
|
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
822
|
-
// 否则用新内容自带的 BOM
|
|
822
|
+
// 否则用新内容自带的 BOM。旧内容整读一次,既是 BOM 的来源,也是审批预览
|
|
823
|
+
// 的「变更前内容」。
|
|
823
824
|
let existing: Buffer | undefined;
|
|
824
825
|
try {
|
|
825
|
-
|
|
826
|
-
try {
|
|
827
|
-
existing = Buffer.alloc(3);
|
|
828
|
-
const { bytesRead } = await fh.read(existing, 0, 3, 0);
|
|
829
|
-
if (bytesRead < 3) {
|
|
830
|
-
existing = undefined;
|
|
831
|
-
}
|
|
832
|
-
} finally {
|
|
833
|
-
await fh.close();
|
|
834
|
-
}
|
|
826
|
+
existing = await readFile(absolutePath);
|
|
835
827
|
} catch {
|
|
836
|
-
//
|
|
828
|
+
// 文件不存在:无旧文件
|
|
837
829
|
}
|
|
838
830
|
const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
|
|
831
|
+
const finalContent = desiredBom + nextText;
|
|
832
|
+
|
|
833
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
834
|
+
await guardWriteAccess(ctx, {
|
|
835
|
+
toolName: "write",
|
|
836
|
+
absolutePath,
|
|
837
|
+
mutation: { contentOld: existing?.toString("utf8") ?? "", contentNew: finalContent },
|
|
838
|
+
policy,
|
|
839
|
+
signal,
|
|
840
|
+
});
|
|
839
841
|
|
|
840
842
|
await mkdir(dir, { recursive: true });
|
|
841
843
|
signal?.throwIfAborted();
|
|
842
|
-
await writeFile(absolutePath,
|
|
844
|
+
await writeFile(absolutePath, finalContent, "utf8");
|
|
843
845
|
// 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
|
|
844
|
-
const snapshot = snapshotOf(
|
|
846
|
+
const snapshot = snapshotOf(finalContent);
|
|
845
847
|
state.reads.set(key, snapshot);
|
|
846
848
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
847
849
|
signal,
|