fix(exec): enforce allowlist argument patterns (#75143)

* fix(exec): enforce allowlist argument patterns

* fix(exec): document argPattern allowlist field

* Add CHANGELOG entry for #75143 cross-platform argPattern enforcement

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
This commit is contained in:
Agustin Rivera
2026-05-05 17:23:40 -07:00
committed by GitHub
parent ad2d13cc67
commit d583013b8f
6 changed files with 104 additions and 45 deletions

View File

@@ -372,6 +372,7 @@ Docs: https://docs.openclaw.ai
- Agents/sessions: after embedded Pi runs, append assistant-visible reply text to session JSONL only when Pi did not already persist an equivalent tail assistant entry, without re-mirroring the user prompt Pi owns. Fixes #77823. (#77839) Thanks @neeravmakwana.
- Plugins/CLI: load the install-records ledger when listing channel-catalog entries, so npm-installed third-party channel plugins resolve through `openclaw channels login`/`channels add` instead of failing with `Unsupported channel`. (#77269) Thanks @pumpkinxing1.
- Memory wiki/Security: enforce session visibility on shared-memory `wiki_search` and `wiki_get` so sandboxed subagents cannot read transcript content from sibling or parent sessions. Fixes GHSA-72fw-cqh5-f324. Thanks @zsxsoft.
- Exec approvals: enforce allowlist `argPattern` argument restrictions on Linux and macOS as well as Windows, so an entry like `{ pattern: "python3", argPattern: "^safe\\.py$" }` no longer silently relaxes to a path-only match on non-Windows hosts. (#75143) Thanks @eleqtrizit.
## 2026.5.3-1

View File

@@ -106,7 +106,7 @@ automatically.
| ---------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables |
| Match type | Executable name + safe-bin argv policy | Resolved executable path glob, or bare command-name glob for PATH-invoked commands |
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match only; arguments are otherwise your responsibility |
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match by default; optional `argPattern` can restrict parsed argv |
| Typical examples | `head`, `tail`, `tr`, `wc` | `jq`, `python3`, `node`, `ffmpeg`, custom CLIs |
| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects |

View File

@@ -299,14 +299,52 @@ Examples:
- `~/.local/bin/*`
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
### Restricting arguments with argPattern
| Field | Meaning |
| ------------------ | -------------------------------- |
| `id` | Stable UUID used for UI identity |
| `lastUsedAt` | Last-used timestamp |
| `lastUsedCommand` | Last command that matched |
| `lastResolvedPath` | Last resolved binary path |
Add `argPattern` when an allowlist entry should match a binary and a
specific argument shape. OpenClaw evaluates the regular expression
against the parsed command arguments, excluding the executable token
(`argv[0]`). For hand-authored entries, arguments are joined with a
single space, so anchor the pattern when you need an exact match.
```json
{
"version": 1,
"agents": {
"main": {
"allowlist": [
{
"pattern": "python3",
"argPattern": "^safe\\.py$"
}
]
}
}
}
```
That entry allows `python3 safe.py`; `python3 other.py` is an allowlist
miss. If a path-only entry for the same binary is also present, unmatched
arguments can still fall back to that path-only entry. Omit the path-only
entry when the goal is to restrict the binary to the declared arguments.
Entries saved by approval flows can use an internal separator format for
exact argv matching. Prefer the UI or approval flow to regenerate those
entries instead of hand-editing the encoded value. If OpenClaw cannot
parse argv for a command segment, entries with `argPattern` do not match.
Each allowlist entry supports:
| Field | Meaning |
| ------------------ | ------------------------------------------------------------- |
| `pattern` | Resolved binary path glob or bare command-name glob |
| `argPattern` | Optional argv regex; omitted entries are path-only |
| `id` | Stable UUID used for UI identity |
| `source` | Entry source, such as `allow-always` |
| `commandText` | Command text captured when an approval flow created the entry |
| `lastUsedAt` | Last-used timestamp |
| `lastUsedCommand` | Last command that matched |
| `lastResolvedPath` | Last resolved binary path |
## Auto-allow skill CLIs

View File

@@ -43,12 +43,17 @@ describe("exec allowlist matching", () => {
expect(matchAllowlist([{ pattern: "rg" }], absoluteResolution)).toBeNull();
});
it("honors Windows argPattern checks for bare command-name matches", () => {
const entries = [{ pattern: "rg", argPattern: "^--json$" }];
it.each(["linux", "darwin", "win32"])(
"honors argPattern checks for bare command-name matches on %s",
(platform) => {
const entries = [{ pattern: "rg", argPattern: "^--json$" }];
expect(matchAllowlist(entries, baseResolution, ["rg", "--json"], "win32")?.pattern).toBe("rg");
expect(matchAllowlist(entries, baseResolution, ["rg", "--files"], "win32")).toBeNull();
});
expect(matchAllowlist(entries, baseResolution, ["rg", "--json"], platform)?.pattern).toBe(
"rg",
);
expect(matchAllowlist(entries, baseResolution, ["rg", "--files"], platform)).toBeNull();
},
);
it("matches bare wildcard patterns against arbitrary resolved executables", () => {
const cases = [

View File

@@ -881,12 +881,6 @@ describe("windowsEscapeArg", () => {
});
describe("matchAllowlist with argPattern", () => {
// argPattern matching is Windows-only; skip this suite on other platforms.
if (process.platform !== "win32") {
it.skip("argPattern tests are Windows-only", () => {});
return;
}
const resolution = {
rawExecutable: "python3",
resolvedPath: "/usr/bin/python3",
@@ -907,25 +901,57 @@ describe("matchAllowlist with argPattern", () => {
expect(matchAllowlist(entries, resolution, ["python3", "a.py", "--verbose"])).toBeNull();
});
it("prefers argPattern match over path-only match", () => {
it.each(["linux", "darwin"])("enforces argPattern on %s", (platform) => {
const entries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3" },
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
{ pattern: "/usr/bin/python3", argPattern: "^safe\\.py$" },
];
const match = matchAllowlist(entries, resolution, ["python3", "a.py"]);
expect(match).toBeTruthy();
expect(match!.argPattern).toBe("^a\\.py$");
expect(matchAllowlist(entries, resolution, ["python3", "safe.py"], platform)).toBeTruthy();
expect(matchAllowlist(entries, resolution, ["python3", "-c", "print(1)"], platform)).toBeNull();
});
it("falls back to path-only match when argPattern doesn't match", () => {
const entries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3" },
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
];
const match = matchAllowlist(entries, resolution, ["python3", "b.py"]);
expect(match).toBeTruthy();
expect(match!.argPattern).toBeUndefined();
});
it.each(["linux", "darwin", "win32"])(
"prefers argPattern match over path-only match on %s",
(platform) => {
const entries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3" },
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
];
const match = matchAllowlist(entries, resolution, ["python3", "a.py"], platform);
expect(match).toBeTruthy();
expect(match!.argPattern).toBe("^a\\.py$");
},
);
it.each(["linux", "darwin", "win32"])(
"falls back to path-only match when argPattern does not match on %s",
(platform) => {
const entries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3" },
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
];
const match = matchAllowlist(entries, resolution, ["python3", "b.py"], platform);
expect(match).toBeTruthy();
expect(match!.argPattern).toBeUndefined();
},
);
it.each(["linux", "darwin", "win32"])(
"requires argv before matching argPattern entries on %s",
(platform) => {
const restrictedEntries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
];
expect(matchAllowlist(restrictedEntries, resolution, undefined, platform)).toBeNull();
const mixedEntries: ExecAllowlistEntry[] = [
{ pattern: "/usr/bin/python3", argPattern: "^a\\.py$" },
{ pattern: "/usr/bin/python3" },
];
const fallback = matchAllowlist(mixedEntries, resolution, undefined, platform);
expect(fallback).toBeTruthy();
expect(fallback!.argPattern).toBeUndefined();
},
);
it("handles invalid regex gracefully", () => {
const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3", argPattern: "[invalid" }];

View File

@@ -359,13 +359,6 @@ export function matchAllowlist(
return null;
}
const resolvedPath = resolution.resolvedPath;
// argPattern matching is currently Windows-only. On other platforms every
// path-matched entry is treated as a match regardless of argPattern, which
// preserves the pre-existing behaviour.
// Use the caller-supplied target platform rather than process.platform so that
// a Linux gateway evaluating a Windows node command applies argPattern correctly.
const effectivePlatform = platform ?? process.platform;
const useArgPattern = normalizeLowercaseStringOrEmpty(effectivePlatform).startsWith("win");
let pathOnlyMatch: ExecAllowlistEntry | null = null;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
@@ -378,10 +371,6 @@ export function matchAllowlist(
if (!patternMatches) {
continue;
}
if (!useArgPattern) {
// Non-Windows: first path match wins (legacy behaviour).
return entry;
}
if (!entry.argPattern) {
if (!pathOnlyMatch) {
pathOnlyMatch = entry;