diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 4f0b69f8e0a..76fc66262cb 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -96,6 +96,14 @@ function stdoutText(): string { return stdoutChunks.join(""); } +function capturedJsonOutput(): T { + const json = stdoutText() + .split("\n") + .filter((line) => line && !line.startsWith("RUN_ID:") && !line.startsWith("PROGRESS:")) + .join("\n"); + return JSON.parse(json) as T; +} + function minimalBlueprint(overrides?: Record): Record { return { version: "1.0", @@ -486,6 +494,57 @@ describe("runner", () => { expect(plan.dry_run).toBe(false); }); + it("does not expose credential field names or secret values in public plan output", async () => { + captureStdout(); + mockExeca.mockResolvedValue({ exitCode: 0 }); + const bp = { + components: { + inference: { + profiles: { + secrets: { + provider_type: "openai", + provider_name: "secret-provider", + endpoint: "https://api.example.com/v1", + model: "gpt-4", + credential_env: "SECRET_KEY", + credential_default: "default-secret-value", + token: "future-token-value", + authorization: "Bearer future-authorization", + }, + }, + }, + sandbox: { image: "openclaw", name: "sb", forward_ports: [18789] }, + }, + }; + process.env.SECRET_KEY = "real-secret-value"; + try { + const plan = await actionPlan("secrets", bp); + const rendered = capturedJsonOutput<{ inference: Record }>(); + const out = stdoutText(); + + expect(plan.inference).not.toHaveProperty("credential_env"); + expect(rendered.inference).toEqual({ + provider_type: "openai", + provider_name: "secret-provider", + endpoint: "https://api.example.com/v1", + model: "gpt-4", + }); + for (const leaked of [ + "credential_env", + "credential_default", + "SECRET_KEY", + "default-secret-value", + "real-secret-value", + "future-token-value", + "future-authorization", + ]) { + expect(out).not.toContain(leaked); + } + } finally { + delete process.env.SECRET_KEY; + } + }); + it("passes dryRun through to the plan", async () => { captureStdout(); mockExeca.mockResolvedValue({ exitCode: 0 }); @@ -820,17 +879,20 @@ describe("runner", () => { expect(plan.timestamp).toBeDefined(); }); - it("excludes secret fields from persisted plan.json", async () => { + it("persists only the explicit safe plan schema", async () => { const bp = { components: { inference: { profiles: { secrets: { provider_type: "openai", + provider_name: "secret-provider", endpoint: "https://api.example.com", model: "gpt-4", credential_env: "SECRET_KEY", credential_default: "default-secret-value", + token: "future-token-value", + authorization: "Bearer future-authorization", }, }, }, @@ -850,11 +912,29 @@ describe("runner", () => { if (!entry?.content) throw new Error("plan.json has no content"); const persisted = JSON.parse(entry.content); - expect(persisted.inference).not.toHaveProperty("credential_env"); - expect(persisted.inference).not.toHaveProperty("credential_default"); - // Ensure non-secret fields are still present - expect(persisted.inference.provider_type).toBe("openai"); - expect(persisted.inference.endpoint).toBe("https://api.example.com"); + expect(Object.keys(persisted).sort()).toEqual( + ["inference", "policy_additions", "profile", "run_id", "sandbox_name", "timestamp"].sort(), + ); + expect(Object.keys(persisted.inference).sort()).toEqual( + ["endpoint", "model", "provider_name", "provider_type"].sort(), + ); + expect(persisted.inference).toEqual({ + provider_type: "openai", + provider_name: "secret-provider", + endpoint: "https://api.example.com", + model: "gpt-4", + }); + for (const leaked of [ + "credential_env", + "credential_default", + "SECRET_KEY", + "default-secret-value", + "real-secret", + "future-token-value", + "future-authorization", + ]) { + expect(entry.content).not.toContain(leaked); + } }); it("emits all progress milestones", async () => { @@ -1107,6 +1187,90 @@ describe("runner", () => { expect(stdoutText()).toContain('"nc-run-1"'); }); + it("re-renders only safe allowlisted fields from plan.json", () => { + const rid = "nc-run-sensitive"; + addDir(`${RUNS_DIR}/${rid}`); + addFile( + `${RUNS_DIR}/${rid}/plan.json`, + JSON.stringify({ + run_id: rid, + profile: "default", + sandbox: { + image: "openclaw", + name: "sb", + forward_ports: [18789], + token: "sandbox-token-value", + }, + sandbox_name: "sb", + policy_additions: {}, + inference: { + provider_type: "openai", + provider_name: "secret-provider", + endpoint: "https://api.example.com/v1", + model: "gpt-4", + credential_env: "SECRET_KEY", + credential_default: "default-secret-value", + token: "future-token-value", + authorization: "Bearer future-authorization", + }, + router: { + enabled: true, + port: 4000, + pool_config_path: "router/pool-config.yaml", + authorization: "router-authorization", + }, + timestamp: "2026-05-17T00:00:00.000Z", + dry_run: false, + token: "top-level-token-value", + authorization: "Bearer top-level-authorization", + future_sensitive_field: { api_key: "future-api-key" }, + }), + ); + + actionStatus(rid); + + expect(capturedJsonOutput()).toEqual({ + run_id: rid, + profile: "default", + sandbox: { + image: "openclaw", + name: "sb", + forward_ports: [18789], + }, + sandbox_name: "sb", + policy_additions: {}, + inference: { + provider_type: "openai", + provider_name: "secret-provider", + endpoint: "https://api.example.com/v1", + model: "gpt-4", + }, + router: { + enabled: true, + port: 4000, + pool_config_path: "router/pool-config.yaml", + }, + timestamp: "2026-05-17T00:00:00.000Z", + dry_run: false, + }); + const out = stdoutText(); + for (const leaked of [ + "credential_env", + "credential_default", + "SECRET_KEY", + "default-secret-value", + "future-token-value", + "future-authorization", + "sandbox-token-value", + "router-authorization", + "top-level-token-value", + "top-level-authorization", + "future-api-key", + ]) { + expect(out).not.toContain(leaked); + } + }); + it("prints unknown status when plan.json is missing", () => { addDir(`${RUNS_DIR}/nc-run-1`); @@ -1114,6 +1278,15 @@ describe("runner", () => { expect(stdoutText()).toContain('"status":"unknown"'); }); + it("prints unknown status when plan.json is corrupt", () => { + addDir(`${RUNS_DIR}/nc-run-1`); + addFile(`${RUNS_DIR}/nc-run-1/plan.json`, "{not valid json"); + + actionStatus("nc-run-1"); + + expect(capturedJsonOutput()).toEqual({ run_id: "nc-run-1", status: "unknown" }); + }); + // ── Path traversal rejection ────────────────────────────────── it.each([ diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index ff4f7f553f1..1ec2294cd6e 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -463,7 +463,6 @@ export interface RunPlan { provider_name: string | undefined; endpoint: string | undefined; model: string | undefined; - credential_env: string | undefined; }; router: { enabled: boolean; @@ -474,6 +473,181 @@ export interface RunPlan { dry_run: boolean; } +interface SafeInferencePlan { + provider_type: string | undefined; + provider_name: string | undefined; + endpoint: string | undefined; + model: string | undefined; +} + +interface PersistedRunPlan { + run_id: string; + profile: string; + sandbox_name: string; + policy_additions: PolicyAdditions; + inference: SafeInferencePlan; + timestamp: string; +} + +type StatusRunPlan = { + run_id: string; + profile?: string; + sandbox?: { + image?: string; + name?: string; + forward_ports?: number[]; + }; + sandbox_name?: string; + policy_additions?: PolicyAdditions; + inference?: SafeInferencePlan; + router?: { + enabled?: boolean; + port?: number; + pool_config_path?: string; + }; + timestamp?: string; + dry_run?: boolean; +}; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function buildSafeInferencePlan(source: InferenceProfile | unknown): SafeInferencePlan { + const record = isObjectLike(source) ? source : {}; + return { + provider_type: optionalString(record.provider_type), + provider_name: optionalString(record.provider_name), + endpoint: optionalString(record.endpoint), + model: optionalString(record.model), + }; +} + +function buildSafePublicRunPlan(args: { + runId: string; + profile: string; + inferenceCfg: InferenceProfile; + sandboxCfg: SandboxConfig; + routerCfg: RouterConfig; + policyAdditions: PolicyAdditions; + dryRun: boolean; +}): RunPlan { + const routerEnabled = args.routerCfg.enabled === true; + const routerPort = args.routerCfg.port ?? DEFAULT_ROUTER_PORT; + + return { + run_id: args.runId, + profile: args.profile, + sandbox: { + image: args.sandboxCfg.image ?? "openclaw", + name: args.sandboxCfg.name ?? "openclaw", + forward_ports: args.sandboxCfg.forward_ports ?? [DASHBOARD_PORT], + }, + inference: buildSafeInferencePlan(args.inferenceCfg), + router: { + enabled: routerEnabled, + port: routerPort, + pool_config_path: args.routerCfg.pool_config_path, + }, + policy_additions: args.policyAdditions, + dry_run: args.dryRun, + }; +} + +function buildPersistedRunPlan(args: { + runId: string; + profile: string; + sandboxName: string; + policyAdditions: PolicyAdditions; + inferenceCfg: InferenceProfile; + timestamp: string; +}): PersistedRunPlan { + return { + run_id: args.runId, + profile: args.profile, + sandbox_name: args.sandboxName, + policy_additions: args.policyAdditions, + inference: buildSafeInferencePlan(args.inferenceCfg), + timestamp: args.timestamp, + }; +} + +function buildStatusRunPlan(source: unknown, fallbackRunId: string): StatusRunPlan | null { + if (!isObjectLike(source)) { + return null; + } + + const safePlan: StatusRunPlan = { + run_id: optionalString(source.run_id) ?? fallbackRunId, + }; + + const profile = optionalString(source.profile); + if (profile !== undefined) { + safePlan.profile = profile; + } + + if (isObjectLike(source.sandbox)) { + const sandbox: StatusRunPlan["sandbox"] = {}; + const image = optionalString(source.sandbox.image); + const name = optionalString(source.sandbox.name); + const forwardPorts = isOptionalPortList(source.sandbox.forward_ports) + ? source.sandbox.forward_ports + : undefined; + if (image !== undefined) { + sandbox.image = image; + } + if (name !== undefined) { + sandbox.name = name; + } + if (forwardPorts !== undefined) { + sandbox.forward_ports = forwardPorts; + } + if (Object.keys(sandbox).length > 0) { + safePlan.sandbox = sandbox; + } + } + + const sandboxName = optionalString(source.sandbox_name); + if (sandboxName !== undefined) { + safePlan.sandbox_name = sandboxName; + } + + if (isPolicyAdditions(source.policy_additions)) { + safePlan.policy_additions = source.policy_additions; + } + + if (isObjectLike(source.inference)) { + safePlan.inference = buildSafeInferencePlan(source.inference); + } + + if (isObjectLike(source.router)) { + const router: StatusRunPlan["router"] = {}; + if (typeof source.router.enabled === "boolean") { + router.enabled = source.router.enabled; + } + if (isValidPort(source.router.port)) { + router.port = source.router.port; + } + const poolConfigPath = optionalString(source.router.pool_config_path); + if (poolConfigPath !== undefined) { + router.pool_config_path = poolConfigPath; + } + if (Object.keys(router).length > 0) { + safePlan.router = router; + } + } + + const timestamp = optionalString(source.timestamp); + if (timestamp !== undefined) { + safePlan.timestamp = timestamp; + } + if (typeof source.dry_run === "boolean") { + safePlan.dry_run = source.dry_run; + } + + return safePlan; +} + export async function actionPlan( profile: string, blueprint: Blueprint, @@ -495,32 +669,15 @@ export async function actionPlan( ); } - const routerEnabled = routerCfg.enabled === true; - const routerPort = routerCfg.port ?? DEFAULT_ROUTER_PORT; - - const plan: RunPlan = { - run_id: rid, + const plan = buildSafePublicRunPlan({ + runId: rid, profile, - sandbox: { - image: sandboxCfg.image ?? "openclaw", - name: sandboxCfg.name ?? "openclaw", - forward_ports: sandboxCfg.forward_ports ?? [DASHBOARD_PORT], - }, - inference: { - provider_type: inferenceCfg.provider_type, - provider_name: inferenceCfg.provider_name, - endpoint: inferenceCfg.endpoint, - model: inferenceCfg.model, - credential_env: inferenceCfg.credential_env, - }, - router: { - enabled: routerEnabled, - port: routerPort, - pool_config_path: routerCfg.pool_config_path, - }, - policy_additions: blueprint.components?.policy?.additions ?? {}, - dry_run: options?.dryRun ?? false, - }; + inferenceCfg, + sandboxCfg, + routerCfg, + policyAdditions: blueprint.components?.policy?.additions ?? {}, + dryRun: options?.dryRun ?? false, + }); progress(100, "Plan complete"); log(JSON.stringify(plan, null, 2)); @@ -661,20 +818,14 @@ export async function actionApply( writeFileSync( join(stateDir, "plan.json"), JSON.stringify( - { - run_id: rid, + buildPersistedRunPlan({ + runId: rid, profile, - sandbox_name: sandboxName, - policy_additions: policyAdditions, - inference: { - provider_type: inferenceCfg.provider_type, - provider_name: inferenceCfg.provider_name, - endpoint: inferenceCfg.endpoint, - model: inferenceCfg.model, - // Omit credential_env and credential_default — secrets must not be persisted - }, + sandboxName, + policyAdditions, + inferenceCfg, timestamp: new Date().toISOString(), - }, + }), null, 2, ), @@ -724,10 +875,16 @@ export function actionStatus(rid?: string): void { runDir = join(runsDir, runs[0]); } + const name = runDir.split("/").pop() ?? "unknown"; try { - log(readFileSync(join(runDir, "plan.json"), "utf-8")); + const planData = readFileSync(join(runDir, "plan.json"), "utf-8"); + const parsedPlan: unknown = JSON.parse(planData); + const safePlan = buildStatusRunPlan(parsedPlan, name); + if (!safePlan) { + throw new Error("plan.json must contain a JSON object"); + } + log(JSON.stringify(safePlan, null, 2)); } catch { - const name = runDir.split("/").pop() ?? "unknown"; log(JSON.stringify({ run_id: name, status: "unknown" })); } }