Skip to content

Commit 704edc5

Browse files
fix(exec/approvals): match executable realpath against allowlist patterns (#45595)
1 parent 1e1fe80 commit 704edc5

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

src/infra/exec-allowlist-matching.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,55 @@ describe("exec allowlist matching", () => {
9797
expect(matchAllowlist([{ pattern }], resolution)?.pattern).toBe(pattern);
9898
}
9999
});
100+
101+
describe("symlink/realpath dual matching (#45595)", () => {
102+
const symlinkResolution = {
103+
rawExecutable: "rg",
104+
resolvedPath: "/opt/homebrew/bin/rg",
105+
resolvedRealPath: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg",
106+
executableName: "rg",
107+
};
108+
109+
it("matches when the allowlist entry pins the real-binary canonical path", () => {
110+
// Operator allowlists the real binary (the path execution actually pins
111+
// to via fs.realpath). The PATH-resolved symlink does not literally match
112+
// the pattern, but the realpath should still satisfy the entry — otherwise
113+
// every Homebrew/nix/asdf-style binary on the host is unnecessarily blocked.
114+
const match = matchAllowlist(
115+
[{ pattern: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg" }],
116+
symlinkResolution,
117+
);
118+
expect(match?.pattern).toBe("/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg");
119+
});
120+
121+
it("still matches when the allowlist entry pins the symlink path", () => {
122+
// Backward compatibility: existing allowlists that pin the symlink keep
123+
// working (the resolvedPath branch fires first, so resolvedRealPath is
124+
// not even consulted).
125+
const match = matchAllowlist([{ pattern: "/opt/homebrew/bin/rg" }], symlinkResolution);
126+
expect(match?.pattern).toBe("/opt/homebrew/bin/rg");
127+
});
128+
129+
it("does not match when neither path is in the allowlist", () => {
130+
const match = matchAllowlist(
131+
[{ pattern: "/usr/local/bin/something-else" }],
132+
symlinkResolution,
133+
);
134+
expect(match).toBeNull();
135+
});
136+
137+
it("does not double-consult realpath when the symlink and realpath are identical", () => {
138+
// Resolution where realpath === resolvedPath (no symlink indirection).
139+
// Match should succeed via the resolvedPath branch and not loop on the
140+
// realpath branch (which is short-circuited via the strict-equality guard).
141+
const direct = {
142+
rawExecutable: "ls",
143+
resolvedPath: "/usr/bin/ls",
144+
resolvedRealPath: "/usr/bin/ls",
145+
executableName: "ls",
146+
};
147+
const match = matchAllowlist([{ pattern: "/usr/bin/ls" }], direct);
148+
expect(match?.pattern).toBe("/usr/bin/ls");
149+
});
150+
});
100151
});

src/infra/exec-command-resolution.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,19 @@ export function matchAllowlist(
359359
return null;
360360
}
361361
const resolvedPath = resolution.resolvedPath;
362+
// The runtime always pins execution to `resolvedRealPath` (via fs.realpath)
363+
// when present. If only `resolvedPath` participates in allowlist matching,
364+
// an operator who allowlists the canonical real-binary path (e.g. the file
365+
// under `/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg`) sees their allowlist
366+
// miss every single Homebrew/nix/asdf-style symlinked binary, while an
367+
// operator who allowlists the symlink (e.g. `/opt/homebrew/bin/rg`) keeps
368+
// approving the *symlink path* even after the symlink target changes.
369+
// Match both paths through every path-shaped pattern so approvals stay
370+
// semantically tied to the real binary that actually runs (#45595).
371+
const resolvedRealPath =
372+
resolution.resolvedRealPath && resolution.resolvedRealPath !== resolvedPath
373+
? resolution.resolvedRealPath
374+
: undefined;
362375
// argPattern matching is currently Windows-only. On other platforms every
363376
// path-matched entry is treated as a match regardless of argPattern, which
364377
// preserves the pre-existing behaviour.
@@ -373,7 +386,9 @@ export function matchAllowlist(
373386
continue;
374387
}
375388
const patternMatches = hasPathSelector(pattern)
376-
? matchesExecAllowlistPattern(pattern, resolvedPath)
389+
? matchesExecAllowlistPattern(pattern, resolvedPath) ||
390+
(resolvedRealPath !== undefined &&
391+
matchesExecAllowlistPattern(pattern, resolvedRealPath))
377392
: pattern !== "*" && matchesExecutableBasenamePattern(pattern, resolution);
378393
if (!patternMatches) {
379394
continue;

0 commit comments

Comments
 (0)