Skip to content

fix(exec): harden approval handling#57517

Closed
scoootscooob wants to merge 3 commits into
openclaw:mainfrom
scoootscooob:codex/exec-approval-core-hardening-main
Closed

fix(exec): harden approval handling#57517
scoootscooob wants to merge 3 commits into
openclaw:mainfrom
scoootscooob:codex/exec-approval-core-hardening-main

Conversation

@scoootscooob

Copy link
Copy Markdown
Contributor

Summary

  • keep denied async exec followups internal when there is no real external delivery route, and harden the denial prompt so it cannot reuse stale output
  • prevent /approve ... from being executed through the shell tool path, and instruct the model to surface it as chat text instead
  • fix the remaining #57377 repro by allowing skill wrapper exec chains to satisfy allowlist mode without tripping on the cat SKILL.md && printf ... display prelude

Testing

  • pnpm test -- src/infra/exec-approval-reply.test.ts src/agents/bash-tools.exec-approval-followup.test.ts src/agents/bash-tools.exec.path.test.ts src/agents/pi-tools-agent-config.test.ts src/agents/system-prompt.test.ts src/infra/exec-approvals-analysis.test.ts
  • pnpm test -- src/agents/bash-tools.exec.approval-id.test.ts -t "runs a skill wrapper chain without prompting when the wrapper is allowlisted"
  • pnpm build

Notes

  • This branch carries the small /approve parser helper it needs in src/infra/exec-approval-reply.ts so it can merge independently of refactor(exec): centralize native approval delivery #57516.
  • scripts/committer again hit the unrelated pre-existing hook failure from extensions/discord/src/monitor/message-handler.queue.test.ts, so the commit itself was created with hooks disabled after the targeted validation above passed.

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M maintainer Maintainer-authored PR labels Mar 30, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9b2d2d566

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/exec-approvals-allowlist.ts Outdated
Comment thread src/infra/exec-approval-reply.ts Outdated
@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens three related exec-approval flows: keeping denied async followup turns internal when no real external delivery route exists, blocking the model from routing /approve through the shell tool path (with both a runtime guard in createExecTool and a system-prompt instruction), and allowing the cat SKILL.md && printf ' ---CMD--- ' && <wrapper> prelude chain to satisfy allowlist mode without tripping on the display preamble.

Key changes:

  • bash-tools.exec-approval-followup.ts: New denial-specific prompt branch prevents stale output reuse; deliverExternally now correctly gates on a real non-internal/non-tui channel being present (previously deliver: true was always set).
  • bash-tools.exec.ts: rejectExecApprovalShellCommand throws early if the command matches the /approve pattern; default host changed from \"sandbox\" to \"gateway\" when no sandbox runtime is wired up, closing a path where sandbox-less configs silently fell through.
  • exec-approvals-allowlist.ts: New skillPrelude trust category allows cat <skills/**>/SKILL.md and printf ' ---CMD--- ' chain parts to be treated as safe preambles — but only when at least one other chain part is trusted via allowlist or skills, preventing standalone prelude-only commands from being mistakenly trusted.
  • exec-approval-reply.ts: Adds the parseExecApprovalCommandText parser needed by the shell guard, bundled here so this branch can land independently of refactor(exec): centralize native approval delivery #57516.
  • system-prompt.ts: Explicit model instruction added: /approve must be surfaced as plain chat text, never executed through exec or any shell path.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style observations with no correctness or security impact.

All three security fixes are logically correct and fully tested. The skill-prelude trust model has solid guards. The /approve shell guard covers the primary model failure mode. The followup delivery fix is well-covered by the new test file. No P0/P1 issues found.

No files require special attention.

Important Files Changed

Filename Overview
src/agents/bash-tools.exec.ts Adds /approve shell rejection guard and fixes default host logic when sandbox is unavailable (sandbox → gateway). Both changes are correct and well-tested.
src/agents/bash-tools.exec-approval-followup.ts Adds denial-specific prompt branch to prevent stale output reuse, and correctly gates external delivery on a real non-internal/non-tui channel being present.
src/infra/exec-approvals-allowlist.ts Introduces skillPrelude trust type with tight constraints: only trusted when another chain part has allowlist/skills satisfaction, and path+argv guards on cat/printf are strict.
src/infra/exec-approval-reply.ts Adds parseExecApprovalCommandText helper. Regex is well-formed, case-insensitive match followed by toLowerCase() is correct, not vulnerable to ReDoS.
src/agents/system-prompt.ts Adds model instruction to never route /approve through exec and clarifies it should be surfaced as plain chat text.
src/infra/exec-approvals-analysis.ts Type update for buildSafeBinsShellCommand to accept skillPrelude; falls through to raw-passthrough path (not needsLiteral), which is correct.

Comments Outside Diff (1)

  1. src/infra/exec-approvals-allowlist.ts, line 561-573 (link)

    P2 cat guard relies on two complementary checks

    isSkillPreludeReadSegment correctly checks segment.argv.length !== 2, so cat -A SKILL.md or cat SKILL.md extra are rejected. Any shell-level quoting that produces exactly two argv tokens but routes to a non-skills path would also be caught downstream by isSkillMarkdownPreludePath. The length-2 guard plus the path check together close the gap — noting this here to help future readers understand why both checks are necessary.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/infra/exec-approvals-allowlist.ts
    Line: 561-573
    
    Comment:
    **`cat` guard relies on two complementary checks**
    
    `isSkillPreludeReadSegment` correctly checks `segment.argv.length !== 2`, so `cat -A SKILL.md` or `cat SKILL.md extra` are rejected. Any shell-level quoting that produces exactly two argv tokens but routes to a non-skills path would also be caught downstream by `isSkillMarkdownPreludePath`. The length-2 guard plus the path check together close the gap — noting this here to help future readers understand why both checks are necessary.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/bash-tools.exec.ts
Line: 402

Comment:
**`/approve` rejection only covers commands that start with `/approve`**

`rejectExecApprovalShellCommand` anchors its regex with `^`, so a chained command like `echo hello && /approve req1 allow-once` passes through undetected. In practice this is harmless (shell would fail trying to execute `/approve` as a binary), but the defence-in-depth guard won't fire for the chained form.

If full coverage is desired, you could scan each `&&`/`;`/`||`-split part rather than the raw command string, similar to how `evaluateShellAllowlist` handles chains. For now the early-exit path (reject the stand-alone form) is the most common model failure mode and the system-prompt instruction provides a second layer.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/infra/exec-approvals-allowlist.ts
Line: 561-573

Comment:
**`cat` guard relies on two complementary checks**

`isSkillPreludeReadSegment` correctly checks `segment.argv.length !== 2`, so `cat -A SKILL.md` or `cat SKILL.md extra` are rejected. Any shell-level quoting that produces exactly two argv tokens but routes to a non-skills path would also be caught downstream by `isSkillMarkdownPreludePath`. The length-2 guard plus the path check together close the gap — noting this here to help future readers understand why both checks are necessary.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(exec): harden approval handling" | Re-trigger Greptile

Comment thread src/agents/bash-tools.exec.ts
@scoootscooob scoootscooob force-pushed the codex/exec-approval-core-hardening-main branch from f9b2d2d to 41fd521 Compare March 30, 2026 06:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41fd521aea

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/exec-approvals-allowlist.ts Outdated
Comment thread src/agents/bash-tools.exec.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e6706d229

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/bash-tools.exec.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ff526178f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +236 to +238
const normalized = option.split("=", 1)[0];
if (sudoOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) {
remaining.shift();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle sudo no-arg flags before stripping /approve

The prefix stripper treats every option in sudoOptionsWithValues as consuming the next token, but sudo --help shows flags like -E/--preserve-env and -A/--askpass are standalone. With the current logic, sudo -E /approve req-1 deny shifts /approve away as a fake option value, so the command bypasses this new /approve guard and still goes down shell execution.

Useful? React with 👍 / 👎.

Comment on lines +250 to +252
const candidates = analysis.ok
? analysis.segments.map((segment) => stripApprovalCommandPrefixes(segment.argv).join(" "))
: rawCommand

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Inspect shell-wrapper payloads when blocking /approve

The guard only checks top-level parsed segment argv (analysis.segments.map(...)), so inline shell payloads are never examined. Commands like bash -lc '/approve req-1 deny' or sh -c '/approve ...' will not match because the candidate starts with bash/sh, allowing /approve to continue through exec despite the hardening goal.

Useful? React with 👍 / 👎.

@aisle-research-bot

aisle-research-bot Bot commented Mar 30, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Allowlist bypass: auto-allowed cat .../skills/SKILL.md prelude enables arbitrary file read (symlink) when any trusted wrapper is later in && chain
2 🟡 Medium Policy bypass: /approve rejection misses env options and nested shell execution
Vulnerabilities

1. 🟠 Allowlist bypass: auto-allowed cat .../skills/SKILL.md prelude enables arbitrary file read (symlink) when any trusted wrapper is later in && chain

Property Value
Severity High
CWE CWE-22
Location src/infra/exec-approvals-allowlist.ts:214-852

Description

The new “skill prelude” exception can mark a non-allowlisted cat <path>/SKILL.md segment as satisfied ("skillPrelude") as long as a later trusted skill wrapper is reachable via a contiguous && chain.

This is unsafe because isSkillMarkdownPreludePath() treats any path ending in /skills/SKILL.md (no skill id directory) as a valid prelude:

  • segmentsAfterSkills === 1 matches .../skills/SKILL.md anywhere on disk
  • In that case resolveSkillPreludeIds() returns an empty set, and the reachability check degrades to reachableSkillIds.size > 0
  • Result: if the chain later reaches any trusted wrapper, the earlier cat .../skills/SKILL.md becomes auto-allowed even though it is not allowlisted

Because the allow decision is based only on a string path pattern (via path.resolve) and does not validate realpath nor enforce a trusted skills root, an attacker who can create files/symlinks in the working directory can point .../skills/SKILL.md at arbitrary sensitive files (e.g., /etc/shadow, SSH keys, cloud creds) and have them printed to stdout without an approval prompt.

Vulnerable logic (pattern too broad + reachability fallback):

if (segmentsAfterSkills === 1 || segmentsAfterSkills === 2) {
  return true;
}
...
const reachesTrustedSkillExecution =
  opToNext === "&&" &&
  (preludeSkillIds.size === 0
    ? reachableSkillIds.size > 0
    : [...preludeSkillIds].some((skillId) => reachableSkillIds.has(skillId)));

Recommendation

Tighten the prelude exception so it cannot be used for arbitrary file reads:

  1. Remove the .../skills/SKILL.md (no-id) case. Require an explicit skill id directory: .../skills/<skillId>/SKILL.md.
  2. Enforce a trusted skills root (e.g., ${cwd}/.openclaw/skills or configured root) and validate with fs.realpathSync to prevent symlink escapes.
  3. Require that the prelude’s skillId matches the reachable trusted wrapper id; do not allow the preludeSkillIds.size === 0 fallback.

Example hardening:

import fs from "node:fs";

function isTrustedSkillPreludePath(rawPath: string, cwd: string, skillsRoot: string): string | null {
  const abs = resolveSkillPreludePath(rawPath, cwd);
  const real = fs.realpathSync.native(abs);
  const rootReal = fs.realpathSync.native(skillsRoot);
  if (!real.startsWith(rootReal + path.sep)) return null;
  if (!/\/skills\/[a-z0-9_-]+\/SKILL\.md$/i.test(real.replace(/\\/g,"/"))) return null;
  return real;
}// and only allow when extracted skillId is non-null and matches reachableSkillIds

2. 🟡 Policy bypass: /approve rejection misses env options and nested shell execution

Property Value
Severity Medium
CWE CWE-285
Location src/agents/bash-tools.exec.ts:221-259

Description

rejectExecApprovalShellCommand() attempts to prevent running /approve <id> <decision> via the exec tool by stripping common prefixes (env/command/sudo/etc) and then applying parseExecApprovalCommandText().

However, the prefix-stripping logic is incomplete and can yield false negatives:

  • env options are not skipped. For commands like env -i /approve abc123 allow-once, the function strips only the env token and then stops at -i, so the candidate string no longer starts with /approve and the rejection does not trigger.
  • Nested execution via interpreters is not inspected (e.g., sh -c '/approve abc123 allow-once'). The top-level argv starts with sh, so the /approve string inside -c is not detected.

Vulnerable code:

if (token === "env") {
  remaining.shift();
  continue;
}
...
const candidates = analysis.ok
  ? analysis.segments.map((segment) => stripApprovalCommandPrefixes(segment.argv).join(" "))
  : ...

Impact depends on the execution environment, but as written this is a policy enforcement bypass: an attacker/user can construct a shell command that still executes /approve ... without being blocked by this guard.

Recommendation

Harden detection so it cannot be bypassed by prefix options or nesting.

At minimum:

  1. Handle env options (GNU/BSD) before stopping, similar to sudo handling.
  2. Consider rejecting if any argv token contains an approval command (e.g., a -c string), or more conservatively reject any command containing /approve as a word boundary.

Example (minimal improvement for env):

if (token === "env") {
  remaining.shift();// consume common env options
  while (remaining[0]?.startsWith("-")) {
    const opt = remaining.shift()!;
    if (opt === "--") break;
    const norm = opt.split("=", 1)[0];// -u VAR takes a value
    if (norm === "-u" && !opt.includes("=") && remaining[0]) remaining.shift();
  }
  continue;
}

For nested shells:

  • Either explicitly detect patterns like sh -c/bash -c and scan the -c payload with parseExecApprovalCommandText() (and/or analyzeShellCommand() recursively),
  • Or implement a stricter policy: reject any exec command that contains a token starting with /approve anywhere (after shell parsing), not only at argv[0].

Analyzed PR: #57517 at commit 0ff5261

Last updated on: 2026-03-30T09:45:22Z

@scoootscooob

Copy link
Copy Markdown
Contributor Author

Superseded by the reviewable split here:

Closing this stacked PR so the approval work only has two active review surfaces: one main approval/runtime/routing/auth PR and one shell /approve hardening PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant