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
1 change: 1 addition & 0 deletions apps/daemon/src/chat/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion apps/daemon/src/room/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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) });
Expand Down
43 changes: 43 additions & 0 deletions packages/runtime/src/executor/claudeCode.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
58 changes: 54 additions & 4 deletions packages/runtime/src/executor/claudeCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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();
Expand All @@ -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'}`);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime/src/executor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

28 changes: 28 additions & 0 deletions packages/shared/src/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?' });
Expand Down Expand Up @@ -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' },
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export {
AskRequestSchema,
BriefingRequestSchema,
JournalRequestSchema,
CodeRequestSchema,
HarnessRequestSchema,
ApprovalRequestSchema,
ApprovalResponseSchema,
JournalEntrySchema,
AskResponseSchema,
BriefingResponseSchema,
Expand Down Expand Up @@ -111,8 +114,12 @@ export type {
AskRequest,
BriefingRequest,
JournalRequest,
CodeRequest,
HarnessRequest,
HarnessRequestInput,
ApprovalRequest,
ApprovalResponse,
ApprovalDecision,
JournalEntry,
AskResponse,
BriefingResponse,
Expand Down
33 changes: 32 additions & 1 deletion packages/shared/src/schemas/harness.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PROJECT_ACCESS_VALUES,
SENDER_KINDS,
HARNESS_REQUEST_KINDS,
APPROVAL_DECISIONS,
} from './constants.js';
import {
TaskSchema,
Expand Down Expand Up @@ -54,7 +55,10 @@ import {
AskRequestSchema,
BriefingRequestSchema,
JournalRequestSchema,
CodeRequestSchema,
HarnessRequestSchema,
ApprovalRequestSchema,
ApprovalResponseSchema,
JournalEntrySchema,
AskResponseSchema,
BriefingResponseSchema,
Expand Down Expand Up @@ -107,9 +111,15 @@ export type HarnessRequestKind = (typeof HARNESS_REQUEST_KINDS)[number];
export type AskRequest = z.input<typeof AskRequestSchema>;
export type BriefingRequest = z.input<typeof BriefingRequestSchema>;
export type JournalRequest = z.input<typeof JournalRequestSchema>;
export type CodeRequest = z.input<typeof CodeRequestSchema>;
export type HarnessRequest = z.infer<typeof HarnessRequestSchema>;
export type HarnessRequestInput = z.input<typeof HarnessRequestSchema>;

// Approval control channel (M7 permission bridge)
export type ApprovalDecision = (typeof APPROVAL_DECISIONS)[number];
export type ApprovalRequest = z.infer<typeof ApprovalRequestSchema>;
export type ApprovalResponse = z.infer<typeof ApprovalResponseSchema>;

export type JournalEntry = z.infer<typeof JournalEntrySchema>;
export type AskResponse = z.infer<typeof AskResponseSchema>;
export type BriefingResponse = z.infer<typeof BriefingResponseSchema>;
Expand Down