feat: 安全创建与修改工作区文件

This commit is contained in:
李岩岩 2026-08-14 15:48:49 +08:00
parent 944a09ce2b
commit 6f029c49ab
9 changed files with 346 additions and 12 deletions

View File

@ -44,10 +44,10 @@
状态: "已完成"
- 编号: "F-007"
名称: "文件创建与安全修改"
状态: "进行中"
状态: "已完成"
- 编号: "F-008"
名称: "会话管理与个人设置"
状态: "待开始"
状态: "进行中"
- 编号: "F-009"
名称: "Claude Desktop 视觉与交互收口"
状态: "待开始"

View File

@ -276,6 +276,8 @@ function toolName(name: string): string {
list_directory: "浏览文件",
search_files: "搜索文件",
read_text_file: "读取文件",
create_text_file: "创建文件",
apply_text_patch: "修改文件",
} as Record<string, string>
)[name] ?? name
);

View File

@ -89,13 +89,17 @@
### F-007——文件创建与安全修改
- 状态:进行中
- 状态:已完成Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
- 用户可见结果Agent 在正确工作区内创建和安全修改文本文件。
- 主要验收AC-008、AC-009、AC-023、AC-028。
- 实现结果:新增 `create_text_file``apply_text_patch` 两个模型工具。新建文件使用排他写入,目标已存在时不会覆盖;修改必须携带最近一次 `read_text_file` 返回的 SHA-256 内容哈希,并使用 1 至 50 项精确文本替换。待替换文本不存在、默认模式下出现多次、文件已被外部改动或修改没有产生变化时均明确失败。
- 写入安全:新文件先解析真实父目录并再次检查工作区边界,阻止通过目录符号链接写到外部;已有文件修改在同目录写临时文件、保留原权限并原子替换,替换前再次核对内容哈希。继续限制 UTF-8 文本和 2 MiB 上限,不增加删除、移动、批量覆盖或 Shell 能力。
- 页面结果:创建和修改与其他文件工具共用已有工具状态卡片,分别显示“创建文件”和“修改文件”;成功与失败仍使用 `tool.completed``tool.failed`,不会混入主要回复消息或用户交互卡片。
- 自动化验证结果2026-08-14 通过全仓类型检查、31 项测试和 109 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖排他创建、哈希修改、过期哈希、缺失替换、歧义替换、路径穿越及目录符号链接逃逸。
### F-008——会话管理与个人设置
- 状态:待开始
- 状态:进行中
- 用户可见结果:重命名和删除会话,查看并修改非敏感设置。
- 主要验收AC-003、AC-018。

View File

@ -36,6 +36,9 @@ export type {
WorkspaceFileInfo,
WorkspacePort,
WorkspaceSearchMatch,
WorkspaceTextFile,
WorkspaceWriteResult,
TextReplacement,
} from "./ports/workspace-port";
export {
WorkspaceService,

View File

@ -20,9 +20,36 @@ export type WorkspaceFileInfo = Readonly<{
readableAsText: boolean;
}>;
export type WorkspaceTextFile = WorkspaceFileInfo &
Readonly<{ content: string; hash: string }>;
export type TextReplacement = Readonly<{
oldText: string;
newText: string;
replaceAll?: boolean;
}>;
export type WorkspaceWriteResult = Readonly<{
path: string;
hash: string;
bytes: number;
summary: string;
}>;
export interface WorkspacePort {
list(root: string, path?: string): Promise<readonly WorkspaceEntry[]>;
search(root: string, query: string): Promise<readonly WorkspaceSearchMatch[]>;
inspect(root: string, path: string): Promise<WorkspaceFileInfo>;
readText(root: string, path: string): Promise<string>;
readText(root: string, path: string): Promise<WorkspaceTextFile>;
createText(
root: string,
path: string,
content: string,
): Promise<WorkspaceWriteResult>;
applyTextPatch(
root: string,
path: string,
expectedHash: string,
replacements: readonly TextReplacement[],
): Promise<WorkspaceWriteResult>;
}

View File

@ -31,7 +31,8 @@ export class WorkspaceService {
}
async read(context: WorkspaceContext, path: string) {
return this.files.readText(await this.resolveRoot(context), path);
return (await this.files.readText(await this.resolveRoot(context), path))
.content;
}
async createAttachments(
@ -108,7 +109,29 @@ export class WorkspaceService {
await this.search(context, readString(input, "query")),
);
if (name === "read_text_file")
return await this.read(context, readString(input, "path"));
return JSON.stringify(
await this.files.readText(
await this.resolveRoot(context),
readString(input, "path"),
),
);
if (name === "create_text_file")
return JSON.stringify(
await this.files.createText(
await this.resolveRoot(context),
readString(input, "path"),
readString(input, "content", true),
),
);
if (name === "apply_text_patch")
return JSON.stringify(
await this.files.applyTextPatch(
await this.resolveRoot(context),
readString(input, "path"),
readString(input, "expectedHash"),
readReplacements(input.replacements),
),
);
throw new CoreError("TOOL_NOT_SUPPORTED", "模型请求了尚未支持的工具");
}
@ -138,6 +161,30 @@ export class WorkspaceService {
}
}
function readReplacements(value: unknown) {
if (!Array.isArray(value) || value.length === 0 || value.length > 50)
throw new CoreError(
"TOOL_ARGUMENTS_INVALID",
"文本修改必须包含 1 至 50 项替换",
);
return value.map((item) => {
if (!item || typeof item !== "object")
throw new CoreError("TOOL_ARGUMENTS_INVALID", "文本替换格式无效");
const input = item as Record<string, unknown>;
if (typeof input.oldText !== "string" || !input.oldText)
throw new CoreError("TOOL_ARGUMENTS_INVALID", "待替换文本不能为空");
if (typeof input.newText !== "string")
throw new CoreError("TOOL_ARGUMENTS_INVALID", "替换后的文本格式无效");
if (input.replaceAll !== undefined && typeof input.replaceAll !== "boolean")
throw new CoreError("TOOL_ARGUMENTS_INVALID", "replaceAll 必须是布尔值");
return {
oldText: input.oldText,
newText: input.newText,
...(input.replaceAll === true ? { replaceAll: true } : {}),
};
});
}
async function mapAttachments(
conversation: Conversation,
transform: (

View File

@ -37,6 +37,49 @@ export const workspaceTools: readonly ModelTool[] = [
additionalProperties: false,
},
},
{
name: "create_text_file",
description:
"在当前会话工作区内创建新的 UTF-8 文本文件;文件已存在时失败。",
parameters: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" },
},
required: ["path", "content"],
additionalProperties: false,
},
},
{
name: "apply_text_patch",
description:
"使用读取文件时得到的 SHA-256 哈希,对文本执行精确替换。默认每项旧文本必须只出现一次。",
parameters: {
type: "object",
properties: {
path: { type: "string" },
expectedHash: { type: "string" },
replacements: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
oldText: { type: "string" },
newText: { type: "string" },
replaceAll: { type: "boolean" },
},
required: ["oldText", "newText"],
additionalProperties: false,
},
},
},
required: ["path", "expectedHash", "replacements"],
additionalProperties: false,
},
},
];
export function isWorkspaceTool(name: string): boolean {

View File

@ -1,7 +1,18 @@
import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
import {
access,
readFile,
readdir,
realpath,
rename,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { constants } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import {
basename,
dirname,
extname,
isAbsolute,
relative,
@ -10,11 +21,14 @@ import {
} from "node:path";
import {
CoreError,
type TextReplacement,
type WorkspaceEntry,
type WorkspaceFileInfo,
type WorkspacePort,
type WorkspaceResolverPort,
type WorkspaceSearchMatch,
type WorkspaceTextFile,
type WorkspaceWriteResult,
} from "@great-agent/agent-core";
export class LocalWorkspaceResolver implements WorkspaceResolverPort {
@ -88,7 +102,7 @@ export class LocalWorkspaceFiles implements WorkspacePort {
if (matches.length >= 200) break;
try {
const content = await this.readText(root, path);
const lines = content.split("\n");
const lines = content.content.split("\n");
let count = 0;
for (let index = 0; index < lines.length && count < 20; index++) {
if (!lines[index]?.toLocaleLowerCase().includes(normalized)) continue;
@ -124,7 +138,7 @@ export class LocalWorkspaceFiles implements WorkspacePort {
};
}
async readText(root: string, path: string): Promise<string> {
async readText(root: string, path: string): Promise<WorkspaceTextFile> {
const info = await this.inspect(root, path);
if (info.size > MAX_READ_BYTES)
throw new CoreError("FILE_TOO_LARGE", "文件超过 2 MiB暂不支持读取");
@ -133,7 +147,70 @@ export class LocalWorkspaceFiles implements WorkspacePort {
"FILE_TYPE_UNSUPPORTED",
"该文件不是支持的 UTF-8 文本",
);
return readFile(await safeExistingPath(root, path), "utf8");
const content = await readFile(await safeExistingPath(root, path), "utf8");
return { ...info, content, hash: hash(content) };
}
async createText(
root: string,
path: string,
content: string,
): Promise<WorkspaceWriteResult> {
validateContent(content);
const target = await safeNewPath(root, path);
try {
await writeFile(target, content, { encoding: "utf8", flag: "wx" });
} catch (cause) {
if (hasCode(cause, "EEXIST"))
throw new CoreError("FILE_ALREADY_EXISTS", "文件已存在,不能覆盖创建");
throw new CoreError("FILE_WRITE_FAILED", "新文件写入失败");
}
return writeResult(await realpath(root), target, content, "已创建文本文件");
}
async applyTextPatch(
root: string,
path: string,
expectedHash: string,
replacements: readonly TextReplacement[],
): Promise<WorkspaceWriteResult> {
const current = await this.readText(root, path);
if (!/^[a-f\d]{64}$/u.test(expectedHash) || current.hash !== expectedHash)
throw new CoreError(
"FILE_CHANGED",
"文件内容已发生变化,请重新读取后再修改",
);
let next = current.content;
for (const replacement of replacements)
next = applyReplacement(next, replacement);
validateContent(next);
if (next === current.content)
throw new CoreError("PATCH_NO_CHANGES", "文本修改没有产生变化");
const target = await safeExistingPath(root, path);
const temporary = resolve(
dirname(target),
`.${basename(target)}.${randomUUID()}.tmp`,
);
try {
const mode = (await stat(target)).mode;
await writeFile(temporary, next, { encoding: "utf8", flag: "wx", mode });
if ((await this.readText(root, path)).hash !== current.hash)
throw new CoreError(
"FILE_CHANGED",
"文件内容已发生变化,请重新读取后再修改",
);
await rename(temporary, target);
} catch (cause) {
await unlink(temporary).catch(() => {});
if (cause instanceof CoreError) throw cause;
throw new CoreError("FILE_WRITE_FAILED", "文件修改写入失败");
}
return writeResult(
await realpath(root),
target,
next,
`已完成 ${replacements.length} 项文本替换`,
);
}
}
@ -215,3 +292,74 @@ function mediaTypeFor(path: string): string {
};
return types[extname(path).toLocaleLowerCase()] ?? "application/octet-stream";
}
async function safeNewPath(root: string, input: string): Promise<string> {
validateRelativePath(input);
if (!input || input.endsWith("/") || input.endsWith("\\"))
throw new CoreError("FILE_PATH_INVALID", "新文件路径无效");
try {
const realRoot = await realpath(root);
const target = resolve(realRoot, input);
const realParent = await realpath(dirname(target));
if (!within(realRoot, realParent))
throw new CoreError(
"WORKSPACE_PATH_FORBIDDEN",
"不能访问工作区之外的路径",
);
if (!(await stat(realParent)).isDirectory())
throw new CoreError("WORKSPACE_NOT_DIRECTORY", "目标父路径不是文件夹");
return target;
} catch (cause) {
if (cause instanceof CoreError) throw cause;
throw new CoreError(
"FILE_PARENT_UNAVAILABLE",
"新文件的父目录不存在或无法访问",
);
}
}
function applyReplacement(
content: string,
replacement: TextReplacement,
): string {
const count = content.split(replacement.oldText).length - 1;
if (count === 0)
throw new CoreError("PATCH_TARGET_NOT_FOUND", "没有找到待替换文本");
if (!replacement.replaceAll && count !== 1)
throw new CoreError(
"PATCH_TARGET_AMBIGUOUS",
"待替换文本出现多次,需要明确允许全部替换",
);
return replacement.replaceAll
? content.split(replacement.oldText).join(replacement.newText)
: content.replace(replacement.oldText, replacement.newText);
}
function validateContent(content: string): void {
if (Buffer.byteLength(content, "utf8") > MAX_READ_BYTES)
throw new CoreError("FILE_TOO_LARGE", "文本内容超过 2 MiB");
if (content.includes("\0"))
throw new CoreError("FILE_TYPE_UNSUPPORTED", "文本内容不能包含空字节");
}
function hash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function writeResult(
root: string,
target: string,
content: string,
summary: string,
): WorkspaceWriteResult {
return {
path: toRelative(root, target),
hash: hash(content),
bytes: Buffer.byteLength(content, "utf8"),
summary,
};
}
function hasCode(cause: unknown, code: string): boolean {
return cause instanceof Error && "code" in cause && cause.code === code;
}

View File

@ -40,7 +40,9 @@ describe("LocalWorkspaceFiles", () => {
line: 2,
matchedBy: "content",
});
expect(await files.readText(root, "docs/说明.md")).toContain("第一行");
expect((await files.readText(root, "docs/说明.md")).content).toContain(
"第一行",
);
} finally {
await rm(root, { recursive: true });
}
@ -52,6 +54,7 @@ describe("LocalWorkspaceFiles", () => {
try {
await writeFile(join(outside, "secret.txt"), "secret");
await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt"));
await symlink(outside, join(root, "outside-directory"));
const files = new LocalWorkspaceFiles();
await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject(
{
@ -66,6 +69,9 @@ describe("LocalWorkspaceFiles", () => {
await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({
code: "WORKSPACE_PATH_FORBIDDEN",
});
await expect(
files.createText(root, "outside-directory/new.txt", "escape"),
).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN" });
} finally {
await rm(root, { recursive: true });
await rm(outside, { recursive: true });
@ -87,4 +93,58 @@ describe("LocalWorkspaceFiles", () => {
await rm(root, { recursive: true });
}
});
test("排他创建并使用内容哈希安全修改文本", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-write-"));
try {
const files = new LocalWorkspaceFiles();
const created = await files.createText(root, "notes.txt", "旧内容\n");
expect(created.path).toBe("notes.txt");
expect(created.hash).toHaveLength(64);
await expect(
files.createText(root, "notes.txt", "不能覆盖"),
).rejects.toMatchObject({ code: "FILE_ALREADY_EXISTS" });
const changed = await files.applyTextPatch(
root,
"notes.txt",
created.hash,
[{ oldText: "旧内容", newText: "新内容" }],
);
expect(changed.hash).not.toBe(created.hash);
expect((await files.readText(root, "notes.txt")).content).toBe(
"新内容\n",
);
await expect(
files.applyTextPatch(root, "notes.txt", created.hash, [
{ oldText: "新内容", newText: "危险覆盖" },
]),
).rejects.toMatchObject({ code: "FILE_CHANGED" });
} finally {
await rm(root, { recursive: true });
}
});
test("修改目标缺失或不唯一时不写文件", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-patch-"));
try {
await writeFile(join(root, "repeat.txt"), "相同\n相同\n");
const files = new LocalWorkspaceFiles();
const current = await files.readText(root, "repeat.txt");
await expect(
files.applyTextPatch(root, "repeat.txt", current.hash, [
{ oldText: "不存在", newText: "内容" },
]),
).rejects.toMatchObject({ code: "PATCH_TARGET_NOT_FOUND" });
await expect(
files.applyTextPatch(root, "repeat.txt", current.hash, [
{ oldText: "相同", newText: "内容" },
]),
).rejects.toMatchObject({ code: "PATCH_TARGET_AMBIGUOUS" });
expect((await files.readText(root, "repeat.txt")).content).toBe(
"相同\n相同\n",
);
} finally {
await rm(root, { recursive: true });
}
});
});