Skip to content

Commit 3957ccb

Browse files
authored
feat(crestodian): run CLI harnesses on the agent loop via a ring-zero MCP server (#100029)
CLI harnesses (claude-cli, gemini-cli) cannot enforce runtime toolsAllow, so Crestodian previously fell back to the single-turn planner for them. The openclaw-tools stdio MCP entry can now serve the ring-zero crestodian tool (OPENCLAW_TOOLS_MCP_TOOLS=crestodian), and Crestodian CLI runs inject it as the run's exclusive MCP surface: no loopback server, no plugin/user MCP servers, with the server kept under the "openclaw" name so existing tool pre-approvals apply. agent-turn routes configured or detected CLI-harness models through runCliAgent with native session resume across turns, keeps embedded toolsAllow enforcement for API-key models and the Codex app-server fallback, and still degrades to the planner when no loop backend works. The host-verified approval contract survives the process boundary: per-turn arming and the pending exact-operation hash travel to the stdio server via the generated MCP config env, and the host mirrors proposal transitions back from harness tool events (denial registers the hash, mismatch voids it, execution consumes it) so a generic yes can never authorize a different mutation. The trust model (the CLI owns its native tools) is documented in docs/cli/crestodian.md.
1 parent f8dd649 commit 3957ccb

13 files changed

Lines changed: 792 additions & 84 deletions

docs/cli/crestodian.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,45 @@ If none are available, setup still writes the default workspace and leaves the m
118118

119119
## Model-assisted planner
120120

121-
Crestodian always starts in deterministic mode. For fuzzy commands the deterministic parser does not understand, it can make one bounded planner turn through OpenClaw's normal runtime paths, using the configured OpenClaw model. If none is usable yet, it falls back to a local runtime already present on the machine:
121+
Interactive Crestodian is AI-first. Exact typed commands run instantly and deterministically. Every other message runs through the same embedded agent loop as regular OpenClaw agents, restricted to one ring-zero `crestodian` tool that wraps the typed operations: read actions run freely, mutations require your conversational yes for that exact operation, and every applied write is audited and re-validated. The agent session persists, so the custodian has real multi-turn memory. It first uses the configured OpenClaw model; with no usable model it falls back to a local runtime already present on the machine:
122122

123-
- Claude Code CLI: `claude-cli/claude-opus-4-8`
124-
- Codex app-server harness: `openai/gpt-5.5`
123+
- Claude Code CLI: `claude-cli/claude-opus-4-8` (agent loop; the ring-zero tool is served over MCP, see the trust model below)
124+
- Codex app-server harness: `openai/gpt-5.5` (agent loop with an enforced single-tool allow-list)
125125

126-
The planner cannot mutate config directly; it must translate the request into one of Crestodian's typed commands, and normal approval/audit rules apply. Crestodian prints the model it used and the interpreted command before running anything. Fallback planner turns are temporary, tool-disabled where the runtime supports it, and use a temporary workspace/session.
126+
When the agent loop is unavailable, Crestodian degrades to a bounded single-turn planner, and without any model to deterministic typed commands. The planner cannot mutate config directly; it must translate the request into one of Crestodian's typed commands, and normal approval/audit rules apply. Crestodian prints the model it used and the interpreted command before running anything. Fallback planner turns are temporary, tool-disabled where the runtime supports it, and use a temporary workspace/session.
127127

128128
Message-channel rescue mode never uses the model-assisted planner. Remote rescue stays deterministic so a broken or compromised normal agent path cannot be used as a config editor.
129129

130+
### CLI harness trust model
131+
132+
Embedded runtimes and the Codex app-server harness enforce the ring-zero
133+
restriction directly: the run carries a tool allow-list with only the
134+
`crestodian` tool. CLI harnesses (Claude Code, Gemini CLI) cannot enforce an
135+
OpenClaw tool allow-list — the CLI owns its native tools and its own permission
136+
policy, so OpenClaw fails closed if asked to restrict one. For CLI-harness
137+
models Crestodian instead:
138+
139+
- injects a dedicated MCP server that serves only the `crestodian` tool and
140+
replaces OpenClaw's normal MCP tool surface for the run (for Claude Code the
141+
generated config is applied with `--strict-mcp-config`, so no other MCP
142+
servers are loaded),
143+
- keeps every config mutation inside the tool's approval and audit contract —
144+
reads run freely, writes require your conversational yes, and every applied
145+
write is audited and re-validated,
146+
- leaves native tools (file reads, shell) to the harness. They follow the same
147+
permission posture as normal OpenClaw agent runs on this machine: with
148+
OpenClaw's default exec settings Claude Code runs with permissions bypassed,
149+
and a restricted `tools.exec` config falls back to the CLI's own permission
150+
policy.
151+
152+
Only Crestodian sessions get the crestodian MCP server; normal agent runs
153+
never see this tool. Treat a Crestodian session on a CLI-harness model like a
154+
normal local agent run on the same host: the ring-zero tool adds an audited,
155+
approval-gated path for config repair, but it does not prevent the harness's
156+
native tools from touching files directly. The Codex app-server fallback and
157+
API-key models enforce the strict single-tool loop; prefer those when you want
158+
the hard restriction.
159+
130160
## Switching to an agent
131161

132162
Use a natural-language selector to leave Crestodian and open the normal TUI:

src/agents/cli-runner/bundle-mcp.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,43 @@ describe("prepareCliBundleMcpConfig", () => {
4141
await prepared.cleanup?.();
4242
});
4343

44+
it("serves only the exclusive config, ignoring user and plugin servers", async () => {
45+
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
46+
"openclaw-cli-bundle-mcp-exclusive-",
47+
);
48+
const userConfig = path.join(workspaceDir, "user-mcp.json");
49+
await fs.writeFile(
50+
userConfig,
51+
`${JSON.stringify({ mcpServers: { user: { command: "node", args: ["user.mjs"] } } })}\n`,
52+
"utf-8",
53+
);
54+
55+
const prepared = await prepareCliBundleMcpConfig({
56+
enabled: true,
57+
mode: "claude-config-file",
58+
backend: {
59+
command: "node",
60+
args: ["./fake-claude.mjs", "--mcp-config", "user-mcp.json"],
61+
},
62+
workspaceDir,
63+
config: { plugins: { enabled: false } },
64+
exclusiveConfig: {
65+
mcpServers: { openclaw: { command: "node", args: ["crestodian.mjs"] } },
66+
},
67+
});
68+
69+
expect(prepared.backend.args).toContain("--strict-mcp-config");
70+
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
71+
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
72+
mcpServers?: Record<string, { args?: string[] }>;
73+
};
74+
expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]);
75+
expect(raw.mcpServers?.openclaw?.args).toEqual(["crestodian.mjs"]);
76+
expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/);
77+
78+
await prepared.cleanup?.();
79+
});
80+
4481
it("injects a merged --mcp-config overlay for bundle-MCP-enabled backends", async () => {
4582
const prepared = await prepareBundleProbeCliConfig();
4683

src/agents/cli-runner/bundle-mcp.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ export async function prepareCliBundleMcpConfig(params: {
182182
workspaceDir: string;
183183
config?: OpenClawConfig;
184184
additionalConfig?: BundleMcpConfig;
185+
/**
186+
* Serve exactly these servers, skipping user/plugin/additional merges.
187+
* Ring-zero Crestodian runs use this so the CLI harness sees only the
188+
* crestodian MCP server instead of the normal openclaw tool surface.
189+
*/
190+
exclusiveConfig?: BundleMcpConfig;
185191
env?: Record<string, string>;
186192
warn?: (message: string) => void;
187193
}): Promise<PreparedCliBundleMcpConfig> {
@@ -190,6 +196,14 @@ export async function prepareCliBundleMcpConfig(params: {
190196
}
191197

192198
const mode = resolveBundleMcpMode(params.mode);
199+
if (params.exclusiveConfig) {
200+
return await prepareModeSpecificBundleMcpConfig({
201+
mode,
202+
backend: params.backend,
203+
mergedConfig: params.exclusiveConfig,
204+
env: params.env,
205+
});
206+
}
193207
const resumeMcpConfigPaths =
194208
mode === "claude-config-file" ? findClaudeMcpConfigPaths(params.backend.resumeArgs) : [];
195209
const existingMcpConfigPaths =

src/agents/cli-runner/prepare.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2964,6 +2964,65 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
29642964
}
29652965
});
29662966

2967+
it("serves only the crestodian MCP server for ring-zero runs", async () => {
2968+
const { dir, sessionFile } = createSessionFile();
2969+
try {
2970+
const getActiveMcpLoopbackRuntime = vi.fn(() => undefined);
2971+
setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime });
2972+
cliBackendsTesting.setDepsForTest({
2973+
resolvePluginSetupCliBackend: () => undefined,
2974+
resolveRuntimeCliBackends: () => [
2975+
{
2976+
id: "claude-cli",
2977+
pluginId: "anthropic",
2978+
bundleMcp: true,
2979+
bundleMcpMode: "claude-config-file",
2980+
config: {
2981+
command: "claude",
2982+
args: ["--print"],
2983+
output: "jsonl",
2984+
jsonlDialect: "claude-stream-json",
2985+
input: "stdin",
2986+
sessionMode: "existing",
2987+
},
2988+
},
2989+
],
2990+
});
2991+
2992+
const context = await prepareCliRunContext({
2993+
sessionId: "session-test",
2994+
sessionFile,
2995+
workspaceDir: dir,
2996+
prompt: "latest ask",
2997+
provider: "claude-cli",
2998+
model: "test-model",
2999+
timeoutMs: 1_000,
3000+
runId: "run-test-crestodian-mcp",
3001+
config: createCliBackendConfig(),
3002+
crestodianTool: { surface: "cli" },
3003+
});
3004+
3005+
// Ring-zero runs never touch the loopback surface (no message tools).
3006+
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
3007+
expect(context.mcpDeliveryCapture).toBeUndefined();
3008+
const args = context.preparedBackend.backend.args ?? [];
3009+
expect(args).toContain("--strict-mcp-config");
3010+
const mcpConfigPath = args[args.indexOf("--mcp-config") + 1];
3011+
const raw = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8")) as {
3012+
mcpServers?: Record<string, { env?: Record<string, string> }>;
3013+
};
3014+
expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]);
3015+
expect(raw.mcpServers?.openclaw?.env).toMatchObject({
3016+
OPENCLAW_TOOLS_MCP_TOOLS: "crestodian",
3017+
OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE: "cli",
3018+
});
3019+
3020+
await context.preparedBackend.cleanup?.();
3021+
} finally {
3022+
fs.rmSync(dir, { recursive: true, force: true });
3023+
}
3024+
});
3025+
29673026
it("fails closed for native tool-capable CLI backends when tools are disabled", async () => {
29683027
const { dir, sessionFile } = createSessionFile();
29693028
try {

src/agents/cli-runner/prepare.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolveMcpLoopbackBearerToken,
2020
} from "../../gateway/mcp-http.loopback-runtime.js";
2121
import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js";
22+
import { buildCrestodianToolsMcpServerConfig } from "../../mcp/openclaw-tools-serve-config.js";
2223
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
2324
import type {
2425
CliBackendAuthEpochMode,
@@ -486,8 +487,18 @@ export async function prepareCliRunContext(
486487
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
487488
previousSignature: params.bootstrapPromptWarningSignature,
488489
});
490+
// Ring-zero Crestodian runs replace the bundle MCP surface entirely: no
491+
// loopback server, no plugin/user servers. The generated MCP config carries
492+
// only the crestodian stdio server, so the CLI harness sees exactly one
493+
// OpenClaw tool (its own native tools stay under the harness's policy).
494+
const crestodianMcpConfig = params.crestodianTool
495+
? buildCrestodianToolsMcpServerConfig(params.crestodianTool)
496+
: undefined;
489497
const bundleMcpEnabled =
490-
!isSideQuestion && backendResolved.bundleMcp && params.disableTools !== true;
498+
!isSideQuestion &&
499+
!crestodianMcpConfig &&
500+
backendResolved.bundleMcp &&
501+
params.disableTools !== true;
491502
let mcpLoopbackRuntime = bundleMcpEnabled ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
492503
if (bundleMcpEnabled && !mcpLoopbackRuntime) {
493504
try {
@@ -511,11 +522,12 @@ export async function prepareCliRunContext(
511522
undefined;
512523
try {
513524
const preparedBackend = await prepareCliBundleMcpConfig({
514-
enabled: bundleMcpEnabled,
525+
enabled: bundleMcpEnabled || crestodianMcpConfig !== undefined,
515526
mode: backendResolved.bundleMcpMode,
516527
backend: backendResolved.config,
517528
workspaceDir,
518529
config: params.config,
530+
...(crestodianMcpConfig ? { exclusiveConfig: crestodianMcpConfig } : {}),
519531
additionalConfig: mcpLoopbackRuntime
520532
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
521533
: undefined,

src/agents/cli-runner/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export type RunCliAgentParams = {
131131
approvalReviewerDeviceId?: string;
132132
/** Runtime tool allow-list. CLI harnesses fail closed when this is set. */
133133
toolsAllow?: string[];
134+
/**
135+
* Ring-zero Crestodian tool served over a dedicated stdio MCP server; set
136+
* only by the Crestodian agent runner. Replaces the normal bundle MCP
137+
* surface for the run — the harness still owns its native tools.
138+
*/
139+
crestodianTool?: import("../tools/crestodian-tool.js").CrestodianToolOptions;
134140
disableTools?: boolean;
135141
abortSignal?: AbortSignal;
136142
onExecutionStarted?: () => void;

src/agents/tools/crestodian-tool.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Crestodian ring-zero tool tests: approval gating, action mapping, verification.
22
import { afterEach, describe, expect, it, vi } from "vitest";
3-
import { createCrestodianTool } from "./crestodian-tool.js";
3+
import {
4+
createCrestodianTool,
5+
hashCrestodianOperation,
6+
resolveCrestodianProposalTransition,
7+
} from "./crestodian-tool.js";
48

59
const mocks = vi.hoisted(() => ({
610
executeCrestodianOperation: vi.fn(async (_op: unknown, runtime: { log: (m: string) => void }) => {
@@ -220,4 +224,35 @@ describe("crestodian tool", () => {
220224
const tool = createCrestodianTool({ surface: "cli" });
221225
await expect(tool.execute("t5", { action: "config_get" })).rejects.toThrow(/path/);
222226
});
227+
228+
it("mirrors proposal transitions for out-of-process (CLI MCP) hosts", () => {
229+
const args = { action: "set_default_model", model: "openai/gpt-5.5" };
230+
const hash = hashCrestodianOperation({ kind: "set-default-model", model: "openai/gpt-5.5" });
231+
232+
// Denial registers the exact-operation hash on the host.
233+
expect(
234+
resolveCrestodianProposalTransition({
235+
args,
236+
resultText: "needs-approval: this action changes state.",
237+
}),
238+
).toEqual({ proposal: hash });
239+
// A voided approval clears it.
240+
expect(
241+
resolveCrestodianProposalTransition({
242+
args,
243+
resultText: "approval-mismatch: this call is not the operation the user approved.",
244+
}),
245+
).toEqual({ proposal: undefined });
246+
// An executed mutation consumes it.
247+
expect(
248+
resolveCrestodianProposalTransition({ args, resultText: "Default model updated." }),
249+
).toEqual({ proposal: undefined });
250+
// Read actions and unparsable calls never touch the proposal.
251+
expect(
252+
resolveCrestodianProposalTransition({ args: { action: "status" }, resultText: "ok" }),
253+
).toBeNull();
254+
expect(
255+
resolveCrestodianProposalTransition({ args: { action: "bogus" }, resultText: "ok" }),
256+
).toBeNull();
257+
});
223258
});

src/agents/tools/crestodian-tool.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ export function hashCrestodianOperation(operation: CrestodianOperation): string
3636
return JSON.stringify(operation, Object.keys(operation).toSorted());
3737
}
3838

39+
/** Result markers shared with out-of-process hosts (CLI MCP runs). */
40+
export const CRESTODIAN_NEEDS_APPROVAL_PREFIX = "needs-approval:";
41+
export const CRESTODIAN_APPROVAL_MISMATCH_PREFIX = "approval-mismatch:";
42+
43+
/**
44+
* Mirror a proposalRef transition from an out-of-process tool result. CLI MCP
45+
* runs execute this tool in a stdio subprocess whose proposalRef dies with the
46+
* run; the host replays the same lifecycle from harness tool events: denial
47+
* registers the exact-operation hash, mismatch voids it, execution consumes it.
48+
*/
49+
export function resolveCrestodianProposalTransition(params: {
50+
args: Record<string, unknown>;
51+
resultText: string;
52+
}): { proposal: string | undefined } | null {
53+
let operation: CrestodianOperation;
54+
try {
55+
operation = operationForAction(params.args);
56+
} catch {
57+
return null;
58+
}
59+
if (!isPersistentCrestodianOperation(operation)) {
60+
return null;
61+
}
62+
if (params.resultText.startsWith(CRESTODIAN_APPROVAL_MISMATCH_PREFIX)) {
63+
return { proposal: undefined };
64+
}
65+
if (params.resultText.startsWith(CRESTODIAN_NEEDS_APPROVAL_PREFIX)) {
66+
return { proposal: hashCrestodianOperation(operation) };
67+
}
68+
// Executed or errored mutation: an armed approval is single-use either way.
69+
return { proposal: undefined };
70+
}
71+
3972
const CRESTODIAN_TOOL_ACTIONS = [
4073
"status",
4174
"models",
@@ -233,15 +266,15 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
233266
options.proposalRef.current = undefined;
234267
}
235268
return textResult(
236-
"approval-mismatch: this call is not the operation the user approved. The approval is void; describe the new change and get a fresh yes before retrying.",
269+
`${CRESTODIAN_APPROVAL_MISMATCH_PREFIX} this call is not the operation the user approved. The approval is void; describe the new change and get a fresh yes before retrying.`,
237270
{ needsApproval: true },
238271
);
239272
}
240273
if (options.proposalRef) {
241274
options.proposalRef.current = operationHash;
242275
}
243276
return textResult(
244-
"needs-approval: this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).",
277+
`${CRESTODIAN_NEEDS_APPROVAL_PREFIX} this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).`,
245278
{ needsApproval: true },
246279
);
247280
}

0 commit comments

Comments
 (0)