diff --git a/.memory/edit-file-symlink-race-20261015.md b/.memory/edit-file-symlink-race-20261015.md new file mode 100644 index 000000000..ae76778d2 --- /dev/null +++ b/.memory/edit-file-symlink-race-20261015.md @@ -0,0 +1,22 @@ +# Parent edit_file symlink-swap fix — 2026-10-15 + +Issue #423: the parent tool previously checked a canonical path, then used path-based `fs.writeFile` after a test seam. A target or ancestor symlink swap could redirect the final write outside the pinned workspace. + +The parent tool now reads an opened regular file, records its exact device/inode and SHA-256, and passes the canonical root-relative path to the existing packaged cross-platform file-mutator helper. Its new `edit-file` operation opens the pinned root and every relative directory component with no-follow descriptors, checks the opened file's identity and content, then truncates and writes only through that descriptor. The ordinary in-root symlink policy remains: TypeScript resolves a stable link to its in-root target before the native traversal. Multiply linked files fail closed so an outside hard link cannot be changed. The helper accepts up to 256 MiB, beyond the existing subagent protocol's 200 KB cap. + +The security claim is limited to preventing a symlink/path replacement from redirecting the write to a different inode. A local actor can rename the already-open file or its parent outside the root after the native name check; the descriptor then still writes that original inode at its new location. This is not a guarantee that every inode modified by the tool remains reachable inside the workspace throughout the write. Such a stronger guarantee would require control of concurrent namespace mutation, not another path recheck or a held parent descriptor. + +The high-level tool test swaps the target or an ancestor between validation and commit and checks that the outside file is unchanged. Native tests exercise the same no-follow rule on macOS and Linux, and a separate test covers a stable in-root link and a file above 200 KB. The root test lane now builds the helper before coding-tools tests; the checked-in helper binaries and manifests were refreshed for macOS universal, Linux x64, and Linux arm64. No shared server protocol, native client, UI, onboarding, or plan status changed. + +Local verification: native mutator suite, CI test registry, native prebuilt manifest suite, scoped ESLint, and TypeScript type-check pass. The full coding-tools suite had one independent glob/native-glob comparison failure on `**/*`; the new edit tests passed. Hosted exact-head checks and review remain PR delivery gates. + +A review of PR #501 found the standalone CLI Bot route used the desktop development fallback (`/build/native/…`) for the new native edit operation. The CLI now passes the helper staged beside `dist/app/cli.js` through the Bot file router and pinned tool factory. The packaged parity test changes into an external temporary project with an executable decoy at the old fallback path, edits a Bot file successfully, and verifies that the decoy did not run. CLI build, CLI and root type checks, scoped lint, and focused desktop and Bot tests passed. +The full CLI suite passed 99/99 tests after the helper lookup fix. The Bot file router suite passed 8/8, and the focused edit tests passed 4/4. PR #492 remained open when this update was made. + +After PR #492 merged as c68462b97, merged origin/main into PR #501 as 46f30b2ce. The combined `edit_file` uses `indexOf`/slices for literal replacement while retaining the opened-file identity/hash checks and descriptor-bound native commit. On the merge head, focused edit/literal tests passed 5/5, Bot router tests 8/8, the CLI build/type-check and full CLI suite passed 99/99, and root type-check and scoped lint passed. The complete coding-tools suite passed 61/62; its sole failure is the previously documented independent foreground/native glob `**/*` mismatch. + +Main advanced again through unrelated PR #491 to dd2c0d03c. Merged it into PR #501 as 6fff24413 without conflicts. The shared `package.json` test chain retained both branches' scripts; CI registry policy tests passed 62/62. The descriptor-bound edit and literal replacement remained intact. Focused edit/literal tests passed 5/5, Bot router tests 8/8, the rebuilt CLI suite passed 99/99, both root and CLI type checks passed, and scoped lint passed. The earlier full coding-tools glob mismatch remains documented above; no edit test failed. + +The exact-head CI run 38825795960 for b5a6ba37 had Electron E2E shard 3 fail on three #491-origin tests: draft-agent-chats.spec.ts:205 failed twice because the workspace header remained keyboard focused and the plus button's intentional group-focus-within style kept opacity at 1, while chat-top-bar.spec.ts:236 and :327 failed once waiting for the worktree-send failure UI and passed retry. Both test and UI sources match main; this is classified as inherited baseline behavior, not an edit-path integration defect. The PR body records exact specs, symptoms, and run link. The run was still active when main advanced to #497, so no failed-job rerun was used on the stale head. Merged latest main 17d51d472 into PR #501 as dee27de64; this merge brought only Android simulator changes. Focused edit/literal tests passed 5/5, CLI Bot parity passed 1/1, and root type-check passed. + +The separate #491 baseline test fix PR #506 merged to main as 896f31ae1. Merged that current main into PR #501 as b93b0b1e1 without conflicts; it changed the E2E focus expectation and native-client stream recovery, not the edit path or package scripts. On this merged checkout, the production build, root and E2E type checks, focused edit/literal tests (5/5), CLI Bot parity (1/1), and the corrected sidebar plus two worktree-send E2E specs repeated twice each (6/6 with fail-on-flaky-tests) passed. diff --git a/main/services/bot-file-tool-router.ts b/main/services/bot-file-tool-router.ts index 6e6b0bbaa..5a652d105 100644 --- a/main/services/bot-file-tool-router.ts +++ b/main/services/bot-file-tool-router.ts @@ -38,6 +38,8 @@ export interface BotFileToolRouterOptions { * Defaults to the process-wide roots (`setBotFileToolProtectedRoots`). */ readonly protectedRoots?: () => readonly string[]; + /** Installation-owned native helper used for edit_file. */ + readonly fileMutatorBinary?: string; } /** File tools that change a file; only these are fenced from protected roots. */ @@ -146,11 +148,12 @@ function checkedLocation(location: BotFileToolLocation, role: string): BotFileTo }); } -function underlyingTools(location: BotFileToolLocation): ReadonlyMap { +function underlyingTools(location: BotFileToolLocation, fileMutatorBinary?: string): ReadonlyMap { const selected = buildPinnedCodingTools( location.root, undefined, location.expectedIdentity, + { fileMutatorBinary }, ).filter((tool) => TOOL_NAMES.has(tool.name)); const map = new Map(); for (const tool of selected) { @@ -252,7 +255,7 @@ export function buildBotFileTools(options: BotFileToolRouterOptions): AgentTool[ const prepared = locations.map((location) => ({ ...location, - tools: underlyingTools(location), + tools: underlyingTools(location, options.fileMutatorBinary), })); const byId = new Map(prepared.map((location) => [location.id, location] as const)); const schemaLocations = prepared.map(({ id, label }) => ({ id, label })); diff --git a/main/services/coding-tools.test.ts b/main/services/coding-tools.test.ts index 2ff913d7a..8435cec7d 100644 --- a/main/services/coding-tools.test.ts +++ b/main/services/coding-tools.test.ts @@ -13,6 +13,7 @@ import { promisify } from "node:util"; import test from "node:test"; import { buildCodingTools, + buildPinnedCodingTools, buildSubagentCodingTools, DISCLOSURE_APPROVAL_TOOL_NAMES, runCommandEnv, @@ -1330,6 +1331,77 @@ test("filesystem tools do not follow workspace symlinks outside the root", async } }); +test("pinned edit_file cannot overwrite an outside file after a target or ancestor swap", async () => { + for (const swappedPart of ["target", "ancestor"] as const) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-edit-root-")); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-edit-outside-")); + try { + const directory = path.join(root, "nested"); + await fs.mkdir(directory); + const target = path.join(directory, "edit.txt"); + const outsideFile = path.join(outside, "edit.txt"); + await fs.writeFile(target, "before\n"); + await fs.writeFile(outsideFile, "outside\n"); + const edit = buildPinnedCodingTools(root, { + beforeWriteCommit: async () => { + if (swappedPart === "target") { + await fs.rename(target, path.join(directory, "original.txt")); + await fs.symlink(outsideFile, target); + } else { + await fs.rename(directory, path.join(root, "original-directory")); + await fs.symlink(outside, directory); + } + }, + }).find((tool) => tool.name === "edit_file"); + assert.ok(edit); + await assert.rejects( + edit.execute("swap", { path: "nested/edit.txt", old_string: "before", new_string: "after" }), + /changed before the edit could be committed/u, + ); + assert.equal(await fs.readFile(outsideFile, "utf8"), "outside\n"); + const original = swappedPart === "target" + ? path.join(directory, "original.txt") + : path.join(root, "original-directory", "edit.txt"); + assert.equal(await fs.readFile(original, "utf8"), "before\n"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); + } + } +}); + +test("pinned edit_file follows a stable in-root file symlink", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-edit-in-root-")); + try { + const target = path.join(root, "actual.txt"); + const alias = path.join(root, "alias.txt"); + await fs.writeFile(target, "before\n"); + await fs.symlink(target, alias); + const edit = buildPinnedCodingTools(root).find((tool) => tool.name === "edit_file"); + assert.ok(edit); + await edit.execute("alias", { path: "alias.txt", old_string: "before", new_string: "after" }); + assert.equal(await fs.readFile(target, "utf8"), "after\n"); + assert.equal((await fs.lstat(alias)).isSymbolicLink(), true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("pinned edit_file keeps ordinary files above the subagent content cap editable", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-edit-large-")); + try { + const target = path.join(root, "large.txt"); + const original = `${"x".repeat(220_000)}before\n`; + await fs.writeFile(target, original); + const edit = buildPinnedCodingTools(root).find((tool) => tool.name === "edit_file"); + assert.ok(edit); + await edit.execute("large", { path: "large.txt", old_string: "before", new_string: "after" }); + assert.equal(await fs.readFile(target, "utf8"), original.replace("before", "after")); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("run_command reports real exit failures through Pi and codemode without re-executing effects", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-command-outcome-")); try { diff --git a/main/services/coding-tools.ts b/main/services/coding-tools.ts index 2c56c53a8..26cb2df35 100644 --- a/main/services/coding-tools.ts +++ b/main/services/coding-tools.ts @@ -6,7 +6,8 @@ // Tool inputs use typebox schemas (pi's AgentTool.parameters), matching tools.ts. import { isImageAspectRatio } from "../../renderer/shared/image-generation/aspect-ratio.js"; -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { ForegroundReadOperations, ForegroundReadCleanupError, closeForegroundResource } from "./foreground-read-scope.js"; import { StringDecoder } from "node:string_decoder"; import { CodingToolMatchTimeout, withCodingToolGlob, withCodingToolMatcher } from "./coding-tool-matcher.js"; @@ -28,6 +29,7 @@ import type { RE2 as RE2Matcher } from "re2-wasm"; import { declarePiRuntimeReplay } from "./pi-runtime-tool.js"; import { boundedToolOutput } from "./tool-output-context.js"; import { parseProducedFile, type ProducedFile } from "../../renderer/shared/produced-file.js"; +import { resolveSubagentFileMutatorBinary } from "./subagents/subagent-file-mutator-io.js"; const MAX_READ_BYTES = 200_000; const MAX_OUTPUT_CHARS = 20_000; @@ -42,6 +44,7 @@ const MAX_GREP_ENTRIES = 10_000; const MAX_GREP_BYTES = 10 * 1024 * 1024; const MAX_GREP_DURATION_MS = 5_000; const MAX_SEARCH_PATTERN_CHARS = 1_000; +const MAX_PARENT_EDIT_BYTES = 256 * 1024 * 1024; const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".cache"]); let re2Constructor: typeof import("re2-wasm").RE2 | undefined; @@ -256,6 +259,7 @@ interface WorkspaceRootGuard { lexical: string; canonical: string | null; identity: Stats | null; + exactIdentity: PinnedWorkspaceRootIdentity | null; testObserver?: { beforeDirectoryOpen?(directoryPath: string): void | Promise; afterDirectoryOpen?(directoryPath: string): void | Promise; @@ -281,6 +285,7 @@ function createParentWorkspaceRoot( lexical: path.resolve(root), canonical: null, identity: null, + exactIdentity: null, testObserver, }; } @@ -293,17 +298,21 @@ function pinWorkspaceRoot( const lexical = path.resolve(root); const canonical = realpathSync(lexical); const identity = statSync(canonical); + const exactStat = statSync(canonical, { bigint: true }); + const exactIdentity = { + device: exactStat.dev.toString(), + inode: exactStat.ino.toString(), + }; if (!identity.isDirectory()) throw new Error("The workspace root is not a directory."); if (expectedIdentity) { - const exactIdentity = statSync(canonical, { bigint: true }); if ( - exactIdentity.dev.toString() !== expectedIdentity.device || - exactIdentity.ino.toString() !== expectedIdentity.inode + exactIdentity.device !== expectedIdentity.device || + exactIdentity.inode !== expectedIdentity.inode ) { throw new Error("The authorized workspace root changed before this generation started."); } } - return { lexical, canonical, identity, testObserver }; + return { lexical, canonical, identity, exactIdentity, testObserver }; } async function verifyWorkspaceRoot( @@ -1076,7 +1085,50 @@ function makeWriteFile(workspace: WorkspaceRootGuard): AgentTool { }; } -function makeEditFile(workspace: WorkspaceRootGuard): AgentTool { +async function commitPinnedEdit(input: { + binary?: string; + root: string; + rootIdentity: PinnedWorkspaceRootIdentity; + relativePath: string; + fileIdentity: PinnedWorkspaceRootIdentity; + originalSha256: string; + updated: Buffer; +}): Promise { + if (input.updated.length > MAX_PARENT_EDIT_BYTES) { + throw new Error("The edited file exceeds the safe write limit."); + } + const args = [ + "edit-file", + input.root, + input.rootIdentity.device, + input.rootIdentity.inode, + input.relativePath, + input.fileIdentity.device, + input.fileIdentity.inode, + input.originalSha256, + String(input.updated.length), + ]; + await new Promise((resolve, reject) => { + const child = execFile(input.binary ?? resolveSubagentFileMutatorBinary(), args, { + encoding: "utf8", + maxBuffer: 1024, + timeout: 120_000, + }, (error, stdout, stderr) => { + if (error || stdout !== "edited\n") { + reject(new Error(stderr.trim() === "conflict" + ? "The workspace file changed before the edit could be committed." + : "The workspace edit could not be completed safely.")); + } else { + resolve(); + } + }); + // The callback owns child errors, including an early native rejection. + child.stdin?.on("error", () => undefined); + child.stdin?.end(input.updated); + }); +} + +function makeEditFile(workspace: WorkspaceRootGuard, binary?: string): AgentTool { return { name: "edit_file", label: "Edit File", @@ -1099,8 +1151,24 @@ function makeEditFile(workspace: WorkspaceRootGuard): AgentTool { old_string: string; new_string: string; }; - const { full } = await resolveExistingInRoot(workspace, p, signal); - const original = await fs.readFile(full, "utf-8"); + const { root, full } = await resolveExistingInRoot(workspace, p, signal); + const handle = await fs.open( + full, + fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0), + ); + let source: Buffer; + let fileIdentity: PinnedWorkspaceRootIdentity; + try { + const stat = await handle.stat({ bigint: true }); + if (!stat.isFile() || stat.nlink !== 1n) { + throw new Error(`Path "${p}" is not an exclusively linked regular file.`); + } + source = await handle.readFile(); + fileIdentity = { device: stat.dev.toString(), inode: stat.ino.toString() }; + } finally { + await handle.close(); + } + const original = source.toString("utf8"); throwIfAborted(signal, "File edit cancelled."); const count = original.split(old_string).length - 1; if (count === 0) throw new Error(`old_string not found in ${p}.`); @@ -1112,8 +1180,24 @@ function makeEditFile(workspace: WorkspaceRootGuard): AgentTool { const updated = original.slice(0, start) + new_string + original.slice(start + old_string.length); const details = fileMutationDetails(original, updated); throwIfAborted(signal, "File edit cancelled."); - details.producedFile = parseProducedFile({ relativePath: path.relative(await fs.realpath(workspace.lexical), path.join(await fs.realpath(path.dirname(full)), path.basename(full))).split(path.sep).join("/"), operation: "edited", bytes: Buffer.byteLength(updated) }, "edit_file"); - await fs.writeFile(full, updated, "utf-8"); + const relativePath = path.relative(root, full).split(path.sep).join("/"); + const updatedBytes = Buffer.from(updated, "utf8"); + details.producedFile = parseProducedFile({ relativePath, operation: "edited", bytes: updatedBytes.length }, "edit_file"); + const rootStat = workspace.exactIdentity === null + ? await fs.stat(root, { bigint: true }) + : null; + await commitPinnedEdit({ + binary, + root, + rootIdentity: workspace.exactIdentity ?? { + device: rootStat!.dev.toString(), + inode: rootStat!.ino.toString(), + }, + relativePath, + fileIdentity, + originalSha256: createHash("sha256").update(source).digest("hex"), + updated: updatedBytes, + }); return fileMutationResult(`Edited ${p}.`, details); }, }; @@ -1793,6 +1877,8 @@ function makeParentGrep(workspace: WorkspaceRootGuard): AgentTool { */ export interface CodingToolOptions { pathPrefix?: string | (() => string | null | undefined); + /** Trusted native helper from the application installation, if packaged outside Electron. */ + fileMutatorBinary?: string; } export function runCommandEnv( @@ -1962,7 +2048,7 @@ function buildParentCodingToolSet(workspace: WorkspaceRootGuard, options: Coding declarePiRuntimeReplay(withForegroundReadScope(makeParentListDir(workspace)), "safe"), declarePiRuntimeReplay(withForegroundReadScope(makeParentGlob(workspace)), "safe"), declarePiRuntimeReplay(withForegroundReadScope(makeParentGrep(workspace)), "safe"), - declarePiRuntimeReplay(makeEditFile(workspace), "never"), + declarePiRuntimeReplay(makeEditFile(workspace, options.fileMutatorBinary), "never"), declarePiRuntimeReplay(makeWriteFile(workspace), "never"), declarePiRuntimeReplay(makeRunCommand(workspace, options), "never"), ]; @@ -1988,8 +2074,9 @@ export function buildPinnedCodingTools( testObserver?: WorkspaceRootGuard["testObserver"], /** Optional authority-proven identity captured before the tool set is built. */ expectedIdentity?: PinnedWorkspaceRootIdentity, + options: CodingToolOptions = {}, ): AgentTool[] { - return buildParentCodingToolSet(pinWorkspaceRoot(root, testObserver, expectedIdentity)); + return buildParentCodingToolSet(pinWorkspaceRoot(root, testObserver, expectedIdentity), options); } /** diff --git a/native/subagent-file-mutator/main.c b/native/subagent-file-mutator/main.c index d9cc80630..73484bd43 100644 --- a/native/subagent-file-mutator/main.c +++ b/native/subagent-file-mutator/main.c @@ -1790,7 +1790,149 @@ static int parse_identity(const char *value, uint64_t *result) { return 0; } +/* + * Parent edit_file keeps its in-place write semantics. Resolve the already + * canonicalized relative path below an exact root descriptor. Every component + * is opened with O_NOFOLLOW, so a late symlink cannot redirect the final open. + */ +static int open_edit_parent(int root_fd, const char *relative, + char leaf[NAME_MAX + 1]) { + size_t length = strlen(relative); + if (length == 0 || length > MAX_PATH_BYTES || relative[0] == '/' || + relative[length - 1] == '/') + return -1; + char parts[MAX_PATH_BYTES + 1]; + memcpy(parts, relative, length + 1); + int current = + openat(root_fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (current < 0) + return -1; + char *component = parts; + for (;;) { + char *slash = strchr(component, '/'); + if (slash != NULL) + *slash = '\0'; + size_t size = strlen(component); + if (size == 0 || size > NAME_MAX || strcmp(component, ".") == 0 || + strcmp(component, "..") == 0) { + close(current); + return -1; + } + if (slash == NULL) { + memcpy(leaf, component, size + 1); + return current; + } + int next = openat(current, component, + O_RDONLY | O_DIRECTORY | O_NONBLOCK | O_NOFOLLOW | + O_CLOEXEC); + close(current); + if (next < 0) + return -1; + current = next; + component = slash + 1; + } +} + +#define MAX_PARENT_EDIT_BYTES (256U * 1024U * 1024U) + +static int parent_edit_error(const char *reason) { + fprintf(stderr, "%s\n", reason); + return EXIT_FAILURE; +} + +static int parent_edit_file(int argc, char **argv) { + uint64_t root_device, root_inode, file_device, file_inode, bytes; + if (argc != 10 || argv[2][0] != '/' || + parse_identity(argv[3], &root_device) != 0 || + parse_identity(argv[4], &root_inode) != 0 || + parse_identity(argv[6], &file_device) != 0 || + parse_identity(argv[7], &file_inode) != 0 || + parse_identity(argv[9], &bytes) != 0 || + bytes > MAX_PARENT_EDIT_BYTES || !valid_hex_digest(argv[8])) + return parent_edit_error("invalid_input"); + + unsigned char *contents = malloc(bytes == 0 ? 1 : (size_t)bytes); + if (contents == NULL) + return parent_edit_error("io_failed"); + size_t received = 0; + while (received < (size_t)bytes) { + ssize_t count = read(STDIN_FILENO, contents + received, + (size_t)bytes - received); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) { + free(contents); + return parent_edit_error("invalid_input"); + } + received += (size_t)count; + } + unsigned char extra; + if (read(STDIN_FILENO, &extra, 1) != 0) { + free(contents); + return parent_edit_error("invalid_input"); + } + + int root_fd = + open(argv[2], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + struct stat root_identity; + if (root_fd < 0 || fstat(root_fd, &root_identity) != 0 || + !S_ISDIR(root_identity.st_mode) || + (uint64_t)root_identity.st_dev != root_device || + (uint64_t)root_identity.st_ino != root_inode) { + if (root_fd >= 0) + close(root_fd); + free(contents); + return parent_edit_error("conflict"); + } + char leaf[NAME_MAX + 1]; + int parent_fd = open_edit_parent(root_fd, argv[5], leaf); + close(root_fd); + if (parent_fd < 0) { + free(contents); + return parent_edit_error("conflict"); + } + int file_fd = openat(parent_fd, leaf, + O_RDWR | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); + struct stat before, after, named; + if (file_fd < 0 || fstat(file_fd, &before) != 0 || + !exclusive_regular(&before) || + (uint64_t)before.st_dev != file_device || + (uint64_t)before.st_ino != file_inode || + before.st_size < 0 || before.st_size > MAX_PARENT_EDIT_BYTES) { + if (file_fd >= 0) + close(file_fd); + close(parent_fd); + free(contents); + return parent_edit_error("conflict"); + } + char actual_sha256[SHA256_HEX_BYTES + 1]; + if (sha256_descriptor(file_fd, MAX_PARENT_EDIT_BYTES, actual_sha256, + &after) != 0 || + !same_read_identity(&before, &after) || + strcmp(actual_sha256, argv[8]) != 0 || + fstatat(parent_fd, leaf, &named, AT_SYMLINK_NOFOLLOW) != 0 || + !same_read_identity(&before, &named)) { + close(file_fd); + close(parent_fd); + free(contents); + return parent_edit_error("conflict"); + } + /* The pathname is never used for the write. A replacement after this + * check cannot cause an unrelated file to be opened or truncated. */ + int good = ftruncate(file_fd, 0) == 0 && + write_all(file_fd, contents, (size_t)bytes) == 0; + free(contents); + close(file_fd); + close(parent_fd); + if (!good) + return parent_edit_error("io_failed"); + puts("edited"); + return ferror(stdout) ? parent_edit_error("io_failed") : 0; +} + int main(int argc, char **argv) { + if (argc >= 2 && strcmp(argv[1], "edit-file") == 0) + return parent_edit_file(argc, argv); if (argc != 8 || strcmp(argv[1], "serve") != 0 || strcmp(argv[2], "--root") != 0 || strcmp(argv[4], "--device") != 0 || strcmp(argv[6], "--inode") != 0 || argv[3][0] != '/') diff --git a/package.json b/package.json index 40fec6c52..8ef5274e2 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", "test:stt-smoke": "AIDEN_STT_SMOKE=1 tsx --test scripts/stt-smoke.test.ts", "test": "node scripts/run-ci-tests.mjs --parallel", - "pretest:serial": "npm run build:worktree-remover && npm run build:worktree-file-io && npm run test:mcp && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:rich-links && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:gemini-live && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:tts && npm run test:sidebar && npm run test:transcript-polish && npm run test:acp", - "pretest:coverage": "npm run build:worktree-remover && npm run build:worktree-file-io && npm run build:subagent-run-store && npm run test:preflight && npm run test:ghostty-terminal && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:gemini-live && npm run test:subagents && npm run test:bots:coverage", + "pretest:serial": "npm run build:worktree-remover && npm run build:worktree-file-io && npm run build:subagent-file-mutator && npm run test:mcp && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:rich-links && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:gemini-live && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:tts && npm run test:sidebar && npm run test:transcript-polish && npm run test:acp", + "pretest:coverage": "npm run build:worktree-remover && npm run build:worktree-file-io && npm run build:subagent-file-mutator && npm run build:subagent-run-store && npm run test:preflight && npm run test:ghostty-terminal && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:gemini-live && npm run test:subagents && npm run test:bots:coverage", "test:preflight": "npm run test:mcp && npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/context-meter.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/composer-slash-palette.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/scroll-follow.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/components/streaming-markdown-reveal.test.tsx renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/context-pressure.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts renderer/shared/assistant-turn-stats.test.ts renderer/lib/sticky-section.test.ts", "test:chat-row-states": "tsx --test renderer/shared/chat-row-state.test.ts renderer/components/chat-row-status.test.tsx renderer/components/chat-row-context.test.tsx renderer/lib/chat-row-context.test.ts renderer/lib/use-mark-chat-read.test.ts main/services/chat-read-markers.test.ts", "test:sidebar": "tsx --test renderer/components/chat-sidebar.test.tsx renderer/lib/sidebar-workspace-groups.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/held-modifier-reveal.test.ts renderer/lib/sidebar-organization.test.ts renderer/components/sidebar-organize-menu.test.tsx renderer/components/sidebar-remote.test.tsx renderer/lib/sidebar-remote-groups.test.ts renderer/lib/hosts/peer-host-feed-state.test.ts renderer/lib/hosts/use-peer-host-sidebar.test.tsx", @@ -196,6 +196,7 @@ "test:studio-foundation": "tsx --test main/services/studio/feature-flags.test.ts renderer/lib/app-capabilities.test.ts renderer/shared/studio-routes.test.ts renderer/main/studio-capability-route.test.tsx renderer/components/sidebar-primary-nav.test.tsx main/services/custom-schemes-core.test.ts main/services/studio-assets/image-validation-core.test.ts main/services/studio-assets/store.test.ts main/services/studio-assets/delivery-core.test.ts main/services/studio-assets/request-handler.test.ts main/services/studio-assets/startup-core.test.ts renderer/canvas/canvas-keymap-core.test.ts renderer/canvas/canvas-controls.test.tsx", "test:custom-model-options": "tsx --test main/services/custom-model-options.test.ts renderer/components/settings/custom-model-options.test.tsx", "test:foreground-file-tools:electron": "node scripts/run-foreground-file-tools-smoke.mjs", + "pretest:tool-outputs": "npm run build:subagent-file-mutator", "test:tool-outputs": "tsx --test main/services/tool-output-store.test.ts main/services/coding-tools.test.ts main/services/mcp-tool-result.test.ts main/services/generation-timeline.test.ts main/services/chat-application-service.test.ts main/services/aiden-remote-protocol.test.ts renderer/components/activity-feed.test.tsx renderer/components/onboarding-flow.test.tsx", "test:chat-pull-requests": "tsx --test renderer/shared/chat-pull-requests.test.ts main/services/chat-pull-request-store.test.ts main/services/chat-pull-request-service.test.ts main/services/pull-request-current-resolver.test.ts main/services/pull-request-create-reconciliation.test.ts main/services/github-pull-request.test.ts main/services/github/github-credentials.test.ts main/services/github/github-api.test.ts main/services/github/github-request-gate.test.ts main/services/github/github-local-repository.test.ts main/services/github/github-pull-request-reader.test.ts main/services/github/github-replay.test.ts", "test:ci": "node scripts/run-ci-tests.mjs", diff --git a/packages/cli/src/bots.ts b/packages/cli/src/bots.ts index 412965145..448fd49a6 100644 --- a/packages/cli/src/bots.ts +++ b/packages/cli/src/bots.ts @@ -15,7 +15,7 @@ import { botMcpLiveToolMatchesGrant, exactBotMcpToolNames } from "../../../main/ import { mcpAgentToolDescriptor } from "../../../main/services/mcp-agent-tool-descriptor.js"; import { createCliMcpPool, mcpCredentialSignature } from "./mcp.ts"; import { createCliWebSearch } from "./extensions/web-search.ts"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { mkdir, lstat, chmod, rm } from "node:fs/promises"; import { watch } from "node:fs"; import { createHmac } from "node:crypto"; @@ -66,6 +66,10 @@ function checkpointFile(agentDir: string, name: string) { } export async function createCliBots(agentDir: string, daemon: ReturnType) { + const cliEntry = process.env.AIDEN_CLI_ENTRY; + const fileMutatorBinary = cliEntry + ? join(dirname(cliEntry), "native", "aiden-subagent-file-mutator") + : undefined; const directory = join(agentDir, "bot-service"), root = () => directory; await mkdir(directory, { recursive: true, mode: 0o700 }); const info = await lstat(directory); @@ -159,7 +163,7 @@ export async function createCliBots(agentDir: string, daemon: ReturnType tool.name === "run_command")!); const retained = await capabilities.getBotBinding(authority.botId); const snapshot = await catalog.snapshotForRuntime({ botId: authority.botId, signal: admission.signal, diff --git a/packages/cli/tests/parity.test.mjs b/packages/cli/tests/parity.test.mjs index 3dc13ce9b..8eca0f2eb 100644 --- a/packages/cli/tests/parity.test.mjs +++ b/packages/cli/tests/parity.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, statSync, utimesSync, renameSync, readdirSync, openSync, closeSync, ftruncateSync } from "node:fs"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync, statSync, utimesSync, renameSync, readdirSync, openSync, closeSync, ftruncateSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -441,6 +441,9 @@ test("Remote HTTPS serves private workspaces, persists turns once, replays SSE a test("Bots retain one-shot notice, exact Custom tools, revocation and independent rollback anchors", async (t) => { const dir = temporary(t), daemon = api.createDaemonChats(dir), requests = []; + const originalEntry = process.env.AIDEN_CLI_ENTRY; + process.env.AIDEN_CLI_ENTRY = join(pkg, "dist/app/cli.js"); + t.after(() => { if (originalEntry === undefined) delete process.env.AIDEN_CLI_ENTRY; else process.env.AIDEN_CLI_ENTRY = originalEntry; }); const server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) body += chunk; const parsed = JSON.parse(body); requests.push(parsed); @@ -471,6 +474,19 @@ test("Bots retain one-shot notice, exact Custom tools, revocation and independen const write = tools.find((tool) => tool.name === "write_file"); const result = await write.execute("one", { path: "note.txt", content: "Scoped" }); assert.ok(!result.isError); assert.equal(readFileSync(join(lease.authority.workingDirectory, "note.txt"), "utf8"), "Scoped"); + const decoyDirectory = join(dir, "build", "native"), decoyMarker = join(dir, "decoy-ran"); + mkdirSync(decoyDirectory, { recursive: true }); + const decoy = join(decoyDirectory, "aiden-subagent-file-mutator"); + writeFileSync(decoy, `#!/bin/sh\ntouch '${decoyMarker}'\nexit 1\n`); chmodSync(decoy, 0o755); + const originalCwd = process.cwd(); + process.chdir(dir); + try { + const edit = tools.find((tool) => tool.name === "edit_file"); + const edited = await edit.execute("edit-outside-repo", { path: "note.txt", old_string: "Scoped", new_string: "Edited" }); + assert.ok(!edited.isError); + } finally { process.chdir(originalCwd); } + assert.equal(readFileSync(join(lease.authority.workingDirectory, "note.txt"), "utf8"), "Edited"); + assert.equal(existsSync(decoyMarker), false); await assert.rejects(write.execute("escape", { path: "../escape.txt", content: "Denied" })); const turn = daemon.llmClient.beginChatTurn(chat.id, "bot-stream", "bot-owner"); assert.ok(turn); const generationEvents = []; diff --git a/prebuilt/native/darwin-universal/aiden-subagent-file-mutator b/prebuilt/native/darwin-universal/aiden-subagent-file-mutator index b5060c215..3f1685909 100755 Binary files a/prebuilt/native/darwin-universal/aiden-subagent-file-mutator and b/prebuilt/native/darwin-universal/aiden-subagent-file-mutator differ diff --git a/prebuilt/native/darwin-universal/manifest.json b/prebuilt/native/darwin-universal/manifest.json index 99f5c1b0d..c398c0762 100644 --- a/prebuilt/native/darwin-universal/manifest.json +++ b/prebuilt/native/darwin-universal/manifest.json @@ -1,6 +1,6 @@ { "target": "darwin-universal", - "builtAt": "2026-09-26T10:12:37.446Z", + "builtAt": "2026-10-15T21:36:40.400Z", "helpers": { "worktree-remover": { "sha256": "72f46591d67614b8125feb58a0d8ac11e794ad83ae917076bb135a49396cb7be", @@ -15,8 +15,8 @@ "source": "6679af2569fb7fea609dce1eedf99059481256f0e57c2bd7946aa59194d70575" }, "subagent-file-mutator": { - "sha256": "562abd96b17f4fd8c4fdacd4e65230c2fb44aa2b788c294a6f35dad97cc369c8", - "source": "9abc3f74bdaadf60c2e8f4921b3ad8ad12401308184cc84db092e9f072caf6cf" + "sha256": "5c559af5a6707b0dccbab03c4b1025e50c0c1ec1394cdf41dddf8c7ffbcac04e", + "source": "8bccc0ac0397463d78b48e488af89f738bfe056dc5241071408074c2c80836d6" } } } diff --git a/prebuilt/native/linux-arm64/aiden-subagent-file-mutator b/prebuilt/native/linux-arm64/aiden-subagent-file-mutator index 3e36f0fe5..ed8ad0a9b 100755 Binary files a/prebuilt/native/linux-arm64/aiden-subagent-file-mutator and b/prebuilt/native/linux-arm64/aiden-subagent-file-mutator differ diff --git a/prebuilt/native/linux-arm64/manifest.json b/prebuilt/native/linux-arm64/manifest.json index e85b46f15..a118f7c9c 100644 --- a/prebuilt/native/linux-arm64/manifest.json +++ b/prebuilt/native/linux-arm64/manifest.json @@ -1,6 +1,6 @@ { "target": "linux-arm64", - "builtAt": "2026-09-26T10:12:57.490Z", + "builtAt": "2026-10-15T21:37:14.255Z", "helpers": { "worktree-remover": { "sha256": "1832482e431808d4fad7d2ce9f27b554caca716ffc44cdc43679976f367142e4", @@ -15,8 +15,8 @@ "source": "6679af2569fb7fea609dce1eedf99059481256f0e57c2bd7946aa59194d70575" }, "subagent-file-mutator": { - "sha256": "901257685634ec526eca8ec83a1ddd5836feaea4110abc74b6396a0e7c9c2536", - "source": "9abc3f74bdaadf60c2e8f4921b3ad8ad12401308184cc84db092e9f072caf6cf" + "sha256": "3b0895a1027028906bc3be3ad8f75e3aebb57a7a54769865e0b3259bf90be1c2", + "source": "8bccc0ac0397463d78b48e488af89f738bfe056dc5241071408074c2c80836d6" } } } diff --git a/prebuilt/native/linux-x64/aiden-subagent-file-mutator b/prebuilt/native/linux-x64/aiden-subagent-file-mutator index dd1e94732..af8e0b83e 100755 Binary files a/prebuilt/native/linux-x64/aiden-subagent-file-mutator and b/prebuilt/native/linux-x64/aiden-subagent-file-mutator differ diff --git a/prebuilt/native/linux-x64/manifest.json b/prebuilt/native/linux-x64/manifest.json index 5d70f1a3e..908f9f9ff 100644 --- a/prebuilt/native/linux-x64/manifest.json +++ b/prebuilt/native/linux-x64/manifest.json @@ -1,6 +1,6 @@ { "target": "linux-x64", - "builtAt": "2026-09-26T10:12:54.204Z", + "builtAt": "2026-10-15T21:37:05.941Z", "helpers": { "worktree-remover": { "sha256": "0a26da641b1f74f1af4c40dce7d2405eff8771a705eb888a8d6366aac917d95a", @@ -15,8 +15,8 @@ "source": "6679af2569fb7fea609dce1eedf99059481256f0e57c2bd7946aa59194d70575" }, "subagent-file-mutator": { - "sha256": "2eeb42a53758ae10dd3803190b351a89b47c937a9368cdc39018907a0bbafc58", - "source": "9abc3f74bdaadf60c2e8f4921b3ad8ad12401308184cc84db092e9f072caf6cf" + "sha256": "115fcec8b87752860935ac48a306cac17dd8b23b641e9e9481f2171bd5868133", + "source": "8bccc0ac0397463d78b48e488af89f738bfe056dc5241071408074c2c80836d6" } } } diff --git a/scripts/ci-test-registry.json b/scripts/ci-test-registry.json index 4ae2afe0d..f86a0183d 100644 --- a/scripts/ci-test-registry.json +++ b/scripts/ci-test-registry.json @@ -12,7 +12,8 @@ "worktree-remover-build", "vcc-build", "worktree-file-io-build", - "worktree-file-io-test-build" + "worktree-file-io-test-build", + "subagent-file-mutator-build" ], "files": [ "main/handlers/assistant-parse.test.ts", @@ -1139,6 +1140,7 @@ { "id": "subagent-file-mutator-build", "for": [ + "core-git", "runtime-subagents", "subagent-file-mutator-native" ], diff --git a/scripts/subagent-file-mutator.test.mjs b/scripts/subagent-file-mutator.test.mjs index 7f9c57d59..131365c8e 100644 --- a/scripts/subagent-file-mutator.test.mjs +++ b/scripts/subagent-file-mutator.test.mjs @@ -81,6 +81,30 @@ async function rootIdentity(root) { return { device: identity.dev.toString(), inode: identity.ino.toString() }; } +async function parentEdit(root, relativePath, fileIdentity, original, updated) { + const rootStat = await rootIdentity(root); + const args = [ + "edit-file", root, rootStat.device, rootStat.inode, relativePath, + fileIdentity.device, fileIdentity.inode, sha256(original), + String(Buffer.byteLength(updated)), + ]; + return new Promise((resolve, reject) => { + const child = spawn(productionBinary, args, { stdio: ["pipe", "pipe", "pipe"] }); + const output = []; + const errors = []; + child.stdout.on("data", (chunk) => output.push(chunk)); + child.stderr.on("data", (chunk) => errors.push(chunk)); + child.on("error", reject); + child.on("close", (code) => resolve({ + code, + stdout: Buffer.concat(output).toString("utf8"), + stderr: Buffer.concat(errors).toString("utf8"), + })); + child.stdin.on("error", () => undefined); + child.stdin.end(updated); + }); +} + function startHelper( t, root, @@ -631,6 +655,39 @@ test("a path replacement after prepare is reported as a conflict and preserved", await helper.close(); }); +test("parent edit opens below the pinned root and refuses swapped symlinks", async (t) => { + if (process.platform !== "darwin" && process.platform !== "linux") return; + const root = await fixture(t, "aiden-parent-edit-root-"); + const outside = await fixture(t, "aiden-parent-edit-outside-"); + const directory = path.join(root, "nested"); + const target = path.join(directory, "edit.txt"); + const outsideFile = path.join(outside, "edit.txt"); + await mkdir(directory); + await writeFile(target, "before\n"); + await writeFile(outsideFile, "outside\n"); + const first = await stat(target, { bigint: true }); + const identity = { device: first.dev.toString(), inode: first.ino.toString() }; + const edited = await parentEdit(root, "nested/edit.txt", identity, "before\n", "after\n"); + assert.deepEqual(edited, { code: 0, stdout: "edited\n", stderr: "" }); + assert.equal(await readFile(target, "utf8"), "after\n"); + + await rename(target, path.join(directory, "original.txt")); + await symlink(outsideFile, target); + const rejectedTarget = await parentEdit(root, "nested/edit.txt", identity, "after\n", "changed\n"); + assert.equal(rejectedTarget.code, 1); + assert.equal(rejectedTarget.stderr, "conflict\n"); + assert.equal(await readFile(outsideFile, "utf8"), "outside\n"); + + await rm(target); + await rename(directory, path.join(root, "original-directory")); + await symlink(outside, directory); + const rejectedAncestor = await parentEdit(root, "nested/original.txt", identity, "after\n", "changed\n"); + assert.equal(rejectedAncestor.code, 1); + assert.equal(rejectedAncestor.stderr, "conflict\n"); + assert.equal(await readFile(outsideFile, "utf8"), "outside\n"); + assert.equal(await readFile(path.join(root, "original-directory", "original.txt"), "utf8"), "after\n"); +}); + test("a replacement in the final install race is atomically rolled back", async (t) => { if (process.platform !== "darwin" && process.platform !== "linux") return; const root = await fixture(t, "aiden-file-mutator-install-race-");