mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(exec): add normalized auto mode
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
feec5d92420f2008f02beed1f09c373a9392bf54fec547087bfa0581d8c5fe85 config-baseline.json
|
||||
e51fb382c686637ba6c413648fead48950982b72595c9c6aa1a50da109f4024d config-baseline.core.json
|
||||
6e64ca2148297da39348568c1faf8c6c431efe3c7b53fb29914905f5b88322a4 config-baseline.channel.json
|
||||
1b763a5524aca2d7ecf1eea38f845ad1ffed5c1b37e85e62f6a7902a3ee0f920 config-baseline.plugin.json
|
||||
c80dea63b0a3786c8999d06aae62c110786f440b4d6748f9838577aaa2816971 config-baseline.json
|
||||
948323a1507817b6580ed976f9f9449239008f40283cc7e6005148ecf0ca4582 config-baseline.core.json
|
||||
f833ffca6bd88162f062bbea4f0eede783373f46674ebbfc3a390c80353930a2 config-baseline.channel.json
|
||||
bc38b58b67132401a030b3b3a77efdb6c88f207ea1fab9abcb4599e1f9552dda config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
260286745285203c06b49fb0397217439bd8ab48be87b9de05fd62603bd720c1 plugin-sdk-api-baseline.json
|
||||
9934014d22861b813f02c3fcb01e3b79d13a47d06a75e9096f2465f3c2f1dd5a plugin-sdk-api-baseline.jsonl
|
||||
59de21361cab0622926ad313caf3f8dc43c28d420a82ba060680ecc30c472453 plugin-sdk-api-baseline.json
|
||||
05adee9037669db4e834d1a0ca9705d5d94df770083862ab149d2f3e559010d2 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -118,7 +118,11 @@ prompts that nobody is around to answer.
|
||||
|
||||
If Codex's local system requirements file disallows implicit YOLO approval,
|
||||
reviewer, or sandbox values, OpenClaw treats the implicit default as guardian
|
||||
instead and selects allowed guardian permissions. Hostname-matching
|
||||
instead and selects allowed guardian permissions. `tools.exec.mode: "auto"`
|
||||
also forces guardian-reviewed Codex approvals and does not preserve unsafe
|
||||
legacy `approvalPolicy: "never"` or `sandbox: "danger-full-access"` overrides;
|
||||
set `tools.exec.mode: "full"` for an intentional no-approval posture.
|
||||
Hostname-matching
|
||||
`[[remote_sandbox_config]]` entries in the same requirements file are honored
|
||||
for the sandbox default decision.
|
||||
|
||||
|
||||
@@ -362,30 +362,35 @@ turn instead of relying on Codex host-side sandboxing. Shell access is exposed
|
||||
through OpenClaw sandbox-backed dynamic tools such as `sandbox_exec` and
|
||||
`sandbox_process` when the normal exec/process tools are available.
|
||||
|
||||
Use guardian mode when you want Codex native auto-review before sandbox escapes
|
||||
or extra permissions:
|
||||
Use normalized OpenClaw exec mode when you want Codex native auto-review before
|
||||
sandbox escapes or extra permissions:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Guardian mode expands to Codex app-server approvals, usually
|
||||
For Codex app-server sessions, OpenClaw maps `tools.exec.mode: "auto"` to Codex
|
||||
Guardian-reviewed approvals, usually
|
||||
`approvalPolicy: "on-request"`, `approvalsReviewer: "auto_review"`, and
|
||||
`sandbox: "workspace-write"` when the local requirements allow those values.
|
||||
In `tools.exec.mode: "auto"`, OpenClaw does not preserve legacy unsafe Codex
|
||||
`approvalPolicy: "never"` or `sandbox: "danger-full-access"` overrides; use
|
||||
`tools.exec.mode: "full"` for an intentional no-approval Codex posture. The
|
||||
legacy `plugins.entries.codex.config.appServer.mode: "guardian"` preset still
|
||||
works, but `tools.exec.mode: "auto"` is the normalized OpenClaw surface.
|
||||
|
||||
For every app-server field, auth order, environment isolation, discovery, and
|
||||
timeout behavior, see [Codex harness reference](/plugins/codex-harness-reference).
|
||||
|
||||
@@ -568,6 +568,7 @@ releases.
|
||||
| `plugin-sdk/dedupe-runtime` | Dedupe helpers | In-memory dedupe caches |
|
||||
| `plugin-sdk/file-access-runtime` | File access helpers | Safe local-file/media path helpers |
|
||||
| `plugin-sdk/transport-ready-runtime` | Transport readiness helpers | `waitForTransportReady` |
|
||||
| `plugin-sdk/exec-approvals-runtime` | Exec approval policy helpers | `loadExecApprovals`, `resolveExecApprovalsFromFile`, `ExecApprovalsFile` |
|
||||
| `plugin-sdk/collection-runtime` | Bounded cache helpers | `pruneMapToMaxSize` |
|
||||
| `plugin-sdk/diagnostic-runtime` | Diagnostic gating helpers | `isDiagnosticFlagEnabled`, `isDiagnosticsEnabled` |
|
||||
| `plugin-sdk/error-runtime` | Error formatting helpers | `formatUncaughtError`, `isApprovalNotFoundError`, error graph helpers |
|
||||
|
||||
@@ -282,6 +282,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/secure-random-runtime` | Secure token/UUID helpers |
|
||||
| `plugin-sdk/system-event-runtime` | System event queue helpers |
|
||||
| `plugin-sdk/transport-ready-runtime` | Transport readiness wait helper |
|
||||
| `plugin-sdk/exec-approvals-runtime` | Exec approval policy file helpers without the broad infra-runtime barrel |
|
||||
| `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use the focused runtime subpaths above |
|
||||
| `plugin-sdk/collection-runtime` | Small bounded cache helpers |
|
||||
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, and trace-context helpers |
|
||||
|
||||
@@ -114,6 +114,20 @@ Example schema:
|
||||
|
||||
## Policy knobs
|
||||
|
||||
### `tools.exec.mode`
|
||||
|
||||
`tools.exec.mode` is the preferred normalized policy surface for host exec.
|
||||
Values are:
|
||||
|
||||
- `deny` - block host exec.
|
||||
- `allowlist` - run only allowlisted commands without asking.
|
||||
- `ask` - use allowlist policy and ask on misses.
|
||||
- `auto` - use allowlist policy, run deterministic matches directly, and send approval misses through OpenClaw's native auto reviewer before falling back to a human approval route.
|
||||
- `full` - run host exec without approval prompts.
|
||||
|
||||
Legacy `tools.exec.security` / `tools.exec.ask` remain supported and still win
|
||||
when set at the narrower session or agent scope.
|
||||
|
||||
### `exec.security`
|
||||
|
||||
<ParamField path="security" type='"deny" | "allowlist" | "full"'>
|
||||
@@ -289,7 +303,10 @@ EOF
|
||||
### Session-only shortcut
|
||||
|
||||
- `/exec security=full ask=off` changes only the current session.
|
||||
- `/elevated full` is a break-glass shortcut that also skips exec approvals for that session.
|
||||
- `/elevated full` is a break-glass shortcut that skips exec approvals only when
|
||||
both the requested policy and the host approvals file resolve to
|
||||
`security: "full"` and `ask: "off"`. A stricter host file, such as
|
||||
`ask: "always"`, still prompts.
|
||||
|
||||
If the host approvals file stays stricter than config, the stricter host
|
||||
policy still wins.
|
||||
|
||||
@@ -68,6 +68,7 @@ Notes:
|
||||
- `host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
|
||||
- `host` only accepts `auto`, `sandbox`, `gateway`, or `node`. It is not a hostname selector; hostname-like values are rejected before the command runs.
|
||||
- `auto` is the default routing strategy, not a wildcard. Per-call `host=node` is allowed from `auto`; per-call `host=gateway` is only allowed when no sandbox runtime is active.
|
||||
- `tools.exec.mode` is the normalized policy knob. Values are `deny`, `allowlist`, `ask`, `auto`, and `full`. `auto` runs deterministic allowlist/safe-bin matches directly and routes every remaining exec approval case through OpenClaw's native auto reviewer before asking a human. `ask` / `ask=always` still asks a human every time.
|
||||
- With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox.
|
||||
- `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider.
|
||||
- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`.
|
||||
@@ -108,7 +109,7 @@ Notes:
|
||||
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
|
||||
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` always require explicit approval. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms still prompt each time.
|
||||
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` require reviewer or explicit approval. In `mode=auto`, the native auto reviewer may allow a clearly low-risk one-off command; if the reviewer asks, the request goes to a human. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms do not become durable allow rules.
|
||||
- `tools.exec.commandHighlighting` (default: false): when true, approval prompts can highlight parser-derived command spans in the command text. Set to `true` globally or per agent to enable command text highlighting without changing exec approval policy.
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
|
||||
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals-advanced#safe-bins-stdin-only).
|
||||
@@ -209,7 +210,7 @@ Use the two controls for different jobs:
|
||||
Do not treat `safeBins` as a generic allowlist, and do not add interpreter/runtime binaries (for example `python3`, `node`, `ruby`, `bash`). If you need those, use explicit allowlist entries and keep approval prompts enabled.
|
||||
`openclaw security audit` warns when interpreter/runtime `safeBins` entries are missing explicit profiles, and `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles` entries.
|
||||
`openclaw security audit` and `openclaw doctor` also warn when you explicitly add broad-behavior bins such as `jq` back into `safeBins`.
|
||||
If you explicitly allowlist interpreters, enable `tools.exec.strictInlineEval` so inline code-eval forms still require a fresh approval.
|
||||
If you explicitly allowlist interpreters, enable `tools.exec.strictInlineEval` so inline code-eval forms still require reviewer or explicit approval.
|
||||
|
||||
For full policy details and examples, see [Exec approvals](/tools/exec-approvals-advanced#safe-bins-stdin-only) and [Safe bins versus allowlist](/tools/exec-approvals-advanced#safe-bins-versus-allowlist).
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ export default definePluginEntry({
|
||||
name: "Codex",
|
||||
description: "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
register(api) {
|
||||
const resolveCurrentConfig = () =>
|
||||
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
|
||||
const resolveCurrentPluginConfig = () =>
|
||||
resolveLivePluginConfigObject(
|
||||
api.runtime.config?.current
|
||||
? () => api.runtime.config.current() as OpenClawConfig
|
||||
: undefined,
|
||||
resolveCurrentConfig,
|
||||
"codex",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
) ?? api.pluginConfig;
|
||||
@@ -114,8 +114,8 @@ export default definePluginEntry({
|
||||
);
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
config: api.runtime.config?.current?.() as OpenClawConfig | undefined,
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
config: resolveCurrentConfig(),
|
||||
resumeCodexCliSessionOnNode: (params) =>
|
||||
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
|
||||
}),
|
||||
|
||||
@@ -133,8 +133,7 @@
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["yolo", "guardian"],
|
||||
"default": "yolo"
|
||||
"enum": ["yolo", "guardian"]
|
||||
},
|
||||
"transport": {
|
||||
"type": "string",
|
||||
@@ -305,7 +304,7 @@
|
||||
},
|
||||
"appServer.mode": {
|
||||
"label": "Execution Mode",
|
||||
"help": "Use yolo for unchained local execution or guardian for Codex guardian-reviewed approvals.",
|
||||
"help": "Legacy Codex app-server preset. Prefer tools.exec.mode=auto for normalized Guardian-reviewed approvals.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.transport": {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CodexAppServerRuntimeOptions, CodexPluginConfig } from "./config.js";
|
||||
import type {
|
||||
CodexAppServerRuntimeOptions,
|
||||
CodexPluginConfig,
|
||||
OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
|
||||
export function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
@@ -6,6 +10,7 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
shouldPromote: boolean;
|
||||
canUseUntrustedApprovalPolicy: boolean;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
}): CodexAppServerRuntimeOptions {
|
||||
if (
|
||||
!params.shouldPromote ||
|
||||
@@ -15,6 +20,7 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
return params.appServer;
|
||||
}
|
||||
const explicitMode =
|
||||
params.execPolicy?.mode === "full" ||
|
||||
params.pluginConfig.appServer?.mode !== undefined ||
|
||||
isCodexAppServerPolicyMode(params.env.OPENCLAW_CODEX_APP_SERVER_MODE);
|
||||
const explicitApprovalPolicy =
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveCodexComputerUseConfig,
|
||||
resolveOpenClawExecModeForCodexAppServer,
|
||||
resolveOpenClawExecModeFromConfig,
|
||||
resolveOpenClawExecPolicyForCodexAppServer,
|
||||
resolveCodexPluginsPolicy,
|
||||
shouldAutoApproveCodexAppServerApprovals,
|
||||
} from "./config.js";
|
||||
@@ -763,6 +766,761 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("maps normalized OpenClaw auto exec mode to guardian-reviewed local execution", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
});
|
||||
|
||||
it("forces guarded app-server policy fields for auto mode", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "danger-full-access",
|
||||
},
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit read-only app-server sandbox for auto mode", () => {
|
||||
const configRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
},
|
||||
execMode: "auto",
|
||||
env: {},
|
||||
});
|
||||
const envRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
env: {
|
||||
OPENCLAW_CODEX_APP_SERVER_MODE: "yolo",
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expectRuntimePolicy(configRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
expectRuntimePolicy(envRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["deny", "allowlist"] as const)(
|
||||
"blocks Codex app-server local execution for normalized OpenClaw %s exec mode",
|
||||
(execMode) => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode,
|
||||
}),
|
||||
).toThrow(
|
||||
`Codex app-server local execution is not available when tools.exec.mode=${execMode}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("maps normalized OpenClaw ask exec mode away from Codex yolo", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "ask",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user approvals for ask mode with explicit legacy guardian mode", () => {
|
||||
const configRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
execMode: "ask",
|
||||
env: {},
|
||||
});
|
||||
const envRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "ask",
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
|
||||
});
|
||||
|
||||
expectRuntimePolicy(configRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
expectRuntimePolicy(envRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides explicit app-server policy fields for ask mode", () => {
|
||||
const configRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
execMode: "ask",
|
||||
env: {},
|
||||
});
|
||||
const envRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "ask",
|
||||
env: {
|
||||
OPENCLAW_CODEX_APP_SERVER_MODE: "yolo",
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "danger-full-access",
|
||||
},
|
||||
});
|
||||
|
||||
expectRuntimePolicy(configRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
expectRuntimePolicy(envRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit read-only app-server sandbox for ask mode", () => {
|
||||
const configRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
execMode: "ask",
|
||||
env: {},
|
||||
});
|
||||
const envRuntime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "ask",
|
||||
env: {
|
||||
OPENCLAW_CODEX_APP_SERVER_MODE: "yolo",
|
||||
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
|
||||
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expectRuntimePolicy(configRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
expectRuntimePolicy(envRuntime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when normalized OpenClaw ask mode cannot use user approvals", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "ask",
|
||||
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { mode: "guardian" } },
|
||||
execMode: "ask",
|
||||
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ execMode: "auto", policies: ["never"] },
|
||||
{ execMode: "auto", policies: ["on-failure"] },
|
||||
{ execMode: "auto", policies: ["untrusted"] },
|
||||
{ execMode: "ask", policies: ["never"] },
|
||||
{ execMode: "ask", policies: ["on-failure"] },
|
||||
{ execMode: "ask", policies: ["untrusted"] },
|
||||
] as const)(
|
||||
"fails closed when normalized OpenClaw $execMode mode can only use $policies approvals",
|
||||
({ execMode, policies }) => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode,
|
||||
requirementsToml: `allowed_approval_policies = [${policies
|
||||
.map((policy) => `"${policy}"`)
|
||||
.join(", ")}]\n`,
|
||||
}),
|
||||
).toThrow(`tools.exec.mode=${execMode} requires Codex app-server prompting approvals`);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps normalized OpenClaw full exec mode on default Codex yolo", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "full",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when normalized OpenClaw auto mode can only use on-failure approvals", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
requirementsToml:
|
||||
'allowed_sandbox_modes = ["read-only"]\nallowed_approval_policies = ["on-failure"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=auto requires Codex app-server prompting approvals");
|
||||
});
|
||||
|
||||
it("fails closed when normalized OpenClaw auto mode cannot force prompting over yolo", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
requirementsToml:
|
||||
'allowed_sandbox_modes = ["danger-full-access", "read-only"]\nallowed_approval_policies = ["never", "on-failure"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=auto requires Codex app-server prompting approvals");
|
||||
});
|
||||
|
||||
it("fails closed when normalized OpenClaw auto mode cannot use an auto reviewer", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
execMode: "auto",
|
||||
requirementsToml:
|
||||
'allowed_approval_policies = ["on-request"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
}),
|
||||
).toThrow("tools.exec.mode=auto requires Codex app-server auto approvals");
|
||||
});
|
||||
|
||||
it("keeps normalized OpenClaw auto mode when legacy app-server yolo was schema-defaulted", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
command: "codex",
|
||||
mode: "yolo",
|
||||
transport: "stdio",
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 60_000,
|
||||
},
|
||||
codexDynamicToolsLoading: "searchable",
|
||||
codexDynamicToolsExclude: [],
|
||||
},
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
expectFields(runtime, "runtime start", {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forces guarded policy fields for normalized OpenClaw auto mode", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
},
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expectRuntimePolicy(runtime, {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves agent-scoped normalized OpenClaw exec mode for Codex app-server mapping", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "ask",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "Codex-Agent",
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveOpenClawExecModeFromConfig({ config, agentId: "codex-agent" })).toBe("auto");
|
||||
expect(resolveOpenClawExecModeFromConfig({ config, agentId: "other-agent" })).toBe("ask");
|
||||
});
|
||||
|
||||
it("keeps legacy exec security overrides ahead of normalized OpenClaw exec mode", () => {
|
||||
expect(
|
||||
resolveOpenClawExecModeFromConfig({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex-agent",
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "codex-agent",
|
||||
}),
|
||||
).toBe("full");
|
||||
});
|
||||
|
||||
it.each(["always"] as const)(
|
||||
"keeps legacy full exec security with ask=%s on prompting Codex policy",
|
||||
(ask) => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask,
|
||||
},
|
||||
},
|
||||
};
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({ config });
|
||||
|
||||
expect(resolveOpenClawExecModeForCodexAppServer({ config })).toBe("ask");
|
||||
expectRuntimePolicy(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
execPolicy,
|
||||
}),
|
||||
{
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps legacy full exec security with ask=on-miss on default Codex yolo", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
},
|
||||
},
|
||||
};
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({ config });
|
||||
|
||||
expect(resolveOpenClawExecModeForCodexAppServer({ config })).toBe("full");
|
||||
expectRuntimePolicy(resolveRuntimeForTest({ execPolicy }), {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when legacy full exec with ask cannot use full Codex sandbox", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
execPolicy: resolveOpenClawExecPolicyForCodexAppServer({ config }),
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
}),
|
||||
).toThrow("legacy full exec security with ask requires Codex app-server danger-full-access");
|
||||
});
|
||||
|
||||
it("clamps legacy full exec with ask when an OpenClaw sandbox is active", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expectRuntimePolicy(
|
||||
resolveRuntimeForTest({
|
||||
execPolicy: resolveOpenClawExecPolicyForCodexAppServer({ config }),
|
||||
openClawSandboxActive: true,
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
}),
|
||||
{
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("applies host exec approval security floors before starting Codex app-server", () => {
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "deny",
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(execPolicy.mode).toBe("deny");
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
},
|
||||
},
|
||||
execPolicy,
|
||||
}),
|
||||
).toThrow("Codex app-server local execution is not available when tools.exec.mode=deny");
|
||||
});
|
||||
|
||||
it("applies host exec approval ask floors before starting Codex app-server", () => {
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: {
|
||||
ask: "always",
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(execPolicy.mode).toBe("ask");
|
||||
expectRuntimePolicy(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
execPolicy,
|
||||
}),
|
||||
{
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("applies agent-scoped exec approval security floors before starting Codex app-server", () => {
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "codex-agent",
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
},
|
||||
agents: {
|
||||
"codex-agent": {
|
||||
security: "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(execPolicy.mode).toBe("deny");
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
},
|
||||
},
|
||||
execPolicy,
|
||||
}),
|
||||
).toThrow("Codex app-server local execution is not available when tools.exec.mode=deny");
|
||||
});
|
||||
|
||||
it("applies agent-scoped exec approval ask floors before starting Codex app-server", () => {
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "codex-agent",
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: {
|
||||
ask: "off",
|
||||
},
|
||||
agents: {
|
||||
"codex-agent": {
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(execPolicy.mode).toBe("ask");
|
||||
expectRuntimePolicy(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
execPolicy,
|
||||
}),
|
||||
{
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("treats ask-only legacy overrides as normalized mode overrides", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex-agent",
|
||||
tools: {
|
||||
exec: {
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveOpenClawExecModeFromConfig({ config, agentId: "codex-agent" })).toBe("allowlist");
|
||||
const execMode = resolveOpenClawExecModeForCodexAppServer({
|
||||
config,
|
||||
agentId: "main",
|
||||
execOverrides: {
|
||||
ask: "always",
|
||||
},
|
||||
});
|
||||
expect(execMode).toBe("ask");
|
||||
expectRuntimePolicy(resolveRuntimeForTest({ execMode }), {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current legacy exec security overrides ahead of configured normalized mode", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config,
|
||||
agentId: "main",
|
||||
execOverrides: {
|
||||
security: "full",
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config,
|
||||
agentId: "main",
|
||||
execOverrides: {
|
||||
ask: "always",
|
||||
},
|
||||
}),
|
||||
).toBe("ask");
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config,
|
||||
agentId: "main",
|
||||
execOverrides: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
});
|
||||
|
||||
it("preserves legacy full exec security before applying current ask overrides", () => {
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
execOverrides: {
|
||||
ask: "off",
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveOpenClawExecModeForCodexAppServer({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
},
|
||||
},
|
||||
},
|
||||
execOverrides: {
|
||||
ask: "off",
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
});
|
||||
|
||||
it("accepts the latest auto_review reviewer and legacy guardian_subagent alias", () => {
|
||||
expect(
|
||||
resolveRuntimeForTest({
|
||||
@@ -980,6 +1738,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
};
|
||||
const appServerProperties = manifest.configSchema.properties.appServer.properties;
|
||||
|
||||
expect(appServerProperties.mode?.default).toBeUndefined();
|
||||
expect(appServerProperties.command?.default).toBeUndefined();
|
||||
expect(appServerProperties.approvalPolicy?.default).toBeUndefined();
|
||||
expect(appServerProperties.sandbox?.default).toBeUndefined();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname as readHostName } from "node:os";
|
||||
import {
|
||||
resolveExecApprovalsFromFile,
|
||||
type ExecApprovalsFile,
|
||||
} from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
@@ -14,11 +19,26 @@ const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/;
|
||||
|
||||
type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
type CodexAppServerPolicyMode = "yolo" | "guardian";
|
||||
type OpenClawExecMode = "deny" | "allowlist" | "ask" | "auto" | "full";
|
||||
type OpenClawExecSecurity = "deny" | "allowlist" | "full";
|
||||
type OpenClawExecAsk = "off" | "on-miss" | "always";
|
||||
type OpenClawExecApprovalFloorsForCodexAppServer = {
|
||||
security?: OpenClawExecSecurity;
|
||||
ask?: OpenClawExecAsk;
|
||||
};
|
||||
export type OpenClawExecPolicyForCodexAppServer = {
|
||||
mode?: OpenClawExecMode;
|
||||
security: OpenClawExecSecurity;
|
||||
ask: OpenClawExecAsk;
|
||||
touched: boolean;
|
||||
};
|
||||
type OpenClawExecPolicy = OpenClawExecPolicyForCodexAppServer;
|
||||
type CodexAppServerDefaultPolicy = {
|
||||
mode: CodexAppServerPolicyMode;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
dangerFullAccessAllowed?: boolean;
|
||||
};
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
||||
export type CodexAppServerApprovalPolicySource = "config" | "env" | "requirements" | "implicit";
|
||||
@@ -369,12 +389,15 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
|
||||
export function resolveCodexAppServerRuntimeOptions(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
execMode?: OpenClawExecMode;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
hostName?: string;
|
||||
openClawSandboxActive?: boolean;
|
||||
} = {},
|
||||
): CodexAppServerRuntimeOptions {
|
||||
const env = params.env ?? process.env;
|
||||
@@ -396,20 +419,68 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const clearEnv = normalizeStringList(config.clearEnv);
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const url = readNonEmptyString(config.url);
|
||||
const execMode = resolveEffectiveOpenClawExecModeForCodexAppServer({
|
||||
execMode: params.execMode,
|
||||
execPolicy: params.execPolicy,
|
||||
});
|
||||
assertCodexAppServerAllowedForOpenClawExecMode(execMode);
|
||||
const explicitPolicyMode =
|
||||
resolvePolicyMode(config.mode) ?? resolvePolicyMode(env.OPENCLAW_CODEX_APP_SERVER_MODE);
|
||||
const defaultPolicy = explicitPolicyMode
|
||||
? undefined
|
||||
: resolveDefaultCodexAppServerPolicy({
|
||||
transport,
|
||||
env,
|
||||
requirementsToml: params.requirementsToml,
|
||||
requirementsPath: params.requirementsPath,
|
||||
readRequirementsFile: params.readRequirementsFile,
|
||||
platform: params.platform,
|
||||
hostName: params.hostName,
|
||||
});
|
||||
const policyMode = explicitPolicyMode ?? defaultPolicy?.mode ?? "yolo";
|
||||
const explicitApprovalPolicy =
|
||||
resolveApprovalPolicy(config.approvalPolicy) ??
|
||||
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY);
|
||||
const configuredSandbox =
|
||||
resolveSandbox(config.sandbox) ?? resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX);
|
||||
const explicitApprovalsReviewer = resolveApprovalsReviewer(config.approvalsReviewer);
|
||||
const normalizedPolicyMode = resolveCodexPolicyModeForOpenClawExecMode(execMode);
|
||||
const ignoreLegacyYoloPolicyMode =
|
||||
normalizedPolicyMode === "guardian" && explicitPolicyMode === "yolo";
|
||||
const forceUserReviewer = execMode !== undefined && execMode !== "auto" && execMode !== "full";
|
||||
const forceGuardianReviewer = execMode === "auto";
|
||||
const forceDangerFullAccessSandbox =
|
||||
params.execPolicy?.touched === true &&
|
||||
params.execPolicy.security === "full" &&
|
||||
params.execPolicy.ask === "always";
|
||||
const forceRuntimePolicy =
|
||||
forceUserReviewer || forceGuardianReviewer || forceDangerFullAccessSandbox;
|
||||
const defaultPolicy =
|
||||
explicitPolicyMode && !forceRuntimePolicy && !ignoreLegacyYoloPolicyMode
|
||||
? undefined
|
||||
: resolveDefaultCodexAppServerPolicy({
|
||||
transport,
|
||||
env,
|
||||
forceGuardian: normalizedPolicyMode === "guardian",
|
||||
forceUserReviewer,
|
||||
execModeRequiringPromptingApprovals:
|
||||
execMode === "auto" || execMode === "ask" ? execMode : undefined,
|
||||
requirementsToml: params.requirementsToml,
|
||||
requirementsPath: params.requirementsPath,
|
||||
readRequirementsFile: params.readRequirementsFile,
|
||||
platform: params.platform,
|
||||
hostName: params.hostName,
|
||||
});
|
||||
const preserveExplicitAutoSandbox = forceGuardianReviewer && configuredSandbox === "read-only";
|
||||
const forcedPolicy = forceRuntimePolicy
|
||||
? {
|
||||
approvalPolicy: defaultPolicy?.approvalPolicy ?? "on-request",
|
||||
sandbox: preserveExplicitAutoSandbox
|
||||
? undefined
|
||||
: forceDangerFullAccessSandbox
|
||||
? selectForcedDangerFullAccessSandbox({
|
||||
defaultPolicy,
|
||||
openClawSandboxActive: params.openClawSandboxActive === true,
|
||||
})
|
||||
: selectForcedPromptingSandbox({
|
||||
configuredSandbox,
|
||||
defaultSandbox: defaultPolicy?.sandbox,
|
||||
}),
|
||||
approvalsReviewer:
|
||||
defaultPolicy?.approvalsReviewer ?? (forceUserReviewer ? "user" : "auto_review"),
|
||||
}
|
||||
: undefined;
|
||||
const policyMode = ignoreLegacyYoloPolicyMode
|
||||
? normalizedPolicyMode
|
||||
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
@@ -457,15 +528,16 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
approvalPolicy,
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
resolveSandbox(config.sandbox) ??
|
||||
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
resolveApprovalsReviewer(config.approvalsReviewer) ??
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
@@ -634,6 +706,9 @@ function resolvePolicyMode(value: unknown): CodexAppServerPolicyMode | undefined
|
||||
|
||||
function resolveDefaultCodexAppServerPolicy(params: {
|
||||
transport: CodexAppServerTransportMode;
|
||||
forceGuardian?: boolean;
|
||||
forceUserReviewer?: boolean;
|
||||
execModeRequiringPromptingApprovals?: Extract<OpenClawExecMode, "auto" | "ask">;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
@@ -642,11 +717,28 @@ function resolveDefaultCodexAppServerPolicy(params: {
|
||||
hostName?: string;
|
||||
}): CodexAppServerDefaultPolicy {
|
||||
if (params.transport !== "stdio") {
|
||||
return { mode: "yolo" };
|
||||
return { mode: "yolo", dangerFullAccessAllowed: true };
|
||||
}
|
||||
const content = readCodexRequirementsToml(params);
|
||||
if (content === undefined) {
|
||||
return { mode: "yolo" };
|
||||
if (!params.forceGuardian) {
|
||||
return { mode: "yolo", dangerFullAccessAllowed: true };
|
||||
}
|
||||
return {
|
||||
mode: "guardian",
|
||||
dangerFullAccessAllowed: true,
|
||||
approvalPolicy: selectGuardianApprovalPolicy(
|
||||
undefined,
|
||||
params.execModeRequiringPromptingApprovals,
|
||||
),
|
||||
approvalsReviewer: params.forceUserReviewer
|
||||
? selectUserApprovalsReviewer(undefined)
|
||||
: selectGuardianApprovalsReviewer(
|
||||
undefined,
|
||||
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
|
||||
),
|
||||
sandbox: selectGuardianSandbox(undefined),
|
||||
};
|
||||
}
|
||||
const allowedSandboxModes = parseAllowedSandboxModesFromCodexRequirements(
|
||||
content,
|
||||
@@ -660,13 +752,22 @@ function resolveDefaultCodexAppServerPolicy(params: {
|
||||
allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("never");
|
||||
const yoloReviewerAllowed =
|
||||
allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user");
|
||||
if (yoloSandboxAllowed && yoloApprovalAllowed && yoloReviewerAllowed) {
|
||||
return { mode: "yolo" };
|
||||
if (!params.forceGuardian && yoloSandboxAllowed && yoloApprovalAllowed && yoloReviewerAllowed) {
|
||||
return { mode: "yolo", dangerFullAccessAllowed: true };
|
||||
}
|
||||
return {
|
||||
mode: "guardian",
|
||||
approvalPolicy: selectGuardianApprovalPolicy(allowedApprovalPolicies),
|
||||
approvalsReviewer: selectGuardianApprovalsReviewer(allowedApprovalsReviewers),
|
||||
dangerFullAccessAllowed: yoloSandboxAllowed,
|
||||
approvalPolicy: selectGuardianApprovalPolicy(
|
||||
allowedApprovalPolicies,
|
||||
params.execModeRequiringPromptingApprovals,
|
||||
),
|
||||
approvalsReviewer: params.forceUserReviewer
|
||||
? selectUserApprovalsReviewer(allowedApprovalsReviewers)
|
||||
: selectGuardianApprovalsReviewer(
|
||||
allowedApprovalsReviewers,
|
||||
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
|
||||
),
|
||||
sandbox: selectGuardianSandbox(allowedSandboxModes),
|
||||
};
|
||||
}
|
||||
@@ -913,10 +1014,16 @@ function normalizeRequirementsApprovalsReviewer(
|
||||
|
||||
function selectGuardianApprovalPolicy(
|
||||
allowedApprovalPolicies: Set<CodexAppServerApprovalPolicy> | undefined,
|
||||
execModeRequiringPromptingApprovals?: Extract<OpenClawExecMode, "auto" | "ask">,
|
||||
): CodexAppServerApprovalPolicy {
|
||||
if (allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("on-request")) {
|
||||
return "on-request";
|
||||
}
|
||||
if (execModeRequiringPromptingApprovals) {
|
||||
throw new Error(
|
||||
`tools.exec.mode=${execModeRequiringPromptingApprovals} requires Codex app-server prompting approvals`,
|
||||
);
|
||||
}
|
||||
if (allowedApprovalPolicies.has("on-failure")) {
|
||||
return "on-failure";
|
||||
}
|
||||
@@ -931,6 +1038,7 @@ function selectGuardianApprovalPolicy(
|
||||
|
||||
function selectGuardianApprovalsReviewer(
|
||||
allowedApprovalsReviewers: Set<CodexAppServerApprovalsReviewer> | undefined,
|
||||
execModeRequiringAutoReviewer?: Extract<OpenClawExecMode, "auto">,
|
||||
): CodexAppServerApprovalsReviewer {
|
||||
if (allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("auto_review")) {
|
||||
return "auto_review";
|
||||
@@ -938,12 +1046,51 @@ function selectGuardianApprovalsReviewer(
|
||||
if (allowedApprovalsReviewers.has("guardian_subagent")) {
|
||||
return "guardian_subagent";
|
||||
}
|
||||
if (execModeRequiringAutoReviewer) {
|
||||
throw new Error(
|
||||
`tools.exec.mode=${execModeRequiringAutoReviewer} requires Codex app-server auto approvals`,
|
||||
);
|
||||
}
|
||||
if (allowedApprovalsReviewers.has("user")) {
|
||||
return "user";
|
||||
}
|
||||
return "auto_review";
|
||||
}
|
||||
|
||||
function selectUserApprovalsReviewer(
|
||||
allowedApprovalsReviewers: Set<CodexAppServerApprovalsReviewer> | undefined,
|
||||
): CodexAppServerApprovalsReviewer {
|
||||
if (allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user")) {
|
||||
return "user";
|
||||
}
|
||||
throw new Error("tools.exec.mode=ask requires Codex app-server user approvals");
|
||||
}
|
||||
|
||||
function selectForcedPromptingSandbox(params: {
|
||||
configuredSandbox?: CodexAppServerSandboxMode;
|
||||
defaultSandbox?: CodexAppServerSandboxMode;
|
||||
}): CodexAppServerSandboxMode {
|
||||
if (params.configuredSandbox === "read-only" || params.defaultSandbox === "read-only") {
|
||||
return "read-only";
|
||||
}
|
||||
return params.defaultSandbox ?? "workspace-write";
|
||||
}
|
||||
|
||||
function selectForcedDangerFullAccessSandbox(params: {
|
||||
defaultPolicy: CodexAppServerDefaultPolicy | undefined;
|
||||
openClawSandboxActive: boolean;
|
||||
}): CodexAppServerSandboxMode {
|
||||
if (params.defaultPolicy?.dangerFullAccessAllowed === false) {
|
||||
if (params.openClawSandboxActive) {
|
||||
return params.defaultPolicy.sandbox ?? "workspace-write";
|
||||
}
|
||||
throw new Error(
|
||||
"legacy full exec security with ask requires Codex app-server danger-full-access",
|
||||
);
|
||||
}
|
||||
return "danger-full-access";
|
||||
}
|
||||
|
||||
function selectGuardianSandbox(
|
||||
allowedSandboxModes: Set<CodexAppServerSandboxMode> | undefined,
|
||||
): CodexAppServerSandboxMode {
|
||||
@@ -980,6 +1127,238 @@ function resolveApprovalsReviewer(value: unknown): CodexAppServerApprovalsReview
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveOpenClawExecModeFromConfig(params: {
|
||||
config?: unknown;
|
||||
agentId?: string;
|
||||
}): OpenClawExecMode | undefined {
|
||||
const policy = resolveOpenClawExecPolicyFromConfig(params);
|
||||
return policy.touched ? policy.mode : undefined;
|
||||
}
|
||||
|
||||
function resolveOpenClawExecPolicyFromConfig(params: {
|
||||
config?: unknown;
|
||||
agentId?: string;
|
||||
}): OpenClawExecPolicy {
|
||||
const root = readRecord(params.config);
|
||||
const globalExec = readRecord(readRecord(root?.tools)?.exec);
|
||||
const globalPolicy = applyOpenClawExecPolicyLayer(createDefaultOpenClawExecPolicy(), globalExec);
|
||||
const agentId = params.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return globalPolicy;
|
||||
}
|
||||
const agents = readRecord(root?.agents);
|
||||
const agentList = Array.isArray(agents?.list) ? agents.list : [];
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const agentEntry = agentList.find((entry) => {
|
||||
const id = readRecord(entry)?.id;
|
||||
return typeof id === "string" && normalizeAgentId(id) === normalizedAgentId;
|
||||
});
|
||||
const agentExec = readRecord(readRecord(readRecord(agentEntry)?.tools)?.exec);
|
||||
return applyOpenClawExecPolicyLayer(globalPolicy, agentExec);
|
||||
}
|
||||
|
||||
export function resolveOpenClawExecModeForCodexAppServer(params: {
|
||||
execOverrides?: {
|
||||
security?: unknown;
|
||||
ask?: unknown;
|
||||
};
|
||||
approvals?: ExecApprovalsFile;
|
||||
config?: unknown;
|
||||
agentId?: string;
|
||||
}): OpenClawExecMode | undefined {
|
||||
const policy = resolveOpenClawExecPolicyForCodexAppServer(params);
|
||||
return policy.touched ? policy.mode : undefined;
|
||||
}
|
||||
|
||||
export function resolveOpenClawExecPolicyForCodexAppServer(params: {
|
||||
execOverrides?: {
|
||||
security?: unknown;
|
||||
ask?: unknown;
|
||||
};
|
||||
approvals?: ExecApprovalsFile;
|
||||
config?: unknown;
|
||||
agentId?: string;
|
||||
}): OpenClawExecPolicyForCodexAppServer {
|
||||
const basePolicy = resolveOpenClawExecPolicyFromConfig({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const overridePolicy = applyOpenClawExecPolicyLayer(basePolicy, params.execOverrides);
|
||||
const approvalFloors = resolveOpenClawExecApprovalFloorsForCodexAppServer({
|
||||
approvals: params.approvals,
|
||||
agentId: params.agentId,
|
||||
policy: overridePolicy,
|
||||
});
|
||||
return applyOpenClawExecApprovalFloors(overridePolicy, approvalFloors);
|
||||
}
|
||||
|
||||
function resolveEffectiveOpenClawExecModeForCodexAppServer(params: {
|
||||
execMode?: OpenClawExecMode;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
}): OpenClawExecMode | undefined {
|
||||
if (params.execPolicy?.touched === true) {
|
||||
return params.execPolicy.mode;
|
||||
}
|
||||
return params.execMode;
|
||||
}
|
||||
|
||||
function resolveCodexPolicyModeForOpenClawExecMode(
|
||||
mode: OpenClawExecMode | undefined,
|
||||
): CodexAppServerPolicyMode | undefined {
|
||||
if (!mode || mode === "full") {
|
||||
return undefined;
|
||||
}
|
||||
return "guardian";
|
||||
}
|
||||
|
||||
function assertCodexAppServerAllowedForOpenClawExecMode(mode: OpenClawExecMode | undefined): void {
|
||||
if (mode === "deny" || mode === "allowlist") {
|
||||
throw new Error(
|
||||
`Codex app-server local execution is not available when tools.exec.mode=${mode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultOpenClawExecPolicy(): OpenClawExecPolicy {
|
||||
return {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
touched: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyOpenClawExecPolicyLayer(
|
||||
base: OpenClawExecPolicy,
|
||||
exec?: { mode?: unknown; security?: unknown; ask?: unknown },
|
||||
): OpenClawExecPolicy {
|
||||
if (!exec) {
|
||||
return base;
|
||||
}
|
||||
const mode = readExecMode(exec.mode);
|
||||
if (mode !== undefined) {
|
||||
return {
|
||||
...resolveOpenClawExecPolicyForMode(mode),
|
||||
touched: true,
|
||||
};
|
||||
}
|
||||
const security = readExecSecurity(exec.security);
|
||||
const ask = readExecAsk(exec.ask);
|
||||
if (security === undefined && ask === undefined) {
|
||||
return base;
|
||||
}
|
||||
const nextSecurity = security ?? base.security;
|
||||
const nextAsk = ask ?? base.ask;
|
||||
return {
|
||||
mode: resolveOpenClawExecModeFromPolicy({ security: nextSecurity, ask: nextAsk }),
|
||||
security: nextSecurity,
|
||||
ask: nextAsk,
|
||||
touched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawExecApprovalFloorsForCodexAppServer(params: {
|
||||
approvals?: ExecApprovalsFile;
|
||||
agentId?: string;
|
||||
policy: OpenClawExecPolicy;
|
||||
}): OpenClawExecApprovalFloorsForCodexAppServer | undefined {
|
||||
if (!params.approvals) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveExecApprovalsFromFile({
|
||||
file: params.approvals,
|
||||
agentId: params.agentId,
|
||||
overrides: {
|
||||
security: params.policy.security,
|
||||
ask: params.policy.ask,
|
||||
},
|
||||
}).agent;
|
||||
}
|
||||
|
||||
function applyOpenClawExecApprovalFloors(
|
||||
base: OpenClawExecPolicy,
|
||||
approvalFloors?: OpenClawExecApprovalFloorsForCodexAppServer,
|
||||
): OpenClawExecPolicy {
|
||||
if (!approvalFloors) {
|
||||
return base;
|
||||
}
|
||||
const nextSecurity = approvalFloors.security
|
||||
? minOpenClawExecSecurity(base.security, approvalFloors.security)
|
||||
: base.security;
|
||||
const nextAsk = approvalFloors.ask ? maxOpenClawExecAsk(base.ask, approvalFloors.ask) : base.ask;
|
||||
if (nextSecurity === base.security && nextAsk === base.ask) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
mode: resolveOpenClawExecModeFromPolicy({ security: nextSecurity, ask: nextAsk }),
|
||||
security: nextSecurity,
|
||||
ask: nextAsk,
|
||||
touched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawExecPolicyForMode(
|
||||
mode: OpenClawExecMode,
|
||||
): Omit<OpenClawExecPolicy, "touched"> {
|
||||
switch (mode) {
|
||||
case "deny":
|
||||
return { mode, security: "deny", ask: "off" };
|
||||
case "allowlist":
|
||||
return { mode, security: "allowlist", ask: "off" };
|
||||
case "ask":
|
||||
case "auto":
|
||||
return { mode, security: "allowlist", ask: "on-miss" };
|
||||
case "full":
|
||||
return { mode, security: "full", ask: "off" };
|
||||
}
|
||||
const exhaustiveMode: never = mode;
|
||||
return exhaustiveMode;
|
||||
}
|
||||
|
||||
function resolveOpenClawExecModeFromPolicy(params: {
|
||||
security: OpenClawExecSecurity;
|
||||
ask: OpenClawExecAsk;
|
||||
}): OpenClawExecMode {
|
||||
if (params.security === "deny") {
|
||||
return "deny";
|
||||
}
|
||||
if (params.security === "allowlist" && params.ask === "off") {
|
||||
return "allowlist";
|
||||
}
|
||||
if (params.security === "full" && params.ask !== "always") {
|
||||
return "full";
|
||||
}
|
||||
return "ask";
|
||||
}
|
||||
|
||||
function minOpenClawExecSecurity(
|
||||
left: OpenClawExecSecurity,
|
||||
right: OpenClawExecSecurity,
|
||||
): OpenClawExecSecurity {
|
||||
const order: Record<OpenClawExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
|
||||
return order[left] <= order[right] ? left : right;
|
||||
}
|
||||
|
||||
function maxOpenClawExecAsk(left: OpenClawExecAsk, right: OpenClawExecAsk): OpenClawExecAsk {
|
||||
const order: Record<OpenClawExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
|
||||
return order[left] >= order[right] ? left : right;
|
||||
}
|
||||
|
||||
function readExecMode(value: unknown): OpenClawExecMode | undefined {
|
||||
return value === "deny" ||
|
||||
value === "allowlist" ||
|
||||
value === "ask" ||
|
||||
value === "auto" ||
|
||||
value === "full"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeCodexServiceTier(value: unknown): CodexServiceTier | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
@@ -1035,6 +1414,14 @@ function readBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readExecSecurity(value: unknown): OpenClawExecSecurity | undefined {
|
||||
return value === "deny" || value === "allowlist" || value === "full" ? value : undefined;
|
||||
}
|
||||
|
||||
function readExecAsk(value: unknown): OpenClawExecAsk | undefined {
|
||||
return value === "off" || value === "on-miss" || value === "always" ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumberEnv(value: string | undefined): number | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || !PLAIN_DECIMAL_NUMBER_RE.test(trimmed)) {
|
||||
|
||||
@@ -469,6 +469,41 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes runtime config into Codex exec dynamic tool construction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const runtimeConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
reviewer: {
|
||||
timeoutMs: 1234,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
params.disableTools = false;
|
||||
params.config = runtimeConfig;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
const toolOptions = factoryOptions[0] as {
|
||||
config?: unknown;
|
||||
exec?: { config?: unknown; mode?: unknown };
|
||||
};
|
||||
expect(factoryOptions).toHaveLength(1);
|
||||
expect(toolOptions.config).toBe(runtimeConfig);
|
||||
expect(toolOptions.exec?.config).toBe(runtimeConfig);
|
||||
expect(toolOptions.exec?.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the tool auth profile store for Codex dynamic tool construction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -187,6 +187,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
...buildEmbeddedAttemptToolRunContext(params),
|
||||
exec: {
|
||||
...params.execOverrides,
|
||||
config: params.config,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox: input.sandbox,
|
||||
|
||||
@@ -2518,6 +2518,27 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(startParams?.sandbox).toBe("danger-full-access");
|
||||
});
|
||||
|
||||
it("keeps normalized full exec mode unpromoted when OpenClaw tool policy exists", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.config = { tools: { exec: { mode: "full" } } } as never;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as Record<string, unknown> | undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("never");
|
||||
expect(startParams?.sandbox).toBe("danger-full-access");
|
||||
});
|
||||
|
||||
it("ignores invalid Codex app-server env overrides when promoting tool policy approval", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
onInternalDiagnosticEvent,
|
||||
resolveDiagnosticModelContentCapturePolicy,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
|
||||
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
@@ -118,6 +119,7 @@ import {
|
||||
readCodexPluginConfig,
|
||||
resolveCodexComputerUseConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveOpenClawExecPolicyForCodexAppServer,
|
||||
shouldAutoApproveCodexAppServerApprovals,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
} from "./config.js";
|
||||
@@ -342,7 +344,11 @@ export async function runCodexAppServerAttempt(
|
||||
const attemptClientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const computerUseConfig = resolveCodexComputerUseConfig({ pluginConfig });
|
||||
const configuredAppServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const beforeToolCallPolicy = getBeforeToolCallPolicyDiagnosticState();
|
||||
preDynamicStartupStages.mark("config");
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
@@ -357,6 +363,17 @@ export async function runCodexAppServerAttempt(
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
preDynamicStartupStages.mark("sandbox");
|
||||
const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({
|
||||
execOverrides: params.execOverrides,
|
||||
approvals: loadExecApprovals(),
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const configuredAppServer = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig,
|
||||
execPolicy,
|
||||
openClawSandboxActive: sandbox?.enabled === true,
|
||||
});
|
||||
const effectiveWorkspace = sandbox?.enabled
|
||||
? sandbox.workspaceAccess === "rw"
|
||||
? resolvedWorkspace
|
||||
@@ -378,6 +395,7 @@ export async function runCodexAppServerAttempt(
|
||||
shouldPromote:
|
||||
beforeToolCallPolicy.hasBeforeToolCallHook ||
|
||||
beforeToolCallPolicy.trustedToolPolicies.length > 0,
|
||||
execPolicy,
|
||||
canUseUntrustedApprovalPolicy:
|
||||
configuredAppServer.start.transport !== "stdio" ||
|
||||
isCodexAppServerApprovalPolicyAllowedByRequirements("untrusted"),
|
||||
@@ -408,11 +426,6 @@ export async function runCodexAppServerAttempt(
|
||||
params.abortSignal?.addEventListener("abort", abortFromUpstream, { once: true });
|
||||
}
|
||||
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId);
|
||||
preDynamicStartupStages.mark("session-agent");
|
||||
let startupBinding = await readCodexAppServerBinding(params.sessionFile);
|
||||
|
||||
@@ -546,6 +546,7 @@ async function bindConversation(
|
||||
sessionFile: ctx.sessionFile,
|
||||
workspaceDir,
|
||||
agentDir: scope.agentDir,
|
||||
sessionKey: ctx.sessionKey,
|
||||
threadId: parsed.threadId,
|
||||
model: parsed.model,
|
||||
modelProvider: parsed.provider,
|
||||
|
||||
@@ -3367,6 +3367,7 @@ describe("codex command", () => {
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
agentDir: path.join(tempDir, "agents", "main", "agent"),
|
||||
sessionKey: undefined,
|
||||
threadId: "thread-123",
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
@@ -3426,6 +3427,7 @@ describe("codex command", () => {
|
||||
sessionFile,
|
||||
workspaceDir: "/repo with space",
|
||||
agentDir: path.join(tempDir, "agents", "main", "agent"),
|
||||
sessionKey: undefined,
|
||||
threadId: "thread-123",
|
||||
model: undefined,
|
||||
modelProvider: undefined,
|
||||
|
||||
@@ -14,10 +14,40 @@ const agentRuntimeMocks = vi.hoisted(() => ({
|
||||
resolveAuthProfileOrder: vi.fn(),
|
||||
resolveDefaultAgentDir: vi.fn(() => "/agent"),
|
||||
resolvePersistedAuthProfileOwnerAgentDir: vi.fn(),
|
||||
resolveProviderIdForAuth: vi.fn((provider: string) => provider),
|
||||
resolveProviderIdForAuth: vi.fn((provider: string, _lookup?: { config?: unknown }) => provider),
|
||||
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
|
||||
saveAuthProfileStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const codexRequirementsTomlMock = vi.hoisted(() => vi.fn<() => string | undefined>());
|
||||
const resolveSandboxContextMock = vi.hoisted(() =>
|
||||
vi.fn<(...args: unknown[]) => Promise<{ enabled: boolean } | null>>(async () => null),
|
||||
);
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
readFileSync(filePath: string | URL | number, options?: BufferEncoding | object | null) {
|
||||
if (filePath === "/etc/codex/requirements.toml") {
|
||||
const content = codexRequirementsTomlMock();
|
||||
if (content !== undefined) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return actual.readFileSync(filePath, options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSandboxContext: resolveSandboxContextMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./app-server/shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
@@ -55,7 +85,11 @@ describe("codex conversation binding", () => {
|
||||
agentRuntimeMocks.resolveDefaultAgentDir.mockClear();
|
||||
agentRuntimeMocks.resolvePersistedAuthProfileOwnerAgentDir.mockReset();
|
||||
agentRuntimeMocks.resolveProviderIdForAuth.mockClear();
|
||||
agentRuntimeMocks.resolveSessionAgentIds.mockClear();
|
||||
agentRuntimeMocks.saveAuthProfileStore.mockReset();
|
||||
codexRequirementsTomlMock.mockReset();
|
||||
resolveSandboxContextMock.mockReset();
|
||||
resolveSandboxContextMock.mockResolvedValue(null);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -66,7 +100,13 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]);
|
||||
agentRuntimeMocks.resolveDefaultAgentDir.mockReturnValue("/agent");
|
||||
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider);
|
||||
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation(
|
||||
(provider: string, _lookup?: { config?: unknown }) => provider,
|
||||
);
|
||||
agentRuntimeMocks.resolveSessionAgentIds.mockReturnValue({
|
||||
defaultAgentId: "main",
|
||||
sessionAgentId: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the default Codex auth profile and omits the public OpenAI provider for new binds", async () => {
|
||||
@@ -208,6 +248,70 @@ describe("codex conversation binding", () => {
|
||||
expect(data.agentDir).toBe(agentDir);
|
||||
});
|
||||
|
||||
it("rejects binding when configured exec auto mode may need unrouted human approvals", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
startCodexConversationThread({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet",
|
||||
);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects binding when configured exec ask mode needs unrouted user approvals", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
startCodexConversationThread({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "ask",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet",
|
||||
);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears the Codex app-server sidecar when a pending bind is denied", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sidecar = `${sessionFile}.codex-app-server.json`;
|
||||
@@ -595,6 +699,104 @@ describe("codex conversation binding", () => {
|
||||
expect(savedBinding).not.toHaveProperty("modelProvider");
|
||||
});
|
||||
|
||||
it("does not silently decline auto-mode approvals during missing thread recovery", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-old",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
}),
|
||||
);
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
const notificationHandlers: Array<(notification: Record<string, unknown>) => void> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
if (method === "turn/start" && requestParams.threadId === "thread-old") {
|
||||
throw new Error("thread not found: thread-old");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}
|
||||
if (method === "turn/start" && requestParams.threadId === "thread-new") {
|
||||
setImmediate(() => {
|
||||
for (const handler of notificationHandlers) {
|
||||
handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-new",
|
||||
turn: {
|
||||
id: "turn-new",
|
||||
status: "completed",
|
||||
items: [{ id: "assistant-1", type: "agentMessage", text: "Recovered" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return { turn: { id: "turn-new" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler) => {
|
||||
notificationHandlers.push(handler);
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hi again",
|
||||
bodyForAgent: "hi again",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: 500,
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply?.text).toContain(
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet",
|
||||
);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates a fresh thread when recovery finds the binding already cleared", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
@@ -674,6 +876,119 @@ describe("codex conversation binding", () => {
|
||||
expect(savedBinding.threadId).toBe("thread-new");
|
||||
});
|
||||
|
||||
it("passes sandbox state when resolving bound turn policy", async () => {
|
||||
codexRequirementsTomlMock.mockReturnValue(
|
||||
[
|
||||
'allowed_sandbox_modes = ["read-only", "workspace-write"]',
|
||||
'allowed_approval_policies = ["never", "on-request"]',
|
||||
'allowed_approvals_reviewers = ["user"]',
|
||||
].join("\n"),
|
||||
);
|
||||
resolveSandboxContextMock.mockResolvedValue({ enabled: true });
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
const turnStartParams: Record<string, unknown>[] = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
if (method === "turn/start") {
|
||||
turnStartParams.push(requestParams);
|
||||
setImmediate(() =>
|
||||
notificationHandler?.({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { turn: { id: "turn-1" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "continue",
|
||||
bodyForAgent: "continue",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
sessionKey: "agent:main:session-1",
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
sessionKey: "agent:main:session-1",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: 50,
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply?.text).toContain(
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet",
|
||||
);
|
||||
expect(result?.reply?.text).not.toContain(
|
||||
"legacy full exec security with ask requires Codex app-server danger-full-access",
|
||||
);
|
||||
expect(resolveSandboxContextMock).toHaveBeenCalledWith({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:session-1",
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
expect(turnStartParams).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a clean failure reply when app-server turn start rejects", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const agentDir = path.join(tempDir, "agents", "bot-b", "agent");
|
||||
@@ -831,5 +1146,10 @@ describe("codex conversation binding", () => {
|
||||
expect(turnStartParams[0]?.input).toEqual([
|
||||
{ type: "text", text: "use the fallback prompt", text_elements: [] },
|
||||
]);
|
||||
expect(turnStartParams[0]?.approvalPolicy).toBe("never");
|
||||
expect(turnStartParams[0]?.approvalsReviewer).toBe("user");
|
||||
expect(turnStartParams[0]?.sandboxPolicy).toEqual({
|
||||
type: "dangerFullAccess",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
formatErrorMessage,
|
||||
resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import type {
|
||||
PluginConversationBindingResolvedEvent,
|
||||
PluginHookInboundClaimContext,
|
||||
PluginHookInboundClaimEvent,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
codexSandboxPolicyForTurn,
|
||||
resolveOpenClawExecPolicyForCodexAppServer,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerApprovalPolicy,
|
||||
type CodexAppServerSandboxMode,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./app-server/config.js";
|
||||
import {
|
||||
type CodexServiceTier,
|
||||
@@ -52,6 +63,8 @@ import { buildCodexConversationTurnInput } from "./conversation-turn-input.js";
|
||||
import { resumeCodexCliSessionOnNode } from "./node-cli-sessions.js";
|
||||
|
||||
const DEFAULT_BOUND_TURN_TIMEOUT_MS = 20 * 60_000;
|
||||
const NATIVE_CONVERSATION_INTERACTIVE_APPROVALS_UNAVAILABLE =
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet; use the Codex harness or explicit /acp spawn codex for that workflow.";
|
||||
|
||||
export {
|
||||
createCodexCliNodeConversationBindingData,
|
||||
@@ -61,7 +74,7 @@ export {
|
||||
|
||||
type CodexConversationRunOptions = {
|
||||
pluginConfig?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
config?: CodexConversationConfig;
|
||||
timeoutMs?: number;
|
||||
resumeCodexCliSessionOnNode?: ResumeCodexCliSessionOnNodeFn;
|
||||
};
|
||||
@@ -72,10 +85,11 @@ type ResumeCodexCliSessionOnNodeFn = (
|
||||
|
||||
type CodexConversationStartParams = {
|
||||
pluginConfig?: unknown;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
config?: CodexConversationConfig;
|
||||
sessionFile: string;
|
||||
workspaceDir?: string;
|
||||
agentDir?: string;
|
||||
sessionKey?: string;
|
||||
threadId?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
@@ -89,10 +103,46 @@ type BoundTurnResult = {
|
||||
reply: ReplyPayload;
|
||||
};
|
||||
|
||||
type CodexConversationConfig = Parameters<
|
||||
typeof resolveCodexAppServerAuthProfileIdForAgent
|
||||
>[0]["config"];
|
||||
|
||||
type CodexConversationGlobalState = {
|
||||
queues: Map<string, Promise<void>>;
|
||||
};
|
||||
|
||||
async function resolveConversationAppServerRuntime(params: {
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<{
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
runtime: ReturnType<typeof resolveCodexAppServerRuntimeOptions>;
|
||||
}> {
|
||||
const execPolicy = resolveConversationExecPolicy({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const sandboxForPolicy =
|
||||
execPolicy?.touched === true && execPolicy.security === "full" && execPolicy.ask !== "off"
|
||||
? await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
})
|
||||
: undefined;
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.pluginConfig,
|
||||
execPolicy,
|
||||
openClawSandboxActive: sandboxForPolicy?.enabled === true,
|
||||
});
|
||||
assertNativeConversationApprovalPolicySupported({ execPolicy, runtime });
|
||||
return { execPolicy, runtime };
|
||||
}
|
||||
|
||||
const CODEX_CONVERSATION_GLOBAL_STATE = Symbol.for("openclaw.codex.conversationBinding");
|
||||
|
||||
function getGlobalState(): CodexConversationGlobalState {
|
||||
@@ -131,6 +181,7 @@ export async function startCodexConversationThread(
|
||||
sandbox: params.sandbox,
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
} else {
|
||||
await createThread({
|
||||
@@ -145,6 +196,7 @@ export async function startCodexConversationThread(
|
||||
sandbox: params.sandbox,
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
}
|
||||
return createCodexConversationBindingData({
|
||||
@@ -222,6 +274,8 @@ export async function handleCodexConversationInboundClaim(
|
||||
data,
|
||||
prompt,
|
||||
event,
|
||||
config: options.config,
|
||||
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
||||
pluginConfig: options.pluginConfig,
|
||||
timeoutMs: options.timeoutMs,
|
||||
}),
|
||||
@@ -263,9 +317,15 @@ async function attachExistingThread(params: {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const { execPolicy, runtime } = await resolveConversationAppServerRuntime({
|
||||
pluginConfig: params.pluginConfig,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.agentDir, config: params.config });
|
||||
const modelProvider = resolveThreadRequestModelProvider({
|
||||
@@ -287,9 +347,11 @@ async function attachExistingThread(params: {
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalPolicy: execPolicy?.touched
|
||||
? runtime.approvalPolicy
|
||||
: (params.approvalPolicy ?? runtime.approvalPolicy),
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
sandbox: execPolicy?.touched ? runtime.sandbox : (params.sandbox ?? runtime.sandbox),
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
@@ -312,8 +374,10 @@ async function attachExistingThread(params: {
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
approvalPolicy: execPolicy?.touched
|
||||
? runtimeApprovalPolicy
|
||||
: (params.approvalPolicy ?? runtimeApprovalPolicy),
|
||||
sandbox: execPolicy?.touched ? runtime.sandbox : (params.sandbox ?? runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
@@ -337,9 +401,15 @@ async function createThread(params: {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const { execPolicy, runtime } = await resolveConversationAppServerRuntime({
|
||||
pluginConfig: params.pluginConfig,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.agentDir, config: params.config });
|
||||
const modelProvider = resolveThreadRequestModelProvider({
|
||||
@@ -361,9 +431,11 @@ async function createThread(params: {
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalPolicy: execPolicy?.touched
|
||||
? runtime.approvalPolicy
|
||||
: (params.approvalPolicy ?? runtime.approvalPolicy),
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
sandbox: execPolicy?.touched ? runtime.sandbox : (params.sandbox ?? runtime.sandbox),
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
@@ -388,8 +460,10 @@ async function createThread(params: {
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
approvalPolicy: execPolicy?.touched
|
||||
? runtimeApprovalPolicy
|
||||
: (params.approvalPolicy ?? runtimeApprovalPolicy),
|
||||
sandbox: execPolicy?.touched ? runtime.sandbox : (params.sandbox ?? runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
@@ -406,17 +480,24 @@ async function runBoundTurn(params: {
|
||||
prompt: string;
|
||||
event: PluginHookInboundClaimEvent;
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
sessionKey?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.pluginConfig,
|
||||
});
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir });
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir, config: params.config });
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile, agentLookup);
|
||||
const threadId = binding?.threadId;
|
||||
if (!threadId) {
|
||||
throw new Error("bound Codex conversation has no thread binding");
|
||||
}
|
||||
const workspaceDir = binding.cwd || params.data.workspaceDir;
|
||||
const { execPolicy, runtime } = await resolveConversationAppServerRuntime({
|
||||
pluginConfig: params.pluginConfig,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
assertNativeConversationApprovalPolicySupported({ execPolicy, runtime });
|
||||
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
@@ -473,12 +554,14 @@ async function runBoundTurn(params: {
|
||||
prompt: params.prompt,
|
||||
event: params.event,
|
||||
}),
|
||||
cwd: binding.cwd || params.data.workspaceDir,
|
||||
approvalPolicy: binding.approvalPolicy ?? runtime.approvalPolicy,
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy: execPolicy?.touched
|
||||
? runtime.approvalPolicy
|
||||
: (binding.approvalPolicy ?? runtime.approvalPolicy),
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(
|
||||
binding.sandbox ?? runtime.sandbox,
|
||||
binding.cwd || params.data.workspaceDir,
|
||||
execPolicy?.touched ? runtime.sandbox : (binding.sandbox ?? runtime.sandbox),
|
||||
workspaceDir,
|
||||
),
|
||||
...(binding.model ? { model: binding.model } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
@@ -513,11 +596,22 @@ async function runBoundTurn(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeConversationApprovalPolicySupported(params: {
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
runtime: ReturnType<typeof resolveCodexAppServerRuntimeOptions>;
|
||||
}): void {
|
||||
if (params.execPolicy?.touched === true && params.runtime.approvalPolicy !== "never") {
|
||||
throw new Error(NATIVE_CONVERSATION_INTERACTIVE_APPROVALS_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBoundTurnWithMissingThreadRecovery(params: {
|
||||
data: CodexAppServerConversationBindingData;
|
||||
prompt: string;
|
||||
event: PluginHookInboundClaimEvent;
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
sessionKey?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
try {
|
||||
@@ -526,8 +620,13 @@ async function runBoundTurnWithMissingThreadRecovery(params: {
|
||||
if (!isCodexThreadNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir });
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir, config: params.config });
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile, agentLookup);
|
||||
const execPolicy = resolveConversationExecPolicy({
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const useCurrentRuntimePolicy = execPolicy?.touched === true;
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: params.pluginConfig,
|
||||
sessionFile: params.data.sessionFile,
|
||||
@@ -536,14 +635,65 @@ async function runBoundTurnWithMissingThreadRecovery(params: {
|
||||
model: binding?.model,
|
||||
modelProvider: binding?.modelProvider,
|
||||
authProfileId: binding?.authProfileId,
|
||||
approvalPolicy: binding?.approvalPolicy,
|
||||
sandbox: binding?.sandbox,
|
||||
approvalPolicy: useCurrentRuntimePolicy ? undefined : binding?.approvalPolicy,
|
||||
sandbox: useCurrentRuntimePolicy ? undefined : binding?.sandbox,
|
||||
serviceTier: binding?.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
return await runBoundTurn(params);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationExecPolicy(params: {
|
||||
config?: CodexConversationConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
if (!params.config) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId =
|
||||
params.agentId ??
|
||||
resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
}).sessionAgentId;
|
||||
return resolveOpenClawExecPolicyForCodexAppServer({
|
||||
config: params.config,
|
||||
agentId,
|
||||
execOverrides: readSessionExecOverrides({
|
||||
config: params.config,
|
||||
agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
approvals: loadExecApprovals(),
|
||||
});
|
||||
}
|
||||
|
||||
function readSessionExecOverrides(params: {
|
||||
config?: CodexConversationConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}): { security?: string; ask?: string } | undefined {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!params.config || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const storePath = resolveStorePath(params.config.session?.store, { agentId: params.agentId });
|
||||
const entry = resolveSessionStoreEntry({
|
||||
store: loadSessionStore(storePath, { skipCache: true }),
|
||||
sessionKey,
|
||||
}).existing;
|
||||
if (!entry?.execSecurity && !entry?.execAsk) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
security: entry.execSecurity,
|
||||
ask: entry.execAsk,
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexThreadNotFoundError(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error);
|
||||
return (
|
||||
|
||||
@@ -93,10 +93,10 @@ function createButtonComponent(params: {
|
||||
id: componentId,
|
||||
kind: params.modalId ? "modal-trigger" : "button",
|
||||
label: params.spec.label,
|
||||
callbackData: params.spec.callbackData,
|
||||
modalId: params.modalId,
|
||||
reusable: params.spec.reusable,
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
|
||||
...(params.modalId !== undefined ? { modalId: params.modalId } : {}),
|
||||
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
|
||||
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -126,10 +126,10 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label,
|
||||
callbackData: params.spec.callbackData,
|
||||
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
|
||||
selectType,
|
||||
...(options ? { options } : {}),
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
|
||||
});
|
||||
|
||||
if (type === "string") {
|
||||
@@ -252,12 +252,13 @@ export function buildDiscordComponentMessage(params: {
|
||||
> = [];
|
||||
|
||||
const addEntry = (entry: DiscordComponentEntry) => {
|
||||
const reusable = entry.reusable ?? params.spec.reusable;
|
||||
entries.push({
|
||||
...entry,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
reusable: entry.reusable ?? params.spec.reusable,
|
||||
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
|
||||
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
|
||||
...(reusable !== undefined ? { reusable } : {}),
|
||||
consumptionGroupId,
|
||||
});
|
||||
};
|
||||
@@ -339,26 +340,30 @@ export function buildDiscordComponentMessage(params: {
|
||||
name: normalizeModalFieldName(field.name, index),
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
description: field.description,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
minValues: field.minValues,
|
||||
maxValues: field.maxValues,
|
||||
minLength: field.minLength,
|
||||
maxLength: field.maxLength,
|
||||
style: field.style,
|
||||
...(field.description !== undefined ? { description: field.description } : {}),
|
||||
...(field.placeholder !== undefined ? { placeholder: field.placeholder } : {}),
|
||||
...(field.required !== undefined ? { required: field.required } : {}),
|
||||
...(field.options !== undefined ? { options: field.options } : {}),
|
||||
...(field.minValues !== undefined ? { minValues: field.minValues } : {}),
|
||||
...(field.maxValues !== undefined ? { maxValues: field.maxValues } : {}),
|
||||
...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
|
||||
...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
|
||||
...(field.style !== undefined ? { style: field.style } : {}),
|
||||
}));
|
||||
modals.push({
|
||||
id: modalId,
|
||||
title: params.spec.modal.title,
|
||||
callbackData: params.spec.modal.callbackData,
|
||||
fields,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
reusable: params.spec.reusable,
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
...(params.spec.modal.callbackData !== undefined
|
||||
? { callbackData: params.spec.modal.callbackData }
|
||||
: {}),
|
||||
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
|
||||
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
|
||||
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
|
||||
...(params.spec.modal.allowedUsers !== undefined
|
||||
? { allowedUsers: params.spec.modal.allowedUsers }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const triggerSpec: DiscordComponentButtonSpec = {
|
||||
|
||||
@@ -147,6 +147,28 @@ describe("discord components", () => {
|
||||
expect(result.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("omits unset optional fields from persisted button entries", () => {
|
||||
const spec = readDiscordComponentSpec({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Allow Once", style: "success" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!spec) {
|
||||
throw new Error("Expected component spec to be parsed");
|
||||
}
|
||||
|
||||
const result = buildDiscordComponentMessage({ spec });
|
||||
const entry = result.entries[0];
|
||||
if (!entry) {
|
||||
throw new Error("Expected button entry");
|
||||
}
|
||||
|
||||
expect(Object.entries(entry).filter(([, value]) => value === undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires options for modal select fields", () => {
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
|
||||
@@ -427,6 +427,10 @@
|
||||
"types": "./dist/plugin-sdk/transport-ready-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/transport-ready-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/exec-approvals-runtime": {
|
||||
"types": "./dist/plugin-sdk/exec-approvals-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/exec-approvals-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/infra-runtime": {
|
||||
"types": "./dist/plugin-sdk/infra-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/infra-runtime.js"
|
||||
|
||||
@@ -158,6 +158,8 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
turnSourceTo: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceAccountId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])),
|
||||
requireDeliveryRoute: Type.Optional(Type.Boolean()),
|
||||
suppressDelivery: Type.Optional(Type.Boolean()),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
twoPhase: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
"types": "./dist/src/plugin-sdk/transport-ready-runtime.d.ts",
|
||||
"default": "./src/transport-ready-runtime.ts"
|
||||
},
|
||||
"./exec-approvals-runtime": {
|
||||
"types": "./dist/src/plugin-sdk/exec-approvals-runtime.d.ts",
|
||||
"default": "./src/exec-approvals-runtime.ts"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./dist/src/plugin-sdk/core.d.ts",
|
||||
"default": "./src/core.ts"
|
||||
|
||||
1
packages/plugin-sdk/src/exec-approvals-runtime.ts
Normal file
1
packages/plugin-sdk/src/exec-approvals-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../../src/plugin-sdk/exec-approvals-runtime.js";
|
||||
@@ -70,6 +70,7 @@
|
||||
"secure-random-runtime",
|
||||
"system-event-runtime",
|
||||
"transport-ready-runtime",
|
||||
"exec-approvals-runtime",
|
||||
"infra-runtime",
|
||||
"runtime-config-snapshot",
|
||||
"runtime-group-policy",
|
||||
|
||||
@@ -99,6 +99,86 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
expect(resultDetails?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("passes normalized exec mode defaults into the exec tool", async () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test-main-mode-deny",
|
||||
agentDir: "/tmp/agent-main-mode-deny",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-mode-deny", {
|
||||
command: "echo blocked",
|
||||
}),
|
||||
).rejects.toThrow("security=deny");
|
||||
});
|
||||
|
||||
it("ignores per-call legacy security when configured mode is full", async () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test-main-mode-call-security",
|
||||
agentDir: "/tmp/agent-main-mode-call-security",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
const result = await execTool.execute("call-mode-security-deny", {
|
||||
command: "echo allowed",
|
||||
security: "deny",
|
||||
});
|
||||
const text = (result.content[0] as { text?: string } | undefined)?.text ?? "";
|
||||
expect(text).toContain("allowed");
|
||||
});
|
||||
|
||||
it("preserves mode-derived security for partial agent exec overrides", async () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
safeBins: [],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
exec: {
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test-main-mode-partial-agent",
|
||||
agentDir: "/tmp/agent-main-mode-partial-agent",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-mode-partial-agent", {
|
||||
command: "echo blocked",
|
||||
}),
|
||||
).rejects.toThrow(/allowlist miss/);
|
||||
});
|
||||
|
||||
it("fails closed when exec host=sandbox is requested without sandbox runtime", async () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: {},
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { ModelCompatConfig } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
|
||||
import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js";
|
||||
import {
|
||||
type ExecAsk,
|
||||
type ExecMode,
|
||||
type ExecSecurity,
|
||||
resolveExecPolicyForMode,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
@@ -291,15 +297,46 @@ function isApplyPatchAllowedForModel(params: {
|
||||
});
|
||||
}
|
||||
|
||||
type ExecPolicyLayer = {
|
||||
mode?: ExecMode;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
};
|
||||
|
||||
function hasLegacyExecPolicy(exec?: ExecPolicyLayer): boolean {
|
||||
return exec?.security !== undefined || exec?.ask !== undefined;
|
||||
}
|
||||
|
||||
function applyExecPolicyLayer(base: ExecPolicyLayer, layer?: ExecPolicyLayer): ExecPolicyLayer {
|
||||
if (!layer) {
|
||||
return base;
|
||||
}
|
||||
if (layer.mode) {
|
||||
return {
|
||||
mode: layer.mode,
|
||||
...resolveExecPolicyForMode(layer.mode),
|
||||
};
|
||||
}
|
||||
if (hasLegacyExecPolicy(layer)) {
|
||||
return {
|
||||
security: layer.security ?? base.security,
|
||||
ask: layer.ask ?? base.ask,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
|
||||
const cfg = params.cfg;
|
||||
const globalExec = cfg?.tools?.exec;
|
||||
const agentExec =
|
||||
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.exec : undefined;
|
||||
const layeredPolicy = applyExecPolicyLayer(applyExecPolicyLayer({}, globalExec), agentExec);
|
||||
return {
|
||||
host: agentExec?.host ?? globalExec?.host,
|
||||
security: agentExec?.security ?? globalExec?.security,
|
||||
ask: agentExec?.ask ?? globalExec?.ask,
|
||||
mode: layeredPolicy.mode,
|
||||
security: layeredPolicy.security,
|
||||
ask: layeredPolicy.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
@@ -313,6 +350,7 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
|
||||
global: globalExec,
|
||||
local: agentExec,
|
||||
}),
|
||||
reviewer: agentExec?.reviewer ?? globalExec?.reviewer,
|
||||
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
|
||||
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
|
||||
approvalRunningNoticeMs:
|
||||
|
||||
@@ -53,6 +53,8 @@ export type RequestExecApprovalDecisionParams = {
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
requireDeliveryRoute?: boolean;
|
||||
suppressDelivery?: boolean;
|
||||
};
|
||||
|
||||
type ExecApprovalRequestToolParams = RequestExecApprovalDecisionParams & {
|
||||
@@ -83,6 +85,8 @@ function buildExecApprovalRequestToolParams(
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
requireDeliveryRoute: params.requireDeliveryRoute,
|
||||
suppressDelivery: params.suppressDelivery,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
twoPhase: true,
|
||||
};
|
||||
@@ -193,6 +197,8 @@ type HostExecApprovalParams = {
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
requireDeliveryRoute?: boolean;
|
||||
suppressDelivery?: boolean;
|
||||
};
|
||||
|
||||
type ExecApprovalRequesterContext = {
|
||||
@@ -294,6 +300,8 @@ async function buildHostApprovalDecisionParams(
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
resolvedPath: params.resolvedPath,
|
||||
requireDeliveryRoute: params.requireDeliveryRoute,
|
||||
suppressDelivery: params.suppressDelivery,
|
||||
...buildExecApprovalTurnSourceContext(params),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type StrictInlineEvalBoundary =
|
||||
typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary;
|
||||
type SendExecApprovalFollowupResult =
|
||||
typeof import("./bash-tools.exec-host-shared.js").sendExecApprovalFollowupResult;
|
||||
type ExecAutoReviewer = typeof import("../infra/exec-auto-review.js").defaultExecAutoReviewer;
|
||||
type BuildExecApprovalFollowupTargetMock = (
|
||||
value: ExecApprovalFollowupTarget,
|
||||
) => ExecApprovalFollowupTarget | null;
|
||||
@@ -59,12 +60,20 @@ const analyzeShellCommandMock = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const buildEnforcedShellCommandMock = vi.hoisted(() =>
|
||||
vi.fn((): { ok: boolean; reason?: string; command?: string } => ({
|
||||
ok: false,
|
||||
reason: "segment execution plan unavailable",
|
||||
})),
|
||||
);
|
||||
const defaultExecAutoReviewerMock = vi.hoisted(() =>
|
||||
vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "allowed",
|
||||
})),
|
||||
);
|
||||
const recordAllowlistMatchesUseMock = vi.hoisted(() => vi.fn());
|
||||
const resolveApprovalDecisionOrUndefinedMock = vi.hoisted(() =>
|
||||
vi.fn(async (): Promise<string | null | undefined> => undefined),
|
||||
@@ -98,12 +107,13 @@ const detectInterpreterInlineEvalArgvMock = vi.hoisted(() =>
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../infra/exec-approvals.js", () => ({
|
||||
vi.mock("../infra/exec-approvals.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../infra/exec-approvals.js")>()),
|
||||
evaluateShellAllowlist: evaluateShellAllowlistMock,
|
||||
analyzeShellCommand: analyzeShellCommandMock,
|
||||
hasDurableExecApproval: hasDurableExecApprovalMock,
|
||||
buildEnforcedShellCommand: buildEnforcedShellCommandMock,
|
||||
requiresExecApproval: vi.fn(() => false),
|
||||
requiresExecApproval: requiresExecApprovalMock,
|
||||
recordAllowlistUse: vi.fn(),
|
||||
recordAllowlistMatchesUse: recordAllowlistMatchesUseMock,
|
||||
resolveApprovalAuditTrustPath: vi.fn(() => null),
|
||||
@@ -113,6 +123,10 @@ vi.mock("../infra/exec-approvals.js", () => ({
|
||||
addDurableCommandApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/exec-auto-review.js", () => ({
|
||||
defaultExecAutoReviewer: defaultExecAutoReviewerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./bash-tools.exec-approval-request.js", () => ({
|
||||
buildExecApprovalRequesterContext: vi.fn(() => ({})),
|
||||
buildExecApprovalTurnSourceContext: vi.fn(() => ({})),
|
||||
@@ -228,11 +242,19 @@ describe("processGatewayAllowlist", () => {
|
||||
}));
|
||||
hasDurableExecApprovalMock.mockReset();
|
||||
hasDurableExecApprovalMock.mockReturnValue(true);
|
||||
requiresExecApprovalMock.mockReset();
|
||||
requiresExecApprovalMock.mockReturnValue(false);
|
||||
buildEnforcedShellCommandMock.mockReset();
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: false,
|
||||
reason: "segment execution plan unavailable",
|
||||
});
|
||||
defaultExecAutoReviewerMock.mockReset();
|
||||
defaultExecAutoReviewerMock.mockResolvedValue({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "allowed",
|
||||
});
|
||||
recordAllowlistMatchesUseMock.mockReset();
|
||||
resolveApprovalDecisionOrUndefinedMock.mockReset();
|
||||
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(undefined);
|
||||
@@ -331,26 +353,274 @@ describe("processGatewayAllowlist", () => {
|
||||
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("allows durable exact-command trust to bypass the synchronous allowlist miss", async () => {
|
||||
it("auto-reviews simple read-only approval misses without prompting", async () => {
|
||||
requiresExecApprovalMock.mockReturnValue(true);
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: false,
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["node", "--version"] }],
|
||||
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
hasDurableExecApprovalMock.mockReturnValue(true);
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: true,
|
||||
command: "node --version",
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command: "node --version",
|
||||
command: "echo ok",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "echo ok",
|
||||
argv: ["echo", "ok"],
|
||||
host: "gateway",
|
||||
reason: "approval-required",
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ execCommandOverride: undefined });
|
||||
expect(result).toEqual({
|
||||
execCommandOverride: undefined,
|
||||
allowWithoutEnforcedCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-reviews heredoc commands instead of forcing human approval", async () => {
|
||||
const command = "python3 - <<'PY'\nprint('ok')\nPY";
|
||||
requiresExecApprovalMock.mockReturnValue(false);
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: true,
|
||||
command,
|
||||
});
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
segments: [
|
||||
{
|
||||
raw: command,
|
||||
resolution: null,
|
||||
argv: ["python3", "-", "<<'PY'"],
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command,
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command,
|
||||
argv: ["python3", "-", "<<'PY'"],
|
||||
host: "gateway",
|
||||
reason: "heredoc",
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
execCommandOverride: command,
|
||||
allowWithoutEnforcedCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-reviews strict inline-eval commands instead of forcing human approval", async () => {
|
||||
const command = "python3 -c 'print(1)'";
|
||||
requiresExecApprovalMock.mockReturnValue(false);
|
||||
detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT);
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: true,
|
||||
command,
|
||||
});
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
segments: [
|
||||
{
|
||||
raw: command,
|
||||
resolution: null,
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
},
|
||||
],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command,
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
strictInlineEval: true,
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command,
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
host: "gateway",
|
||||
reason: "strict-inline-eval",
|
||||
analysis: expect.objectContaining({
|
||||
inlineEval: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(warnings[0]).toContain("reviewer or explicit approval");
|
||||
expect(result).toEqual({
|
||||
execCommandOverride: command,
|
||||
allowWithoutEnforcedCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-reviews allowlist plan misses instead of forcing human approval", async () => {
|
||||
const command = "echo ok";
|
||||
requiresExecApprovalMock.mockReturnValue(false);
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: false,
|
||||
reason: "segment execution plan unavailable",
|
||||
});
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
segments: [{ raw: command, resolution: null, argv: ["echo", "ok"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command,
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command,
|
||||
argv: ["echo", "ok"],
|
||||
host: "gateway",
|
||||
reason: "execution-plan-miss",
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
execCommandOverride: undefined,
|
||||
allowWithoutEnforcedCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("requests human approval when auto-review asks on an approval miss", async () => {
|
||||
requiresExecApprovalMock.mockReturnValue(true);
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
defaultExecAutoReviewerMock.mockResolvedValue({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "needs a person",
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command: "echo ok",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnings.join("\n")).toContain("needs a person");
|
||||
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("does not use fallback-full when auto-review asks for human approval", async () => {
|
||||
requiresExecApprovalMock.mockReturnValue(true);
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
defaultExecAutoReviewerMock.mockResolvedValue({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "needs a person",
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "full",
|
||||
});
|
||||
createExecApprovalDecisionStateMock.mockReturnValue({
|
||||
baseDecision: { timedOut: true },
|
||||
approvedByAsk: true,
|
||||
deniedReason: null,
|
||||
});
|
||||
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
|
||||
enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) =>
|
||||
value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut
|
||||
? { approvedByAsk: false, deniedReason: "approval-timeout" }
|
||||
: { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason },
|
||||
);
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command: "echo ok",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
turnSourceChannel: "webchat",
|
||||
});
|
||||
|
||||
expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requiresAutoReviewHumanApproval: true,
|
||||
}),
|
||||
);
|
||||
expect(result.deniedResult?.details.status).toBe("failed");
|
||||
expect(result.deniedResult?.content[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: "Exec denied (gateway id=req-1, approval-timeout): echo ok",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires approval for security audit suppression edits unless yolo mode is active", async () => {
|
||||
@@ -371,6 +641,29 @@ describe("processGatewayAllowlist", () => {
|
||||
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("keeps security audit suppression edits off the auto-review path", async () => {
|
||||
const warnings: string[] = [];
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "full",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command: "openclaw config set security.audit.suppressions '[]'",
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled();
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnings[0]).toContain("explicit approval");
|
||||
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("does not require approval for security audit suppression edits in yolo mode", async () => {
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
@@ -550,6 +843,28 @@ EOF`,
|
||||
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("allows durable exact-command trust to bypass the synchronous allowlist miss", async () => {
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["node", "--version"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
hasDurableExecApprovalMock.mockReturnValue(true);
|
||||
buildEnforcedShellCommandMock.mockReturnValue({
|
||||
ok: true,
|
||||
command: "node --version",
|
||||
});
|
||||
|
||||
const result = await runGatewayAllowlist({
|
||||
command: "node --version",
|
||||
});
|
||||
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ execCommandOverride: undefined });
|
||||
});
|
||||
|
||||
it("keeps denying allowlist misses when durable trust does not match", async () => {
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describeInterpreterInlineEval } from "../infra/command-analysis/inline-
|
||||
import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js";
|
||||
import {
|
||||
addDurableCommandApproval,
|
||||
analyzeShellCommand,
|
||||
commandRequiresSecurityAuditSuppressionApproval,
|
||||
type ExecAsk,
|
||||
resolveExecApprovalAllowedDecisions,
|
||||
type ExecSecurity,
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
resolveApprovalAuditTrustPath,
|
||||
requiresExecApproval,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import {
|
||||
defaultExecAutoReviewer,
|
||||
type ExecAutoReviewer,
|
||||
type ExecAutoReviewInput,
|
||||
} from "../infra/exec-auto-review.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
import { isRecord } from "../shared/record-coerce.js";
|
||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
@@ -62,6 +67,8 @@ export type ProcessGatewayAllowlistParams = {
|
||||
defaultTimeoutSec: number;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
autoReview?: boolean;
|
||||
autoReviewer?: ExecAutoReviewer;
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
|
||||
strictInlineEval?: boolean;
|
||||
@@ -106,109 +113,35 @@ function hasGatewayAllowlistMiss(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommandName(value: string | undefined): string {
|
||||
return (value ?? "").split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function textMentionsSecurityAuditSuppressions(value: string): boolean {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes("security.audit.suppressions") ||
|
||||
/["']?security["']?[\s\S]{0,200}["']?audit["']?[\s\S]{0,200}["']?suppressions["']?/.test(
|
||||
normalized,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isReadOnlySecurityAuditSuppressionInspection(argv: string[]): boolean {
|
||||
const command = normalizeCommandName(argv[0]);
|
||||
let offset = command === "pnpm" && argv[1] === "openclaw" ? 1 : 0;
|
||||
if (normalizeCommandName(argv[offset]) !== "openclaw") {
|
||||
return false;
|
||||
function resolveGatewayAutoReviewReason(params: {
|
||||
requiresInlineEvalApproval: boolean;
|
||||
requiresHeredocApproval: boolean;
|
||||
requiresAllowlistPlanApproval: boolean;
|
||||
hostSecurity: ExecSecurity;
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
durableApprovalSatisfied: boolean;
|
||||
}): ExecAutoReviewInput["reason"] {
|
||||
if (params.requiresInlineEvalApproval) {
|
||||
return "strict-inline-eval";
|
||||
}
|
||||
offset += 1;
|
||||
while (offset < argv.length) {
|
||||
const arg = argv[offset];
|
||||
if (["--dev", "--no-color"].includes(arg ?? "")) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (["--profile", "--container", "--log-level"].includes(arg ?? "")) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
arg?.startsWith("--profile=") ||
|
||||
arg?.startsWith("--container=") ||
|
||||
arg?.startsWith("--log-level=")
|
||||
) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
if (params.requiresHeredocApproval) {
|
||||
return "heredoc";
|
||||
}
|
||||
return (
|
||||
argv[offset] === "config" && ["get", "schema", "validate"].includes(argv[offset + 1] ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function removeParsedSegmentText(command: string, segments: Array<{ raw?: string }>): string {
|
||||
let remaining = command;
|
||||
for (const segment of segments) {
|
||||
const raw = segment.raw?.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
remaining = remaining.replace(raw, " ");
|
||||
if (params.requiresAllowlistPlanApproval) {
|
||||
return "execution-plan-miss";
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
function commandRequiresSecurityAuditSuppressionApproval(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
segments: Array<{ argv: string[]; raw?: string }>;
|
||||
}): boolean {
|
||||
let sawSegmentMention = false;
|
||||
for (const segment of params.segments) {
|
||||
const segmentText = `${segment.raw ?? ""} ${segment.argv.join(" ")}`;
|
||||
if (!textMentionsSecurityAuditSuppressions(segmentText)) {
|
||||
continue;
|
||||
}
|
||||
sawSegmentMention = true;
|
||||
if (!isReadOnlySecurityAuditSuppressionInspection(segment.argv)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
hasGatewayAllowlistMiss({
|
||||
hostSecurity: params.hostSecurity,
|
||||
analysisOk: params.analysisOk,
|
||||
allowlistSatisfied: params.allowlistSatisfied,
|
||||
durableApprovalSatisfied: params.durableApprovalSatisfied,
|
||||
})
|
||||
) {
|
||||
return "allowlist-miss";
|
||||
}
|
||||
if (sawSegmentMention) {
|
||||
const rawAnalysis = analyzeShellCommand({
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
platform: process.platform,
|
||||
});
|
||||
if (!rawAnalysis.ok) {
|
||||
return textMentionsSecurityAuditSuppressions(params.command);
|
||||
}
|
||||
for (const segment of rawAnalysis.segments) {
|
||||
if (
|
||||
textMentionsSecurityAuditSuppressions(`${segment.raw} ${segment.argv.join(" ")}`) &&
|
||||
!isReadOnlySecurityAuditSuppressionInspection(segment.argv)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
textMentionsSecurityAuditSuppressions(
|
||||
removeParsedSegmentText(params.command, rawAnalysis.segments),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return textMentionsSecurityAuditSuppressions(params.command);
|
||||
return "approval-required";
|
||||
}
|
||||
|
||||
function formatOutcomeExitLabel(outcome: { exitCode: number | null; timedOut: boolean }): string {
|
||||
@@ -433,7 +366,7 @@ export async function processGatewayAllowlist(
|
||||
params.strictInlineEval === true ? detectPolicyInlineEval(allowlistEval.segments) : null;
|
||||
if (inlineEvalHit) {
|
||||
params.warnings.push(
|
||||
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
|
||||
`Warning: strict inline-eval mode requires reviewer or explicit approval for ${describeInterpreterInlineEval(
|
||||
inlineEvalHit,
|
||||
)}.`,
|
||||
);
|
||||
@@ -493,12 +426,12 @@ export async function processGatewayAllowlist(
|
||||
requiresSecurityAuditSuppressionApproval;
|
||||
if (requiresHeredocApproval) {
|
||||
params.warnings.push(
|
||||
"Warning: heredoc execution requires explicit approval in allowlist mode.",
|
||||
"Warning: heredoc execution requires reviewer or explicit approval in allowlist mode.",
|
||||
);
|
||||
}
|
||||
if (requiresAllowlistPlanApproval) {
|
||||
params.warnings.push(
|
||||
`Warning: allowlist auto-execution is unavailable on ${process.platform}; explicit approval is required.`,
|
||||
`Warning: allowlist auto-execution is unavailable on ${process.platform}; reviewer or explicit approval is required.`,
|
||||
);
|
||||
}
|
||||
if (requiresSecurityAuditSuppressionApproval) {
|
||||
@@ -506,8 +439,69 @@ export async function processGatewayAllowlist(
|
||||
"Warning: security audit suppression changes require explicit approval unless exec is running in yolo mode.",
|
||||
);
|
||||
}
|
||||
|
||||
if (requiresAsk) {
|
||||
const [autoReviewSegment] = allowlistEval.segments;
|
||||
const autoReviewArgv =
|
||||
allowlistEval.segments.length === 1 &&
|
||||
(autoReviewSegment?.raw === undefined ||
|
||||
autoReviewSegment.raw.trim() === params.command.trim())
|
||||
? autoReviewSegment.argv
|
||||
: undefined;
|
||||
const canAutoReviewApprovalMiss =
|
||||
params.autoReview === true &&
|
||||
hostAsk !== "always" &&
|
||||
!requiresSecurityAuditSuppressionApproval;
|
||||
let autoReviewRequiresHumanApproval = false;
|
||||
if (canAutoReviewApprovalMiss) {
|
||||
const reviewer = params.autoReviewer ?? defaultExecAutoReviewer;
|
||||
const decision = await reviewer({
|
||||
command: params.command,
|
||||
argv: autoReviewArgv,
|
||||
cwd: params.workdir,
|
||||
envKeys: Object.keys(params.requestedEnv ?? {}).toSorted(),
|
||||
host: "gateway",
|
||||
reason: resolveGatewayAutoReviewReason({
|
||||
requiresInlineEvalApproval,
|
||||
requiresHeredocApproval,
|
||||
requiresAllowlistPlanApproval,
|
||||
hostSecurity,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied,
|
||||
}),
|
||||
analysis: {
|
||||
parsed: analysisOk,
|
||||
allowlistMatched: allowlistSatisfied,
|
||||
durableApprovalMatched: durableApprovalSatisfied,
|
||||
inlineEval: requiresInlineEvalApproval,
|
||||
heredoc: requiresHeredocApproval,
|
||||
},
|
||||
agent: {
|
||||
id: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
});
|
||||
if (decision.decision === "allow-once") {
|
||||
params.warnings.push(
|
||||
`Exec auto-review allowed once (risk=${decision.risk}): ${decision.rationale}`,
|
||||
);
|
||||
recordMatchedAllowlistUse(
|
||||
resolveApprovalAuditTrustPath(
|
||||
allowlistEval.segments[0]?.resolution ?? null,
|
||||
params.workdir,
|
||||
),
|
||||
);
|
||||
return {
|
||||
execCommandOverride: enforcedCommand,
|
||||
allowWithoutEnforcedCommand: enforcedCommand === undefined,
|
||||
};
|
||||
}
|
||||
params.warnings.push(
|
||||
`Exec auto-review deferred to human approval (risk=${decision.risk}): ${decision.rationale}`,
|
||||
);
|
||||
autoReviewRequiresHumanApproval = true;
|
||||
}
|
||||
|
||||
const requestArgs = buildDefaultExecApprovalRequestArgs({
|
||||
warnings: params.warnings,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
@@ -565,6 +559,7 @@ export async function processGatewayAllowlist(
|
||||
approvedByAsk,
|
||||
deniedReason,
|
||||
requiresInlineEvalApproval,
|
||||
requiresAutoReviewHumanApproval: autoReviewRequiresHumanApproval,
|
||||
});
|
||||
|
||||
if (strictInlineEvalDecision.deniedReason || !strictInlineEvalDecision.approvedByAsk) {
|
||||
@@ -649,6 +644,7 @@ export async function processGatewayAllowlist(
|
||||
approvedByAsk,
|
||||
deniedReason,
|
||||
requiresInlineEvalApproval,
|
||||
requiresAutoReviewHumanApproval: autoReviewRequiresHumanApproval,
|
||||
}));
|
||||
|
||||
if (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
type SystemRunApprovalPlan,
|
||||
commandRequiresSecurityAuditSuppressionApproval,
|
||||
evaluateShellAllowlist,
|
||||
hasDurableExecApproval,
|
||||
resolveExecApprovalsFromFile,
|
||||
@@ -48,6 +49,8 @@ type NodeApprovalAnalysis = {
|
||||
allowlistSatisfied: boolean;
|
||||
durableApprovalSatisfied: boolean;
|
||||
inlineEvalHit: InterpreterInlineEvalHit | null;
|
||||
requiresSecurityAuditSuppressionApproval: boolean;
|
||||
autoReviewArgv?: string[];
|
||||
};
|
||||
|
||||
export function shouldSkipNodeApprovalPrepare(params: {
|
||||
@@ -317,11 +320,18 @@ export async function analyzeNodeApprovalRequirement(params: {
|
||||
: null;
|
||||
if (inlineEvalHit) {
|
||||
params.request.warnings.push(
|
||||
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
|
||||
`Warning: strict inline-eval mode requires reviewer or explicit approval for ${describeInterpreterInlineEval(
|
||||
inlineEvalHit,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
const requiresSecurityAuditSuppressionApproval =
|
||||
commandRequiresSecurityAuditSuppressionApproval({
|
||||
command: params.request.command,
|
||||
cwd: params.request.workdir,
|
||||
env: params.request.env,
|
||||
segments: baseAllowlistEval.segments,
|
||||
}) && !(params.hostSecurity === "full" && params.hostAsk === "off");
|
||||
if ((params.hostAsk === "always" || params.hostSecurity === "allowlist") && analysisOk) {
|
||||
try {
|
||||
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
|
||||
@@ -367,5 +377,12 @@ export async function analyzeNodeApprovalRequirement(params: {
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied,
|
||||
inlineEvalHit,
|
||||
requiresSecurityAuditSuppressionApproval,
|
||||
autoReviewArgv:
|
||||
baseAllowlistEval.segments.length === 1 &&
|
||||
(baseAllowlistEval.segments[0]?.raw === undefined ||
|
||||
baseAllowlistEval.segments[0].raw.trim() === params.request.command.trim())
|
||||
? baseAllowlistEval.segments[0].argv
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type StrictInlineEvalBoundary =
|
||||
typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary;
|
||||
type ExecAutoReviewer = typeof import("../infra/exec-auto-review.js").defaultExecAutoReviewer;
|
||||
|
||||
const INLINE_EVAL_HIT = {
|
||||
executable: "python3",
|
||||
@@ -27,6 +28,16 @@ const preparedPlan = vi.hoisted(() => ({
|
||||
const callGatewayToolMock = vi.hoisted(() => vi.fn());
|
||||
const listNodesMock = vi.hoisted(() => vi.fn());
|
||||
const parsePreparedSystemRunPayloadMock = vi.hoisted(() => vi.fn());
|
||||
const commandRequiresSecurityAuditSuppressionApprovalMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const evaluateShellAllowlistMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["bun", "./script.ts"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
})),
|
||||
);
|
||||
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
@@ -76,13 +87,9 @@ const detectInterpreterInlineEvalArgvMock = vi.hoisted(() =>
|
||||
);
|
||||
|
||||
vi.mock("../infra/exec-approvals.js", () => ({
|
||||
evaluateShellAllowlist: vi.fn(() => ({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["bun", "./script.ts"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
})),
|
||||
evaluateShellAllowlist: evaluateShellAllowlistMock,
|
||||
commandRequiresSecurityAuditSuppressionApproval:
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock,
|
||||
hasDurableExecApproval: vi.fn(() => false),
|
||||
requiresExecApproval: requiresExecApprovalMock,
|
||||
resolveExecApprovalAllowedDecisions: vi.fn(() => ["allow-once", "allow-always", "deny"]),
|
||||
@@ -225,6 +232,9 @@ describe("executeNodeHostCommand", () => {
|
||||
callGatewayToolMock.mockReset();
|
||||
callGatewayToolMock.mockImplementation(
|
||||
async (method: string, _options: unknown, params: MockNodeInvokeParams | undefined) => {
|
||||
if (method === "exec.approval.resolve") {
|
||||
return { payload: {} };
|
||||
}
|
||||
if (method !== "node.invoke") {
|
||||
throw new Error(`unexpected gateway method: ${method}`);
|
||||
}
|
||||
@@ -255,6 +265,16 @@ describe("executeNodeHostCommand", () => {
|
||||
]);
|
||||
parsePreparedSystemRunPayloadMock.mockReset();
|
||||
parsePreparedSystemRunPayloadMock.mockReturnValue({ plan: preparedPlan });
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock.mockReset();
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock.mockReturnValue(false);
|
||||
evaluateShellAllowlistMock.mockReset();
|
||||
evaluateShellAllowlistMock.mockReturnValue({
|
||||
allowlistMatches: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
segments: [{ resolution: null, argv: ["bun", "./script.ts"] }],
|
||||
segmentAllowlistEntries: [],
|
||||
});
|
||||
requiresExecApprovalMock.mockReset();
|
||||
requiresExecApprovalMock.mockReturnValue(true);
|
||||
resolveExecHostApprovalContextMock.mockReset();
|
||||
@@ -352,6 +372,247 @@ describe("executeNodeHostCommand", () => {
|
||||
expect(runParams.turnSourceThreadId).toBe("42");
|
||||
});
|
||||
|
||||
it("does not build a human approval prompt for node auto-review allows", async () => {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "safe read",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "bun ./script.ts",
|
||||
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).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "bun ./script.ts",
|
||||
argv: ["bun", "./script.ts"],
|
||||
host: "node",
|
||||
reason: "allowlist-miss",
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(registerExecApprovalRequestForHostOrThrowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "node",
|
||||
requireDeliveryRoute: false,
|
||||
suppressDelivery: true,
|
||||
}),
|
||||
);
|
||||
expect(callGatewayToolMock).toHaveBeenCalledWith(
|
||||
"exec.approval.resolve",
|
||||
{ timeoutMs: 15_000 },
|
||||
{ id: expect.any(String), decision: "allow-once" },
|
||||
{ scopes: ["operator.approvals"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("requests human approval when node auto-review asks on an approval miss", async () => {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "needs a person",
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "bun ./script.ts",
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnings.join("\n")).toContain("needs a person");
|
||||
});
|
||||
|
||||
it("auto-reviews strict inline-eval commands before asking a human", async () => {
|
||||
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: [],
|
||||
});
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "python3 -c 'print(1)'",
|
||||
workdir: "/tmp/work",
|
||||
env: {},
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
autoReview: true,
|
||||
autoReviewer,
|
||||
strictInlineEval: true,
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings,
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "python3 -c 'print(1)'",
|
||||
argv: ["python3", "-c", "print(1)"],
|
||||
host: "node",
|
||||
reason: "strict-inline-eval",
|
||||
analysis: expect.objectContaining({
|
||||
inlineEval: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
||||
expect(warnings[0]).toContain("requires reviewer or explicit approval");
|
||||
});
|
||||
|
||||
it("keeps security audit suppression edits off the auto-review path", async () => {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "test reviewer would allow it",
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
commandRequiresSecurityAuditSuppressionApprovalMock.mockReturnValue(true);
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "deny",
|
||||
});
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "openclaw config set security.audit.suppressions '[]'",
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
expect(warnings).toContain(
|
||||
"Warning: security audit suppression changes require explicit approval unless exec is running in yolo mode.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use fallback-full when node auto-review asks for human approval", async () => {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "needs a person",
|
||||
}));
|
||||
resolveExecHostApprovalContextMock.mockReturnValue({
|
||||
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
||||
hostSecurity: "allowlist",
|
||||
hostAsk: "on-miss",
|
||||
askFallback: "full",
|
||||
});
|
||||
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
|
||||
createExecApprovalDecisionStateMock.mockReturnValue({
|
||||
baseDecision: { timedOut: true },
|
||||
approvedByAsk: true,
|
||||
deniedReason: null,
|
||||
});
|
||||
enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) =>
|
||||
value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut
|
||||
? { approvedByAsk: false, deniedReason: "approval-timeout" }
|
||||
: { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason },
|
||||
);
|
||||
|
||||
const result = await executeNodeHostCommand({
|
||||
command: "bun ./script.ts",
|
||||
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");
|
||||
await vi.waitFor(() => {
|
||||
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith(
|
||||
{ approvalId: "approval-1" },
|
||||
"Exec denied (node=node-1 id=approval-1, approval-timeout): bun ./script.ts",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
callGatewayToolMock.mock.calls.some(
|
||||
([method, , params]) =>
|
||||
method === "node.invoke" &&
|
||||
(params as MockNodeInvokeParams | undefined)?.command === "system.run",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a local systemRunPlan when approval is required and the node omits prepare", async () => {
|
||||
listNodesMock.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { APPROVALS_SCOPE, WRITE_SCOPE } from "../gateway/operator-scopes.js";
|
||||
import type { InterpreterInlineEvalHit } from "../infra/command-analysis/inline-eval.js";
|
||||
import {
|
||||
type ExecSecurity,
|
||||
requiresExecApproval,
|
||||
resolveExecApprovalAllowedDecisions,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { defaultExecAutoReviewer, type ExecAutoReviewInput } from "../infra/exec-auto-review.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
@@ -32,6 +36,26 @@ export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.t
|
||||
|
||||
const APPROVED_NODE_INVOKE_SCOPES = [WRITE_SCOPE, APPROVALS_SCOPE];
|
||||
|
||||
function resolveNodeAutoReviewReason(params: {
|
||||
inlineEvalHit: InterpreterInlineEvalHit | null;
|
||||
hostSecurity: ExecSecurity;
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
durableApprovalSatisfied: boolean;
|
||||
}): ExecAutoReviewInput["reason"] {
|
||||
if (params.inlineEvalHit !== null) {
|
||||
return "strict-inline-eval";
|
||||
}
|
||||
if (
|
||||
params.hostSecurity === "allowlist" &&
|
||||
(!params.analysisOk || !params.allowlistSatisfied) &&
|
||||
!params.durableApprovalSatisfied
|
||||
) {
|
||||
return "allowlist-miss";
|
||||
}
|
||||
return "approval-required";
|
||||
}
|
||||
|
||||
export async function executeNodeHostCommand(
|
||||
params: ExecuteNodeHostCommandParams,
|
||||
): Promise<AgentToolResult<ExecToolDetails>> {
|
||||
@@ -60,8 +84,14 @@ export async function executeNodeHostCommand(
|
||||
hostSecurity,
|
||||
hostAsk,
|
||||
});
|
||||
const { analysisOk, allowlistSatisfied, durableApprovalSatisfied, inlineEvalHit } =
|
||||
approvalAnalysis;
|
||||
const {
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied,
|
||||
inlineEvalHit,
|
||||
requiresSecurityAuditSuppressionApproval,
|
||||
autoReviewArgv,
|
||||
} = approvalAnalysis;
|
||||
const requiresAsk =
|
||||
requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
@@ -69,214 +99,291 @@ export async function executeNodeHostCommand(
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied,
|
||||
}) || inlineEvalHit !== null;
|
||||
}) ||
|
||||
inlineEvalHit !== null ||
|
||||
requiresSecurityAuditSuppressionApproval;
|
||||
if (requiresSecurityAuditSuppressionApproval) {
|
||||
params.warnings.push(
|
||||
"Warning: security audit suppression changes require explicit approval unless exec is running in yolo mode.",
|
||||
);
|
||||
}
|
||||
const registerNodeApproval = async (
|
||||
approvalId: string,
|
||||
options: { requireDeliveryRoute?: boolean; suppressDelivery?: boolean } = {},
|
||||
) =>
|
||||
await registerExecApprovalRequestForHostOrThrow({
|
||||
approvalId,
|
||||
systemRunPlan: prepared.plan,
|
||||
env: target.env,
|
||||
workdir: prepared.cwd,
|
||||
host: "node",
|
||||
nodeId: target.nodeId,
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
commandHighlighting: params.commandHighlighting,
|
||||
...buildExecApprovalRequesterContext({
|
||||
agentId: prepared.agentId,
|
||||
sessionKey: prepared.sessionKey,
|
||||
}),
|
||||
...(options.requireDeliveryRoute !== undefined
|
||||
? { requireDeliveryRoute: options.requireDeliveryRoute }
|
||||
: {}),
|
||||
...(options.suppressDelivery !== undefined
|
||||
? { suppressDelivery: options.suppressDelivery }
|
||||
: {}),
|
||||
...buildExecApprovalTurnSourceContext(params),
|
||||
});
|
||||
|
||||
let inlineApprovedByAsk = false;
|
||||
let inlineApprovalDecision: "allow-once" | "allow-always" | null = null;
|
||||
let inlineApprovalId: string | undefined;
|
||||
if (requiresAsk) {
|
||||
const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({
|
||||
warnings: params.warnings,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
createApprovalSlug,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
});
|
||||
const registerNodeApproval = async (approvalId: string) =>
|
||||
await registerExecApprovalRequestForHostOrThrow({
|
||||
approvalId,
|
||||
systemRunPlan: prepared.plan,
|
||||
env: target.env,
|
||||
workdir: prepared.cwd,
|
||||
host: "node",
|
||||
nodeId: target.nodeId,
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
commandHighlighting: params.commandHighlighting,
|
||||
...buildExecApprovalRequesterContext({
|
||||
agentId: prepared.agentId,
|
||||
sessionKey: prepared.sessionKey,
|
||||
}),
|
||||
...buildExecApprovalTurnSourceContext(params),
|
||||
});
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
warningText,
|
||||
expiresAtMs,
|
||||
preResolvedDecision,
|
||||
initiatingSurface,
|
||||
sentApproverDms,
|
||||
unavailableReason,
|
||||
} = await execHostShared.createAndRegisterDefaultExecApprovalRequest({
|
||||
...requestArgs,
|
||||
register: registerNodeApproval,
|
||||
});
|
||||
let autoReviewRequiresHumanApproval = false;
|
||||
if (
|
||||
execHostShared.shouldResolveExecApprovalUnavailableInline({
|
||||
trigger: params.trigger,
|
||||
unavailableReason,
|
||||
preResolvedDecision,
|
||||
})
|
||||
params.autoReview === true &&
|
||||
hostAsk !== "always" &&
|
||||
!requiresSecurityAuditSuppressionApproval
|
||||
) {
|
||||
const { baseDecision, approvedByAsk, deniedReason } =
|
||||
execHostShared.createExecApprovalDecisionState({
|
||||
decision: preResolvedDecision,
|
||||
askFallback,
|
||||
});
|
||||
const strictInlineEvalDecision = execHostShared.enforceStrictInlineEvalApprovalBoundary({
|
||||
baseDecision,
|
||||
approvedByAsk,
|
||||
deniedReason,
|
||||
requiresInlineEvalApproval: inlineEvalHit !== null,
|
||||
const reviewer = params.autoReviewer ?? defaultExecAutoReviewer;
|
||||
const decision = await reviewer({
|
||||
command: params.command,
|
||||
argv: autoReviewArgv,
|
||||
cwd: prepared.cwd,
|
||||
envKeys: Object.keys(params.requestedEnv ?? {}).toSorted(),
|
||||
host: "node",
|
||||
reason: resolveNodeAutoReviewReason({
|
||||
inlineEvalHit,
|
||||
hostSecurity,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied,
|
||||
}),
|
||||
analysis: {
|
||||
parsed: analysisOk,
|
||||
allowlistMatched: allowlistSatisfied,
|
||||
durableApprovalMatched: durableApprovalSatisfied,
|
||||
inlineEval: inlineEvalHit !== null,
|
||||
},
|
||||
agent: {
|
||||
id: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
});
|
||||
if (strictInlineEvalDecision.deniedReason || !strictInlineEvalDecision.approvedByAsk) {
|
||||
throw new Error(
|
||||
execHostShared.buildHeadlessExecApprovalDeniedMessage({
|
||||
trigger: params.trigger,
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
askFallback,
|
||||
}),
|
||||
if (decision.decision === "allow-once") {
|
||||
const approvalId = randomUUID();
|
||||
await registerNodeApproval(approvalId, {
|
||||
requireDeliveryRoute: false,
|
||||
suppressDelivery: true,
|
||||
});
|
||||
await callGatewayTool(
|
||||
"exec.approval.resolve",
|
||||
{ timeoutMs: 15_000 },
|
||||
{ id: approvalId, decision: "allow-once" },
|
||||
{ scopes: [APPROVALS_SCOPE] },
|
||||
);
|
||||
inlineApprovedByAsk = true;
|
||||
inlineApprovalDecision = "allow-once";
|
||||
inlineApprovalId = approvalId;
|
||||
}
|
||||
if (decision.decision !== "allow-once") {
|
||||
autoReviewRequiresHumanApproval = true;
|
||||
params.warnings.push(
|
||||
`Exec auto-review deferred to human approval (risk=${decision.risk}): ${decision.rationale}`,
|
||||
);
|
||||
}
|
||||
inlineApprovedByAsk = strictInlineEvalDecision.approvedByAsk;
|
||||
inlineApprovalDecision = strictInlineEvalDecision.approvedByAsk ? "allow-once" : null;
|
||||
inlineApprovalId = approvalId;
|
||||
} else {
|
||||
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey ?? params.sessionKey,
|
||||
bashElevated: params.bashElevated,
|
||||
}
|
||||
|
||||
if (!inlineApprovedByAsk) {
|
||||
const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({
|
||||
warnings: params.warnings,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
createApprovalSlug,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const decision = await execHostShared.resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
warningText,
|
||||
expiresAtMs,
|
||||
preResolvedDecision,
|
||||
initiatingSurface,
|
||||
sentApproverDms,
|
||||
unavailableReason,
|
||||
} = await execHostShared.createAndRegisterDefaultExecApprovalRequest({
|
||||
...requestArgs,
|
||||
register: registerNodeApproval,
|
||||
});
|
||||
if (
|
||||
execHostShared.shouldResolveExecApprovalUnavailableInline({
|
||||
trigger: params.trigger,
|
||||
unavailableReason,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
void execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
baseDecision,
|
||||
approvedByAsk: initialApprovedByAsk,
|
||||
deniedReason: initialDeniedReason,
|
||||
} = execHostShared.createExecApprovalDecisionState({
|
||||
decision,
|
||||
askFallback,
|
||||
});
|
||||
let approvedByAsk = initialApprovedByAsk;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
let deniedReason = initialDeniedReason;
|
||||
|
||||
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
|
||||
approvalDecision = "allow-once";
|
||||
} else if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-always";
|
||||
}
|
||||
|
||||
({ approvedByAsk, deniedReason } = execHostShared.enforceStrictInlineEvalApprovalBoundary({
|
||||
})
|
||||
) {
|
||||
const { baseDecision, approvedByAsk, deniedReason } =
|
||||
execHostShared.createExecApprovalDecisionState({
|
||||
decision: preResolvedDecision,
|
||||
askFallback,
|
||||
});
|
||||
const strictInlineEvalDecision = execHostShared.enforceStrictInlineEvalApprovalBoundary({
|
||||
baseDecision,
|
||||
approvedByAsk,
|
||||
deniedReason,
|
||||
requiresInlineEvalApproval: inlineEvalHit !== null,
|
||||
}));
|
||||
if (deniedReason) {
|
||||
approvalDecision = null;
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
await execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await callGatewayTool(
|
||||
"node.invoke",
|
||||
{ timeoutMs: target.invokeTimeoutMs },
|
||||
buildNodeSystemRunInvoke({
|
||||
target,
|
||||
command: prepared.argv,
|
||||
rawCommand: prepared.rawCommand,
|
||||
cwd: prepared.cwd,
|
||||
agentId: prepared.agentId,
|
||||
sessionKey: prepared.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision:
|
||||
approvalDecision === "allow-always" && inlineEvalHit !== null
|
||||
? "allow-once"
|
||||
: approvalDecision,
|
||||
runId: approvalId,
|
||||
suppressNotifyOnExit: true,
|
||||
notifyOnExit: params.notifyOnExit,
|
||||
systemRunPlan: prepared.plan,
|
||||
requiresAutoReviewHumanApproval: autoReviewRequiresHumanApproval,
|
||||
});
|
||||
if (strictInlineEvalDecision.deniedReason || !strictInlineEvalDecision.approvedByAsk) {
|
||||
throw new Error(
|
||||
execHostShared.buildHeadlessExecApprovalDeniedMessage({
|
||||
trigger: params.trigger,
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
askFallback,
|
||||
}),
|
||||
{ scopes: APPROVED_NODE_INVOKE_SCOPES },
|
||||
);
|
||||
const payload =
|
||||
raw?.payload && typeof raw.payload === "object"
|
||||
? (raw.payload as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
})
|
||||
: {};
|
||||
const combined = [payload.stdout, payload.stderr, payload.error]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
|
||||
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
|
||||
const summary = output
|
||||
? `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})\n${output}`
|
||||
: `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})`;
|
||||
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
|
||||
} catch {
|
||||
await execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
inlineApprovedByAsk = strictInlineEvalDecision.approvedByAsk;
|
||||
inlineApprovalDecision = strictInlineEvalDecision.approvedByAsk ? "allow-once" : null;
|
||||
inlineApprovalId = approvalId;
|
||||
} else {
|
||||
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey ?? params.sessionKey,
|
||||
bashElevated: params.bashElevated,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
|
||||
return execHostShared.buildExecApprovalPendingToolResult({
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
initiatingSurface,
|
||||
sentApproverDms,
|
||||
unavailableReason,
|
||||
allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: hostAsk }),
|
||||
nodeId: target.nodeId,
|
||||
});
|
||||
void (async () => {
|
||||
const decision = await execHostShared.resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
void execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
baseDecision,
|
||||
approvedByAsk: initialApprovedByAsk,
|
||||
deniedReason: initialDeniedReason,
|
||||
} = execHostShared.createExecApprovalDecisionState({
|
||||
decision,
|
||||
askFallback,
|
||||
});
|
||||
let approvedByAsk = initialApprovedByAsk;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
let deniedReason = initialDeniedReason;
|
||||
|
||||
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
|
||||
approvalDecision = "allow-once";
|
||||
} else if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-always";
|
||||
}
|
||||
|
||||
({ approvedByAsk, deniedReason } = execHostShared.enforceStrictInlineEvalApprovalBoundary(
|
||||
{
|
||||
baseDecision,
|
||||
approvedByAsk,
|
||||
deniedReason,
|
||||
requiresInlineEvalApproval: inlineEvalHit !== null,
|
||||
requiresAutoReviewHumanApproval: autoReviewRequiresHumanApproval,
|
||||
},
|
||||
));
|
||||
if (deniedReason) {
|
||||
approvalDecision = null;
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
await execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await callGatewayTool(
|
||||
"node.invoke",
|
||||
{ timeoutMs: target.invokeTimeoutMs },
|
||||
buildNodeSystemRunInvoke({
|
||||
target,
|
||||
command: prepared.argv,
|
||||
rawCommand: prepared.rawCommand,
|
||||
cwd: prepared.cwd,
|
||||
agentId: prepared.agentId,
|
||||
sessionKey: prepared.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision:
|
||||
approvalDecision === "allow-always" && inlineEvalHit !== null
|
||||
? "allow-once"
|
||||
: approvalDecision,
|
||||
runId: approvalId,
|
||||
suppressNotifyOnExit: true,
|
||||
notifyOnExit: params.notifyOnExit,
|
||||
systemRunPlan: prepared.plan,
|
||||
}),
|
||||
{ scopes: APPROVED_NODE_INVOKE_SCOPES },
|
||||
);
|
||||
const payload =
|
||||
raw?.payload && typeof raw.payload === "object"
|
||||
? (raw.payload as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
})
|
||||
: {};
|
||||
const combined = [payload.stdout, payload.stderr, payload.error]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
|
||||
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
|
||||
const summary = output
|
||||
? `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})\n${output}`
|
||||
: `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})`;
|
||||
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
|
||||
} catch {
|
||||
await execHostShared.sendExecApprovalFollowupResult(
|
||||
followupTarget,
|
||||
`Exec denied (node=${target.nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return execHostShared.buildExecApprovalPendingToolResult({
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
initiatingSurface,
|
||||
sentApproverDms,
|
||||
unavailableReason,
|
||||
allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: hostAsk }),
|
||||
nodeId: target.nodeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import type { ExecElevatedDefaults } from "./bash-tools.exec-types.js";
|
||||
|
||||
export type ExecuteNodeHostCommandParams = {
|
||||
@@ -18,6 +19,8 @@ export type ExecuteNodeHostCommandParams = {
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
autoReview?: boolean;
|
||||
autoReviewer?: ExecAutoReviewer;
|
||||
strictInlineEval?: boolean;
|
||||
commandHighlighting?: boolean;
|
||||
timeoutSec?: number;
|
||||
|
||||
@@ -330,6 +330,23 @@ describe("enforceStrictInlineEvalApprovalBoundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("denies timeout-based fallback when auto-review defers to human approval", () => {
|
||||
const params = {
|
||||
baseDecision: { timedOut: true },
|
||||
approvedByAsk: true,
|
||||
deniedReason: null,
|
||||
requiresInlineEvalApproval: false,
|
||||
requiresAutoReviewHumanApproval: true,
|
||||
} satisfies Parameters<typeof enforceStrictInlineEvalApprovalBoundary>[0] & {
|
||||
requiresAutoReviewHumanApproval: true;
|
||||
};
|
||||
|
||||
expect(enforceStrictInlineEvalApprovalBoundary(params)).toEqual({
|
||||
approvedByAsk: false,
|
||||
deniedReason: "approval-timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit approvals intact for strict inline-eval commands", () => {
|
||||
expect(
|
||||
enforceStrictInlineEvalApprovalBoundary({
|
||||
|
||||
@@ -353,15 +353,14 @@ export function enforceStrictInlineEvalApprovalBoundary(params: {
|
||||
approvedByAsk: boolean;
|
||||
deniedReason: string | null;
|
||||
requiresInlineEvalApproval: boolean;
|
||||
requiresAutoReviewHumanApproval?: boolean;
|
||||
}): {
|
||||
approvedByAsk: boolean;
|
||||
deniedReason: string | null;
|
||||
} {
|
||||
if (
|
||||
!params.baseDecision.timedOut ||
|
||||
!params.requiresInlineEvalApproval ||
|
||||
!params.approvedByAsk
|
||||
) {
|
||||
const requiresRealApproval =
|
||||
params.requiresInlineEvalApproval || params.requiresAutoReviewHumanApproval === true;
|
||||
if (!params.baseDecision.timedOut || !requiresRealApproval || !params.approvedByAsk) {
|
||||
return {
|
||||
approvedByAsk: params.approvedByAsk,
|
||||
deniedReason: params.deniedReason,
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { EventSessionRoutingPolicy } from "../infra/event-session-routing.js";
|
||||
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
||||
import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../infra/exec-approvals.js";
|
||||
import type {
|
||||
ExecAsk,
|
||||
ExecHost,
|
||||
ExecMode,
|
||||
ExecSecurity,
|
||||
ExecTarget,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import type { EmbeddedFullAccessBlockedReason } from "./embedded-agent-runner/types.js";
|
||||
import type { ExecReviewerConfig } from "./exec-auto-reviewer.js";
|
||||
|
||||
export type ExecToolDefaults = {
|
||||
hasCronTool?: boolean;
|
||||
host?: ExecTarget;
|
||||
mode?: ExecMode;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
trigger?: string;
|
||||
@@ -18,6 +28,9 @@ export type ExecToolDefaults = {
|
||||
commandHighlighting?: boolean;
|
||||
safeBinTrustedDirs?: string[];
|
||||
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
|
||||
reviewer?: ExecReviewerConfig;
|
||||
config?: OpenClawConfig;
|
||||
autoReviewer?: ExecAutoReviewer;
|
||||
agentId?: string;
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
describe("exec security floor", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
@@ -34,6 +41,7 @@ describe("exec security floor", () => {
|
||||
delete process.env.HOMEPATH;
|
||||
}
|
||||
resetProcessRegistryForTests();
|
||||
vi.mocked(callGatewayTool).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,4 +118,260 @@ describe("exec security floor", () => {
|
||||
}),
|
||||
).rejects.toThrow(/exec denied/i);
|
||||
});
|
||||
|
||||
it("does not let host approval defaults deny implicit sandbox execution", async () => {
|
||||
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(openclawDir, "exec-approvals.json"),
|
||||
`${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`,
|
||||
);
|
||||
const buildExecSpec = vi.fn(async () => ({
|
||||
argv: ["/bin/sh", "-lc", "printf sandbox-ok"],
|
||||
env: process.env,
|
||||
stdinMode: "pipe-closed" as const,
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "auto",
|
||||
sandbox: {
|
||||
containerName: "sandbox-host-approval-defaults-test",
|
||||
workspaceDir: tempRoot ?? "/tmp",
|
||||
containerWorkdir: "/workspace",
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-sandbox-host-defaults", {
|
||||
command: "echo sandbox-ok",
|
||||
});
|
||||
|
||||
expect(buildExecSpec).toHaveBeenCalledTimes(1);
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
const text = (result.content[0] as { text?: string }).text ?? "";
|
||||
expect(text).toContain("sandbox-ok");
|
||||
});
|
||||
|
||||
it("honors configured deny mode before implicit sandbox execution", async () => {
|
||||
const buildExecSpec = vi.fn(async () => ({
|
||||
argv: ["/bin/sh", "-lc", "printf leaked"],
|
||||
env: process.env,
|
||||
stdinMode: "pipe-closed" as const,
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "auto",
|
||||
mode: "deny",
|
||||
sandbox: {
|
||||
containerName: "sandbox-deny-test",
|
||||
workspaceDir: tempRoot ?? "/tmp",
|
||||
containerWorkdir: "/workspace",
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-mode-deny-sandbox", {
|
||||
command: "echo blocked",
|
||||
}),
|
||||
).rejects.toThrow(/security=deny|exec denied/i);
|
||||
expect(buildExecSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets normalized auto mode run implicit sandbox execution", async () => {
|
||||
const buildExecSpec = vi.fn(async () => ({
|
||||
argv: ["/bin/sh", "-lc", "printf sandbox-auto-ok"],
|
||||
env: process.env,
|
||||
stdinMode: "pipe-closed" as const,
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "auto",
|
||||
mode: "auto",
|
||||
sandbox: {
|
||||
containerName: "sandbox-auto-mode-test",
|
||||
workspaceDir: tempRoot ?? "/tmp",
|
||||
containerWorkdir: "/workspace",
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-mode-auto-sandbox", {
|
||||
command: "echo sandbox-auto-ok",
|
||||
});
|
||||
|
||||
expect(buildExecSpec).toHaveBeenCalledTimes(1);
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
const text = (result.content[0] as { text?: string }).text ?? "";
|
||||
expect(text).toContain("sandbox-auto-ok");
|
||||
});
|
||||
|
||||
it("intersects normalized gateway auto mode with host approval deny defaults", async () => {
|
||||
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(openclawDir, "exec-approvals.json"),
|
||||
`${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`,
|
||||
);
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "would otherwise run",
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
mode: "auto",
|
||||
safeBins: [],
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-auto-mode-host-deny", {
|
||||
command: "echo blocked",
|
||||
}),
|
||||
).rejects.toThrow(/security=deny|exec denied/i);
|
||||
expect(autoReviewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses agent-scoped host policy when clamping normalized modes", async () => {
|
||||
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(openclawDir, "exec-approvals.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
defaults: { security: "deny", ask: "off" },
|
||||
agents: { main: { security: "full", ask: "off" } },
|
||||
})}\n`,
|
||||
);
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
mode: "full",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-agent-host-policy", {
|
||||
command: "echo agent-ok",
|
||||
});
|
||||
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
const text = (result.content[0] as { text?: string }).text ?? "";
|
||||
expect(text.trim()).toContain("agent-ok");
|
||||
});
|
||||
|
||||
it("preserves host ask floors for elevated full gateway exec", async () => {
|
||||
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(openclawDir, "exec-approvals.json"),
|
||||
`${JSON.stringify({ version: 1, defaults: { security: "full", ask: "always" }, agents: {} })}\n`,
|
||||
);
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "full" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-elevated-full-host-ask-floor", {
|
||||
command: "echo ok",
|
||||
elevated: true,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("honors normalized auto mode before elevated full bypass", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "high",
|
||||
rationale: "test reviewer asks for approval",
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
mode: "auto",
|
||||
safeBins: [],
|
||||
autoReviewer,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "full" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-elevated-full-auto-mode", {
|
||||
command: "pwd",
|
||||
elevated: true,
|
||||
});
|
||||
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "pwd",
|
||||
host: "gateway",
|
||||
reason: "approval-required",
|
||||
}),
|
||||
);
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it.each(["on-miss", "off"] as const)(
|
||||
"keeps auto review enabled when legacy ask=%s does not strengthen auto mode",
|
||||
async (ask) => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
|
||||
decision: "ask",
|
||||
risk: "high",
|
||||
rationale: "test reviewer asks for approval",
|
||||
}));
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
mode: "auto",
|
||||
safeBins: [],
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
const result = await tool.execute(`call-auto-review-${ask}`, {
|
||||
command: "pwd",
|
||||
ask,
|
||||
});
|
||||
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "pwd",
|
||||
host: "gateway",
|
||||
reason: "approval-required",
|
||||
}),
|
||||
);
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type ExecSecurity,
|
||||
loadExecApprovals,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
requireValidExecTarget,
|
||||
resolveExecApprovalsFromFile,
|
||||
resolveExecModePolicy,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
|
||||
@@ -18,7 +21,11 @@ import {
|
||||
resolveShellEnvFallbackTimeoutMs,
|
||||
} from "../infra/shell-env.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -57,6 +64,7 @@ import {
|
||||
resolveWorkdir,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { createModelExecAutoReviewer } from "./exec-auto-reviewer.js";
|
||||
import type { AgentToolResult } from "./runtime/index.js";
|
||||
import { EXEC_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js";
|
||||
import { type AgentToolWithMeta, failedTextResult, textResult } from "./tools/common.js";
|
||||
@@ -1215,6 +1223,18 @@ function rejectUnsafeControlShellCommand(command: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecReviewerDefaults(params: { defaults?: ExecToolDefaults; agentId?: string }) {
|
||||
if (params.defaults?.reviewer) {
|
||||
return params.defaults.reviewer;
|
||||
}
|
||||
const cfg = params.defaults?.config;
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const agentExec = agentId
|
||||
? cfg?.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec
|
||||
: undefined;
|
||||
return agentExec?.reviewer ?? cfg?.tools?.exec?.reviewer;
|
||||
}
|
||||
|
||||
export function createExecTool(
|
||||
defaults?: ExecToolDefaults,
|
||||
): AgentToolWithMeta<typeof execSchema, ExecToolDetails> {
|
||||
@@ -1271,6 +1291,13 @@ export function createExecTool(
|
||||
const agentId =
|
||||
defaults?.agentId ??
|
||||
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
|
||||
const autoReviewer =
|
||||
defaults?.autoReviewer ??
|
||||
createModelExecAutoReviewer({
|
||||
cfg: defaults?.config,
|
||||
agentId,
|
||||
reviewer: resolveExecReviewerDefaults({ defaults, agentId }),
|
||||
});
|
||||
|
||||
return {
|
||||
name: "exec",
|
||||
@@ -1391,21 +1418,58 @@ export function createExecTool(
|
||||
});
|
||||
const host: ExecHost = target.effectiveHost;
|
||||
|
||||
const approvalDefaults = loadExecApprovals().defaults;
|
||||
const configuredSecurity =
|
||||
defaults?.security ?? approvalDefaults?.security ?? (host === "sandbox" ? "deny" : "full");
|
||||
let security = configuredSecurity;
|
||||
if (elevatedRequested && elevatedMode === "full") {
|
||||
const explicitSecurity = defaults?.security;
|
||||
const configuredSecurity = explicitSecurity ?? (host === "sandbox" ? "deny" : "full");
|
||||
const modePolicy = resolveExecModePolicy({
|
||||
mode: defaults?.mode,
|
||||
security: configuredSecurity,
|
||||
ask: defaults?.ask ?? "off",
|
||||
});
|
||||
const approvalPolicy =
|
||||
host === "sandbox"
|
||||
? undefined
|
||||
: resolveExecApprovalsFromFile({
|
||||
file: loadExecApprovals(),
|
||||
agentId,
|
||||
overrides: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
}).agent;
|
||||
let security = minSecurity(
|
||||
modePolicy.security,
|
||||
approvalPolicy?.security ?? modePolicy.security,
|
||||
);
|
||||
if (
|
||||
security === "deny" &&
|
||||
(host !== "sandbox" || defaults?.mode === "deny" || explicitSecurity === "deny")
|
||||
) {
|
||||
throw new Error(`exec denied: host=${host} security=deny`);
|
||||
}
|
||||
const hostPolicyAllowsFullBypass =
|
||||
(approvalPolicy?.security ?? "full") === "full" && (approvalPolicy?.ask ?? "off") === "off";
|
||||
const modePolicyAllowsFullBypass = modePolicy.security === "full" && modePolicy.ask === "off";
|
||||
if (
|
||||
elevatedRequested &&
|
||||
elevatedMode === "full" &&
|
||||
modePolicyAllowsFullBypass &&
|
||||
hostPolicyAllowsFullBypass
|
||||
) {
|
||||
security = "full";
|
||||
}
|
||||
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset.
|
||||
const configuredAsk = defaults?.ask ?? approvalDefaults?.ask ?? "off";
|
||||
const requestedAsk = normalizeExecAsk(params.ask);
|
||||
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
const bypassApprovals = elevatedRequested && elevatedMode === "full";
|
||||
const hostAsk = maxAsk(modePolicy.ask, approvalPolicy?.ask ?? modePolicy.ask);
|
||||
let ask = maxAsk(hostAsk, requestedAsk ?? hostAsk);
|
||||
const bypassApprovals =
|
||||
elevatedRequested &&
|
||||
elevatedMode === "full" &&
|
||||
modePolicyAllowsFullBypass &&
|
||||
hostPolicyAllowsFullBypass;
|
||||
if (bypassApprovals) {
|
||||
ask = "off";
|
||||
}
|
||||
const autoReview = modePolicy.autoReview && ask === modePolicy.ask && !bypassApprovals;
|
||||
|
||||
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
|
||||
if (target.selectedTarget === "sandbox" && !sandbox) {
|
||||
@@ -1535,6 +1599,8 @@ export function createExecTool(
|
||||
agentId,
|
||||
security,
|
||||
ask,
|
||||
autoReview,
|
||||
autoReviewer,
|
||||
strictInlineEval: defaults?.strictInlineEval,
|
||||
commandHighlighting: defaults?.commandHighlighting,
|
||||
trigger: defaults?.trigger,
|
||||
@@ -1564,6 +1630,8 @@ export function createExecTool(
|
||||
defaultTimeoutSec,
|
||||
security,
|
||||
ask,
|
||||
autoReview,
|
||||
autoReviewer,
|
||||
safeBins,
|
||||
safeBinProfiles,
|
||||
strictInlineEval: defaults?.strictInlineEval,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as execApprovals from "../infra/exec-approvals.js";
|
||||
import { buildEmbeddedSandboxInfo } from "./embedded-agent-runner.js";
|
||||
import { resolveEmbeddedFullAccessState } from "./embedded-agent-runner/sandbox-info.js";
|
||||
import {
|
||||
resolveEmbeddedFullAccessState,
|
||||
resolveEmbeddedSandboxInfoExecPolicy,
|
||||
} from "./embedded-agent-runner/sandbox-info.js";
|
||||
import type { SandboxContext } from "./sandbox.js";
|
||||
|
||||
function createSandboxContext(overrides?: Partial<SandboxContext>): SandboxContext {
|
||||
@@ -41,6 +45,14 @@ function createSandboxContext(overrides?: Partial<SandboxContext>): SandboxConte
|
||||
}
|
||||
|
||||
describe("buildEmbeddedSandboxInfo", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(execApprovals, "loadExecApprovals").mockReturnValue({
|
||||
version: 1,
|
||||
agents: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when sandbox is missing", () => {
|
||||
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
|
||||
});
|
||||
@@ -113,6 +125,169 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks full access unavailable when exec policy denies execution", () => {
|
||||
const sandbox = createSandboxContext();
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
{ mode: "deny" },
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: false,
|
||||
fullAccessBlockedReason: "host-policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config exec mode when building prompt full-access state", () => {
|
||||
const sandbox = createSandboxContext();
|
||||
const execPolicy = resolveEmbeddedSandboxInfoExecPolicy({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
execPolicy,
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: false,
|
||||
fullAccessBlockedReason: "host-policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses elevated host policy when sandbox is active and exec policy is unset", () => {
|
||||
const sandbox = createSandboxContext();
|
||||
const execPolicy = resolveEmbeddedSandboxInfoExecPolicy({
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
execPolicy,
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks full access unavailable when host approval defaults deny execution", () => {
|
||||
const sandbox = createSandboxContext();
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
{ mode: "full", security: "full" },
|
||||
{ security: "deny" },
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: false,
|
||||
fullAccessBlockedReason: "host-policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks full access unavailable when host approval floors still require review", () => {
|
||||
const sandbox = createSandboxContext();
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
{ mode: "full", security: "full", ask: "off" },
|
||||
{ security: "allowlist", ask: "off" },
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: false,
|
||||
fullAccessBlockedReason: "host-policy",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
{ mode: "full", security: "full", ask: "off" },
|
||||
{ security: "full", ask: "always" },
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: false,
|
||||
fullAccessBlockedReason: "host-policy",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
{
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
},
|
||||
{ mode: "full", security: "full", ask: "on-miss" },
|
||||
{ security: "full", ask: "on-miss" },
|
||||
)?.elevated,
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
defaultLevel: "full",
|
||||
fullAccessAvailable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEmbeddedFullAccessState", () => {
|
||||
|
||||
@@ -148,7 +148,7 @@ import { resolveModelAsync } from "./model.js";
|
||||
import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js";
|
||||
import { createEmbeddedAgentResourceLoader } from "./resource-loader.js";
|
||||
import { resolveAttemptSpawnWorkspaceDir } from "./run/attempt.thread-helpers.js";
|
||||
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
|
||||
import { buildEmbeddedSandboxInfo, resolveEmbeddedSandboxInfoExecPolicy } from "./sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
|
||||
import {
|
||||
@@ -741,6 +741,8 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
const runAbortController = new AbortController();
|
||||
const toolsRaw = createOpenClawCodingTools({
|
||||
exec: {
|
||||
...params.execOverrides,
|
||||
config: params.config,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
@@ -914,7 +916,18 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const sandboxInfoExecPolicy = resolveEmbeddedSandboxInfoExecPolicy({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sandboxAvailable: sandbox?.enabled === true,
|
||||
execOverrides: params.execOverrides,
|
||||
});
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
params.bashElevated,
|
||||
sandboxInfoExecPolicy,
|
||||
);
|
||||
const reasoningTagHint = isReasoningTagProvider(provider, {
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js";
|
||||
import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js";
|
||||
import type { ExecElevatedDefaults } from "../bash-tools.exec-types.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js";
|
||||
import type { AgentRuntimePlan } from "../runtime-plan/types.js";
|
||||
import type { SkillSnapshot } from "../skills.js";
|
||||
|
||||
@@ -59,6 +59,7 @@ export type CompactEmbeddedAgentSessionParams = {
|
||||
runtimePlan?: AgentRuntimePlan;
|
||||
thinkLevel?: ThinkLevel;
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
customInstructions?: string;
|
||||
tokenBudget?: number;
|
||||
|
||||
@@ -234,7 +234,7 @@ import {
|
||||
updateActiveEmbeddedRunSessionFile,
|
||||
updateActiveEmbeddedRunSnapshot,
|
||||
} from "../runs.js";
|
||||
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
|
||||
import { buildEmbeddedSandboxInfo, resolveEmbeddedSandboxInfoExecPolicy } from "../sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../skills-runtime.js";
|
||||
@@ -1061,6 +1061,7 @@ export async function runEmbeddedAttempt(
|
||||
...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }),
|
||||
exec: {
|
||||
...params.execOverrides,
|
||||
config: params.config,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
@@ -1557,7 +1558,18 @@ export async function runEmbeddedAttempt(
|
||||
accountId: params.agentAccountId,
|
||||
})
|
||||
: undefined;
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const sandboxInfoExecPolicy = resolveEmbeddedSandboxInfoExecPolicy({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sandboxAvailable: sandbox?.enabled === true,
|
||||
execOverrides: params.execOverrides,
|
||||
});
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(
|
||||
sandbox,
|
||||
params.bashElevated,
|
||||
sandboxInfoExecPolicy,
|
||||
);
|
||||
const reasoningTagHint = isReasoningTagProvider(params.provider, {
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.js";
|
||||
import { resolveExecDefaults } from "../exec-defaults.js";
|
||||
import type { resolveSandboxContext } from "../sandbox.js";
|
||||
import type { EmbeddedFullAccessBlockedReason, EmbeddedSandboxInfo } from "./types.js";
|
||||
|
||||
export function resolveEmbeddedFullAccessState(params: { execElevated?: ExecElevatedDefaults }): {
|
||||
type EmbeddedFullAccessExecPolicy = Pick<ExecToolDefaults, "mode" | "security" | "ask">;
|
||||
type EmbeddedFullAccessHostPolicy = Pick<ExecToolDefaults, "security" | "ask">;
|
||||
type EmbeddedSandboxInfoExecOverrides = Pick<
|
||||
ExecToolDefaults,
|
||||
"host" | "security" | "ask" | "node"
|
||||
>;
|
||||
|
||||
function execPolicyBlocksFullAccess(params: {
|
||||
execPolicy?: EmbeddedFullAccessExecPolicy;
|
||||
hostPolicy?: EmbeddedFullAccessHostPolicy;
|
||||
}): boolean {
|
||||
return (
|
||||
(params.execPolicy?.mode !== undefined && params.execPolicy.mode !== "full") ||
|
||||
(params.execPolicy?.security !== undefined && params.execPolicy.security !== "full") ||
|
||||
(params.execPolicy?.ask !== undefined && params.execPolicy.ask === "always") ||
|
||||
(params.hostPolicy?.security !== undefined && params.hostPolicy.security !== "full") ||
|
||||
(params.hostPolicy?.ask !== undefined && params.hostPolicy.ask === "always")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEmbeddedFullAccessState(params: {
|
||||
execElevated?: ExecElevatedDefaults;
|
||||
execPolicy?: EmbeddedFullAccessExecPolicy;
|
||||
hostPolicy?: EmbeddedFullAccessHostPolicy;
|
||||
}): {
|
||||
available: boolean;
|
||||
blockedReason?: EmbeddedFullAccessBlockedReason;
|
||||
} {
|
||||
if (execPolicyBlocksFullAccess(params)) {
|
||||
return {
|
||||
available: false,
|
||||
blockedReason: "host-policy",
|
||||
};
|
||||
}
|
||||
if (params.execElevated?.fullAccessAvailable === true) {
|
||||
return { available: true };
|
||||
}
|
||||
@@ -24,9 +56,33 @@ export function resolveEmbeddedFullAccessState(params: { execElevated?: ExecElev
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
export function resolveEmbeddedSandboxInfoExecPolicy(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sandboxAvailable?: boolean;
|
||||
execOverrides?: EmbeddedSandboxInfoExecOverrides;
|
||||
}): EmbeddedFullAccessExecPolicy {
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sandboxAvailable: params.sandboxAvailable,
|
||||
elevatedRequested: true,
|
||||
execOverrides: params.execOverrides,
|
||||
});
|
||||
return {
|
||||
mode: defaults.mode,
|
||||
security: defaults.security,
|
||||
ask: defaults.ask,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmbeddedSandboxInfo(
|
||||
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
|
||||
execElevated?: ExecElevatedDefaults,
|
||||
execPolicy?: EmbeddedFullAccessExecPolicy,
|
||||
hostPolicy?: EmbeddedFullAccessHostPolicy,
|
||||
): EmbeddedSandboxInfo | undefined {
|
||||
if (!sandbox?.enabled) {
|
||||
return undefined;
|
||||
@@ -35,6 +91,8 @@ export function buildEmbeddedSandboxInfo(
|
||||
const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed);
|
||||
const fullAccess = resolveEmbeddedFullAccessState({
|
||||
execElevated,
|
||||
execPolicy,
|
||||
hostPolicy,
|
||||
});
|
||||
return {
|
||||
enabled: true,
|
||||
|
||||
11
src/agents/exec-auto-reviewer.prompt.ts
Normal file
11
src/agents/exec-auto-reviewer.prompt.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const DEFAULT_EXEC_REVIEWER_SYSTEM_PROMPT = `You are OpenClaw's exec safety reviewer.
|
||||
Review exactly one pending shell command before it runs.
|
||||
Return exactly one JSON object and no other text.
|
||||
|
||||
Decision rules:
|
||||
- Use "allow" only when the command is clearly low-risk for this single execution.
|
||||
- Use "ask" when intent, path safety or command parsing, seem dangerous. This will prompt the user for confirmation.
|
||||
- Treat internal network access, package publishing, chmod/chown, rm/mv sensitive paths, sudo, ssh/scp/rsync, and secret paths as high security risk.
|
||||
- "ask" should be high fidelity, only "ask" when you are genuinely unsure. Ideally the user does not get prompted often as to reduce fatigue.
|
||||
|
||||
Output schema: {"decision":"allow|ask","risk":"low|medium|high|unknown","rationale":"one short sentence"}`;
|
||||
311
src/agents/exec-auto-reviewer.test.ts
Normal file
311
src/agents/exec-auto-reviewer.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createModelExecAutoReviewer, parseExecAutoReviewResponse } from "./exec-auto-reviewer.js";
|
||||
|
||||
const input = {
|
||||
command: "git status",
|
||||
argv: ["git", "status"],
|
||||
cwd: "/repo",
|
||||
envKeys: [],
|
||||
host: "gateway" as const,
|
||||
reason: "approval-required" as const,
|
||||
analysis: {
|
||||
parsed: true,
|
||||
allowlistMatched: false,
|
||||
inlineEval: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe("parseExecAutoReviewResponse", () => {
|
||||
it("maps model allow decisions to single-use approvals", () => {
|
||||
expect(
|
||||
parseExecAutoReviewResponse(
|
||||
JSON.stringify({
|
||||
decision: "allow",
|
||||
risk: "low",
|
||||
rationale: "read-only inspection",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "read-only inspection",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps model ask decisions to human approval", () => {
|
||||
expect(
|
||||
parseExecAutoReviewResponse(
|
||||
JSON.stringify({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "side effects need a human",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
decision: "ask",
|
||||
risk: "medium",
|
||||
rationale: "side effects need a human",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes unsupported or malformed decisions to human review", () => {
|
||||
expect(parseExecAutoReviewResponse("sure, run it")).toMatchObject({
|
||||
decision: "ask",
|
||||
});
|
||||
expect(
|
||||
parseExecAutoReviewResponse(
|
||||
JSON.stringify({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "legacy internal decision",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
decision: "ask",
|
||||
rationale: "exec reviewer returned an unsupported response",
|
||||
});
|
||||
expect(
|
||||
parseExecAutoReviewResponse(
|
||||
JSON.stringify({
|
||||
decision: "deny",
|
||||
risk: "high",
|
||||
rationale: "dangerous command",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
decision: "ask",
|
||||
rationale: "exec reviewer returned an unsupported response",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires allow decisions to carry low risk", () => {
|
||||
for (const risk of ["medium", "high", "unknown"] as const) {
|
||||
expect(
|
||||
parseExecAutoReviewResponse(
|
||||
JSON.stringify({
|
||||
decision: "allow",
|
||||
risk,
|
||||
rationale: "looks fine",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
decision: "ask",
|
||||
risk,
|
||||
rationale: "exec reviewer returned a non-low allow decision",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModelExecAutoReviewer", () => {
|
||||
it("uses the configured exec reviewer model for review calls", async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
selection: {
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4-6",
|
||||
agentDir: "/agent",
|
||||
},
|
||||
model: { provider: "openrouter", id: "anthropic/claude-sonnet-4-6", api: "openai" },
|
||||
auth: { apiKey: "key", mode: "env" },
|
||||
}));
|
||||
const complete = vi.fn(async () => ({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
decision: "ask",
|
||||
risk: "high",
|
||||
rationale: "network side effect",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
const reviewer = createModelExecAutoReviewer({
|
||||
cfg: {},
|
||||
agentId: "ops",
|
||||
reviewer: { model: { primary: "openrouter/anthropic/claude-sonnet-4-6" } },
|
||||
deps: {
|
||||
prepareSimpleCompletionModelForAgent:
|
||||
prepare as unknown as typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModelForAgent,
|
||||
completeWithPreparedSimpleCompletionModel:
|
||||
complete as unknown as typeof import("./simple-completion-runtime.js").completeWithPreparedSimpleCompletionModel,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(reviewer(input)).resolves.toEqual({
|
||||
decision: "ask",
|
||||
risk: "high",
|
||||
rationale: "network side effect",
|
||||
});
|
||||
expect(prepare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "ops",
|
||||
modelRef: "openrouter/anthropic/claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
expect(complete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
systemPrompt: expect.stringContaining('"decision":"allow|ask"'),
|
||||
}),
|
||||
options: expect.objectContaining({
|
||||
temperature: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to human approval when the model is unavailable", async () => {
|
||||
const reviewer = createModelExecAutoReviewer({
|
||||
cfg: {},
|
||||
deps: {
|
||||
prepareSimpleCompletionModelForAgent: vi.fn(async () => ({
|
||||
error: "missing API key",
|
||||
})) as unknown as typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModelForAgent,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(reviewer(input)).resolves.toMatchObject({
|
||||
decision: "ask",
|
||||
rationale: "exec reviewer model unavailable: missing API key",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to human approval with the model completion error", async () => {
|
||||
const complete = vi.fn(async () => ({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "openai-responses",
|
||||
provider: "atlassian-aigw",
|
||||
model: "gpt-5.4-nano",
|
||||
stopReason: "error",
|
||||
errorMessage: "OpenAI API error (400): 400 Model Id [gpt-5.4-nano] not found",
|
||||
}));
|
||||
const reviewer = createModelExecAutoReviewer({
|
||||
cfg: {},
|
||||
deps: {
|
||||
prepareSimpleCompletionModelForAgent: vi.fn(async () => ({
|
||||
selection: {
|
||||
provider: "atlassian-aigw",
|
||||
modelId: "gpt-5.4-nano",
|
||||
agentDir: "/agent",
|
||||
},
|
||||
model: { provider: "atlassian-aigw", id: "gpt-5.4-nano", api: "openai-responses" },
|
||||
auth: { apiKey: "key", mode: "env" },
|
||||
})) as unknown as typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModelForAgent,
|
||||
completeWithPreparedSimpleCompletionModel:
|
||||
complete as unknown as typeof import("./simple-completion-runtime.js").completeWithPreparedSimpleCompletionModel,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(reviewer(input)).resolves.toEqual({
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale:
|
||||
"exec reviewer completion failed: OpenAI API error (400): 400 Model Id [gpt-5.4-nano] not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the reviewer timeout while preparing the model", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const prepare = vi.fn(
|
||||
() =>
|
||||
new Promise<never>(() => {
|
||||
// Keep model preparation pending until the reviewer timeout wins.
|
||||
}),
|
||||
);
|
||||
const reviewer = createModelExecAutoReviewer({
|
||||
cfg: {},
|
||||
reviewer: { timeoutMs: 5_000 },
|
||||
deps: {
|
||||
prepareSimpleCompletionModelForAgent:
|
||||
prepare as unknown as typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModelForAgent,
|
||||
},
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const result = Promise.resolve(reviewer(input)).then((decision) => {
|
||||
settled = true;
|
||||
return decision;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_001);
|
||||
|
||||
expect(settled).toBe(true);
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: "ask",
|
||||
rationale: "exec reviewer timed out after 5000ms",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives reviewer completion a fresh timeout after slow model preparation", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const prepare = vi.fn(
|
||||
() =>
|
||||
new Promise<{
|
||||
selection: { provider: string; modelId: string; agentDir: string };
|
||||
model: { provider: string; id: string; api: "openai" };
|
||||
auth: { apiKey: string; mode: "env" };
|
||||
}>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
selection: {
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4-6",
|
||||
agentDir: "/agent",
|
||||
},
|
||||
model: { provider: "openrouter", id: "anthropic/claude-sonnet-4-6", api: "openai" },
|
||||
auth: { apiKey: "key", mode: "env" },
|
||||
});
|
||||
}, 4_900);
|
||||
}),
|
||||
);
|
||||
const complete = vi.fn(
|
||||
() =>
|
||||
new Promise<{ content: Array<{ type: "text"; text: string }> }>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
decision: "allow",
|
||||
risk: "low",
|
||||
rationale: "read-only inspection",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 2_000);
|
||||
}),
|
||||
);
|
||||
const reviewer = createModelExecAutoReviewer({
|
||||
cfg: {},
|
||||
reviewer: { timeoutMs: 5_000 },
|
||||
deps: {
|
||||
prepareSimpleCompletionModelForAgent:
|
||||
prepare as unknown as typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModelForAgent,
|
||||
completeWithPreparedSimpleCompletionModel:
|
||||
complete as unknown as typeof import("./simple-completion-runtime.js").completeWithPreparedSimpleCompletionModel,
|
||||
},
|
||||
});
|
||||
|
||||
const result = reviewer(input);
|
||||
await vi.advanceTimersByTimeAsync(4_900);
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: "allow-once",
|
||||
risk: "low",
|
||||
rationale: "read-only inspection",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
291
src/agents/exec-auto-reviewer.ts
Normal file
291
src/agents/exec-auto-reviewer.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentModelConfig } from "../config/types.agents-shared.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
defaultExecAutoReviewer,
|
||||
type ExecAutoReviewDecision,
|
||||
type ExecAutoReviewInput,
|
||||
type ExecAutoReviewer,
|
||||
} from "../infra/exec-auto-review.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { DEFAULT_EXEC_REVIEWER_SYSTEM_PROMPT } from "./exec-auto-reviewer.prompt.js";
|
||||
import {
|
||||
completeWithPreparedSimpleCompletionModel,
|
||||
prepareSimpleCompletionModelForAgent,
|
||||
} from "./simple-completion-runtime.js";
|
||||
import { coerceToolModelConfig } from "./tools/model-config.helpers.js";
|
||||
|
||||
const DEFAULT_EXEC_REVIEWER_TIMEOUT_MS = 30_000;
|
||||
const EXEC_REVIEWER_MAX_TOKENS = 360;
|
||||
const EXEC_REVIEWER_TIMEOUT = Symbol("exec-reviewer-timeout");
|
||||
|
||||
const execAutoReviewResponseSchema = z.object({
|
||||
decision: z.enum(["allow", "ask"]),
|
||||
risk: z.enum(["low", "medium", "high", "unknown"]),
|
||||
rationale: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ExecReviewerConfig = {
|
||||
model?: AgentModelConfig;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type ExecReviewerDeps = {
|
||||
prepareSimpleCompletionModelForAgent?: typeof prepareSimpleCompletionModelForAgent;
|
||||
completeWithPreparedSimpleCompletionModel?: typeof completeWithPreparedSimpleCompletionModel;
|
||||
};
|
||||
|
||||
function stringifyInput(input: ExecAutoReviewInput): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
command: input.command,
|
||||
argv: input.argv,
|
||||
cwd: input.cwd,
|
||||
envKeys: input.envKeys,
|
||||
host: input.host,
|
||||
reason: input.reason,
|
||||
analysis: input.analysis,
|
||||
agent: input.agent,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRationale(value: unknown, fallback: string): string {
|
||||
const text = normalizeOptionalString(typeof value === "string" ? value : undefined);
|
||||
return (text ?? fallback).slice(0, 500);
|
||||
}
|
||||
|
||||
function stripJsonFence(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/iu.exec(trimmed);
|
||||
return fenced?.[1]?.trim() ?? trimmed;
|
||||
}
|
||||
|
||||
function extractJsonObject(text: string): string | null {
|
||||
const stripped = stripJsonFence(text);
|
||||
if (stripped.startsWith("{") && stripped.endsWith("}")) {
|
||||
return stripped;
|
||||
}
|
||||
const start = stripped.indexOf("{");
|
||||
const end = stripped.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
return stripped.slice(start, end + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseExecAutoReviewResponse(text: string): ExecAutoReviewDecision {
|
||||
const objectText = extractJsonObject(text);
|
||||
if (!objectText) {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: "exec reviewer returned no parseable JSON",
|
||||
};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(objectText);
|
||||
} catch {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: "exec reviewer returned malformed JSON",
|
||||
};
|
||||
}
|
||||
const response = execAutoReviewResponseSchema.safeParse(parsed);
|
||||
if (!response.success) {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: "exec reviewer returned an unsupported response",
|
||||
};
|
||||
}
|
||||
|
||||
const { decision, risk } = response.data;
|
||||
const rationale = normalizeRationale(
|
||||
response.data.rationale,
|
||||
"exec reviewer did not explain decision",
|
||||
);
|
||||
if (decision === "ask") {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
if (risk !== "low") {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk,
|
||||
rationale: "exec reviewer returned a non-low allow decision",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decision: "allow-once",
|
||||
risk,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextContent(
|
||||
result: Awaited<ReturnType<typeof completeWithPreparedSimpleCompletionModel>>,
|
||||
) {
|
||||
return result.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractCompletionError(
|
||||
result: Awaited<ReturnType<typeof completeWithPreparedSimpleCompletionModel>>,
|
||||
): string | undefined {
|
||||
if (!("stopReason" in result) || result.stopReason !== "error") {
|
||||
return undefined;
|
||||
}
|
||||
const message =
|
||||
"errorMessage" in result && typeof result.errorMessage === "string"
|
||||
? result.errorMessage
|
||||
: undefined;
|
||||
return normalizeRationale(message, "model returned an error");
|
||||
}
|
||||
|
||||
function resolveReviewerModelRef(config?: ExecReviewerConfig): string | undefined {
|
||||
return coerceToolModelConfig(config?.model).primary;
|
||||
}
|
||||
|
||||
function resolveReviewerTimeoutMs(config?: ExecReviewerConfig): number {
|
||||
return typeof config?.timeoutMs === "number" && Number.isFinite(config.timeoutMs)
|
||||
? Math.max(1_000, Math.floor(config.timeoutMs))
|
||||
: DEFAULT_EXEC_REVIEWER_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function buildReviewerTimeoutDecision(timeoutMs: number): ExecAutoReviewDecision {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: `exec reviewer timed out after ${timeoutMs}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
async function raceWithReviewerTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
params: {
|
||||
timeoutMs: number;
|
||||
onTimeout?: () => void;
|
||||
},
|
||||
): Promise<T | typeof EXEC_REVIEWER_TIMEOUT> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<typeof EXEC_REVIEWER_TIMEOUT>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
params.onTimeout?.();
|
||||
resolve(EXEC_REVIEWER_TIMEOUT);
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelExecAutoReviewer(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
reviewer?: ExecReviewerConfig;
|
||||
deps?: ExecReviewerDeps;
|
||||
}): ExecAutoReviewer {
|
||||
const cfg = params.cfg;
|
||||
const agentId = params.agentId ?? "main";
|
||||
if (!cfg) {
|
||||
return defaultExecAutoReviewer;
|
||||
}
|
||||
const prepareModel =
|
||||
params.deps?.prepareSimpleCompletionModelForAgent ?? prepareSimpleCompletionModelForAgent;
|
||||
const complete =
|
||||
params.deps?.completeWithPreparedSimpleCompletionModel ??
|
||||
completeWithPreparedSimpleCompletionModel;
|
||||
const modelRef = resolveReviewerModelRef(params.reviewer);
|
||||
const timeoutMs = resolveReviewerTimeoutMs(params.reviewer);
|
||||
return async (input) => {
|
||||
let completionController: AbortController | undefined;
|
||||
try {
|
||||
const prepared = await raceWithReviewerTimeout(
|
||||
prepareModel({
|
||||
cfg,
|
||||
agentId,
|
||||
modelRef,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
}),
|
||||
{ timeoutMs },
|
||||
);
|
||||
if (prepared === EXEC_REVIEWER_TIMEOUT) {
|
||||
return buildReviewerTimeoutDecision(timeoutMs);
|
||||
}
|
||||
if ("error" in prepared) {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: `exec reviewer model unavailable: ${prepared.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
completionController = new AbortController();
|
||||
const result = await raceWithReviewerTimeout(
|
||||
complete({
|
||||
model: prepared.model,
|
||||
auth: prepared.auth,
|
||||
cfg,
|
||||
context: {
|
||||
systemPrompt: DEFAULT_EXEC_REVIEWER_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Review this pending exec request:\n\n${stringifyInput(input)}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maxTokens: EXEC_REVIEWER_MAX_TOKENS,
|
||||
temperature: 0,
|
||||
signal: completionController.signal,
|
||||
},
|
||||
}),
|
||||
{
|
||||
timeoutMs,
|
||||
onTimeout: () => completionController?.abort(),
|
||||
},
|
||||
);
|
||||
if (result === EXEC_REVIEWER_TIMEOUT) {
|
||||
return buildReviewerTimeoutDecision(timeoutMs);
|
||||
}
|
||||
const completionError = extractCompletionError(result);
|
||||
if (completionError) {
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: `exec reviewer completion failed: ${completionError}`,
|
||||
};
|
||||
}
|
||||
return parseExecAutoReviewResponse(extractTextContent(result));
|
||||
} catch (err) {
|
||||
if (completionController?.signal.aborted) {
|
||||
return buildReviewerTimeoutDecision(timeoutMs);
|
||||
}
|
||||
return {
|
||||
decision: "ask",
|
||||
risk: "unknown",
|
||||
rationale: `exec reviewer failed: ${formatErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -94,6 +94,7 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
expect(defaults.host).toBe("auto");
|
||||
expect(defaults.effectiveHost).toBe("gateway");
|
||||
expect(defaults.mode).toBe("full");
|
||||
expect(defaults.security).toBe("full");
|
||||
expect(defaults.ask).toBe("off");
|
||||
});
|
||||
@@ -112,10 +113,180 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
expect(defaults.host).toBe("auto");
|
||||
expect(defaults.effectiveHost).toBe("sandbox");
|
||||
expect(defaults.mode).toBe("deny");
|
||||
expect(defaults.security).toBe("deny");
|
||||
expect(defaults.ask).toBe("off");
|
||||
});
|
||||
|
||||
it("ignores host approval defaults when auto resolves to sandbox", () => {
|
||||
vi.mocked(execApprovals.loadExecApprovals).mockReturnValue({
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
},
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
expect(defaults.effectiveHost).toBe("sandbox");
|
||||
expect(defaults.security).toBe("deny");
|
||||
expect(defaults.ask).toBe("off");
|
||||
expect(execApprovals.loadExecApprovals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps normalized auto mode to allowlist plus on-miss approvals", () => {
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "auto",
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports host approval floors after normalized exec modes", () => {
|
||||
vi.mocked(execApprovals.loadExecApprovals).mockReturnValue({
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "off",
|
||||
},
|
||||
agents: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "deny",
|
||||
security: "deny",
|
||||
ask: "on-miss",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports agent-scoped host approval floors", () => {
|
||||
vi.mocked(execApprovals.loadExecApprovals).mockReturnValue({
|
||||
version: 1,
|
||||
agents: {
|
||||
"agent-a": {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "agent-a",
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "ask",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy security overrides ahead of higher-scope normalized mode", () => {
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-a",
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "agent-a",
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "full",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mode-derived security for partial legacy agent overrides", () => {
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-a",
|
||||
tools: {
|
||||
exec: {
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "agent-a",
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "allowlist",
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks node advertising in helper calls when sandbox is available", () => {
|
||||
expect(
|
||||
canExecRequestNode({
|
||||
|
||||
@@ -4,8 +4,18 @@ import {
|
||||
loadExecApprovals,
|
||||
type ExecAsk,
|
||||
type ExecHost,
|
||||
type ExecMode,
|
||||
type ExecSecurity,
|
||||
type ExecTarget,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeExecAsk,
|
||||
normalizeExecSecurity,
|
||||
normalizeExecTarget,
|
||||
resolveExecApprovalsFromFile,
|
||||
resolveExecModeFromPolicy,
|
||||
resolveExecModePolicy,
|
||||
resolveExecPolicyForMode,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveAgentConfig, resolveSessionAgentId } from "./agent-scope.js";
|
||||
import { isRequestedExecTargetAllowed, resolveExecTarget } from "./bash-tools.exec-runtime.js";
|
||||
@@ -13,19 +23,71 @@ import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
|
||||
|
||||
type ResolvedExecConfig = {
|
||||
host?: ExecTarget;
|
||||
mode?: ExecMode;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
};
|
||||
|
||||
type ExecOverridesConfig = Omit<ResolvedExecConfig, "mode">;
|
||||
|
||||
function hasLegacyExecPolicyOverride(exec?: ResolvedExecConfig): boolean {
|
||||
return exec?.security !== undefined || exec?.ask !== undefined;
|
||||
}
|
||||
|
||||
type LayeredExecPolicy = {
|
||||
mode?: ExecMode;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
};
|
||||
|
||||
function applyExecPolicyLayer(
|
||||
base: LayeredExecPolicy,
|
||||
layer?: ResolvedExecConfig,
|
||||
): LayeredExecPolicy {
|
||||
if (!layer) {
|
||||
return base;
|
||||
}
|
||||
if (layer.mode) {
|
||||
return {
|
||||
mode: layer.mode,
|
||||
...resolveExecPolicyForMode(layer.mode),
|
||||
};
|
||||
}
|
||||
if (hasLegacyExecPolicyOverride(layer)) {
|
||||
return {
|
||||
security: layer.security ?? base.security,
|
||||
ask: layer.ask ?? base.ask,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function applySessionLegacyExecPolicyLayer(
|
||||
base: LayeredExecPolicy,
|
||||
sessionEntry?: SessionEntry,
|
||||
): LayeredExecPolicy {
|
||||
const security = normalizeExecSecurity(sessionEntry?.execSecurity);
|
||||
const ask = normalizeExecAsk(sessionEntry?.execAsk);
|
||||
if (security !== null || ask !== null) {
|
||||
return {
|
||||
security: security ?? base.security,
|
||||
ask: ask ?? base.ask,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function resolveExecConfigState(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
sessionEntry?: SessionEntry;
|
||||
execOverrides?: ExecOverridesConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}): {
|
||||
cfg: OpenClawConfig;
|
||||
host: ExecTarget;
|
||||
agentId: string | undefined;
|
||||
agentExec?: ResolvedExecConfig;
|
||||
globalExec?: ResolvedExecConfig;
|
||||
} {
|
||||
@@ -41,13 +103,15 @@ function resolveExecConfigState(params: {
|
||||
? resolveAgentConfig(cfg, resolvedAgentId)?.tools?.exec
|
||||
: undefined;
|
||||
const host =
|
||||
(params.sessionEntry?.execHost as ExecTarget | undefined) ??
|
||||
params.execOverrides?.host ??
|
||||
normalizeExecTarget(params.sessionEntry?.execHost) ??
|
||||
(agentExec?.host as ExecTarget | undefined) ??
|
||||
(globalExec?.host as ExecTarget | undefined) ??
|
||||
"auto";
|
||||
return {
|
||||
cfg,
|
||||
host,
|
||||
agentId: resolvedAgentId,
|
||||
agentExec,
|
||||
globalExec,
|
||||
};
|
||||
@@ -72,6 +136,7 @@ function resolveExecSandboxAvailability(params: {
|
||||
export function canExecRequestNode(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
sessionEntry?: SessionEntry;
|
||||
execOverrides?: ExecOverridesConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sandboxAvailable?: boolean;
|
||||
@@ -91,18 +156,27 @@ export function canExecRequestNode(params: {
|
||||
export function resolveExecDefaults(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
sessionEntry?: SessionEntry;
|
||||
execOverrides?: ExecOverridesConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sandboxAvailable?: boolean;
|
||||
elevatedRequested?: boolean;
|
||||
}): {
|
||||
host: ExecTarget;
|
||||
effectiveHost: ExecHost;
|
||||
mode: ExecMode;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
node?: string;
|
||||
canRequestNode: boolean;
|
||||
} {
|
||||
const { cfg, host, agentExec, globalExec } = resolveExecConfigState(params);
|
||||
const {
|
||||
cfg,
|
||||
host,
|
||||
agentId: resolvedAgentId,
|
||||
agentExec,
|
||||
globalExec,
|
||||
} = resolveExecConfigState(params);
|
||||
const sandboxAvailable = resolveExecSandboxAvailability({
|
||||
cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -110,27 +184,56 @@ export function resolveExecDefaults(params: {
|
||||
});
|
||||
const resolved = resolveExecTarget({
|
||||
configuredTarget: host,
|
||||
elevatedRequested: false,
|
||||
elevatedRequested: params.elevatedRequested === true,
|
||||
sandboxAvailable,
|
||||
});
|
||||
const approvalDefaults = loadExecApprovals().defaults;
|
||||
const defaultSecurity = resolved.effectiveHost === "sandbox" ? "deny" : "full";
|
||||
const approvalDefaults =
|
||||
resolved.effectiveHost === "sandbox"
|
||||
? undefined
|
||||
: resolveExecApprovalsFromFile({
|
||||
file: loadExecApprovals(),
|
||||
agentId: resolvedAgentId,
|
||||
overrides: {
|
||||
security: defaultSecurity,
|
||||
ask: "off",
|
||||
},
|
||||
}).agent;
|
||||
const basePolicy: LayeredExecPolicy = {
|
||||
security: approvalDefaults?.security ?? defaultSecurity,
|
||||
ask: approvalDefaults?.ask ?? "off",
|
||||
};
|
||||
const layeredPolicy = applyExecPolicyLayer(
|
||||
applySessionLegacyExecPolicyLayer(
|
||||
applyExecPolicyLayer(applyExecPolicyLayer(basePolicy, globalExec), agentExec),
|
||||
params.sessionEntry,
|
||||
),
|
||||
params.execOverrides,
|
||||
);
|
||||
const modePolicy = resolveExecModePolicy(layeredPolicy);
|
||||
const security =
|
||||
approvalDefaults?.security !== undefined
|
||||
? minSecurity(modePolicy.security, approvalDefaults.security)
|
||||
: modePolicy.security;
|
||||
const ask =
|
||||
approvalDefaults?.ask !== undefined
|
||||
? maxAsk(modePolicy.ask, approvalDefaults.ask)
|
||||
: modePolicy.ask;
|
||||
const mode =
|
||||
security === modePolicy.security && ask === modePolicy.ask
|
||||
? modePolicy.mode
|
||||
: resolveExecModeFromPolicy({ security, ask });
|
||||
return {
|
||||
host,
|
||||
effectiveHost: resolved.effectiveHost,
|
||||
security:
|
||||
(params.sessionEntry?.execSecurity as ExecSecurity | undefined) ??
|
||||
agentExec?.security ??
|
||||
globalExec?.security ??
|
||||
approvalDefaults?.security ??
|
||||
defaultSecurity,
|
||||
ask:
|
||||
(params.sessionEntry?.execAsk as ExecAsk | undefined) ??
|
||||
agentExec?.ask ??
|
||||
globalExec?.ask ??
|
||||
approvalDefaults?.ask ??
|
||||
"off",
|
||||
node: params.sessionEntry?.execNode ?? agentExec?.node ?? globalExec?.node,
|
||||
mode,
|
||||
security,
|
||||
ask,
|
||||
node:
|
||||
params.execOverrides?.node ??
|
||||
params.sessionEntry?.execNode ??
|
||||
agentExec?.node ??
|
||||
globalExec?.node,
|
||||
canRequestNode: isRequestedExecTargetAllowed({
|
||||
configuredTarget: host,
|
||||
requestedTarget: "node",
|
||||
|
||||
@@ -303,6 +303,7 @@ const TARGET_KEYS = [
|
||||
"tools.deny",
|
||||
"tools.exec",
|
||||
"tools.exec.host",
|
||||
"tools.exec.mode",
|
||||
"tools.exec.security",
|
||||
"tools.exec.ask",
|
||||
"tools.exec.node",
|
||||
|
||||
@@ -413,6 +413,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Exec-tool policy grouping for shell execution host, security mode, approval behavior, and runtime bindings. Keep conservative defaults in production and tighten elevated execution paths.",
|
||||
"tools.exec.host":
|
||||
'Selects execution target strategy for shell commands. Use "auto" for runtime-aware behavior (sandbox when available, otherwise gateway), or pin sandbox/gateway/node explicitly when you need a fixed surface.',
|
||||
"tools.exec.mode":
|
||||
'Normalized exec policy selector. Use "auto" for classifier-reviewed approval misses, "ask" for human-reviewed misses, "allowlist" for deterministic safe commands only, or "full" for trusted local operation.',
|
||||
"tools.exec.reviewer":
|
||||
"Model-backed exec reviewer used by auto mode before human approval fallback. Configure a narrow model override here when you want exec review isolated from the main agent model.",
|
||||
"tools.exec.reviewer.model":
|
||||
"Optional provider/model override for the exec reviewer agent. Omit to reuse the current agent model.",
|
||||
"tools.exec.reviewer.timeoutMs":
|
||||
"Exec reviewer timeout in milliseconds before falling back to human approval (default: 30000).",
|
||||
"tools.exec.security":
|
||||
"Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.",
|
||||
"tools.exec.ask":
|
||||
|
||||
@@ -237,6 +237,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
"tools.exec.host": "Exec Target",
|
||||
"tools.exec.mode": "Exec Mode",
|
||||
"tools.exec.reviewer": "Exec Reviewer",
|
||||
"tools.exec.reviewer.model": "Exec Reviewer Model",
|
||||
"tools.exec.reviewer.timeoutMs": "Exec Reviewer Timeout (ms)",
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
|
||||
@@ -57,6 +57,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
|
||||
"gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"],
|
||||
"proxy.tls.caFile": ["security", "network", "storage", "advanced"],
|
||||
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
|
||||
"tools.exec.mode": ["tools", "security", "access"],
|
||||
};
|
||||
|
||||
const PREFIX_RULES: Array<{ prefix: string; tags: ConfigTag[] }> = [
|
||||
|
||||
@@ -493,6 +493,69 @@ describe("config schema", () => {
|
||||
expect(config.agents?.list?.[0]?.tools?.exec?.commandHighlighting).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exec reviewer model config in global and agent scopes", () => {
|
||||
const tools = ToolsSchema.parse({
|
||||
exec: {
|
||||
reviewer: {
|
||||
model: {
|
||||
primary: "openrouter/anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools?.exec?.reviewer?.model).toEqual({
|
||||
primary: "openrouter/anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const config = OpenClawSchema.parse({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
exec: {
|
||||
reviewer: {
|
||||
model: "openai/gpt-5.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(config.agents?.list?.[0]?.tools?.exec?.reviewer?.model).toBe("openai/gpt-5.5");
|
||||
});
|
||||
|
||||
it("rejects mixed normalized and legacy exec policy config", () => {
|
||||
expect(
|
||||
ToolsSchema.safeParse({
|
||||
exec: {
|
||||
mode: "auto",
|
||||
ask: "always",
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
OpenClawSchema.safeParse({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
security: "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts experimental tool flags in the runtime zod schema", () => {
|
||||
const parsed = ToolsSchema.parse({
|
||||
experimental: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import type { AgentModelConfig } from "./types.agents-shared.js";
|
||||
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
|
||||
import type { MemoryQmdIndexPath } from "./types.memory.js";
|
||||
import type { ConfiguredProviderRequest } from "./types.provider-request.js";
|
||||
@@ -298,6 +299,8 @@ export type GroupToolPolicyBySenderConfig = Record<string, GroupToolPolicyConfig
|
||||
export type ExecToolConfig = {
|
||||
/** Exec host routing (default: auto). */
|
||||
host?: "auto" | "sandbox" | "gateway" | "node";
|
||||
/** Normalized exec policy mode. Prefer this over raw security/ask knobs. */
|
||||
mode?: "deny" | "allowlist" | "ask" | "auto" | "full";
|
||||
/** Exec security mode (default: full; sandbox host defaults to deny). */
|
||||
security?: "deny" | "allowlist" | "full";
|
||||
/** Exec ask mode (default: off). */
|
||||
@@ -319,6 +322,13 @@ export type ExecToolConfig = {
|
||||
safeBinTrustedDirs?: string[];
|
||||
/** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */
|
||||
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
|
||||
/** Model-backed reviewer used by tools.exec.mode=auto before falling back to human approval. */
|
||||
reviewer?: {
|
||||
/** Optional reviewer model override (provider/model or agent model config). */
|
||||
model?: AgentModelConfig;
|
||||
/** Reviewer timeout in milliseconds (default: 30000). */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
|
||||
@@ -553,6 +553,7 @@ const ToolExecSafeBinProfileSchema = z
|
||||
|
||||
const ToolExecBaseShape = {
|
||||
host: z.enum(["auto", "sandbox", "gateway", "node"]).optional(),
|
||||
mode: z.enum(["deny", "allowlist", "ask", "auto", "full"]).optional(),
|
||||
security: z.enum(["deny", "allowlist", "full"]).optional(),
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
@@ -562,6 +563,13 @@ const ToolExecBaseShape = {
|
||||
commandHighlighting: z.boolean().optional(),
|
||||
safeBinTrustedDirs: z.array(z.string()).optional(),
|
||||
safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(),
|
||||
reviewer: z
|
||||
.object({
|
||||
model: AgentModelSchema.optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
@@ -570,15 +578,34 @@ const ToolExecBaseShape = {
|
||||
applyPatch: ToolExecApplyPatchSchema,
|
||||
} as const;
|
||||
|
||||
function addExecPolicyModeConflictIssue(
|
||||
value: { mode?: unknown; security?: unknown; ask?: unknown },
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
if (value.mode === undefined || (value.security === undefined && value.ask === undefined)) {
|
||||
return;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["mode"],
|
||||
message: "tools.exec.mode cannot be combined with tools.exec.security or tools.exec.ask",
|
||||
});
|
||||
}
|
||||
|
||||
const AgentToolExecSchema = z
|
||||
.object({
|
||||
...ToolExecBaseShape,
|
||||
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(addExecPolicyModeConflictIssue)
|
||||
.optional();
|
||||
|
||||
const ToolExecSchema = z.object(ToolExecBaseShape).strict().optional();
|
||||
const ToolExecSchema = z
|
||||
.object(ToolExecBaseShape)
|
||||
.strict()
|
||||
.superRefine(addExecPolicyModeConflictIssue)
|
||||
.optional();
|
||||
|
||||
const ToolFsSchema = z
|
||||
.object({
|
||||
|
||||
@@ -195,9 +195,10 @@ describe("withOperatorApprovalsGatewayClient", () => {
|
||||
);
|
||||
|
||||
expect(typeof clientState.options?.approvalRuntimeToken).toBe("string");
|
||||
expect(clientState.options?.deviceIdentity).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps device identity for loopback approval clients without shared auth", async () => {
|
||||
it("omits stored device identity for local runtime-token approval clients without shared auth", async () => {
|
||||
bootstrapState.auth = { token: undefined, password: undefined };
|
||||
|
||||
await withOperatorApprovalsGatewayClient(
|
||||
@@ -208,7 +209,8 @@ describe("withOperatorApprovalsGatewayClient", () => {
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(clientState.options?.deviceIdentity).toBeUndefined();
|
||||
expect(typeof clientState.options?.approvalRuntimeToken).toBe("string");
|
||||
expect(clientState.options?.deviceIdentity).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces close failures before hello", async () => {
|
||||
|
||||
@@ -35,6 +35,18 @@ function shouldSendApprovalRuntimeToken(urlSource: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldOmitApprovalRuntimeDeviceIdentity(params: {
|
||||
url: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
sendsApprovalRuntimeToken: boolean;
|
||||
}): boolean {
|
||||
if (params.sendsApprovalRuntimeToken) {
|
||||
return true;
|
||||
}
|
||||
return shouldOmitOperatorApprovalDeviceIdentity(params);
|
||||
}
|
||||
|
||||
export async function createOperatorApprovalsGatewayClient(
|
||||
params: Pick<
|
||||
GatewayClientOptions,
|
||||
@@ -54,12 +66,13 @@ export async function createOperatorApprovalsGatewayClient(
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
env: process.env,
|
||||
});
|
||||
const sendsApprovalRuntimeToken = shouldSendApprovalRuntimeToken(bootstrap.urlSource);
|
||||
|
||||
return new GatewayClient({
|
||||
url: bootstrap.url,
|
||||
token: bootstrap.auth.token,
|
||||
password: bootstrap.auth.password,
|
||||
...(shouldSendApprovalRuntimeToken(bootstrap.urlSource)
|
||||
...(sendsApprovalRuntimeToken
|
||||
? { approvalRuntimeToken: getOperatorApprovalRuntimeToken() }
|
||||
: {}),
|
||||
preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs,
|
||||
@@ -67,10 +80,11 @@ export async function createOperatorApprovalsGatewayClient(
|
||||
clientDisplayName: params.clientDisplayName,
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
scopes: ["operator.approvals"],
|
||||
deviceIdentity: shouldOmitOperatorApprovalDeviceIdentity({
|
||||
deviceIdentity: shouldOmitApprovalRuntimeDeviceIdentity({
|
||||
url: bootstrap.url,
|
||||
token: bootstrap.auth.token,
|
||||
password: bootstrap.auth.password,
|
||||
sendsApprovalRuntimeToken,
|
||||
})
|
||||
? null
|
||||
: undefined,
|
||||
|
||||
@@ -528,6 +528,57 @@ describe("handlePendingApprovalRequest", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps register-only approval requests pending without a delivery route", async () => {
|
||||
hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false);
|
||||
const manager = new ExecApprovalManager();
|
||||
const record = manager.create(
|
||||
{
|
||||
command: "echo ok",
|
||||
},
|
||||
60_000,
|
||||
"approval-register-only",
|
||||
);
|
||||
const decisionPromise = manager.register(record, 60_000);
|
||||
const respond = vi.fn();
|
||||
const requestPromise = handlePendingApprovalRequest({
|
||||
manager,
|
||||
record,
|
||||
decisionPromise,
|
||||
respond,
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
hasExecApprovalClients: () => false,
|
||||
} as unknown as GatewayRequestContext,
|
||||
requestEventName: "exec.approval.requested",
|
||||
requestEvent: {
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
twoPhase: true,
|
||||
requireDeliveryRoute: false,
|
||||
deliverRequest: () => false,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(manager.getSnapshot(record.id)?.resolvedAtMs).toBeUndefined();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-register-only", status: "accepted" }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(manager.resolve(record.id, "allow-once")).toBe(true);
|
||||
await requestPromise;
|
||||
expect(manager.getSnapshot(record.id)?.resolvedBy).not.toBe("no-approval-route");
|
||||
expect(respond).toHaveBeenLastCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-register-only", decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not target no-device browser UI approvals to unrelated approval-scoped clients", async () => {
|
||||
hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false);
|
||||
const manager = new ExecApprovalManager();
|
||||
|
||||
@@ -287,30 +287,39 @@ export async function handlePendingApprovalRequest<
|
||||
requestEvent: RequestedApprovalEvent<TPayload>,
|
||||
) => Promise<void> | void;
|
||||
afterDecisionErrorLabel?: string;
|
||||
keepPendingWithoutRoute?: boolean;
|
||||
requireDeliveryRoute?: boolean;
|
||||
suppressDelivery?: boolean;
|
||||
}): Promise<void> {
|
||||
const approvalClientConnIds = resolveApprovalRequestRecipientConnIds({
|
||||
context: params.context,
|
||||
record: params.record,
|
||||
excludeConnId: params.clientConnId,
|
||||
});
|
||||
if (approvalClientConnIds) {
|
||||
params.context.broadcastToConnIds(
|
||||
params.requestEventName,
|
||||
params.requestEvent,
|
||||
approvalClientConnIds,
|
||||
{
|
||||
dropIfSlow: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true });
|
||||
const suppressDelivery = params.suppressDelivery === true;
|
||||
const approvalClientConnIds = suppressDelivery
|
||||
? null
|
||||
: resolveApprovalRequestRecipientConnIds({
|
||||
context: params.context,
|
||||
record: params.record,
|
||||
excludeConnId: params.clientConnId,
|
||||
});
|
||||
if (!suppressDelivery) {
|
||||
if (approvalClientConnIds) {
|
||||
params.context.broadcastToConnIds(
|
||||
params.requestEventName,
|
||||
params.requestEvent,
|
||||
approvalClientConnIds,
|
||||
{
|
||||
dropIfSlow: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true });
|
||||
}
|
||||
}
|
||||
|
||||
const hasApprovalClients =
|
||||
approvalClientConnIds !== null
|
||||
const hasApprovalClients = suppressDelivery
|
||||
? false
|
||||
: approvalClientConnIds !== null
|
||||
? approvalClientConnIds.size > 0
|
||||
: (params.context.hasExecApprovalClients?.(params.clientConnId) ?? false);
|
||||
const deliveredResult = params.deliverRequest();
|
||||
const deliveredResult = suppressDelivery ? false : params.deliverRequest();
|
||||
const delivered = isPromiseLike(deliveredResult) ? await deliveredResult : deliveredResult;
|
||||
const hasTurnSourceRoute =
|
||||
!hasApprovalClients &&
|
||||
@@ -321,7 +330,13 @@ export async function handlePendingApprovalRequest<
|
||||
approvalKind: params.approvalKind ?? "exec",
|
||||
});
|
||||
|
||||
if (!hasApprovalClients && !hasTurnSourceRoute && !delivered) {
|
||||
if (
|
||||
params.requireDeliveryRoute !== false &&
|
||||
!params.keepPendingWithoutRoute &&
|
||||
!hasApprovalClients &&
|
||||
!hasTurnSourceRoute &&
|
||||
!delivered
|
||||
) {
|
||||
params.manager.expire(params.record.id, "no-approval-route");
|
||||
params.respond(
|
||||
true,
|
||||
|
||||
@@ -187,6 +187,8 @@ export function createExecApprovalHandlers(
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
requireDeliveryRoute?: boolean;
|
||||
suppressDelivery?: boolean;
|
||||
timeoutMs?: number;
|
||||
twoPhase?: boolean;
|
||||
};
|
||||
@@ -368,6 +370,8 @@ export function createExecApprovalHandlers(
|
||||
requestEvent,
|
||||
twoPhase,
|
||||
approvalKind: "exec",
|
||||
requireDeliveryRoute: p.requireDeliveryRoute,
|
||||
suppressDelivery: p.suppressDelivery,
|
||||
deliverRequest: () => {
|
||||
const deliveryTasks: Array<Promise<boolean>> = [];
|
||||
if (opts?.forwarder) {
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveExecApprovalsFromFile,
|
||||
resolveExecModeFromPolicy,
|
||||
resolveExecModePolicy,
|
||||
type ExecApprovalsFile,
|
||||
type ExecAsk,
|
||||
type ExecMode,
|
||||
type ExecSecurity,
|
||||
type ExecTarget,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -23,6 +26,7 @@ const REQUESTED_DEFAULT_LABEL = {
|
||||
} as const;
|
||||
type ExecPolicyConfig = {
|
||||
host?: ExecTarget;
|
||||
mode?: ExecMode;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
};
|
||||
@@ -46,6 +50,12 @@ export type ExecPolicyScopeSnapshot = {
|
||||
configPath: string;
|
||||
agentId?: string;
|
||||
host: ExecPolicyHostSummary;
|
||||
mode: {
|
||||
requested: ExecMode;
|
||||
requestedSource: string;
|
||||
effective: ExecMode;
|
||||
note: string;
|
||||
};
|
||||
security: ExecPolicyFieldSummary<ExecSecurity>;
|
||||
ask: ExecPolicyFieldSummary<ExecAsk>;
|
||||
askFallback: {
|
||||
@@ -93,6 +103,13 @@ function formatRequestedSource(params: {
|
||||
: `${params.sourcePath}.${params.field}`;
|
||||
}
|
||||
|
||||
function formatModeSource(params: { sourcePath: string; configPath: string }): string {
|
||||
if (params.sourcePath === "__default__") {
|
||||
return "derived from OpenClaw defaults";
|
||||
}
|
||||
return `${params.sourcePath === "scope" ? params.configPath : params.sourcePath}.mode`;
|
||||
}
|
||||
|
||||
type ExecPolicyField = "security" | "ask" | "askFallback";
|
||||
|
||||
function resolveRequestedField<
|
||||
@@ -124,6 +141,125 @@ function resolveRequestedField<
|
||||
};
|
||||
}
|
||||
|
||||
function hasLegacyExecPolicyOverride(exec?: ExecPolicyConfig): boolean {
|
||||
return exec?.security !== undefined || exec?.ask !== undefined;
|
||||
}
|
||||
|
||||
function resolveRequestedPolicy(params: {
|
||||
scopeExecConfig?: ExecPolicyConfig;
|
||||
globalExecConfig?: ExecPolicyConfig;
|
||||
configPath: string;
|
||||
}): {
|
||||
mode: ExecMode;
|
||||
modeSource: string;
|
||||
security: ExecSecurity;
|
||||
securitySource: string;
|
||||
ask: ExecAsk;
|
||||
askSource: string;
|
||||
} {
|
||||
if (params.scopeExecConfig?.mode) {
|
||||
const policy = resolveExecModePolicy({
|
||||
mode: params.scopeExecConfig.mode,
|
||||
security: DEFAULT_REQUESTED_SECURITY,
|
||||
ask: DEFAULT_REQUESTED_ASK,
|
||||
});
|
||||
const source = formatModeSource({ sourcePath: "scope", configPath: params.configPath });
|
||||
return {
|
||||
mode: policy.mode,
|
||||
modeSource: source,
|
||||
security: policy.security,
|
||||
securitySource: source,
|
||||
ask: policy.ask,
|
||||
askSource: source,
|
||||
};
|
||||
}
|
||||
if (!hasLegacyExecPolicyOverride(params.scopeExecConfig) && params.globalExecConfig?.mode) {
|
||||
const policy = resolveExecModePolicy({
|
||||
mode: params.globalExecConfig.mode,
|
||||
security: DEFAULT_REQUESTED_SECURITY,
|
||||
ask: DEFAULT_REQUESTED_ASK,
|
||||
});
|
||||
const source = formatModeSource({ sourcePath: "tools.exec", configPath: params.configPath });
|
||||
return {
|
||||
mode: policy.mode,
|
||||
modeSource: source,
|
||||
security: policy.security,
|
||||
securitySource: source,
|
||||
ask: policy.ask,
|
||||
askSource: source,
|
||||
};
|
||||
}
|
||||
if (hasLegacyExecPolicyOverride(params.scopeExecConfig) && params.globalExecConfig?.mode) {
|
||||
const inherited = resolveExecModePolicy({
|
||||
mode: params.globalExecConfig.mode,
|
||||
security: DEFAULT_REQUESTED_SECURITY,
|
||||
ask: DEFAULT_REQUESTED_ASK,
|
||||
});
|
||||
const inheritedSource = formatModeSource({
|
||||
sourcePath: "tools.exec",
|
||||
configPath: params.configPath,
|
||||
});
|
||||
const scopeSecuritySource = formatRequestedSource({
|
||||
sourcePath: params.configPath,
|
||||
field: "security",
|
||||
defaultValue: DEFAULT_REQUESTED_SECURITY,
|
||||
});
|
||||
const scopeAskSource = formatRequestedSource({
|
||||
sourcePath: params.configPath,
|
||||
field: "ask",
|
||||
defaultValue: DEFAULT_REQUESTED_ASK,
|
||||
});
|
||||
const security = params.scopeExecConfig?.security ?? inherited.security;
|
||||
const ask = params.scopeExecConfig?.ask ?? inherited.ask;
|
||||
const securitySource =
|
||||
params.scopeExecConfig?.security !== undefined ? scopeSecuritySource : inheritedSource;
|
||||
const askSource = params.scopeExecConfig?.ask !== undefined ? scopeAskSource : inheritedSource;
|
||||
return {
|
||||
mode: resolveExecModeFromPolicy({ security, ask }),
|
||||
modeSource:
|
||||
securitySource === askSource
|
||||
? `derived from ${securitySource}`
|
||||
: `derived from ${securitySource} and ${askSource}`,
|
||||
security,
|
||||
securitySource,
|
||||
ask,
|
||||
askSource,
|
||||
};
|
||||
}
|
||||
|
||||
const security = resolveRequestedField<ExecSecurity>({
|
||||
field: "security",
|
||||
scopeExecConfig: params.scopeExecConfig,
|
||||
globalExecConfig: params.globalExecConfig,
|
||||
});
|
||||
const ask = resolveRequestedField<ExecAsk>({
|
||||
field: "ask",
|
||||
scopeExecConfig: params.scopeExecConfig,
|
||||
globalExecConfig: params.globalExecConfig,
|
||||
});
|
||||
const securitySource = formatRequestedSource({
|
||||
sourcePath: security.sourcePath === "scope" ? params.configPath : security.sourcePath,
|
||||
field: "security",
|
||||
defaultValue: DEFAULT_REQUESTED_SECURITY,
|
||||
});
|
||||
const askSource = formatRequestedSource({
|
||||
sourcePath: ask.sourcePath === "scope" ? params.configPath : ask.sourcePath,
|
||||
field: "ask",
|
||||
defaultValue: DEFAULT_REQUESTED_ASK,
|
||||
});
|
||||
return {
|
||||
mode: resolveExecModeFromPolicy({ security: security.value, ask: ask.value }),
|
||||
modeSource:
|
||||
securitySource === askSource
|
||||
? `derived from ${securitySource}`
|
||||
: `derived from ${securitySource} and ${askSource}`,
|
||||
security: security.value,
|
||||
securitySource,
|
||||
ask: ask.value,
|
||||
askSource,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHostFieldSource(params: {
|
||||
hostPath: string;
|
||||
field: ExecPolicyField;
|
||||
@@ -213,32 +349,34 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
agentId?: string;
|
||||
hostPath?: string;
|
||||
}): ExecPolicyScopeSnapshot {
|
||||
const requestedSecurity = resolveRequestedField<ExecSecurity>({
|
||||
field: "security",
|
||||
scopeExecConfig: params.scopeExecConfig,
|
||||
globalExecConfig: params.globalExecConfig,
|
||||
});
|
||||
const requestedHost = resolveRequestedHost({
|
||||
scopeExecConfig: params.scopeExecConfig,
|
||||
globalExecConfig: params.globalExecConfig,
|
||||
});
|
||||
const requestedAsk = resolveRequestedField<ExecAsk>({
|
||||
field: "ask",
|
||||
const requestedPolicy = resolveRequestedPolicy({
|
||||
scopeExecConfig: params.scopeExecConfig,
|
||||
globalExecConfig: params.globalExecConfig,
|
||||
configPath: params.configPath,
|
||||
});
|
||||
const resolved = resolveExecApprovalsFromFile({
|
||||
file: params.approvals,
|
||||
agentId: params.agentId,
|
||||
overrides: {
|
||||
security: requestedSecurity.value,
|
||||
ask: requestedAsk.value,
|
||||
security: requestedPolicy.security,
|
||||
ask: requestedPolicy.ask,
|
||||
},
|
||||
});
|
||||
const hostPath = params.hostPath ?? DEFAULT_HOST_PATH;
|
||||
const effectiveSecurity = minSecurity(requestedSecurity.value, resolved.agent.security);
|
||||
const effectiveAsk = maxAsk(requestedAsk.value, resolved.agent.ask);
|
||||
const effectiveSecurity = minSecurity(requestedPolicy.security, resolved.agent.security);
|
||||
const effectiveAsk = maxAsk(requestedPolicy.ask, resolved.agent.ask);
|
||||
const effectiveAskFallback = minSecurity(effectiveSecurity, resolved.agent.askFallback);
|
||||
const effectiveMode =
|
||||
effectiveSecurity === requestedPolicy.security && effectiveAsk === requestedPolicy.ask
|
||||
? requestedPolicy.mode
|
||||
: resolveExecModeFromPolicy({
|
||||
security: effectiveSecurity,
|
||||
ask: effectiveAsk,
|
||||
});
|
||||
return {
|
||||
scopeLabel: params.scopeLabel,
|
||||
configPath: params.configPath,
|
||||
@@ -250,16 +388,18 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
? "OpenClaw default (auto)"
|
||||
: `${requestedHost.sourcePath === "scope" ? params.configPath : requestedHost.sourcePath}.host`,
|
||||
},
|
||||
mode: {
|
||||
requested: requestedPolicy.mode,
|
||||
requestedSource: requestedPolicy.modeSource,
|
||||
effective: effectiveMode,
|
||||
note:
|
||||
effectiveMode === requestedPolicy.mode
|
||||
? "requested mode applies"
|
||||
: "host policy changes effective mode",
|
||||
},
|
||||
security: {
|
||||
requested: requestedSecurity.value,
|
||||
requestedSource: formatRequestedSource({
|
||||
sourcePath:
|
||||
requestedSecurity.sourcePath === "scope"
|
||||
? params.configPath
|
||||
: requestedSecurity.sourcePath,
|
||||
field: "security",
|
||||
defaultValue: DEFAULT_REQUESTED_SECURITY,
|
||||
}),
|
||||
requested: requestedPolicy.security,
|
||||
requestedSource: requestedPolicy.securitySource,
|
||||
host: resolved.agent.security,
|
||||
hostSource: formatHostFieldSource({
|
||||
hostPath,
|
||||
@@ -268,18 +408,13 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
}),
|
||||
effective: effectiveSecurity,
|
||||
note:
|
||||
effectiveSecurity === requestedSecurity.value
|
||||
effectiveSecurity === requestedPolicy.security
|
||||
? "requested security applies"
|
||||
: "stricter host security wins",
|
||||
},
|
||||
ask: {
|
||||
requested: requestedAsk.value,
|
||||
requestedSource: formatRequestedSource({
|
||||
sourcePath:
|
||||
requestedAsk.sourcePath === "scope" ? params.configPath : requestedAsk.sourcePath,
|
||||
field: "ask",
|
||||
defaultValue: DEFAULT_REQUESTED_ASK,
|
||||
}),
|
||||
requested: requestedPolicy.ask,
|
||||
requestedSource: requestedPolicy.askSource,
|
||||
host: resolved.agent.ask,
|
||||
hostSource: formatHostFieldSource({
|
||||
hostPath,
|
||||
@@ -288,7 +423,7 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
}),
|
||||
effective: effectiveAsk,
|
||||
note: resolveAskNote({
|
||||
requestedAsk: requestedAsk.value,
|
||||
requestedAsk: requestedPolicy.ask,
|
||||
hostAsk: resolved.agent.ask,
|
||||
effectiveAsk,
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,13 @@ let minSecurity: typeof import("./exec-approvals.js").minSecurity;
|
||||
let requireValidExecTarget: typeof import("./exec-approvals.js").requireValidExecTarget;
|
||||
let normalizeExecAsk: typeof import("./exec-approvals.js").normalizeExecAsk;
|
||||
let normalizeExecHost: typeof import("./exec-approvals.js").normalizeExecHost;
|
||||
let normalizeExecMode: typeof import("./exec-approvals.js").normalizeExecMode;
|
||||
let normalizeExecTarget: typeof import("./exec-approvals.js").normalizeExecTarget;
|
||||
let normalizeExecSecurity: typeof import("./exec-approvals.js").normalizeExecSecurity;
|
||||
let requiresExecApproval: typeof import("./exec-approvals.js").requiresExecApproval;
|
||||
let resolveExecModeFromPolicy: typeof import("./exec-approvals.js").resolveExecModeFromPolicy;
|
||||
let resolveExecModePolicy: typeof import("./exec-approvals.js").resolveExecModePolicy;
|
||||
let resolveExecPolicyForMode: typeof import("./exec-approvals.js").resolveExecPolicyForMode;
|
||||
|
||||
async function loadActualExecApprovalModules(): Promise<void> {
|
||||
vi.resetModules();
|
||||
@@ -39,9 +43,13 @@ async function loadActualExecApprovalModules(): Promise<void> {
|
||||
requireValidExecTarget = execApprovals.requireValidExecTarget;
|
||||
normalizeExecAsk = execApprovals.normalizeExecAsk;
|
||||
normalizeExecHost = execApprovals.normalizeExecHost;
|
||||
normalizeExecMode = execApprovals.normalizeExecMode;
|
||||
normalizeExecTarget = execApprovals.normalizeExecTarget;
|
||||
normalizeExecSecurity = execApprovals.normalizeExecSecurity;
|
||||
requiresExecApproval = execApprovals.requiresExecApproval;
|
||||
resolveExecModeFromPolicy = execApprovals.resolveExecModeFromPolicy;
|
||||
resolveExecModePolicy = execApprovals.resolveExecModePolicy;
|
||||
resolveExecPolicyForMode = execApprovals.resolveExecPolicyForMode;
|
||||
}
|
||||
|
||||
function expectFields(value: unknown, expected: Record<string, unknown>): void {
|
||||
@@ -137,6 +145,73 @@ describe("exec approvals policy helpers", () => {
|
||||
expect(normalizeExecAsk(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ raw: " auto ", expected: "auto" },
|
||||
{ raw: "ASK", expected: "ask" },
|
||||
{ raw: "allowlist", expected: "allowlist" },
|
||||
{ raw: "maybe", expected: null },
|
||||
])("normalizes exec mode value %j", ({ raw, expected }) => {
|
||||
expect(normalizeExecMode(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ security: "deny" as const, ask: "off" as const, expected: "deny" as const },
|
||||
{
|
||||
security: "allowlist" as const,
|
||||
ask: "off" as const,
|
||||
expected: "allowlist" as const,
|
||||
},
|
||||
{
|
||||
security: "allowlist" as const,
|
||||
ask: "on-miss" as const,
|
||||
expected: "ask" as const,
|
||||
},
|
||||
{ security: "full" as const, ask: "off" as const, expected: "full" as const },
|
||||
{ security: "full" as const, ask: "on-miss" as const, expected: "full" as const },
|
||||
{ security: "full" as const, ask: "always" as const, expected: "ask" as const },
|
||||
])("derives normalized exec mode from legacy policy %j", ({ security, ask, expected }) => {
|
||||
expect(resolveExecModeFromPolicy({ security, ask })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
mode: "deny" as const,
|
||||
expected: { security: "deny" as const, ask: "off" as const, autoReview: false },
|
||||
},
|
||||
{
|
||||
mode: "allowlist" as const,
|
||||
expected: { security: "allowlist" as const, ask: "off" as const, autoReview: false },
|
||||
},
|
||||
{
|
||||
mode: "ask" as const,
|
||||
expected: { security: "allowlist" as const, ask: "on-miss" as const, autoReview: false },
|
||||
},
|
||||
{
|
||||
mode: "auto" as const,
|
||||
expected: { security: "allowlist" as const, ask: "on-miss" as const, autoReview: true },
|
||||
},
|
||||
{
|
||||
mode: "full" as const,
|
||||
expected: { security: "full" as const, ask: "off" as const, autoReview: false },
|
||||
},
|
||||
])("maps explicit exec mode to effective policy %j", ({ mode, expected }) => {
|
||||
expect(resolveExecPolicyForMode(mode)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("preserves legacy security and ask when no explicit mode is set", () => {
|
||||
expect(
|
||||
resolveExecModePolicy({
|
||||
security: "full",
|
||||
ask: "always",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "ask",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
autoReview: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ left: "deny" as const, right: "full" as const, expected: "deny" as const },
|
||||
{
|
||||
@@ -327,6 +402,126 @@ describe("exec approvals policy helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps normalized requested mode into policy snapshots", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
},
|
||||
scopeExecConfig: {
|
||||
mode: "auto",
|
||||
},
|
||||
configPath: "tools.exec",
|
||||
scopeLabel: "tools.exec",
|
||||
});
|
||||
|
||||
expectFields(summary.mode, {
|
||||
requested: "auto",
|
||||
requestedSource: "tools.exec.mode",
|
||||
effective: "auto",
|
||||
note: "requested mode applies",
|
||||
});
|
||||
expectFields(summary.security, {
|
||||
requested: "allowlist",
|
||||
requestedSource: "tools.exec.mode",
|
||||
effective: "allowlist",
|
||||
});
|
||||
expectFields(summary.ask, {
|
||||
requested: "on-miss",
|
||||
requestedSource: "tools.exec.mode",
|
||||
effective: "on-miss",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets narrower legacy policy override a global normalized mode in snapshots", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
},
|
||||
globalExecConfig: {
|
||||
mode: "deny",
|
||||
},
|
||||
scopeExecConfig: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
configPath: "agents.list.runner.tools.exec",
|
||||
scopeLabel: "agent:runner",
|
||||
agentId: "runner",
|
||||
});
|
||||
|
||||
expectFields(summary.mode, {
|
||||
requested: "full",
|
||||
requestedSource:
|
||||
"derived from agents.list.runner.tools.exec.security and agents.list.runner.tools.exec.ask",
|
||||
effective: "full",
|
||||
});
|
||||
expectFields(summary.security, {
|
||||
requested: "full",
|
||||
requestedSource: "agents.list.runner.tools.exec.security",
|
||||
effective: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mode-derived siblings for partial narrower legacy policy snapshots", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
},
|
||||
globalExecConfig: {
|
||||
mode: "auto",
|
||||
},
|
||||
scopeExecConfig: {
|
||||
ask: "off",
|
||||
},
|
||||
configPath: "agents.list.runner.tools.exec",
|
||||
scopeLabel: "agent:runner",
|
||||
agentId: "runner",
|
||||
});
|
||||
|
||||
expectFields(summary.security, {
|
||||
requested: "allowlist",
|
||||
requestedSource: "tools.exec.mode",
|
||||
});
|
||||
expectFields(summary.ask, {
|
||||
requested: "off",
|
||||
requestedSource: "agents.list.runner.tools.exec.ask",
|
||||
});
|
||||
expectFields(summary.mode, {
|
||||
requested: "allowlist",
|
||||
effective: "allowlist",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports full plus on-miss as full because on-miss only gates allowlist misses", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
},
|
||||
globalExecConfig: {
|
||||
mode: "auto",
|
||||
},
|
||||
scopeExecConfig: {
|
||||
security: "full",
|
||||
},
|
||||
configPath: "agents.list.runner.tools.exec",
|
||||
scopeLabel: "agent:runner",
|
||||
agentId: "runner",
|
||||
});
|
||||
|
||||
expectFields(summary.security, {
|
||||
requested: "full",
|
||||
requestedSource: "agents.list.runner.tools.exec.security",
|
||||
});
|
||||
expectFields(summary.ask, {
|
||||
requested: "on-miss",
|
||||
requestedSource: "tools.exec.mode",
|
||||
});
|
||||
expectFields(summary.mode, {
|
||||
requested: "full",
|
||||
effective: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the actual approvals path when reporting host sources", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../shared/string-coerce.js";
|
||||
import type { CommandExplanationSummary } from "./command-analysis/explain.js";
|
||||
import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js";
|
||||
import type { ExecCommandSegment } from "./exec-approvals-analysis.js";
|
||||
import { analyzeShellCommand, type ExecCommandSegment } from "./exec-approvals-analysis.js";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
|
||||
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
|
||||
@@ -23,6 +23,7 @@ export type ExecHost = "sandbox" | "gateway" | "node";
|
||||
export type ExecTarget = "auto" | ExecHost;
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
export type ExecAsk = "off" | "on-miss" | "always";
|
||||
export type ExecMode = "deny" | "allowlist" | "ask" | "auto" | "full";
|
||||
|
||||
export const EXEC_TARGET_VALUES: readonly ExecTarget[] = ["auto", "sandbox", "gateway", "node"];
|
||||
|
||||
@@ -85,6 +86,82 @@ export function normalizeExecAsk(value?: string | null): ExecAsk | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeExecMode(value?: string | null): ExecMode | null {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (
|
||||
normalized === "deny" ||
|
||||
normalized === "allowlist" ||
|
||||
normalized === "ask" ||
|
||||
normalized === "auto" ||
|
||||
normalized === "full"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveExecModeFromPolicy(params: {
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
}): ExecMode {
|
||||
if (params.security === "deny") {
|
||||
return "deny";
|
||||
}
|
||||
if (params.security === "allowlist" && params.ask === "off") {
|
||||
return "allowlist";
|
||||
}
|
||||
if (params.security === "full" && params.ask !== "always") {
|
||||
return "full";
|
||||
}
|
||||
return "ask";
|
||||
}
|
||||
|
||||
export function resolveExecPolicyForMode(mode: ExecMode): {
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
autoReview: boolean;
|
||||
} {
|
||||
switch (mode) {
|
||||
case "deny":
|
||||
return { security: "deny", ask: "off", autoReview: false };
|
||||
case "allowlist":
|
||||
return { security: "allowlist", ask: "off", autoReview: false };
|
||||
case "ask":
|
||||
return { security: "allowlist", ask: "on-miss", autoReview: false };
|
||||
case "auto":
|
||||
return { security: "allowlist", ask: "on-miss", autoReview: true };
|
||||
case "full":
|
||||
return { security: "full", ask: "off", autoReview: false };
|
||||
}
|
||||
const _exhaustive: never = mode;
|
||||
void _exhaustive;
|
||||
throw new Error("Unsupported exec mode");
|
||||
}
|
||||
|
||||
export function resolveExecModePolicy(params: {
|
||||
mode?: ExecMode | null;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
}): {
|
||||
mode: ExecMode;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
autoReview: boolean;
|
||||
} {
|
||||
if (!params.mode) {
|
||||
return {
|
||||
mode: resolveExecModeFromPolicy({ security: params.security, ask: params.ask }),
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
autoReview: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: params.mode,
|
||||
...resolveExecPolicyForMode(params.mode),
|
||||
};
|
||||
}
|
||||
|
||||
export type SystemRunApprovalBinding = {
|
||||
argv: string[];
|
||||
cwd: string | null;
|
||||
@@ -1059,6 +1136,111 @@ export function requiresExecApproval(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommandName(value: string | undefined): string {
|
||||
return (value ?? "").split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function textMentionsSecurityAuditSuppressions(value: string): boolean {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes("security.audit.suppressions") ||
|
||||
/["']?security["']?[\s\S]{0,200}["']?audit["']?[\s\S]{0,200}["']?suppressions["']?/.test(
|
||||
normalized,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isReadOnlySecurityAuditSuppressionInspection(argv: string[]): boolean {
|
||||
const command = normalizeCommandName(argv[0]);
|
||||
let offset = command === "pnpm" && argv[1] === "openclaw" ? 1 : 0;
|
||||
if (normalizeCommandName(argv[offset]) !== "openclaw") {
|
||||
return false;
|
||||
}
|
||||
offset += 1;
|
||||
while (offset < argv.length) {
|
||||
const arg = argv[offset];
|
||||
if (["--dev", "--no-color"].includes(arg ?? "")) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (["--profile", "--container", "--log-level"].includes(arg ?? "")) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
arg?.startsWith("--profile=") ||
|
||||
arg?.startsWith("--container=") ||
|
||||
arg?.startsWith("--log-level=")
|
||||
) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return (
|
||||
argv[offset] === "config" && ["get", "schema", "validate"].includes(argv[offset + 1] ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function removeParsedSegmentText(command: string, segments: Array<{ raw?: string }>): string {
|
||||
let remaining = command;
|
||||
for (const segment of segments) {
|
||||
const raw = segment.raw?.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
remaining = remaining.replace(raw, " ");
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
export function commandRequiresSecurityAuditSuppressionApproval(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
segments: Array<{ argv: string[]; raw?: string }>;
|
||||
}): boolean {
|
||||
let sawSegmentMention = false;
|
||||
for (const segment of params.segments) {
|
||||
const segmentText = `${segment.raw ?? ""} ${segment.argv.join(" ")}`;
|
||||
if (!textMentionsSecurityAuditSuppressions(segmentText)) {
|
||||
continue;
|
||||
}
|
||||
sawSegmentMention = true;
|
||||
if (!isReadOnlySecurityAuditSuppressionInspection(segment.argv)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (sawSegmentMention) {
|
||||
const rawAnalysis = analyzeShellCommand({
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
platform: process.platform,
|
||||
});
|
||||
if (!rawAnalysis.ok) {
|
||||
return textMentionsSecurityAuditSuppressions(params.command);
|
||||
}
|
||||
for (const segment of rawAnalysis.segments) {
|
||||
if (
|
||||
textMentionsSecurityAuditSuppressions(`${segment.raw} ${segment.argv.join(" ")}`) &&
|
||||
!isReadOnlySecurityAuditSuppressionInspection(segment.argv)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
textMentionsSecurityAuditSuppressions(
|
||||
removeParsedSegmentText(params.command, rawAnalysis.segments),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return textMentionsSecurityAuditSuppressions(params.command);
|
||||
}
|
||||
|
||||
export function hasDurableExecApproval(params: {
|
||||
analysisOk: boolean;
|
||||
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
|
||||
|
||||
50
src/infra/exec-auto-review.test.ts
Normal file
50
src/infra/exec-auto-review.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExecAutoReviewer, type ExecAutoReviewInput } from "./exec-auto-review.js";
|
||||
|
||||
function reviewCommand(command: string, argv: string[]) {
|
||||
return defaultExecAutoReviewer({
|
||||
command,
|
||||
argv,
|
||||
host: "gateway",
|
||||
reason: "approval-required",
|
||||
analysis: {
|
||||
parsed: true,
|
||||
allowlistMatched: false,
|
||||
inlineEval: false,
|
||||
},
|
||||
} satisfies ExecAutoReviewInput);
|
||||
}
|
||||
|
||||
describe("default exec auto reviewer", () => {
|
||||
it("falls back to human approval instead of maintaining a static allowlist", () => {
|
||||
expect(reviewCommand("pwd", ["pwd"])).toMatchObject({
|
||||
decision: "ask",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["./pwd", ["./pwd"]],
|
||||
["/tmp/pwd", ["/tmp/pwd"]],
|
||||
])("does not auto-approve path-qualified pwd lookalikes: %s", (_command, argv) => {
|
||||
expect(reviewCommand(_command, argv)).toMatchObject({
|
||||
decision: "ask",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cat ~/.openclaw/credentials/model.json", ["cat", "~/.openclaw/credentials/model.json"]],
|
||||
["rg token ~/.ssh", ["rg", "token", "~/.ssh"]],
|
||||
["sed -i s/foo/bar/g file.txt", ["sed", "-i", "s/foo/bar/g", "file.txt"]],
|
||||
["git status", ["git", "status"]],
|
||||
["git branch scratch", ["git", "branch", "scratch"]],
|
||||
["git diff --output=/tmp/diff.patch", ["git", "diff", "--output=/tmp/diff.patch"]],
|
||||
["git status\nnode -e 'console.log(1)'", ["git", "status"]],
|
||||
])(
|
||||
"asks for human review on sensitive or externally influenced commands: %s",
|
||||
(_command, argv) => {
|
||||
expect(reviewCommand(_command, argv)).toMatchObject({
|
||||
decision: "ask",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
59
src/infra/exec-auto-review.ts
Normal file
59
src/infra/exec-auto-review.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ExecAutoReviewRisk = "unknown" | "low" | "medium" | "high";
|
||||
|
||||
export type ExecAutoReviewDecision =
|
||||
| {
|
||||
decision: "allow-once";
|
||||
rationale: string;
|
||||
risk: "low" | "medium" | "high";
|
||||
}
|
||||
| {
|
||||
decision: "ask";
|
||||
rationale: string;
|
||||
risk: ExecAutoReviewRisk;
|
||||
};
|
||||
|
||||
export type ExecAutoReviewHost = "gateway" | "node";
|
||||
|
||||
export type ExecAutoReviewInput = {
|
||||
command: string;
|
||||
argv?: readonly string[];
|
||||
cwd?: string | null;
|
||||
envKeys?: readonly string[];
|
||||
host: ExecAutoReviewHost;
|
||||
reason:
|
||||
| "approval-required"
|
||||
| "allowlist-miss"
|
||||
| "strict-inline-eval"
|
||||
| "heredoc"
|
||||
| "execution-plan-miss";
|
||||
analysis: {
|
||||
parsed: boolean;
|
||||
allowlistMatched: boolean;
|
||||
safeBinMatched?: boolean;
|
||||
durableApprovalMatched?: boolean;
|
||||
inlineEval: boolean;
|
||||
heredoc?: boolean;
|
||||
shellWrapper?: boolean;
|
||||
};
|
||||
agent?: {
|
||||
id?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExecAutoReviewer = (
|
||||
input: ExecAutoReviewInput,
|
||||
) => Promise<ExecAutoReviewDecision> | ExecAutoReviewDecision;
|
||||
|
||||
/**
|
||||
* Conservative fallback used when no model-backed reviewer is available.
|
||||
* Auto mode must never become a static allowlist; without a reviewer, defer to
|
||||
* the normal human approval route.
|
||||
*/
|
||||
export const defaultExecAutoReviewer: ExecAutoReviewer = (input) => {
|
||||
return {
|
||||
decision: "ask",
|
||||
rationale: `no model-backed exec reviewer is configured for ${input.host}`,
|
||||
risk: input.analysis.inlineEval ? "medium" : "unknown",
|
||||
};
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveExecApprovalsPath,
|
||||
saveExecApprovals,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import type { ExecHostResponse } from "../infra/exec-host.js";
|
||||
import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
|
||||
import { handleSystemRunInvoke } from "./invoke-system-run.js";
|
||||
@@ -290,6 +291,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProductionExecSecurity(value?: string): "deny" | "allowlist" | "full" {
|
||||
return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist";
|
||||
}
|
||||
|
||||
function resolveProductionExecAsk(value?: string): "off" | "on-miss" | "always" {
|
||||
return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss";
|
||||
}
|
||||
|
||||
function createInvokeSpies(params?: { runCommand?: MockedRunCommand }): {
|
||||
runCommand: MockedRunCommand;
|
||||
sendInvokeResult: MockedSendInvokeResult;
|
||||
@@ -450,6 +459,9 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
skillBinsCurrent?: () => Promise<Array<{ name: string; resolvedPath: string }>>;
|
||||
isCmdExeInvocation?: HandleSystemRunInvokeOptions["isCmdExeInvocation"];
|
||||
sanitizeEnv?: HandleSystemRunInvokeOptions["sanitizeEnv"];
|
||||
resolveExecSecurity?: HandleSystemRunInvokeOptions["resolveExecSecurity"];
|
||||
resolveExecAsk?: HandleSystemRunInvokeOptions["resolveExecAsk"];
|
||||
autoReviewer?: ExecAutoReviewer;
|
||||
}): Promise<{
|
||||
runCommand: MockedRunCommand;
|
||||
runViaMacAppExecHost: MockedRunViaMacAppExecHost;
|
||||
@@ -506,8 +518,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
},
|
||||
execHostEnforced: false,
|
||||
execHostFallbackAllowed: true,
|
||||
resolveExecSecurity: () => params.security ?? "full",
|
||||
resolveExecAsk: () => params.ask ?? "off",
|
||||
resolveExecSecurity: params.resolveExecSecurity ?? (() => params.security ?? "full"),
|
||||
resolveExecAsk: params.resolveExecAsk ?? (() => params.ask ?? "off"),
|
||||
isCmdExeInvocation: params.isCmdExeInvocation ?? (() => false),
|
||||
sanitizeEnv: params.sanitizeEnv ?? (() => undefined),
|
||||
runCommand,
|
||||
@@ -518,6 +530,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
sendExecFinishedEvent,
|
||||
preferMacAppExecHost: params.preferMacAppExecHost,
|
||||
getRuntimeConfig: () => getRuntimeConfigSnapshot() ?? {},
|
||||
autoReviewer: params.autoReviewer,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -572,6 +585,192 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses auto reviewer for system.run approval misses when exec mode is auto", async () => {
|
||||
const tmp = createFixtureDir("openclaw-system-run-auto-review-");
|
||||
const executablePath = createTempExecutable({ dir: tmp, name: "read-info" });
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({
|
||||
decision: "allow-once",
|
||||
rationale: "reads fixture metadata only",
|
||||
risk: "low",
|
||||
}));
|
||||
const runCommand = vi.fn(async () => createLocalRunResult("auto-reviewed"));
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: [executablePath],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const invoke = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: prepared.plan.argv,
|
||||
cwd: prepared.plan.cwd ?? tmp,
|
||||
systemRunPlan: prepared.plan,
|
||||
runCommand,
|
||||
resolveExecSecurity: resolveProductionExecSecurity,
|
||||
resolveExecAsk: resolveProductionExecAsk,
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
expect(autoReviewer).toHaveBeenCalledTimes(1);
|
||||
expect(autoReviewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: executablePath,
|
||||
argv: [executablePath],
|
||||
cwd: tmp,
|
||||
host: "node",
|
||||
reason: "approval-required",
|
||||
analysis: expect.objectContaining({
|
||||
parsed: true,
|
||||
allowlistMatched: false,
|
||||
inlineEval: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expectInvokeOk(invoke.sendInvokeResult, { payloadContains: "auto-reviewed" });
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-review direct system.run approval misses without an approval plan", async () => {
|
||||
const tmp = createFixtureDir("openclaw-system-run-auto-review-no-plan-");
|
||||
const executablePath = createTempExecutable({ dir: tmp, name: "read-info" });
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({
|
||||
decision: "allow-once",
|
||||
rationale: "reads fixture metadata only",
|
||||
risk: "low",
|
||||
}));
|
||||
const runCommand = vi.fn(async () => createLocalRunResult("should-not-run"));
|
||||
const invoke = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: [executablePath],
|
||||
cwd: tmp,
|
||||
runCommand,
|
||||
resolveExecSecurity: resolveProductionExecSecurity,
|
||||
resolveExecAsk: resolveProductionExecAsk,
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
expect(autoReviewer).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(invoke.sendInvokeResult, {
|
||||
message: "SYSTEM_RUN_DENIED: approval required",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-review direct system.run security audit suppression edits", async () => {
|
||||
const tmp = createFixtureDir("openclaw-system-run-auto-review-suppression-");
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({
|
||||
decision: "allow-once",
|
||||
rationale: "test reviewer would allow it",
|
||||
risk: "low",
|
||||
}));
|
||||
const runCommand = vi.fn(async () => createLocalRunResult("should-not-run"));
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: ["openclaw", "config", "set", "security.audit.suppressions", "[]"],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const invoke = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: prepared.plan.argv,
|
||||
cwd: prepared.plan.cwd ?? tmp,
|
||||
systemRunPlan: prepared.plan,
|
||||
runCommand,
|
||||
resolveExecSecurity: resolveProductionExecSecurity,
|
||||
resolveExecAsk: resolveProductionExecAsk,
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
expect(autoReviewer).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(invoke.sendInvokeResult, {
|
||||
message: "SYSTEM_RUN_DENIED: approval required",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
it("defers to human approval when system.run auto reviewer asks", async () => {
|
||||
const tmp = createFixtureDir("openclaw-system-run-auto-review-ask-");
|
||||
const executablePath = createTempExecutable({ dir: tmp, name: "read-info" });
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({
|
||||
decision: "ask",
|
||||
rationale: "needs a person",
|
||||
risk: "medium",
|
||||
}));
|
||||
const runCommand = vi.fn(async () => createLocalRunResult("should-not-run"));
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: [executablePath],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const invoke = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: prepared.plan.argv,
|
||||
cwd: prepared.plan.cwd ?? tmp,
|
||||
systemRunPlan: prepared.plan,
|
||||
runCommand,
|
||||
resolveExecSecurity: resolveProductionExecSecurity,
|
||||
resolveExecAsk: resolveProductionExecAsk,
|
||||
autoReviewer,
|
||||
});
|
||||
|
||||
expect(autoReviewer).toHaveBeenCalledTimes(1);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(invoke.sendInvokeResult, {
|
||||
message: "exec auto-review deferred to human approval",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
const approvedEnvShellWrapperCases = [
|
||||
{
|
||||
name: "preserves wrapper argv for approved env shell commands in local execution",
|
||||
|
||||
@@ -8,18 +8,25 @@ import {
|
||||
import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js";
|
||||
import {
|
||||
addDurableCommandApproval,
|
||||
commandRequiresSecurityAuditSuppressionApproval,
|
||||
hasDurableExecApproval,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
persistAllowAlwaysPatterns,
|
||||
recordAllowlistMatchesUse,
|
||||
resolveApprovalAuditTrustPath,
|
||||
resolveExecApprovals,
|
||||
resolveExecModePolicy,
|
||||
resolveExecPolicyForMode,
|
||||
type ExecAllowlistEntry,
|
||||
type ExecAsk,
|
||||
type ExecCommandSegment,
|
||||
type ExecMode,
|
||||
type ExecSegmentSatisfiedBy,
|
||||
type ExecSecurity,
|
||||
type SkillBinTrustEntry,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import {
|
||||
@@ -106,6 +113,7 @@ type SystemRunParsePhase = {
|
||||
type SystemRunPolicyPhase = SystemRunParsePhase & {
|
||||
approvals: ResolvedExecApprovals;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
policy: ReturnType<typeof evaluateSystemRunPolicy>;
|
||||
durableApprovalSatisfied: boolean;
|
||||
strictInlineEval: boolean;
|
||||
@@ -134,6 +142,35 @@ const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE =
|
||||
"SYSTEM_RUN_DENIED: approval script operand changed before execution";
|
||||
type ExecToolConfig = NonNullable<NonNullable<OpenClawConfig["tools"]>["exec"]>;
|
||||
|
||||
type LayeredExecPolicy = {
|
||||
mode?: ExecMode;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
};
|
||||
|
||||
function hasLegacyExecPolicyOverride(exec?: ExecToolConfig): boolean {
|
||||
return exec?.security !== undefined || exec?.ask !== undefined;
|
||||
}
|
||||
|
||||
function applyExecPolicyLayer(base: LayeredExecPolicy, layer?: ExecToolConfig): LayeredExecPolicy {
|
||||
if (!layer) {
|
||||
return base;
|
||||
}
|
||||
if (layer.mode) {
|
||||
return {
|
||||
mode: layer.mode,
|
||||
...resolveExecPolicyForMode(layer.mode),
|
||||
};
|
||||
}
|
||||
if (hasLegacyExecPolicyOverride(layer)) {
|
||||
return {
|
||||
security: layer.security ?? base.security,
|
||||
ask: layer.ask ?? base.ask,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function warnWritableTrustedDirOnce(message: string): void {
|
||||
if (safeBinTrustedDirWarningCache.has(message)) {
|
||||
return;
|
||||
@@ -173,6 +210,24 @@ function resolveAgentExecConfig(
|
||||
return entry?.tools?.exec;
|
||||
}
|
||||
|
||||
async function resolveSystemRunAutoReviewer(params: {
|
||||
opts: HandleSystemRunInvokeOptions;
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string | undefined;
|
||||
agentExec: ExecToolConfig | undefined;
|
||||
globalExec: ExecToolConfig | undefined;
|
||||
}): Promise<ExecAutoReviewer> {
|
||||
if (params.opts.autoReviewer) {
|
||||
return params.opts.autoReviewer;
|
||||
}
|
||||
const { createModelExecAutoReviewer } = await import("../agents/exec-auto-reviewer.js");
|
||||
return createModelExecAutoReviewer({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
reviewer: params.agentExec?.reviewer ?? params.globalExec?.reviewer,
|
||||
});
|
||||
}
|
||||
|
||||
export type HandleSystemRunInvokeOptions = {
|
||||
client: GatewayClient;
|
||||
params: SystemRunParams;
|
||||
@@ -199,6 +254,7 @@ export type HandleSystemRunInvokeOptions = {
|
||||
sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise<void>;
|
||||
preferMacAppExecHost: boolean;
|
||||
getRuntimeConfig?: () => OpenClawConfig;
|
||||
autoReviewer?: ExecAutoReviewer;
|
||||
};
|
||||
|
||||
async function loadSystemRunConfig(opts: HandleSystemRunInvokeOptions): Promise<OpenClawConfig> {
|
||||
@@ -257,6 +313,14 @@ async function sendSystemRunCompleted(
|
||||
});
|
||||
}
|
||||
|
||||
function argvArraysMatch(left: readonly string[] | undefined, right: readonly string[]): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.length === right.length &&
|
||||
left.every((entry, index) => entry === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
export { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
|
||||
|
||||
async function parseSystemRunPhase(
|
||||
@@ -380,17 +444,31 @@ async function evaluateSystemRunPolicyPhase(
|
||||
): Promise<SystemRunPolicyPhase | null> {
|
||||
const cfg = await loadSystemRunConfig(opts);
|
||||
const agentExec = resolveAgentExecConfig(cfg, parsed.agentId);
|
||||
const configuredSecurity = opts.resolveExecSecurity(
|
||||
agentExec?.security ?? cfg.tools?.exec?.security,
|
||||
const globalExec = cfg.tools?.exec;
|
||||
const layeredPolicy = applyExecPolicyLayer(
|
||||
applyExecPolicyLayer(
|
||||
{
|
||||
security: opts.resolveExecSecurity(undefined),
|
||||
ask: opts.resolveExecAsk(undefined),
|
||||
},
|
||||
globalExec,
|
||||
),
|
||||
agentExec,
|
||||
);
|
||||
const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
||||
const modePolicy = resolveExecModePolicy({
|
||||
mode: layeredPolicy.mode,
|
||||
security: layeredPolicy.security,
|
||||
ask: layeredPolicy.ask,
|
||||
});
|
||||
const configuredSecurity = modePolicy.security;
|
||||
const configuredAsk = modePolicy.ask;
|
||||
const approvals = resolveExecApprovals(parsed.agentId, {
|
||||
security: configuredSecurity,
|
||||
ask: configuredAsk,
|
||||
requireSocket: opts.preferMacAppExecHost,
|
||||
});
|
||||
const security = approvals.agent.security;
|
||||
const ask = approvals.agent.ask;
|
||||
const security = minSecurity(configuredSecurity, approvals.agent.security);
|
||||
const ask = maxAsk(configuredAsk, approvals.agent.ask);
|
||||
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||
const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({
|
||||
global: cfg.tools?.exec,
|
||||
@@ -436,13 +514,14 @@ async function evaluateSystemRunPolicyPhase(
|
||||
const inlineEvalExecutableTrusted =
|
||||
inlineEvalHit !== null &&
|
||||
segmentAllowlistEntries.some((entry) => entry?.source === "allow-always");
|
||||
const policy = evaluateSystemRunPolicy({
|
||||
let approvalDecision = parsed.approvalDecision;
|
||||
let policy = evaluateSystemRunPolicy({
|
||||
security,
|
||||
ask,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied: durableApprovalSatisfied || inlineEvalExecutableTrusted,
|
||||
approvalDecision: parsed.approvalDecision,
|
||||
approvalDecision,
|
||||
approved: parsed.approved,
|
||||
isWindows,
|
||||
cmdInvocation,
|
||||
@@ -450,6 +529,28 @@ async function evaluateSystemRunPolicyPhase(
|
||||
// Env sanitization uses broader shell-wrapper detection in parse phase.
|
||||
shellWrapperInvocation: parsed.shellPayload !== null,
|
||||
});
|
||||
const requiresSecurityAuditSuppressionApproval =
|
||||
commandRequiresSecurityAuditSuppressionApproval({
|
||||
command: parsed.commandText,
|
||||
cwd: parsed.cwd,
|
||||
env: parsed.env,
|
||||
segments,
|
||||
}) && !(security === "full" && ask === "off");
|
||||
if (requiresSecurityAuditSuppressionApproval && !policy.approvedByAsk) {
|
||||
policy = {
|
||||
allowed: false,
|
||||
eventReason: "approval-required",
|
||||
errorMessage: "SYSTEM_RUN_DENIED: approval required",
|
||||
analysisOk: policy.analysisOk,
|
||||
allowlistSatisfied: policy.allowlistSatisfied,
|
||||
shellWrapperBlocked: policy.shellWrapperBlocked,
|
||||
windowsShellWrapperBlocked: policy.windowsShellWrapperBlocked,
|
||||
requiresAsk: true,
|
||||
approvalDecision: policy.approvalDecision,
|
||||
approvedByAsk: policy.approvedByAsk,
|
||||
};
|
||||
}
|
||||
let autoReviewDeferredMessage: string | undefined;
|
||||
analysisOk = policy.analysisOk;
|
||||
allowlistSatisfied = policy.allowlistSatisfied;
|
||||
const strictInlineEvalRequiresApproval =
|
||||
@@ -466,10 +567,78 @@ async function evaluateSystemRunPolicyPhase(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!policy.allowed) {
|
||||
const [autoReviewSegment] = segments;
|
||||
const directAutoReviewArgvMatchesRequest =
|
||||
parsed.shellPayload !== null || argvArraysMatch(autoReviewSegment?.argv, parsed.argv);
|
||||
const autoReviewArgv =
|
||||
segments.length === 1 &&
|
||||
directAutoReviewArgvMatchesRequest &&
|
||||
(parsed.shellPayload === null ||
|
||||
(autoReviewSegment?.raw !== undefined &&
|
||||
autoReviewSegment.raw.trim() === parsed.shellPayload.trim()))
|
||||
? autoReviewSegment?.argv
|
||||
: undefined;
|
||||
const canAutoReviewApprovalMiss =
|
||||
modePolicy.autoReview &&
|
||||
ask !== "always" &&
|
||||
analysisOk &&
|
||||
autoReviewArgv !== undefined &&
|
||||
parsed.approvalPlan !== null &&
|
||||
inlineEvalHit === null &&
|
||||
!requiresSecurityAuditSuppressionApproval &&
|
||||
policy.eventReason !== "security=deny";
|
||||
if (canAutoReviewApprovalMiss) {
|
||||
const reviewer = await resolveSystemRunAutoReviewer({
|
||||
opts,
|
||||
cfg,
|
||||
agentId: parsed.agentId,
|
||||
agentExec,
|
||||
globalExec,
|
||||
});
|
||||
const decision = await reviewer({
|
||||
command: parsed.commandText,
|
||||
argv: autoReviewArgv,
|
||||
cwd: parsed.cwd,
|
||||
envKeys: Object.keys(parsed.envOverrides ?? {}).toSorted(),
|
||||
host: "node",
|
||||
reason: policy.eventReason === "allowlist-miss" ? "allowlist-miss" : "approval-required",
|
||||
analysis: {
|
||||
parsed: analysisOk,
|
||||
allowlistMatched: allowlistSatisfied,
|
||||
durableApprovalMatched: durableApprovalSatisfied,
|
||||
inlineEval: false,
|
||||
shellWrapper: parsed.shellWrapperInvocation,
|
||||
},
|
||||
agent: {
|
||||
id: parsed.agentId,
|
||||
sessionKey: parsed.sessionKey,
|
||||
},
|
||||
});
|
||||
if (decision.decision === "allow-once") {
|
||||
approvalDecision = "allow-once";
|
||||
policy = evaluateSystemRunPolicy({
|
||||
security,
|
||||
ask,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
durableApprovalSatisfied: durableApprovalSatisfied || inlineEvalExecutableTrusted,
|
||||
approvalDecision,
|
||||
approved: true,
|
||||
isWindows,
|
||||
cmdInvocation,
|
||||
shellWrapperInvocation: parsed.shellPayload !== null,
|
||||
});
|
||||
} else {
|
||||
autoReviewDeferredMessage = `${policy.errorMessage} (exec auto-review deferred to human approval: ${decision.rationale})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!policy.allowed) {
|
||||
await sendSystemRunDenied(opts, parsed.execution, {
|
||||
reason: policy.eventReason,
|
||||
message: policy.errorMessage,
|
||||
message: autoReviewDeferredMessage ?? policy.errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -520,10 +689,12 @@ async function evaluateSystemRunPolicyPhase(
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
approvalDecision,
|
||||
argv: hardenedPaths.argv,
|
||||
cwd: hardenedPaths.cwd,
|
||||
approvals,
|
||||
security,
|
||||
ask,
|
||||
policy,
|
||||
durableApprovalSatisfied,
|
||||
strictInlineEval,
|
||||
|
||||
7
src/plugin-sdk/exec-approvals-runtime.ts
Normal file
7
src/plugin-sdk/exec-approvals-runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Exec approval policy file helpers without the broad infra-runtime barrel.
|
||||
|
||||
export {
|
||||
loadExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
type ExecApprovalsFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
@@ -221,6 +221,9 @@ describe("opt-in extension package boundaries", () => {
|
||||
expect(packageJson.exports?.["./error-runtime"]?.types).toBe(
|
||||
"./dist/src/plugin-sdk/error-runtime.d.ts",
|
||||
);
|
||||
expect(packageJson.exports?.["./exec-approvals-runtime"]?.types).toBe(
|
||||
"./dist/src/plugin-sdk/exec-approvals-runtime.d.ts",
|
||||
);
|
||||
expect(packageJson.exports?.["./plugin-entry"]?.types).toBe(
|
||||
"./dist/src/plugin-sdk/plugin-entry.d.ts",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user