diff --git a/src/agents/bash-tools.exec-host-gateway.test.ts b/src/agents/bash-tools.exec-host-gateway.test.ts index 1247f2c90d6..eba74ccb43c 100644 --- a/src/agents/bash-tools.exec-host-gateway.test.ts +++ b/src/agents/bash-tools.exec-host-gateway.test.ts @@ -129,7 +129,7 @@ vi.mock("./bash-process-registry.js", () => ({ tail: vi.fn((value) => value), })); -vi.mock("../infra/exec-inline-eval.js", () => ({ +vi.mock("../infra/command-analysis/inline-eval.js", () => ({ describeInterpreterInlineEval: vi.fn(() => "python -c"), detectInterpreterInlineEvalArgv: detectInterpreterInlineEvalArgvMock, })); diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index e2690010ed0..63b4ccee82d 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { describeInterpreterInlineEval } from "../infra/command-analysis/inline-eval.js"; import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js"; import { addDurableCommandApproval, @@ -13,7 +14,6 @@ import { resolveApprovalAuditCandidatePath, requiresExecApproval, } from "../infra/exec-approvals.js"; -import { describeInterpreterInlineEval } from "../infra/exec-inline-eval.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; import { diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index c44ea7f7a7b..cabbcb26a2a 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -1,5 +1,9 @@ import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + describeInterpreterInlineEval, + type InterpreterInlineEvalHit, +} from "../infra/command-analysis/inline-eval.js"; import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js"; import { type ExecApprovalsFile, @@ -10,10 +14,6 @@ import { hasDurableExecApproval, resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; -import { - describeInterpreterInlineEval, - type InterpreterInlineEvalHit, -} from "../infra/exec-inline-eval.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js"; import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 08e9696d34e..8e58c4593ba 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -92,7 +92,7 @@ vi.mock("../infra/exec-approvals.js", () => ({ })), })); -vi.mock("../infra/exec-inline-eval.js", () => ({ +vi.mock("../infra/command-analysis/inline-eval.js", () => ({ describeInterpreterInlineEval: vi.fn(() => "inline-eval"), detectInterpreterInlineEvalArgv: detectInterpreterInlineEvalArgvMock, })); diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index e438bd8724c..5bafbb2d8b9 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -1,8 +1,4 @@ -import { - summarizeCommandSegmentsForDisplay, - type CommandExplanationSummary, -} from "../../infra/command-analysis/explain.js"; -import { analyzeCommandForPolicy } from "../../infra/command-analysis/policy.js"; +import { resolveCommandAnalysisSummaryForDisplay } from "../../infra/command-analysis/explain.js"; import { resolveExecApprovalCommandDisplay, sanitizeExecApprovalDisplayText, @@ -47,43 +43,6 @@ const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = { } as const; const RESERVED_PLUGIN_APPROVAL_ID_PREFIX = "plugin:"; -function sanitizeCommandAnalysisSummary( - summary: CommandExplanationSummary, -): CommandExplanationSummary { - return { - commandCount: summary.commandCount, - nestedCommandCount: summary.nestedCommandCount, - riskKinds: summary.riskKinds.map((kind) => sanitizeExecApprovalWarningText(kind)), - warningLines: summary.warningLines.map((line) => sanitizeExecApprovalWarningText(line)), - }; -} - -function resolveExecApprovalCommandAnalysis(params: { - host: string; - commandText: string; - commandArgv?: string[]; - cwd?: string | null; -}): CommandExplanationSummary | null { - const analysis = - Array.isArray(params.commandArgv) && params.commandArgv.length > 0 - ? analyzeCommandForPolicy({ - source: "argv", - argv: params.commandArgv, - cwd: params.cwd ?? undefined, - }) - : params.host === "node" - ? null - : analyzeCommandForPolicy({ - source: "shell", - command: params.commandText, - cwd: params.cwd ?? undefined, - }); - if (!analysis?.ok) { - return null; - } - return sanitizeCommandAnalysisSummary(summarizeCommandSegmentsForDisplay(analysis.segments)); -} - type ExecApprovalIosPushDelivery = { handleRequested?: (request: ExecApprovalRequest) => Promise; handleResolved?: (resolved: ExecApprovalResolved) => Promise; @@ -249,11 +208,12 @@ export function createExecApprovalHandlers( } const envBinding = buildSystemRunApprovalEnvBinding(p.env); const warningText = normalizeOptionalString(p.warningText); - const commandAnalysis = resolveExecApprovalCommandAnalysis({ + const commandAnalysis = resolveCommandAnalysisSummaryForDisplay({ host, commandText: effectiveCommandText, commandArgv: effectiveCommandArgv, cwd: effectiveCwd, + sanitizeText: sanitizeExecApprovalWarningText, }); const systemRunBinding = host === "node" diff --git a/src/infra/command-analysis/explain.test.ts b/src/infra/command-analysis/explain.test.ts index 2eff34f8dd8..043e9ed6972 100644 --- a/src/infra/command-analysis/explain.test.ts +++ b/src/infra/command-analysis/explain.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { explainShellCommand } from "../command-explainer/index.js"; -import { summarizeCommandExplanation, summarizeCommandSegmentsForDisplay } from "./explain.js"; +import { + resolveCommandAnalysisSummaryForDisplay, + summarizeCommandExplanation, + summarizeCommandSegmentsForDisplay, +} from "./explain.js"; describe("command-analysis explanation summary", () => { it("summarizes commands and risk kinds", async () => { @@ -26,4 +30,33 @@ describe("command-analysis explanation summary", () => { expect(summary.riskKinds).toEqual(["inline-eval"]); expect(summary.warningLines).toEqual(["Contains inline-eval: python3 -c"]); }); + + it("resolves display summaries from argv or shell commands", () => { + expect( + resolveCommandAnalysisSummaryForDisplay({ + host: "gateway", + commandText: "echo ok", + commandArgv: ["python3", "-c", "print(1)"], + }), + ).toEqual( + expect.objectContaining({ + commandCount: 1, + riskKinds: ["inline-eval"], + warningLines: ["Contains inline-eval: python3 -c"], + }), + ); + expect( + resolveCommandAnalysisSummaryForDisplay({ + host: "node", + commandText: "python3 -c 'print(1)'", + }), + ).toBeNull(); + expect( + resolveCommandAnalysisSummaryForDisplay({ + host: "gateway", + commandText: "python3 -c 'print(1)'", + sanitizeText: (value) => value.replaceAll("python3", "python"), + })?.warningLines, + ).toEqual(["Contains inline-eval: python -c"]); + }); }); diff --git a/src/infra/command-analysis/explain.ts b/src/infra/command-analysis/explain.ts index 8bdb00a8479..4d28d5a660b 100644 --- a/src/infra/command-analysis/explain.ts +++ b/src/infra/command-analysis/explain.ts @@ -1,6 +1,7 @@ import { explainShellCommand } from "../command-explainer/extract.js"; import type { CommandExplanation, CommandRisk } from "../command-explainer/types.js"; import type { ExecCommandSegment } from "../exec-approvals-analysis.js"; +import { analyzeCommandForPolicy } from "./policy.js"; import { detectCommandCarrierArgv, detectInlineEvalInSegments } from "./risks.js"; export type CommandExplanationSummary = { @@ -80,6 +81,43 @@ export function summarizeCommandSegmentsForDisplay( }; } +export function resolveCommandAnalysisSummaryForDisplay(params: { + host?: string | null; + commandText: string; + commandArgv?: string[]; + cwd?: string | null; + sanitizeText?: (value: string) => string; +}): CommandExplanationSummary | null { + const analysis = + Array.isArray(params.commandArgv) && params.commandArgv.length > 0 + ? analyzeCommandForPolicy({ + source: "argv", + argv: params.commandArgv, + cwd: params.cwd ?? undefined, + }) + : params.host === "node" + ? null + : analyzeCommandForPolicy({ + source: "shell", + command: params.commandText, + cwd: params.cwd ?? undefined, + }); + if (!analysis?.ok) { + return null; + } + const summary = summarizeCommandSegmentsForDisplay(analysis.segments); + const sanitizeText = params.sanitizeText; + if (!sanitizeText) { + return summary; + } + return { + commandCount: summary.commandCount, + nestedCommandCount: summary.nestedCommandCount, + riskKinds: summary.riskKinds.map((kind) => sanitizeText(kind)), + warningLines: summary.warningLines.map((line) => sanitizeText(line)), + }; +} + export async function explainCommandForDisplay( command: string, ): Promise<{ explanation: CommandExplanation; summary: CommandExplanationSummary } | null> { diff --git a/src/infra/exec-inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts similarity index 99% rename from src/infra/exec-inline-eval.test.ts rename to src/infra/command-analysis/inline-eval.test.ts index 35cf9678d49..8c684da53c6 100644 --- a/src/infra/exec-inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -3,7 +3,7 @@ import { describeInterpreterInlineEval, detectInterpreterInlineEvalArgv, isInterpreterLikeAllowlistPattern, -} from "./exec-inline-eval.js"; +} from "./inline-eval.js"; describe("exec inline eval detection", () => { it.each([ diff --git a/src/infra/exec-inline-eval.ts b/src/infra/command-analysis/inline-eval.ts similarity index 98% rename from src/infra/exec-inline-eval.ts rename to src/infra/command-analysis/inline-eval.ts index db7ee1907d3..8c21ec01213 100644 --- a/src/infra/exec-inline-eval.ts +++ b/src/infra/command-analysis/inline-eval.ts @@ -1,5 +1,5 @@ -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { normalizeExecutableToken } from "./exec-wrapper-resolution.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; export type InterpreterInlineEvalHit = { executable: string; diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index ca5c5b91e33..3751b05e60d 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -1,15 +1,12 @@ import { splitShellArgs } from "../../utils/shell-argv.js"; import { unwrapKnownDispatchWrapperInvocation } from "../dispatch-wrapper-resolution.js"; import type { ExecCommandSegment } from "../exec-approvals-analysis.js"; -import { - detectInterpreterInlineEvalArgv, - type InterpreterInlineEvalHit, -} from "../exec-inline-eval.js"; import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; import { extractShellWrapperInlineCommand, isShellWrapperExecutable, } from "../shell-wrapper-resolution.js"; +import { detectInterpreterInlineEvalArgv, type InterpreterInlineEvalHit } from "./inline-eval.js"; export const COMMAND_CARRIER_EXECUTABLES = new Set(["sudo", "doas", "env", "command", "builtin"]); diff --git a/src/infra/command-explainer/extract.ts b/src/infra/command-explainer/extract.ts index 043b1c9200a..02097f13f58 100644 --- a/src/infra/command-explainer/extract.ts +++ b/src/infra/command-explainer/extract.ts @@ -1,4 +1,5 @@ import type { Node as TreeSitterNode } from "web-tree-sitter"; +import type { InterpreterInlineEvalHit } from "../command-analysis/inline-eval.js"; import { detectCarriedShellBuiltinArgv, detectCommandCarrierArgv, @@ -6,7 +7,6 @@ import { detectShellWrapperThroughCarrierArgv, SOURCE_EXECUTABLES, } from "../command-analysis/risks.js"; -import type { InterpreterInlineEvalHit } from "../exec-inline-eval.js"; import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; import { extractShellWrapperCommand, diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index c5b15639e9d..01a436f43b2 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { isInterpreterLikeAllowlistPattern } from "./command-analysis/inline-eval.js"; import { detectInlineEvalArgv } from "./command-analysis/risks.js"; import { isDispatchWrapperExecutable } from "./dispatch-wrapper-resolution.js"; import { @@ -23,7 +24,6 @@ import { type ShellChainOperator, } from "./exec-approvals-analysis.js"; import type { ExecAllowlistEntry } from "./exec-approvals.types.js"; -import { isInterpreterLikeAllowlistPattern } from "./exec-inline-eval.js"; import { DEFAULT_SAFE_BINS, SAFE_BIN_PROFILES, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 114da3d9eb6..7c0cc14b2a1 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1212,7 +1212,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); it("requires explicit approval for strict inline-eval carriers", async () => { - // The full carrier matrix lives in exec-inline-eval.test.ts; this is the + // The full carrier matrix lives in command-analysis tests; this is the // handle-level smoke for strictInlineEval denial wiring. const cases = [ { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index f5dbdee8023..63488591171 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,6 +1,10 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayClient } from "../gateway/client.js"; +import { + describeInterpreterInlineEval, + type InterpreterInlineEvalHit, +} from "../infra/command-analysis/inline-eval.js"; import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js"; import { addDurableCommandApproval, @@ -15,10 +19,6 @@ import { type ExecSecurity, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; -import { - describeInterpreterInlineEval, - type InterpreterInlineEvalHit, -} from "../infra/exec-inline-eval.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; import { extractEnvAssignmentKeysFromDispatchWrappers, diff --git a/src/security/audit.ts b/src/security/audit.ts index b569a36bee2..f47587f3497 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -4,8 +4,8 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; +import { isInterpreterLikeAllowlistPattern } from "../infra/command-analysis/inline-eval.js"; import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js"; -import { isInterpreterLikeAllowlistPattern } from "../infra/exec-inline-eval.js"; import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures,