fix(exec): harden shell-side approval guardrails#57839
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Arbitrary file read via symlinked skills//SKILL.md allowed by skill prelude allowlist relaxation
DescriptionThe new However, As a result, if an attacker can create/modify files in the workspace (including creating symlinks), they can point
Vulnerable code: return isSkillMarkdownPreludePath(resolveSkillPreludePath(rawPath, cwd));This only enforces a pathname pattern; it does not enforce filesystem containment. RecommendationTreat skill prelude reads as safe only when the target file is verified to be within the skills directory after resolving symlinks. Recommended hardening:
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 2. 🟡 Allowlist suffix-based trust misclassification enables skill prelude allowlist bypass (cat/printf)
Description
However, Impact:
Vulnerable logic:
RecommendationDo not infer “trusted skill wrapper” status from the executable basename. Safer options:
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:
Analyzed PR: #57839 at commit Last updated on: 2026-03-30T20:53:48Z Latest run failed. Keeping previous successful results. Trace ID: Last updated on: 2026-03-31T07:21:19Z Latest run failed. Keeping previous successful results. Trace ID: Last updated on: 2026-03-31T08:08:07Z |
Greptile SummaryThis PR tightens shell-side Key findings:
Confidence Score: 4/5Not 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 AIThis 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 |
84c0805 to
4aeb5d0
Compare
|
@codex review |
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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)); |
There was a problem hiding this comment.
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 👍 / 👎.
| if (token === "env") { | ||
| remaining.shift(); |
There was a problem hiding this comment.
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 👍 / 👎.
* 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
* 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
* 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
* 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
* 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
* 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
* 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
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
/approveas a runnable shell command, including when it is hidden behind wrapper forms.Problem
Before this branch, the shell guardrail was too narrow:
/approvehandling was not consistently aligned with wrapped formssudo,sudo -E,bash -lc,bash -c, andsh -ccould obscure approval commands from the shell-side rejection pathThat is a different problem from channel approval delivery and auth. It belongs in its own PR.
What changed
/approveshell detection local tosrc/agents/bash-tools.exec.tsbash -c 'sudo /approve ...'are still identifiedsrc/infra/exec-approvals-allowlist.tsandsrc/infra/exec-approvals-analysis.tsaround wrapped approval-command handling/approveformsmainWhy this is separate from #57838
Verification
pnpm buildpnpm 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.tsSupersedes