Skip to content

Commit 05917bd

Browse files
ARHAEEMclaude
andcommitted
fix(build): parameterize the symlink policy so verify-pins survives no-install (24)
assertSafeSymlinks unconditionally realpathSync'd the workspace node_modules root, so the advisory verify-binary-pins.yml job — which deliberately skips `pnpm install` — threw ENOENT on a clean checkout and failed PR #19's verify-pins check. Replace the implicit global allowlist with an explicit policy every call site must state: OWN_ROOT_ONLY (foreign tarball/cache trees and the digest generator's scratch extraction — only the tree's own canonical root may be a link target) vs ALLOW_WORKSPACE_NODE_MODULES (installed pnpm packages, where the workspace node_modules root is additionally a legitimate target). A missing node_modules under ALLOW_WORKSPACE_NODE_MODULES is now tolerated — absence just means that allowance doesn't apply, not a crash. Wire the correct policy into all four call sites (vendor-platform-packages.mjs x2, prepare-package-deps.mjs, record-native-binary-digests.mjs) and add scripts/safe-symlinks.test.mjs (node:test, 12 cases) covering both policies, the missing-node_modules case, and genuine escape attempts under each policy that must still be refused. Wired into `pnpm test` via a new `test:scripts` script. Also harden verify-binary-pins.yml: add `permissions: contents: read`, and extend its path triggers to scripts/safe-symlinks.mjs, scripts/vendor-platform-packages.mjs, and package.json so a future change to the check itself re-runs the job. The advisory-not-gate framing in the workflow's header comment is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fbd9487 commit 05917bd

7 files changed

Lines changed: 325 additions & 27 deletions

.github/workflows/verify-binary-pins.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ on:
6464
- 'scripts/native-binary-digests.json' # the pins themselves
6565
- 'scripts/record-native-binary-digests.mjs' # HOW digests are produced
6666
- 'scripts/vsix-targets.mjs' # WHICH packages are expected at all
67+
- 'scripts/safe-symlinks.mjs' # the symlink-escape guard the check relies on
68+
- 'scripts/vendor-platform-packages.mjs' # tarball fetch/verify/vendor logic the check imports
69+
- 'package.json' # the `check:binary-digests` script definition itself
70+
71+
permissions:
72+
contents: read
6773

6874
jobs:
6975
verify-pins:

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"check:binary-digests": "node scripts/record-native-binary-digests.mjs --check",
1212
"check:tool-sync": "node scripts/check-tool-sync.mjs",
1313
"check:bundle-sync": "node scripts/check-bundle-sync.mjs",
14-
"test": "node scripts/check-tool-sync.mjs && pnpm -F shared test && pnpm -F language-services test && pnpm -F airtable-user-mcp test && pnpm -F webview test && pnpm -F airtable-formula test",
14+
"test:scripts": "node --test scripts/safe-symlinks.test.mjs",
15+
"test": "node scripts/check-tool-sync.mjs && pnpm test:scripts && pnpm -F shared test && pnpm -F language-services test && pnpm -F airtable-user-mcp test && pnpm -F webview test && pnpm -F airtable-formula test",
1516
"packx": "pnpm -F airtable-formula packx",
1617
"packx:no-bump": "pnpm -F airtable-formula packx:no-bump",
1718
"version:bump": "pnpm -F airtable-formula version:bump",

scripts/prepare-package-deps.mjs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
2525
import { createRequire } from 'node:module';
2626
import { ALL_TARGETS, hostTarget, isPlatformPackage, targetConfig } from './vsix-targets.mjs';
2727
import { vendorPlatformPackagesForTarget } from './vendor-platform-packages.mjs';
28-
import { assertSafeSymlinks } from './safe-symlinks.mjs';
28+
import { assertSafeSymlinks, SYMLINK_POLICY } from './safe-symlinks.mjs';
2929

3030
const __dirname = dirname(fileURLToPath(import.meta.url));
3131
const require = createRequire(import.meta.url);
@@ -98,8 +98,11 @@ function resolvePackageRoot(packageName) {
9898
}
9999

100100
// The symlink-escape guard `copyPackage` applies before every dereferencing
101-
// copy now lives in `safe-symlinks.mjs`, so the foreign-platform vendoring path
102-
// (`vendor-platform-packages.mjs`) applies the identical check.
101+
// copy now lives in `safe-symlinks.mjs`, shared with the foreign-platform
102+
// vendoring path (`vendor-platform-packages.mjs`) — though the two apply
103+
// different policies: this path resolves an installed pnpm package (whose own
104+
// links legitimately point into the workspace node_modules .pnpm store),
105+
// while the foreign-tarball path allows only that tree's own root.
103106

104107
/**
105108
* Resolve + safety-check + copy a single package into dist/node_modules.
@@ -117,7 +120,9 @@ function copyPackage(packageName) {
117120
return false;
118121
}
119122

120-
assertSafeSymlinks(realSource);
123+
// Resolved from an installed pnpm package: its own dependency links
124+
// legitimately resolve into the workspace node_modules .pnpm store.
125+
assertSafeSymlinks(realSource, SYMLINK_POLICY.ALLOW_WORKSPACE_NODE_MODULES);
121126

122127
const target = join(extensionNodeModules, packageName);
123128
// dereference: true — follow symlinks and copy real files, required for

scripts/record-native-binary-digests.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
findNodeBinaries,
5757
sha256Hex,
5858
} from './vendor-platform-packages.mjs';
59-
import { assertSafeSymlinks } from './safe-symlinks.mjs';
59+
import { assertSafeSymlinks, SYMLINK_POLICY } from './safe-symlinks.mjs';
6060

6161
const __dirname = dirname(fileURLToPath(import.meta.url));
6262
const OUT_FILE = join(__dirname, 'native-binary-digests.json');
@@ -85,8 +85,12 @@ async function digestsFor(name, scratch) {
8585
// hash THAT, pinning a digest of something we never unpacked. Unreachable
8686
// today (the tarball is byte-identical to what the registry published, per the
8787
// integrity gate above), but this is the routine that mints the trust root for
88-
// every later check, so it should not rely on an upstream guarantee.
89-
assertSafeSymlinks(unpacked);
88+
// every later check, so it should not rely on an upstream guarantee. This is
89+
// a throwaway scratch extraction, not an installed package, so only its own
90+
// root is an allowed link target — and, notably, this call never touches
91+
// node_modules at all, which is what lets `--check` run in the advisory
92+
// verify-pins CI job that deliberately skips `pnpm install`.
93+
assertSafeSymlinks(unpacked, SYMLINK_POLICY.OWN_ROOT_ONLY);
9094

9195
const manifest = JSON.parse(readFileSync(join(unpacked, 'package.json'), 'utf8'));
9296
if (manifest.name !== name || manifest.version !== version) {

scripts/safe-symlinks.mjs

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,42 @@
88
* inside the published artifact.
99
*
1010
* Before copying, walk the tree (following directory symlinks, cycle-safe) and
11-
* require every symlink to resolve inside an allowed root. pnpm's legitimate
12-
* nested-dependency links all resolve into <workspace>/node_modules/.pnpm, so
13-
* that path plus the tree's own root is the default allowlist.
11+
* require every symlink to resolve inside an allowed root. There are two
12+
* legitimately different sets of allowed roots, so the caller must say which
13+
* one applies — there is no default, because silently picking one back into a
14+
* single implicit global is exactly the mistake this replaces:
15+
*
16+
* OWN_ROOT_ONLY — for a foreign tarball/cache tree (registry tarballs
17+
* unpacked by `tar`, which happily creates whatever symlinks the archive
18+
* asks for) or the digest generator's own throwaway scratch extraction.
19+
* Only the tree's own canonical root may be a link target; nothing
20+
* legitimate ever needs to point further out.
21+
*
22+
* ALLOW_WORKSPACE_NODE_MODULES — for a tree that IS (or is copied from) an
23+
* installed pnpm package. pnpm's store layout legitimately nests real
24+
* dependency links that resolve into the workspace `node_modules` root
25+
* (the `.pnpm` virtual store), so that root is additionally allowed.
1426
*
1527
* Lives here rather than in `prepare-package-deps.mjs` so the foreign-package
1628
* path (`vendor-platform-packages.mjs`, which unpacks registry tarballs with
17-
* `tar` — and `tar` happily creates symlinks) gets the identical guard the
18-
* non-foreign path already had.
29+
* `tar`) gets the identical walking/comparison logic the installed-package
30+
* path already had — only the allowed-roots policy differs between them.
1931
*/
2032

21-
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
33+
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs';
2234
import { dirname, join, sep } from 'node:path';
2335
import { fileURLToPath } from 'node:url';
2436

2537
const __dirname = dirname(fileURLToPath(import.meta.url));
2638

2739
const workspaceRoot = realpathSync(join(__dirname, '..'));
2840

41+
/** The two symlink policies a call site can choose. See file header. */
42+
export const SYMLINK_POLICY = Object.freeze({
43+
OWN_ROOT_ONLY: 'own-root-only',
44+
ALLOW_WORKSPACE_NODE_MODULES: 'allow-workspace-node-modules',
45+
});
46+
2947
// Windows paths are case-insensitive; normalize before prefix comparison.
3048
const normalizeForCompare = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
3149

@@ -36,14 +54,46 @@ const isWithin = (child, parent) => {
3654
};
3755

3856
/**
39-
* Throw if any symlink under `rootDir` resolves outside `rootDir` or the
40-
* workspace `node_modules` tree, or is broken.
57+
* Throw if any symlink under `rootDir` resolves outside the roots `policy`
58+
* allows, or is broken.
59+
*
60+
* `policy` is required — one of `SYMLINK_POLICY.OWN_ROOT_ONLY` or
61+
* `SYMLINK_POLICY.ALLOW_WORKSPACE_NODE_MODULES`; see the file header for which
62+
* one a given call site needs.
63+
*
64+
* `workspaceNodeModulesRoot` is a test-only override for the workspace
65+
* `node_modules` path `ALLOW_WORKSPACE_NODE_MODULES` consults; production call
66+
* sites never pass it and get the real workspace root.
4167
*/
42-
export function assertSafeSymlinks(rootDir) {
43-
// Both roots must be canonicalized — symlink targets are compared after
44-
// realpathSync, so an un-resolved allowlist entry (workspace itself behind
45-
// a symlink, e.g. a git worktree) would reject legitimate pnpm links.
46-
const allowedRoots = [realpathSync(rootDir), realpathSync(join(workspaceRoot, 'node_modules'))];
68+
export function assertSafeSymlinks(
69+
rootDir,
70+
policy,
71+
{ workspaceNodeModulesRoot = join(workspaceRoot, 'node_modules') } = {}
72+
) {
73+
if (policy !== SYMLINK_POLICY.OWN_ROOT_ONLY && policy !== SYMLINK_POLICY.ALLOW_WORKSPACE_NODE_MODULES) {
74+
throw new Error(
75+
'assertSafeSymlinks: a policy is required — pass SYMLINK_POLICY.OWN_ROOT_ONLY or ' +
76+
`SYMLINK_POLICY.ALLOW_WORKSPACE_NODE_MODULES (got: ${JSON.stringify(policy)})`
77+
);
78+
}
79+
80+
// The root must be canonicalized — symlink targets are compared after
81+
// realpathSync, so an un-resolved allowlist entry (e.g. the workspace
82+
// itself behind a symlink, as in a git worktree) would reject legitimate
83+
// pnpm links.
84+
const allowedRoots = [realpathSync(rootDir)];
85+
86+
if (policy === SYMLINK_POLICY.ALLOW_WORKSPACE_NODE_MODULES) {
87+
// A no-install checkout (e.g. the advisory verify-pins CI job, which
88+
// deliberately skips `pnpm install` because it needs only Node builtins,
89+
// the repo's scripts, the lockfile and `tar`) has no node_modules at all.
90+
// Absence is not a violation — it just means this allowance does not
91+
// apply, and the tree is held to its own root only.
92+
if (existsSync(workspaceNodeModulesRoot)) {
93+
allowedRoots.push(realpathSync(workspaceNodeModulesRoot));
94+
}
95+
}
96+
4797
const visited = new Set();
4898
const stack = [realpathSync(rootDir)];
4999

@@ -70,7 +120,7 @@ export function assertSafeSymlinks(rootDir) {
70120
}
71121
if (!allowedRoots.some((root) => isWithin(resolved, root))) {
72122
throw new Error(
73-
`Refusing to package: symlink escapes workspace node_modules:\n ${entryPath}\n → ${resolved}`
123+
`Refusing to package: symlink escapes its allowed root:\n ${entryPath}\n → ${resolved}`
74124
);
75125
}
76126
// dereference:true copies the target's contents too — keep walking

0 commit comments

Comments
 (0)