Skip to content

fix(exec): harden shell-side approval guardrails#57839

Merged
scoootscooob merged 8 commits into
openclaw:mainfrom
scoootscooob:codex/exec-shell-hardening
Mar 30, 2026
Merged

fix(exec): harden shell-side approval guardrails#57839
scoootscooob merged 8 commits into
openclaw:mainfrom
scoootscooob:codex/exec-shell-hardening

Conversation

@scoootscooob

@scoootscooob scoootscooob commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Summary

This is the shell-side follow-up to #57838.

It keeps the shell guardrail work separate from the channel approval runtime work and focuses only on preventing exec from treating /approve as a runnable shell command, including when it is hidden behind wrapper forms.

Problem

Before this branch, the shell guardrail was too narrow:

  • direct /approve handling was not consistently aligned with wrapped forms
  • wrapper patterns like sudo, sudo -E, bash -lc, bash -c, and sh -c could obscure approval commands from the shell-side rejection path
  • the parsing and allowlist analysis around these cases were not strict enough

That is a different problem from channel approval delivery and auth. It belongs in its own PR.

What changed

  • keep /approve shell detection local to src/agents/bash-tools.exec.ts
  • recurse through wrapped shell forms so nested cases like bash -c 'sudo /approve ...' are still identified
  • tighten src/infra/exec-approvals-allowlist.ts and src/infra/exec-approvals-analysis.ts around wrapped approval-command handling
  • update the focused shell approval tests and surrounding guidance for direct, wrapped, and nested wrapped /approve forms
  • restore the missing allowlist wrapper import exposed by the rebase onto current main

Why this is separate from #57838

  • different risk surface: shell parsing and exec guardrails, not channel approval delivery
  • easier to review and reason about independently
  • easier to revert independently if a shell parsing edge case shows up later

Verification

  • pnpm build
  • pnpm test -- src/agents/bash-tools.exec-approval-followup.test.ts src/agents/bash-tools.exec.approval-id.test.ts src/agents/bash-tools.exec.path.test.ts src/agents/system-prompt.test.ts src/infra/exec-approvals-analysis.test.ts

Supersedes

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: L maintainer Maintainer-authored PR labels Mar 30, 2026
@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 Arbitrary file read via symlinked skills//SKILL.md allowed by skill prelude allowlist relaxation
2 🟡 Medium Allowlist suffix-based trust misclassification enables skill prelude allowlist bypass (cat/printf)
1. 🟠 Arbitrary file read via symlinked skills//SKILL.md allowed by skill prelude allowlist relaxation
Property Value
Severity High
CWE CWE-59
Location src/infra/exec-approvals-allowlist.ts:205-273

Description

The new skillPrelude exception in evaluateShellAllowlist() treats cat <...>/SKILL.md (and a marker printf) as implicitly approved when they precede a trusted/allowlisted skill wrapper via &&.

However, isSkillPreludeReadSegment() only checks that the path string resolves to something that looks like .../skills/<id>/SKILL.md and does not verify that the resolved file is actually inside the expected skills directory after resolving symlinks.

As a result, if an attacker can create/modify files in the workspace (including creating symlinks), they can point skills/<id>/SKILL.md at an arbitrary target (e.g. /etc/passwd, $HOME/.ssh/id_rsa, cloud credentials) and have it read and printed via an otherwise-approved command chain:

  • input: shell command containing cat <workspace>/skills/<id>/SKILL.md && ... && <allowlisted wrapper>
  • bypass: allowlist evaluation marks the cat segment as skillPrelude even if cat is not allowlisted/safeBins
  • sink: cat reads the symlink target and outputs it to stdout (potentially captured/logged/transmitted by the agent)

Vulnerable code:

return isSkillMarkdownPreludePath(resolveSkillPreludePath(rawPath, cwd));

This only enforces a pathname pattern; it does not enforce filesystem containment.

Recommendation

Treat skill prelude reads as safe only when the target file is verified to be within the skills directory after resolving symlinks.

Recommended hardening:

  1. Compute an expected skills root (e.g. path.resolve(cwd, "skills") or an explicit configured skills directory).
  2. Resolve the candidate path and then resolve symlinks using fs.realpathSync.native() (or async equivalent).
  3. Ensure the real path is under the real skills root (prefix check with path separators).
  4. Optionally require the basename to be exactly SKILL.md and disallow skills/SKILL.md (no id) if not intended.

Example:

import fs from "node:fs";

function isUnderDir(child: string, parent: string): boolean {
  const rel = path.relative(parent, child);
  return !!rel && !rel.startsWith(".." + path.sep) && rel !== "..";
}

function isSafeSkillPreludePath(rawPath: string, cwd?: string): boolean {
  const candidate = resolveSkillPreludePath(rawPath, cwd);
  const skillsRoot = path.resolve(cwd?.trim() || process.cwd(), "skills");
  const realCandidate = fs.realpathSync.native(candidate);
  const realSkillsRoot = fs.realpathSync.native(skillsRoot);
  return (
    realCandidate.toLowerCase().endsWith(path.sep + "skill.md") &&
    isUnderDir(realCandidate, realSkillsRoot)
  );
}

If symlink support is required, consider disallowing symlinks for SKILL.md altogether (e.g., via lstat + isSymbolicLink) to prevent exfiltration.

2. 🟡 Allowlist suffix-based trust misclassification enables skill prelude allowlist bypass (cat/printf)
Property Value
Severity Medium
CWE CWE-285
Location src/infra/exec-approvals-allowlist.ts:319-357

Description

evaluateShellAllowlist() introduces a skillPrelude exception that allows previously-non-allowlisted cat <...>/SKILL.md and printf '\n---CMD---\n' segments to run without prompting when the command chain later reaches a “trusted skill wrapper”.

However, resolveTrustedSkillExecutionIds() treats any allowlisted executable whose (normalized) basename ends with -wrapper as a trusted skill wrapper by deriving a skillId from the basename.

Impact:

  • If an allowlist entry matches an arbitrary binary named *-wrapper (including a user-controlled path, a symlink, or a broad allowlist pattern), the chain will be considered to “reach a trusted skill wrapper”.
  • This can allow cat of .../skills/<derived-skillId>/SKILL.md (and the printf marker) to execute without approval even though cat/printf are not allowlisted.
  • Because the check is based on path string shape only, an attacker who can influence the filesystem could place skills/<id>/SKILL.md as a symlink to other readable files, turning the prelude into an unintended file-read/exfiltration primitive.

Vulnerable logic:

  • Trust is granted based solely on normalizeExecutableToken(...).endsWith('-wrapper'), not on a verified wrapper identity/path/trust plan.

Recommendation

Do not infer “trusted skill wrapper” status from the executable basename.

Safer options:

  1. Only treat wrappers as trusted when they are satisfied by the skills trust mechanism (i.e., satisfiedBy === 'skills'), and remove the allowlist *-wrapper suffix inference.

  2. If allowlisted wrappers must be supported, require stronger verification, for example:

  • Resolve the executable to a real path and verify it is within an expected, immutable skill-wrapper directory, and
  • Verify the wrapper corresponds to a known skill id (from a registry), and
  • Avoid symlink-based confusion by checking realpath and optionally file ownership/permissions.

Example tightening (conceptual):

if (satisfiedBy === 'skills') {// existing behavior
} else {// do NOT treat generic allowlist hits as trusted wrappers
}

Additionally, consider hardening the prelude file rule:

  • Verify the target SKILL.md is a regular file under an expected skills root (using realpath), rejecting symlinks/out-of-root paths.

Analyzed PR: #57839 at commit 4aeb5d0

Last updated on: 2026-03-30T20:53:48Z

Latest run failed. Keeping previous successful results. Trace ID: 019d40d2e2bfc69e9bc0550d240c4366.

Last updated on: 2026-03-31T07:21:19Z

Latest run failed. Keeping previous successful results. Trace ID: 019d40df8c866985a43e28aa597046e0.

Last updated on: 2026-03-31T08:08:07Z

@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR tightens shell-side /approve command rejection by introducing rejectExecApprovalShellCommand in bash-tools.exec.ts, which strips well-known command prefixes (env, sudo, command/builtin/exec, shell wrappers via -c/-lc etc.) before checking for a hidden /approve invocation. It also adds a skillPrelude allowlist segment type and a backwards chain-reachability pass for the skill-wrapper prelude pattern. The system prompt is updated with matching guidance.

Key findings:

  • P1 – Nested wrapper bypass undetected: buildCandidates appends the raw string returned by extractShellWrapperPayload directly to the candidate list. Because parseExecApprovalShellCommand only matches strings beginning with /approve, the combination bash -c 'sudo /approve abc123 allow-once' evades rejection. Fix: re-parse each wrapper payload with splitShellArgs and recurse into buildCandidates.
  • Skill-prelude chain-evaluation logic in exec-approvals-allowlist.ts is correct and well-tested.
  • Test infrastructure improvements in bash-tools.exec-approval-followup.test.ts (top-level vi.mock + afterEach reset) are a net improvement.

Confidence Score: 4/5

Not safe to merge as-is: a bash-c-plus-sudo nested form bypasses the new rejection guard.

One P1 finding: buildCandidates does not recursively apply prefix-stripping to the string extracted from a shell wrapper payload, allowing bash -c 'sudo /approve ...' to evade detection. All other changes are correct and well-tested.

src/agents/bash-tools.exec.ts (buildCandidates / extractShellWrapperPayload interaction); src/agents/bash-tools.exec.path.test.ts (missing nested wrapper test case)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/bash-tools.exec.ts
Line: 287-292

Comment:
**Nested wrapper bypass not caught**

`buildCandidates` appends the strings returned by `extractShellWrapperPayload` directly to the candidates list, but `parseExecApprovalShellCommand` only matches strings that start with `/approve`. When both a shell wrapper and a sudo prefix are combined — e.g. `bash -c 'sudo /approve abc123 allow-once'` — the inner payload extracted by `extractShellWrapperPayload` is `"sudo /approve abc123 allow-once"`. Because this string starts with `sudo` rather than `/approve`, the regex check does not fire and the command slips through.

Tracing the current path for `bash -c 'sudo /approve abc123 allow-once'`:
1. `analyzeShellCommand` produces a segment with `argv = ["bash", "-c", "sudo /approve abc123 allow-once"]`.
2. `stripApprovalCommandPrefixes` leaves `["bash", "-c", "sudo /approve abc123 allow-once"]` unchanged (bash is not a prefix token).
3. `extractShellWrapperPayload` detects `bash` + `-c` and returns `["sudo /approve abc123 allow-once"]` as a raw string.
4. `parseExecApprovalShellCommand("sudo /approve abc123 allow-once")` tests `^\/approve…` — no match → no rejection.

The fix is to re-parse each payload string from `extractShellWrapperPayload` with `splitShellArgs` and pass the resulting argv back through `buildCandidates` recursively:

```
  const buildCandidates = (argv: string[]): string[] => {
    const stripped = stripApprovalCommandPrefixes(argv);
    if (stripped.length === 0) {
      return [];
    }
    const nestedCandidates = extractShellWrapperPayload(stripped).flatMap((payload) => {
      const innerArgv = splitShellArgs(payload);
      return innerArgv ? buildCandidates(innerArgv) : [payload];
    });
    return [stripped.join(" "), ...nestedCandidates];
  };
```

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

Reviews (1): Last reviewed commit: "fix(exec): isolate shell approval guardr..." | Re-trigger Greptile

Comment thread src/agents/bash-tools.exec.ts Outdated
@scoootscooob scoootscooob changed the title fix(exec): harden /approve shell handling fix(exec): reject wrapped /approve shell commands Mar 30, 2026
@scoootscooob scoootscooob force-pushed the codex/exec-shell-hardening branch from 84c0805 to 4aeb5d0 Compare March 30, 2026 20:43
@scoootscooob scoootscooob changed the title fix(exec): reject wrapped /approve shell commands fix(exec): harden shell-side approval guardrails Mar 30, 2026
@scoootscooob

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 4aeb5d0b98

ℹ️ 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: 372dd5fc37

ℹ️ 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
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: 9c24696c2e

ℹ️ 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".

if (!rawPath) {
return false;
}
return isSkillMarkdownPreludePath(resolveSkillPreludePath(rawPath, cwd));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve SKILL.md realpath before prelude allowlisting

The new skill-prelude exception trusts a cat <...>/SKILL.md path based only on its string form, so a symlinked skills/<id>/SKILL.md can point outside the skill tree (for example to a sensitive file) and still be auto-allowed when chained to a trusted wrapper. Because this path check never resolves the filesystem target, allowlist mode can now approve arbitrary file reads under the skillPrelude branch in environments where an attacker (or untrusted repo content) can place such a symlink.

Useful? React with 👍 / 👎.

Comment on lines +296 to +297
if (token === "env") {
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 Normalize wrapper executable names in /approve rejection

The shell-side /approve blocker only recognizes the literal token env, so path-qualified wrappers like /usr/bin/env -i /approve <id> deny bypass the guard and reach shell execution. env --help explicitly documents env ... [COMMAND [ARG]...], so this form still executes the wrapped command; the parser should normalize wrapper tokens (e.g., basename) before deciding whether to strip wrapper prefixes.

Useful? React with 👍 / 👎.

@scoootscooob scoootscooob merged commit dd9d0bd into openclaw:main Mar 30, 2026
8 checks passed
pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
Tardisyuan pushed a commit to Tardisyuan/openclaw that referenced this pull request Apr 30, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
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