Merge branch 'main' into phase-0/path-traversal-check

This commit is contained in:
dev 2026-04-09 09:43:41 +00:00
commit 613758e912
4 changed files with 164 additions and 0 deletions

View file

@ -163,11 +163,19 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
} }
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
// IMPORTANT: --bare prevents CLAUDE.md walk-up through parent directories.
// Without this flag every agent inherits the DisClaw project CLAUDE.md as
// its identity (and any other CLAUDE.md files up the directory tree).
// --append-system-prompt-file injects the workspace-specific CLAUDE.md
// explicitly so each agent keeps its own isolated identity.
const args = [ const args = [
"-p", "-p",
prompt, prompt,
"--output-format", "--output-format",
"json", "json",
"--bare",
"--append-system-prompt-file",
path.join(workspacePath, "CLAUDE.md"),
]; ];
const child = spawn(claudeCommand, args, { const child = spawn(claudeCommand, args, {

2
tests/fixtures/fake-claude.cmd vendored Normal file
View file

@ -0,0 +1,2 @@
@echo off
node "%~dp0fake-claude.mjs" %*

8
tests/fixtures/fake-claude.mjs vendored Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env node
const args = process.argv.slice(2);
const systemFileIdx = args.indexOf('--append-system-prompt-file');
console.log(JSON.stringify({
args,
hasBare: args.includes('--bare'),
systemPromptFile: systemFileIdx >= 0 ? args[systemFileIdx + 1] : null,
}));

View file

@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
/**
* Integration test: verifies that every Claude CLI invocation includes
* --bare (prevents CLAUDE.md walk-up through parent directories) and
* --append-system-prompt-file pointing to the workspace CLAUDE.md.
*
* We mock cross-spawn so no real process is launched, and capture the exact
* args array that runner.ts would pass to the CLI.
*/
// Capture spawned args via a mock before any module imports runner
let capturedArgs: string[] = [];
vi.mock("cross-spawn", () => {
// Minimal EventEmitter-like child mock that immediately closes with code 0
function makeMockChild(args: string[]) {
capturedArgs = args;
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
const stdioStream = {
setEncoding: () => {},
on: (event: string, cb: (...a: unknown[]) => void) => {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(cb);
// emit 'data' with a JSON payload that looks like our fake-claude output
if (event === "data") {
Promise.resolve().then(() =>
cb(
JSON.stringify({
args,
hasBare: args.includes("--bare"),
systemPromptFile:
args.indexOf("--append-system-prompt-file") >= 0
? args[args.indexOf("--append-system-prompt-file") + 1]
: null,
})
)
);
}
},
};
const child = {
stdout: { ...stdioStream },
stderr: {
setEncoding: () => {},
on: () => {},
},
stdin: { end: () => {} },
on: (event: string, cb: (...a: unknown[]) => void) => {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(cb);
if (event === "close") {
// Fire 'close' after stdout data has been emitted
Promise.resolve()
.then(() => Promise.resolve())
.then(() => cb(0));
}
},
kill: () => {},
};
return child;
}
return {
default: (_cmd: string, args: string[]) => makeMockChild(args),
};
});
// Import after mock registration
import { runAgent } from "../../src/agent/runner";
import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude";
describe("no-parent-claude-md-leak", () => {
let workspaceDir: string;
const originalEnv = { ...process.env };
beforeAll(() => {
// Create a minimal temp workspace with agent.yaml and CLAUDE.md
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-"));
fs.writeFileSync(
path.join(workspaceDir, "agent.yaml"),
`name: test-agent\ndisplay_name: Test Agent\nrole: Testing\nchannel_id: "123456789"\n`,
"utf-8"
);
fs.writeFileSync(
path.join(workspaceDir, "CLAUDE.md"),
"# Test Agent\nYou are a test agent.\n",
"utf-8"
);
});
beforeEach(() => {
capturedArgs = [];
// Reset the resolve-claude cache and set a dummy CLAUDE_PATH
_resetResolveClaudeCache();
process.env.CLAUDE_PATH = "/fake/claude";
});
afterEach(() => {
process.env = { ...originalEnv };
_resetResolveClaudeCache();
});
afterAll(() => {
try {
fs.rmSync(workspaceDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
});
it("passes --bare flag to the Claude CLI", async () => {
await runAgent({
workspacePath: workspaceDir,
channelName: "test-channel",
userMessage: "hello",
conversationHistory: [],
});
expect(capturedArgs).toContain("--bare");
});
it("passes --append-system-prompt-file pointing to workspace CLAUDE.md", async () => {
await runAgent({
workspacePath: workspaceDir,
channelName: "test-channel",
userMessage: "hello",
conversationHistory: [],
});
const idx = capturedArgs.indexOf("--append-system-prompt-file");
expect(idx).toBeGreaterThanOrEqual(0);
const systemPromptFile = capturedArgs[idx + 1];
expect(systemPromptFile).toMatch(/CLAUDE\.md$/);
// Must point to the workspace-specific CLAUDE.md, not a parent directory
expect(systemPromptFile).toBe(path.join(workspaceDir, "CLAUDE.md"));
});
});