diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index dbcd4a4c06d..78e4b45044b 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -41,10 +41,6 @@ runs: shell: bash run: npm install --ignore-scripts - - name: Enforce base-trusted createRequire allowlist ratchet - shell: bash - run: npx tsx "$GITHUB_ACTION_PATH/create-require-ratchet.mts" - - name: Validate config schemas shell: bash run: npm run validate:configs diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index c0677c8f704..57b9a9dbb4f 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -121,13 +121,17 @@ function isPolicyBuilderModule( ); } -function requireModuleSpecifier(expression: ts.Expression | undefined): string | null { +function requireModuleSpecifier( + expression: ts.Expression | undefined, + checker: ts.TypeChecker, +): string | null { if ( !expression || !ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || expression.expression.text !== "require" || - expression.arguments.length !== 1 + expression.arguments.length !== 1 || + checker.getSymbolAtLocation(expression.expression) ) { return null; } @@ -143,7 +147,7 @@ function collectRequiredPolicyBindings( identifiers: Set, namespaces: Set, ): void { - const moduleSpecifier = requireModuleSpecifier(declaration.initializer); + const moduleSpecifier = requireModuleSpecifier(declaration.initializer, checker); if (!moduleSpecifier || !isPolicyBuilderModule(fileName, moduleSpecifier, repoRoot)) return; if (ts.isIdentifier(declaration.name)) { const symbol = checker.getSymbolAtLocation(declaration.name); @@ -258,7 +262,39 @@ function literalText(expression: ts.Expression): string | null { return ts.isStringLiteralLike(expression) ? expression.text : null; } -function isDirectPolicyRead(expression: ts.ArrayLiteralExpression): boolean { +function isCanonicalOpenshellResolverCall( + expression: ts.Expression, + fileName: string, + repoRoot: string, + checker: ts.TypeChecker, +): boolean { + if ( + !ts.isCallExpression(expression) || + !ts.isIdentifier(expression.expression) || + expression.expression.text !== "resolveOpenshellBinary" || + expression.arguments.length !== 0 || + path.resolve(fileName) !== path.resolve(repoRoot, "src/lib/policy/commands.ts") + ) { + return false; + } + const symbol = checker.getSymbolAtLocation(expression.expression); + return ( + symbol?.declarations?.some( + (declaration) => + ts.isFunctionDeclaration(declaration) && + ts.isSourceFile(declaration.parent) && + declaration.name?.text === "resolveOpenshellBinary" && + path.resolve(declaration.getSourceFile().fileName) === path.resolve(fileName), + ) === true + ); +} + +function isDirectPolicyRead( + expression: ts.ArrayLiteralExpression, + fileName: string, + repoRoot: string, + checker: ts.TypeChecker, +): boolean { const first = expression.elements[0]; if (!first || !ts.isExpression(first)) return false; const firstText = literalText(first); @@ -266,7 +302,7 @@ function isDirectPolicyRead(expression: ts.ArrayLiteralExpression): boolean { firstText === "policy" ? 0 : firstText === "openshell" || - (ts.isCallExpression(first) && calledName(first.expression) === "resolveOpenshellBinary") + isCanonicalOpenshellResolverCall(first, fileName, repoRoot, checker) ? 1 : -1; if (offset < 0) return false; @@ -295,7 +331,10 @@ export function countPolicyReadCalls( isPolicyBuilderCall(node.expression, builderBindings, checker) ) { readCalls += 1; - } else if (ts.isArrayLiteralExpression(node) && isDirectPolicyRead(node)) { + } else if ( + ts.isArrayLiteralExpression(node) && + isDirectPolicyRead(node, fileName, repoRoot, checker) + ) { readCalls += 1; } ts.forEachChild(node, visit); diff --git a/test/policy-mutation-read-discovery.test.ts b/test/policy-mutation-read-discovery.test.ts index e9c00e34747..c6e2ac102e5 100644 --- a/test/policy-mutation-read-discovery.test.ts +++ b/test/policy-mutation-read-discovery.test.ts @@ -52,6 +52,51 @@ describe("OpenShell policy mutation read discovery (#6921)", () => { expect(countPolicyReadCalls(source, "/repo/src/lib/fixture.ts", "/repo")).toBe(0); }); + it("ignores locally shadowed CommonJS require and OpenShell resolver decoys", () => { + const source = [ + "const require = () => ({ buildPolicyGetCommand: () => [] });", + 'const { buildPolicyGetCommand } = require("./policy");', + "function resolveOpenshellBinary() { return 'openshell'; }", + "buildPolicyGetCommand(sandboxName);", + '[resolveOpenshellBinary(), "policy", "get", "--base", sandboxName];', + ].join("\n"); + + expect(countPolicyReadCalls(source, "/repo/src/lib/fixture.ts", "/repo")).toBe(0); + }); + + it("ignores a nested resolver shadow in the canonical policy command module", () => { + const source = [ + "function resolveOpenshellBinary() { return 'openshell'; }", + "function inspect(resolveOpenshellBinary: () => string) {", + ' return [resolveOpenshellBinary(), "policy", "get", "--base", sandboxName];', + "}", + ].join("\n"); + + expect(countPolicyReadCalls(source, "/repo/src/lib/policy/commands.ts", "/repo")).toBe(0); + }); + + it("ignores a nested resolver function in the canonical policy command module", () => { + const source = [ + "function resolveOpenshellBinary() { return 'openshell'; }", + "function inspect() {", + " function resolveOpenshellBinary() { return 'decoy'; }", + ' return [resolveOpenshellBinary(), "policy", "get", "--base", sandboxName];', + "}", + ].join("\n"); + + expect(countPolicyReadCalls(source, "/repo/src/lib/policy/commands.ts", "/repo")).toBe(0); + }); + + it("counts the canonical policy command resolver arrays", () => { + const source = [ + "function resolveOpenshellBinary() { return 'openshell'; }", + 'const base = [resolveOpenshellBinary(), "policy", "get", "--base", sandboxName];', + 'const full = [resolveOpenshellBinary(), "policy", "get", "--full", sandboxName];', + ].join("\n"); + + expect(countPolicyReadCalls(source, "/repo/src/lib/policy/commands.ts", "/repo")).toBe(2); + }); + it("ignores a named policy builder import when a nested binding shadows its alias", () => { const source = [ 'import { buildPolicyGetCommand as buildBase } from "./policy/commands";', diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index f798daea7be..13b851eabba 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -1074,6 +1074,9 @@ describe("pull request and main workflow contracts", () => { expect(trustedRatchet.run).toBe( 'node --experimental-strip-types "$GITHUB_ACTION_PATH/create-require-ratchet.mts"', ); + expect(stepRuns(sharedActions.staticChecks)).not.toContain( + 'npx tsx "$GITHUB_ACTION_PATH/create-require-ratchet.mts"', + ); expect( requiredStepIndex( sharedActions.staticChecks,