diff --git a/apps/daemon/src/chat/server.ts b/apps/daemon/src/chat/server.ts index ebfdbcb..caa3cad 100644 --- a/apps/daemon/src/chat/server.ts +++ b/apps/daemon/src/chat/server.ts @@ -106,6 +106,7 @@ export function createChatServer(deps: ChatServerDeps): Server { const input = await buildChatInput(); for await (const ev of executor.runStream!({ ...input, signal: ac.signal })) { if (ev.type === 'delta') send({ type: 'delta', text: ev.text }); + else if (ev.type === 'tool_call') send({ type: 'tool_call', name: ev.name, phase: ev.phase }); else send({ type: 'done', sessionId: ev.sessionId ?? sessionId, costUsd: ev.costUsd }); } } catch (err) { diff --git a/apps/daemon/src/room/client.ts b/apps/daemon/src/room/client.ts index 506ead4..c13033e 100644 --- a/apps/daemon/src/room/client.ts +++ b/apps/daemon/src/room/client.ts @@ -29,6 +29,7 @@ export interface DispatchDeps { function sendStreamEvent(send: (obj: unknown) => void, requestId: string, ev: ExecutorStreamEvent): void { if (ev.type === 'delta') send({ type: 'delta', requestId, text: ev.text }); + else if (ev.type === 'tool_call') send({ type: 'tool_call', requestId, name: ev.name, phase: ev.phase }); else send({ type: 'done', requestId, text: ev.text, sessionId: ev.sessionId, costUsd: ev.costUsd }); } @@ -68,9 +69,13 @@ export async function dispatchRequest(raw: string, deps: DispatchDeps): Promise< const reply = await harness.briefing(input); send({ requestId: req.id, kind: 'briefing', text: reply.text, costUsd: reply.costUsd }); } - } else { + } else if (req.kind === 'journal') { const result = await harness.journal({ limit: req.limit, before: req.before }); send({ requestId: req.id, kind: 'journal', entries: result.entries, nextBefore: result.nextBefore }); + } else { + // code (M7): execution (worktree + auto mode + permission bridge) lands in + // later tasks. Reject clearly for now so the phone shows an error, not a hang. + send({ type: 'error', requestId: req.id, message: 'Coding is not enabled on this daemon yet.' }); } } catch (err) { send({ type: 'error', requestId: req.id, message: err instanceof Error ? err.message : String(err) }); diff --git a/packages/runtime/src/executor/claudeCode.test.ts b/packages/runtime/src/executor/claudeCode.test.ts new file mode 100644 index 0000000..d2dbd92 --- /dev/null +++ b/packages/runtime/src/executor/claudeCode.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { stripUnsafeEnv } from './claudeCode.js'; + +test('stripUnsafeEnv removes exec-hijack keys and prefixes, keeps the rest', () => { + const env: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + HOME: '/home/me', + NODE_OPTIONS: '--require /evil.js', + GIT_SSH_COMMAND: 'ssh -o ProxyCommand=evil', + LD_PRELOAD: '/evil.so', + DYLD_INSERT_LIBRARIES: '/evil.dylib', + BASH_FUNC_foo: '() { evil; }', + GIT_CONFIG_GLOBAL: '/tmp/evil', + NPM_CONFIG_REGISTRY: 'http://evil', + PYTHONPATH: '/evil', + SAFE_VAR: 'ok', + }; + const out = stripUnsafeEnv(env); + for (const key of [ + 'NODE_OPTIONS', + 'GIT_SSH_COMMAND', + 'LD_PRELOAD', + 'DYLD_INSERT_LIBRARIES', + 'BASH_FUNC_foo', + 'GIT_CONFIG_GLOBAL', + 'NPM_CONFIG_REGISTRY', + 'PYTHONPATH', + ]) { + assert.equal(out[key], undefined, `${key} should be stripped`); + } + assert.equal(out.PATH, '/usr/bin'); + assert.equal(out.HOME, '/home/me'); + assert.equal(out.SAFE_VAR, 'ok'); +}); + +test('stripUnsafeEnv does not mutate its input', () => { + const env: NodeJS.ProcessEnv = { NODE_OPTIONS: 'x', KEEP: 'y' }; + const out = stripUnsafeEnv(env); + assert.equal(env.NODE_OPTIONS, 'x'); + assert.equal(out.NODE_OPTIONS, undefined); + assert.equal(out.KEEP, 'y'); +}); diff --git a/packages/runtime/src/executor/claudeCode.ts b/packages/runtime/src/executor/claudeCode.ts index 82eef17..603c7b5 100644 --- a/packages/runtime/src/executor/claudeCode.ts +++ b/packages/runtime/src/executor/claudeCode.ts @@ -16,6 +16,41 @@ const SANITIZED_ENV_KEYS = [ 'CLAUDE_CODE_USE_FOUNDRY', ] as const; +/** + * Exec-environment hijack vectors. A remote-triggered coding run (M7) must not + * inherit these — they can silently rewrite what a shell / build / git actually + * executes (an agent that can set `GIT_SSH_COMMAND` or `NODE_OPTIONS` is a + * machine-takeover primitive). Ported from OpenClaw's host-env-security policy + * (MIT). Applied to every run — hardening the shared executor, not just coding. + */ +const UNSAFE_ENV_KEYS = [ + 'NODE_OPTIONS', + 'BASH_ENV', + 'ENV', + 'PROMPT_COMMAND', + 'PS4', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_EXTERNAL_DIFF', + 'GIT_PAGER', + 'PYTHONSTARTUP', + 'PYTHONPATH', + 'PERL5LIB', + 'RUBYOPT', + 'RUBYLIB', +] as const; +const UNSAFE_ENV_PREFIXES = ['LD_', 'DYLD_', 'BASH_FUNC_', 'GIT_CONFIG', 'NPM_CONFIG_', 'YARN_', 'TF_VAR_']; + +/** Remove exec-hijack env vars (exact keys + prefixes). Returns a fresh object. */ +export function stripUnsafeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = { ...env }; + for (const key of UNSAFE_ENV_KEYS) delete out[key]; + for (const key of Object.keys(out)) { + if (UNSAFE_ENV_PREFIXES.some((p) => key.startsWith(p))) delete out[key]; + } + return out; +} + export interface ClaudeCodeExecutorOptions { /** CLI binary to invoke. Default: `"claude"` (must be on PATH). */ command?: string; @@ -43,7 +78,7 @@ interface ClaudeJsonResult { /** One line of `--output-format stream-json` output. */ interface StreamLine { type?: string; - event?: { type?: string; delta?: { text?: string } }; + event?: { type?: string; delta?: { text?: string }; content_block?: { type?: string; name?: string } }; result?: string; session_id?: string; total_cost_usd?: number; @@ -104,6 +139,7 @@ export class ClaudeCodeExecutor implements AgentExecutor { const child = spawn(this.command, args, { env, + cwd: input.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal: controller.signal, }); @@ -173,7 +209,7 @@ export class ClaudeCodeExecutor implements AgentExecutor { else input.signal.addEventListener('abort', onAbort, { once: true }); } - const child = spawn(this.command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], signal: controller.signal }); + const child = spawn(this.command, args, { env, cwd: input.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal: controller.signal }); let stderr = ''; child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); @@ -198,6 +234,15 @@ export class ClaudeCodeExecutor implements AgentExecutor { if (evt.type === 'stream_event' && evt.event?.type === 'content_block_delta') { const text = evt.event.delta?.text; if (typeof text === 'string' && text.length > 0) yield { type: 'delta', text }; + } else if ( + evt.type === 'stream_event' && + evt.event?.type === 'content_block_start' && + evt.event.content_block?.type === 'tool_use' + ) { + // Agentic (coding) runs: surface each tool call so the phone can show a + // tool-use timeline. `phase:'end'` (via tool_result correlation) is a follow-up. + const name = evt.event.content_block.name; + if (typeof name === 'string' && name.length > 0) yield { type: 'tool_call', name, phase: 'start' }; } else if (evt.type === 'result') { if (evt.is_error) { throw new Error(`Claude Code reported an error: ${evt.result ?? evt.subtype ?? 'unknown'}`); @@ -236,6 +281,8 @@ export class ClaudeCodeExecutor implements AgentExecutor { const model = input.model ?? this.defaultModel; if (model) args.push('--model', model); } + if (input.permissionMode) args.push('--permission-mode', input.permissionMode); + if (input.allowedTools?.length) args.push('--allowedTools', input.allowedTools.join(',')); args.push(input.userPrompt); return args; } @@ -245,6 +292,8 @@ export class ClaudeCodeExecutor implements AgentExecutor { if (input.systemPrompt) args.push('--append-system-prompt', input.systemPrompt); const model = input.model ?? this.defaultModel; if (model) args.push('--model', model); + if (input.permissionMode) args.push('--permission-mode', input.permissionMode); + if (input.allowedTools?.length) args.push('--allowedTools', input.allowedTools.join(',')); if (input.mcp) { args.push('--mcp-config', input.mcp.configPath, '--strict-mcp-config'); if (input.mcp.allowedTools?.length) { @@ -260,8 +309,9 @@ export class ClaudeCodeExecutor implements AgentExecutor { private buildEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; for (const key of SANITIZED_ENV_KEYS) delete env[key]; - if (this.oauthToken) env['CLAUDE_CODE_OAUTH_TOKEN'] = this.oauthToken; - return env; + const safe = stripUnsafeEnv(env); + if (this.oauthToken) safe['CLAUDE_CODE_OAUTH_TOKEN'] = this.oauthToken; + return safe; } private parse(stdout: string): ExecutorResult { diff --git a/packages/runtime/src/executor/types.ts b/packages/runtime/src/executor/types.ts index 3e495b2..362a92a 100644 --- a/packages/runtime/src/executor/types.ts +++ b/packages/runtime/src/executor/types.ts @@ -25,11 +25,18 @@ export interface ExecutorRunInput { sessionId?: string; /** Resume an existing chat session by id (subsequent turns); systemPrompt/model are already bound. */ resume?: string; + /** Working directory for the run (the target repo for a coding turn, M7). */ + cwd?: string; + /** Permission mode (`--permission-mode`), e.g. `"auto"` for agentic coding runs (M7). */ + permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'auto' | 'bypassPermissions'; + /** Tools to pre-approve (`--allowedTools`), the static first filter before auto mode. */ + allowedTools?: string[]; } /** Streaming event from {@link AgentExecutor.runStream}. */ export type ExecutorStreamEvent = | { type: 'delta'; text: string } + | { type: 'tool_call'; name: string; phase: 'start' | 'end' } | { type: 'done'; text: string; sessionId?: string; costUsd?: number }; export interface ExecutorResult { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a7edec2..662a487 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -32,7 +32,12 @@ export const PROJECT_ACCESS_VALUES = ['owner', 'edit', 'view'] as const; // 'agent' turns ship with interactive chat. export const SENDER_KINDS = ['human', 'agent'] as const; -// The read capabilities Lucid exposes over the harness, satisfied identically by -// both transports — the local daemon gateway (free) and the hosted Pro server. -export const HARNESS_REQUEST_KINDS = ['ask', 'briefing', 'journal'] as const; +// The capabilities Lucid exposes over the harness, satisfied identically by both +// transports — the local daemon gateway (free) and the hosted Pro server. `code` +// (M7) drives Claude Code in a repo on the user's machine. +export const HARNESS_REQUEST_KINDS = ['ask', 'briefing', 'journal', 'code'] as const; + +// The phone's answer to an approval request during a `code` turn (M7 permission +// bridge): a mutating/risky tool call auto mode escalated to the human. +export const APPROVAL_DECISIONS = ['allow', 'deny'] as const; diff --git a/packages/shared/src/harness.test.ts b/packages/shared/src/harness.test.ts index 08dd944..793337a 100644 --- a/packages/shared/src/harness.test.ts +++ b/packages/shared/src/harness.test.ts @@ -4,9 +4,12 @@ import { HarnessRequestSchema, HarnessResponseSchema, HarnessStreamEventSchema, + ApprovalRequestSchema, + ApprovalResponseSchema, } from './schemas/harness.js'; const ID = '019ea7c7-8446-75fa-b6fc-41c7e7cb7204'; +const ID2 = '019ea7c7-8446-75fa-b6fc-41c7e7cb7205'; test('ask request parses and defaults senderKind to human', () => { const parsed = HarnessRequestSchema.parse({ id: ID, kind: 'ask', prompt: 'what now?' }); @@ -39,6 +42,31 @@ test('journal response carries entries and an optional cursor', () => { assert.equal(parsed.kind === 'journal' && parsed.entries.length, 1); }); +test('code request carries repoId + prompt (never a path)', () => { + const parsed = HarnessRequestSchema.parse({ id: ID, kind: 'code', repoId: 'lucidity', prompt: 'fix the flaky test' }); + assert.equal(parsed.kind, 'code'); + assert.equal(parsed.kind === 'code' && parsed.repoId, 'lucidity'); +}); + +test('code request rejects a missing repoId', () => { + assert.throws(() => HarnessRequestSchema.parse({ id: ID, kind: 'code', prompt: 'do a thing' })); +}); + +test('approval request/response round-trip with a decision', () => { + const req = ApprovalRequestSchema.parse({ + type: 'approval_request', + id: ID, + requestId: ID2, + toolName: 'Bash', + toolInput: { command: 'npm test' }, + cwd: '/repo', + }); + assert.equal(req.toolName, 'Bash'); + const res = ApprovalResponseSchema.parse({ type: 'approval_response', id: ID, decision: 'allow' }); + assert.equal(res.decision, 'allow'); + assert.throws(() => ApprovalResponseSchema.parse({ type: 'approval_response', id: ID, decision: 'maybe' })); +}); + test('stream events discriminate delta, tool_call, done, error', () => { for (const evt of [ { type: 'delta', requestId: ID, text: 'hi' }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b4a6845..fb501dc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,7 +68,10 @@ export { AskRequestSchema, BriefingRequestSchema, JournalRequestSchema, + CodeRequestSchema, HarnessRequestSchema, + ApprovalRequestSchema, + ApprovalResponseSchema, JournalEntrySchema, AskResponseSchema, BriefingResponseSchema, @@ -111,8 +114,12 @@ export type { AskRequest, BriefingRequest, JournalRequest, + CodeRequest, HarnessRequest, HarnessRequestInput, + ApprovalRequest, + ApprovalResponse, + ApprovalDecision, JournalEntry, AskResponse, BriefingResponse, diff --git a/packages/shared/src/schemas/harness.ts b/packages/shared/src/schemas/harness.ts index 02e7eac..ee5e05b 100644 --- a/packages/shared/src/schemas/harness.ts +++ b/packages/shared/src/schemas/harness.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { SENDER_KINDS, HARNESS_REQUEST_KINDS } from '../constants.js'; +import { SENDER_KINDS, HARNESS_REQUEST_KINDS, APPROVAL_DECISIONS } from '../constants.js'; /** * The Lucid harness wire contract (M6). One shape that every transport speaks: @@ -40,12 +40,43 @@ export const JournalRequestSchema = HarnessRequestBase.extend({ before: z.string().optional(), }); +// Drive Claude Code in a repo on the user's machine (M7). `repoId` is resolved +// server-side through the daemon's allowlist to an absolute path — the phone +// never sends a filesystem path. +export const CodeRequestSchema = HarnessRequestBase.extend({ + kind: z.literal('code'), + repoId: z.string().min(1), + prompt: z.string().min(1).max(8000), +}); + export const HarnessRequestSchema = z.discriminatedUnion('kind', [ AskRequestSchema, BriefingRequestSchema, JournalRequestSchema, + CodeRequestSchema, ]); +// ---- Approval control channel (M7 permission bridge) ---------------------- + +// Engine → phone: a mutating/risky tool call that Claude Code's auto mode +// escalated (via a PreToolUse hook). `id` correlates the response; `requestId` +// ties it to the parent `code` turn. +export const ApprovalRequestSchema = z.object({ + type: z.literal('approval_request'), + id: z.uuidv7(), + requestId: z.uuidv7(), + toolName: z.string(), + toolInput: z.record(z.string(), z.unknown()).optional(), + cwd: z.string().optional(), +}); + +// Phone → engine: the human's decision, echoed back to the hook. +export const ApprovalResponseSchema = z.object({ + type: z.literal('approval_response'), + id: z.uuidv7(), + decision: z.enum(APPROVAL_DECISIONS), +}); + // ---- Journal entries ------------------------------------------------------- // One past run in Lucid's journal — a briefing, weekly review, or ask. Sourced diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8a61163..7db1ec3 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -6,6 +6,7 @@ import { PROJECT_ACCESS_VALUES, SENDER_KINDS, HARNESS_REQUEST_KINDS, + APPROVAL_DECISIONS, } from './constants.js'; import { TaskSchema, @@ -54,7 +55,10 @@ import { AskRequestSchema, BriefingRequestSchema, JournalRequestSchema, + CodeRequestSchema, HarnessRequestSchema, + ApprovalRequestSchema, + ApprovalResponseSchema, JournalEntrySchema, AskResponseSchema, BriefingResponseSchema, @@ -107,9 +111,15 @@ export type HarnessRequestKind = (typeof HARNESS_REQUEST_KINDS)[number]; export type AskRequest = z.input; export type BriefingRequest = z.input; export type JournalRequest = z.input; +export type CodeRequest = z.input; export type HarnessRequest = z.infer; export type HarnessRequestInput = z.input; +// Approval control channel (M7 permission bridge) +export type ApprovalDecision = (typeof APPROVAL_DECISIONS)[number]; +export type ApprovalRequest = z.infer; +export type ApprovalResponse = z.infer; + export type JournalEntry = z.infer; export type AskResponse = z.infer; export type BriefingResponse = z.infer;