diff --git a/packages/core/src/task.ts b/packages/core/src/task.ts index 907dafd..54ac7d5 100644 --- a/packages/core/src/task.ts +++ b/packages/core/src/task.ts @@ -80,6 +80,21 @@ export function createTaskStore(cwd: string, rootDir?: string): TaskStore { const normalizedRoot = normalizePath(resolve(rootDir ?? join(cwd, ".myagent", "tasks"))); const recordsDir = normalizePath(join(normalizedRoot, "records")); const outputsDir = normalizePath(join(normalizedRoot, "outputs")); + const taskLocks = new Map>(); + + async function withTaskLock(taskId: string, fn: () => Promise): Promise { + const previous = taskLocks.get(taskId) ?? Promise.resolve(); + const next = previous.then(fn, fn); + const settled = next.catch(() => undefined); + taskLocks.set(taskId, settled); + try { + return await next; + } finally { + if (taskLocks.get(taskId) === settled) { + taskLocks.delete(taskId); + } + } + } return { rootDir: normalizedRoot, @@ -116,9 +131,9 @@ export function createTaskStore(cwd: string, rootDir?: string): TaskStore { await mkdir(recordsDir, { recursive: true }); await mkdir(outputsDir, { recursive: true }); const path = this.pathFor(record.id); - const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await writeFile(tempPath, `${JSON.stringify(normalizeTaskRecord(record), null, 2)}\n`, "utf8"); - await rename(tempPath, path); + await renameWithRetry(tempPath, path); }, async list() { const names = await readdir(recordsDir).catch(() => []); @@ -132,10 +147,12 @@ export function createTaskStore(cwd: string, rootDir?: string): TaskStore { return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); }, async patch(taskId, updater) { - const current = await this.load(taskId); - const next = normalizeTaskRecord(updater(current)); - await this.save(next); - return this.load(taskId); + return withTaskLock(taskId, async () => { + const current = await this.load(taskId); + const next = normalizeTaskRecord(updater(current)); + await this.save(next); + return this.load(taskId); + }); }, async appendOutput(taskId, chunk) { const current = await this.load(taskId); @@ -631,6 +648,28 @@ function firstPathArg(args: string[]): string | undefined { return args.find((arg) => !arg.startsWith("-")); } +const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); +const RENAME_MAX_ATTEMPTS = 6; + +// Windows can briefly hold a rename target with EBUSY/EPERM/EACCES when +// antivirus, the indexer, or another handle has the destination open. +// Short bounded retry turns those transient failures into success without +// masking real errors. +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await rename(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_MAX_ATTEMPTS - 1) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1))); + } + } +} + function isBlockedPath(absolutePath: string, cwd: string): boolean { const relativePath = normalizePath(relative(cwd, absolutePath)); const parts = relativePath.split("/"); diff --git a/packages/core/test/security/README.md b/packages/core/test/security/README.md new file mode 100644 index 0000000..9b56d9e --- /dev/null +++ b/packages/core/test/security/README.md @@ -0,0 +1,105 @@ +# Security invariants — test catalog + +This directory is the single index for myagent's load-bearing security +invariants. Each row below names an invariant, what *should* happen when +it is violated, and the test that pins it. **Read this before changing +anything in the permission chain, tool registry, hooks pipeline, or +scheduler partitioning.** Any new invariant added to the codebase +should land with a corresponding row here. + +Tests live in two trees because of the package boundary +(`@mini-claude-code/core` cannot depend on `@mini-claude-code/tools`): + +- Core-only invariants (scheduler, hook framework, state) → this + directory. +- Tool-level invariants (Bash parser, file-path checks, end-to-end + hook block) → `packages/tools/test/security/`. + +## Catalog + +### Permission modes + +| Invariant | Test | +|---|---| +| `plan` mode allows read-only tools | `packages/core/test/tool-pipeline.test.ts` | +| `plan` mode denies write-side tools (Edit, Write) | `packages/core/test/tool-pipeline.test.ts` | +| `default` mode denies write tools without an approval channel | `packages/core/test/tool-pipeline.test.ts` | +| `bypassPermissions` mode allows write tools | `packages/tools/test/week4-tools.test.ts` | + +### Bash whitelist (parser-level rejections) + +| Invariant | Test | +|---|---| +| `;` / `\|` / `&` / `>` / `<` / `$(…)` / backtick all rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` | +| `rm`, `mv`, and other write-like commands rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` + `week4-tools.test.ts` | +| `git commit` / other non-whitelisted subcommands rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` + `week4-tools.test.ts` | +| Path args containing `..`, mid-path `/../`, or absolute paths rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` | +| Null bytes in any arg rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` | +| `cat .env` (and `.env` anywhere in a path arg) rejected | `packages/tools/test/security/bash-parser-rejections.test.ts` + `week4-tools.test.ts` | + +### File tools — path scope + +| Invariant | Test | +|---|---| +| Read/Glob/Edit/Write reject `..` traversal that escapes cwd | `packages/tools/test/security/path-traversal.test.ts` | +| Grep rejects an absolute path outside the project | `packages/tools/test/security/path-traversal.test.ts` | +| `SessionStore.pathFor` rejects `..` in session ids | `packages/core/test/session.test.ts` | + +### File state (read-before-write + staleness) + +| Invariant | Test | +|---|---| +| Edit fails if file was never Read first | `packages/tools/test/week4-tools.test.ts` | +| Write fails if existing file was never Read first | `packages/tools/test/week4-tools.test.ts` | +| Edit/Write fail if the on-disk file changed since last Read | `packages/tools/test/week4-tools.test.ts` | + +### Hooks pipeline + +| Invariant | Test | +|---|---| +| `runToolHooks` returns `blocked` on PreToolUse exit code 2 | `packages/core/test/hooks.test.ts` | +| `runToolHooks` returns `blocked` on PostToolUse exit code 2 | `packages/core/test/hooks.test.ts` | +| Non-zero non-blocking exit becomes a soft warning, not block | `packages/core/test/hooks.test.ts` | +| Hook snapshot is frozen — config edits after load don't apply | `packages/core/test/hooks.test.ts` | +| **End-to-end:** PreToolUse exit-2 stops `executeToolUse` before disk write | `packages/tools/test/security/hook-preuse-blocks-tool.test.ts` | +| **End-to-end:** PostToolUse exit-2 surfaces as tool error after Edit | `packages/tools/test/week4-tools.test.ts` | +| Hook tool filter — non-listed tool passes through | `packages/tools/test/security/hook-preuse-blocks-tool.test.ts` | + +### Scheduler concurrency + +| Invariant | Test | +|---|---| +| `partitionToolCalls` keeps writes in their own serial batches | `packages/core/test/security/scheduler-write-serialization.test.ts` + `scheduler.test.ts` | +| `executeToolBatch` never overlaps two non-concurrency-safe tools | `packages/core/test/security/scheduler-write-serialization.test.ts` | +| Sibling read tools cancel when a Bash sibling errors with cancel-on-error | `packages/core/test/scheduler.test.ts` | + +### TaskStore concurrency + +| Invariant | Test | +|---|---| +| Concurrent `patch` calls on the same task serialize in-process (load→save is atomic per task) | `packages/core/test/task.test.ts` (kill-while-running test, deterministic under the per-task lock in `createTaskStore`) | +| Windows transient `EBUSY`/`EPERM`/`EACCES` on the records rename retry up to 6× with linear backoff before throwing | covered indirectly by the same kill test; the helper is `renameWithRetry` in `task.ts` | + +### Sub-agents + +| Invariant | Test | +|---|---| +| `maxSubAgentDepth=1` blocks nested Agent recursion | `packages/tools/test/agent-tools.test.ts` | +| `explore` sub-agent cannot self-approve writes (plan mode forced) | `packages/tools/test/agent-tools.test.ts` | +| `verifier` runs as a `local_agent` background task | `packages/tools/test/agent-tools.test.ts` | + +### Cross-platform test hygiene + +| Invariant | Test | +|---|---| +| No test file embeds a Windows drive-letter, `/home//`, or `/Users//` literal — use `process.cwd()` / `os.tmpdir()` / `node:path` instead | `packages/core/test/security/test-file-hygiene.test.ts` | + +## Adding a new invariant + +1. Write the negative test in the package that owns the surface + (`packages/core/test/security/` for scheduler/hooks/state, + `packages/tools/test/security/` for tools). +2. Add a row to the table above. +3. If the invariant is a *behavior* rather than a single function, add + both a unit test (call the function directly) and an end-to-end + test (drive it through `executeToolUse` or the query loop). diff --git a/packages/core/test/security/scheduler-write-serialization.test.ts b/packages/core/test/security/scheduler-write-serialization.test.ts new file mode 100644 index 0000000..58033aa --- /dev/null +++ b/packages/core/test/security/scheduler-write-serialization.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + buildTool, + executeToolBatch, + partitionToolCalls, + type ToolDefinition, + type ToolUse +} from "../../src/index.js"; + +const InputSchema = z.object({ id: z.string() }).strict(); +const inputJsonSchema = { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false +} as const; + +function makeTool(name: string, concurrencySafe: boolean, hooks?: { + onStart?: () => void; + onEnd?: () => void; +}): ToolDefinition { + return buildTool({ + name, + description: `${name} fixture`, + inputSchema: InputSchema, + inputJsonSchema, + isReadOnly: () => concurrencySafe, + isConcurrencySafe: () => concurrencySafe, + async call(input) { + hooks?.onStart?.(); + await new Promise((resolve) => setTimeout(resolve, 10)); + hooks?.onEnd?.(); + return { status: "success", content: `done:${input.id}` }; + } + }); +} + +function toolUse(id: string, name: string): ToolUse { + return { id: `toolu_${id}`, name, input: { id } }; +} + +describe("security: scheduler write serialization", () => { + it("partitions three Edits as three serial batches of one each", () => { + const edit = makeTool("Edit", false); + const toolsByName = new Map([[edit.name, edit]]); + + const batches = partitionToolCalls( + [toolUse("a", "Edit"), toolUse("b", "Edit"), toolUse("c", "Edit")], + toolsByName, + { cwd: process.cwd() } + ); + + expect(batches).toHaveLength(3); + for (const batch of batches) { + expect(batch.kind).toBe("serial"); + expect(batch.toolUses).toHaveLength(1); + } + }); + + it("never groups two write tools in the same parallel batch", () => { + const read = makeTool("Read", true); + const edit = makeTool("Edit", false); + const write = makeTool("Write", false); + const toolsByName = new Map([ + [read.name, read], + [edit.name, edit], + [write.name, write] + ]); + + const batches = partitionToolCalls( + [ + toolUse("r1", "Read"), + toolUse("e1", "Edit"), + toolUse("w1", "Write"), + toolUse("r2", "Read"), + toolUse("e2", "Edit") + ], + toolsByName, + { cwd: process.cwd() } + ); + + for (const batch of batches) { + if (batch.kind !== "parallel") continue; + const writers = batch.toolUses.filter((tu) => tu.name === "Edit" || tu.name === "Write"); + expect(writers).toHaveLength(0); + } + }); + + it("executes two Edit tool_uses sequentially, never overlapping", async () => { + let active = 0; + let maxActive = 0; + const edit = makeTool("Edit", false, { + onStart: () => { + active += 1; + maxActive = Math.max(maxActive, active); + }, + onEnd: () => { + active -= 1; + } + }); + const toolsByName = new Map([[edit.name, edit]]); + const uses = [toolUse("a", "Edit"), toolUse("b", "Edit")]; + const batches = partitionToolCalls(uses, toolsByName, { cwd: process.cwd() }); + + for (const batch of batches) { + await executeToolBatch({ + batch, + toolsByName, + context: { cwd: process.cwd(), permissionMode: "bypassPermissions" } + }); + } + + expect(maxActive).toBe(1); + }); +}); diff --git a/packages/core/test/security/test-file-hygiene.test.ts b/packages/core/test/security/test-file-hygiene.test.ts new file mode 100644 index 0000000..8e287d8 --- /dev/null +++ b/packages/core/test/security/test-file-hygiene.test.ts @@ -0,0 +1,79 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); +const packagesRoot = join(repoRoot, "packages"); + +const FORBIDDEN_PATTERNS: ReadonlyArray<{ name: string; regex: RegExp }> = [ + // Hardcoded drive-letter absolute paths (Windows-only). + { name: "Windows drive-letter path", regex: /["'][A-Za-z]:[\\/]/ }, + // Hardcoded POSIX home paths that bake in a specific user. + { name: "hardcoded /home// path", regex: /["']\/home\/[A-Za-z0-9_.-]+\// }, + // Hardcoded macOS Users path. + { name: "hardcoded /Users// path", regex: /["']\/Users\/[A-Za-z0-9_.-]+\// } +]; + +// Paths (relative to repo root, posix-normalised) allowed to mention these +// patterns. The security/ test dirs intentionally embed the forbidden +// literals to assert the runtime rejects them. +const ALLOWLIST_PREFIXES: ReadonlyArray = [ + "packages/core/test/security/", + "packages/tools/test/security/" +]; + +function isAllowlisted(relPath: string): boolean { + return ALLOWLIST_PREFIXES.some((prefix) => relPath.startsWith(prefix)); +} + +function listTestFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + out.push(...listTestFiles(full)); + } else if (entry.isFile() && /\.test\.ts$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +describe("security: test file platform-path hygiene", () => { + it("no test file embeds a platform-specific absolute path literal", () => { + expect(statSync(packagesRoot).isDirectory()).toBe(true); + const files = listTestFiles(packagesRoot); + const violations: string[] = []; + + for (const file of files) { + const rel = relative(repoRoot, file).replace(/[\\/]/g, "/"); + if (isAllowlisted(rel)) continue; + const content = readFileSync(file, "utf8"); + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + for (const { name, regex } of FORBIDDEN_PATTERNS) { + if (regex.test(line)) { + violations.push(`${rel}:${index + 1} [${name}] ${line.trim()}`); + } + } + } + } + + if (violations.length > 0) { + throw new Error( + `Hardcoded platform paths in test files (use node:path + process.cwd()/os.tmpdir() instead):\n ${violations.join("\n ")}` + ); + } + }); + + it("finds at least one .test.ts under packages/ (sanity)", () => { + const files = listTestFiles(packagesRoot); + expect(files.length).toBeGreaterThan(5); + // Smoke-check the helper resolves the repo correctly. + expect(files.some((f) => f.endsWith(`${sep}state.test.ts`))).toBe(true); + }); +}); diff --git a/packages/tools/test/security/bash-parser-rejections.test.ts b/packages/tools/test/security/bash-parser-rejections.test.ts new file mode 100644 index 0000000..5d0c0e1 --- /dev/null +++ b/packages/tools/test/security/bash-parser-rejections.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { executeToolUse, type PermissionMode, type ToolDefinition } from "@mini-claude-code/core"; +import { describe, expect, it } from "vitest"; + +import { createProjectToolRegistry } from "../../src/index.js"; + +function fixtureProject(): string { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-bash-")); + mkdirSync(join(cwd, "src")); + writeFileSync(join(cwd, "src", "a.ts"), "export const a = 1;\n", "utf8"); + writeFileSync(join(cwd, ".env"), "SECRET=1\n", "utf8"); + return cwd; +} + +async function runBash( + tools: ToolDefinition[], + cwd: string, + command: string, + permissionMode: PermissionMode = "bypassPermissions" +) { + return executeToolUse( + { id: "toolu_bash_sec", name: "Bash", input: { command } }, + new Map(tools.map((tool) => [tool.name, tool])), + { cwd, permissionMode } + ); +} + +describe("security: Bash parser rejections", () => { + const tools = createProjectToolRegistry(); + + it.each([ + ["semicolon chaining", "ls; rm -rf .", "redirects, pipes, command chaining"], + ["pipe", "ls | grep secret", "redirects, pipes, command chaining"], + ["ampersand", "ls & echo done", "redirects, pipes, command chaining"], + ["stdout redirect", "ls > out.txt", "redirects, pipes, command chaining"], + ["stdin redirect", "cat < src/a.ts", "redirects, pipes, command chaining"], + ["subshell", "echo $(whoami)", "redirects, pipes, command chaining"], + ["backtick substitution", "echo `whoami`", "redirects, pipes, command chaining"] + ])("rejects %s", async (_label, command, expectedFragment) => { + const cwd = fixtureProject(); + const result = await runBash(tools, cwd, command); + expect(result.status).toBe("error"); + expect(result.error).toContain(expectedFragment); + }); + + it.each([ + ["relative parent traversal", "cat ../etc/passwd"], + ["mid-path parent traversal", "cat src/../../../etc/passwd"], + ["POSIX absolute path", "cat /etc/passwd"] + ])("rejects %s as outside the project", async (_label, command) => { + const cwd = fixtureProject(); + const result = await runBash(tools, cwd, command); + expect(result.status).toBe("error"); + expect(result.error).toContain("outside the project"); + }); + + it("rejects null bytes in arguments", async () => { + const cwd = fixtureProject(); + // Build the null byte at runtime so the source file stays plain UTF-8 + // (a literal U+0000 in source would trip git's binary detection). + const command = `cat src/a.ts${String.fromCharCode(0)}.env`; + const result = await runBash(tools, cwd, command); + expect(result.status).toBe("error"); + expect(result.error).toContain("null bytes"); + }); + + it("rejects .env reads through cat", async () => { + const cwd = fixtureProject(); + const result = await runBash(tools, cwd, "cat .env"); + expect(result.status).toBe("error"); + expect(result.error).toContain(".env"); + }); + + it.each([ + ["rm", "rm src/a.ts"], + ["mv", "mv src/a.ts src/b.ts"] + ])("rejects disallowed write-like command: %s", async (executable, command) => { + const cwd = fixtureProject(); + const result = await runBash(tools, cwd, command); + expect(result.status).toBe("error"); + expect(result.error).toContain(executable); + }); + + it("rejects git subcommands outside the read-only whitelist", async () => { + const cwd = fixtureProject(); + const result = await runBash(tools, cwd, "git commit -m x"); + expect(result.status).toBe("error"); + expect(result.error).toContain("git commit"); + }); +}); diff --git a/packages/tools/test/security/hook-preuse-blocks-tool.test.ts b/packages/tools/test/security/hook-preuse-blocks-tool.test.ts new file mode 100644 index 0000000..a0f887a --- /dev/null +++ b/packages/tools/test/security/hook-preuse-blocks-tool.test.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { executeToolUse, loadHookSnapshot } from "@mini-claude-code/core"; +import { describe, expect, it } from "vitest"; + +import { createProjectToolRegistry } from "../../src/index.js"; + +function fixtureProject(): string { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-prehook-")); + return cwd; +} + +function quote(value: string): string { + return `"${value.replace(/"/g, '\\"')}"`; +} + +function writeBlockingPreHook(cwd: string): void { + const script = join(cwd, "block-pre.cjs"); + writeFileSync( + script, + [ + "process.stdin.resume();", + "process.stdin.on('end', () => {", + " console.error('preuse blocked');", + " process.exit(2);", + "});" + ].join("\n"), + "utf8" + ); + mkdirSync(join(cwd, ".myagent"), { recursive: true }); + writeFileSync( + join(cwd, ".myagent", "hooks.json"), + JSON.stringify({ + hooks: [ + { + name: "deny-writes", + event: "PreToolUse", + command: `${quote(process.execPath)} ${quote(script)}`, + tools: ["Write"] + } + ] + }), + "utf8" + ); +} + +describe("security: PreToolUse hook end-to-end", () => { + it("blocks Write before it touches disk on exit code 2", async () => { + const cwd = fixtureProject(); + writeBlockingPreHook(cwd); + const hookSnapshot = await loadHookSnapshot(cwd); + const tools = createProjectToolRegistry(); + const targetRel = "src/new.ts"; + const targetAbs = join(cwd, targetRel); + + const result = await executeToolUse( + { + id: "toolu_write_prehook", + name: "Write", + input: { path: targetRel, content: "export const x = 1;\n" } + }, + new Map(tools.map((tool) => [tool.name, tool])), + { cwd, permissionMode: "bypassPermissions", hookSnapshot } + ); + + expect(result.status).toBe("error"); + expect(result.error).toContain("Blocked by PreToolUse hook deny-writes"); + expect(result.error).toContain("preuse blocked"); + expect(existsSync(targetAbs)).toBe(false); + }); + + it("allows tools outside the hook's tool filter through", async () => { + const cwd = fixtureProject(); + mkdirSync(join(cwd, "src")); + writeFileSync(join(cwd, "src", "existing.ts"), "ok\n", "utf8"); + writeBlockingPreHook(cwd); + const hookSnapshot = await loadHookSnapshot(cwd); + const tools = createProjectToolRegistry(); + + const result = await executeToolUse( + { id: "toolu_read_prehook", name: "Read", input: { path: "src/existing.ts" } }, + new Map(tools.map((tool) => [tool.name, tool])), + { cwd, permissionMode: "default", hookSnapshot } + ); + + expect(result.status).toBe("success"); + }); +}); diff --git a/packages/tools/test/security/path-traversal.test.ts b/packages/tools/test/security/path-traversal.test.ts new file mode 100644 index 0000000..88306ea --- /dev/null +++ b/packages/tools/test/security/path-traversal.test.ts @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { executeToolUse, type PermissionMode, type ToolDefinition } from "@mini-claude-code/core"; +import { describe, expect, it } from "vitest"; + +import { createProjectToolRegistry } from "../../src/index.js"; + +function fixtureProject(): string { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-traversal-")); + mkdirSync(join(cwd, "src")); + writeFileSync(join(cwd, "src", "inside.ts"), "export const inside = 1;\n", "utf8"); + return cwd; +} + +async function runTool( + tools: ToolDefinition[], + cwd: string, + name: string, + input: Record, + permissionMode: PermissionMode = "bypassPermissions" +) { + return executeToolUse( + { id: `toolu_${name}_traversal`, name, input }, + new Map(tools.map((tool) => [tool.name, tool])), + { cwd, permissionMode } + ); +} + +describe("security: file-tool path traversal", () => { + const tools = createProjectToolRegistry(); + + it.each([ + ["Read", { path: "../outside.txt" }], + ["Read", { path: "src/../../outside.txt" }], + ["Glob", { pattern: "*.ts", path: "../outside" }], + ["Edit", { path: "../outside.txt", oldString: "x", newString: "y" }], + ["Write", { path: "../outside.txt", content: "leak" }] + ])("%s rejects parent-traversal path %j", async (toolName, input) => { + const cwd = fixtureProject(); + const result = await runTool(tools, cwd, toolName, input); + expect(result.status).toBe("error"); + expect(result.error?.toLowerCase()).toMatch(/outside|invalid|not allowed/); + }); + + it("Grep rejects an absolute path outside the project", async () => { + const cwd = fixtureProject(); + const absolute = process.platform === "win32" ? "C:\\Windows" : "/etc"; + const result = await runTool(tools, cwd, "Grep", { + pattern: "secret", + path: absolute + }); + expect(result.status).toBe("error"); + expect(result.error?.toLowerCase()).toMatch(/outside|invalid|not allowed/); + }); +});