mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 08:06:07 +00:00
fix(exec): bind node auto-review to prepared plans
This commit is contained in:
@@ -66,6 +66,7 @@ const resolveExecApprovalsFromFileMock = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
@@ -117,7 +118,7 @@ vi.mock("../infra/exec-approvals.js", () => ({
|
||||
evaluateShellAllowlist: evaluateShellAllowlistMock,
|
||||
commandRequiresSecurityAuditSuppressionApproval:
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock,
|
||||
hasDurableExecApproval: vi.fn(() => false),
|
||||
hasDurableExecApproval: hasDurableExecApprovalMock,
|
||||
requiresExecApproval: requiresExecApprovalMock,
|
||||
resolveExecApprovalAllowedDecisions: vi.fn(() => ["allow-once", "allow-always", "deny"]),
|
||||
resolveExecApprovalsFromFile: resolveExecApprovalsFromFileMock,
|
||||
@@ -318,6 +319,8 @@ describe("executeNodeHostCommand", () => {
|
||||
});
|
||||
requiresExecApprovalMock.mockReset();
|
||||
requiresExecApprovalMock.mockReturnValue(true);
|
||||
hasDurableExecApprovalMock.mockReset();
|
||||
hasDurableExecApprovalMock.mockReturnValue(false);
|
||||
resolveExecHostApprovalContextMock.mockReset();
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
@@ -425,6 +428,10 @@ describe("executeNodeHostCommand", () => {
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "bun ./script.ts",
|
||||
@@ -466,6 +473,614 @@ describe("executeNodeHostCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reviews the prepared node plan before suppressing human approval", async () => {
|
||||
const divergentPlan = {
|
||||
argv: ["rm", "-rf", "/tmp/work"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: "rm -rf /tmp/work",
|
||||
commandPreview: "./scripts/check_mail.sh --limit 5",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: divergentPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const nodeAllowlist = [{ pattern: "./scripts/check_mail.sh" }];
|
||||
resolveExecApprovalsFromFileMock.mockReturnValue({
|
||||
allowlist: nodeAllowlist,
|
||||
file: { version: 1, agents: {} },
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation(
|
||||
(params?: { command?: string; allowlist?: unknown[] }) => {
|
||||
const command = params?.command ?? "";
|
||||
const hasNodeAllowlist = Array.isArray(params?.allowlist) && params.allowlist.length > 0;
|
||||
const previewMatch = command === "./scripts/check_mail.sh --limit 5";
|
||||
return {
|
||||
allowlistMatches: previewMatch && hasNodeAllowlist ? [{}] : [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: previewMatch && hasNodeAllowlist,
|
||||
segments: [
|
||||
previewMatch
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["./scripts/check_mail.sh", "--limit", "5"],
|
||||
raw: "./scripts/check_mail.sh --limit 5",
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["rm", "-rf", "/tmp/work"],
|
||||
raw: "rm -rf /tmp/work",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: previewMatch && hasNodeAllowlist ? [{}] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async (input) =>
|
||||
input.command.includes("rm -rf")
|
||||
? {
|
||||
decision: "ask",
|
||||
risk: "high",
|
||||
rationale: "destructive prepared plan",
|
||||
}
|
||||
: {
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "safe requested text",
|
||||
},
|
||||
);
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "echo SAFE",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("approval-pending");
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "rm -rf /tmp/work",
|
||||
argv: ["rm", "-rf", "/tmp/work"],
|
||||
agent: {
|
||||
id: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalled();
|
||||
expect(callGatewayToolMock).not.toHaveBeenCalledWith(
|
||||
"exec.approval.resolve",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors node allowlist matches on prepared POSIX shell payloads", async () => {
|
||||
const wrapperPlan = {
|
||||
argv: ["/bin/sh", "-lc", "./scripts/check_mail.sh --limit 5"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "./scripts/check_mail.sh --limit 5"`,
|
||||
commandPreview: "./scripts/check_mail.sh --limit 5",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: wrapperPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const nodeAllowlist = [{ pattern: "./scripts/check_mail.sh" }];
|
||||
resolveExecApprovalsFromFileMock.mockReturnValue({
|
||||
allowlist: nodeAllowlist,
|
||||
file: { version: 1, agents: {} },
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation(
|
||||
(params?: { command?: string; allowlist?: unknown[] }) => {
|
||||
const command = params?.command ?? "";
|
||||
const hasNodeAllowlist = Array.isArray(params?.allowlist) && params.allowlist.length > 0;
|
||||
const semanticMatch = command === "./scripts/check_mail.sh --limit 5";
|
||||
return {
|
||||
allowlistMatches: semanticMatch && hasNodeAllowlist ? [{}] : [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: semanticMatch && hasNodeAllowlist,
|
||||
segments: [
|
||||
command.startsWith("/bin/sh")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "./scripts/check_mail.sh --limit 5"],
|
||||
raw: `/bin/sh -lc "./scripts/check_mail.sh --limit 5"`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["./scripts/check_mail.sh", "--limit", "5"],
|
||||
raw: "./scripts/check_mail.sh --limit 5",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: semanticMatch && hasNodeAllowlist ? [{}] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "should not be needed",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "./scripts/check_mail.sh --limit 5",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(autoReviewer).not.toHaveBeenCalled();
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(resolveExecApprovalsFromFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: "prepared-agent" }),
|
||||
);
|
||||
expectSystemRunInvoke({ invokeTimeoutMs: 35_000, runTimeoutMs: 30_000 });
|
||||
});
|
||||
|
||||
it("does not let transport wrapper allowlist matches approve shell payloads", async () => {
|
||||
const wrapperPlan = {
|
||||
argv: ["/bin/sh", "-lc", "./scripts/untrusted.sh"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "./scripts/untrusted.sh"`,
|
||||
commandPreview: "./scripts/untrusted.sh",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: wrapperPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const nodeAllowlist = [{ pattern: "/bin/sh" }];
|
||||
resolveExecApprovalsFromFileMock.mockReturnValue({
|
||||
allowlist: nodeAllowlist,
|
||||
file: { version: 1, agents: {} },
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation(
|
||||
(params?: { command?: string; allowlist?: unknown[] }) => {
|
||||
const command = params?.command ?? "";
|
||||
const hasNodeAllowlist = Array.isArray(params?.allowlist) && params.allowlist.length > 0;
|
||||
const wrapperMatch = command.startsWith("/bin/sh") && hasNodeAllowlist;
|
||||
return {
|
||||
allowlistMatches: wrapperMatch ? [{}] : [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: wrapperMatch,
|
||||
segments: [
|
||||
command.startsWith("/bin/sh")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "./scripts/untrusted.sh"],
|
||||
raw: `/bin/sh -lc "./scripts/untrusted.sh"`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["./scripts/untrusted.sh"],
|
||||
raw: "./scripts/untrusted.sh",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: wrapperMatch ? [{}] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
hasDurableExecApprovalMock.mockImplementation(
|
||||
(params?: { segmentAllowlistEntries?: unknown[] }) =>
|
||||
Array.isArray(params?.segmentAllowlistEntries) && params.segmentAllowlistEntries.length > 0,
|
||||
);
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "inner payload is not allowlisted",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "./scripts/untrusted.sh",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("approval-pending");
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: `/bin/sh -lc "./scripts/untrusted.sh"`,
|
||||
argv: ["./scripts/untrusted.sh"],
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses exact durable approvals for prepared shell wrappers", async () => {
|
||||
const wrapperPlan = {
|
||||
argv: ["/bin/sh", "-lc", "cd ."],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "cd ."`,
|
||||
commandPreview: "cd .",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: wrapperPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
resolveExecApprovalsFromFileMock.mockReturnValue({
|
||||
allowlist: [
|
||||
{
|
||||
pattern: "=command:placeholder",
|
||||
source: "allow-always",
|
||||
commandText: wrapperPlan.commandText,
|
||||
},
|
||||
],
|
||||
file: { version: 1, agents: {} },
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation((params?: { command?: string }) => {
|
||||
const command = params?.command ?? "";
|
||||
return {
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [
|
||||
command.startsWith("/bin/sh")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "cd ."],
|
||||
raw: `/bin/sh -lc "cd ."`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["cd", "."],
|
||||
raw: "cd .",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
};
|
||||
});
|
||||
hasDurableExecApprovalMock.mockImplementation(
|
||||
(params?: { commandText?: string | null }) => params?.commandText === wrapperPlan.commandText,
|
||||
);
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "cd .",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expectSystemRunInvoke({ invokeTimeoutMs: 35_000, runTimeoutMs: 30_000 });
|
||||
});
|
||||
|
||||
it("keeps non-transport login shells approval-gated", async () => {
|
||||
const loginPlan = {
|
||||
argv: ["bash", "-lc", "./scripts/check_mail.sh --limit 5"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `bash -lc "./scripts/check_mail.sh --limit 5"`,
|
||||
commandPreview: "./scripts/check_mail.sh --limit 5",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: loginPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const nodeAllowlist = [{ pattern: "./scripts/check_mail.sh" }];
|
||||
resolveExecApprovalsFromFileMock.mockReturnValue({
|
||||
allowlist: nodeAllowlist,
|
||||
file: { version: 1, agents: {} },
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation(
|
||||
(params?: { command?: string; allowlist?: unknown[] }) => {
|
||||
const command = params?.command ?? "";
|
||||
const hasNodeAllowlist = Array.isArray(params?.allowlist) && params.allowlist.length > 0;
|
||||
const semanticMatch = command === "./scripts/check_mail.sh --limit 5";
|
||||
return {
|
||||
allowlistMatches: semanticMatch && hasNodeAllowlist ? [{}] : [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: semanticMatch && hasNodeAllowlist,
|
||||
segments: [
|
||||
command.startsWith("bash")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["bash", "-lc", "./scripts/check_mail.sh --limit 5"],
|
||||
raw: `bash -lc "./scripts/check_mail.sh --limit 5"`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["./scripts/check_mail.sh", "--limit", "5"],
|
||||
raw: "./scripts/check_mail.sh --limit 5",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: semanticMatch && hasNodeAllowlist ? [{}] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
requiresExecApprovalMock.mockImplementation(
|
||||
(params?: { allowlistSatisfied?: boolean; durableApprovalSatisfied?: boolean }) =>
|
||||
params?.allowlistSatisfied !== true && params?.durableApprovalSatisfied !== true,
|
||||
);
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "login shell needs human approval",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "./scripts/check_mail.sh --limit 5",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("approval-pending");
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: `bash -lc "./scripts/check_mail.sh --limit 5"`,
|
||||
argv: ["bash", "-lc", "./scripts/check_mail.sh --limit 5"],
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires human approval when prepared shell payload has multiple commands", async () => {
|
||||
const chainPlan = {
|
||||
argv: ["/bin/sh", "-lc", "openclaw status; id"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "openclaw status; id"`,
|
||||
commandPreview: "openclaw status; id",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: chainPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation((params?: { command?: string }) => {
|
||||
const command = params?.command ?? "";
|
||||
return {
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: command.startsWith("/bin/sh")
|
||||
? [
|
||||
{
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "openclaw status; id"],
|
||||
raw: `/bin/sh -lc "openclaw status; id"`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
resolution: null,
|
||||
argv: ["openclaw", "status"],
|
||||
raw: "openclaw status",
|
||||
},
|
||||
{
|
||||
resolution: null,
|
||||
argv: ["id"],
|
||||
raw: "id",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
};
|
||||
});
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "test reviewer would allow it",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "openclaw status; id",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("approval-pending");
|
||||
expect(autoReviewer).not.toHaveBeenCalled();
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat read-only suppression inspections as wrapper writes", async () => {
|
||||
const wrapperPlan = {
|
||||
argv: ["/bin/sh", "-lc", "openclaw config get security.audit.suppressions"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "openclaw config get security.audit.suppressions"`,
|
||||
commandPreview: "openclaw config get security.audit.suppressions",
|
||||
agentId: "prepared-agent",
|
||||
sessionKey: "prepared-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: wrapperPlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
evaluateShellAllowlistMock.mockImplementation((params?: { command?: string }) => {
|
||||
const command = params?.command ?? "";
|
||||
return {
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
segments: [
|
||||
command.startsWith("/bin/sh")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "openclaw config get security.audit.suppressions"],
|
||||
raw: `/bin/sh -lc "openclaw config get security.audit.suppressions"`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["openclaw", "config", "get", "security.audit.suppressions"],
|
||||
raw: "openclaw config get security.audit.suppressions",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
};
|
||||
});
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock.mockImplementation(
|
||||
(params?: { command?: string }) => params?.command?.startsWith("/bin/sh") === true,
|
||||
);
|
||||
requiresExecApprovalMock.mockReturnValue(false);
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "openclaw config get security.audit.suppressions",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expectSystemRunInvoke({ invokeTimeoutMs: 35_000, runTimeoutMs: 30_000 });
|
||||
});
|
||||
|
||||
it("requests human approval when node auto-review asks on an approval miss", async () => {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
@@ -744,24 +1359,46 @@ describe("executeNodeHostCommand", () => {
|
||||
});
|
||||
|
||||
it("auto-reviews strict inline-eval commands before asking a human", async () => {
|
||||
const inlinePlan = {
|
||||
argv: ["/bin/sh", "-lc", "python3 -c 'print(1)'"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: `/bin/sh -lc "python3 -c 'print(1)'"`,
|
||||
commandPreview: "python3 -c 'print(1)'",
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: inlinePlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "safe inline eval",
|
||||
}));
|
||||
detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT);
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [
|
||||
{
|
||||
resolution: null,
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
raw: "python3 -c 'print(1)'",
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
detectInterpreterInlineEvalArgvMock.mockImplementation((argv?: unknown) =>
|
||||
Array.isArray(argv) && argv[0] === "python3" ? INLINE_EVAL_HIT : null,
|
||||
);
|
||||
evaluateShellAllowlistMock.mockImplementation((params?: { command?: string }) => {
|
||||
const command = params?.command ?? "";
|
||||
const segment = command.startsWith("/bin/sh")
|
||||
? {
|
||||
resolution: null,
|
||||
argv: ["/bin/sh", "-lc", "python3 -c 'print(1)'"],
|
||||
raw: `/bin/sh -lc "python3 -c 'print(1)'"`,
|
||||
}
|
||||
: {
|
||||
resolution: null,
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
raw: "python3 -c 'print(1)'",
|
||||
};
|
||||
return {
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [segment],
|
||||
segmentAllowlistEntries: [],
|
||||
};
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
@@ -790,7 +1427,7 @@ describe("executeNodeHostCommand", () => {
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "python3 -c 'print(1)'",
|
||||
command: `/bin/sh -lc "python3 -c 'print(1)'"`,
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
host: "node",
|
||||
reason: "strict-inline-eval",
|
||||
@@ -1273,6 +1910,18 @@ describe("executeNodeHostCommand", () => {
|
||||
});
|
||||
|
||||
it("auto-reviews strict inline-eval commands with full/off host policy when node policy is available", async () => {
|
||||
const inlinePlan = {
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
cwd: "/tmp/work",
|
||||
commandText: "python3 -c 'print(1)'",
|
||||
commandPreview: null,
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
};
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({
|
||||
plan: inlinePlan,
|
||||
execPolicy: { security: "full", ask: "off" },
|
||||
});
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
|
||||
Reference in New Issue
Block a user