refactor: route inline eval through command analysis

This commit is contained in:
Peter Steinberger
2026-05-03 13:22:54 +01:00
parent 99176e1950
commit bd0e10a2f6
15 changed files with 94 additions and 66 deletions

View File

@@ -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,
}));

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,
}));

View File

@@ -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<boolean>;
handleResolved?: (resolved: ExecApprovalResolved) => Promise<void>;
@@ -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"

View File

@@ -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"]);
});
});

View File

@@ -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> {

View File

@@ -3,7 +3,7 @@ import {
describeInterpreterInlineEval,
detectInterpreterInlineEvalArgv,
isInterpreterLikeAllowlistPattern,
} from "./exec-inline-eval.js";
} from "./inline-eval.js";
describe("exec inline eval detection", () => {
it.each([

View File

@@ -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;

View File

@@ -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"]);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 = [
{

View File

@@ -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,

View File

@@ -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,