From a54918c3be93f1340a33efd1020cf45b49f58e1e Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 11:22:44 +0200 Subject: [PATCH] feat(runner): add --bare and --append-system-prompt-file flags Prevents CLAUDE.md walk-up through parent directories by passing --bare to every Claude CLI invocation, and explicitly injects the workspace CLAUDE.md via --append-system-prompt-file so agent identity is isolated. Adds fake-claude fixture and integration test to verify flags are present. Co-Authored-By: Claude Sonnet 4.6 --- src/agent/runner.ts | 8 + tests/fixtures/fake-claude.cmd | 2 + tests/fixtures/fake-claude.mjs | 8 + .../no-parent-claude-md-leak.test.ts | 146 ++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 tests/fixtures/fake-claude.cmd create mode 100644 tests/fixtures/fake-claude.mjs create mode 100644 tests/integration/no-parent-claude-md-leak.test.ts diff --git a/src/agent/runner.ts b/src/agent/runner.ts index add2aeb..324a2e3 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -163,11 +163,19 @@ export async function runAgent(options: RunAgentOptions): Promise { } return new Promise((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 = [ "-p", prompt, "--output-format", "json", + "--bare", + "--append-system-prompt-file", + path.join(workspacePath, "CLAUDE.md"), ]; const child = spawn(claudeCommand, args, { diff --git a/tests/fixtures/fake-claude.cmd b/tests/fixtures/fake-claude.cmd new file mode 100644 index 0000000..e33da1a --- /dev/null +++ b/tests/fixtures/fake-claude.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0fake-claude.mjs" %* diff --git a/tests/fixtures/fake-claude.mjs b/tests/fixtures/fake-claude.mjs new file mode 100644 index 0000000..b26038f --- /dev/null +++ b/tests/fixtures/fake-claude.mjs @@ -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, +})); diff --git a/tests/integration/no-parent-claude-md-leak.test.ts b/tests/integration/no-parent-claude-md-leak.test.ts new file mode 100644 index 0000000..a85403d --- /dev/null +++ b/tests/integration/no-parent-claude-md-leak.test.ts @@ -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 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")); + }); +});