diff --git a/docs/codex-cloud.md b/docs/codex-cloud.md index 767cd435ad..1e0a3f9fd4 100644 --- a/docs/codex-cloud.md +++ b/docs/codex-cloud.md @@ -382,10 +382,13 @@ copying credentials into the checkout. but it cannot inject host MCP tools or retroactively grant OAuth. Restart the MCP client or open a new task after consent. When the host exposes its app identifiers, pass that non-secret inventory to the environment check with - `--hosted-app-inventory=github,railway,supabase`. The checker rejects stale `railway_cloud` and - unrecognized names (so tokens/secrets are never accepted), reports only allowlisted presence - flags in the sanitized capability lines, and leaves the inventory explicitly unverified when the - host does not provide it; repository config is never substituted for this evidence. + `--hosted-app-inventory=github,railway,supabase`. The exact `=` form is required and the option + may appear only once; every other unsupported argument fails closed. The checker rejects stale + `railway_cloud`, shared sensitive-token patterns, and identifiers outside its reviewed connector + allowlist. It normalizes connector-name case and never echoes supplied values. Invalid input + suppresses capability output rather than printing a misleading unverified state. Inventory + remains explicitly unverified when the host does not provide it; repository config is never + substituted for this evidence. 4. **Prove the shell boundary before providers.** First run the direct raw-shell command above before profiles or command shims. Then run `npm run check:codex-cloud`, `npm run check:codex-cloud -- --runtime`, `npm run check:runtime`, and diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index df3d6362da..5cf8350326 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -11,6 +11,7 @@ import { hasSafeGitHubCredentialHelper, inspectOriginRemote, } from "./ensure-codex-cloud-git-remote.mjs"; +import { redactSensitiveText } from "./sensitive-text.mjs"; import { providerEnvironmentKeys } from "./test-environment.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -230,6 +231,8 @@ export const knownHostedAppInventoryNames = Object.freeze([ "supabase", "figma", "sentry", + "slack", + "linear", "figma_cloud", "sentry_cloud", "supabase_cloud", @@ -255,6 +258,28 @@ export function parseHostedAppInventoryArgument(args) { .filter(Boolean); } +/** + * Reject malformed or ambiguous attempts to supply hosted inventory evidence. + * @param {string[]} args + * @returns {string[]} + */ +export function validateHostedAppInventoryArguments(args) { + const exactPrefix = "--hosted-app-inventory="; + const exactArguments = args.filter((value) => value.startsWith(exactPrefix)); + const supportedArguments = new Set(["--environment", "--runtime"]); + const unsupportedArguments = args.filter((value) => !supportedArguments.has(value) && !value.startsWith(exactPrefix)); + const errors = []; + if (unsupportedArguments.length > 0) { + errors.push( + "Unsupported Cloud-check argument; hosted app inventory must use exactly --hosted-app-inventory=.", + ); + } + if (exactArguments.length > 1) { + errors.push("Hosted app inventory may be supplied only once."); + } + return errors; +} + /** * Format hosted-app inventory for sanitized capability output. * Never echo arbitrary operator-supplied strings — only allowlisted presence flags. @@ -263,8 +288,9 @@ export function parseHostedAppInventoryArgument(args) { */ export function hostedAppInventoryCapabilityLine(appNames) { if (appNames === null) return "hosted_app.inventory=external-unverified-until-fresh-task"; - const set = new Set(appNames); - const unknownCount = appNames.filter( + const normalizedNames = appNames.map((name) => name.toLowerCase()); + const set = new Set(normalizedNames); + const unknownCount = normalizedNames.filter( (name) => !knownHostedAppInventoryNames.includes(name) && !staleHostedAppInventoryNames.includes(name), ).length; const parts = [ @@ -289,11 +315,18 @@ export function validateHostedAppInventory(appNames) { errors.push("Hosted app inventory was supplied but contained no app names."); return errors; } - const invalidNames = appNames.filter((name) => !/^[A-Za-z0-9_.-]+$/.test(name)); + const normalizedNames = appNames.map((name) => name.toLowerCase()); + const invalidNames = normalizedNames.filter((name) => !/^[A-Za-z0-9_.-]+$/.test(name)); if (invalidNames.length > 0) { errors.push("Hosted app inventory names may contain only letters, numbers, dot, underscore, and hyphen."); } - const unrecognizedNames = appNames.filter( + const credentialShapedNames = appNames.filter((name) => redactSensitiveText(name) !== name || name.length > 128); + if (credentialShapedNames.length > 0) { + errors.push( + "Hosted app inventory appears to contain a credential; supply connector names only, never tokens or secrets.", + ); + } + const unrecognizedNames = normalizedNames.filter( (name) => /^[A-Za-z0-9_.-]+$/.test(name) && !knownHostedAppInventoryNames.includes(name) && @@ -301,10 +334,10 @@ export function validateHostedAppInventory(appNames) { ); if (unrecognizedNames.length > 0) { errors.push( - `Hosted app inventory contains unrecognized app names; supply only connector names (${knownHostedAppInventoryNames.join(", ")}), never tokens or secrets.`, + "Hosted app inventory contains unrecognized connector identifiers; update the checker allowlist before accepting them as evidence.", ); } - if (appNames.some((name) => staleHostedAppInventoryNames.includes(name))) { + if (normalizedNames.some((name) => staleHostedAppInventoryNames.includes(name))) { errors.push( "Hosted app inventory contains stale railway_cloud; remove or reconnect that host-local app, then start a fresh task and supply the new inventory.", ); @@ -1025,8 +1058,12 @@ export async function validateCodexCloudRuntime(env = process.env) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const errors = validateCodexCloudSetup(); - const hostedAppInventory = parseHostedAppInventoryArgument(process.argv.slice(2)); - errors.push(...validateHostedAppInventory(hostedAppInventory)); + const commandArguments = process.argv.slice(2); + const hostedAppArgumentErrors = validateHostedAppInventoryArguments(commandArguments); + const hostedAppInventory = parseHostedAppInventoryArgument(commandArguments); + const hostedAppInventoryErrors = validateHostedAppInventory(hostedAppInventory); + const hostedAppInputErrors = [...hostedAppArgumentErrors, ...hostedAppInventoryErrors]; + errors.push(...hostedAppInputErrors); const runtime = process.argv.includes("--runtime"); const environment = runtime || process.env.CODEX_CLOUD === "1" || process.argv.includes("--environment"); if (runtime) errors.push(...(await validateCodexCloudRuntime())); @@ -1040,7 +1077,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me "[Codex Cloud Check] WARN: checkout freshness is unverified during provisioning; run explicit acceptance with CODEX_CLOUD_EXPECTED_BASE_SHA.", ); } - if (environment) { + if (environment && hostedAppInputErrors.length === 0) { console.log("[Codex Cloud Environment] sanitized effective modes and capabilities:"); for (const line of sanitizedCloudCapabilityLines(process.env, { hostedAppInventory })) console.log(` ${line}`); } diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index b546b3a4ac..39d8b0d636 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -25,6 +25,7 @@ import { validateCodexCloudEnvironment, validateCodexProjectMcpConfiguration, validateHostedAppInventory, + validateHostedAppInventoryArguments, validateMcpConfiguration, } from "../scripts/check-codex-cloud-setup.mjs"; import { providerEnvironmentKeys } from "../scripts/test-environment.mjs"; @@ -446,15 +447,25 @@ describe("Codex Cloud environment contract", () => { "Hosted app inventory contains stale railway_cloud; remove or reconnect that host-local app, then start a fresh task and supply the new inventory.", ); expect(validateHostedAppInventory(["github", "railway", "supabase"])).toEqual([]); - const secretShaped = "sk-abcdef0123456789tokenvalue"; + expect(validateHostedAppInventory(["GitHub", "Slack", "Linear"])).toEqual([]); + expect(validateHostedAppInventory(["github", "custom-app"])).toContain( + "Hosted app inventory contains unrecognized connector identifiers; update the checker allowlist before accepting them as evidence.", + ); + const secretShaped = ["xoxb", "12345678"].join("-"); expect(validateHostedAppInventory(["github", secretShaped])).toContain( - "Hosted app inventory contains unrecognized app names; supply only connector names (github, railway, supabase, figma, sentry, figma_cloud, sentry_cloud, supabase_cloud), never tokens or secrets.", + "Hosted app inventory appears to contain a credential; supply connector names only, never tokens or secrets.", + ); + const jwtShaped = [["eyJ", "abcdefgh"].join(""), "ijklmnop", "qrstuvwx"].join("."); + expect(validateHostedAppInventory(["github", jwtShaped])).toContain( + "Hosted app inventory appears to contain a credential; supply connector names only, never tokens or secrets.", ); - const capabilityLine = hostedAppInventoryCapabilityLine(["github", "railway", "supabase"]); - expect(capabilityLine).toContain("hosted_app.inventory=provided count=3"); + const capabilityLine = hostedAppInventoryCapabilityLine(["GitHub", "railway", "supabase", "Slack", "Linear"]); + expect(capabilityLine).toContain("hosted_app.inventory=provided count=5"); expect(capabilityLine).toContain("github=true"); expect(capabilityLine).toContain("railway=true"); expect(capabilityLine).toContain("supabase=true"); + expect(capabilityLine).toContain("slack=true"); + expect(capabilityLine).toContain("linear=true"); expect(capabilityLine).toContain("stale_railway_cloud=false"); expect(capabilityLine).toContain("unknown=0"); const leaked = hostedAppInventoryCapabilityLine(["github", secretShaped]); @@ -463,6 +474,29 @@ describe("Codex Cloud environment contract", () => { expect( sanitizedCloudCapabilityLines({}, { hostedAppInventory: ["github", secretShaped] }).join("\n"), ).not.toContain(secretShaped); + expect(validateHostedAppInventoryArguments(["--hosted-app-inventory", "github,railway"])).toContain( + "Unsupported Cloud-check argument; hosted app inventory must use exactly --hosted-app-inventory=.", + ); + expect(validateHostedAppInventoryArguments(["--hosted-app-inventroy=github,railway"])).toContain( + "Unsupported Cloud-check argument; hosted app inventory must use exactly --hosted-app-inventory=.", + ); + expect( + validateHostedAppInventoryArguments(["--hosted-app-inventory=github", "--hosted-app-inventory=railway"]), + ).toContain("Hosted app inventory may be supplied only once."); + expect(validateHostedAppInventoryArguments(["--environment", "--runtime"])).toEqual([]); + + const malformedCli = spawnSync( + process.execPath, + [ + fileURLToPath(new URL("../scripts/check-codex-cloud-setup.mjs", import.meta.url)), + "--environment", + "--hosted-app-inventroy=github", + ], + { cwd: repoRoot, encoding: "utf8", env: { PATH: process.env.PATH, NODE_ENV: "test" } }, + ); + expect(malformedCli.status).toBe(1); + expect(malformedCli.stderr).toContain("Unsupported Cloud-check argument"); + expect(malformedCli.stdout).not.toContain("hosted_app.inventory="); }); it("probes the raw task environment without printing credential values", () => {