Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions packages/core/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<unknown>>();

async function withTaskLock<T>(taskId: string, fn: () => Promise<T>): Promise<T> {
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,
Expand Down Expand Up @@ -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(() => []);
Expand All @@ -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);
Expand Down Expand Up @@ -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<void> {
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("/");
Expand Down
105 changes: 105 additions & 0 deletions packages/core/test/security/README.md
Original file line number Diff line number Diff line change
@@ -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/<user>/`, or `/Users/<user>/` 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).
116 changes: 116 additions & 0 deletions packages/core/test/security/scheduler-write-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
79 changes: 79 additions & 0 deletions packages/core/test/security/test-file-hygiene.test.ts
Original file line number Diff line number Diff line change
@@ -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/<user>/ path", regex: /["']\/home\/[A-Za-z0-9_.-]+\// },
// Hardcoded macOS Users path.
{ name: "hardcoded /Users/<user>/ 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<string> = [
"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);
});
});
Loading
Loading