diff --git a/CHANGELOG.md b/CHANGELOG.md index 9294e482160..c5b301fbfbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 4ede6fdf3d0..4e2d6fb1a4b 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -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 | diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 66a70f65a33..49df3a6c80f 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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 diff --git a/src/infra/exec-allowlist-matching.test.ts b/src/infra/exec-allowlist-matching.test.ts index 723a335fa0e..a1db9470b7a 100644 --- a/src/infra/exec-allowlist-matching.test.ts +++ b/src/infra/exec-allowlist-matching.test.ts @@ -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 = [ diff --git a/src/infra/exec-approvals-analysis.test.ts b/src/infra/exec-approvals-analysis.test.ts index e823b9400c8..bdd1be9983c 100644 --- a/src/infra/exec-approvals-analysis.test.ts +++ b/src/infra/exec-approvals-analysis.test.ts @@ -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" }]; diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 2e526fbf4d1..2ec3d84062f 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -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;