diff --git a/.github/workflows/ai-proposal-live-conformance.yml b/.github/workflows/ai-proposal-live-conformance.yml index e61843d7b..825e872d2 100644 --- a/.github/workflows/ai-proposal-live-conformance.yml +++ b/.github/workflows/ai-proposal-live-conformance.yml @@ -18,7 +18,6 @@ concurrency: cancel-in-progress: false env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true CONTEXTUAL_ORCHESTRATOR_COMMIT: 6841b71935e0b7cb98fb52bcb4709cc5100c8d87 PROVIDER_BASE_URL: https://integrate.api.nvidia.com/v1 PROVIDER_ALLOWED_HOST: integrate.api.nvidia.com @@ -47,11 +46,15 @@ jobs: steps: - name: Checkout LifeOS uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -142,6 +145,10 @@ jobs: - name: Checkout pinned contextual-orchestrator if: env.LIVE_ENABLED == 'true' && steps.models.outputs.configured == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: repository: ContextualWisdomLab/contextual-orchestrator ref: ${{ env.CONTEXTUAL_ORCHESTRATOR_COMMIT }} diff --git a/.github/workflows/appguardrail.yml b/.github/workflows/appguardrail.yml index b8db82d43..3ffeb130d 100644 --- a/.github/workflows/appguardrail.yml +++ b/.github/workflows/appguardrail.yml @@ -30,10 +30,13 @@ jobs: security-events: write env: APPGUARDRAIL_COMMIT: 4e4f6c34e4e9640ba769020e9ab4a6ebee07bab1 - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout LifeOS exact contributor head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -45,6 +48,10 @@ jobs: - name: Checkout pinned AppGuardrail uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: repository: ContextualWisdomLab/appguardrail ref: 4e4f6c34e4e9640ba769020e9ab4a6ebee07bab1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c86718012..ab226516e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,11 @@ jobs: timeout-minutes: 10 steps: - name: Checkout exact contributor head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -69,13 +73,17 @@ jobs: --health-retries 5 steps: - name: Checkout exact contributor head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -119,13 +127,17 @@ jobs: --health-retries 5 steps: - name: Checkout exact contributor head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -179,13 +191,17 @@ jobs: timeout-minutes: 15 steps: - name: Checkout exact contributor head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -261,7 +277,11 @@ jobs: printf 'current_base=%s\n' "$current_base" >> "$GITHUB_OUTPUT" - name: Checkout exact current contributor head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false fetch-depth: 0 @@ -307,7 +327,7 @@ jobs: fi - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 diff --git a/.github/workflows/commercial-readiness.yml b/.github/workflows/commercial-readiness.yml index 786e8e0bb..0679ec41a 100644 --- a/.github/workflows/commercial-readiness.yml +++ b/.github/workflows/commercial-readiness.yml @@ -17,7 +17,6 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true EVIDENCE_DIR: commercial-readiness-evidence jobs: @@ -34,12 +33,16 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -117,11 +120,15 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -158,11 +165,15 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f763ec9c4..c115e5262 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,7 +38,11 @@ jobs: timeout-minutes: 10 steps: - name: Checkout exact revision - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false @@ -84,7 +88,11 @@ jobs: WEB_ORIGIN: ${{ inputs.web_origin }} steps: - name: Checkout exact revision - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: persist-credentials: false diff --git a/.github/workflows/opencode-commercial-development.yml b/.github/workflows/opencode-commercial-development.yml index 3e85ef2d0..76cfc75eb 100644 --- a/.github/workflows/opencode-commercial-development.yml +++ b/.github/workflows/opencode-commercial-development.yml @@ -12,7 +12,6 @@ concurrency: cancel-in-progress: false env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true OPENCODE_PACKAGE_VERSION: '1.18.9' POLICY_PATH: product/opencode-commercial-development-policy.json NIM_BRIDGE_PORT: '8765' @@ -50,13 +49,17 @@ jobs: steps: - name: Checkout exact main source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main with: ref: main fetch-depth: 0 persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cc4bd7e..4723df269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to LifeOS are documented in this file. ### Changed +- Persistent GitHub workflows now use immutable Node-24-backed checkout and setup-node releases directly, removing the temporary runtime-forcing compatibility switch while preserving exact checkout and credential boundaries. - Production contextual-orchestrator proposal requests now explicitly use adaptive `auto` mode and avoid provider-native structured-output passthrough, allowing the orchestration plane to meet the quality requirement and then minimize known cost while LifeOS retains strict fail-closed proposal validation. ### Added diff --git a/apps/ai-service/src/proposal-quality-live-workflow.test.ts b/apps/ai-service/src/proposal-quality-live-workflow.test.ts index ec2807342..bb1160814 100644 --- a/apps/ai-service/src/proposal-quality-live-workflow.test.ts +++ b/apps/ai-service/src/proposal-quality-live-workflow.test.ts @@ -54,7 +54,7 @@ describe('NVIDIA NIM live conformance workflow contract', () => { 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', ); expect(workflow).toContain( - 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020', + 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020', ); expect(workflow).toContain( 'actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97', diff --git a/packages/commercial-development-agent/src/node24-actions-contract.test.mjs b/packages/commercial-development-agent/src/node24-actions-contract.test.mjs new file mode 100644 index 000000000..2263a81e1 --- /dev/null +++ b/packages/commercial-development-agent/src/node24-actions-contract.test.mjs @@ -0,0 +1,525 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(process.cwd(), '../..'); +const workflowPaths = [ + '.github/workflows/ci.yml', + '.github/workflows/deploy.yml', + '.github/workflows/commercial-readiness.yml', + '.github/workflows/ai-proposal-live-conformance.yml', + '.github/workflows/opencode-commercial-development.yml', + '.github/workflows/appguardrail.yml', +]; +const workflows = Object.fromEntries( + workflowPaths.map((path) => [ + path, + readFileSync(resolve(repositoryRoot, path), 'utf8'), + ]), +); +const checkoutNode24 = + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'; +const setupNode24 = + 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020'; +const checkoutNode20 = + 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262'; +const setupNode20 = + 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'; + +function expectReviewedActionPins(path, workflow) { + expect(workflow, path).not.toContain(checkoutNode20); + expect(workflow, path).not.toContain(setupNode20); + expect(workflow, path).not.toContain('FORCE_JAVASCRIPT_ACTIONS_TO_NODE24'); + for (const match of workflow.matchAll(/actions\/checkout@([^\s"'#]+)/gi)) { + expect(match[0], path).toBe(checkoutNode24); + } + for (const match of workflow.matchAll(/actions\/setup-node@([^\s"'#]+)/gi)) { + expect(match[0], path).toBe(setupNode24); + } +} + +function lineIndent(line) { + return line.length - line.trimStart().length; +} + +function isBlockScalarHeader(line) { + return /(?:^|:\s+|-\s+)[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$/.test( + line.trimStart(), + ); +} + +function isInsideBlockScalar(lines, lineIndex) { + const targetIndent = lineIndent(lines[lineIndex]); + + for (let headerIndex = lineIndex - 1; headerIndex >= 0; headerIndex -= 1) { + const header = lines[headerIndex]; + if (header.trim() === '') continue; + const headerIndent = lineIndent(header); + if (headerIndent >= targetIndent || !isBlockScalarHeader(header)) continue; + + let boundedByHeader = true; + for (let index = headerIndex + 1; index <= lineIndex; index += 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + if (lineIndent(candidate) <= headerIndent) { + boundedByHeader = false; + break; + } + } + if (boundedByHeader) return true; + } + + return false; +} + +function isReviewedUsesScalar(trimmed, reviewedAction) { + const authorities = [ + `uses: ${reviewedAction}`, + `uses: '${reviewedAction}'`, + `uses: "${reviewedAction}"`, + ]; + return authorities.some( + (authority) => + trimmed === authority || trimmed.startsWith(`${authority} #`), + ); +} + +function directStepUsesAuthority(lines, lineIndex, reviewedAction) { + if (isInsideBlockScalar(lines, lineIndex)) return null; + + const line = lines[lineIndex]; + const trimmed = line.trimStart(); + const directSequenceMatch = /^-( +)(uses:.*)$/.exec(trimmed); + const directSequenceScalar = directSequenceMatch?.[2] ?? null; + const isDirectSequence = + directSequenceScalar !== null && + isReviewedUsesScalar(directSequenceScalar, reviewedAction); + const isMappingMember = isReviewedUsesScalar(trimmed, reviewedAction); + if (!isDirectSequence && !isMappingMember) return null; + + const lineIndentValue = lineIndent(line); + const stepIndent = isDirectSequence ? lineIndentValue : lineIndentValue - 2; + const usesIndent = isDirectSequence + ? stepIndent + 1 + directSequenceMatch[1].length + : lineIndentValue; + const stepsIndent = stepIndent - 2; + if (stepIndent < 2 || stepsIndent < 0) return null; + + let stepStart = lineIndex; + if (!isDirectSequence) { + stepStart = -1; + for (let index = lineIndex - 1; index >= 0; index -= 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + const candidateIndent = lineIndent(candidate); + if (candidateIndent < stepIndent) return null; + if (candidateIndent === stepIndent) { + if (!candidate.trimStart().startsWith('- ')) return null; + stepStart = index; + break; + } + } + } + + if (stepStart < 0) return null; + for (let index = stepStart - 1; index >= 0; index -= 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + const candidateIndent = lineIndent(candidate); + if (candidateIndent < stepsIndent) return null; + if (candidateIndent === stepsIndent) { + if (candidate.trimStart() !== 'steps:') return null; + return { stepIndent, usesIndent }; + } + } + + return null; +} + +function expectCheckoutInitialBranchAuthority(path, workflow) { + const lines = workflow.split(String.fromCharCode(10)); + const checkoutAuthorities = lines.flatMap((line, index) => { + const authority = directStepUsesAuthority(lines, index, checkoutNode24); + return authority === null ? [] : [{ lineIndex: index, ...authority }]; + }); + + expect(checkoutAuthorities.length, `${path} checkout count`).toBeGreaterThan( + 0, + ); + + for (const { + lineIndex: checkoutLineIndex, + stepIndent, + usesIndent, + } of checkoutAuthorities) { + let stepEnd = lines.length; + + for (let index = checkoutLineIndex + 1; index < lines.length; index += 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + const candidateIndent = lineIndent(candidate); + if (candidateIndent <= stepIndent) { + stepEnd = index; + break; + } + } + + const stepLines = lines.slice(checkoutLineIndex, stepEnd); + const envIndent = ' '.repeat(usesIndent); + const entryIndent = ' '.repeat(usesIndent + 2); + const envLineIndexes = stepLines.flatMap((line, index) => + line === `${envIndent}env:` ? [index] : [], + ); + expect(envLineIndexes, `${path} checkout env`).toHaveLength(1); + + const envLineIndex = envLineIndexes[0]; + let envEnd = stepLines.length; + for (let index = envLineIndex + 1; index < stepLines.length; index += 1) { + const candidate = stepLines[index]; + if (candidate.trim() === '') continue; + if (lineIndent(candidate) <= usesIndent) { + envEnd = index; + break; + } + } + const envLines = stepLines.slice(envLineIndex + 1, envEnd); + + const reviewedEntries = [ + ['GIT_CONFIG_COUNT', "'1'"], + ['GIT_CONFIG_KEY_0', 'init.defaultBranch'], + ['GIT_CONFIG_VALUE_0', 'main'], + ]; + for (const [key, expectedValue] of reviewedEntries) { + const declarations = envLines.filter((line) => + line.startsWith(`${entryIndent}${key}:`), + ); + expect(declarations, `${path} checkout ${key} declaration`).toHaveLength(1); + expect(declarations[0], `${path} checkout ${key} value`).toBe( + `${entryIndent}${key}: ${expectedValue}`, + ); + } + } +} + +function expectAppGuardrailScanSteps(workflow) { + const lines = workflow.split(String.fromCharCode(10)); + const jobsIndexes = lines.flatMap((line, index) => + line === 'jobs:' ? [index] : [], + ); + expect(jobsIndexes, 'top-level jobs authority').toHaveLength(1); + + const jobsIndex = jobsIndexes[0]; + let jobsEnd = lines.length; + for (let index = jobsIndex + 1; index < lines.length; index += 1) { + const candidate = lines[index]; + if (candidate.trim() === '' || candidate.trimStart().startsWith('#')) { + continue; + } + if (lineIndent(candidate) === 0) { + jobsEnd = index; + break; + } + } + + const scanIndexes = lines.flatMap((line, index) => + index > jobsIndex && index < jobsEnd && line === ' scan:' ? [index] : [], + ); + expect(scanIndexes, 'jobs.scan authority').toHaveLength(1); + + const scanIndex = scanIndexes[0]; + let scanEnd = jobsEnd; + for (let index = scanIndex + 1; index < jobsEnd; index += 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + if (lineIndent(candidate) === 2 && /:\s*(?:#.*)?$/.test(candidate.trim())) { + scanEnd = index; + break; + } + } + + const scanLines = lines.slice(scanIndex + 1, scanEnd); + expect( + scanLines.filter((line) => line === ' steps:'), + 'jobs.scan.steps authority', + ).toHaveLength(1); + expect(scanLines).not.toContain(' steps:'); +} + +describe('persistent GitHub Action runtime authority', () => { + it('uses reviewed Node 24 action pins without a runtime-forcing compatibility switch', () => { + for (const [path, workflow] of Object.entries(workflows)) { + expectReviewedActionPins(path, workflow); + } + }); + + it('rejects floating or otherwise unreviewed checkout/setup-node refs', () => { + const hostileWorkflow = [ + 'steps:', + ' - uses: actions/checkout@v7', + ' - uses: actions/setup-node@main', + ].join(String.fromCharCode(10)); + + expect(() => + expectReviewedActionPins('hostile-floating-ref.yml', hostileWorkflow), + ).toThrow(); + }); + + it('rejects case-variant checkout/setup-node identities with unreviewed refs', () => { + const hostileWorkflow = [ + 'jobs:', + ' scan:', + ' runs-on: ubuntu-24.04', + ' steps:', + ' - uses: Actions/Checkout@v4', + ' - uses: Actions/Setup-Node@main', + ].join(String.fromCharCode(10)); + + expect(() => + expectReviewedActionPins('hostile-case-variant-ref.yml', hostileWorkflow), + ).toThrow(); + }); + + it('requires checkout branch authority for quoted executable uses scalars', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Reviewed checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ' - name: Hostile quoted checkout', + ` uses: "${checkoutNode24}"`, + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-quoted-checkout-without-env.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('requires checkout branch authority for direct sequence uses steps', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Reviewed checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ` - uses: ${checkoutNode24}`, + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-direct-sequence-checkout-without-env.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('requires checkout branch authority for multi-space direct sequence steps', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Reviewed checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ` - uses: ${checkoutNode24}`, + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-multi-space-direct-sequence-checkout-without-env.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('configures every persistent checkout git init to use main explicitly', () => { + for (const [path, workflow] of Object.entries(workflows)) { + expectCheckoutInitialBranchAuthority(path, workflow); + } + }); + + it('rejects a scalar env payload impersonating checkout Git config authority', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Hostile checkout', + ` uses: ${checkoutNode24}`, + ' env: |', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-scalar-env.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects a block scalar impersonating an executable checkout step', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Hostile shell scalar', + ' run: |', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-scalar-checkout.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects a nested scalar sequence impersonating a checkout step boundary', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Hostile shell scalar', + ' run: |', + ' - fake checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-nested-scalar-checkout.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects a non-steps sequence impersonating an executable checkout step', () => { + const hostileWorkflow = [ + 'jobs:', + ' scan:', + ' decoy:', + ' - name: Fake checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-non-steps-sequence.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects checkout env authority borrowed after the executable step ends', () => { + const hostileWorkflow = [ + 'jobs:', + ' scan:', + ' steps:', + ' - name: Hostile checkout', + ` uses: ${checkoutNode24}`, + ' decoy:', + ' nested:', + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-post-step-env-borrow.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects checkout Git config authority borrowed from a sibling mapping', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Hostile checkout', + ` uses: ${checkoutNode24}`, + ' env:', + ' DECOY: safe', + ' with:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-sibling-mapping-env-borrow.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('rejects conflicting duplicate checkout Git configuration keys', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Hostile checkout', + ` uses: ${checkoutNode24}`, + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ' GIT_CONFIG_VALUE_0: master', + ].join(String.fromCharCode(10)); + + expect(() => + expectCheckoutInitialBranchAuthority( + 'hostile-duplicate-git-config.yml', + hostileWorkflow, + ), + ).toThrow(); + }); + + it('preserves AppGuardrail steps at the scan job boundary', () => { + expectAppGuardrailScanSteps(workflows['.github/workflows/appguardrail.yml']); + }); + + it('rejects AppGuardrail steps owned only by a sibling job', () => { + const hostileWorkflow = [ + 'jobs:', + ' scan:', + ' permissions:', + ' contents: read', + ' decoy:', + ' steps:', + ' - run: echo decoy', + ].join(String.fromCharCode(10)); + + expect(() => expectAppGuardrailScanSteps(hostileWorkflow)).toThrow(); + }); + + it('rejects AppGuardrail scan authority outside the jobs mapping', () => { + const hostileWorkflow = [ + 'jobs:', + ' build:', + ' runs-on: ubuntu-24.04', + 'other:', + ' scan:', + ' steps:', + ' - run: echo decoy', + ].join(String.fromCharCode(10)); + + expect(() => expectAppGuardrailScanSteps(hostileWorkflow)).toThrow(); + }); +}); diff --git a/packages/commercial-development-agent/src/node24-actions-quoted-key-contract.test.mjs b/packages/commercial-development-agent/src/node24-actions-quoted-key-contract.test.mjs new file mode 100644 index 000000000..bd84b2743 --- /dev/null +++ b/packages/commercial-development-agent/src/node24-actions-quoted-key-contract.test.mjs @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(process.cwd(), '../..'); +const workflowPaths = [ + '.github/workflows/ci.yml', + '.github/workflows/deploy.yml', + '.github/workflows/commercial-readiness.yml', + '.github/workflows/ai-proposal-live-conformance.yml', + '.github/workflows/opencode-commercial-development.yml', + '.github/workflows/appguardrail.yml', +]; + +/** Return leading indentation so scalar ownership can be bounded without parsing payload text. */ +function lineIndent(line) { + return line.length - line.trimStart().length; +} + +/** Identify YAML literal/folded scalar headers whose body must not create executable authority. */ +function isBlockScalarHeader(line) { + return /(?:^|:\s+|-\s+)[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$/.test( + line.trimStart(), + ); +} + +/** + * Determine whether a candidate line is owned by an enclosing block scalar. + * Security contracts must ignore scalar text even when it resembles a workflow mapping key. + */ +function isInsideBlockScalar(lines, lineIndex) { + const targetIndent = lineIndent(lines[lineIndex]); + + for (let headerIndex = lineIndex - 1; headerIndex >= 0; headerIndex -= 1) { + const header = lines[headerIndex]; + if (header.trim() === '') continue; + const headerIndent = lineIndent(header); + if (headerIndent >= targetIndent || !isBlockScalarHeader(header)) continue; + + let boundedByHeader = true; + for (let index = headerIndex + 1; index <= lineIndex; index += 1) { + const candidate = lines[index]; + if (candidate.trim() === '') continue; + if (lineIndent(candidate) <= headerIndent) { + boundedByHeader = false; + break; + } + } + if (boundedByHeader) return true; + } + + return false; +} + +/** + * Reject quoted `uses` mapping keys outside scalar payloads. + * YAML normalizes quoted and unquoted mapping keys to the same semantic key, while the + * existing checkout-authority contract intentionally recognizes the canonical unquoted form. + */ +function expectCanonicalUsesKeys(path, workflow) { + const lines = workflow.split(String.fromCharCode(10)); + const quotedUsesKeyIndexes = lines.flatMap((line, index) => { + if (isInsideBlockScalar(lines, index)) return []; + return /^\s*(?:-\s+)?["']uses["']\s*:/.test(line) ? [index] : []; + }); + + expect(quotedUsesKeyIndexes, `${path} quoted uses keys`).toHaveLength(0); +} + +describe('persistent GitHub Action uses-key authority', () => { + it('keeps executable uses mapping keys in the canonical unquoted form', () => { + for (const path of workflowPaths) { + expectCanonicalUsesKeys( + path, + readFileSync(resolve(repositoryRoot, path), 'utf8'), + ); + } + }); + + it('rejects a quoted uses key that YAML treats as executable authority', () => { + const hostileWorkflow = [ + 'steps:', + ' - name: Reviewed checkout', + ' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + ' env:', + " GIT_CONFIG_COUNT: '1'", + ' GIT_CONFIG_KEY_0: init.defaultBranch', + ' GIT_CONFIG_VALUE_0: main', + ' - name: Hostile quoted-key checkout', + ' "uses": actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + ].join(String.fromCharCode(10)); + + expect(() => + expectCanonicalUsesKeys('hostile-quoted-uses-key.yml', hostileWorkflow), + ).toThrow(); + }); + + it('does not treat quoted uses text inside a block scalar as YAML authority', () => { + const controlWorkflow = [ + 'steps:', + ' - name: Explain a rejected shape', + ' run: |', + ' echo \"\\\"uses\\\": actions/checkout@example\"', + ].join(String.fromCharCode(10)); + + expectCanonicalUsesKeys('block-scalar-control.yml', controlWorkflow); + }); +}); diff --git a/packages/commercial-development-agent/src/workflow-contract.test.mjs b/packages/commercial-development-agent/src/workflow-contract.test.mjs index dc6d98dff..004dfb981 100644 --- a/packages/commercial-development-agent/src/workflow-contract.test.mjs +++ b/packages/commercial-development-agent/src/workflow-contract.test.mjs @@ -123,7 +123,7 @@ describe('OpenCode commercial development workflow contract', () => { 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', ); expect(workflow).toContain( - 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020', + 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020', ); expect(workflow).toContain( 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a',