diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md new file mode 100644 index 000000000..27b13f5cc --- /dev/null +++ b/.github/PR_MODERATION.md @@ -0,0 +1,131 @@ +# PR submission moderation + +The PR Burst Guard protects review capacity using creation metadata. It runs on +new and reopened PRs targeting any branch, after this workflow merges. It does not +judge whether a patch is useful. Existing open PRs are not bulk-closed on rollout. + +## Policy + +| Control | Default | Enforcement | +| --- | --- | --- | +| Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs | +| Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers | +| Open-PR cap | Ten open PRs per author at any time | Close the newest PRs above the cap with an explanatory comment; no lock, no alert | +| Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR | +| Trusted contributors | Live repository write, maintain, or admin permission | Exempt from the burst guard | +| Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment | + +Edit `pr-burst-policy.json` to change thresholds, notification recipients or +denied numeric IDs, or `maxOpen` to change the open-PR cap. + +The open-PR cap is evaluated on the same opened and reopened events, after any +burst closures in that run, against a full paginated list of the repository's +open PRs. The author's oldest `maxOpen` PRs stay open; newer ones are closed with +a bot comment that names the count and the limit. Closing is reversible: once +the author is under the cap, reopening a closed PR passes. A PR the burst guard +already closed and locked in the same run is never touched again by the cap. The +cap does not bulk-close PRs that were already open when it shipped; it applies +as each author opens or reopens their next PR. + +Closed and draft PRs count toward burst detection. Counting uses stable author +IDs and paginated REST creation metadata, without the search index. A run checks +historical windows from the triggering PR's creation time through newer API +rows. Queue delays, out-of-order delivery and replaced pending runs cannot hide +a burst already present in that snapshot. Excess PRs are determined in creation +order across all scanned windows, with PR number breaking timestamp ties. +Already closed excess PRs count +but are not mutated again. + +Reopening an old ordinary PR does not add a new creation to the burst count. +Reopening a denied author's PR triggers denial even outside the time window. +Actions moderation happens after creation and cannot prevent every CI job from starting. Account +blocks cannot stop someone from using a different GitHub account. + +## Activate + +1. Merge the reviewed workflow and script into `main`. +2. To enable automatic account blocking, `affaan-m` creates a dedicated + fine-grained token with the account permission **Block another user: write**. + Store it as repository Actions secret `PR_MODERATION_BLOCK_TOKEN`. Use GitHub's + secret UI or `gh secret set PR_MODERATION_BLOCK_TOKEN --repo affaan-m/ECC` + with its hidden prompt. Do not paste credentials into chat or source files. +3. Verify a genuine subsequent guard run and its job summary. Without the secret, + closure and alerting still run. A severe burst records blocking as unavailable + in the alert and fails the job so maintainers see the missing protection. + +ECC currently belongs to the personal account `affaan-m`. GitHub account blocking +applies across **all repositories owned by that account**. The script checks the +token's authenticated identity before calling the personal blocking API. A +collaborator's personal block cannot protect this repository. The normal +`GITHUB_TOKEN` cannot perform account blocking. Organization blocking requires +a separate implementation if repository ownership changes. + +Closing PRs cannot reset burst counts. Rapid closing and resubmission still +count as creations within the same rolling window. + +## Verify without moderating anyone + +```sh +node tests/ci/pr-burst-guard.test.js +node tests/ci/pr-burst-guard-workflow.test.js +node scripts/ci/validate-workflow-security.js +``` + +The workflow test executes the actual CLI against a disposable HTTP fixture. +It verifies close, lock, owner-identity blocking, secret separation, deduplicated +notifications, missing-secret failure and collaborator exemption. + +For a live read-only replay, supply `GITHUB_TOKEN` through your secure shell +environment, then run: + +```sh +GITHUB_REPOSITORY=affaan-m/ECC node scripts/ci/pr-burst-guard.js --dry-run --pr 3580 +``` + +Expected for the October 14 snapshot: author `jwcastillo`, count `14`, `block: +true`, and nine excess PRs. Dry run never uses the blocking secret or writes to +GitHub. Hosted event delivery and real account blocking require post-merge +verification; the local fixture cannot certify those platform operations. + +## Security and recovery + +- The privileged event checks out its trusted default-branch SHA and two moderation files. + It never checks out PR code, installs dependencies, or executes PR text. +- Credentials enter through environment variables. Owner identity verification + and account blocking receive the owner credential. Repository moderation uses + `GITHUB_TOKEN`. +- API failures cannot silently become an allowlist exemption. Only collaborator + lookup `404` means an outsider; other errors fail the run. +- Close or lock failures appear in the alert and fail the job. Block failures + preserve closure and alerting, then fail the job. +- Repeated runs update the bot-authored alert instead of creating new issues. + A contributor-created lookalike cannot suppress alerts. Escalation remains + recorded if a later run has a lower count. +- To roll back, disable **PR Burst Guard** in Actions. Remove its secret if + abandoning automatic blocking. +- To restore a contributor, the owner removes the account block. Remove any + denied user ID from the policy, then unlock and reopen selected PRs manually. + +## October 15 investigation + +Authenticated GitHub GraphQL returned zero `CLOSED_EVENT` or `REOPENED_EVENT` +entries across all 55 `dajiaohuang` PRs that were open at the investigation's start. Their latest update was +October 8. The public timeline therefore supplies no evidence that they reopened +on October 15. Restored visibility after GitHub spam classification is a possible +explanation, but GitHub's private moderation history was unavailable. A block on +a collaborator's personal account would also have a different scope from an +owner block. The owner's actual block state was not available to this session. + +The visible newer burst was 14 PRs from `jwcastillo`, #3567 through #3580, between +October 14 at 19:01:37 and 19:07:14 UTC. Volume alone does not establish the quality +of those patches. The maintainer subsequently authorized closing all 55 open +`dajiaohuang` PRs. All closures were verified, and a fresh paginated REST scan +found zero remaining open PRs from numeric user ID `108231307`. + +This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet, +release publication and shared application installation remain separate work. + +## GitHub references + +- [Personal account blocking REST endpoints](https://docs.github.com/en/rest/users/blocking) +- [Privileged pull request event security](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) diff --git a/.github/pr-burst-policy.json b/.github/pr-burst-policy.json new file mode 100644 index 000000000..9988e1566 --- /dev/null +++ b/.github/pr-burst-policy.json @@ -0,0 +1,8 @@ +{ + "windowMinutes": 20, + "closeAbove": 5, + "blockAbove": 10, + "maxOpen": 10, + "deniedUserIDs": [108231307], + "notify": ["affaan-m", "haelyra"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 575981b06..cfedaa4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,22 +18,35 @@ permissions: jobs: test: - name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }}) + name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }}, ${{ matrix.scope }}) runs-on: ${{ matrix.os }} - # The complete cross-platform suite reaches its final installer cases at - # 30 minutes on hosted Windows Node 18; retain a bounded completion window. + # The serial Windows full lane reaches its final installer cases at about + # 30 minutes; retain a bounded completion window. timeout-minutes: 45 strategy: fail-fast: false + # Two kinds of lane. `full` runs the whole suite; `pm` runs only the suites + # that read CLAUDE_CODE_PACKAGE_MANAGER, which is all the package-manager + # axis ever changed. The old 33-way cross product ran the full suite 33 + # times per push, with Windows lanes at 30 to 40 minutes each. matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - node: ['18.x', '20.x', '22.x'] - pm: [npm, pnpm, yarn, bun] - exclude: - # Bun has limited Windows support - - os: windows-latest - pm: bun + include: + # Full suite: Linux on every supported Node line, current LTS on Windows and macOS. + - { os: ubuntu-latest, node: '18.x', pm: npm, scope: full } + - { os: ubuntu-latest, node: '20.x', pm: npm, scope: full } + - { os: ubuntu-latest, node: '22.x', pm: npm, scope: full } + - { os: windows-latest, node: '22.x', pm: npm, scope: full } + - { os: macos-latest, node: '22.x', pm: npm, scope: full } + # Package-manager lanes on current LTS. Bun has limited Windows support. + - { os: ubuntu-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: ubuntu-latest, node: '22.x', pm: yarn, scope: pm } + - { os: ubuntu-latest, node: '22.x', pm: bun, scope: pm } + - { os: windows-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: windows-latest, node: '22.x', pm: yarn, scope: pm } + - { os: macos-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: macos-latest, node: '22.x', pm: yarn, scope: pm } + - { os: macos-latest, node: '22.x', pm: bun, scope: pm } steps: - name: Checkout @@ -125,18 +138,37 @@ jobs: *) echo "Unsupported package manager: ${{ matrix.pm }}" && exit 1 ;; esac - # Run tests + # Run tests. Full lanes run every file through a 4-process pool on Linux + # and macOS; Windows stays serial because its process spawn and temp-dir + # cleanup are the flaky part. pm lanes derive their file list from the + # suites that actually read the package-manager variable, so the lane + # cannot drift out of date as tests are added. - name: Run tests - run: node tests/run-all.js + shell: bash + run: | + if [ "${{ matrix.scope }}" = "pm" ]; then + ECC_TEST_ONLY=$(grep -rlE --include='*.test.js' 'CLAUDE(_CODE)?_PACKAGE_MANAGER' tests | sed 's#^tests/##' | sort | paste -sd, -) + export ECC_TEST_ONLY + echo "pm lane files: $ECC_TEST_ONLY" + fi + # Suites that run scripts/build-opencode.js, or rename a checkout path + # to a .backup- copy and restore it, mutate the shared checkout; + # suites that assert a wall-clock budget cannot share cores with the + # pool. Both groups run last, one at a time. + ECC_TEST_SERIAL=$(grep -rlE --include='*.test.js' 'build-opencode|\.backup-|dist\.backup|time budget' tests | sed 's#^tests/##' | sort | paste -sd, -) + export ECC_TEST_SERIAL + echo "serial after the pool: $ECC_TEST_SERIAL" + node tests/run-all.js env: CLAUDE_CODE_PACKAGE_MANAGER: ${{ matrix.pm }} + ECC_TEST_JOBS: ${{ runner.os == 'Windows' && '1' || '4' }} # Upload test artifacts on failure - name: Upload test artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }} + name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }}-${{ matrix.scope }} path: | tests/ !tests/node_modules/ diff --git a/.github/workflows/pr-burst-guard.yml b/.github/workflows/pr-burst-guard.yml new file mode 100644 index 000000000..550655339 --- /dev/null +++ b/.github/workflows/pr-burst-guard.yml @@ -0,0 +1,39 @@ +name: PR Burst Guard + +on: + pull_request_target: + types: [opened, reopened] + +# Process one author's ledger serially. A newer pending event replaces an older +# pending event; each run reads all PR states instead of relying on event counts. +concurrency: + group: pr-burst-${{ github.repository }}-${{ github.event.pull_request.user.id }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + moderate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # pull_request_target's SHA is the trusted default branch, never a PR head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: | + .github/pr-burst-policy.json + scripts/ci/pr-burst-guard.js + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.x' + - name: Enforce PR submission policy + env: + GITHUB_TOKEN: ${{ github.token }} + PR_MODERATION_BLOCK_TOKEN: ${{ secrets.PR_MODERATION_BLOCK_TOKEN }} + run: node scripts/ci/pr-burst-guard.js diff --git a/CLAUDE.md b/CLAUDE.md index d5649ac50..f514243e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,12 @@ This is a **Claude Code plugin** - a collection of production-ready agents, skil # Run all tests node tests/run-all.js +# Run files through a 4-process pool, or only files whose path contains a pattern +ECC_TEST_JOBS=4 node tests/run-all.js +ECC_TEST_ONLY=package-manager,hooks/hooks.test node tests/run-all.js +# Suites that rebuild or back up .opencode/dist, or assert a time budget, must run after the pool, one at a time +ECC_TEST_JOBS=4 ECC_TEST_SERIAL=$(grep -rlE --include='*.test.js' 'build-opencode|\.backup-|dist\.backup|time budget' tests | sed 's#^tests/##' | paste -sd, -) node tests/run-all.js + # Run individual test files node tests/lib/utils.test.js node tests/lib/package-manager.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5576d4699..8fe695388 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -433,6 +433,23 @@ Translations live under `docs/` (e.g. `docs/zh-CN`, `docs/zh-TW`, `docs/ja-JP`). ## Pull Request Process +### Submission volume + +Please submit at most five PRs within any 20-minute period. Automated moderation +closes and locks excess PRs, including drafts. More than ten submissions within +20 minutes triggers a maintainer alert and an account-block attempt. Closed PRs +still count toward the window. Contributors with repository write access bypass +this policy. Maintainers can review exceptions through the moderation alert. + +Please also keep at most ten PRs open at a time. When you open or reopen a PR +above that cap, automation closes the newest ones with a comment explaining the +count. Get existing PRs merged or close the ones you no longer need, then reopen. +Ten open PRs is more than any one reviewer can keep in flight, so this keeps +review time on the work you care about most. + +See the [moderation runbook](.github/PR_MODERATION.md) +for activation, credential requirements, and recovery. + ### 1. PR Title Format ``` diff --git a/commands/multi-backend.md b/commands/multi-backend.md index 95cc95d83..1faa1c883 100644 --- a/commands/multi-backend.md +++ b/commands/multi-backend.md @@ -38,7 +38,7 @@ You are the **Backend Orchestrator**, coordinating multi-model collaboration for ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-execute.md b/commands/multi-execute.md index 2c0ac1c45..c70349d2d 100644 --- a/commands/multi-execute.md +++ b/commands/multi-execute.md @@ -29,7 +29,7 @@ $ARGUMENTS ``` # Resume session call (recommended) - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -44,7 +44,7 @@ EOF", # New session call - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -62,7 +62,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: Audit the final code changes. diff --git a/commands/multi-frontend.md b/commands/multi-frontend.md index 939dc5afe..5807823bf 100644 --- a/commands/multi-frontend.md +++ b/commands/multi-frontend.md @@ -38,7 +38,7 @@ You are the **Frontend Orchestrator**, coordinating multi-model collaboration fo ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend antigravity - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend antigravity - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend antigravity resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend antigravity resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-plan.md b/commands/multi-plan.md index 3b4920761..ac9e5c259 100644 --- a/commands/multi-plan.md +++ b/commands/multi-plan.md @@ -28,7 +28,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-workflow.md b/commands/multi-workflow.md index 5aad6cb4f..b5595e943 100644 --- a/commands/multi-workflow.md +++ b/commands/multi-workflow.md @@ -42,7 +42,7 @@ You are the **Orchestrator**, coordinating a multi-model collaborative system (R ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -57,7 +57,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docker/context-profiles/ai-eval-lib.js b/docker/context-profiles/ai-eval-lib.js index c90793cf2..9b5397470 100644 --- a/docker/context-profiles/ai-eval-lib.js +++ b/docker/context-profiles/ai-eval-lib.js @@ -75,6 +75,37 @@ function validateCorpusIds(cases) { // Complex corpora hold a few realistic multi-file tasks with scored hidden graders. Sample gates // are descriptive at this size, so the distinct-task minimum relaxes to the corpus itself. +function invalidComplexStep(step) { + return typeof step.query !== 'string' || !bounded(Buffer.byteLength(step.query), 1, 8192) + || typeof step.check !== 'string' || !bounded(Buffer.byteLength(step.check), 1, 65536) + || (step.checkTimeoutMs !== undefined && !bounded(step.checkTimeoutMs, 1, 120000)) + || (step.manualIds !== undefined && (!Array.isArray(step.manualIds) || step.manualIds.length > 3)); +} + +function validateComplexTaskFiles(task) { + const files = Object.entries(task.files || {}); + if (!Array.isArray(task.manualIds) || task.manualIds.length > 3 || !bounded(files.length, 1, 24) + || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 65536)) { + throw new Error('Invalid corpus task'); + } +} + +function validateComplexTask(task) { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(task.id)) throw new Error('Invalid corpus case'); + if (task.steps === undefined + && (typeof task.query !== 'string' || !bounded(Buffer.byteLength(task.query), 1, 8192))) throw new Error('Invalid corpus case'); + validateComplexTaskFiles(task); + if (task.steps !== undefined) { + // Stepped (chained) task: sequential tickets graded in one accumulating workspace. + if (!Array.isArray(task.steps) || !bounded(task.steps.length, 2, 8) || task.steps.some(invalidComplexStep)) { + throw new Error('Invalid corpus task'); + } + } else if (typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 65536) + || (task.checkTimeoutMs !== undefined && !bounded(task.checkTimeoutMs, 1, 120000))) { + throw new Error('Invalid corpus task'); + } +} + function validateComplexCorpus(corpus) { if (!Array.isArray(corpus.selection) || !Array.isArray(corpus.tasks) || !bounded(corpus.selection.length, 0, 50) || !bounded(corpus.tasks.length, 1, 10) @@ -83,29 +114,7 @@ function validateComplexCorpus(corpus) { } validateCorpusIds(corpus.selection); if (new Set(corpus.tasks.map(c => c.id)).size !== corpus.tasks.length) throw new Error('Duplicate corpus ID'); - for (const task of corpus.tasks) { - if (!/^[a-z][a-z0-9-]{0,63}$/.test(task.id)) throw new Error('Invalid corpus case'); - if (task.steps === undefined - && (typeof task.query !== 'string' || !bounded(Buffer.byteLength(task.query), 1, 8192))) throw new Error('Invalid corpus case'); - const files = Object.entries(task.files || {}); - if (!Array.isArray(task.manualIds) || task.manualIds.length > 3 || !bounded(files.length, 1, 24) - || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 65536)) { - throw new Error('Invalid corpus task'); - } - if (task.steps !== undefined) { - // Stepped (chained) task: sequential tickets graded in one accumulating workspace. - if (!Array.isArray(task.steps) || !bounded(task.steps.length, 2, 8) - || task.steps.some(step => typeof step.query !== 'string' || !bounded(Buffer.byteLength(step.query), 1, 8192) - || typeof step.check !== 'string' || !bounded(Buffer.byteLength(step.check), 1, 65536) - || (step.checkTimeoutMs !== undefined && !bounded(step.checkTimeoutMs, 1, 120000)) - || (step.manualIds !== undefined && (!Array.isArray(step.manualIds) || step.manualIds.length > 3)))) { - throw new Error('Invalid corpus task'); - } - } else if (typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 65536) - || (task.checkTimeoutMs !== undefined && !bounded(task.checkTimeoutMs, 1, 120000))) { - throw new Error('Invalid corpus task'); - } - } + for (const task of corpus.tasks) validateComplexTask(task); } function sourceSnapshot(repoRoot) { @@ -601,36 +610,50 @@ function launchEnvironment(launch) { TMPDIR: launch.home, LANG: 'C.UTF-8' }; } +// One provider call. A throwing provider still records its elapsed time and +// restores the environment; only source drift propagates as itself. +function callProvider(state, environment, request, started) { + try { + return state.provider(request); + } catch (error) { + state.metrics.push({ phase: request.phase, elapsedMs: Date.now() - started, usage: null }); + if (error?.code === 'source-drift') throw error; + return fail('provider-failed'); + } finally { environment.restore(); } +} + +function parseProviderOutput(state, raw) { + const parsed = state.family === 'claude' ? parseClaudeJson(raw?.stdout) : parseCodexJsonl(raw?.stdout); + const succeeded = raw?.status === 0 && !raw?.error; + return { parsed, succeeded }; +} + +function adapterCall(state, cwd, environment, args, options) { + if (state.calls >= state.maxCalls) fail('call-budget'); + state.assertCurrent(); + environment.verify(); + const remaining = state.deadline - Date.now(); + if (remaining <= 0) fail('deadline'); + const phase = options.phase || (args.includes('read-only') ? 'selection' : 'task'); + state.calls++; + const started = Date.now(); + // Coding tasks outgrow the launcher's interactive default, so the evaluator's own call bound governs them. + const timeoutMs = Math.min(phase === 'task' ? state.callTimeoutMs : options.timeout, state.callTimeoutMs, remaining); + const env = options.env || launchEnvironment(environment.launch); + const raw = callProvider(state, environment, + { phase, input: options.input, cwd, env, timeoutMs, maxBuffer: 1024 * 1024 }, started); + const elapsedMs = Date.now() - started; + const { parsed, succeeded } = parseProviderOutput(state, raw); + state.metrics.push({ phase, elapsedMs, usage: parsed.valid && succeeded ? parsed.usage : null }); + if (Date.now() >= state.deadline || elapsedMs > timeoutMs) fail('deadline'); + state.assertCurrent(); + if (!succeeded) fail('provider-failed'); + if (!parsed.valid) fail(parsed.error ? 'provider-failed' : 'invalid-jsonl'); + return { status: 0, stdout: parsed.text }; +} + function executeAdapter(state, cwd, environment) { - return (_command, args, options) => { - if (state.calls >= state.maxCalls) fail('call-budget'); - state.assertCurrent(); - environment.verify(); - const remaining = state.deadline - Date.now(); - if (remaining <= 0) fail('deadline'); - const phase = options.phase || (args.includes('read-only') ? 'selection' : 'task'); - state.calls++; - const started = Date.now(); - let raw; - // Coding tasks outgrow the launcher's interactive default, so the evaluator's own call bound governs them. - const timeoutMs = Math.min(phase === 'task' ? state.callTimeoutMs : options.timeout, state.callTimeoutMs, remaining); - const env = options.env || launchEnvironment(environment.launch); - try { - raw = state.provider({ phase, input: options.input, cwd, env, timeoutMs, maxBuffer: 1024 * 1024 }); - } catch (error) { - state.metrics.push({ phase, elapsedMs: Date.now() - started, usage: null }); - if (error?.code === 'source-drift') throw error; - fail('provider-failed'); - } finally { environment.restore(); } - const elapsedMs = Date.now() - started; - const parsed = state.family === 'claude' ? parseClaudeJson(raw?.stdout) : parseCodexJsonl(raw?.stdout); - state.metrics.push({ phase, elapsedMs, usage: parsed.valid && raw?.status === 0 && !raw?.error ? parsed.usage : null }); - if (Date.now() >= state.deadline || elapsedMs > timeoutMs) fail('deadline'); - state.assertCurrent(); - if (raw?.status !== 0 || raw?.error) fail('provider-failed'); - if (!parsed.valid) fail(parsed.error ? 'provider-failed' : 'invalid-jsonl'); - return { status: 0, stdout: parsed.text }; - }; + return (_command, args, options) => adapterCall(state, cwd, environment, args, options); } function selectionProbe(item, repoRoot, execute, environment, target) { @@ -661,48 +684,63 @@ function selectionProbe(item, repoRoot, execute, environment, target) { // Full relies on native discovery of the whole install; the Lean arms receive ECC-selected skill bodies; // ecc-legacy runs bare against the pinned pre-scoping skill library; Baseline runs the bare task query. // Stepped tasks run each ticket in the same accumulating workspace, grading after every step. -function outcomeTrial(item, arm, repeat, repoRoot, execute, cwd, environment, target, harvest, metrics = null) { - const launchStep = (query, manualIds) => { - const task = { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query }; - return launchTaskContext({ repoRoot, execute, nativeEnvironment: environment.launch, target, - bare: arm === 'baseline' || arm === 'ecc-legacy', - task: { ...task, ...(arm === 'manual-lean' && manualIds?.length ? { explicitIds: manualIds } : {}) }, - profileId: arm === 'full' ? 'full@1' : 'lean@1', selectionMode: arm === 'auto-lean' ? 'auto' : 'manual' }); - }; - try { - if (!item.steps) { - const result = launchStep(item.query, item.manualIds); - if (harvest) harvest(arm, item.id, repeat, environment); - const verdict = runScoredCheck(cwd, item.check, item.checkTimeoutMs); - const passed = result.status === 'completed' && verdict.passed && verdict.score >= 0.999; - return { id: item.id, arm, repeat, passed, score: result.status === 'completed' ? verdict.score : 0, - selectedIds: result.selection.selectedIds, failure: passed ? null : 'hidden-check' }; - } - const steps = []; - const selectedIds = []; - for (let index = 0; index < item.steps.length; index++) { - const step = item.steps[index]; - const start = metrics ? metrics.length : 0; - const result = launchStep(step.query, step.manualIds || item.manualIds); - if (harvest) harvest(arm, `${item.id}--step${index + 1}`, repeat, environment); - if (result.status !== 'completed') { - // A failed ticket ends the chain; remaining tickets are unscored. - steps.push({ score: 0, ...(metrics ? metricsSince(metrics, start) : {}) }); - for (let rest = index + 1; rest < item.steps.length; rest++) { - steps.push({ score: 0, ...(metrics ? metricsSince(metrics, metrics.length) : {}) }); - } - break; +// `trial` carries repoRoot, execute, cwd, environment, target, harvest (or null) +// and metrics (or null) for one task trial. +function launchTrialStep(item, arm, trial, query, manualIds) { + const task = { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query }; + return launchTaskContext({ repoRoot: trial.repoRoot, execute: trial.execute, + nativeEnvironment: trial.environment.launch, target: trial.target, + bare: arm === 'baseline' || arm === 'ecc-legacy', + task: { ...task, ...(arm === 'manual-lean' && manualIds?.length ? { explicitIds: manualIds } : {}) }, + profileId: arm === 'full' ? 'full@1' : 'lean@1', selectionMode: arm === 'auto-lean' ? 'auto' : 'manual' }); +} + +function stepMetrics(metrics, start) { + return metrics ? metricsSince(metrics, start) : {}; +} + +function singleStepTrial(item, arm, repeat, trial) { + const { cwd, environment, harvest } = trial; + const result = launchTrialStep(item, arm, trial, item.query, item.manualIds); + if (harvest) harvest(arm, item.id, repeat, environment); + const verdict = runScoredCheck(cwd, item.check, item.checkTimeoutMs); + const passed = result.status === 'completed' && verdict.passed && verdict.score >= 0.999; + return { id: item.id, arm, repeat, passed, score: result.status === 'completed' ? verdict.score : 0, + selectedIds: result.selection.selectedIds, failure: passed ? null : 'hidden-check' }; +} + +function steppedTrial(item, arm, repeat, trial) { + const { cwd, environment, harvest, metrics } = trial; + const steps = []; + const selectedIds = []; + for (let index = 0; index < item.steps.length; index++) { + const step = item.steps[index]; + const start = metrics ? metrics.length : 0; + const result = launchTrialStep(item, arm, trial, step.query, step.manualIds || item.manualIds); + if (harvest) harvest(arm, `${item.id}--step${index + 1}`, repeat, environment); + if (result.status !== 'completed') { + // A failed ticket ends the chain; remaining tickets are unscored. + steps.push({ score: 0, ...stepMetrics(metrics, start) }); + for (let rest = index + 1; rest < item.steps.length; rest++) { + steps.push({ score: 0, ...stepMetrics(metrics, metrics ? metrics.length : 0) }); } - selectedIds.push(...result.selection.selectedIds); - const verdict = runScoredCheck(cwd, step.check, step.checkTimeoutMs, index + 1); - steps.push({ score: verdict.passed ? verdict.score : 0, ...(metrics ? metricsSince(metrics, start) : {}) }); + break; } - const score = steps.reduce((sum, step) => sum + step.score, 0) / item.steps.length; - const passed = steps.length === item.steps.length && steps.every(step => step.score >= 0.999); - return { id: item.id, arm, repeat, passed, score, selectedIds: [...new Set(selectedIds)], steps, - failure: passed ? null : 'hidden-check' }; + selectedIds.push(...result.selection.selectedIds); + const verdict = runScoredCheck(cwd, step.check, step.checkTimeoutMs, index + 1); + steps.push({ score: verdict.passed ? verdict.score : 0, ...stepMetrics(metrics, start) }); + } + const score = steps.reduce((sum, step) => sum + step.score, 0) / item.steps.length; + const passed = steps.length === item.steps.length && steps.every(step => step.score >= 0.999); + return { id: item.id, arm, repeat, passed, score, selectedIds: [...new Set(selectedIds)], steps, + failure: passed ? null : 'hidden-check' }; +} + +function outcomeTrial(item, arm, repeat, trial) { + try { + return item.steps ? steppedTrial(item, arm, repeat, trial) : singleStepTrial(item, arm, repeat, trial); } catch (error) { - if (harvest) harvest(arm, item.id, repeat, environment); + if (trial.harvest) trial.harvest(arm, item.id, repeat, trial.environment); return { id: item.id, arm, repeat, passed: false, score: 0, selectedIds: [], failure: failureCode(error) }; } } @@ -753,10 +791,10 @@ function createHarvester(artifactDir, envs) { }; } -function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), registration, - repeats = 1, provider, family, allowRealProvider = false, allowCredentialedTools = false, - executable, model, effort, authHome, environments, - arms = undefined, artifactDir = null, maxCalls = 300, deadlineMs = 3600000, callTimeoutMs = 300000 } = {}) { +// Validate the caller's bounds and opt-ins and settle on the provider family. +function resolveEvaluationFamily(options) { + const { provider, allowRealProvider, maxCalls, deadlineMs, callTimeoutMs, registration, + family, executable, effort, allowCredentialedTools } = options; if (!provider && !allowRealProvider) throw new Error('Evaluation requires an injected provider or explicit opt-in'); if (!bounded(maxCalls, 1, 2000) || !bounded(deadlineMs, 1, 8 * 3600000) || !bounded(callTimeoutMs, 1, 600000)) throw new Error('Invalid call or deadline bound'); @@ -766,21 +804,97 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re if (!provider && resolvedFamily === 'claude' && !allowCredentialedTools) { throw new Error('Claude task tools can read provider credentials; explicit credentialed-tool opt-in is required'); } + return resolvedFamily; +} + +function pinEvaluation(options, resolvedFamily) { + const { repoRoot, corpus, repeats, model, executable, effort, arms, provider, registration } = options; const pin = preregister({ repoRoot, corpus, repeats, model, executable, effort, arms }); if (!provider && resolvedFamily === 'codex' && pin.arms.includes('ecc-legacy')) { throw new Error('Codex real evaluation requires --arms without ecc-legacy; the pinned legacy skills arm is Claude-only'); } if (registration && !isDeepStrictEqual(registration, pin)) throw new Error('Registration pin mismatch'); - const injected = Boolean(provider); - const liveProvider = provider || (resolvedFamily === 'claude' + return pin; +} + +function createLiveProvider(resolvedFamily, options) { + const { allowRealProvider, allowCredentialedTools, executable, model, effort, authHome, artifactDir } = options; + return resolvedFamily === 'claude' ? createClaudeProvider({ allowRealProvider, allowCredentialedTools, executable, model, persistSessions: Boolean(artifactDir) }) - : createCodexProvider({ allowRealProvider, executable, model, effort, authHome })); - const state = { calls: 0, metrics: [], maxCalls, callTimeoutMs, family: resolvedFamily, + : createCodexProvider({ allowRealProvider, executable, model, effort, authHome }); +} + +function createEvaluationState(options, plan) { + const { repoRoot, corpus, maxCalls, callTimeoutMs, deadlineMs } = options; + const { pin, resolvedFamily, liveProvider } = plan; + return { calls: 0, metrics: [], maxCalls, callTimeoutMs, family: resolvedFamily, deadline: Date.now() + deadlineMs, provider: liveProvider, assertCurrent() { if (digestObject(corpus) !== pin.corpusDigest || sourceSnapshot(repoRoot).sourceDigest !== pin.sourceDigest) fail('source-drift'); } }; +} + +function planEvaluation(options) { + const resolvedFamily = resolveEvaluationFamily(options); + const pin = pinEvaluation(options, resolvedFamily); + const injected = Boolean(options.provider); + const liveProvider = options.provider || createLiveProvider(resolvedFamily, options); + const plan = { resolvedFamily, pin, injected, liveProvider }; + plan.state = createEvaluationState(options, plan); + return plan; +} + +function prepareEvaluationEnvironments(options, plan, installRoot) { + const { repoRoot, executable } = options; + const { injected, resolvedFamily, pin } = plan; + if (injected) return syntheticEnvironments(installRoot); + if (resolvedFamily !== 'claude') return prepareEnvironments({ repoRoot, executable, root: installRoot }); + return prepareClaudeEnvironments({ repoRoot, executable, root: installRoot, + ...(pin.arms.includes('ecc-legacy') + ? { legacySource: exportLegacySource({ repoRoot, destination: path.join(installRoot, 'legacy-source') }) } + : {}) }); +} + +function describeInstalls(envs) { + return Object.fromEntries(Object.entries(envs).map(([name, env]) => [name, + { profileId: env.profileId, skills: env.skills, ...(env.sourceSha ? { sourceSha: env.sourceSha } : {}) }])); +} + +function runSelectionProbes(run, selection) { + const { corpus, repoRoot, state, temp, envs, resolvedFamily } = run; + for (const item of corpus.selection) { + const cwd = path.join(temp, `${item.id}--selection`); + fs.mkdirSync(cwd); + const start = state.metrics.length; + selection.push({ ...selectionProbe(item, repoRoot, executeAdapter(state, cwd, envs.lean), envs.lean, resolvedFamily), + ...metricsSince(state.metrics, start) }); + } +} + +function runOutcomeTrials(run, outcomes) { + const { corpus, pin, repoRoot, state, temp, envs, resolvedFamily, harvest } = run; + for (const scheduled of pin.order) { + const item = corpus.tasks.find(c => c.id === scheduled.id); + for (const arm of scheduled.arms) { + const cwd = path.join(temp, `${item.id}--${arm}--${scheduled.repeat}`); + const environment = envs[['full', 'baseline', 'ecc-legacy'].includes(arm) ? arm : 'lean']; + fs.mkdirSync(cwd); + writeWorkspace(cwd, item.files); + const start = state.metrics.length; + const trial = { repoRoot, execute: executeAdapter(state, cwd, environment), cwd, environment, + target: resolvedFamily, harvest, metrics: state.metrics }; + outcomes.push({ ...outcomeTrial(item, arm, scheduled.repeat, trial), ...metricsSince(state.metrics, start) }); + fs.rmSync(cwd, { recursive: true, force: true }); + } + } +} + +// Prepare installs in a private temp root, run every selection probe and task +// trial, and always remove the root afterwards. +function executeEvaluation(options, plan) { + const { repoRoot, corpus, environments, artifactDir } = options; + const { resolvedFamily, pin, injected, state } = plan; const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-ai-eval-'))); const selection = []; const outcomes = []; @@ -789,52 +903,38 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re try { const installRoot = path.join(temp, 'installs'); fs.mkdirSync(installRoot, { mode: 0o700 }); - const envs = environments || (injected ? syntheticEnvironments(installRoot) - : resolvedFamily === 'claude' - ? prepareClaudeEnvironments({ repoRoot, executable, root: installRoot, - ...(pin.arms.includes('ecc-legacy') - ? { legacySource: exportLegacySource({ repoRoot, destination: path.join(installRoot, 'legacy-source') }) } - : {}) }) - : prepareEnvironments({ repoRoot, executable, root: installRoot })); - installs = Object.fromEntries(Object.entries(envs).map(([name, env]) => [name, - { profileId: env.profileId, skills: env.skills, ...(env.sourceSha ? { sourceSha: env.sourceSha } : {}) }])); + const envs = environments || prepareEvaluationEnvironments(options, plan, installRoot); + installs = describeInstalls(envs); harvester = artifactDir && resolvedFamily === 'claude' && !injected ? createHarvester(artifactDir, envs) : null; const harvest = harvester ? (arm, id, repeat, env) => harvester.record(arm, id, repeat, env) : null; - for (const item of corpus.selection) { - const cwd = path.join(temp, `${item.id}--selection`); - fs.mkdirSync(cwd); - const start = state.metrics.length; - selection.push({ ...selectionProbe(item, repoRoot, executeAdapter(state, cwd, envs.lean), envs.lean, resolvedFamily), - ...metricsSince(state.metrics, start) }); - } - for (const scheduled of pin.order) { - const item = corpus.tasks.find(c => c.id === scheduled.id); - for (const arm of scheduled.arms) { - const cwd = path.join(temp, `${item.id}--${arm}--${scheduled.repeat}`); - const environment = envs[['full', 'baseline', 'ecc-legacy'].includes(arm) ? arm : 'lean']; - fs.mkdirSync(cwd); - writeWorkspace(cwd, item.files); - const start = state.metrics.length; - outcomes.push({ ...outcomeTrial(item, arm, scheduled.repeat, repoRoot, - executeAdapter(state, cwd, environment), cwd, environment, resolvedFamily, harvest, state.metrics), - ...metricsSince(state.metrics, start) }); - fs.rmSync(cwd, { recursive: true, force: true }); - } - } + const run = { corpus, pin, repoRoot, state, temp, envs, resolvedFamily, harvest }; + runSelectionProbes(run, selection); + runOutcomeTrials(run, outcomes); if (harvester) harvester.writeIndex(); } finally { if (harvester) harvester.writeIndex(); fs.rmSync(temp, { recursive: true, force: true }); } + return { selection, outcomes, installs, harvester }; +} + +function summarizeSelection(selection) { + const selectionSuccesses = selection.filter(row => row.passed).length; + return { n: selection.length, successes: selectionSuccesses, + categories: [...new Set(selection.map(row => row.category))].map(category => ({ category, + n: selection.filter(row => row.category === category).length, + successes: selection.filter(row => row.category === category && row.passed).length })), + interval: wilson(selectionSuccesses, selection.length), method: 'wilson-95-descriptive-purposive-sample' }; +} + +function buildEvaluationReport(options, plan, run) { + const { maxCalls, deadlineMs, callTimeoutMs } = options; + const { pin, injected, liveProvider, state, resolvedFamily } = plan; + const { selection, outcomes, installs, harvester } = run; const summary = summarize(outcomes, pin.arms); const insufficient = summary.distinctTasks < pin.minimumDistinctTasks || selection.length < pin.minimumDistinctTasks; - const selectionSuccesses = selection.filter(row => row.passed).length; return { schemaVersion: 'ecc.context-eval.v2', registration: pin, evidence: injected ? 'injected-provider' : resolvedFamily === 'claude' ? 'claude-json' : 'codex-jsonl', installs, authentication: injected ? 'injected' : liveProvider.authentication, credentialsRetained: false, calls: state.calls, bounds: { maxCalls, deadlineMs, callTimeoutMs }, selection, outcomes, summary, - selectionSummary: { n: selection.length, successes: selectionSuccesses, - categories: [...new Set(selection.map(row => row.category))].map(category => ({ category, - n: selection.filter(row => row.category === category).length, - successes: selection.filter(row => row.category === category && row.passed).length })), - interval: wilson(selectionSuccesses, selection.length), method: 'wilson-95-descriptive-purposive-sample' }, + selectionSummary: summarizeSelection(selection), gate: { status: insufficient ? 'insufficient-sample' : injected ? 'synthetic-only' : 'review-required', nonInferioritySupported: !insufficient && !injected && summary.pairs.every(p => p.interval[0] >= -pin.nonInferiorityMargin), releaseApproved: false }, nativeInvocation: 'unobserved', @@ -842,6 +942,18 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re artifactRetention: harvester ? 'session-jsonl-per-task-trial' : 'none', ...metricsSince(state.metrics, 0) }; } +function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), registration, + repeats = 1, provider, family, allowRealProvider = false, allowCredentialedTools = false, + executable, model, effort, authHome, environments, + arms = undefined, artifactDir = null, maxCalls = 300, deadlineMs = 3600000, callTimeoutMs = 300000 } = {}) { + const options = { repoRoot, corpus, registration, repeats, provider, family, allowRealProvider, + allowCredentialedTools, executable, model, effort, authHome, environments, arms, artifactDir, + maxCalls, deadlineMs, callTimeoutMs }; + const plan = planEvaluation(options); + const run = executeEvaluation(options, plan); + return buildEvaluationReport(options, plan, run); +} + module.exports = { loadCorpus, preregister, runEvaluation, parseCodexJsonl, parseClaudeJson, summarize, wilson, runCheck, runScoredCheck, createAuthLease, createCodexProvider, createClaudeProvider, prepareEnvironments, prepareClaudeEnvironments, exportLegacySource, providerFamily, resolveFamily, readClaudeKeychainToken }; diff --git a/docs/ja-JP/commands/multi-backend.md b/docs/ja-JP/commands/multi-backend.md index 17ce771fc..99787725e 100644 --- a/docs/ja-JP/commands/multi-backend.md +++ b/docs/ja-JP/commands/multi-backend.md @@ -34,7 +34,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -49,7 +49,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> diff --git a/docs/ja-JP/commands/multi-execute.md b/docs/ja-JP/commands/multi-execute.md index 393b8f1b3..d45ca8228 100644 --- a/docs/ja-JP/commands/multi-execute.md +++ b/docs/ja-JP/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # セッション再開呼び出し(推奨) - 実装プロトタイプ Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <タスクの説明> @@ -40,7 +40,7 @@ EOF", # 新規セッション呼び出し - 実装プロトタイプ Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <タスクの説明> @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Scope: 最終的なコード変更を監査。 @@ -80,7 +80,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/ja-JP/commands/multi-frontend.md b/docs/ja-JP/commands/multi-frontend.md index 3d2360b5c..9dbdff02c 100644 --- a/docs/ja-JP/commands/multi-frontend.md +++ b/docs/ja-JP/commands/multi-frontend.md @@ -34,7 +34,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -49,7 +49,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> diff --git a/docs/ja-JP/commands/multi-plan.md b/docs/ja-JP/commands/multi-plan.md index 433595745..5bd2095c0 100644 --- a/docs/ja-JP/commands/multi-plan.md +++ b/docs/ja-JP/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件> @@ -39,7 +39,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/ja-JP/commands/multi-workflow.md b/docs/ja-JP/commands/multi-workflow.md index d2eabf098..8d40355e9 100644 --- a/docs/ja-JP/commands/multi-workflow.md +++ b/docs/ja-JP/commands/multi-workflow.md @@ -38,7 +38,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -53,7 +53,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -68,7 +68,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/tr/commands/multi-backend.md b/docs/tr/commands/multi-backend.md index e551576e0..2f101fc55 100644 --- a/docs/tr/commands/multi-backend.md +++ b/docs/tr/commands/multi-backend.md @@ -34,7 +34,7 @@ Backend odaklı iş akışı (Research → Ideation → Plan → Execute → Opt ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -49,7 +49,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docs/tr/commands/multi-execute.md b/docs/tr/commands/multi-execute.md index a9490efb7..822bad2ab 100644 --- a/docs/tr/commands/multi-execute.md +++ b/docs/tr/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # Session devam ettirme çağrısı (önerilen) - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", # Yeni session çağrısı - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: Audit the final code changes. @@ -80,7 +80,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/tr/commands/multi-frontend.md b/docs/tr/commands/multi-frontend.md index babf3cf51..5d873eaa1 100644 --- a/docs/tr/commands/multi-frontend.md +++ b/docs/tr/commands/multi-frontend.md @@ -34,7 +34,7 @@ Frontend odaklı iş akışı (Research → Ideation → Plan → Execute → Op ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -49,7 +49,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docs/tr/commands/multi-plan.md b/docs/tr/commands/multi-plan.md index 30e9d1017..b5686a484 100644 --- a/docs/tr/commands/multi-plan.md +++ b/docs/tr/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -39,7 +39,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/tr/commands/multi-workflow.md b/docs/tr/commands/multi-workflow.md index 0e94a2c75..9e7db5796 100644 --- a/docs/tr/commands/multi-workflow.md +++ b/docs/tr/commands/multi-workflow.md @@ -38,7 +38,7 @@ Kalite kontrol noktaları, MCP servisleri ve multi-model işbirliği ile yapıla ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -68,7 +68,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/zh-CN/commands/multi-backend.md b/docs/zh-CN/commands/multi-backend.md index 5eb5c6bac..6a3f2955d 100644 --- a/docs/zh-CN/commands/multi-backend.md +++ b/docs/zh-CN/commands/multi-backend.md @@ -35,7 +35,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示路径> 需求: <增强后的需求(若未增强则为 $ARGUMENTS)> @@ -50,7 +50,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示路径> 需求: <增强后的需求(若未增强则为 $ARGUMENTS)> diff --git a/docs/zh-CN/commands/multi-execute.md b/docs/zh-CN/commands/multi-execute.md index 83d067eb3..9ec056f03 100644 --- a/docs/zh-CN/commands/multi-execute.md +++ b/docs/zh-CN/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # 恢复会话调用(推荐)- 实现原型 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", # 新建会话调用 - 实现原型 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: 审计最终的代码变更。 @@ -81,7 +81,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`:当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 +* ``:当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 **角色提示**: diff --git a/docs/zh-CN/commands/multi-frontend.md b/docs/zh-CN/commands/multi-frontend.md index cc52bf34b..5ce96d19b 100644 --- a/docs/zh-CN/commands/multi-frontend.md +++ b/docs/zh-CN/commands/multi-frontend.md @@ -35,7 +35,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(若未增强则为$ARGUMENTS)> @@ -50,7 +50,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(若未增强则为$ARGUMENTS)> diff --git a/docs/zh-CN/commands/multi-plan.md b/docs/zh-CN/commands/multi-plan.md index 46e60fb7a..005ef9088 100644 --- a/docs/zh-CN/commands/multi-plan.md +++ b/docs/zh-CN/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 +* ``: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 **角色提示**: diff --git a/docs/zh-CN/commands/multi-workflow.md b/docs/zh-CN/commands/multi-workflow.md index c29e661fc..48a7c9ba3 100644 --- a/docs/zh-CN/commands/multi-workflow.md +++ b/docs/zh-CN/commands/multi-workflow.md @@ -39,7 +39,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(如未增强则为$ARGUMENTS)> @@ -54,7 +54,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(如未增强则为$ARGUMENTS)> @@ -70,7 +70,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意末尾空格);对于 codex 使用空字符串 +* ``: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意末尾空格);对于 codex 使用空字符串 **角色提示词**: diff --git a/scripts/ci/catalog.js b/scripts/ci/catalog.js index 2a079082d..1aa5d8a0b 100644 --- a/scripts/ci/catalog.js +++ b/scripts/ci/catalog.js @@ -316,13 +316,29 @@ function parseCrossHarnessIdentityExpectations(content, source) { ]; } +/** + * Build a replacer for a summary line whose capture groups alternate between + * literal text and the agent, skill, and command counts. `groupCount` is the + * number of capture groups in the regex; `countGroups` lists the 1-based + * groups that hold the three counts, in that order. Every other group is + * written back verbatim. + */ +function catalogCountReplacer(catalog, groupCount, countGroups) { + const counts = [catalog.agents.count, catalog.skills.count, catalog.commands.count]; + return (...args) => { + const groups = args.slice(1, groupCount + 1); + countGroups.forEach((groupIndex, position) => { + groups[groupIndex - 1] = counts[position]; + }); + return groups.join(''); + }; +} + function syncCrossHarnessIdentity(content, catalog, source) { return replaceOrThrow( content, /(with\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\s+skills,\s+(?:and\s+)?)(\d+)(\s+commands)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => ( - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}` - ), + catalogCountReplacer(catalog, 7, [2, 4, 6]), source ); } @@ -482,8 +498,7 @@ function syncEnglishAgents(content, catalog) { nextContent = replaceOrThrow( nextContent, /(providing\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\+?)(\s+skills,\s+)(\d+)(\s+commands)/i, - (_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 8, [2, 4, 7]), 'AGENTS.md summary' ); nextContent = replaceOrThrow( @@ -512,8 +527,7 @@ function syncZhRootReadme(content, catalog) { return replaceOrThrow( content, /(你现在可以使用\s+)(\d+)(\s+个代理、\s*)(\d+)(\s*个技能和\s*)(\d+)(\s*个命令[。.!!]?)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 7, [2, 4, 6]), 'README.zh-CN.md quick-start summary' ); } @@ -524,8 +538,7 @@ function syncZhDocsReadme(content, catalog) { nextContent = replaceOrThrow( nextContent, /(你现在可以使用\s+)(\d+)(\s+个智能体、\s*)(\d+)(\s*项技能和\s*)(\d+)(\s*个命令了[。.!!]?)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 7, [2, 4, 6]), 'docs/zh-CN/README.md quick-start summary' ); nextContent = replaceOrThrow( @@ -574,8 +587,7 @@ function syncZhAgents(content, catalog) { nextContent = replaceOrThrow( nextContent, /(提供\s+)(\d+)(\s+个专业代理、\s*)(\d+)(\+?)(\s*项技能、\s*)(\d+)(\s+条命令)/i, - (_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 8, [2, 4, 7]), 'docs/zh-CN/AGENTS.md summary' ); nextContent = replaceOrThrow( @@ -616,8 +628,7 @@ function syncCatalogDescription(content, catalog, source, getDescription, setDes const nextDescription = replaceOrThrow( description, /(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+)(\d+)(\s+legacy command shims?)/i, - (_, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 6, [1, 3, 5]), source ); diff --git a/scripts/ci/pr-burst-guard.js b/scripts/ci/pr-burst-guard.js new file mode 100644 index 000000000..d7f3f8653 --- /dev/null +++ b/scripts/ci/pr-burst-guard.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** Metadata-only moderation. Never execute PR code or use PR titles/bodies. */ +const fs = require('node:fs'); +const path = require('node:path'); + +const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i; + +function validatePolicy(policy) { + for (const key of ['windowMinutes', 'closeAbove', 'blockAbove', 'maxOpen']) { + if (!Number.isSafeInteger(policy[key]) || policy[key] < 1) throw new Error(`Invalid policy ${key}`); + } + if (policy.blockAbove <= policy.closeAbove) throw new Error('blockAbove must exceed closeAbove'); + if (!Array.isArray(policy.deniedUserIDs) || !policy.deniedUserIDs.every(Number.isSafeInteger)) { + throw new Error('Invalid deniedUserIDs'); + } + if (!Array.isArray(policy.notify) || !policy.notify.every(login => LOGIN.test(login))) { + throw new Error('Invalid notification logins'); + } +} + +function githubAPI(token, fetcher = fetch) { + if (!token) throw new Error('GitHub token is required'); + return async (method, route, body) => { + if (!/^\/[a-zA-Z0-9][a-zA-Z0-9/_.?=&%[\]-]*$/.test(route)) throw new Error('Invalid GitHub API path'); + const response = await fetcher(`https://api.github.com${route}`, { + method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + // Never print response bodies, which can contain attacker-controlled text. + const error = new Error(`GitHub ${method} failed: HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return response.status === 204 ? null : response.json(); + }; +} + +async function listPages(api, route, stop = () => false) { + const rows = []; + for (let page = 1; page <= 100; page++) { + const batch = await api('GET', `${route}&per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error('Expected GitHub list response'); + rows.push(...batch); + if (batch.length < 100 || stop(batch)) return rows; + } + throw new Error('Pagination limit reached; refusing an incomplete moderation decision'); +} + +function evaluate(rows, current, policy, end) { + validatePolicy(policy); + if (!Number.isFinite(end)) throw new Error('Invalid window timestamp'); + const since = end - policy.windowMinutes * 60000; + const recent = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id && Date.parse(pr.created_at) >= since && Date.parse(pr.created_at) <= end) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const denied = policy.deniedUserIDs.includes(current.user.id); + const excess = denied ? [current] : recent.slice(policy.closeAbove); + return { count: recent.length, denied, block: denied || recent.length > policy.blockAbove, + excess: excess.map(pr => ({ number: pr.number, state: pr.state })), + since: new Date(since).toISOString(), until: new Date(end).toISOString() }; +} + +function strongestWindow(rows, current, policy, eventTime) { + const ordered = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const times = ordered.map(pr => Date.parse(pr.created_at)); + const excess = new Map(evaluate(rows, current, policy, eventTime).excess.map(pr => [pr.number, pr])); + let left = 0; + let count = 0; + let end = eventTime; + for (let right = 0; right < times.length; right++) { + while (times[left] < times[right] - policy.windowMinutes * 60000) left++; + if (times[right] >= eventTime && right - left + 1 >= count) { + count = right - left + 1; + end = times[right]; + } + if (times[right] >= eventTime && right - left + 1 > policy.closeAbove) { + excess.set(ordered[right].number, { number: ordered[right].number, state: ordered[right].state }); + } + } + const peak = evaluate(rows, current, policy, end); + return { ...peak, excess: peak.denied ? peak.excess : [...excess.values()] }; +} + +/** Open-PR cap: keep the author's oldest `maxOpen` open PRs, close the rest. PRs the burst + * guard already closed in this run are treated as closed so nothing is mutated twice. */ +function capExcess(openRows, current, policy, alreadyClosed = new Set()) { + validatePolicy(policy); + const open = [...new Map([...openRows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id && pr.state === 'open' && !alreadyClosed.has(pr.number)) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + return { openCount: open.length, maxOpen: policy.maxOpen, + capExcess: open.slice(policy.maxOpen).map(pr => ({ number: pr.number, state: pr.state })) }; +} + +const CAP_MARKER = ''; + +async function enforceCap(api, root, pr, current, result) { + try { + await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' }); + const comments = await listPages(api, `${root}/issues/${pr.number}/comments?sort=created`); + if (comments.some(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(CAP_MARKER))) return; + await api('POST', `${root}/issues/${pr.number}/comments`, { body: [CAP_MARKER, + `Closed automatically: @${current.user.login} has ${result.openCount} open pull requests in this repository, above the limit of ${result.maxOpen} per contributor.`, + '', 'Get existing PRs merged, or close the ones you no longer need, then reopen this one. The limit is about review capacity, not the quality of this patch.', + 'Contributors with repository write access are exempt. Policy: `.github/PR_MODERATION.md`.'].join('\n') }); + } catch (error) { + result.errors.push(`#${pr.number}: ${error.message}`); + } +} + +async function blockAuthor(blockAPI, owner, login) { + if (!blockAPI) return 'Unavailable: PR_MODERATION_BLOCK_TOKEN is not configured'; + try { + if (owner.type !== 'User') throw new Error('Only personal repository owners are supported'); + const identity = await blockAPI('GET', '/user'); + if (identity.login.toLowerCase() !== owner.login.toLowerCase()) { + throw new Error('Block token must use the repository owner identity'); + } + await blockAPI('PUT', `/user/blocks/${login}`); + return 'Blocked by repository owner (all repositories owned by this account)'; + } catch (error) { + return `Failed: ${error.message}`; + } +} + +async function notify(api, root, current, result, policy) { + // A bot-authored ledger prevents contributors from spoofing a suppression marker. + const marker = ``; + const issues = await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`, + batch => batch.some(issue => issue.created_at && issue.created_at.slice(0, 10) < result.until.slice(0, 10))); + const existing = issues.find(issue => !issue.pull_request && issue.user.login === 'github-actions[bot]' + && issue.body?.includes(marker)); + const escalation = ''; + if (existing?.body.includes(escalation) && !result.block) return; + const mentions = policy.notify.map(login => `@${login}`).join(' '); + const body = [marker, result.block ? escalation : '', `${mentions}: PR submission moderation triggered.`, + '', `Author: @${current.user.login} (user ID ${current.user.id}).`, + `Observed: ${result.count} PR creations between ${result.since} and ${result.until}.`, + `Policy: close PRs beyond ${policy.closeAbove}; block beyond ${policy.blockAbove} in ${policy.windowMinutes} minutes.`, + `Denied account: ${result.denied}.`, `Blocking: ${result.blockStatus}.`, + `Excess PRs across scanned windows: ${result.excess.map(pr => `#${pr.number}`).join(', ') || 'none'}.`, + `Enforcement errors: ${result.errors.join('; ') || 'none'}.`, + '', 'Counts include closed and draft PRs. Volume triggers moderation, not a judgment about individual patch quality.', + 'Review the Actions run and PR timeline before lifting an account block.'].filter(Boolean).join('\n'); + if (!existing) { + await api('POST', `${root}/issues`, { title: `PR burst alert: ${current.user.login}`, body }); + } else { + // Notify again only when a soft-limit alert escalates to blocking. + if (result.block && !existing.body.includes(escalation)) { + await api('POST', `${root}/issues/${existing.number}/comments`, { + body: `${mentions}: the PR burst escalated. Blocking: ${result.blockStatus}.`, + }); + } + await api('PATCH', `${root}/issues/${existing.number}`, { body }); + } +} + +async function moderate({ api, blockAPI, repository, number, action = 'opened', policy, dryRun = false, now = Date.now() }) { + validatePolicy(policy); + if (!/^[a-z\d-]+\/[a-z\d_.-]+$/i.test(repository) || !Number.isSafeInteger(number) || number < 1) { + throw new Error('Invalid repository or PR number'); + } + if (!['opened', 'reopened'].includes(action)) throw new Error('Unsupported PR event'); + const root = `/repos/${repository}`; + const repo = await api('GET', root); + const current = await api('GET', `${root}/pulls/${number}`); + if (!LOGIN.test(current.user.login)) throw new Error('Invalid PR author login'); + let permission; + try { + permission = (await api('GET', `${root}/collaborators/${current.user.login}/permission`)).permission; + } catch (error) { + if (error.status !== 404) throw error; + permission = 'none'; + } + if (['admin', 'maintain', 'write'].includes(permission)) return { exempt: true, author: current.user.login }; + // Anchor opened events to GitHub's creation time so Actions queue delays cannot erase a burst. + const end = action === 'opened' ? Date.parse(current.created_at) : now; + const since = end - policy.windowMinutes * 60000; + const rows = await listPages(api, `${root}/pulls?state=all&sort=created&direction=desc`, + batch => batch.some(pr => Date.parse(pr.created_at) < since)); + // Inspect later windows too: delivery order and replaced pending runs must not + // let an older event conceal a newer burst already present in the API snapshot. + const result = { ...strongestWindow(rows, current, policy, end), author: current.user.login, + dryRun, errors: [], blockStatus: 'Below blocking threshold' }; + // Total open PRs is a separate, unwindowed list: the burst scan stops at the window edge. + const openRows = await listPages(api, `${root}/pulls?state=open&sort=created&direction=desc`); + Object.assign(result, capExcess(openRows, current, policy, new Set(result.excess.map(pr => pr.number)))); + if (dryRun) { + if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking'; + return result; + } + if (result.excess.length > 0) await enforceBurst({ api, blockAPI, root, repo, current, result, policy }); + for (const pr of result.capExcess) await enforceCap(api, root, pr, current, result); + return result; +} + +async function enforceBurst({ api, blockAPI, root, repo, current, result, policy }) { + if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login); + for (const pr of result.excess) { + if (pr.state !== 'open') continue; + try { + await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' }); + await api('PUT', `${root}/issues/${pr.number}/lock`, { lock_reason: 'spam' }); + } catch (error) { + result.errors.push(`#${pr.number}: ${error.message}`); + } + } + await notify(api, root, current, result, policy); +} + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) : {}; + const prIndex = process.argv.indexOf('--pr'); + const number = prIndex >= 0 ? Number(process.argv[prIndex + 1]) : event.pull_request?.number; + const repository = process.env.GITHUB_REPOSITORY; + const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../../.github/pr-burst-policy.json'), 'utf8')); + const result = await moderate({ api: githubAPI(process.env.GITHUB_TOKEN), + blockAPI: !dryRun && process.env.PR_MODERATION_BLOCK_TOKEN ? githubAPI(process.env.PR_MODERATION_BLOCK_TOKEN) : undefined, + repository, number, action: event.action || 'opened', policy, dryRun }); + const report = JSON.stringify(result, null, 2); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## PR burst guard\n\n\`\`\`json\n${report}\n\`\`\`\n`); + if (result.errors?.length || /^(Failed|Unavailable):/.test(result.blockStatus || '')) process.exitCode = 1; +} + +if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); +module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow, capExcess }; diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js index 2156b07dd..338bfbb83 100644 --- a/scripts/ci/validate-hooks.js +++ b/scripts/ci/validate-hooks.js @@ -82,6 +82,93 @@ function isNonEmptyStringArray(value) { return Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item)); } +function isStringRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.values(value).every(item => typeof item === 'string'); +} + +const INLINE_ESCAPES = { '\\': '\\', '"': '"', n: '\n', t: '\t' }; + +/** Unescape a shell double-quoted `node -e` body in one pass, so `\\n` stays a literal backslash and n. */ +function unescapeInlineJs(source) { + return source.replace(/\\(["\\nt])/g, (match, ch) => INLINE_ESCAPES[ch]); +} + +/** + * Compile the inline program of a `node -e "..."` command so a typo in + * hooks.json fails validation instead of every hook invocation. + * @returns {boolean} true if errors were found + */ +function validateInlineJs(command, label) { + const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); + if (!nodeEMatch) return false; + try { + new vm.Script(unescapeInlineJs(nodeEMatch[1])); + return false; + } catch (syntaxErr) { + console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); + return true; + } +} + +/** @returns {boolean} true if errors were found */ +function validateCommandHook(hook, label) { + let hasErrors = false; + + if ('async' in hook && typeof hook.async !== 'boolean') { + console.error(`ERROR: ${label} 'async' must be a boolean`); + hasErrors = true; + } + + if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) { + console.error(`ERROR: ${label} missing or invalid 'command' field`); + hasErrors = true; + } else if (typeof hook.command === 'string' && validateInlineJs(hook.command, label)) { + hasErrors = true; + } + + return hasErrors; +} + +/** @returns {boolean} true if errors were found */ +function validateHttpHook(hook, label) { + let hasErrors = false; + + if (!isNonEmptyString(hook.url)) { + console.error(`ERROR: ${label} missing or invalid 'url' field`); + hasErrors = true; + } + + if ('headers' in hook && !isStringRecord(hook.headers)) { + console.error(`ERROR: ${label} 'headers' must be an object with string values`); + hasErrors = true; + } + + if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) { + console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`); + hasErrors = true; + } + + return hasErrors; +} + +/** prompt and agent hooks share one shape. @returns {boolean} true if errors were found */ +function validatePromptHook(hook, label) { + let hasErrors = false; + + if (!isNonEmptyString(hook.prompt)) { + console.error(`ERROR: ${label} missing or invalid 'prompt' field`); + hasErrors = true; + } + + if ('model' in hook && !isNonEmptyString(hook.model)) { + console.error(`ERROR: ${label} 'model' must be a non-empty string`); + hasErrors = true; + } + + return hasErrors; +} + /** * Validate a single hook entry has required fields and valid inline JS * @param {object} hook - Hook object with type and command fields @@ -105,27 +192,7 @@ function validateHookEntry(hook, label) { } if (hook.type === 'command') { - if ('async' in hook && typeof hook.async !== 'boolean') { - console.error(`ERROR: ${label} 'async' must be a boolean`); - hasErrors = true; - } - - if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) { - console.error(`ERROR: ${label} missing or invalid 'command' field`); - hasErrors = true; - } else if (typeof hook.command === 'string') { - const nodeEMatch = hook.command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); - if (nodeEMatch) { - try { - new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); - } catch (syntaxErr) { - console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); - hasErrors = true; - } - } - } - - return hasErrors; + return validateCommandHook(hook, label) || hasErrors; } if ('async' in hook) { @@ -134,35 +201,10 @@ function validateHookEntry(hook, label) { } if (hook.type === 'http') { - if (!isNonEmptyString(hook.url)) { - console.error(`ERROR: ${label} missing or invalid 'url' field`); - hasErrors = true; - } - - if ('headers' in hook && (typeof hook.headers !== 'object' || hook.headers === null || Array.isArray(hook.headers) || !Object.values(hook.headers).every(value => typeof value === 'string'))) { - console.error(`ERROR: ${label} 'headers' must be an object with string values`); - hasErrors = true; - } - - if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) { - console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`); - hasErrors = true; - } - - return hasErrors; + return validateHttpHook(hook, label) || hasErrors; } - if (!isNonEmptyString(hook.prompt)) { - console.error(`ERROR: ${label} missing or invalid 'prompt' field`); - hasErrors = true; - } - - if ('model' in hook && !isNonEmptyString(hook.model)) { - console.error(`ERROR: ${label} 'model' must be a non-empty string`); - hasErrors = true; - } - - return hasErrors; + return validatePromptHook(hook, label) || hasErrors; } /** @@ -237,7 +279,15 @@ function validateAgainstSchema(document, schemaPath, label) { return true; } -function validateHooks() { +function isObjectFormat(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function isValidMatcherValue(value) { + return typeof value === 'string' || (typeof value === 'object' && value !== null); +} + +function readHooksFile() { if (!fs.existsSync(HOOKS_FILE)) { console.log('No hooks.json found, skipping validation'); process.exit(0); @@ -250,45 +300,193 @@ function validateHooks() { console.error(`ERROR: Invalid JSON in hooks.json: ${e.message}`); process.exit(1); } + return data; +} - // Without a sidecar, hooks.json keeps its legacy inline ids. With one, the - // sidecar is the sole owner of id/description and hooks.json must stay - // within Claude Code's schema. - let metadata = null; - const metadataPath = metadataPathFor(HOOKS_FILE); - if (fs.existsSync(metadataPath)) { +/** + * Load and check the hooks.metadata.json sidecar, if present. + * + * Without a sidecar, hooks.json keeps its legacy inline ids. With one, the + * sidecar is the sole owner of id/description and hooks.json must stay + * within Claude Code's schema. Exits the process on any sidecar error. + * + * @returns {object|null} parsed (and possibly refreshed) metadata, or null + */ +function loadMetadata(data, metadataPath) { + if (!fs.existsSync(metadataPath)) { + return null; + } + + let metadata; + try { + metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + } catch (e) { + console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`); + process.exit(1); + } + + if (validateHarnessCompatibility(data)) { + process.exit(1); + } + + if (UPDATE_FINGERPRINTS) { try { - metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - } catch (e) { - console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`); + metadata = withRefreshedFingerprints(data, metadata); + } catch (error) { + console.error(`ERROR: ${error.message}`); process.exit(1); } + } - if (validateHarnessCompatibility(data)) { - process.exit(1); + if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) { + process.exit(1); + } + + const mismatches = findMetadataMismatches(data, metadata); + if (mismatches.length > 0) { + for (const mismatch of mismatches) { + console.error(`ERROR: ${mismatch}`); + } + process.exit(1); + } + + return metadata; +} + +/** + * Validate each hook entry of one matcher. + * @returns {boolean} true if errors were found + */ +function validateHookEntries(entries, label) { + let hasErrors = false; + for (let j = 0; j < entries.length; j++) { + if (validateHookEntry(entries[j], `${label}.hooks[${j}]`)) { + hasErrors = true; + } + } + return hasErrors; +} + +/** + * Validate one matcher of the object format. `ids` tracks stable ids when the + * document requires them, and is null otherwise. + * @returns {boolean} true if errors were found + */ +function validateObjectMatcher(eventType, index, matcher, ids) { + const matcherLabel = `${eventType}[${index}]`; + let hasErrors = false; + + if (ids && !isNonEmptyString(matcher.id)) { + console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`); + hasErrors = true; + } else if (ids && ids.has(matcher.id)) { + console.error( + `ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${ids.get(matcher.id)})` + ); + hasErrors = true; + } else if (ids) { + ids.set(matcher.id, matcherLabel); + } + + if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) { + console.error(`ERROR: ${matcherLabel} missing 'matcher' field`); + hasErrors = true; + } else if ('matcher' in matcher && !isValidMatcherValue(matcher.matcher)) { + console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`); + hasErrors = true; + } + + if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) { + console.error(`ERROR: ${matcherLabel} missing 'hooks' array`); + hasErrors = true; + } else if (validateHookEntries(matcher.hooks, matcherLabel)) { + hasErrors = true; + } + + return hasErrors; +} + +/** + * Object format: { EventType: [matchers] } + * @returns {{ hasErrors: boolean, totalMatchers: number }} + */ +function validateObjectFormat(hooks, requiresStableIds) { + const ids = requiresStableIds ? new Map() : null; + let hasErrors = false; + let totalMatchers = 0; + + for (const [eventType, matchers] of Object.entries(hooks)) { + if (!VALID_EVENTS.includes(eventType)) { + console.error(`ERROR: Invalid event type: ${eventType}`); + hasErrors = true; + continue; } - if (UPDATE_FINGERPRINTS) { - try { - metadata = withRefreshedFingerprints(data, metadata); - } catch (error) { - console.error(`ERROR: ${error.message}`); - process.exit(1); + if (!Array.isArray(matchers)) { + console.error(`ERROR: ${eventType} must be an array`); + hasErrors = true; + continue; + } + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (typeof matcher !== 'object' || matcher === null) { + console.error(`ERROR: ${eventType}[${i}] is not an object`); + hasErrors = true; + continue; } - } - - if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) { - process.exit(1); - } - - const mismatches = findMetadataMismatches(data, metadata); - if (mismatches.length > 0) { - for (const mismatch of mismatches) { - console.error(`ERROR: ${mismatch}`); + if (validateObjectMatcher(eventType, i, matcher, ids)) { + hasErrors = true; } - process.exit(1); + totalMatchers++; } + } + return { hasErrors, totalMatchers }; +} + +/** @returns {boolean} true if errors were found */ +function validateLegacyHook(hook, index) { + let hasErrors = false; + + if (!('matcher' in hook)) { + console.error(`ERROR: Hook ${index} missing 'matcher' field`); + hasErrors = true; + } else if (!isValidMatcherValue(hook.matcher)) { + console.error(`ERROR: Hook ${index} has invalid 'matcher' field`); + hasErrors = true; + } + + if (!hook.hooks || !Array.isArray(hook.hooks)) { + console.error(`ERROR: Hook ${index} missing 'hooks' array`); + hasErrors = true; + } else if (validateHookEntries(hook.hooks, `Hook ${index}`)) { + hasErrors = true; + } + + return hasErrors; +} + +/** + * Array format (legacy): [{ matcher, hooks }] + * @returns {{ hasErrors: boolean, totalMatchers: number }} + */ +function validateArrayFormat(hooks) { + let hasErrors = false; + for (let i = 0; i < hooks.length; i++) { + if (validateLegacyHook(hooks[i], i)) { + hasErrors = true; + } + } + return { hasErrors, totalMatchers: hooks.length }; +} + +function validateHooks() { + let data = readHooksFile(); + + const metadataPath = metadataPathFor(HOOKS_FILE); + const metadata = loadMetadata(data, metadataPath); + if (metadata) { // Validate the merged view so the id/description rules below still apply. data = applyHooksMetadata(data, metadata); } @@ -300,103 +498,19 @@ function validateHooks() { // Support both object format { hooks: {...} } and array format const hooks = data.hooks || data; - const requiresStableIds = Boolean( - data - && typeof data === 'object' - && !Array.isArray(data) - && data.hooks - && typeof data.hooks === 'object' - && !Array.isArray(data.hooks) - ); - let hasErrors = false; - let totalMatchers = 0; - const matcherIdLocations = new Map(); + const requiresStableIds = isObjectFormat(data) && isObjectFormat(data.hooks); + let result; if (typeof hooks === 'object' && !Array.isArray(hooks)) { - // Object format: { EventType: [matchers] } - for (const [eventType, matchers] of Object.entries(hooks)) { - if (!VALID_EVENTS.includes(eventType)) { - console.error(`ERROR: Invalid event type: ${eventType}`); - hasErrors = true; - continue; - } - - if (!Array.isArray(matchers)) { - console.error(`ERROR: ${eventType} must be an array`); - hasErrors = true; - continue; - } - - for (let i = 0; i < matchers.length; i++) { - const matcher = matchers[i]; - if (typeof matcher !== 'object' || matcher === null) { - console.error(`ERROR: ${eventType}[${i}] is not an object`); - hasErrors = true; - continue; - } - const matcherLabel = `${eventType}[${i}]`; - if (requiresStableIds && !isNonEmptyString(matcher.id)) { - console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`); - hasErrors = true; - } else if (requiresStableIds && matcherIdLocations.has(matcher.id)) { - console.error( - `ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${matcherIdLocations.get(matcher.id)})` - ); - hasErrors = true; - } else if (requiresStableIds) { - matcherIdLocations.set(matcher.id, matcherLabel); - } - if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) { - console.error(`ERROR: ${matcherLabel} missing 'matcher' field`); - hasErrors = true; - } else if ('matcher' in matcher && typeof matcher.matcher !== 'string' && (typeof matcher.matcher !== 'object' || matcher.matcher === null)) { - console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`); - hasErrors = true; - } - if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) { - console.error(`ERROR: ${matcherLabel} missing 'hooks' array`); - hasErrors = true; - } else { - // Validate each hook entry - for (let j = 0; j < matcher.hooks.length; j++) { - if (validateHookEntry(matcher.hooks[j], `${matcherLabel}.hooks[${j}]`)) { - hasErrors = true; - } - } - } - totalMatchers++; - } - } + result = validateObjectFormat(hooks, requiresStableIds); } else if (Array.isArray(hooks)) { - // Array format (legacy) - for (let i = 0; i < hooks.length; i++) { - const hook = hooks[i]; - if (!('matcher' in hook)) { - console.error(`ERROR: Hook ${i} missing 'matcher' field`); - hasErrors = true; - } else if (typeof hook.matcher !== 'string' && (typeof hook.matcher !== 'object' || hook.matcher === null)) { - console.error(`ERROR: Hook ${i} has invalid 'matcher' field`); - hasErrors = true; - } - if (!hook.hooks || !Array.isArray(hook.hooks)) { - console.error(`ERROR: Hook ${i} missing 'hooks' array`); - hasErrors = true; - } else { - // Validate each hook entry - for (let j = 0; j < hook.hooks.length; j++) { - if (validateHookEntry(hook.hooks[j], `Hook ${i}.hooks[${j}]`)) { - hasErrors = true; - } - } - } - totalMatchers++; - } + result = validateArrayFormat(hooks); } else { console.error('ERROR: hooks.json must be an object or array'); process.exit(1); } - if (hasErrors) { + if (result.hasErrors) { process.exit(1); } @@ -405,7 +519,7 @@ function validateHooks() { console.log(`Updated fingerprints in ${METADATA_FILENAME}`); } - console.log(`Validated ${totalMatchers} hook matchers`); + console.log(`Validated ${result.totalMatchers} hook matchers`); } validateHooks(); diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 99589b299..5b1cfcee2 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -216,7 +216,23 @@ function gitEnvironmentOverride(environment, budget) { return false; } -function checkGitWords(words, budget, start = 0, environmentOverride = false) { +// True when a git global option at words[index] redirects hooks via config. +// The separate-operand forms always charge the operand so budget accounting +// does not depend on whether an earlier option already matched. +function gitGlobalOptionRedirectsHooks(words, index, value, budget) { + if (value === '-c' || value === '--config-env') { + const setting = words[index + 1]?.value || ''; + budget.spend(setting.length + 1); + return isHookRedirectSetting(setting); + } + if (value.startsWith('-c')) return isHookRedirectSetting(value.slice(2)); + if (value.startsWith('--config-env=')) return isHookRedirectSetting(value.slice('--config-env='.length)); + return false; +} + +// Walk git's global options after the executable. Returns the index of the +// subcommand word and whether any option redirected the hooks path. +function scanGitGlobalOptions(words, budget, start, environmentOverride) { let index = start + 1; let override = environmentOverride; for (; index < words.length; index++) { @@ -224,20 +240,16 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) { budget.spend(value.length + 1); if (!value.startsWith('-')) break; if (value === '--') { index++; break; } - if (value === '-c' || value === '--config-env') { - const setting = words[index + 1]?.value || ''; - budget.spend(setting.length + 1); - override ||= isHookRedirectSetting(setting); - } else if (value.startsWith('-c')) override ||= isHookRedirectSetting(value.slice(2)); - else if (value.startsWith('--config-env=')) override ||= isHookRedirectSetting(value.slice('--config-env='.length)); + if (gitGlobalOptionRedirectsHooks(words, index, value, budget)) override = true; if (GIT_GLOBAL_VALUES.has(value)) index++; } - const command = words[index]?.value; - budget.spend((command?.length || 0) + 1); - if (!PROTECTED_GIT_COMMANDS.has(command)) return null; - if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`; + return { index, override }; +} + +// Scan a protected subcommand's own arguments for a hook-bypass flag. +function findGitBypassFlag(words, budget, start, command) { let skipNext = false; - for (index++; index < words.length; index++) { + for (let index = start; index < words.length; index++) { const value = words[index].value; budget.spend(value.length + 1); if (skipNext) { skipNext = false; continue; } @@ -246,9 +258,19 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) { if (commitOptionConsumesNextValue(value)) { skipNext = true; continue; } if (commitOptionContainsInlineValue(value)) continue; } - if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) { - return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`; - } + if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) return true; + } + return false; +} + +function checkGitWords(words, budget, start = 0, environmentOverride = false) { + const { index, override } = scanGitGlobalOptions(words, budget, start, environmentOverride); + const command = words[index]?.value; + budget.spend((command?.length || 0) + 1); + if (!PROTECTED_GIT_COMMANDS.has(command)) return null; + if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`; + if (findGitBypassFlag(words, budget, index + 1, command)) { + return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`; } return null; } @@ -284,15 +306,45 @@ function validateEnvSplitEscapes(payload, budget) { } } +const ENV_VALUE_OPTIONS = new Set(['-u', '--unset', '-C', '--chdir']); +const ENV_FLAG_OPTIONS = new Set(['-i', '--ignore-environment', '-0', '--null']); +const SUDO_VALUE_OPTIONS = new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']); +const SUDO_FLAG_OPTIONS = new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']); +const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/; +const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/; + +function isEnvSplitFlag(flag) { + return flag === '-S' || flag === '--split-string' + || flag.startsWith('--split-string=') || flag.startsWith('-S'); +} + +// A value-taking wrapper option with its operand attached (`-uNAME`, `--unset=NAME`). +function hasAttachedOperand(flag, values) { + return [...values].some(value => value.startsWith('--') + ? flag.startsWith(`${value}=`) + : flag.startsWith(value) && flag.length > value.length); +} + +// Only literal word splitting is understood for env -S. Keep complex env +// syntax opaque rather than pretending to execute it as a shell. +function isLiteralWordList(scan) { + const command = scan.commands[0]; + return scan.commands.length === 1 && !command.nested.length + && !command.redirects.length && !command.words.some(word => word.dynamic); +} + // Only explicit option grammars remove wrapper operands. Unknown launchers are // opaque/conservative, never guessed from a name found among data arguments. function executableWords(words, budget, inherited = new Map(), callerValues = inherited) { budget.spend(inherited.size + 1); const environments = [new Map(inherited)]; const prefixAssignments = new Map(); + const dynamicAssignments = new Set(); let local = true; let assignmentOnly = true; - const dynamicAssignments = new Set(); + let i = 0; + let assignments = true; + let environmentAssignments = false; function result(values) { return { words: values, environments, prefixAssignments, local, assignmentOnly, dynamicAssignments }; } function suffix(start) { budget.spend(words.length - start); @@ -304,7 +356,7 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in const equals = value.indexOf('='); const append = !environmentAssignments && value[equals - 1] === '+'; const key = value.slice(0, append ? equals - 1 : equals); - if (/^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/.test(key)) { + if (GIT_ENV_NAME.test(key)) { const operand = value.slice(equals + 1); const count = environments.length; budget.spend(count + 1); @@ -336,87 +388,139 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in else environment.delete(name); } } - let i = 0; - let assignments = true; - let environmentAssignments = false; + // Wrapper handlers below return a final result to stop scanning, or + // undefined once the wrapper's own options are consumed and the scan + // should continue with the wrapped command. + function commandBuiltin(token) { + assignmentOnly = false; + local &&= token.value === 'command'; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i++].value; + budget.spend(flag.length + 1); + if (flag === '--') break; + if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]); + if (!/^-p+$/.test(flag)) return suffix(i - 1); + } + assignments = false; + return undefined; + } + function execBuiltin() { + assignmentOnly = false; + local = false; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i++].value; + budget.spend(flag.length + 1); + if (flag === '--') break; + if (/^-[cl]*a$/.test(flag)) i++; + else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1); + if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment(); + } + assignments = false; + return undefined; + } + // env -S splits a literal payload into argv words in place; words[i] is + // then re-examined as the first expanded word. + function envSplitString(flag) { + const separated = flag === '-S' || flag === '--split-string'; + const payload = separated ? words[i + 1]?.value || '' + : flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2); + budget.spend(payload.length + 1); + validateEnvSplitEscapes(payload, budget); + const scan = scanShell(payload, budget); + if (!isLiteralWordList(scan)) return suffix(i - 1); + // Pay for both slices and the expanded array before allocating them. + budget.spend(2 * words.length + scan.commands[0].words.length); + words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))]; + return undefined; + } + function envResetFlag(flag) { + if (flag === '-i' || flag === '--ignore-environment') resetEnvironment(); + if (flag === '-u' || flag === '--unset') resetEnvironment(words[i + 1]?.value || ''); + else if (flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length)); + else if (flag.startsWith('-u')) resetEnvironment(flag.slice(2)); + } + function envWrapper(name) { + assignmentOnly = false; + local = false; + const env = name === 'env'; + const values = env ? ENV_VALUE_OPTIONS : SUDO_VALUE_OPTIONS; + const flags = env ? ENV_FLAG_OPTIONS : SUDO_FLAG_OPTIONS; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i].value; + budget.spend(flag.length + 1); + if (flag === '--') { i++; break; } + if (env && isEnvSplitFlag(flag)) { + const stop = envSplitString(flag); + if (stop) return stop; + continue; + } + if (env) envResetFlag(flag); + if (values.has(flag)) i += 2; + else if (flags.has(flag) || hasAttachedOperand(flag, values)) i++; + else return suffix(i - 1); // Includes opaque env -S / sudo shell modes. + } + assignments = true; + environmentAssignments = true; + return undefined; + } while (i < words.length) { const token = words[i]; budget.spend(token.value.length + token.raw.length + 1); - if (assignments && /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; } + if (assignments && SHELL_ASSIGNMENT.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; } if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; } const name = basename(token.value); - if (name === 'command') { - assignmentOnly = false; - local &&= token.value === 'command'; - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i++].value; - budget.spend(flag.length + 1); - if (flag === '--') break; - if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]); - if (!/^-p+$/.test(flag)) return suffix(i - 1); - } - assignments = false; continue; - } - if (name === 'exec') { - assignmentOnly = false; - local = false; - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i++].value; - budget.spend(flag.length + 1); - if (flag === '--') break; - if (/^-[cl]*a$/.test(flag)) i++; - else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1); - if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment(); - } - assignments = false; continue; - } - if (name === 'env' || name === 'sudo' || name === 'doas') { - assignmentOnly = false; - local = false; - const env = name === 'env'; - const values = env - ? new Set(['-u', '--unset', '-C', '--chdir']) - : new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']); - const flags = env ? new Set(['-i', '--ignore-environment', '-0', '--null']) : new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']); - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i].value; - budget.spend(flag.length + 1); - if (flag === '--') { i++; break; } - if (env && (flag === '-S' || flag === '--split-string' - || flag.startsWith('--split-string=') || flag.startsWith('-S'))) { - const separated = flag === '-S' || flag === '--split-string'; - const payload = separated ? words[i + 1]?.value || '' - : flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2); - budget.spend(payload.length + 1); - validateEnvSplitEscapes(payload, budget); - const scan = scanShell(payload, budget); - // Only literal word splitting is understood here. Keep complex env - // syntax opaque rather than pretending to execute it as a shell. - if (scan.commands.length !== 1 || scan.commands[0].nested.length - || scan.commands[0].redirects.length || scan.commands[0].words.some(word => word.dynamic)) return suffix(i - 1); - // Pay for both slices and the expanded array before allocating them. - budget.spend(2 * words.length + scan.commands[0].words.length); - words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))]; - continue; - } - if (env && (flag === '-i' || flag === '--ignore-environment')) resetEnvironment(); - if (env && (flag === '-u' || flag === '--unset')) resetEnvironment(words[i + 1]?.value || ''); - else if (env && flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length)); - else if (env && flag.startsWith('-u')) resetEnvironment(flag.slice(2)); - if (values.has(flag)) i += 2; - else if (flags.has(flag) || [...values].some(value => value.startsWith('--') ? flag.startsWith(`${value}=`) : flag.startsWith(value) && flag.length > value.length)) i++; - else return suffix(i - 1); // Includes opaque env -S / sudo shell modes. - } - assignments = true; environmentAssignments = true; continue; - } - return suffix(i); + let stop; + if (name === 'command') stop = commandBuiltin(token); + else if (name === 'exec') stop = execBuiltin(); + else if (name === 'env' || name === 'sudo' || name === 'doas') stop = envWrapper(name); + else return suffix(i); + if (stop) return stop; } return result([]); } +const SHELL_LONG_FLAGS = ['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login']; +const OPAQUE_SHELL = { kind: 'opaque', stdin: true }; + +// Classify one short shell flag inside an option cluster. +function classifyShellFlag(flag, shell) { + // Named-option arity is unproved for sh/dash/ksh: keep the invocation + // opaque instead of consuming a code flag as a guessed option operand. + if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return 'opaque'; + if (flag === 'c') return 'code'; + if (flag === 's') return 'stdin'; + // zsh consumes the rest of this argv word as the option name, or one + // separate word if no suffix exists, then ends this option cluster. + if (shell === 'zsh' && flag === 'o') return 'zshOption'; + // These are not Bash's operand grammar; unmodeled zsh modes stay opaque. + if (shell === 'zsh' && (flag === 'O' || flag === 'b')) return 'opaque'; + if (flag === 'o' || flag === 'O') return 'operand'; + return 'abefhiklmnprtuvxBCEHPTD'.includes(flag) ? 'plain' : 'opaque'; +} + +// Bash accepts either sign and consumes a separate operand for each o/O +// even inside a cluster. The command string follows ALL option processing, +// not necessarily the argv word immediately after the first c flag. +// Returns null when the cluster makes the invocation opaque. +function scanShellOptionCluster(option, words, start, shell, budget) { + const cluster = { next: start + 1, code: false, stdin: false }; + for (let j = 1; j < option.length; j++) { + budget.spend(); + const kind = classifyShellFlag(option[j], shell); + if (kind === 'opaque') return null; + if (kind === 'code') cluster.code = true; + else if (kind === 'stdin') cluster.stdin = true; + else if (kind === 'zshOption') { + if (j + 1 === option.length && cluster.next < words.length) cluster.next++; + return cluster; + } else if (kind === 'operand' && cluster.next < words.length) cluster.next++; + } + return cluster; +} + function shellRole(words, budget, shell) { let i = 1; let stdin = false; @@ -428,33 +532,14 @@ function shellRole(words, budget, shell) { if (!/^[+-]/.test(option)) break; if (option === '--rcfile' || option === '--init-file') { i += 2; continue; } if (option.startsWith('--')) { - if (!['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'].includes(option)) return { kind: 'opaque', stdin: true }; + if (!SHELL_LONG_FLAGS.includes(option)) return { ...OPAQUE_SHELL }; i++; continue; } - // Bash accepts either sign and consumes a separate operand for each o/O - // even inside a cluster. The command string follows ALL option processing, - // not necessarily the argv word immediately after the first c flag. - let next = i + 1; - for (let j = 1; j < option.length; j++) { - budget.spend(); - const flag = option[j]; - // Named-option arity is unproved for sh/dash/ksh: keep the invocation - // opaque instead of consuming a code flag as a guessed option operand. - if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return { kind: 'opaque', stdin: true }; - if (flag === 'c') code = true; - else if (flag === 's') stdin = true; - else if (shell === 'zsh' && flag === 'o') { - // zsh consumes the rest of this argv word as the option name, or one - // separate word if no suffix exists, then ends this option cluster. - if (j + 1 === option.length && next < words.length) next++; - break; - } else if (shell === 'zsh' && (flag === 'O' || flag === 'b')) { - // These are not Bash's operand grammar; unmodeled zsh modes stay opaque. - return { kind: 'opaque', stdin: true }; - } else if (flag === 'o' || flag === 'O') { if (next < words.length) next++; } - else if (!'abefhiklmnprtuvxBCEHPTD'.includes(flag)) return { kind: 'opaque', stdin: true }; - } - i = next; + const cluster = scanShellOptionCluster(option, words, i, shell, budget); + if (!cluster) return { ...OPAQUE_SHELL }; + code ||= cluster.code; + stdin ||= cluster.stdin; + i = cluster.next; } if (code) return { kind: 'shell', code: words[i]?.value, stdin: false }; // A script filename and its positional arguments are not shell source text. @@ -761,7 +846,6 @@ function pipelineSources(command, budget) { return sources; } -const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/; const DECLARATIONS = new Set(['export', 'declare', 'typeset', 'readonly', 'unset']); function shellState(environment, budget) { @@ -825,31 +909,11 @@ function updateShellState(state, normalized, budget) { // Exact builtin names only: /some/path/export is an external executable. const name = words[0]?.value; if (!DECLARATIONS.has(name)) return result(false, false); - let exported = name === 'export' ? true : null; - let readonly = name === 'readonly'; - let passive = false; - let uncertain = dynamicAssignments.size > 0; - let i = 1; - for (; i < words.length; i++) { - const flag = words[i].value; - budget.spend(flag.length + 1); - if (flag === '--') { i++; break; } - if (!/^[+-]/.test(flag)) break; - if (name === 'export' && /^-[npf]+$/.test(flag)) { - if (flag.includes('n')) exported = false; - passive ||= flag.includes('f'); - } else if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) { - if (flag.includes('x')) exported = flag[0] === '-'; - if (flag[0] === '-' && flag.includes('r')) readonly = true; - passive ||= /[pf]/.test(flag); - } else if (name === 'readonly' && /^-[pf]+$/.test(flag)) passive ||= flag.includes('f'); - else if (name === 'unset' && /^-[vf]+$/.test(flag)) passive ||= flag.includes('f'); - else uncertain = true; - } - if (passive && !uncertain) return result(true, false); + const declaration = parseDeclarationFlags(name, words, budget, dynamicAssignments.size > 0); + if (declaration.passive && !declaration.uncertain) return result(true, false); let changed = prefixAssignments.size > 0; assignPrefixes(); - for (; i < words.length; i++) { + for (let i = declaration.index; i < words.length; i++) { const value = words[i].value; budget.spend(2 * value.length + 1); const equals = value.indexOf('='); @@ -859,21 +923,65 @@ function updateShellState(state, normalized, budget) { changed = true; if (name !== 'unset' && equals >= 0) assign(key, value.slice(equals + 1), words[i].dynamic, append); budget.spend(states.length + 1); - for (const current of states) { - if (name === 'unset') { - if (equals < 0 && !current.readonly.has(key)) { - current.variables.delete(key); current.exported.delete(key); - } - } else { - if (exported === true || uncertain) current.exported.add(key); - else if (exported === false) current.exported.delete(key); - if (readonly || uncertain) current.readonly.add(key); - } - } + for (const current of states) applyDeclaration(current, key, equals, declaration); } // Unsupported attributes may transform values or reject the declaration. // Retain old and conservative literal states; never use them to prove reset. - return result(true, changed, uncertain); + return result(true, changed, declaration.uncertain); +} + +// Fold one declaration builtin flag into the parsed attribute set. +function applyDeclarationFlag(declaration, flag) { + const { name } = declaration; + if (name === 'export' && /^-[npf]+$/.test(flag)) { + if (flag.includes('n')) declaration.exported = false; + declaration.passive ||= flag.includes('f'); + return; + } + if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) { + if (flag.includes('x')) declaration.exported = flag[0] === '-'; + if (flag[0] === '-' && flag.includes('r')) declaration.readonly = true; + declaration.passive ||= /[pf]/.test(flag); + return; + } + if (name === 'readonly' && /^-[pf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; } + if (name === 'unset' && /^-[vf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; } + declaration.uncertain = true; +} + +// Parse the option words of export/declare/typeset/readonly/unset. Returns the +// attributes they set and the index of the first operand. +function parseDeclarationFlags(name, words, budget, uncertain) { + const declaration = { + name, + exported: name === 'export' ? true : null, + readonly: name === 'readonly', + passive: false, + uncertain, + index: 1 + }; + for (; declaration.index < words.length; declaration.index++) { + const flag = words[declaration.index].value; + budget.spend(flag.length + 1); + if (flag === '--') { declaration.index++; break; } + if (!/^[+-]/.test(flag)) break; + applyDeclarationFlag(declaration, flag); + } + return declaration; +} + +// Apply a parsed declaration of `key` to one literal shell state. +function applyDeclaration(current, key, equals, declaration) { + const { name, exported, readonly, uncertain } = declaration; + if (name === 'unset') { + if (equals < 0 && !current.readonly.has(key)) { + current.variables.delete(key); current.exported.delete(key); + } + return; + } + if (exported === true || uncertain) current.exported.add(key); + else if (exported === false) current.exported.delete(key); + if (readonly || uncertain) current.readonly.add(key); } function checkCommand(input) { @@ -896,6 +1004,148 @@ function checkCommand(input) { } return null; } + // Split a text task into one pending task per scanned command. + function expandText(task) { + const scan = scanShell(task.text, budget); + const contexts = new Map([[scan.rootScope, task.context]]); + budget.spend(scan.commands.length + 1); + for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts }); + } + // A closing scope folds its states back into the enclosing context when the + // scope ended a pipeline. + function exitScope(command, contexts) { + const closing = command.scopeExit; + const exited = contexts.get(closing); + const enclosing = contexts.get(closing.parent); + if (closing.pipelineLast && exited && enclosing) { + budget.spend(exited.states.length + 1); + enclosing.states.push(...exited.states); + } + } + // Materialize contexts for any scopes between the command and the nearest + // known ancestor, then return the command's own scope context. + function resolveScope(command, contexts) { + const missing = []; + for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); } + while (missing.length) { + const scope = missing.pop(); + const parent = contexts.get(scope.parent); + contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent); + } + return contexts.get(command.scope); + } + function isConditional(scope) { + let conditional = false; + for (let current = scope; current; current = current.parent) { budget.spend(); conditional ||= current.conditional; } + return conditional; + } + // Stdin fed to a shell-like receiver (here-strings, heredocs, literal pipe + // producers) is scanned as source. Opaque receivers keep their text opaque. + function collectStdinSources(frame, role, environment) { + const opaque = role.kind === 'opaque'; + for (const redirect of frame.command.redirects) { + if (redirect.operator === '<<<') frame.childEnvironments.push({ code: redirect.word.value, opaque, environment }); + else if (redirect.operator === '<<' || redirect.operator === '<<-') frame.childEnvironments.push({ code: redirect.body, opaque, environment }); + } + if (frame.command.pipeFrom) { + for (const source of pipelineSources(frame.command.pipeFrom, budget)) frame.childEnvironments.push({ code: source.text, opaque: source.opaque || opaque, environment }); + } + } + // Inspect one command under one literal environment. Returns a block reason + // or null, and records any child source text the command would execute. + function inspectEnvironment(frame, role, normalized, environment) { + const { task, command } = frame; + const { words } = normalized; + const reason = task.opaque || role.kind === 'opaque' + ? inspectOpaque(command.words, task.text, environment) + : role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null; + if (reason) return reason; + if (role.code) { + if (normalized.local && words[0]?.value === 'eval') frame.sameShellCode = role.code; + else frame.childEnvironments.push({ code: role.code, opaque: false, environment }); + } + if (role.kind === 'sedExec') { + // Fragments sed will hand to the shell are scanned as real shell + // source, not as opaque text — this closes the GNU sed `e` gap. + for (const part of role.parts) { + budget.spend(part.length + 1); + frame.childEnvironments.push({ code: part, opaque: false, environment }); + } + } + if (role.stdin) collectStdinSources(frame, role, environment); + return null; + } + // Advance one literal shell state through the command: apply declarations + // and prefix assignments, then inspect the executable under each resulting + // environment. + function inspectState(frame, state) { + const { command, conditional } = frame; + const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables); + const { words, environments } = normalized; + const next = copyShellState(state, budget); + const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0; + const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget); + if (evalPrefix) { + frame.alternatives.push(state); + budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1)); + for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name); + } + budget.spend(mutation.states.length + 1); + frame.alternatives.push(...mutation.states); + if (mutation.changed && (conditional || mutation.uncertain)) frame.alternatives.push(state); + frame.changed ||= mutation.changed; + if (mutation.handled && !evalPrefix) return null; + const role = commandRole(words, budget); + budget.spend(environments.length + 1); + for (const environment of environments) { + const reason = inspectEnvironment(frame, role, normalized, environment); + if (reason) return reason; + } + return null; + } + // Commit the command's resulting states and queue whatever it would run. + function finishCommand(frame) { + const { command, context, parent, conditional, sameShellCode, changed } = frame; + context.states = frame.alternatives; + // Bash lastpipe and zsh can execute a final pipeline builtin in the + // parent shell. Preserve that possible state as well as isolation; this + // is deliberately conservative when the host shell/options are unknown. + if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context }); + for (const child of frame.childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] }); + if (sameShellCode) { + // A conditional eval may not run. Its nested scans have fresh lexical + // roots, so preserve the skipped branch across all delayed updates. + const evaluated = conditional ? copyShellContext(context, budget) : context; + if (conditional) pending.push({ mergeInto: context, context: evaluated }); + enqueue(sameShellCode, false, evaluated); + } + } + // Process one scanned command task. Returns a block reason or null. + function processCommand(task) { + const { command, contexts } = task; + if (command.scopeExit) { exitScope(command, contexts); return null; } + const parent = resolveScope(command, contexts); + const isolated = command.pipeFrom || command.pipeTo || command.background; + const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent); + if (!task.nestedDone && command.nested.length) { + pending.push({ ...task, nestedDone: true, commandContext: context }); + budget.spend(command.nested.length + 1); + for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget)); + return null; + } + const frame = { + task, command, context, parent, + conditional: isConditional(command.scope), + alternatives: [], childEnvironments: [], sameShellCode: null, changed: false + }; + budget.spend(context.states.length + 1); + for (const state of context.states) { + const reason = inspectState(frame, state); + if (reason) return reason; + } + finishCommand(frame); + return null; + } try { while (pending.length) { const task = pending.pop(); @@ -904,108 +1154,9 @@ function checkCommand(input) { task.mergeInto.states.push(...task.context.states); continue; } - if (!task.command) { - const scan = scanShell(task.text, budget); - const contexts = new Map([[scan.rootScope, task.context]]); - budget.spend(scan.commands.length + 1); - for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts }); - continue; - } - const { command, contexts } = task; - if (command.scopeExit) { - const closing = command.scopeExit; - const exited = contexts.get(closing); - const enclosing = contexts.get(closing.parent); - if (closing.pipelineLast && exited && enclosing) { - budget.spend(exited.states.length + 1); - enclosing.states.push(...exited.states); - } - continue; - } - const missing = []; - for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); } - while (missing.length) { - const scope = missing.pop(); - const parent = contexts.get(scope.parent); - contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent); - } - const parent = contexts.get(command.scope); - const isolated = command.pipeFrom || command.pipeTo || command.background; - const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent); - if (!task.nestedDone && command.nested.length) { - pending.push({ ...task, nestedDone: true, commandContext: context }); - budget.spend(command.nested.length + 1); - for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget)); - continue; - } - let conditional = false; - for (let scope = command.scope; scope; scope = scope.parent) { budget.spend(); conditional ||= scope.conditional; } - const alternatives = []; - const childEnvironments = []; - let sameShellCode = null; - let changed = false; - budget.spend(context.states.length + 1); - for (const state of context.states) { - const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables); - const { words, environments } = normalized; - const next = copyShellState(state, budget); - const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0; - const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget); - if (evalPrefix) { - alternatives.push(state); - budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1)); - for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name); - } - budget.spend(mutation.states.length + 1); - alternatives.push(...mutation.states); - if (mutation.changed && (conditional || mutation.uncertain)) alternatives.push(state); - changed ||= mutation.changed; - if (mutation.handled && !evalPrefix) continue; - const role = commandRole(words, budget); - budget.spend(environments.length + 1); - for (const environment of environments) { - const reason = task.opaque || role.kind === 'opaque' - ? inspectOpaque(command.words, task.text, environment) - : role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null; - if (reason) return { blocked: true, reason }; - if (role.code) { - if (normalized.local && words[0]?.value === 'eval') { - sameShellCode = role.code; - } - else childEnvironments.push({ code: role.code, opaque: false, environment }); - } - if (role.kind === 'sedExec') { - // Fragments sed will hand to the shell are scanned as real shell - // source, not as opaque text — this closes the GNU sed `e` gap. - for (const part of role.parts) { - budget.spend(part.length + 1); - childEnvironments.push({ code: part, opaque: false, environment }); - } - } - if (role.stdin) { - for (const redirect of command.redirects) { - if (redirect.operator === '<<<') childEnvironments.push({ code: redirect.word.value, opaque: role.kind === 'opaque', environment }); - else if (redirect.operator === '<<' || redirect.operator === '<<-') childEnvironments.push({ code: redirect.body, opaque: role.kind === 'opaque', environment }); - } - if (command.pipeFrom) { - for (const source of pipelineSources(command.pipeFrom, budget)) childEnvironments.push({ code: source.text, opaque: source.opaque || role.kind === 'opaque', environment }); - } - } - } - } - context.states = alternatives; - // Bash lastpipe and zsh can execute a final pipeline builtin in the - // parent shell. Preserve that possible state as well as isolation; this - // is deliberately conservative when the host shell/options are unknown. - if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context }); - for (const child of childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] }); - if (sameShellCode) { - // A conditional eval may not run. Its nested scans have fresh lexical - // roots, so preserve the skipped branch across all delayed updates. - const evaluated = conditional ? copyShellContext(context, budget) : context; - if (conditional) pending.push({ mergeInto: context, context: evaluated }); - enqueue(sameShellCode, false, evaluated); - } + if (!task.command) { expandText(task); continue; } + const reason = processCommand(task); + if (reason) return { blocked: true, reason }; } } catch (error) { if (error instanceof UnsupportedEnvSplitEscape) { diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 1a2c54cd9..15b2a5da5 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -651,13 +651,14 @@ const DD_LAUNCHER_OPTIONS = { }; /** Return the command position after one explicitly supported launcher's options. */ + function ddLauncherCommandIndex(argv, index, name) { - const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name]; + const options = DD_LAUNCHER_OPTIONS[name]; // GNU time uses getopt_long: unique prefixes resolve against its complete // eight-option table, including terminating help/version. Other launchers // retain their explicit spellings; this does not affect shell-keyword time. const timeLongOptions = name === 'time' - ? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--')) + ? [...options.values, ...options.flags, ...options.nonCommand].filter(flag => flag.startsWith('--')) : null; index += 1; while (index < argv.length) { @@ -672,38 +673,47 @@ function ddLauncherCommandIndex(argv, index, name) { index += 1; continue; } - if (arg.startsWith('--')) { - const separator = arg.indexOf('='); - let flag = separator === -1 ? arg : arg.slice(0, separator); - if (timeLongOptions && !timeLongOptions.includes(flag)) { - const matches = timeLongOptions.filter(option => option.startsWith(flag)); - if (matches.length !== 1) return argv.length; - [flag] = matches; - } - if (nonCommand && nonCommand.has(flag)) return argv.length; - if (values.has(flag)) index += separator === -1 ? 2 : 1; - else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1; - else return argv.length; - continue; - } - let consumesNext = false; - for (let offset = 1; offset < arg.length; offset += 1) { - const flag = `-${arg[offset]}`; - if (nonCommand && nonCommand.has(flag)) return argv.length; - if (values.has(flag)) { - consumesNext = offset + 1 === arg.length; - break; - } - if (optional.has(flag)) break; - if (!flags.has(flag)) return argv.length; - } - index += consumesNext ? 2 : 1; + const step = arg.startsWith('--') + ? ddLauncherLongOptionStep(arg, options, timeLongOptions) + : ddLauncherShortClusterStep(arg, options); + if (step === -1) return argv.length; + index += step; } // timeout's duration and taskset's mask or CPU list are data, followed by // exactly one executable position. return name === 'timeout' || name === 'taskset' ? index + 1 : index; } +// One long option of a dd launcher: how many argv positions it consumes, or +// -1 when the launcher can no longer reach a command position. +function ddLauncherLongOptionStep(arg, options, timeLongOptions) { + const { values, optional, flags, nonCommand } = options; + const separator = arg.indexOf('='); + let flag = separator === -1 ? arg : arg.slice(0, separator); + if (timeLongOptions && !timeLongOptions.includes(flag)) { + const matches = timeLongOptions.filter(option => option.startsWith(flag)); + if (matches.length !== 1) return -1; + [flag] = matches; + } + if (nonCommand && nonCommand.has(flag)) return -1; + if (values.has(flag)) return separator === -1 ? 2 : 1; + if (optional.has(flag) || (separator === -1 && flags.has(flag))) return 1; + return -1; +} + +// One short-option cluster of a dd launcher, same contract as above. +function ddLauncherShortClusterStep(arg, options) { + const { values, optional, flags, nonCommand } = options; + for (let offset = 1; offset < arg.length; offset += 1) { + const flag = `-${arg[offset]}`; + if (nonCommand && nonCommand.has(flag)) return -1; + if (values.has(flag)) return offset + 1 === arg.length ? 2 : 1; + if (optional.has(flag)) return 1; + if (!flags.has(flag)) return -1; + } + return 1; +} + /** * Resolve leading assignments, shell prefixes and sudo/doas/env into command * argv. Wrapper-specific option values never become executable names. Every @@ -715,6 +725,7 @@ function ddLauncherCommandIndex(argv, index, name) { * @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar * @returns {string[]} normalized argv, or [] when no literal command resolves */ + function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) { let argv = tokens.slice(); let index = 0; @@ -722,6 +733,126 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers let allowShellTime = allowShellBuiltins; const timeTokens = SHELL_TIME_TOKENS.get(tokens); let budget = tokens.reduce((size, token) => size + token.length + 1, 1); + + // Each wrapper handler consumes its own options and returns true when no + // literal command can resolve behind it. + function isReservedTime() { + return allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index); + } + function skipReservedTime() { + // Current Bash accepts only raw -p and -- as reserved-time options. + // Quotes/escapes make them executable words, unlike external time argv. + // The next command/exec builtin or assignment keeps shell semantics. + index += 1; + if (argv[index] === '-p' && timeTokens.has(index)) index += 1; + if (argv[index] === '--' && timeTokens.has(index)) index += 1; + return false; + } + function skipCommandBuiltin() { + allowShellTime = false; + index += 1; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index++]; + if (flag === '--') break; + // -v/-V (including -pv) only describe names; no command executes. + if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return true; + } + allowAssignments = false; + return false; + } + function skipExecBuiltin() { + allowShellTime = false; + index += 1; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index]; + if (flag === '--') { + index += 1; + break; + } + const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS); + const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1); + if (!/^[cl]*$/.test(flagLetters)) return true; + index += option && option.value === undefined ? 2 : 1; + } + // exec replaces the shell with an external executable; its argument + // 'command' is not the shell's builtin, and A=1 is not an assignment. + allowShellBuiltins = false; + allowAssignments = false; + return false; + } + function skipDdLauncher(base) { + index = ddLauncherCommandIndex(argv, index, base); + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = false; + return false; + } + function skipPrivilegeWrapper(base) { + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = true; + const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; + index += 1; + while (index < argv.length) { + const flag = argv[index]; + if (flag === '--') { + index += 1; + break; + } + if (flag === '-' || !flag.startsWith('-')) break; + const option = wrapperValueOption(flag, valueFlags); + index += option && option.value === undefined ? 2 : 1; + } + return false; + } + function expandEnvSplit(option) { + const separate = option.value === undefined; + const source = separate ? argv[index + 1] : option.value; + if (source === undefined || budget-- <= 0) return true; + const expanded = splitEnvWords(source); + if (!expanded) return true; + argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))]; + index = 0; + return false; + } + function skipEnvWrapper() { + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = true; + index += 1; + while (index < argv.length) { + const arg = argv[index]; + if (arg === '--') { + index += 1; + break; + } + const option = wrapperValueOption(arg, ENV_VALUE_FLAGS); + if (option && (option.name === '-S' || option.name === '--split-string')) { + if (expandEnvSplit(option)) return true; + continue; + } + if (option) { + index += option.value === undefined ? 2 : 1; + continue; + } + if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) { + index += 1; + continue; + } + break; + } + return false; + } + function leadWrapperFor(base) { + if (isReservedTime()) return skipReservedTime; + if (allowShellBuiltins && base === 'command') return skipCommandBuiltin; + if (allowShellBuiltins && base === 'exec') return skipExecBuiltin; + if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) return skipDdLauncher; + if (base === 'sudo' || base === 'doas') return skipPrivilegeWrapper; + if (base === 'env') return skipEnvWrapper; + return null; + } + while (index < argv.length && budget-- > 0) { while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) { index += 1; @@ -729,107 +860,9 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers } if (index >= argv.length) return []; const base = commandBasename(argv[index]); - if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) { - // Current Bash accepts only raw -p and -- as reserved-time options. - // Quotes/escapes make them executable words, unlike external time argv. - // The next command/exec builtin or assignment keeps shell semantics. - index += 1; - if (argv[index] === '-p' && timeTokens.has(index)) index += 1; - if (argv[index] === '--' && timeTokens.has(index)) index += 1; - continue; - } - if (allowShellBuiltins && base === 'command') { - allowShellTime = false; - index += 1; - while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { - const flag = argv[index++]; - if (flag === '--') break; - // -v/-V (including -pv) only describe names; no command executes. - if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return []; - } - allowAssignments = false; - continue; - } - if (allowShellBuiltins && base === 'exec') { - allowShellTime = false; - index += 1; - while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { - const flag = argv[index]; - if (flag === '--') { - index += 1; - break; - } - const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS); - const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1); - if (!/^[cl]*$/.test(flagLetters)) return []; - index += option && option.value === undefined ? 2 : 1; - } - // exec replaces the shell with an external executable; its argument - // 'command' is not the shell's builtin, and A=1 is not an assignment. - allowShellBuiltins = false; - allowAssignments = false; - continue; - } - if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) { - index = ddLauncherCommandIndex(argv, index, base); - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = false; - continue; - } - if (base === 'sudo' || base === 'doas') { - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = true; - const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; - index += 1; - while (index < argv.length) { - const flag = argv[index]; - if (flag === '--') { - index += 1; - break; - } - if (flag === '-' || !flag.startsWith('-')) break; - const option = wrapperValueOption(flag, valueFlags); - index += option && option.value === undefined ? 2 : 1; - } - continue; - } - if (base === 'env') { - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = true; - index += 1; - while (index < argv.length) { - const arg = argv[index]; - if (arg === '--') { - index += 1; - break; - } - const option = wrapperValueOption(arg, ENV_VALUE_FLAGS); - if (option && (option.name === '-S' || option.name === '--split-string')) { - const separate = option.value === undefined; - const source = separate ? argv[index + 1] : option.value; - if (source === undefined || budget-- <= 0) return []; - const expanded = splitEnvWords(source); - if (!expanded) return []; - argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))]; - index = 0; - continue; - } - if (option) { - index += option.value === undefined ? 2 : 1; - continue; - } - if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) { - index += 1; - continue; - } - break; - } - continue; - } - return argv.slice(index); + const wrapper = leadWrapperFor(base); + if (!wrapper) return argv.slice(index); + if (wrapper(base)) return []; } return []; } @@ -1067,6 +1100,113 @@ function pushTargetsSharedBranch(rest) { return false; } +/** Short-option cluster carrying `letter` (`-f`, `-fd`); long options never match. */ +function hasShortFlag(token, letter) { + return token.startsWith('-') && !token.startsWith('--') && token.slice(1).includes(letter); +} + +// `git checkout -- `, `git checkout .`, and the force forms +// (`--force` / `-f`) all discard uncommitted working-tree changes, +// mirroring the `switch` handler. +function isDestructiveGitCheckout(rest) { + return rest.some(t => t === '--' || t === '.' || t === '--force' || hasShortFlag(t, 'f')); +} + +// `git switch` can discard local working-tree changes in three forms: +// --discard-changes explicit discard +// --force / -f ignore conflicts and overwrite +// -C force-create (overwrites existing branch) +// Short combined form: -f, -fC, -Cf, -C +function isDestructiveGitSwitch(rest) { + return rest.some(t => t === '--discard-changes' || t === '--force' + || (t.startsWith('-') && !t.startsWith('--') && /[fC]/.test(t.slice(1)))); +} + +// Only `--force-with-lease` qualifies as a safety-checked force. +// `--force-if-includes` is a no-op when used WITHOUT +// `--force-with-lease` (per git-scm.com/docs/git-push), and when +// combined with a bare `--force` the bare force is still in effect. +// So `--force --force-if-includes` must be treated as destructive. +// +// A `+` refspec prefix (e.g. `git push origin +main`, +// `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward +// update of that ref and is destructive on its own. +function isDestructiveGitPush(rest) { + let withLease = false; + let bareForce = false; + let plusRefspecForce = false; + for (const t of rest) { + if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) { + withLease = true; + continue; + } + if (t === '--force' || t.startsWith('--force=') || hasShortFlag(t, 'f')) { + bareForce = true; + continue; + } + // Refspec prefix: `+[:]`. Match tokens like `+main`, + // `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare + // `+` and numeric-only `+123` which are not refspecs. + if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) plusRefspecForce = true; + } + if (bareForce || (plusRefspecForce && !withLease)) return true; + // A lease-checked force still rewrites a shared branch's history. + return withLease && pushTargetsSharedBranch(rest); +} + +// `git branch -D` (long spelling: `--delete --force`) deletes a +// branch even when it is unmerged, orphaning its commits. Plain +// `-d` refuses when unmerged, so it is safe to leave ungated. +function isDestructiveGitBranch(rest) { + let del = false; + let force = false; + for (const t of rest) { + if (t === '--delete') { del = true; continue; } + if (t === '--force') { force = true; continue; } + if (!t.startsWith('-') || t.startsWith('--')) continue; + const body = t.slice(1); + if (body.includes('D')) return true; + if (body.includes('d')) del = true; + if (body.includes('f')) force = true; + } + return del && force; +} + +// `git restore ` overwrites the working tree from the index +// by default, the modern spelling of gated `git checkout -- `. +// Only `--staged` alone is non-destructive (it leaves the file on +// disk untouched); `--worktree` (the default target) is destructive. +function isDestructiveGitRestore(rest) { + const has = (long, short) => rest.some(t => t === long || hasShortFlag(t, short)); + const staged = has('--staged', 'S'); + const worktree = has('--worktree', 'W'); + return worktree || !staged; +} + +// Subcommand -> predicate over the tokens after it. Each entry is the +// smallest rule that identifies the destructive spelling of that command. +const DESTRUCTIVE_GIT_SUBCOMMANDS = new Map([ + ['reset', rest => rest.includes('--hard')], + ['checkout', isDestructiveGitCheckout], + // `git clean -f`, `-fd`, `-fdx`, `-df`, `--force` + ['clean', rest => rest.some(t => t === '--force' || hasShortFlag(t, 'f'))], + ['push', isDestructiveGitPush], + ['commit', rest => rest.includes('--amend')], + // `git rm -r` / `-rf` / `-r -f`: destructive within the index too. + ['rm', rest => rest.some(t => t.startsWith('-') && !t.startsWith('--') && /[rR]/.test(t.slice(1)))], + ['switch', isDestructiveGitSwitch], + ['branch', isDestructiveGitBranch], + // `drop` destroys one stash entry, `clear` the entire stash. + // `list`, `show`, `pop` and `apply` keep the entries recoverable. + ['stash', rest => rest[0] === 'drop' || rest[0] === 'clear'], + // `expire` and `delete` remove the recovery net that makes every + // other gated git command recoverable. + ['reflog', rest => rest[0] === 'expire' || rest[0] === 'delete'], + // `git update-ref -d ` deletes a ref directly. + ['update-ref', rest => rest.includes('-d') || rest.includes('--delete')], + ['restore', isDestructiveGitRestore] +]); + /** * Detect destructive `git` invocations: `reset --hard`, `checkout --`, * `clean -f...`, `push --force` (`--force-with-lease` only to a shared @@ -1077,149 +1217,12 @@ function pushTargetsSharedBranch(rest) { * @param {string[]} tokens * @returns {boolean} */ + function isDestructiveGit(tokens) { const sub = findGitSubcommand(tokens); if (!sub) return false; - const { command, rest } = sub; - - if (command === 'reset') { - return rest.includes('--hard'); - } - - if (command === 'checkout') { - // `git checkout -- `, `git checkout .`, and the force forms - // (`--force` / `-f`) all discard uncommitted working-tree changes, - // mirroring the `switch` handler below. - return rest.some(t => { - if (t === '--' || t === '.' || t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - return t.slice(1).includes('f'); - }); - } - - if (command === 'clean') { - // `git clean -f`, `-fd`, `-fdx`, `-df`, `--force` - return rest.some(t => { - if (t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - return t.slice(1).includes('f'); - }); - } - - if (command === 'push') { - // Only `--force-with-lease` qualifies as a safety-checked force. - // `--force-if-includes` is a no-op when used WITHOUT - // `--force-with-lease` (per git-scm.com/docs/git-push), and when - // combined with a bare `--force` the bare force is still in effect. - // So `--force --force-if-includes` must be treated as destructive. - // - // A `+` refspec prefix (e.g. `git push origin +main`, - // `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward - // update of that ref and is destructive on its own. - let withLease = false; - let bareForce = false; - let plusRefspecForce = false; - for (const t of rest) { - if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) { - withLease = true; - continue; - } - if (t === '--force' || t.startsWith('--force=')) { - bareForce = true; - continue; - } - if (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes('f')) { - bareForce = true; - continue; - } - // Refspec prefix: `+[:]`. Match tokens like `+main`, - // `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare - // `+` and numeric-only `+123` which are not refspecs. - if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) { - plusRefspecForce = true; - } - } - if (bareForce || (plusRefspecForce && !withLease)) return true; - // A lease-checked force still rewrites a shared branch's history. - return withLease && pushTargetsSharedBranch(rest); - } - - if (command === 'commit') { - return rest.includes('--amend'); - } - - if (command === 'rm') { - // `git rm -r` / `-rf` / `-r -f` — destructive within the index too. - let hasR = false; - for (const t of rest) { - if (!t.startsWith('-') || t.startsWith('--')) continue; - if (/[rR]/.test(t.slice(1))) hasR = true; - } - return hasR; - } - - if (command === 'switch') { - // `git switch` can discard local working-tree changes in three forms: - // --discard-changes explicit discard - // --force / -f ignore conflicts and overwrite - // -C force-create (overwrites existing branch) - return rest.some(t => { - if (t === '--discard-changes' || t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - // Short combined form: -f, -fC, -Cf, -C - const body = t.slice(1); - return /[fC]/.test(body); - }); - } - - if (command === 'branch') { - // `git branch -D` (long spelling: `--delete --force`) deletes a - // branch even when it is unmerged, orphaning its commits. Plain - // `-d` refuses when unmerged, so it is safe to leave ungated. - let del = false; - let force = false; - for (const t of rest) { - if (t === '--delete') { del = true; continue; } - if (t === '--force') { force = true; continue; } - if (!t.startsWith('-') || t.startsWith('--')) continue; - const body = t.slice(1); - if (body.includes('D')) return true; - if (body.includes('d')) del = true; - if (body.includes('f')) force = true; - } - return del && force; - } - - if (command === 'stash') { - // `drop` destroys one stash entry, `clear` the entire stash. - // `list`, `show`, `pop` and `apply` keep the entries recoverable. - return rest[0] === 'drop' || rest[0] === 'clear'; - } - - if (command === 'reflog') { - // `expire` and `delete` remove the recovery net that makes every - // other gated git command recoverable. - return rest[0] === 'expire' || rest[0] === 'delete'; - } - - if (command === 'update-ref') { - // `git update-ref -d ` deletes a ref directly. - return rest.includes('-d') || rest.includes('--delete'); - } - - if (command === 'restore') { - // `git restore ` overwrites the working tree from the index - // by default, the modern spelling of gated `git checkout -- `. - // Only `--staged` alone is non-destructive (it leaves the file on - // disk untouched); `--worktree` (the default target) is destructive. - const has = (long, short) => rest.some(t => - t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short))); - const staged = has('--staged', 'S'); - const worktree = has('--worktree', 'W'); - return worktree || !staged; - } - - return false; + const check = DESTRUCTIVE_GIT_SUBCOMMANDS.get(sub.command); + return check ? check(sub.rest) : false; } /** @@ -1298,6 +1301,7 @@ const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']); * @param {string | string[]} command raw segment or already dequoted argv * @returns {boolean} */ + function isDestructiveFindExec(command) { const quoteAware = Array.isArray(command); const tokens = quoteAware ? command : tokenize(String(command || '').trim()); @@ -1314,30 +1318,41 @@ function isDestructiveFindExec(command) { continue; } if (!FIND_EXEC_ACTIONS.has(action)) continue; - const execTokens = []; - for (index += 1; index < tokens.length; index += 1) { - const token = tokens[index]; - if (token === ';' || token === '\\;' || ( - token === '+' && (action === '-exec' || action === '-execdir') && - execTokens[execTokens.length - 1] === '{}' - )) break; - execTokens.push(token); - } - if (execTokens.length === 0) continue; - // The legacy raw fallback can split quoted prose into apparent actions. - // Preserve its old rm/Git coverage, but classify dd only from real argv. - if (quoteAware && isDestructiveDd(execTokens, false)) return true; - const baseCmd = commandBasename(execTokens[0]); - // Preserve main's existing rm/rmdir/unlink and git-reset handling. - if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true; - if (baseCmd === 'git') { - const sub = findGitSubcommand(execTokens); - if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) return true; - } + const exec = readFindExecArgv(tokens, index + 1, action); + index = exec.index; + if (exec.argv.length && isDestructiveFindExecArgv(exec.argv, quoteAware)) return true; } return false; } +// Collect the argv of one find action starting at `start`. Returns the argv +// and the index of its terminator (or the end of the token list). +function readFindExecArgv(tokens, start, action) { + const argv = []; + let index = start; + for (; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === ';' || token === '\\;' || ( + token === '+' && (action === '-exec' || action === '-execdir') && + argv[argv.length - 1] === '{}' + )) break; + argv.push(token); + } + return { argv, index }; +} + +function isDestructiveFindExecArgv(argv, quoteAware) { + // The legacy raw fallback can split quoted prose into apparent actions. + // Preserve its old rm/Git coverage, but classify dd only from real argv. + if (quoteAware && isDestructiveDd(argv, false)) return true; + const baseCmd = commandBasename(argv[0]); + // Preserve main's existing rm/rmdir/unlink and git-reset handling. + if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true; + if (baseCmd !== 'git') return false; + const sub = findGitSubcommand(argv); + return Boolean(sub && sub.command === 'reset' && sub.rest.includes('--hard')); +} + function isDestructiveBash(command) { // SQL phrases live in command bodies, not as flag-bearing // arguments, so we still match them by regex - but on the input @@ -1817,37 +1832,51 @@ function inRange(code, [lo, hi]) { return code >= lo && code <= hi; } + function sanitizePath(filePath) { - // Strip control chars (including null), bidi overrides, separators, - // and the dangerous invisible characters defined by the constants - // above (mirroring scripts/ci/check-unicode-safety.js), so a denial - // message cannot carry content a human reviewer cannot see. let sanitized = ''; for (const char of String(filePath || '')) { - const code = char.codePointAt(0); - const isAsciiControl = - code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS); - const isBidiOverride = - inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES); - const isUnicodeSeparator = code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR; - const isDangerousInvisible = - inRange(code, ZERO_WIDTHS) || - code === WORD_JOINER || - code === BYTE_ORDER_MARK || - code === SOFT_HYPHEN || - inRange(code, VARIATION_SELECTORS) || - inRange(code, VARIATION_SUPPLEMENTS) || - inRange(code, TAG_BLOCK) || - code === MONGOLIAN_VOWEL_SEPARATOR || - code === HANGUL_CHOSEONG_FILLER || - code === HANGUL_JUNGSEONG_FILLER || - code === HANGUL_FILLER || - inRange(code, INVISIBLE_MATH_OPERATORS); - sanitized += isAsciiControl || isBidiOverride || isUnicodeSeparator || isDangerousInvisible ? ' ' : char; + sanitized += isUnsafePathCodePoint(char.codePointAt(0)) ? ' ' : char; } return sanitized.trim().slice(0, SANITIZED_PATH_MAX_LENGTH); } +// Control chars (including null), bidi overrides, separators, and the +// dangerous invisible characters defined by the constants above (mirroring +// scripts/ci/check-unicode-safety.js), so a denial message cannot carry +// content a human reviewer cannot see. +function isUnsafePathCodePoint(code) { + return isAsciiControlCodePoint(code) + || isBidiOverrideCodePoint(code) + || code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR + || isDangerousInvisibleCodePoint(code); +} + +function isAsciiControlCodePoint(code) { + return code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS); +} + +function isBidiOverrideCodePoint(code) { + return inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES); +} + +function isDangerousInvisibleCodePoint(code) { + return ( + inRange(code, ZERO_WIDTHS) || + code === WORD_JOINER || + code === BYTE_ORDER_MARK || + code === SOFT_HYPHEN || + inRange(code, VARIATION_SELECTORS) || + inRange(code, VARIATION_SUPPLEMENTS) || + inRange(code, TAG_BLOCK) || + code === MONGOLIAN_VOWEL_SEPARATOR || + code === HANGUL_CHOSEONG_FILLER || + code === HANGUL_JUNGSEONG_FILLER || + code === HANGUL_FILLER || + inRange(code, INVISIBLE_MATH_OPERATORS) + ); +} + function normalizeForMatch(value) { return String(value || '') .replace(/\\/g, '/') @@ -1859,75 +1888,47 @@ function isClaudeSettingsPath(filePath) { return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized); } + function isReadOnlyGitIntrospection(command) { const trimmed = String(command || '').trim(); - if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) { - return false; - } - - const segments = splitCommandSegments(trimmed); - if (segments.length !== 1) { - return false; - } - + if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) return false; + if (splitCommandSegments(trimmed).length !== 1) return false; const tokens = tokenizeAllowlistedShellWords(trimmed); - if (!tokens) { - return false; - } - if (commandBasename(tokens[0]) !== 'git' || tokens.length < 2) { - return false; - } + if (!tokens || commandBasename(tokens[0]) !== 'git' || tokens.length < 2) return false; + const check = READ_ONLY_GIT_SUBCOMMANDS.get(tokens[1].toLowerCase()); + return check ? check(tokens.slice(2)) : false; +} - const subcommand = tokens[1].toLowerCase(); - const args = tokens.slice(2); +const READ_ONLY_GIT_SHOW_REF = /^[a-zA-Z0-9._:/ -]+$/; +const READ_ONLY_GIT_DIFF_ARGS = new Set(['--name-only', '--name-status', '--cached', '--staged', '--stat']); - if (subcommand === 'status') { - return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg)); +// Permits: git show , git show --stat, git show --name-only, +// git show --stat, git show --name-only +function isReadOnlyGitShow(args) { + if (args.length === 1) { + const arg = args[0]; + if (arg === '--stat' || arg === '--name-only') return true; + return !arg.startsWith('--') && READ_ONLY_GIT_SHOW_REF.test(arg); } - - if (subcommand === 'diff') { - const allowedDiffArgs = new Set(['--name-only', '--name-status', '--cached', '--staged', '--stat']); - // git diff without arguments is read-only introspection - if (args.length === 0) return true; - return args.length <= 2 && args.every(arg => allowedDiffArgs.has(arg)); + if (args.length === 2) { + const [first, second] = args; + return !first.startsWith('--') && READ_ONLY_GIT_SHOW_REF.test(first) && (second === '--stat' || second === '--name-only'); } - - if (subcommand === 'log') { - return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg)); - } - - if (subcommand === 'show') { - // Permite: git show , git show --stat, git show --name-only, - // git show --stat, git show --name-only - if (args.length === 0) return false; - if (args.length === 1) { - const arg = args[0]; - if (arg === '--stat' || arg === '--name-only') return true; - // ref - return !arg.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(arg); - } - if (args.length === 2) { - const [first, second] = args; - // ref + flag - if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) { - return true; - } - return false; - } - return false; - } - - if (subcommand === 'branch') { - return args.length === 1 && args[0] === '--show-current'; - } - - if (subcommand === 'rev-parse') { - return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]); - } - return false; } +// Subcommand -> predicate over its arguments. Only these exact spellings are +// treated as read-only introspection that skips the shell gate. +const READ_ONLY_GIT_SUBCOMMANDS = new Map([ + ['status', args => args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg))], + // git diff without arguments is read-only introspection + ['diff', args => args.length === 0 || (args.length <= 2 && args.every(arg => READ_ONLY_GIT_DIFF_ARGS.has(arg)))], + ['log', args => args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg))], + ['show', isReadOnlyGitShow], + ['branch', args => args.length === 1 && args[0] === '--show-current'], + ['rev-parse', args => args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1])] +]); + // --- Gate messages --- /** @@ -1971,7 +1972,7 @@ function firstTouchGateMsg(filePath, isWrite, cls, profile) { * (#2142). Carries the denial ordinal so consecutive denials differ * textually, and a one-line recovery hint instead of the multi-line block. */ -function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') { +function condensedGateMsg(action, filePath, ordinal, { cls = 'code', targetNote = '', profile = null, missNote = '' } = {}) { const safe = sanitizePath(filePath); const hint = condensedHintFor(cls, action === 'creation', profile, questionOptions()); return ( @@ -2018,7 +2019,7 @@ function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked = }); if (denials > getFullDenialBudget()) { const action = isWrite ? 'creation' : 'edit'; - return denyResult(condensedGateMsg(action, filePath, denials, cls, targetNote, profile, missNote), { includeRecoveryHint: false }); + return denyResult(condensedGateMsg(action, filePath, denials, { cls, targetNote, profile, missNote }), { includeRecoveryHint: false }); } const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote); return denyResult(targetNote ? withNoteBeforeRetry(message, targetNote) : message, { @@ -2331,6 +2332,7 @@ function run(rawInput) { } } + function gate(rawInput) { let data; try { @@ -2348,212 +2350,259 @@ function gate(rawInput) { const rawToolName = data.tool_name || ''; const toolInput = data.tool_input || {}; - // Normalize: case-insensitive matching via lookup map - const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' }; const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName; if (isMetricsEnabled()) { pendingMetrics = { tool: toolName, data, entries: [], keys: new Set() }; } - const inSubagent = isSubagentInvocation(data); let turnScanner = null; - const getTurnScan = () => (turnScanner || (turnScanner = createTurnScanner(data)))(); + const ctx = { + rawInput, + data, + toolInput, + toolName, + inSubagent: isSubagentInvocation(data), + getTurnScan: () => (turnScanner || (turnScanner = createTurnScanner(data)))() + }; - if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') { - const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || ''; + if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') return gateSingleEdit(ctx); + if (toolName === 'MultiEdit') return gateMultiEdit(ctx); + if (toolName === 'Bash' || toolName === 'PowerShell') return gateShell(ctx); + return rawInput; // allow +} + +// Normalize: case-insensitive matching via lookup map +const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' }; + +// --- Edit / Write / NotebookEdit --- + +function gateSingleEdit(ctx) { + const { toolName, toolInput, data, rawInput, inSubagent } = ctx; + const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || ''; + const exempt = exemptReason(filePath, data); + if (exempt) { + recordDecision('pass-exempt', exempt, { target: filePath }); + return rawInput; // allow + } + + if (inSubagent) { + const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write'); + if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath }); + return denial || rawInput; + } + + const fileKey = canonicalPathKey(filePath, data); + if (!isChecked([fileKey, filePath])) return firstTouchSingleEdit(ctx, filePath, fileKey); + + recordDecision('pass-checked', 'checked', { target: filePath }); + return rawInput; // allow +} + +function singleEditProfile(ctx, filePath, cls, restricted) { + const { toolName, toolInput, data } = ctx; + if (restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls)) return null; + return changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data); +} + +// see docs/gateguard/design-notes.md#sibling-collapse +function newFileCollapse(ctx, filePath, cls, isNewFile, restricted) { + if (!isNewFile || restricted) return { sibling: null, dirGate: undefined }; + const turnId = currentTurnId(ctx.getTurnScan()); + const collapsible = turnId !== null || !transcriptPathFor(ctx.data); + const gateKey = collapsible ? newFileGateKey(filePath, ctx.data, cls) : null; + const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null; + const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; + return { sibling, dirGate }; +} + +function firstTouchSingleEdit(ctx, filePath, fileKey) { + const { toolName, data, rawInput, getTurnScan } = ctx; + const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); + const cls = classifyTargetFor(filePath, data); + // see docs/gateguard/design-notes.md#sensitive-targets + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive }); + return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; + } + const profile = singleEditProfile(ctx, filePath, cls, restricted); + if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile }); + return { additionalContext: trivialNote(filePath), exitCode: 0 }; + } + const { sibling, dirGate } = newFileCollapse(ctx, filePath, cls, isNewFile, restricted); + if (sibling) { + if (!markCheckedAndCountSibling(fileKey)) { + return allowWithStateWarning(); + } + recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile }); + return { additionalContext: siblingNote(sibling), exitCode: 0 }; + } + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); + if (!ok) { + return allowWithStateWarning(); + } + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile }); + return rawInput; + } + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data); + return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss }); +} + +// --- MultiEdit --- + +function gateMultiEdit(ctx) { + const { toolInput, data, rawInput, inSubagent } = ctx; + const edits = multiEditEntries(toolInput); + if (inSubagent) return gateMultiEditSubagent(edits, data, rawInput); + + const batch = { notes: [], trivialKeys: new Set() }; + for (const edit of edits) { + const filePath = (edit && edit.file_path) || ''; const exempt = exemptReason(filePath, data); if (exempt) { - recordDecision('pass-exempt', exempt, { target: filePath }); - return rawInput; // allow + recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` }); + continue; } - - if (inSubagent) { - const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write'); - if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath }); - return denial || rawInput; - } - const fileKey = canonicalPathKey(filePath, data); - if (!isChecked([fileKey, filePath])) { - const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); - const cls = classifyTargetFor(filePath, data); - // see docs/gateguard/design-notes.md#sensitive-targets - const { sensitive, linked, restricted } = allowanceFacts(filePath, data); - const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); - if (credit) { - if (!markCheckedAndCountCredit(fileKey, cls)) { - return allowWithStateWarning(); - } - recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive }); - return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; - } - const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls) - ? null - : changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data); - if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) { - if (!countTrivialAllow()) { - return allowWithStateWarning(); - } - recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile }); - return { additionalContext: trivialNote(filePath), exitCode: 0 }; - } - const turnId = isNewFile && !restricted ? currentTurnId(getTurnScan()) : null; - const collapsible = isNewFile && !restricted && (turnId !== null || !transcriptPathFor(data)); - const gateKey = collapsible ? newFileGateKey(filePath, data, cls) : null; - const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null; - if (sibling) { - if (!markCheckedAndCountSibling(fileKey)) { - return allowWithStateWarning(); - } - recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile }); - return { additionalContext: siblingNote(sibling), exitCode: 0 }; - } - const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; - const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); - const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); - if (!ok) { - return allowWithStateWarning(); - } - if (denials === null) { - // see docs/gateguard/design-notes.md#denial-cap - recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile }); - return rawInput; - } - const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data); - return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss }); - } - - recordDecision('pass-checked', 'checked', { target: filePath }); - return rawInput; // allow - } - - if (toolName === 'MultiEdit') { - const edits = multiEditEntries(toolInput); - if (inSubagent) { - for (const edit of edits) { - const filePath = (edit && edit.file_path) || ''; - if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; - const denial = subagentRestrictedDenial(filePath, data, false); - if (denial) return denial; - } - recordDecision('pass-subagent', 'subagent'); - return rawInput; - } - - const notes = []; - const trivialKeys = new Set(); - for (const edit of edits) { - const filePath = (edit && edit.file_path) || ''; - const exempt = exemptReason(filePath, data); - if (exempt) { - recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` }); - continue; - } - const fileKey = canonicalPathKey(filePath, data); - if (trivialKeys.has(fileKey)) continue; - if (!isChecked([fileKey, filePath])) { - const cls = classifyTargetFor(filePath, data); - const { sensitive, linked, restricted } = allowanceFacts(filePath, data); - const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data); - if (credit) { - if (!markCheckedAndCountCredit(fileKey, cls)) { - return allowWithStateWarning(); - } - recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey }); - notes.push(creditNote(credit, filePath)); - continue; - } - const profile = restricted || !needsProfile('MultiEdit', cls) - ? null - : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data); - if (isTrivialChange(cls, restricted, profile)) { - if (!countTrivialAllow()) { - return allowWithStateWarning(); - } - trivialKeys.add(fileKey); - recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey }); - notes.push(trivialNote(filePath)); - continue; - } - const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); - const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); - if (!ok) { - return allowWithStateWarning(); - } - if (denials === null) { - // see docs/gateguard/design-notes.md#denial-cap - recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey }); - continue; - } - const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data); - return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss }); - } + if (batch.trivialKeys.has(fileKey)) continue; + if (isChecked([fileKey, filePath])) { recordDecision('pass-checked', 'checked', { target: filePath, key: fileKey }); + continue; } - if (notes.length > 0) { - return { additionalContext: notes, exitCode: 0 }; - } - return rawInput; // allow + const result = firstTouchMultiEdit(ctx, edits, filePath, fileKey, batch); + if (result) return result; } - - if (toolName === 'Bash' || toolName === 'PowerShell') { - const command = toolInput.command || ''; - if (isReadOnlyGitIntrospection(command)) { - recordDecision('pass', 'readonly-git'); - return rawInput; - } - - if (classifyDestructiveCommand(toolName, command).length > 0) { - // Gate destructive commands on first attempt; allow retry after facts presented - const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); - if (!isChecked(key)) { - if (!markChecked(key)) { - return allowWithStateWarning(); - } - recordDecision('destructive-deny', 'destructive'); - return denyResult(destructiveBashMsg(), { includeRecoveryHint: false }); - } - recordDecision('pass-checked', 'destructive-retry'); - return rawInput; // allow retry after facts presented - } - - // Operator opt-out: skip the routine shell gate entirely. The destructive - // gate above still fires. This is the documented escape hatch for hosts - // (Cursor, OpenCode, etc.) where the once-per-session routine gate is - // friction without signal. - if (isRoutineBashGateDisabled()) { - recordDecision('pass-exempt', 'routine-disabled'); - return rawInput; // routine gate opted out via env - } - - if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { - if (isReadOnlyShellCommand(toolName, command)) { - if (!countRoutineReadonlyPass()) { - return allowWithStateWarning(); - } - recordDecision('routine-readonly', 'readonly'); - return rawInput; - } - if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { - return allowWithStateWarning(); - } - recordDecision('routine-deny', 'first-command'); - const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; - const narrowRecoveryHint = toolName === 'PowerShell' - ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT - : ROUTINE_BASH_NARROW_RECOVERY_HINT; - return denyResult(routineShellMsg(toolName), { - hookIds: [hookId], - narrowRecoveryHint - }); - } - - recordDecision('pass-checked', 'routine-checked'); - return rawInput; // allow + if (batch.notes.length > 0) { + return { additionalContext: batch.notes, exitCode: 0 }; } - return rawInput; // allow } +function gateMultiEditSubagent(edits, data, rawInput) { + for (const edit of edits) { + const filePath = (edit && edit.file_path) || ''; + if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; + const denial = subagentRestrictedDenial(filePath, data, false); + if (denial) return denial; + } + recordDecision('pass-subagent', 'subagent'); + return rawInput; +} + +// Returns a gate result to stop on, or null when the batch loop should move +// to the next edit. `batch` collects notes and trivially allowed file keys. +function firstTouchMultiEdit(ctx, edits, filePath, fileKey, batch) { + const { data, getTurnScan } = ctx; + const cls = classifyTargetFor(filePath, data); + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey }); + batch.notes.push(creditNote(credit, filePath)); + return null; + } + const profile = restricted || !needsProfile('MultiEdit', cls) + ? null + : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data); + if (isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + batch.trivialKeys.add(fileKey); + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey }); + batch.notes.push(trivialNote(filePath)); + return null; + } + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); + if (!ok) { + return allowWithStateWarning(); + } + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey }); + return null; + } + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data); + return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss }); +} + +// --- Bash / PowerShell --- + +function gateShell(ctx) { + const { toolName, toolInput, rawInput } = ctx; + const command = toolInput.command || ''; + if (isReadOnlyGitIntrospection(command)) { + recordDecision('pass', 'readonly-git'); + return rawInput; + } + + if (classifyDestructiveCommand(toolName, command).length > 0) return gateDestructiveShell(command, rawInput); + + // Operator opt-out: skip the routine shell gate entirely. The destructive + // gate above still fires. This is the documented escape hatch for hosts + // (Cursor, OpenCode, etc.) where the once-per-session routine gate is + // friction without signal. + if (isRoutineBashGateDisabled()) { + recordDecision('pass-exempt', 'routine-disabled'); + return rawInput; // routine gate opted out via env + } + + if (!isChecked(ROUTINE_BASH_SESSION_KEY)) return gateRoutineShell(toolName, command, rawInput); + + recordDecision('pass-checked', 'routine-checked'); + return rawInput; // allow +} + +// Gate destructive commands on first attempt; allow retry after facts presented +function gateDestructiveShell(command, rawInput) { + const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); + if (isChecked(key)) { + recordDecision('pass-checked', 'destructive-retry'); + return rawInput; // allow retry after facts presented + } + if (!markChecked(key)) { + return allowWithStateWarning(); + } + recordDecision('destructive-deny', 'destructive'); + return denyResult(destructiveBashMsg(), { includeRecoveryHint: false }); +} + +function gateRoutineShell(toolName, command, rawInput) { + if (isReadOnlyShellCommand(toolName, command)) { + if (!countRoutineReadonlyPass()) { + return allowWithStateWarning(); + } + recordDecision('routine-readonly', 'readonly'); + return rawInput; + } + if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { + return allowWithStateWarning(); + } + recordDecision('routine-deny', 'first-command'); + const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; + const narrowRecoveryHint = toolName === 'PowerShell' + ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT + : ROUTINE_BASH_NARROW_RECOVERY_HINT; + return denyResult(routineShellMsg(toolName), { + hookIds: [hookId], + narrowRecoveryHint + }); +} + module.exports = { run, classifyDestructiveCommand, diff --git a/scripts/hooks/lib/shell-scan.js b/scripts/hooks/lib/shell-scan.js index b887e2789..94a23a32a 100644 --- a/scripts/hooks/lib/shell-scan.js +++ b/scripts/hooks/lib/shell-scan.js @@ -18,13 +18,15 @@ function continuationEnd(input, index) { return index; } -// Delimiters undergo quote removal, not command expansion. Keeping this small -// reader shared with substitution matching prevents body punctuation becoming -// shell syntax while locating the enclosing execution region. -function heredocDelimiter(input, start, budget) { - if (!input.startsWith('<<', start) || input[start + 2] === '<') return null; - const operator = input[start + 2] === '-' ? '<<-' : '<<'; - let i = start + operator.length; +// Skip to the end of a comment. The returned index is the newline or EOF. +function skipComment(input, start, budget) { + let i = start; + while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } + return i; +} + +function skipDelimiterBlanks(input, start, budget) { + let i = start; while (i < input.length) { budget.spend(); if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; } @@ -32,37 +34,55 @@ function heredocDelimiter(input, start, budget) { if (continued === i) break; i = continued; } - let value = ''; - let quote = null; - let began = false; - let quoted = false; + return i; +} + +// A backslash in the delimiter word. Returns the index of the last consumed +// character; a continuation is removed without starting the word. +function delimiterEscape(input, i, word) { + const continued = continuationEnd(input, i); + if (continued !== i) return continued - 1; + word.began = true; word.quoted = true; + const next = input[i + 1]; + if (next === undefined) { word.value += '\\'; return i; } + if (word.quote === '"' && !'"$`\\'.includes(next)) word.value += '\\'; + word.value += next; + return i + 1; +} + +function readDelimiterWord(input, start, budget) { + const word = { value: '', quote: null, began: false, quoted: false, end: start }; + let i = start; for (; i < input.length; i++) { budget.spend(); const c = input[i]; - if (quote === "'") { - if (c === "'") quote = null; - else value += c; + if (word.quote === "'") { + if (c === "'") word.quote = null; + else word.value += c; continue; } - if (c === '\\') { - const continued = continuationEnd(input, i); - if (continued !== i) { i = continued - 1; continue; } - began = true; quoted = true; - const next = input[i + 1]; - if (next === undefined) { value += c; continue; } - if (quote === '"' && !'"$`\\'.includes(next)) value += '\\'; - value += next; i++; continue; - } - if (quote === '"') { - if (c === '"') quote = null; - else value += c; + if (c === '\\') { i = delimiterEscape(input, i, word); continue; } + if (word.quote === '"') { + if (c === '"') word.quote = null; + else word.value += c; continue; } - if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; } - if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break; - began = true; value += c; + if (c === "'" || c === '"') { word.quote = c; word.began = true; word.quoted = true; continue; } + if (/[\s;|&()<>]/.test(c) || (!word.began && c === '#')) break; + word.began = true; word.value += c; } - return began ? { operator, word: { value, quoted }, end: i } : null; + word.end = i; + return word; +} + +// Delimiters undergo quote removal, not command expansion. Keeping this small +// reader shared with substitution matching prevents body punctuation becoming +// shell syntax while locating the enclosing execution region. +function heredocDelimiter(input, start, budget) { + if (!input.startsWith('<<', start) || input[start + 2] === '<') return null; + const operator = input[start + 2] === '-' ? '<<-' : '<<'; + const word = readDelimiterWord(input, skipDelimiterBlanks(input, start + operator.length, budget), budget); + return word.began ? { operator, word: { value: word.value, quoted: word.quoted }, end: word.end } : null; } function heredocLine(input, start, joinContinuations, budget) { @@ -129,90 +149,141 @@ function legacyRegion(input, start, budget) { return { text: decoded.join(''), end: i < input.length ? i + 1 : i }; } +// --- $(...) region state --- +// Each helper below takes the innermost frame and the index of the current +// character. Those that consume input return the index of the last consumed +// character, or undefined when the character was not theirs to handle. + +function regionFrame() { + return { quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] }; +} + +function endRegionWord(state) { + if (!state.word) return; + const phase = state.cases[state.cases.length - 1]; + if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop(); + else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject'); + else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in'; + else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern'; + state.commandStart = false; + state.word = ''; state.quotedWord = false; +} + +// Inside '...' or $'...': only the closing quote matters, and an ANSI-C escape +// protects the next character. +function regionQuotedEnd(state, input, i) { + const c = input[i]; + if (state.quote === "$'" && c === '\\' && i + 1 < input.length) return i + 1; + if (c === "'") state.quote = null; + return i; +} + +// Escapes and nested substitutions apply inside double quotes as well. +function regionSubstitution(state, stack, input, i, budget) { + const c = input[i]; + if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; return i + 1; } + if (c === '$' && input[i + 1] === '(') { + state.word += '$()'; state.quotedWord = true; stack.push(regionFrame()); return i + 1; + } + if (c === '`') { + const region = legacyRegion(input, i, budget); + state.word += '`'; state.quotedWord = true; return region.end - 1; + } + return undefined; +} + +function openRegionQuote(state, input, i) { + const c = input[i]; + if (c === '$' && input[i + 1] === "'") { + state.quote = "$'"; state.word += "$'"; state.quotedWord = true; return i + 1; + } + if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; return i; } + return undefined; +} + +// A here-string or heredoc operator ends the current word. A heredoc +// delimiter queues its body for the next newline. +function regionHeredocOperator(state, input, i, budget) { + if (input[i] !== '<') return undefined; + if (input.startsWith('<<<', i)) { endRegionWord(state); return i + 2; } + if (input[i + 1] === '<' && input[i + 2] !== '<') { + endRegionWord(state); + const delimiter = heredocDelimiter(input, i, budget); + if (delimiter) { state.heredocs.push(delimiter); return delimiter.end - 1; } + } + return undefined; +} + +function regionHeredocBodies(state, input, i, budget) { + endRegionWord(state); + let next = i + 1; + for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end; + state.heredocs.length = 0; + state.commandStart = true; + return next - 1; +} + +// A case pattern's closing ')' is not the end of $(...). Only literal +// keyword positions affect this state; quoted or echo operands do not. +function casePhaseStep(state, input, i) { + const c = input[i]; + const phase = state.cases[state.cases.length - 1]; + if (c === ')' && phase === 'pattern') { + state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; return i; + } + if (c === '(' && phase === 'pattern') return i; + if (c === ';' && input[i + 1] === ';' && phase === 'body') { + state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; return i + 1; + } + return undefined; +} + +// Tracks parenthesis depth; true once the outermost region has closed. +function closeRegionParen(stack, state, c) { + if (/[;|&\n]/.test(c)) state.commandStart = true; + if (c === '(') state.depth++; + if (c !== ')') return false; + state.depth--; + if (state.depth !== 0) return false; + stack.pop(); + return stack.length === 0; +} + // Locate a nested execution region without evaluating any supplied text. // Balanced substitutions have independent quote state; malformed regions extend // to EOF and remain conservatively inspectable instead of silently disappearing. function executionRegion(input, start, budget) { if (input[start] === '`') return legacyRegion(input, start, budget); const from = start + 2; - const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] }); - const stack = [frame()]; - function endWord(state) { - if (!state.word) return; - const phase = state.cases[state.cases.length - 1]; - if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop(); - else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject'); - else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in'; - else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern'; - state.commandStart = false; - state.word = ''; state.quotedWord = false; - } + const stack = [regionFrame()]; for (let i = from; i < input.length; i++) { budget.spend(); const state = stack[stack.length - 1]; const c = input[i]; - if (state.quote === "'" || state.quote === "$'") { - if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; } - if (c === "'") state.quote = null; - continue; - } - if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; } - if (c === '$' && input[i + 1] === '(') { - state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue; - } - if (c === '`') { - const region = legacyRegion(input, i, budget); - state.word += '`'; state.quotedWord = true; i = region.end - 1; continue; - } + if (state.quote === "'" || state.quote === "$'") { i = regionQuotedEnd(state, input, i); continue; } + const substituted = regionSubstitution(state, stack, input, i, budget); + if (substituted !== undefined) { i = substituted; continue; } if (state.quote === '"') { if (c === '"') state.quote = null; continue; } - if (c === '$' && input[i + 1] === "'") { - state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue; - } - if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; } + const opened = openRegionQuote(state, input, i); + if (opened !== undefined) { i = opened; continue; } if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) { - while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } - i--; // Process the newline, including any pending heredoc bodies. + // Stop before the newline so pending heredoc bodies are still processed. + i = skipComment(input, i, budget) - 1; continue; } - if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; } - if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') { - endWord(state); - const delimiter = heredocDelimiter(input, i, budget); - if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; } - } - if (c === '\n' && state.heredocs.length) { - endWord(state); - let next = i + 1; - for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end; - state.heredocs.length = 0; - state.commandStart = true; i = next - 1; continue; - } - if (/[\s;|&()]/.test(c)) endWord(state); + const redirected = regionHeredocOperator(state, input, i, budget); + if (redirected !== undefined) { i = redirected; continue; } + if (c === '\n' && state.heredocs.length) { i = regionHeredocBodies(state, input, i, budget); continue; } + if (/[\s;|&()]/.test(c)) endRegionWord(state); else state.word += c; - const phase = state.cases[state.cases.length - 1]; - // A case pattern's closing ')' is not the end of $(...). Only literal - // keyword positions affect this state; quoted or echo operands do not. - if (c === ')' && phase === 'pattern') { - state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue; - } - if (c === '(' && phase === 'pattern') continue; - if (c === ';' && input[i + 1] === ';' && phase === 'body') { - state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue; - } - if (/[;|&\n]/.test(c)) state.commandStart = true; - if (c === '(') state.depth++; - if (c === ')') { - state.depth--; - if (state.depth === 0) { - stack.pop(); - if (stack.length === 0) { - budget.spend(i - from + 1); - return { text: input.slice(from, i), end: i + 1 }; - } - } + const stepped = casePhaseStep(state, input, i); + if (stepped !== undefined) { i = stepped; continue; } + if (closeRegionParen(stack, state, c)) { + budget.spend(i - from + 1); + return { text: input.slice(from, i), end: i + 1 }; } } budget.spend(input.length - from + 1); @@ -243,164 +314,226 @@ function scanExpansions(input, budget) { return nested; } -function scanShell(input, budget) { - const commands = []; - const nested = []; - const pendingHeredocs = []; +// --- Top-level scan state --- +// scan.i is the read position; every helper advances it past what it consumed. + +const COMPOUND_KEYWORDS = ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function']; + +function newCommand(scan, pipeFrom) { + return { words: [], redirects: [], pipeFrom, nested: [], scope: scan.scope }; +} + +function createScan(input, budget) { const rootScope = { parent: null, isolated: false, conditional: false }; - let scope = rootScope; - const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope }); - let current = command(null); - let word = null; - let quote = null; - let pendingRedirect = null; - let i = 0; - function begin() { - if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false }; + const scan = { + input, budget, commands: [], nested: [], pendingHeredocs: [], rootScope, + scope: rootScope, current: null, word: null, quote: null, pendingRedirect: null, i: 0 + }; + scan.current = newCommand(scan, null); + return scan; +} + +function beginWord(scan) { + if (!scan.word) scan.word = { value: '', start: scan.i, end: scan.i, quoted: false, literal: true, dynamic: false }; +} + +function flushWord(scan) { + const { word, current } = scan; + if (!word) return; + word.end = scan.i; + word.raw = scan.input.slice(word.start, scan.i); + if (scan.pendingRedirect) { + const redirect = { operator: scan.pendingRedirect, word, body: '' }; + current.redirects.push(redirect); + if (scan.pendingRedirect === '<<' || scan.pendingRedirect === '<<-') scan.pendingHeredocs.push({ redirect, owner: current }); + scan.pendingRedirect = null; + } else current.words.push(word); + scan.word = null; +} + +function flushCommand(scan, pipe = false, background = false) { + flushWord(scan); + const previous = scan.current; + previous.pipeTo = pipe; + previous.background = background; + if (previous.closedScope && (pipe || background)) { + previous.closedScope.isolated = true; + previous.closedScope.pipelineLast = false; } - function flushWord() { - if (!word) return; - word.end = i; - word.raw = input.slice(word.start, i); - if (pendingRedirect) { - const redirect = { operator: pendingRedirect, word, body: '' }; - current.redirects.push(redirect); - if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current }); - pendingRedirect = null; - } else current.words.push(word); - word = null; - } - function flushCommand(pipe = false, background = false) { - flushWord(); - const previous = current; - previous.pipeTo = pipe; - previous.background = background; - if (previous.closedScope && (pipe || background)) { - previous.closedScope.isolated = true; - previous.closedScope.pipelineLast = false; + const first = previous.words[0]; + if (first && !first.quoted && COMPOUND_KEYWORDS.includes(first.value)) scan.scope.conditional = true; + if (previous.words.length || previous.redirects.length) scan.commands.push(previous); + scan.current = newCommand(scan, pipe ? previous : null); + scan.pendingRedirect = null; +} + +function consumeHeredocs(scan) { + for (const { redirect, owner } of scan.pendingHeredocs) { + const region = heredocBody(scan.input, scan.i, redirect, scan.budget); + redirect.body = region.body; + scan.i = region.end; + if (!redirect.word.quoted) { + const regions = scanExpansions(redirect.body, scan.budget); + for (const text of regions) { scan.budget.spend(); scan.nested.push(text); owner.nested.push(text); } } - const first = previous.words[0]; - if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true; - if (previous.words.length || previous.redirects.length) commands.push(previous); - current = command(pipe ? previous : null); - pendingRedirect = null; } - function consumeHeredocs() { - for (const { redirect, owner } of pendingHeredocs) { - const region = heredocBody(input, i, redirect, budget); - redirect.body = region.body; - i = region.end; - if (!redirect.word.quoted) { - const regions = scanExpansions(redirect.body, budget); - for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); } - } - } - pendingHeredocs.length = 0; + scan.pendingHeredocs.length = 0; +} + +// Inside '...' or $'...'. +function scanQuotedChar(scan) { + const c = scan.input[scan.i]; + if (scan.quote === "$'" && c === '\\' && scan.i + 1 < scan.input.length) { + const next = scan.input[scan.i + 1]; + // Preserve boundaries without claiming general ANSI-C escape expansion. + scan.word.value += next === "'" || next === '\\' ? next : c + next; + scan.i += 2; + return; } - while (i < input.length) { + if (c === "'") scan.quote = null; + else scan.word.value += c; + scan.i++; +} + +function scanDoubleQuotedChar(scan, c) { + if (c === '"') scan.quote = null; + else scan.word.value += c; + scan.i++; +} + +function scanEscape(scan) { + beginWord(scan); scan.word.quoted = true; + const next = scan.input[scan.i + 1]; + if (next === undefined) { scan.word.value += '\\'; scan.i++; return; } + if (scan.quote === '"' && !'"$`\\'.includes(next)) scan.word.value += '\\'; + scan.word.value += next; scan.i += 2; +} + +function scanSubstitution(scan) { + beginWord(scan); scan.word.literal = false; scan.word.dynamic = true; + const region = executionRegion(scan.input, scan.i, scan.budget); + scan.nested.push(region.text); scan.current.nested.push(region.text); + scan.word.value += '\u0000'; scan.i = region.end; +} + +// Parameter expansions remain opaque values. Escaped/single/ANSI-C +// quoted dollars have already been consumed as data before this point. +function markParameterExpansion(scan, c) { + if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(scan.input[scan.i + 1] || '')) { + beginWord(scan); scan.word.dynamic = true; + } +} + +function openQuote(scan, c) { + if (c === '$' && scan.input[scan.i + 1] === "'") { + // ANSI-C escaped quotes do not close the word; its contents never expand. + // Numeric/control escapes and generated names remain outside this grammar. + beginWord(scan); scan.word.quoted = true; scan.quote = "$'"; scan.i += 2; + return true; + } + if (c === '"' || c === "'") { beginWord(scan); scan.word.quoted = true; scan.quote = c; scan.i++; return true; } + return false; +} + +function isBraceKeyword(scan, c) { + return (c === '{' || c === '}') && !scan.word && scan.current.words.length === 0 && /[\s;&|]/.test(scan.input[scan.i + 1] || ' '); +} + +function markFunctionDefinition(scan) { + flushWord(scan); + // A function definition does not execute its body. Function grammar + // is unsupported: keep pre-definition alternatives instead of using + // flattened body mutations to certify a later command as safe. + const words = scan.current.words; + if (words.length === 1 && !words[0].quoted && /^[A-Za-z_][A-Za-z0-9_]*$/.test(words[0].value)) scan.scope.conditional = true; +} + +function openScope(scan, c, incomingPipe) { + scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe }; + scan.current.scope = scan.scope; +} + +function closeScope(scan) { + const closedScope = scan.scope; + scan.scope = closedScope.parent; + scan.current.scope = scan.scope; + scan.current.closedScope = closedScope; + // Ordered metadata only; there is no executable argv in this event. + scan.commands.push({ ...newCommand(scan, null), scopeExit: closedScope }); +} + +function scanOperator(scan, c, braceKeyword) { + const doubled = (c === '&' || c === '|') && scan.input[scan.i + 1] === c; + const pipe = c === '|' && !doubled; + const background = c === '&' && !doubled; + if (doubled) scan.scope.conditional = true; + const incomingPipe = Boolean(scan.current.pipeFrom); + if (c === '(') markFunctionDefinition(scan); + flushCommand(scan, pipe, background); + if (c === '(' || (braceKeyword && c === '{')) openScope(scan, c, incomingPipe); + else if ((c === ')' || (braceKeyword && c === '}')) && scan.scope.parent) closeScope(scan); + scan.i += doubled ? 2 : 1; + if (c === '\n') consumeHeredocs(scan); +} + +// An immediately adjacent numeric word is a descriptor, not an argv word. +function dropDescriptorWord(scan) { + if (scan.word && /^\d+$/.test(scan.word.value)) scan.word = null; +} + +function queueHeredoc(scan, delimiter) { + dropDescriptorWord(scan); + flushWord(scan); + const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' }; + scan.current.redirects.push(redirect); + scan.pendingHeredocs.push({ redirect, owner: scan.current }); + scan.i = delimiter.end; + scan.pendingRedirect = null; +} + +function redirectOperator(input, i, c) { + let operator = c; + if (input[i + 1] === c) operator += c; + if (operator === '<<' && input[i + 2] === '<') operator = '<<<'; + else if (operator === '<<' && input[i + 2] === '-') operator = '<<-'; + else if (input[i + 1] === '&') operator += '&'; + return operator; +} + +function scanRedirect(scan, c) { + const { input } = scan; + if (c === '<' && input[scan.i + 1] === '<' && input[scan.i + 2] !== '<') { + const delimiter = heredocDelimiter(input, scan.i, scan.budget); + if (delimiter) { queueHeredoc(scan, delimiter); return; } + } + dropDescriptorWord(scan); + flushWord(scan); + scan.pendingRedirect = redirectOperator(input, scan.i, c); + scan.i += scan.pendingRedirect.length; +} + +function scanShell(input, budget) { + const scan = createScan(input, budget); + while (scan.i < input.length) { budget.spend(); - const c = input[i]; - if (quote === "'" || quote === "$'") { - if (quote === "$'" && c === '\\' && i + 1 < input.length) { - const next = input[i + 1]; - // Preserve boundaries without claiming general ANSI-C escape expansion. - word.value += next === "'" || next === '\\' ? next : c + next; - i += 2; continue; - } - if (c === "'") quote = null; - else word.value += c; - i++; continue; - } - const continued = continuationEnd(input, i); - if (continued !== i) { i = continued; continue; } - if (c === '\\') { - begin(); word.quoted = true; - const next = input[i + 1]; - if (next === undefined) { word.value += c; i++; continue; } - if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\'; - word.value += next; i += 2; continue; - } - if (hasExpansion(input, i, quote === null)) { - begin(); word.literal = false; word.dynamic = true; - const region = executionRegion(input, i, budget); - nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue; - } - // Parameter expansions remain opaque values. Escaped/single/ANSI-C - // quoted dollars have already been consumed as data above. - if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) { - begin(); word.dynamic = true; - } - if (quote === '"') { - if (c === '"') quote = null; - else word.value += c; - i++; continue; - } - if (c === '$' && input[i + 1] === "'") { - // ANSI-C escaped quotes do not close the word; its contents never expand. - // Numeric/control escapes and generated names remain outside this grammar. - begin(); word.quoted = true; quote = "$'"; i += 2; continue; - } - if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; } - if (c === '#' && !word) { - while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } - continue; - } - const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' '); - if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) { - const pipe = c === '|' && input[i + 1] !== '|'; - const background = c === '&' && input[i + 1] !== '&'; - if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true; - const incomingPipe = Boolean(current.pipeFrom); - if (c === '(') { - flushWord(); - // A function definition does not execute its body. Function grammar - // is unsupported: keep pre-definition alternatives instead of using - // flattened body mutations to certify a later command as safe. - if (current.words.length === 1 && !current.words[0].quoted && - /^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true; - } - flushCommand(pipe, background); - if (c === '(' || (braceKeyword && c === '{')) { - scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe }; - current.scope = scope; - } else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) { - const closedScope = scope; - scope = scope.parent; - current.scope = scope; - current.closedScope = closedScope; - // Ordered metadata only; there is no executable argv in this event. - commands.push({ ...command(null), scopeExit: closedScope }); - } - i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1; - if (c === '\n') consumeHeredocs(); - continue; - } - if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') { - const delimiter = heredocDelimiter(input, i, budget); - if (delimiter) { - if (word && /^\d+$/.test(word.value)) word = null; - flushWord(); - const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' }; - current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current }); - i = delimiter.end; pendingRedirect = null; continue; - } - } - if (c === '<' || c === '>') { - // An immediately adjacent numeric word is a descriptor, not an argv word. - if (word && /^\d+$/.test(word.value)) word = null; - flushWord(); - let operator = c; - if (input[i + 1] === c) operator += c; - if (operator === '<<' && input[i + 2] === '<') operator = '<<<'; - else if (operator === '<<' && input[i + 2] === '-') operator = '<<-'; - else if (input[i + 1] === '&') operator += '&'; - pendingRedirect = operator; i += operator.length; continue; - } - if (/\s/.test(c)) { flushWord(); i++; continue; } - begin(); word.value += c; i++; + if (scan.quote === "'" || scan.quote === "$'") { scanQuotedChar(scan); continue; } + const continued = continuationEnd(input, scan.i); + if (continued !== scan.i) { scan.i = continued; continue; } + const c = input[scan.i]; + if (c === '\\') { scanEscape(scan); continue; } + if (hasExpansion(input, scan.i, scan.quote === null)) { scanSubstitution(scan); continue; } + markParameterExpansion(scan, c); + if (scan.quote === '"') { scanDoubleQuotedChar(scan, c); continue; } + if (openQuote(scan, c)) continue; + if (c === '#' && !scan.word) { scan.i = skipComment(input, scan.i, budget); continue; } + const braceKeyword = isBraceKeyword(scan, c); + if (braceKeyword || '\n;&|()'.includes(c)) { scanOperator(scan, c, braceKeyword); continue; } + if (c === '<' || c === '>') { scanRedirect(scan, c); continue; } + if (/\s/.test(c)) { flushWord(scan); scan.i++; continue; } + beginWord(scan); scan.word.value += c; scan.i++; } - flushCommand(); - return { commands, nested, rootScope }; + flushCommand(scan); + return { commands: scan.commands, nested: scan.nested, rootScope: scan.rootScope }; } module.exports = { createBudget, scanShell, scanExpansions }; diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index c7431eea7..1235cf7a9 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -18,6 +18,61 @@ const SUMMARY_START_MARKER = ''; const SUMMARY_END_MARKER = ''; const SESSION_SEPARATOR = '\n---\n'; +// Harness noise: local command echoes, caveats, system reminders. +const HARNESS_NOISE = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i; +const TRANSCRIPT_UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const NEXT_SESSION_FOOTER = '\n\n### Notes for Next Session\n-\n\n### Context to Load\n```\n[relevant files]\n```'; +const EMPTY_SESSION_BODY = '## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n```\n[relevant files]\n```'; + +/** + * Text of a user turn, or '' when the entry is not a user ask worth keeping. + */ +function userMessageText(entry) { + if (entry.type !== 'user' && entry.role !== 'user' && entry.message?.role !== 'user') return ''; + // Support both direct content and nested message.content (Claude Code JSONL format) + const rawContent = entry.message?.content ?? entry.content; + // Skip tool_result carrier turns — they are not user asks. + if (Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result')) return ''; + const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; + const cleaned = stripAnsi(text).trim(); + // `isMeta` is also used for genuine channel- and plugin-originated + // human prompts. Exclude known structured noise instead of + // discarding every metadata-marked user turn. + return HARNESS_NOISE.test(cleaned) ? '' : cleaned; +} + +/** + * Record one tool invocation: its name, and its target when it edits a file. + */ +function recordToolUse(toolName, filePath, tally) { + if (toolName) tally.toolsUsed.add(toolName); + if (filePath && (toolName === 'Edit' || toolName === 'Write')) { + tally.filesModified.add(filePath); + } +} + +/** + * Fold one transcript entry into the running tally. + */ +function collectEntry(entry, tally) { + // Collect user messages (first 200 chars each) + const text = userMessageText(entry); + if (text) tally.userMessages.push(text.slice(0, 200)); + + // Collect tool names and modified files (direct tool_use entries) + if (entry.type === 'tool_use' || entry.tool_name) { + const toolName = entry.tool_name || entry.name || ''; + recordToolUse(toolName, entry.tool_input?.file_path || entry.input?.file_path || '', tally); + } + + // Extract tool uses from assistant message content blocks (Claude Code JSONL format) + if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { + for (const block of entry.message.content) { + if (block.type === 'tool_use') recordToolUse(block.name || '', block.input?.file_path || '', tally); + } + } +} + /** * Extract a meaningful summary from the session transcript. * Reads the JSONL transcript and pulls out key information: @@ -30,58 +85,12 @@ function extractSessionSummary(transcriptPath) { if (!content) return null; const lines = content.split('\n').filter(Boolean); - const userMessages = []; - const toolsUsed = new Set(); - const filesModified = new Set(); + const tally = { userMessages: [], toolsUsed: new Set(), filesModified: new Set() }; let parseErrors = 0; for (const line of lines) { try { - const entry = JSON.parse(line); - - // Collect user messages (first 200 chars each) - if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') { - // Support both direct content and nested message.content (Claude Code JSONL format) - const rawContent = entry.message?.content ?? entry.content; - // Skip tool_result carrier turns — they are not user asks. - const isToolResult = Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result'); - const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; - const cleaned = stripAnsi(text).trim(); - // Skip harness noise: local command echoes, caveats, system reminders. - const isNoise = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i.test(cleaned); - // `isMeta` is also used for genuine channel- and plugin-originated - // human prompts. Exclude known structured noise above instead of - // discarding every metadata-marked user turn. - if (cleaned && !isToolResult && !isNoise) { - userMessages.push(cleaned.slice(0, 200)); - } - } - - // Collect tool names and modified files (direct tool_use entries) - if (entry.type === 'tool_use' || entry.tool_name) { - const toolName = entry.tool_name || entry.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - - // Extract tool uses from assistant message content blocks (Claude Code JSONL format) - if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { - for (const block of entry.message.content) { - if (block.type === 'tool_use') { - const toolName = block.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = block.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - } - } + collectEntry(JSON.parse(line), tally); } catch { parseErrors++; } @@ -91,13 +100,13 @@ function extractSessionSummary(transcriptPath) { log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`); } - if (userMessages.length === 0) return null; + if (tally.userMessages.length === 0) return null; return { - userMessages: userMessages.slice(-10), // Last 10 user messages - toolsUsed: Array.from(toolsUsed).slice(0, 20), - filesModified: Array.from(filesModified).slice(0, 30), - totalMessages: userMessages.length + userMessages: tally.userMessages.slice(-10), // Last 10 user messages + toolsUsed: Array.from(tally.toolsUsed).slice(0, 20), + filesModified: Array.from(tally.filesModified).slice(0, 30), + totalMessages: tally.userMessages.length }; } @@ -174,25 +183,130 @@ function mergeSessionHeader(content, today, currentTime, metadata) { return `${nextHeader}${SESSION_SEPARATOR}${body}`; } -async function main() { - // Parse stdin JSON to get transcript_path; fall back to env var on missing, - // empty, or non-string values as well as on malformed JSON. - let transcriptPath = null; +/** + * Parse stdin JSON to get transcript_path; fall back to env var on missing, + * empty, or non-string values as well as on malformed JSON. + */ +function resolveTranscriptPath() { try { const input = JSON.parse(stdinData); if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) { - transcriptPath = input.transcript_path; + return input.transcript_path; } } catch { // Malformed stdin: fall through to the env-var fallback below. } - if (!transcriptPath) { - const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; - if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) { - transcriptPath = envTranscriptPath; + const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + return typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0 ? envTranscriptPath : null; +} + +/** + * Derive shortId from transcript_path UUID when available, using the SAME + * last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps + * backward compatibility for normal sessions (the derived shortId matches what + * getSessionIdShort() would have produced from the same UUID), while making + * every session map to a unique filename based on its own transcript UUID. + * + * Without this, a parent session and any `claude -p ...` subprocess spawned by + * another Stop hook share the project-name fallback filename, and the subprocess + * overwrites the parent's summary. See issue #1494 for full repro details. + */ +function sessionShortId(transcriptPath) { + const m = transcriptPath ? path.basename(transcriptPath).match(TRANSCRIPT_UUID) : null; + // Run through sanitizeSessionId() for byte-for-byte parity with + // getSessionIdShort(sessionId.slice(-8)). + const derived = m ? sanitizeSessionId(m[1].slice(-8).toLowerCase()) : null; + return derived || getSessionIdShort(); +} + +/** + * Decide whether to call LLM for a richer summary. + * Triggers: context remaining < 20%, or every 50 user messages as a baseline. + */ +function maybeGenerateLlmSummary(transcriptPath, summary) { + const contextPct = getContextRemainingPct(transcriptPath); + const isContextLow = contextPct !== null && contextPct < getContextThreshold(); + const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); + const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50; + const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; + if (!isContextLow && !isPeriodicTurn) return null; + + log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); + let skipped = false; + const llmSummary = generateSessionSummary(transcriptPath, { + onSkip: reason => { + skipped = true; + log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); + } + }); + if (llmSummary) { + log('[SessionEnd] LLM summary generated successfully'); + } else if (!skipped) { + log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); + } + return llmSummary; +} + +function summaryBlockFor(summary, llmSummary) { + return llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); +} + +/** + * Replace only the generated summary block. This keeps repeated Stop + * invocations idempotent and preserves user-authored sections in the same + * session file. + */ +function replaceSummaryBlock(content, summaryBlock) { + // Use function replacers: summaryBlock embeds raw user-message text, and a + // string replacement argument interprets $-sequences ($&, $$, $`, $', $n). + // A $& in a user message would otherwise re-inject the entire matched block + // and corrupt the persisted summary. A function replacer is treated literally. + if (content.includes(SUMMARY_START_MARKER) && content.includes(SUMMARY_END_MARKER)) { + return content.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); + } + // Migration path for files created before summary markers existed. + return content.replace( + /## (?:Session Summary|Current State)[\s\S]*?$/, + () => `${summaryBlock}${NEXT_SESSION_FOOTER}\n` + ); +} + +function updateSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const existing = readFile(sessionFile); + let updatedContent = existing; + + if (existing) { + const merged = mergeSessionHeader(existing, today, currentTime, metadata); + if (merged) { + updatedContent = merged; + } else { + log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); } } + if (summary && updatedContent) { + updatedContent = replaceSummaryBlock(updatedContent, summaryBlockFor(summary, llmSummary)); + } + + if (updatedContent) { + writeFile(sessionFile, updatedContent); + } + + log(`[SessionEnd] Updated session file: ${sessionFile}`); +} + +function createSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const block = llmSummary || summary ? summaryBlockFor(summary, llmSummary) : null; + const summarySection = block ? `${block}${NEXT_SESSION_FOOTER}` : EMPTY_SESSION_BODY; + const template = `${buildSessionHeader(today, currentTime, metadata)}${SESSION_SEPARATOR}${summarySection}\n`; + + writeFile(sessionFile, template); + log(`[SessionEnd] Created session file: ${sessionFile}`); +} + +async function main() { + const transcriptPath = resolveTranscriptPath(); + // ECC's LLM summary helper launches a one-shot Claude subprocess whose Stop // hooks inherit this dedicated marker. Skip that known internal session // before touching session state. Transcript cardinality is not a safe proxy: @@ -218,111 +332,20 @@ async function main() { const sessionsDir = getSessionsDir(); const today = getDateString(); - // Derive shortId from transcript_path UUID when available, using the SAME - // last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps - // backward compatibility for normal sessions (the derived shortId matches what - // getSessionIdShort() would have produced from the same UUID), while making - // every session map to a unique filename based on its own transcript UUID. - // - // Without this, a parent session and any `claude -p ...` subprocess spawned by - // another Stop hook share the project-name fallback filename, and the subprocess - // overwrites the parent's summary. See issue #1494 for full repro details. - let shortId = null; - if (transcriptPath) { - const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i); - if (m) { - // Run through sanitizeSessionId() for byte-for-byte parity with - // getSessionIdShort(sessionId.slice(-8)). - shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase()); - } - } - if (!shortId) { - shortId = getSessionIdShort(); - } + const shortId = sessionShortId(transcriptPath); const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - const sessionMetadata = getSessionMetadata(); + const metadata = getSessionMetadata(); ensureDir(sessionsDir); const currentTime = getTimeString(); - - // Decide whether to call LLM for a richer summary. - // Triggers: context remaining < 20%, or every 50 user messages as a baseline. - let llmSummary = null; - if (transcriptPath && summary && transcriptExists) { - const contextPct = getContextRemainingPct(transcriptPath); - const isContextLow = contextPct !== null && contextPct < getContextThreshold(); - const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); - const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50; - const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; - if (isContextLow || isPeriodicTurn) { - log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); - let skipped = false; - llmSummary = generateSessionSummary(transcriptPath, { - onSkip: reason => { - skipped = true; - log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); - } - }); - if (llmSummary) { - log('[SessionEnd] LLM summary generated successfully'); - } else if (!skipped) { - log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); - } - } - } + const llmSummary = transcriptPath && summary && transcriptExists ? maybeGenerateLlmSummary(transcriptPath, summary) : null; + const context = { today, currentTime, metadata, summary, llmSummary }; if (fs.existsSync(sessionFile)) { - const existing = readFile(sessionFile); - let updatedContent = existing; - - if (existing) { - const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata); - if (merged) { - updatedContent = merged; - } else { - log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); - } - } - - // If we have a new summary, update only the generated summary block. - // This keeps repeated Stop invocations idempotent and preserves - // user-authored sections in the same session file. - if (summary && updatedContent) { - const summaryBlock = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); - - // Use function replacers: summaryBlock embeds raw user-message text, and a - // string replacement argument interprets $-sequences ($&, $$, $`, $', $n). - // A $& in a user message would otherwise re-inject the entire matched block - // and corrupt the persisted summary. A function replacer is treated literally. - if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) { - updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); - } else { - // Migration path for files created before summary markers existed. - updatedContent = updatedContent.replace( - /## (?:Session Summary|Current State)[\s\S]*?$/, - () => `${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n` - ); - } - } - - if (updatedContent) { - writeFile(sessionFile, updatedContent); - } - - log(`[SessionEnd] Updated session file: ${sessionFile}`); + updateSessionFile(sessionFile, context); } else { - // Create new session file - const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null; - const summarySection = block - ? `${block}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`` - : `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``; - - const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection} -`; - - writeFile(sessionFile, template); - log(`[SessionEnd] Created session file: ${sessionFile}`); + createSessionFile(sessionFile, context); } process.exit(0); diff --git a/scripts/lib/gateguard-code-lexer.js b/scripts/lib/gateguard-code-lexer.js index f89c9e101..c45d7e53b 100644 --- a/scripts/lib/gateguard-code-lexer.js +++ b/scripts/lib/gateguard-code-lexer.js @@ -110,141 +110,170 @@ function stringSpecials(quote) { return pattern; } +// --- lexCodeLines state --- +// lx carries the text, spec and partial line. Each step reads from index i and +// returns the index of the last consumed character, or null when the text is +// not lexable and the edit cannot be trivial. + +function appendCode(lx, piece) { + lx.code += piece; + lx.tail = (lx.tail + piece).slice(-2); +} + +function endComment(lx, end) { + const marker = directiveMarker(lx.text.slice(lx.commentStart, end), lx.spec); + if (marker) appendCode(lx, marker); +} + +function endLine(lx) { + if (endsWithContinuation(lx.code)) return false; + lx.lines.push(lx.code); + lx.code = ''; + lx.tail = ''; + return true; +} + +function lexNewline(lx, i) { + const { text } = lx; + if (lx.state === 'string') return null; + if (lx.state === 'line') { + if (text.slice(lx.commentStart, i).trimEnd().endsWith('\\')) return null; + endComment(lx, i); + lx.state = 'code'; + } + if (lx.state === 'block') { + lx.lines.push(lx.code); + lx.code = ''; + lx.tail = ''; + } else if (!endLine(lx)) { + return null; + } + return text[i] === '\r' && text[i + 1] === '\n' ? i + 1 : i; +} + +function lexLineComment(lx, i) { + return nextSpecial(LINE_BREAK, lx.text, i) - 1; +} + +function lexBlockComment(lx, i) { + const { text } = lx; + const ch = text[i]; + if (ch !== '*' && ch !== '/') return nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1; + if (ch === '*' && text[i + 1] === '/') { + endComment(lx, i); + lx.state = 'code'; + appendCode(lx, ' '); + return i + 1; + } + if (ch === '/' && text[i + 1] === '*') return null; + return i; +} + +function lexString(lx, i) { + const { text, quote } = lx; + const ch = text[i]; + if (ch !== '\\' && ch !== quote) { + const end = nextSpecial(stringSpecials(quote), text, i + 1); + const run = text.slice(i, end); + appendCode(lx, run); + lx.stringBody += run; + return end - 1; + } + appendCode(lx, ch); + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return null; + appendCode(lx, next); + return i + 1; + } + if (lx.spec.noDollarInStrings && lx.stringBody.includes('$')) return null; + lx.state = 'code'; + return i; +} + +function lexCommentStart(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') { + lx.state = 'line'; + lx.commentStart = i + 2; + return i + 1; + } + if (spec.lineComment === '#' && ch === '#') { + lx.state = 'line'; + lx.commentStart = i + 1; + return i; + } + if (spec.blockComment && ch === '/' && text[i + 1] === '*') { + lx.state = 'block'; + lx.commentStart = i + 2; + return i + 1; + } + return undefined; +} + +function startsJsx(spec, text, i) { + const ch = text[i]; + const next = text[i + 1]; + if (spec.jsx && ch === '<') return isLetter(next) || next === '/' || next === '>' || next === '!'; + return spec.jsx !== undefined && ch === '-' && next === '-' && text[i + 2] === '>'; +} + +function lexStringStart(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null; + if (stringPrefixIsUnsafe(lx.tail, spec)) return null; + lx.state = 'string'; + lx.quote = ch; + lx.stringBody = ''; + appendCode(lx, ch); + return i; +} + +function lexCode(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (!lx.specials.chars.has(ch)) { + const end = nextSpecial(lx.specials.pattern, text, i + 1); + appendCode(lx, text.slice(i, end)); + return end - 1; + } + const comment = lexCommentStart(lx, i); + if (comment !== undefined) return comment; + if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null; + if (startsJsx(spec, text, i)) return null; + if (ch === "'" && spec.rustChars) { + const end = rustCharEnd(text, i); + appendCode(lx, end === -1 ? ch : text.slice(i, end + 1)); + return end === -1 ? i : end; + } + if (spec.quotes.includes(ch)) return lexStringStart(lx, i); + appendCode(lx, ch); + return i; +} + +const LEX_STEPS = { code: lexCode, line: lexLineComment, block: lexBlockComment, string: lexString }; + +function finishLex(lx) { + if (lx.state === 'string' || lx.state === 'block') return null; + if (lx.state === 'line') { + if (lx.text.slice(lx.commentStart).trimEnd().endsWith('\\')) return null; + endComment(lx, lx.text.length); + } + return endLine(lx) ? lx.lines : null; +} + function lexCodeLines(text, spec) { - const specials = codeSpecials(spec); - const lines = []; - let code = ''; - let state = 'code'; - let quote = ''; - let stringBody = ''; - let commentStart = 0; - let tail = ''; - const append = piece => { - code += piece; - tail = (tail + piece).slice(-2); - }; - const endComment = end => { - const marker = directiveMarker(text.slice(commentStart, end), spec); - if (marker) append(marker); - }; - const endLine = () => { - if (endsWithContinuation(code)) return false; - lines.push(code); - code = ''; - tail = ''; - return true; - }; - if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line'; + const lx = { text, spec, specials: codeSpecials(spec), lines: [], code: '', state: 'code', quote: '', stringBody: '', commentStart: 0, tail: '' }; + if (spec.jsx !== undefined && text.startsWith('#!')) lx.state = 'line'; for (let i = 0; i < text.length; i++) { const ch = text[i]; - if (ch === '\n' || ch === '\r') { - if (state === 'string') return null; - if (state === 'line') { - if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null; - endComment(i); - state = 'code'; - } - if (state === 'block') { - lines.push(code); - code = ''; - tail = ''; - } else if (!endLine()) { - return null; - } - if (ch === '\r' && text[i + 1] === '\n') i++; - continue; - } - if (state === 'line') { - i = nextSpecial(LINE_BREAK, text, i) - 1; - continue; - } - if (state === 'block') { - if (ch !== '*' && ch !== '/') { - i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1; - continue; - } - if (ch === '*' && text[i + 1] === '/') { - endComment(i); - state = 'code'; - append(' '); - i++; - } else if (ch === '/' && text[i + 1] === '*') { - return null; - } - continue; - } - if (state === 'string') { - if (ch !== '\\' && ch !== quote) { - const end = nextSpecial(stringSpecials(quote), text, i + 1); - const run = text.slice(i, end); - append(run); - stringBody += run; - i = end - 1; - continue; - } - append(ch); - if (ch === '\\') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - append(next); - i++; - } else if (ch === quote) { - if (spec.noDollarInStrings && stringBody.includes('$')) return null; - state = 'code'; - } else { - stringBody += ch; - } - continue; - } - if (!specials.chars.has(ch)) { - const end = nextSpecial(specials.pattern, text, i + 1); - append(text.slice(i, end)); - i = end - 1; - continue; - } - if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') { - state = 'line'; - commentStart = i + 2; - i++; - continue; - } - if (spec.lineComment === '#' && ch === '#') { - state = 'line'; - commentStart = i + 1; - continue; - } - if (spec.blockComment && ch === '/' && text[i + 1] === '*') { - state = 'block'; - commentStart = i + 2; - i++; - continue; - } - if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null; - if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null; - if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null; - if (ch === "'" && spec.rustChars) { - const end = rustCharEnd(text, i); - append(end === -1 ? ch : text.slice(i, end + 1)); - if (end !== -1) i = end; - continue; - } - if (spec.quotes.includes(ch)) { - if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null; - if (stringPrefixIsUnsafe(tail, spec)) return null; - state = 'string'; - quote = ch; - stringBody = ''; - append(ch); - continue; - } - append(ch); + const next = ch === '\n' || ch === '\r' ? lexNewline(lx, i) : LEX_STEPS[lx.state](lx, i); + if (next === null) return null; + i = next; } - if (state === 'string' || state === 'block') return null; - if (state === 'line') { - if (text.slice(commentStart).trimEnd().endsWith('\\')) return null; - endComment(text.length); - } - return endLine() ? lines : null; + return finishLex(lx); } function normalizeCodeLine(line, spec) { @@ -364,56 +393,86 @@ function isBlank(ch) { return ch === ' ' || ch === '\t'; } +// --- Shell and PowerShell readers --- +// Each reader appends what it consumed to lx.code and returns the index of the +// last consumed character, or -1 when the text is not lexable. + +function pushShellLine(lx, i) { + lx.lines.push(lx.code); + lx.code = ''; + return lx.text[i] === '\r' && lx.text[i + 1] === '\n' ? i + 1 : i; +} + +function readEscapedPair(lx, i) { + const next = lx.text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return -1; + lx.code += lx.text[i] + next; + return i + 1; +} + +function readQuotedSpan(lx, i, end) { + if (end === -1) return -1; + lx.code += lx.text.slice(i, end + 1); + return end; +} + +function lineCommentEnd(text, i) { + while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; + return i; +} + +function readLineComment(lx, i) { + const end = lineCommentEnd(lx.text, i); + lx.code += directiveMarker(lx.text.slice(i + 1, end + 1), SHELL_SPEC); + return end; +} + +function shSingleQuoted(lx, i) { + const { text } = lx; + if (text[i - 1] === '$') return -1; + const end = text.indexOf("'", i + 1); + if (end === -1) return -1; + const body = text.slice(i, end + 1); + if (body.includes('\n') || body.includes('\r')) return -1; + lx.code += body; + return end; +} + +function shDoubleQuoted(lx, i) { + return readQuotedSpan(lx, i, shDoubleQuoteEnd(lx.text, i)); +} + +function shHash(lx, i) { + const starts = hashStartsComment(lx.text, i); + if (starts === null) return -1; + if (!starts) { + lx.code += '#'; + return i; + } + return readLineComment(lx, i); +} + +const SH_READERS = new Map([['\\', readEscapedPair], ["'", shSingleQuoted], ['"', shDoubleQuoted], ['#', shHash]]); + function shCodeLines(text) { if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null; - const lines = []; - let code = ''; + const lx = { text, code: '', lines: [] }; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '\n' || ch === '\r') { - lines.push(code); - code = ''; - if (ch === '\r' && text[i + 1] === '\n') i++; + i = pushShellLine(lx, i); continue; } - if (ch === '\\') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - code += ch + next; - i++; + const reader = SH_READERS.get(ch); + if (!reader) { + lx.code += ch; continue; } - if (ch === "'") { - if (text[i - 1] === '$') return null; - const end = text.indexOf("'", i + 1); - if (end === -1) return null; - const body = text.slice(i, end + 1); - if (body.includes('\n') || body.includes('\r')) return null; - code += body; - i = end; - continue; - } - if (ch === '"') { - const end = shDoubleQuoteEnd(text, i); - if (end === -1) return null; - code += text.slice(i, end + 1); - i = end; - continue; - } - if (ch === '#') { - const starts = hashStartsComment(text, i); - if (starts === null) return null; - if (starts) { - const start = i + 1; - while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; - code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); - continue; - } - } - code += ch; + i = reader(lx, i); + if (i === -1) return null; } - lines.push(code); - return lines; + lx.lines.push(lx.code); + return lx.lines; } const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/; @@ -442,62 +501,58 @@ function psQuoteEnd(text, i) { return -1; } +function psQuoted(lx, i) { + return readQuotedSpan(lx, i, psQuoteEnd(lx.text, i)); +} + +function psBlockStart(lx, i) { + if (!psHashStartsComment(lx.text, i)) return -1; + lx.block = true; + lx.blockStart = i + 2; + return i + 1; +} + +function psHash(lx, i) { + if (!psHashStartsComment(lx.text, i)) return -1; + return readLineComment(lx, i); +} + +function psBlockComment(lx, i) { + const { text } = lx; + if (text[i] === '<' && text[i + 1] === '#') return -1; + if (text[i] === '#' && text[i + 1] === '>') { + lx.block = false; + lx.code += directiveMarker(text.slice(lx.blockStart, i), SHELL_SPEC) || ' '; + return i + 1; + } + return i; +} + +function psToken(lx, i) { + const ch = lx.text[i]; + if (ch === '`') return readEscapedPair(lx, i); + if (ch === "'" || ch === '"') return psQuoted(lx, i); + if (ch === '<' && lx.text[i + 1] === '#') return psBlockStart(lx, i); + if (ch === '#') return psHash(lx, i); + lx.code += ch; + return i; +} + function psCodeLines(text) { if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null; - const lines = []; - let code = ''; - let block = false; - let blockStart = 0; + const lx = { text, code: '', lines: [], block: false, blockStart: 0 }; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '\n' || ch === '\r') { - lines.push(code); - code = ''; - if (ch === '\r' && text[i + 1] === '\n') i++; + i = pushShellLine(lx, i); continue; } - if (block) { - if (ch === '<' && text[i + 1] === '#') return null; - if (ch === '#' && text[i + 1] === '>') { - block = false; - code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' '; - i++; - } - continue; - } - if (ch === '`') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - code += ch + next; - i++; - continue; - } - if (ch === "'" || ch === '"') { - const end = psQuoteEnd(text, i); - if (end === -1) return null; - code += text.slice(i, end + 1); - i = end; - continue; - } - if (ch === '<' && text[i + 1] === '#') { - if (!psHashStartsComment(text, i)) return null; - block = true; - blockStart = i + 2; - i++; - continue; - } - if (ch === '#') { - if (!psHashStartsComment(text, i)) return null; - const start = i + 1; - while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; - code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); - continue; - } - code += ch; + i = lx.block ? psBlockComment(lx, i) : psToken(lx, i); + if (i === -1) return null; } - if (block) return null; - lines.push(code); - return lines; + if (lx.block) return null; + lx.lines.push(lx.code); + return lx.lines; } function batchCodeLines(text) { diff --git a/scripts/lib/gateguard-file-context.js b/scripts/lib/gateguard-file-context.js index 4c571c16d..32eeaf6f3 100644 --- a/scripts/lib/gateguard-file-context.js +++ b/scripts/lib/gateguard-file-context.js @@ -169,26 +169,32 @@ function wordStart(text, i) { return start; } +function cFamilyBacktickEnd(text, i, flavor) { + if (flavor === 'kotlin') return quotedEnd(text, i, '`', false); + if (flavor !== 'go') return -1; + const end = text.indexOf('`', i + 1); + return end === -1 ? -1 : end + 1; +} + +// Single quotes: a Rust char or lifetime, a C digit separator inside a +// numeric literal, or an ordinary char literal. +function cFamilySingleQuoteEnd(text, i, flavor) { + if (flavor === 'rust') { + const end = rustCharEnd(text, i); + return end === -1 ? null : end + 1; + } + if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) { + const first = text[wordStart(text, i)]; + if (first >= '0' && first <= '9') return null; + } + return quotedEnd(text, i, "'", false); +} + function cFamilyStringEnd(text, i, spec) { const ch = text[i]; const flavor = spec.flavor; - if (ch === '`') { - if (flavor === 'kotlin') return quotedEnd(text, i, '`', false); - if (flavor !== 'go') return -1; - const end = text.indexOf('`', i + 1); - return end === -1 ? -1 : end + 1; - } - if (ch === "'") { - if (flavor === 'rust') { - const end = rustCharEnd(text, i); - return end === -1 ? null : end + 1; - } - if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) { - const first = text[wordStart(text, i)]; - if (first >= '0' && first <= '9') return null; - } - return quotedEnd(text, i, "'", false); - } + if (ch === '`') return cFamilyBacktickEnd(text, i, flavor); + if (ch === "'") return cFamilySingleQuoteEnd(text, i, flavor); if (flavor === 'c') { const start = wordStart(text, i); const prefix = text.slice(start, i); @@ -201,166 +207,253 @@ function cFamilyStringEnd(text, i, spec) { return quotedEnd(text, i, '"', flavor === 'rust'); } -function cFamilyStartsFresh(text, spec) { +// Scanner state for cFamilyStartsFresh. `frames` tracks template-literal +// nesting, `parens` whether each open paren started a control header, +// `lastSig` what the previous significant token was (op, value, close). +function createCFamilyScan(text, spec) { const js = spec.flavor === 'js'; - const frames = [{ template: false, depth: 0 }]; - const parens = []; - let lastWord = ''; - let lastSig = 'op'; - let lineEnd = ''; - let continued = false; - let i = js && text.startsWith('#!') ? lineEndAt(text, 0) : 0; - while (i < text.length) { - const frame = frames[frames.length - 1]; - const ch = text[i]; - const next = text[i + 1]; - if (frame.template) { - if (ch === '\\') { - i += 2; - } else if (ch === '`') { - frames.pop(); - lastSig = 'value'; - i++; - } else if (ch === '$' && next === '{') { - frames.push({ template: false, depth: 0 }); - lastSig = 'op'; - i += 2; - } else { - i++; - } + return { + text, + spec, + js, + frames: [{ template: false, depth: 0 }], + parens: [], + lastWord: '', + lastSig: 'op', + lineEnd: '', + continued: false, + i: js && text.startsWith('#!') ? lineEndAt(text, 0) : 0, + }; +} + +// Each step* helper below returns true after consuming input, false when the +// prefix cannot end fresh, and null when the character was not its concern. + +function stepTemplate(s) { + const { text, frames } = s; + const ch = text[s.i]; + if (ch === '\\') { + s.i += 2; + } else if (ch === '`') { + frames.pop(); + s.lastSig = 'value'; + s.i++; + } else if (ch === '$' && text[s.i + 1] === '{') { + frames.push({ template: false, depth: 0 }); + s.lastSig = 'op'; + s.i += 2; + } else { + s.i++; + } +} + +function stepComment(s) { + const { text, spec } = s; + if (text[s.i] !== '/') return null; + const next = text[s.i + 1]; + if (next === '/') { + const end = lineEndAt(text, s.i); + if (text.slice(s.i, end).trimEnd().endsWith('\\')) return false; + s.i = end; + return true; + } + if (next === '*') { + const end = spec.rustDocs ? nestedBlockEnd(text, s.i) : text.indexOf('*/', s.i + 2); + if (end === -1) return false; + s.i = spec.rustDocs ? end : end + 2; + s.lineEnd = ''; + return true; + } + return null; +} + +// A `/` that is not a comment: a regex literal after an operator, or +// division. A regex right after `}` is ambiguous and never fresh. +function stepJsSlash(s) { + const { text } = s; + if (s.lastSig === 'op') { + const end = regexEnd(text, s.i); + if (end === -1) return false; + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; + } + if (s.lastSig === 'close' && /[/'"`]/.test(text.slice(s.i + 1, lineEndAt(text, s.i)))) return false; + s.lastSig = 'op'; + s.i++; + return true; +} + +function stepJs(s) { + if (!s.js) return null; + const { text, spec, frames } = s; + const ch = text[s.i]; + const next = text[s.i + 1]; + if (ch === '`') { + frames.push({ template: true, depth: 0 }); + s.i++; + return true; + } + if (ch === '/') return stepJsSlash(s); + if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false; + if (ch === '-' && next === '-' && text[s.i + 2] === '>') return false; + const frame = frames[frames.length - 1]; + if (frames.length > 1 && ch === '{') frame.depth++; + if (frames.length > 1 && ch === '}') { + if (frame.depth === 0) { + frames.pop(); + s.i++; + return true; + } + frame.depth--; + } + return null; +} + +function stepCSharpString(s) { + const { text, spec } = s; + const ch = text[s.i]; + if (spec.flavor !== 'csharp' || (ch !== '@' && ch !== '$')) return null; + if (!/^[@$]{1,3}"/.test(text.slice(s.i, s.i + 4))) return null; + const end = csharpStringEnd(text, s.i); + if (end === -1) return false; + s.lineEnd = '"'; + s.i = end; + return true; +} + +function stepRustRaw(s) { + const { text, spec } = s; + const ch = text[s.i]; + const next = text[s.i + 1]; + if (spec.flavor !== 'rust' || isIdentChar(text[s.i - 1])) return null; + if (ch !== 'r' && !((ch === 'b' || ch === 'c') && next === 'r')) return null; + const end = rustRawEnd(text, s.i); + if (end === -1) return false; + if (end === null) return null; + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; +} + +function stepString(s) { + const { text, spec } = s; + const ch = text[s.i]; + if (ch !== '"' && ch !== "'" && ch !== '`') return null; + const end = cFamilyStringEnd(text, s.i, spec); + if (end === -1) return false; + if (end === null) { + s.i++; + return true; + } + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; +} + +function stepWord(s) { + const { text, js } = s; + if (!isIdentChar(text[s.i])) return null; + let end = s.i; + while (end < text.length && isIdentChar(text[end])) end++; + s.lastWord = text.slice(s.i, end); + s.lastSig = js && JS_REGEX_KEYWORDS.has(s.lastWord) ? 'op' : 'value'; + s.lineEnd = text[end - 1]; + s.i = end; + return true; +} + +function stepPunctuation(s) { + const ch = s.text[s.i]; + if (ch === '(') { + s.parens.push(s.lastSig === 'value' && JS_CONTROL_HEADERS.has(s.lastWord)); + s.lastSig = 'op'; + } else if (ch === ')') { + s.lastSig = s.parens.pop() ? 'op' : 'value'; + } else { + s.lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op'; + } + s.lastWord = ''; + s.i++; +} + +const C_FAMILY_TOKEN_STEPS = [stepJs, stepCSharpString, stepRustRaw, stepString, stepWord]; + +function stepToken(s) { + for (const step of C_FAMILY_TOKEN_STEPS) { + const result = step(s); + if (result !== null) return result; + } + return null; +} + +function cFamilyStartsFresh(text, spec) { + const s = createCFamilyScan(text, spec); + while (s.i < text.length) { + const ch = text[s.i]; + if (s.frames[s.frames.length - 1].template) { + stepTemplate(s); continue; } if (isNewline(ch)) { - continued = lineEnd === '\\'; - lineEnd = ''; - i++; + s.continued = s.lineEnd === '\\'; + s.lineEnd = ''; + s.i++; continue; } if (isSpace(ch)) { - i++; + s.i++; continue; } - if (ch === '/' && next === '/') { - const end = lineEndAt(text, i); - if (text.slice(i, end).trimEnd().endsWith('\\')) return false; - i = end; + const comment = stepComment(s); + if (comment !== null) { + if (!comment) return false; continue; } - if (ch === '/' && next === '*') { - const end = spec.rustDocs ? nestedBlockEnd(text, i) : text.indexOf('*/', i + 2); - if (end === -1) return false; - i = spec.rustDocs ? end : end + 2; - lineEnd = ''; - continue; - } - lineEnd = ch; - if (js) { - if (ch === '`') { - frames.push({ template: true, depth: 0 }); - i++; - continue; - } - if (ch === '/') { - if (lastSig === 'op') { - const end = regexEnd(text, i); - if (end === -1) return false; - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - if (lastSig === 'close' && /[/'"`]/.test(text.slice(i + 1, lineEndAt(text, i)))) return false; - lastSig = 'op'; - i++; - continue; - } - if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false; - if (ch === '-' && next === '-' && text[i + 2] === '>') return false; - if (frames.length > 1 && ch === '{') frame.depth++; - if (frames.length > 1 && ch === '}') { - if (frame.depth === 0) { - frames.pop(); - i++; - continue; - } - frame.depth--; - } - } - if (spec.flavor === 'csharp' && (ch === '@' || ch === '$') && /^[@$]{1,3}"/.test(text.slice(i, i + 4))) { - const end = csharpStringEnd(text, i); - if (end === -1) return false; - lineEnd = '"'; - i = end; - continue; - } - if (spec.flavor === 'rust' && (ch === 'r' || ((ch === 'b' || ch === 'c') && next === 'r')) && !isIdentChar(text[i - 1])) { - const end = rustRawEnd(text, i); - if (end === -1) return false; - if (end !== null) { - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - } - if (ch === '"' || ch === "'" || ch === '`') { - const end = cFamilyStringEnd(text, i, spec); - if (end === -1) return false; - if (end !== null) { - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - i++; - continue; - } - if (isIdentChar(ch)) { - let end = i; - while (end < text.length && isIdentChar(text[end])) end++; - lastWord = text.slice(i, end); - lastSig = js && JS_REGEX_KEYWORDS.has(lastWord) ? 'op' : 'value'; - lineEnd = text[end - 1]; - i = end; - continue; - } - if (ch === '(') { - parens.push(lastSig === 'value' && JS_CONTROL_HEADERS.has(lastWord)); - lastSig = 'op'; - } else if (ch === ')') { - lastSig = parens.pop() ? 'op' : 'value'; - } else { - lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op'; - } - lastWord = ''; - i++; + s.lineEnd = ch; + const token = stepToken(s); + if (token === false) return false; + if (token === null) stepPunctuation(s); } - return frames.length === 1 && !continued; + return s.frames.length === 1 && !s.continued; +} + +// One character inside an f-string replacement field. Returns the last index +// consumed, or -1 when the field cannot be read. Updates `field.depth`. +function pythonFieldStep(text, j, quote, field) { + const ch = text[j]; + if (ch === '{') { + field.depth++; + } else if (ch === '}') { + field.depth--; + } else if ((ch === '"' || ch === "'") && ch !== quote) { + let k = j + 1; + while (k < text.length && text[k] !== ch) { + if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1; + k++; + } + if (k >= text.length) return -1; + return k; + } else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) { + return -1; + } + return j; } function pythonStringEnd(text, i, prefix) { const quote = text[i]; const triple = text[i + 1] === quote && text[i + 2] === quote; + const quoteLength = triple ? 3 : 1; const fString = prefix.toLowerCase().includes('f'); - let depth = 0; - for (let j = i + (triple ? 3 : 1); j < text.length; j++) { + const field = { depth: 0 }; + for (let j = i + quoteLength; j < text.length; j++) { const ch = text[j]; - if (fString && depth > 0) { - if (ch === '{') { - depth++; - } else if (ch === '}') { - depth--; - } else if ((ch === '"' || ch === "'") && ch !== quote) { - let k = j + 1; - while (k < text.length && text[k] !== ch) { - if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1; - k++; - } - if (k >= text.length) return -1; - j = k; - } else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) { - return -1; - } + if (fString && field.depth > 0) { + j = pythonFieldStep(text, j, quote, field); + if (j === -1) return -1; continue; } if (ch === '\\') { @@ -368,11 +461,11 @@ function pythonStringEnd(text, i, prefix) { if (text[j] === '\r' && text[j + 1] === '\n') j++; } else if (fString && ch === '{') { if (text[j + 1] === '{') j++; - else depth = 1; + else field.depth = 1; } else if (isNewline(ch) && !triple) { return -1; } else if (ch === quote && (!triple || (text[j + 1] === quote && text[j + 2] === quote))) { - return j + (triple ? 3 : 1); + return j + quoteLength; } } return -1; @@ -489,63 +582,89 @@ function shDoubleQuoteScan(text, i, level) { return -1; } +// Move a line scanner to `end`, or report that the token never closed. +function advanceTo(s, end) { + if (end === -1) return false; + s.i = end; + return true; +} + +// A newline ends any pending heredoc bodies before scanning resumes. +function shNewline(s) { + const { text } = s; + s.continued = s.lineEnd === '\\'; + s.lineEnd = ''; + s.i += text[s.i] === '\r' && text[s.i + 1] === '\n' ? 2 : 1; + if (s.pending.length > 0) { + s.i = skipHeredocs(text, s.i, s.pending); + if (s.i === -1) return false; + s.pending.length = 0; + } + return true; +} + +function shQuotedToken(s) { + const { text } = s; + const ch = text[s.i]; + if (ch === "'") return advanceTo(s, shSingleQuoteEnd(text, s.i)); + if (ch === '"') return advanceTo(s, shDoubleQuoteScan(text, s.i, 0)); + const end = text.indexOf('`', s.i + 1); + if (end === -1 || /['"\\#\n\r]/.test(text.slice(s.i + 1, end))) return false; + s.i = end + 1; + return true; +} + +function shHashToken(s) { + const { text } = s; + const starts = hashStartsComment(text, s.i); + if (starts === null) return false; + s.i = starts ? lineEndAt(text, s.i) : s.i + 1; + if (starts) s.lineEnd = ''; + return true; +} + +function shHeredocToken(s) { + const { text } = s; + if (text[s.i + 2] === '<') { + s.i += 3; + return true; + } + const doc = heredocDelimiter(text, s.i); + if (!doc) return false; + s.pending.push(doc); + s.i = doc.end; + return true; +} + function shStartsFresh(text) { if (hasLoneCarriageReturn(text)) return false; - const pending = []; - let lineEnd = ''; - let continued = false; - let i = 0; - while (i < text.length) { - const ch = text[i]; + const s = { text, pending: [], lineEnd: '', continued: false, i: 0 }; + while (s.i < text.length) { + const ch = text[s.i]; if (isNewline(ch)) { - continued = lineEnd === '\\'; - lineEnd = ''; - i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1; - if (pending.length > 0) { - i = skipHeredocs(text, i, pending); - if (i === -1) return false; - pending.length = 0; - } + if (!shNewline(s)) return false; continue; } if (isBlank(ch)) { - i++; + s.i++; continue; } - lineEnd = ch; + s.lineEnd = ch; + let ok = true; if (ch === '\\') { - i += isNewline(text[i + 1]) ? 1 : 2; - } else if (ch === "'") { - const end = shSingleQuoteEnd(text, i); - if (end === -1) return false; - i = end; - } else if (ch === '"') { - const end = shDoubleQuoteScan(text, i, 0); - if (end === -1) return false; - i = end; - } else if (ch === '`') { - const end = text.indexOf('`', i + 1); - if (end === -1 || /['"\\#\n\r]/.test(text.slice(i + 1, end))) return false; - i = end + 1; + s.i += isNewline(text[s.i + 1]) ? 1 : 2; + } else if (ch === "'" || ch === '"' || ch === '`') { + ok = shQuotedToken(s); } else if (ch === '#') { - const starts = hashStartsComment(text, i); - if (starts === null) return false; - i = starts ? lineEndAt(text, i) : i + 1; - if (starts) lineEnd = ''; - } else if (ch === '<' && text[i + 1] === '<') { - if (text[i + 2] === '<') { - i += 3; - continue; - } - const doc = heredocDelimiter(text, i); - if (!doc) return false; - pending.push(doc); - i = doc.end; + ok = shHashToken(s); + } else if (ch === '<' && text[s.i + 1] === '<') { + ok = shHeredocToken(s); } else { - i++; + s.i++; } + if (!ok) return false; } - return !continued && pending.length === 0; + return !s.continued && s.pending.length === 0; } function psSingleQuoteEnd(text, i) { @@ -597,66 +716,78 @@ function psDoubleQuoteScan(text, i, level) { return -1; } +// End of a PowerShell here-string opened at `i`: the closer must start its +// own line, and nothing may follow the opener on its line. +function psHereStringEnd(text, i, quote) { + const open = lineEndAt(text, i); + if (text.slice(i + 2, open).trim() !== '') return -1; + let at = open; + while (at < text.length) { + at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1; + if (text.startsWith(`${quote}@`, at)) return at + 2; + at = lineEndAt(text, at); + } + return -1; +} + +function psBlockCommentToken(s) { + const { text } = s; + if (!psHashStartsComment(text, s.i)) return false; + const end = text.indexOf('#>', s.i + 2); + if (end === -1) return false; + s.i = end + 2; + s.lineEnd = ''; + return true; +} + +function psHashToken(s) { + const { text } = s; + if (psHashStartsComment(text, s.i)) { + s.i = lineEndAt(text, s.i); + s.lineEnd = ''; + return true; + } + if (isIdentChar(text[s.i - 1]) || '-.:'.includes(text[s.i - 1])) { + s.i++; + return true; + } + return false; +} + function psStartsFresh(text) { if (PS_SMART_QUOTES.test(text)) return false; - let lineEnd = ''; - let continued = false; - let i = 0; - while (i < text.length) { - const ch = text[i]; - const next = text[i + 1]; + const s = { text, lineEnd: '', continued: false, i: 0 }; + while (s.i < text.length) { + const ch = text[s.i]; + const next = text[s.i + 1]; if (isNewline(ch)) { - continued = lineEnd === '`'; - lineEnd = ''; - i++; + s.continued = s.lineEnd === '`'; + s.lineEnd = ''; + s.i++; continue; } if (isBlank(ch)) { - i++; + s.i++; continue; } - lineEnd = ch; + s.lineEnd = ch; + let ok = true; if (ch === '`') { - i += isNewline(next) ? 1 : 2; + s.i += isNewline(next) ? 1 : 2; } else if (ch === '@' && (next === '"' || next === "'")) { - const open = lineEndAt(text, i); - if (text.slice(i + 2, open).trim() !== '') return false; - let at = open; - let found = -1; - while (at < text.length) { - at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1; - if (text.startsWith(`${next}@`, at)) { - found = at + 2; - break; - } - at = lineEndAt(text, at); - } - if (found === -1) return false; - i = found; + ok = advanceTo(s, psHereStringEnd(text, s.i, next)); } else if (ch === "'" || ch === '"') { - const end = ch === "'" ? psSingleQuoteEnd(text, i) : psDoubleQuoteScan(text, i, 0); - if (end === -1) return false; - i = end; + ok = advanceTo(s, ch === "'" ? psSingleQuoteEnd(text, s.i) : psDoubleQuoteScan(text, s.i, 0)); } else if (ch === '<' && next === '#') { - if (!psHashStartsComment(text, i)) return false; - const end = text.indexOf('#>', i + 2); - if (end === -1) return false; - i = end + 2; - lineEnd = ''; + ok = psBlockCommentToken(s); } else if (ch === '#') { - if (psHashStartsComment(text, i)) { - i = lineEndAt(text, i); - lineEnd = ''; - } else if (isIdentChar(text[i - 1]) || '-.:'.includes(text[i - 1])) { - i++; - } else { - return false; - } + ok = psHashToken(s); } else { - i++; + s.i++; } + if (!ok) return false; } - return !continued; + return !s.continued; } function batchStartsFresh(text) { diff --git a/scripts/lib/gateguard-search-evidence.js b/scripts/lib/gateguard-search-evidence.js index 567d41f0d..c20a594bc 100644 --- a/scripts/lib/gateguard-search-evidence.js +++ b/scripts/lib/gateguard-search-evidence.js @@ -304,42 +304,68 @@ const MISS_RANK = Object.freeze({ 'same-batch': 0, lookup: 1, excluded: 2, 'out-of-scope': 3, 'stdin-only': 4, 'not-a-search': 5, 'generic-stem': 6 }); +// --- Tool search evidence --- + +function explicitPath(input) { + return typeof input.path === 'string' && input.path ? input.path : ''; +} + +function globEvidence(input) { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = explicitPath(input); + const prefix = globLiteralPrefix(input.pattern); + const scope = [explicit || '.', prefix || '.']; + if (!GLOB_CHARS_PATTERN.test(input.pattern)) { + const lookup = [explicit || '.', input.pattern]; + return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; + } + const dirs = prefix || explicit ? [scope] : []; + // see docs/gateguard/design-notes.md#path-patterns + const pathPattern = pathPatternFor([explicit || '.'], input.pattern); + return [{ texts: [input.pattern], dirs, scope, detail: input.pattern, pathPattern }]; +} + +function grepEvidence(input) { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = explicitPath(input); + const detail = grepDetail(input); + const filters = grepToolFilters(input.glob, input.type); + return [{ + texts: stringsOf(input.pattern, ...filters.positives), + dirs: explicit ? [[explicit]] : [], + scope: [explicit || '.'], + detail, + pathIncludes: true, + ...filters + }]; +} + +function lsEvidence(input) { + return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); +} + +const TOOL_EVIDENCE = new Map([['Glob', globEvidence], ['Grep', grepEvidence], ['LS', lsEvidence]]); + +function searchHasMessage(search) { + return typeof search.messageId === 'string' && Boolean(search.messageId); +} + +/** First evidence item that credits the target by stem or, when allowed, by directory. */ +function creditingItem(items, ctx, stem, allowDirMatch) { + for (const item of items) { + if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; + const byStem = stem !== null && stemCredits(item, ctx, stem); + const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); + if (byStem || byDir) return item; + } + return null; +} /** Build the prior-search matcher around the hook's shell segmenter. */ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) { function searchEvidence(search, shellDirsTrusted, misses) { - const input = search.input; - if (search.name === 'Glob') { - if (typeof input.pattern !== 'string' || !input.pattern) return []; - const explicit = typeof input.path === 'string' && input.path ? input.path : ''; - const prefix = globLiteralPrefix(input.pattern); - const scope = [explicit || '.', prefix || '.']; - if (!GLOB_CHARS_PATTERN.test(input.pattern)) { - const lookup = [explicit || '.', input.pattern]; - return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; - } - const dirs = prefix || explicit ? [scope] : []; - // see docs/gateguard/design-notes.md#path-patterns - const pathPattern = pathPatternFor([explicit || '.'], input.pattern); - return [{ texts: [input.pattern], dirs, scope, detail: input.pattern, pathPattern }]; - } - if (search.name === 'Grep') { - if (typeof input.pattern !== 'string' || !input.pattern) return []; - const explicit = typeof input.path === 'string' && input.path ? input.path : ''; - const detail = grepDetail(input); - const filters = grepToolFilters(input.glob, input.type); - return [{ - texts: stringsOf(input.pattern, ...filters.positives), - dirs: explicit ? [[explicit]] : [], - scope: [explicit || '.'], - detail, - pathIncludes: true, - ...filters - }]; - } - if (search.name === 'LS') { - return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); - } - return shellSearchEvidence(input.command, search.name, shellDirsTrusted, misses); + const byTool = TOOL_EVIDENCE.get(search.name); + if (byTool) return byTool(search.input); + return shellSearchEvidence(search.input.command, search.name, shellDirsTrusted, misses); } function segmentPipeFlags(input) { @@ -493,17 +519,11 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME const excluded = excludedBatchId(scan, data); let shellDirsTrusted = null; for (const search of scan.searches) { - if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue; + if (!searchHasMessage(search) || search.messageId === excluded) continue; if (!allowDirMatch && !inputMayMention(search.input, eligible)) continue; if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); - for (const item of cachedEvidence(search, shellDirsTrusted).items) { - if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; - const byStem = stem !== null && stemCredits(item, ctx, stem); - const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); - if (byStem || byDir) { - return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; - } - } + const item = creditingItem(cachedEvidence(search, shellDirsTrusted).items, ctx, stem, allowDirMatch); + if (item) return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; } return null; } catch (_) { @@ -534,6 +554,19 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME return 'excluded'; } + function missReason(miss, stem, rawStem) { + if (!mentionsTarget(miss.texts, stem, rawStem)) return null; + return stem === null ? 'generic-stem' : miss.reason; + } + + function considerReads(scan, ctx, stem, consider) { + for (const read of Array.isArray(scan.reads) ? scan.reads : []) { + if (!read || typeof read.path !== 'string' || !read.path) continue; + const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null); + if (readsTarget) consider('Read', read.path, 'not-a-search'); + } + } + /** Closest search of the current turn that did not credit the target, with a reason code, else null. */ function findClosestMiss(scan, targetPath, allowDirMatch, data) { try { @@ -551,22 +584,15 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason }; }; for (const search of scan.searches) { - if (!search || typeof search.messageId !== 'string' || !search.messageId) continue; + if (!search || !searchHasMessage(search)) continue; if (!allowDirMatch && (!bare || !inputMayMention(search.input, bare))) continue; if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); const { items, misses } = cachedEvidence(search, shellDirsTrusted); - for (const item of items) { - consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded)); - } - for (const miss of misses) { - if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason); - } - } - for (const read of Array.isArray(scan.reads) ? scan.reads : []) { - if (!read || typeof read.path !== 'string' || !read.path) continue; - const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null); - if (readsTarget) consider('Read', read.path, 'not-a-search'); + const sameBatch = search.messageId === excluded; + for (const item of items) consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch)); + for (const miss of misses) consider(search.name, miss.detail, missReason(miss, stem, rawStem)); } + considerReads(scan, ctx, stem, consider); return best; } catch (_) { return null; diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 1ef50b03c..c500a2b70 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -312,330 +312,418 @@ function shouldRepairFromRecordedOperations(state) { return getManagedOperations(state).some(operation => operation.kind !== 'copy-file'); } -function executeRepairOperation( - repoRoot, - operation, - trustedRoot, - linkIndex = null, - target = null, - settingsLockHeld = false, - writeOptions = {} -) { +function repairCopyFile(repoRoot, operation, trustedRoot, options) { + const sourcePath = resolveOperationSourcePath(repoRoot, operation); + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); + } + + if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { + const source = readFileWithMetadataNoFollow(sourcePath, 'utf8'); + writeContainedFile( + operation.destinationPath, + getExpectedCopyFileContent(operation, source.content, options.linkIndex), + trustedRoot, + 'repair', + source.mode & 0o777, + options.writeOptions + ); + } else { + copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); + } + return operation.destinationPath; +} + +function repairRenderTemplate(operation, trustedRoot) { + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); + } + + writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair'); + return operation.destinationPath; +} + +function repairMergeJson(operation, trustedRoot) { + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); + } + + const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair'); + const currentValue = existingDestination + ? readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + ) + : {}; + const mergedValue = deepMergeJson(currentValue, payload); + + writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair'); + return operation.destinationPath; +} + +function repairClaudeSettings(operation, trustedRoot, options) { + assertClaudeSettingsDestination(operation, trustedRoot, options.target); + const managedHooks = validateManagedHooks(operation.managedHooks); + const previousManagedHooks = operation.previousManagedHooks + ? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks') + : null; + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'repair' + ); + const settingsPath = existingDestination + ? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + : prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair'); + updateSettingsAtomic( + settingsPath, + currentSettings => repairManagedHooks(currentSettings, managedHooks, { + previousManagedHooks, + }), + { + lockHeld: options.settingsLockHeld, + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'repair'); + }, + } + ); + return operation.destinationPath; +} + +function repairRemove(operation, trustedRoot) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'repair', + { recursive: true, force: true } + ); + return removedPath ? operation.destinationPath : null; +} + +/** + * Apply one recorded operation to the install root. `options` carries + * `linkIndex`, `target`, `settingsLockHeld`, and `writeOptions`. + */ +function executeRepairOperation(repoRoot, operation, trustedRoot, options = {}) { // Install-state is attacker-controllable; never write/delete outside the // adapter-derived trusted root, regardless of what the state file claims // (GHSA-hfpv-w6mp-5g95). - if (operation.kind === 'copy-file') { - const sourcePath = resolveOperationSourcePath(repoRoot, operation); - if (!sourcePath || !fs.existsSync(sourcePath)) { - throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); - } + const { + linkIndex = null, + target = null, + settingsLockHeld = false, + writeOptions = {}, + } = options; + const resolved = { linkIndex, target, settingsLockHeld, writeOptions }; - if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { - const source = readFileWithMetadataNoFollow(sourcePath, 'utf8'); - writeContainedFile( - operation.destinationPath, - getExpectedCopyFileContent(operation, source.content, linkIndex), - trustedRoot, - 'repair', - source.mode & 0o777, - writeOptions - ); - } else { - copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); - } - return operation.destinationPath; + switch (operation.kind) { + case 'copy-file': + return repairCopyFile(repoRoot, operation, trustedRoot, resolved); + case 'render-template': + return repairRenderTemplate(operation, trustedRoot); + case 'merge-json': + return repairMergeJson(operation, trustedRoot); + case 'update-claude-settings': + return repairClaudeSettings(operation, trustedRoot, resolved); + case 'remove': + return repairRemove(operation, trustedRoot); + default: + throw new Error(`Unsupported repair operation kind: ${operation.kind}`); + } +} + +function uninstallNoop() { + return { + removedPaths: [], + cleanupTargets: [] + }; +} + +function uninstallRetained(operation) { + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: [operation.destinationPath] + }; +} + +// Delete the managed destination and report it, or report nothing when it +// was already gone. +function uninstallRemovePath(operation, trustedRoot) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { force: true } + ); + if (!removedPath) return uninstallNoop(); + + return { + removedPaths: [operation.destinationPath], + cleanupTargets: [removedPath] + }; +} + +// Restore the pre-install content when the operation recorded it. Returns +// null when nothing was recorded so the caller can fall through. +function restorePreviousContent(operation, trustedRoot) { + const previousContent = getOperationPreviousContent(operation); + if (previousContent !== null) { + writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); + return uninstallNoop(); } - if (operation.kind === 'render-template') { - const renderedContent = getOperationTextContent(operation); - if (renderedContent === null) { - throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); - } - - writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair'); - return operation.destinationPath; + const previousJson = getOperationPreviousJson(operation); + if (previousJson !== undefined) { + writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); + return uninstallNoop(); } - if (operation.kind === 'merge-json') { - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); - } + return null; +} - const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair'); - const currentValue = existingDestination - ? readJsonNoFollow( - getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath - ) - : {}; - const mergedValue = deepMergeJson(currentValue, payload); +// Whether a drifted or symlinked copy must stay in place. Returns the +// uninstall result to report, or null when the copy may be removed. +function retainDriftedCopy(operation, trustedRoot) { + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { allowFinalSymlink: true } + ); + if (!existingDestination) return uninstallNoop(); + if (fs.lstatSync(existingDestination).isSymbolicLink()) return uninstallRetained(operation); - writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair'); - return operation.destinationPath; + const recordedDigest = operation.contentSha256; + const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '') + ? crypto.createHash('sha256') + .update(readFileNoFollow(existingDestination)) + .digest('hex') + : null; + if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) { + return uninstallRetained(operation); + } + return null; +} + +function uninstallCopyFile(operation, trustedRoot, options) { + if (options.preserveDriftedCopies) { + const retained = retainDriftedCopy(operation, trustedRoot); + if (retained) return retained; + } + return uninstallRemovePath(operation, trustedRoot); +} + +function uninstallRenderTemplate(operation, trustedRoot) { + return restorePreviousContent(operation, trustedRoot) || uninstallRemovePath(operation, trustedRoot); +} + +function uninstallMergeJson(operation, trustedRoot) { + const restored = restorePreviousContent(operation, trustedRoot); + if (restored) return restored; + + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) return uninstallNoop(); + + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); } - if (operation.kind === 'update-claude-settings') { - assertClaudeSettingsDestination(operation, trustedRoot, target); - const managedHooks = validateManagedHooks(operation.managedHooks); - const previousManagedHooks = operation.previousManagedHooks - ? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks') - : null; - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'repair' - ); - const settingsPath = existingDestination - ? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath - : prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair'); - updateSettingsAtomic( - settingsPath, - currentSettings => repairManagedHooks(currentSettings, managedHooks, { - previousManagedHooks, - }), - { - lockHeld: settingsLockHeld, - beforeCommit() { - getManagedDestination(settingsPath, trustedRoot, 'repair'); - }, - } - ); - return operation.destinationPath; - } - - if (operation.kind === 'remove') { + const currentValue = readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath + ); + const nextValue = deepRemoveJsonSubset(currentValue, payload); + if (nextValue === JSON_REMOVE_SENTINEL) { const removedPath = removeContainedPath( operation.destinationPath, trustedRoot, - 'repair', - { recursive: true, force: true } + 'uninstall', + { force: true } ); - return removedPath ? operation.destinationPath : null; + return { + removedPaths: removedPath ? [operation.destinationPath] : [], + cleanupTargets: removedPath ? [removedPath] : [] + }; } - throw new Error(`Unsupported repair operation kind: ${operation.kind}`); + writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall'); + return uninstallNoop(); +} + +function uninstallClaudeSettings(operation, trustedRoot, options) { + assertClaudeSettingsDestination(operation, trustedRoot, options.target); + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) return uninstallNoop(); + + const settingsPath = getManagedDestination( + existingDestination, + trustedRoot, + 'uninstall' + ).managedPath; + const uninstalled = updateSettingsAtomic( + settingsPath, + currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks), + { + lockHeld: Boolean(options.settingsLockHeld), + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'uninstall'); + }, + } + ); + + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: uninstalled.retained.length > 0 + ? [operation.destinationPath] + : [] + }; +} + +function uninstallRemoveOperation(operation, trustedRoot) { + return restorePreviousContent(operation, trustedRoot) || uninstallNoop(); } function executeUninstallOperation(operation, trustedRoot, options = {}) { // Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95). - if (operation.kind === 'copy-file') { - if (options.preserveDriftedCopies) { - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { allowFinalSymlink: true } - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - if (fs.lstatSync(existingDestination).isSymbolicLink()) { - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: [operation.destinationPath] - }; - } - const recordedDigest = operation.contentSha256; - const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '') - ? crypto.createHash('sha256') - .update(readFileNoFollow(existingDestination)) - .digest('hex') - : null; - if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) { - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: [operation.destinationPath] - }; - } - } + switch (operation.kind) { + case 'copy-file': + return uninstallCopyFile(operation, trustedRoot, options); + case 'render-template': + return uninstallRenderTemplate(operation, trustedRoot); + case 'merge-json': + return uninstallMergeJson(operation, trustedRoot); + case 'update-claude-settings': + return uninstallClaudeSettings(operation, trustedRoot, options); + case 'remove': + return uninstallRemoveOperation(operation, trustedRoot); + default: + throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); + } +} - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } +function inspectionResult(status, operation, destinationPath, extra = {}) { + return { + status, + operation, + destinationPath, + ...extra + }; +} + +// Compare installed bytes with what the recorded copy operation should have +// written. Throws when either side cannot be read. +function copyFileContentMatches(operation, inspectedPath, copySourcePath, linkIndex) { + // A deactivation transform changes the desired bytes. A historical + // active-file digest proves ownership, not completion of that transition. + const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks' + || operation.contentTransform === 'opencode-disable-plugin-entrypoint'; + if (hasRecordedContentDigest(operation) && !deactivatesOpenCode) { + return fileMatchesRecordedContent(inspectedPath, operation); + } + if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { + return readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent( + operation, + readFileNoFollow(copySourcePath, 'utf8'), + linkIndex ); - if (!removedPath) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } + } + return areFilesEqual(copySourcePath, inspectedPath); +} - return { - removedPaths: [operation.destinationPath], - cleanupTargets: [removedPath] - }; +function inspectCopyFileOperation(operation, inspectedPath, copySourcePath, linkIndex) { + const { destinationPath } = operation; + const sourcePath = copySourcePath; + if (!copySourcePath || !fs.existsSync(copySourcePath)) { + return inspectionResult('missing-source', operation, destinationPath, { sourcePath }); } - if (operation.kind === 'render-template') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } - ); - if (!removedPath) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - return { - removedPaths: [operation.destinationPath], - cleanupTargets: [removedPath] - }; + let contentMatches; + try { + contentMatches = copyFileContentMatches(operation, inspectedPath, copySourcePath, linkIndex); + } catch (_error) { + return inspectionResult('unverified', operation, destinationPath, { sourcePath }); } - if (operation.kind === 'merge-json') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } + return inspectionResult(contentMatches ? 'ok' : 'drifted', operation, destinationPath, { sourcePath }); +} - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall' - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); - } - - const currentValue = readJsonNoFollow( - getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath - ); - const nextValue = deepRemoveJsonSubset(currentValue, payload); - if (nextValue === JSON_REMOVE_SENTINEL) { - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } - ); - return { - removedPaths: removedPath ? [operation.destinationPath] : [], - cleanupTargets: removedPath ? [removedPath] : [] - }; - } - - writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; +function inspectRenderTemplateOperation(operation, inspectedPath) { + const { destinationPath } = operation; + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + return inspectionResult('unverified', operation, destinationPath); } - if (operation.kind === 'update-claude-settings') { - assertClaudeSettingsDestination(operation, trustedRoot, options.target); - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall' - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; + try { + if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) { + return inspectionResult('drifted', operation, destinationPath); } - - const settingsPath = getManagedDestination( - existingDestination, - trustedRoot, - 'uninstall' - ).managedPath; - const uninstalled = updateSettingsAtomic( - settingsPath, - currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks), - { - lockHeld: Boolean(options.settingsLockHeld), - beforeCommit() { - getManagedDestination(settingsPath, trustedRoot, 'uninstall'); - }, - } - ); - - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: uninstalled.retained.length > 0 - ? [operation.destinationPath] - : [] - }; + } catch { + return inspectionResult('drifted', operation, destinationPath); } - if (operation.kind === 'remove') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } + return inspectionResult('ok', operation, destinationPath); +} - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - return { - removedPaths: [], - cleanupTargets: [] - }; +function inspectMergeJsonOperation(operation, inspectedPath) { + const { destinationPath } = operation; + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + return inspectionResult('unverified', operation, destinationPath); } - throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); + try { + const currentValue = readJsonNoFollow(inspectedPath); + if (!jsonContainsSubset(currentValue, payload)) { + return inspectionResult('drifted', operation, destinationPath); + } + } catch (_error) { + return inspectionResult('drifted', operation, destinationPath); + } + + return inspectionResult('ok', operation, destinationPath); +} + +function inspectClaudeSettingsOperation(operation, inspectedPath, trustedRoot, target) { + const { destinationPath } = operation; + try { + assertClaudeSettingsDestination(operation, trustedRoot, target); + } catch (_error) { + return inspectionResult('unsafe-destination', operation, destinationPath, { + reason: 'non-canonical-claude-settings' + }); + } + let managedHooks; + try { + managedHooks = validateManagedHooks(operation.managedHooks); + } catch (_error) { + return inspectionResult('unverified', operation, destinationPath); + } + + try { + const inspection = inspectManagedHooks( + readJsonNoFollow(inspectedPath), + managedHooks + ); + return inspectionResult(inspection.status, operation, destinationPath, { + managedHookInspection: inspection + }); + } catch (error) { + return inspectionResult('invalid-settings', operation, destinationPath, { + error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}` + }); + } } function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null, target = null) { @@ -656,32 +744,17 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n { allowFinalSymlink: operation.kind === 'remove' } ); } catch (error) { - return { - status: 'unsafe-destination', - operation, - destinationPath, + return inspectionResult('unsafe-destination', operation, destinationPath, { reason: error && error.code === 'ECC_FINAL_DESTINATION_SYMLINK' ? 'final-symlink' : 'outside-root' - }; + }); } const inspectedPath = managedDestination.managedPath; if (operation.kind === 'remove') { - if (managedDestination.exists) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; + return inspectionResult(managedDestination.exists ? 'drifted' : 'ok', operation, destinationPath); } let copySourcePath = null; @@ -689,188 +762,26 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n try { copySourcePath = resolveOperationSourcePath(repoRoot, operation); } catch { - return { - status: 'unsafe-source', - operation, - destinationPath - }; + return inspectionResult('unsafe-source', operation, destinationPath); } } if (!managedDestination.exists) { - return { - status: 'missing', - operation, - destinationPath - }; + return inspectionResult('missing', operation, destinationPath); } - if (operation.kind === 'copy-file') { - if (!copySourcePath || !fs.existsSync(copySourcePath)) { - return { - status: 'missing-source', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - let contentMatches; - try { - // A deactivation transform changes the desired bytes. A historical - // active-file digest proves ownership, not completion of that transition. - const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks' - || operation.contentTransform === 'opencode-disable-plugin-entrypoint'; - contentMatches = hasRecordedContentDigest(operation) && !deactivatesOpenCode - ? fileMatchesRecordedContent(inspectedPath, operation) - : operation.contentTransform || isMarkdownPath(operation.destinationPath) - ? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent( - operation, - readFileNoFollow(copySourcePath, 'utf8'), - linkIndex - ) - : areFilesEqual(copySourcePath, inspectedPath); - } catch (_error) { - return { - status: 'unverified', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - if (!contentMatches) { - return { - status: 'drifted', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - return { - status: 'ok', - operation, - destinationPath, - sourcePath: copySourcePath - }; + switch (operation.kind) { + case 'copy-file': + return inspectCopyFileOperation(operation, inspectedPath, copySourcePath, linkIndex); + case 'render-template': + return inspectRenderTemplateOperation(operation, inspectedPath); + case 'merge-json': + return inspectMergeJsonOperation(operation, inspectedPath); + case 'update-claude-settings': + return inspectClaudeSettingsOperation(operation, inspectedPath, trustedRoot, target); + default: + return inspectionResult('unverified', operation, destinationPath); } - - if (operation.kind === 'render-template') { - const renderedContent = getOperationTextContent(operation); - if (renderedContent === null) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - } catch { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; - } - - if (operation.kind === 'merge-json') { - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - const currentValue = readJsonNoFollow(inspectedPath); - if (!jsonContainsSubset(currentValue, payload)) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - } catch (_error) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; - } - - if (operation.kind === 'update-claude-settings') { - try { - assertClaudeSettingsDestination(operation, trustedRoot, target); - } catch (_error) { - return { - status: 'unsafe-destination', - operation, - destinationPath, - reason: 'non-canonical-claude-settings' - }; - } - let managedHooks; - try { - managedHooks = validateManagedHooks(operation.managedHooks); - } catch (_error) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - const inspection = inspectManagedHooks( - readJsonNoFollow(inspectedPath), - managedHooks - ); - return { - status: inspection.status, - operation, - destinationPath, - managedHookInspection: inspection - }; - } catch (error) { - return { - status: 'invalid-settings', - operation, - destinationPath, - error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}` - }; - } - } - - return { - status: 'unverified', - operation, - destinationPath - }; } function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, target = null) { @@ -1134,61 +1045,43 @@ function determineStatus(issues) { return 'ok'; } -function analyzeRecord(record, context) { - const issues = []; - - if (record.legacyLayout === 'antigravity') { - issues.push(buildIssue( - 'warning', - 'legacy-antigravity-layout', - 'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.' - )); +const LEGACY_LAYOUT_ISSUES = Object.freeze({ + antigravity: { + code: 'legacy-antigravity-layout', + message: 'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.' + }, + opencode: { + code: 'legacy-opencode-layout', + message: 'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.' } +}); - if (record.legacyLayout === 'opencode') { - issues.push(buildIssue( - 'warning', - 'legacy-opencode-layout', - 'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.' - )); +function collectLegacyLayoutIssues(record, issues) { + if (!Object.hasOwn(LEGACY_LAYOUT_ISSUES, record.legacyLayout)) return; + const legacyIssue = LEGACY_LAYOUT_ISSUES[record.legacyLayout]; + issues.push(buildIssue('warning', legacyIssue.code, legacyIssue.message)); +} + +// Returns true when hook-deactivation planning itself failed, so the later +// resolution-drift check does not report the same failure twice. +function collectOpenCodeHookIssues(record, context, issues) { + if (record.adapter.target !== 'opencode') return false; + let checks; + try { + checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true }); + } catch (error) { + issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + return true; } - - if (record.error) { - issues.push(buildIssue('error', 'invalid-install-state', record.error)); - return { - ...record, - status: determineStatus(issues), - issues - }; - } - - const state = record.state; - if (!state) { - return { - ...record, - status: 'missing', - issues - }; - } - - let planningFailureReported = false; - if (record.adapter.target === 'opencode') { - let checks; - try { - checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true }); - } catch (error) { - planningFailureReported = true; - issues.push(buildIssue('error', 'resolution-unavailable', error.message)); - } - if (checks) { - try { - for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options); - } catch (error) { - issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message)); - } - } + try { + for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options); + } catch (error) { + issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message)); } + return false; +} +function collectTargetPathIssues(record, state, issues) { if (!fs.existsSync(state.target.root)) { issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`)); } @@ -1210,15 +1103,16 @@ function analyzeRecord(record, context) { }) ); } +} - const managedOperations = getManagedOperations(state); +function collectOperationHealthIssues(record, context, state, issues) { const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, - managedOperations, + getManagedOperations(state), record.adapter.target ); - const missingManagedOperations = operationHealth.missing; + const destinationPaths = entries => entries.map(entry => entry.destinationPath); if (operationHealth.unsafeDestination.length > 0) { issues.push( @@ -1246,15 +1140,15 @@ function analyzeRecord(record, context) { 'error', 'invalid-claude-settings', operationHealth.invalidSettings[0].error, - { paths: operationHealth.invalidSettings.map(entry => entry.destinationPath) } + { paths: destinationPaths(operationHealth.invalidSettings) } ) ); } - if (missingManagedOperations.length > 0) { + if (operationHealth.missing.length > 0) { issues.push( - buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, { - paths: missingManagedOperations.map(entry => entry.destinationPath) + buildIssue('error', 'missing-managed-files', `${operationHealth.missing.length} managed file(s) are missing`, { + paths: destinationPaths(operationHealth.missing) }) ); } @@ -1262,7 +1156,7 @@ function analyzeRecord(record, context) { if (operationHealth.drifted.length > 0) { issues.push( buildIssue('warning', 'drifted-managed-files', `${operationHealth.drifted.length} managed file(s) differ from the source repo`, { - paths: operationHealth.drifted.map(entry => entry.destinationPath) + paths: destinationPaths(operationHealth.drifted) }) ); } @@ -1278,11 +1172,13 @@ function analyzeRecord(record, context) { if (operationHealth.unverified.length > 0) { issues.push( buildIssue('warning', 'unverified-managed-operations', `${operationHealth.unverified.length} managed operation(s) could not be content-verified`, { - paths: operationHealth.unverified.map(entry => entry.destinationPath).filter(Boolean) + paths: destinationPaths(operationHealth.unverified).filter(Boolean) }) ); } +} +function collectVersionIssues(context, state, issues) { if (state.source.manifestVersion !== context.manifestVersion) { issues.push(buildIssue('warning', 'manifest-version-mismatch', `Recorded manifest version ${state.source.manifestVersion} differs from current manifest version ${context.manifestVersion}`)); } @@ -1290,24 +1186,55 @@ function analyzeRecord(record, context) { if (context.packageVersion && state.source.repoVersion && state.source.repoVersion !== context.packageVersion) { issues.push(buildIssue('warning', 'repo-version-mismatch', `Recorded repo version ${state.source.repoVersion} differs from current repo version ${context.packageVersion}`)); } +} - if (!state.request.legacyMode && !planningFailureReported) { - try { - const desiredPlan = resolveRecordedManifestPlan(record, context); +function collectResolutionDriftIssues(record, context, state, issues) { + try { + const desiredPlan = resolveRecordedManifestPlan(record, context); - if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) { - issues.push( - buildIssue('warning', 'resolution-drift', 'Current manifest resolution differs from recorded install-state', { - expectedSelectedModules: desiredPlan.selectedModuleIds, - recordedSelectedModules: state.resolution.selectedModules, - expectedSkippedModules: desiredPlan.skippedModuleIds, - recordedSkippedModules: state.resolution.skippedModules - }) - ); - } - } catch (error) { - issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) { + issues.push( + buildIssue('warning', 'resolution-drift', 'Current manifest resolution differs from recorded install-state', { + expectedSelectedModules: desiredPlan.selectedModuleIds, + recordedSelectedModules: state.resolution.selectedModules, + expectedSkippedModules: desiredPlan.skippedModuleIds, + recordedSkippedModules: state.resolution.skippedModules + }) + ); } + } catch (error) { + issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + } +} + +function analyzeRecord(record, context) { + const issues = []; + collectLegacyLayoutIssues(record, issues); + + if (record.error) { + issues.push(buildIssue('error', 'invalid-install-state', record.error)); + return { + ...record, + status: determineStatus(issues), + issues + }; + } + + const state = record.state; + if (!state) { + return { + ...record, + status: 'missing', + issues + }; + } + + const planningFailureReported = collectOpenCodeHookIssues(record, context, issues); + collectTargetPathIssues(record, state, issues); + collectOperationHealthIssues(record, context, state, issues); + collectVersionIssues(context, state, issues); + if (!state.request.legacyMode && !planningFailureReported) { + collectResolutionDriftIssues(record, context, state, issues); } return { @@ -1564,6 +1491,319 @@ function preflightOpenCodeHookDeactivation(record, context, options = {}) { } } +function repairErrorResult(record, error) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + error + }; +} + +function acquireRepairSettingsLock(record, options) { + const settingsPathToLock = !options.dryRun + && getManagedOperations(record.state || {}).some( + operation => operation.kind === 'update-claude-settings' + ) + ? getClaudeSettingsPath(record.targetRoot) + : null; + return settingsPathToLock ? acquireSettingsLock(settingsPathToLock) : null; +} + +// Returns an error result when the OpenCode build fails, otherwise null. +function runOpencodeBuild(repair) { + try { + repair.buildOpencodeRunner(repair.context.repoRoot); + return null; + } catch (error) { + return repairErrorResult(repair.record, formatBuildErrorMessage(error)); + } +} + +// Legacy ~/.opencode layouts are migrated by applying the canonical plan +// rather than repairing files in place. +function repairLegacyOpencodeLayout(repair, build) { + const { record, context, options, opencodeLease } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + if (needsOpencodeBuild && !options.dryRun) { + const buildFailure = runOpencodeBuild(repair); + if (buildFailure) return buildFailure; + } + + const canonicalPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: options.dryRun && needsOpencodeBuild + ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] + : [], + }); + assertOpenCodeRepairHookDeactivation(canonicalPlan); + const buildPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; + const planPaths = canonicalPlan.operations.map(operation => operation.destinationPath); + const plannedRepairs = [...new Set([ + ...buildPaths, + ...planPaths, + ...getManagedOperations(record.state).map(operation => operation.destinationPath), + record.installStatePath, + ])]; + + if (options.dryRun) { + return { + adapter: record.adapter, + status: 'planned', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: canonicalPlan.warnings, + error: null, + }; + } + + // Load lazily to avoid a module cycle during install-lifecycle startup. + const { applyInstallPlan } = require('./install/apply'); + const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease }); + return { + adapter: record.adapter, + status: 'repaired', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [...buildPaths, ...planPaths], + plannedRepairs: [], + stateRefreshed: true, + warnings: appliedPlan.warnings, + error: null, + }; +} + +// Dry run while the OpenCode plugin is not built: plan around the missing +// artifact instead of failing validation. +function planOpencodeBuildRepair(repair, build) { + const { record, context } = repair; + const rawPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], + }); + const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations, + record.adapter.target + ); + const unsafeOperationResult = getUnsafeOperationResult(record, operationHealth); + if (unsafeOperationResult) return unsafeOperationResult; + + const repairOperations = [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ]; + const plannedRepairs = [ + build.opencodeBuildRepairPath, + ...repairOperations.map(operation => operation.destinationPath), + ]; + + return { + adapter: record.adapter, + status: 'planned', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: desiredPlan.warnings, + error: null + }; +} + +// Missing and drifted managed files, plus settings operations whose managed +// hooks changed since the previous install. Deduplicated by kind and path. +function collectRepairOperations(operationHealth, desiredPlan) { + return [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ...desiredPlan.operations + .filter(operation => ( + operation.kind === 'update-claude-settings' + && operation.previousManagedHooks + && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) + )) + .map(operation => ({ ...operation })), + ].filter((operation, index, items) => items.findIndex(candidate => ( + candidate.kind === operation.kind + && candidate.destinationPath === operation.destinationPath + )) === index); +} + +function executeGuardedRepairOperation(repair, plan, operation, repairedPaths) { + const { record, context, settingsLockHeld } = repair; + const { desiredPlan, migration, activationSnapshot, repairLinkIndex } = plan; + const apply = require('./install/apply'); + if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { + apply.assertOpenCodeScriptBoundary(desiredPlan, new Set(repairedPaths)); + } + if (desiredPlan.target === 'opencode') { + apply.assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); + } + assertNoNewUserOwnedFile(migration, operation, desiredPlan); + const repairedPath = executeRepairOperation(context.repoRoot, operation, record.targetRoot, { + linkIndex: repairLinkIndex, + target: record.adapter.target, + settingsLockHeld, + writeOptions: apply.getOpenCodeActivationWriteOptions(operation, activationSnapshot), + }); + if (repairedPath) { + repairedPaths.push(repairedPath); + } +} + +function removeLegacyMigrationPaths(record, migration, repairedPaths) { + for (const operation of migration.legacyOperationsToRemove) { + const removedPath = removeContainedPath( + operation.destinationPath, + record.targetRoot, + 'migrate managed Claude skill', + { force: true } + ); + if (removedPath) { + repairedPaths.push(removedPath); + } + } +} + +function applyRecordRepairs(repair, build, plan) { + const { record } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + const { desiredPlan, migration, activationSnapshot, repairOperations } = plan; + const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; + if (desiredPlan.target === 'opencode') { + const { assertOpenCodeActivationUnchanged } = require('./install/apply'); + // Health inspection must not let a changed activation become owned by + // a bridge checkpoint before the per-write check can reject it. + for (const destinationPath of activationSnapshot.keys()) { + assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot); + } + } + if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { + writeRefreshedInstallState(record, migration.bridgeState); + } + + for (const operation of repairOperations) { + executeGuardedRepairOperation(repair, plan, operation, repairedPaths); + } + if (hasLegacyMigration) { + removeLegacyMigrationPaths(record, migration, repairedPaths); + } + + const changedInstalledBytes = repairOperations.length > 0 + || needsOpencodeBuild + || hasLegacyMigration; + const statePreviewToWrite = changedInstalledBytes + ? desiredPlan.statePreview + : { + ...desiredPlan.statePreview, + installedAt: record.state.installedAt, + source: { ...record.state.source }, + }; + assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true }); + writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); + + return { + adapter: record.adapter, + status: changedInstalledBytes ? 'repaired' : 'ok', + installStatePath: record.installStatePath, + repairedPaths, + plannedRepairs: [], + stateRefreshed: true, + warnings: desiredPlan.warnings, + error: null + }; +} + +function repairManagedRecord(repair, build) { + const { record, context, options } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + const rawPlan = createRepairPlanFromRecord(record, context); + require('./install/apply').assertOpenCodeScriptBoundary(rawPlan); + const { + migration, + plan: desiredPlan, + } = prepareRepairMigration(rawPlan, record); + const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations, + record.adapter.target + ); + + const unsafeOperationResult = getUnsafeOperationResult(record, operationHealth); + if (unsafeOperationResult) return unsafeOperationResult; + + if (operationHealth.missingSource.length > 0) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + warnings: desiredPlan.warnings, + error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` + }; + } + + const repairOperations = collectRepairOperations(operationHealth, desiredPlan); + const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); + const legacyMigrationPaths = migration.legacyOperationsToRemove.map( + operation => operation.destinationPath + ); + const plannedRepairs = [...new Set([ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...repairOperations.map(operation => operation.destinationPath), + ...legacyMigrationPaths, + ])]; + + if (options.dryRun) { + return { + adapter: record.adapter, + status: plannedRepairs.length > 0 ? 'planned' : 'ok', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: plannedRepairs.length === 0, + warnings: desiredPlan.warnings, + error: null + }; + } + + return applyRecordRepairs(repair, build, { + desiredPlan, + migration, + activationSnapshot, + repairOperations, + repairLinkIndex, + }); +} + +// Repair one discovered record. `repair` carries the record, lifecycle +// context, caller options, the OpenCode build runner, an optional OpenCode +// lease, and whether the Claude settings lock is already held. +function runRecordRepair(repair) { + const { record, context, options } = repair; + const needsOpencodeBuild = record.adapter.target === 'opencode' + && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); + const build = { + needsOpencodeBuild, + opencodeBuildRepairPath: path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT), + }; + + if (record.legacyLayout === 'opencode') return repairLegacyOpencodeLayout(repair, build); + if (needsOpencodeBuild && options.dryRun) return planOpencodeBuildRepair(repair, build); + if (needsOpencodeBuild) { + const buildFailure = runOpencodeBuild(repair); + if (buildFailure) return buildFailure; + } + return repairManagedRecord(repair, build); +} + function repairInstalledStates(options = {}) { const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT; const manifests = loadInstallManifests({ repoRoot }); @@ -1591,295 +1831,22 @@ function repairInstalledStates(options = {}) { const results = records.map(initialRecord => { let record = initialRecord; const performRepair = (opencodeLease) => { - if (record.error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: record.error - }; - } + if (record.error) return repairErrorResult(record, record.error); let releaseSettingsLock = null; try { preflightOpenCodeHookDeactivation(record, context); - const settingsPathToLock = !options.dryRun - && getManagedOperations(record.state || {}).some( - operation => operation.kind === 'update-claude-settings' - ) - ? getClaudeSettingsPath(record.targetRoot) - : null; - if (settingsPathToLock) { - releaseSettingsLock = acquireSettingsLock(settingsPathToLock); - } - const needsOpencodeBuild = record.adapter.target === 'opencode' - && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); - const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); - - if (record.legacyLayout === 'opencode') { - if (needsOpencodeBuild && !options.dryRun) { - try { - buildOpencodeRunner(context.repoRoot); - } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: formatBuildErrorMessage(error), - }; - } - } - - const canonicalPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: options.dryRun && needsOpencodeBuild - ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] - : [], - }); - assertOpenCodeRepairHookDeactivation(canonicalPlan); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ...getManagedOperations(record.state).map(operation => operation.destinationPath), - record.installStatePath, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: 'planned', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: canonicalPlan.warnings, - error: null, - }; - } - - // Load lazily to avoid a module cycle during install-lifecycle startup. - const { applyInstallPlan } = require('./install/apply'); - const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease }); - return { - adapter: record.adapter, - status: 'repaired', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ], - plannedRepairs: [], - stateRefreshed: true, - warnings: appliedPlan.warnings, - error: null, - }; - } - - if (needsOpencodeBuild && options.dryRun) { - const rawPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], - }); - const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); - const operationHealth = summarizeManagedOperationHealth( - context.repoRoot, - record.targetRoot, - desiredPlan.operations, - record.adapter.target - ); - const unsafeOperationResult = getUnsafeOperationResult( - record, - operationHealth - ); - if (unsafeOperationResult) { - return unsafeOperationResult; - } - const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; - const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; - - return { - adapter: record.adapter, - status: 'planned', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: desiredPlan.warnings, - error: null - }; - } - - if (needsOpencodeBuild) { - try { - buildOpencodeRunner(context.repoRoot); - } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: formatBuildErrorMessage(error) - }; - } - } - - const rawPlan = createRepairPlanFromRecord(record, context); - require('./install/apply').assertOpenCodeScriptBoundary(rawPlan); - const { - migration, - plan: desiredPlan, - } = prepareRepairMigration(rawPlan, record); - const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan); - const operationHealth = summarizeManagedOperationHealth( - context.repoRoot, - record.targetRoot, - desiredPlan.operations, - record.adapter.target - ); - - const unsafeOperationResult = getUnsafeOperationResult( + releaseSettingsLock = acquireRepairSettingsLock(record, options); + return runRecordRepair({ record, - operationHealth - ); - if (unsafeOperationResult) { - return unsafeOperationResult; - } - - if (operationHealth.missingSource.length > 0) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - warnings: desiredPlan.warnings, - error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` - }; - } - - const repairOperations = [ - ...operationHealth.missing.map(entry => ({ ...entry.operation })), - ...operationHealth.drifted.map(entry => ({ ...entry.operation })), - ...desiredPlan.operations - .filter(operation => ( - operation.kind === 'update-claude-settings' - && operation.previousManagedHooks - && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) - )) - .map(operation => ({ ...operation })), - ].filter((operation, index, items) => items.findIndex(candidate => ( - candidate.kind === operation.kind - && candidate.destinationPath === operation.destinationPath - )) === index); - const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); - const legacyMigrationPaths = migration.legacyOperationsToRemove.map( - operation => operation.destinationPath - ); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...repairOperations.map(operation => operation.destinationPath), - ...legacyMigrationPaths, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: plannedRepairs.length > 0 ? 'planned' : 'ok', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: plannedRepairs.length === 0, - warnings: desiredPlan.warnings, - error: null - }; - } - - const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; - const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; - if (desiredPlan.target === 'opencode') { - const { assertOpenCodeActivationUnchanged } = require('./install/apply'); - // Health inspection must not let a changed activation become owned by - // a bridge checkpoint before the per-write check can reject it. - for (const destinationPath of activationSnapshot.keys()) { - assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot); - } - } - if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { - writeRefreshedInstallState(record, migration.bridgeState); - } - - for (const operation of repairOperations) { - if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { - require('./install/apply').assertOpenCodeScriptBoundary(desiredPlan, new Set(repairedPaths)); - } - if (desiredPlan.target === 'opencode') { - const { assertOpenCodeActivationUnchanged } = require('./install/apply'); - assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); - } - assertNoNewUserOwnedFile(migration, operation, desiredPlan); - const repairedPath = executeRepairOperation( - context.repoRoot, - operation, - record.targetRoot, - repairLinkIndex, - record.adapter.target, - Boolean(releaseSettingsLock), - require('./install/apply').getOpenCodeActivationWriteOptions(operation, activationSnapshot) - ); - if (repairedPath) { - repairedPaths.push(repairedPath); - } - } - if (hasLegacyMigration) { - for (const operation of migration.legacyOperationsToRemove) { - const removedPath = removeContainedPath( - operation.destinationPath, - record.targetRoot, - 'migrate managed Claude skill', - { force: true } - ); - if (removedPath) { - repairedPaths.push(removedPath); - } - } - } - const changedInstalledBytes = repairOperations.length > 0 - || needsOpencodeBuild - || hasLegacyMigration; - const statePreviewToWrite = changedInstalledBytes - ? desiredPlan.statePreview - : { - ...desiredPlan.statePreview, - installedAt: record.state.installedAt, - source: { ...record.state.source }, - }; - assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true }); - writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); - - return { - adapter: record.adapter, - status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration) - ? 'repaired' - : 'ok', - installStatePath: record.installStatePath, - repairedPaths, - plannedRepairs: [], - stateRefreshed: true, - warnings: desiredPlan.warnings, - error: null - }; + context, + options, + buildOpencodeRunner, + opencodeLease, + settingsLockHeld: Boolean(releaseSettingsLock), + }); } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: error.message - }; + return repairErrorResult(record, error.message); } finally { if (releaseSettingsLock) releaseSettingsLock(); } diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js index 6e5391768..a8fb93a49 100644 --- a/scripts/lib/nasiko-release.js +++ b/scripts/lib/nasiko-release.js @@ -88,47 +88,68 @@ function readTarOctal(block, offset, length) { return size; } -function extractQualifiedTarGzip(archiveBytes, expectedName) { - let tar; - try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } +function gunzipArchive(archiveBytes) { + try { return zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); } +} + +function isZeroBlock(bytes) { + return bytes.every(byte => byte === 0); +} + +function assertTarTerminator(tar, offset) { + const terminatorEnd = offset + 1024; + if (terminatorEnd > tar.length || !isZeroBlock(tar.subarray(offset + 512, terminatorEnd)) || !isZeroBlock(tar.subarray(terminatorEnd))) { + throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.'); + } +} + +function readTarEntry(tar, offset) { + const header = tar.subarray(offset, offset + 512); + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const type = String.fromCharCode(header[156] || 48); + const size = readTarOctal(header, 124, 12); + const start = offset + 512; + const end = start + size; + const paddedEnd = start + Math.ceil(size / 512) * 512; + if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.'); + const payload = tar.subarray(start, end); + if (!isZeroBlock(tar.subarray(end, paddedEnd))) throw new Error('Unsafe Nasiko archive: nonzero tar padding.'); + return { name, prefix, type, size, payload, paddedEnd }; +} + +function isBinaryEntry(entry, expectedName) { + return !entry.prefix && entry.name === expectedName && (entry.type === '0' || entry.type === '\0'); +} + +// macOS tar may add an AppleDouble fork and a pax header beside the binary. +// Both are tolerated only when small and when the pax header renames nothing. +function isSidecarEntry(entry, expectedName) { + if (entry.prefix) return false; + if (entry.name === `._${expectedName}`) return entry.type === '0' && entry.size <= 1024 * 1024; + if (entry.name === `PaxHeader/${expectedName}`) { + return entry.type === 'x' && entry.size <= 64 * 1024 && !/(?:^|\n)(?:path|linkpath)=/i.test(entry.payload.toString('utf8')); + } + return false; +} + +function extractQualifiedTarGzip(archiveBytes, expectedName) { + const tar = gunzipArchive(archiveBytes); let offset = 0; let binary = null; let terminated = false; while (offset < tar.length) { if (offset + 512 > tar.length) throw new Error('Unsafe Nasiko archive: truncated tar header.'); - const header = tar.subarray(offset, offset + 512); - if (header.every(byte => byte === 0)) { - const terminatorEnd = offset + 1024; - if ( - terminatorEnd > tar.length - || !tar.subarray(offset + 512, terminatorEnd).every(byte => byte === 0) - || !tar.subarray(terminatorEnd).every(byte => byte === 0) - ) { - throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.'); - } + if (isZeroBlock(tar.subarray(offset, offset + 512))) { + assertTarTerminator(tar, offset); terminated = true; break; } - const name = readTarString(header, 0, 100); - const prefix = readTarString(header, 345, 155); - const type = String.fromCharCode(header[156] || 48); - const size = readTarOctal(header, 124, 12); - const start = offset + 512; - const end = start + size; - const paddedEnd = start + Math.ceil(size / 512) * 512; - if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.'); - const payload = tar.subarray(start, end); - if (!tar.subarray(end, paddedEnd).every(byte => byte === 0)) { - throw new Error('Unsafe Nasiko archive: nonzero tar padding.'); - } - const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0'); - const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024; - const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024 - && !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8')); - if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload); - else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); - offset = paddedEnd; + const entry = readTarEntry(tar, offset); + if (isBinaryEntry(entry, expectedName) && !binary && entry.size > 0 && entry.size <= MAX_BINARY_BYTES) binary = Buffer.from(entry.payload); + else if (!isSidecarEntry(entry, expectedName)) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + offset = entry.paddedEnd; } if (!terminated) throw new Error('Unsafe Nasiko archive: missing complete tar terminator.'); if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); @@ -376,45 +397,48 @@ function acquireLifecycleLock(installDirectory, fileSystem = fs, options = {}) { } } -async function installNasiko(options = {}, dependencies = {}) { - const version = options.version || 'v0.1.0'; - const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); - const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; +function qualifiedReleaseFor(options, dependencies) { + return getQualifiedRelease(options.version || 'v0.1.0', dependencies.platform || process.platform, dependencies.arch || process.arch); +} + +function installPathsFor(options, dependencies, release) { const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); - const destination = path.join(installDirectory, release.binaryName); - const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; - if (options.dryRun) return plan; - if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); - fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); - assertPrivateInstallDirectory(installDirectory); - const releaseLock = acquireLifecycleLock(installDirectory); + return { installDirectory, destination: path.join(installDirectory, release.binaryName) }; +} + +async function fetchVerifiedBinary(release, dependencies) { + const retrieve = dependencies.fetchBytes || fetchBytes; + const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); + assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); + const layer = validateManifest(manifestBytes); + const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); + if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); + assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); + const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); + assertDigest(binary, release.binaryDigest, 'Nasiko binary'); + return { binary, layer }; +} + +function writeBinary(descriptor, binary) { + try { + fs.writeFileSync(descriptor, binary); + fs.fsyncSync(descriptor); + if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); + } finally { fs.closeSync(descriptor); } +} + +// Publish the binary and its receipt exclusively; a failure after either file +// is created removes what this call owns and nothing else. +function publishBinary(plan, release, layer, binary, dependencies) { + const { destination } = plan; const metadataPath = metadataPathFor(destination); let destinationOwned = false; let metadataOwned = false; try { - const existing = inspectInstalledNasiko(destination); - if (existing.installed) { - if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; - throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); - } - const retrieve = dependencies.fetchBytes || fetchBytes; - const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); - assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); - const layer = validateManifest(manifestBytes); - const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); - if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); - assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); - const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); - assertDigest(binary, release.binaryDigest, 'Nasiko binary'); - if (dependencies.beforePublish) dependencies.beforePublish(destination); const descriptor = fs.openSync(destination, 'wx', 0o700); destinationOwned = true; - try { - fs.writeFileSync(descriptor, binary); - fs.fsyncSync(descriptor); - if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); - } finally { fs.closeSync(descriptor); } - const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; + writeBinary(descriptor, binary); + const metadata = { version: plan.version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); metadataOwned = true; return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest }; @@ -422,51 +446,83 @@ async function installNasiko(options = {}, dependencies = {}) { if (metadataOwned) fs.rmSync(metadataPath, { force: true }); if (destinationOwned) fs.rmSync(destination, { force: true }); throw error; + } +} + +async function installNasiko(options = {}, dependencies = {}) { + const base = qualifiedReleaseFor(options, dependencies); + const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; + const { installDirectory, destination } = installPathsFor(options, dependencies, release); + const plan = { dryRun: Boolean(options.dryRun), version: base.version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); + fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + try { + const existing = inspectInstalledNasiko(destination); + if (existing.installed) { + if (existing.qualified && existing.version === plan.version) return { ...plan, dryRun: false, installed: true, reused: true }; + throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); + } + const { binary, layer } = await fetchVerifiedBinary(release, dependencies); + if (dependencies.beforePublish) dependencies.beforePublish(destination); + return publishBinary(plan, release, layer, binary, dependencies); } finally { releaseLock(); } } -function uninstallNasiko(options = {}, dependencies = {}) { - const version = options.version || 'v0.1.0'; - const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); - const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); - const destination = path.join(installDirectory, release.binaryName); - const plan = { dryRun: Boolean(options.dryRun), version, destination }; - if (options.dryRun) return plan; - if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); - if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; - assertPrivateInstallDirectory(installDirectory); - const releaseLock = acquireLifecycleLock(installDirectory); +function removeOrRetain(remove, target, retained) { + try { remove(target); } catch (_error) { retained.push(target); } +} + +// Rename both files aside before deleting them so a failed rename restores +// the install, while a failed delete leaves a named tombstone for the operator. +function removeInstalledFiles(destination, dependencies) { const metadataPath = metadataPathFor(destination); const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`; const binaryTombstone = `${destination}.remove-${suffix}`; const metadataTombstone = `${metadataPath}.remove-${suffix}`; - let binaryStaged = false; - let metadataStaged = false; const rename = dependencies.rename || fs.renameSync; const remove = dependencies.remove || (target => fs.rmSync(target)); + let binaryStaged = false; + let metadataStaged = false; try { - const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); - if (!status.installed) return { ...plan, dryRun: false, removed: false }; - if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); rename(destination, binaryTombstone); binaryStaged = true; rename(metadataPath, metadataTombstone); metadataStaged = true; const cleanupPending = []; - try { remove(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); } + removeOrRetain(remove, metadataTombstone, cleanupPending); metadataStaged = false; - try { remove(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); } + removeOrRetain(remove, binaryTombstone, cleanupPending); binaryStaged = false; if (cleanupPending.length > 0) { const cleanupError = new Error(`Nasiko uninstall is incomplete; retained staged file(s): ${cleanupPending.join(', ')}. Remove these files before reinstalling.`); cleanupError.cleanupPending = cleanupPending; throw cleanupError; } - return { ...plan, dryRun: false, removed: true, cleanupPending: [] }; } catch (error) { if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath); if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination); throw error; + } +} + +function uninstallNasiko(options = {}, dependencies = {}) { + const release = qualifiedReleaseFor(options, dependencies); + const { installDirectory, destination } = installPathsFor(options, dependencies, release); + const plan = { dryRun: Boolean(options.dryRun), version: release.version, destination }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); + if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + try { + const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); + if (!status.installed) return { ...plan, dryRun: false, removed: false }; + if (!status.qualified || status.version !== release.version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); + removeInstalledFiles(destination, dependencies); + return { ...plan, dryRun: false, removed: true, cleanupPending: [] }; } finally { releaseLock(); } } diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index c5b90d048..5ecb6cac6 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -113,88 +113,169 @@ function isCommandFlag(token) { isParameterPrefix(token, 'commandwithargs') || name === 'cwa'; } +/** + * Shared inert-region tracking for the character scanners below. Comments, + * `${}` variable names, quoted strings, and backtick escapes never contain + * container delimiters or here-string openers, so every scanner skips them + * the same way. + */ +function createLexState() { + return { + quote: null, + lineComment: false, + blockComment: false, + bracedVariable: false, + comment: false, + }; +} + +/** + * Consume the character at `index` when it belongs to an open inert region. + * Returns the last index consumed, or -1 when the character is live and the + * caller must interpret it. `state.comment` reports whether the consumed run + * is comment text. + */ +function consumeInertChar(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (state.lineComment) { + if (char === '\n' || char === '\r') { + state.lineComment = false; + } else { + state.comment = true; + } + return index; + } + if (state.blockComment) { + if (char === '#' && next === '>') { + state.blockComment = false; + state.comment = true; + return index + 1; + } + state.comment = char !== '\n' && char !== '\r'; + return index; + } + if (state.bracedVariable) { + if (char === '`') return index + 1; + if (char === '}') state.bracedVariable = false; + return index; + } + if (state.quote === "'") { + if (char === "'" && next === "'") return index + 1; + if (char === "'") state.quote = null; + return index; + } + if (char === '`') return index + 1; + if (state.quote === '"') { + if (char === '"') state.quote = null; + return index; + } + return -1; +} + +/** + * Open an inert region at a live character. Returns the last index consumed, + * or -1 when the character does not start a region. + */ +function openInertRegion(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (char === "'" || char === '"') { + state.quote = char; + return index; + } + if (char === '$' && next === '{') { + state.bracedVariable = true; + return index + 1; + } + if (char === '<' && next === '#') { + state.blockComment = true; + state.comment = true; + return index + 1; + } + if (char === '#') { + state.lineComment = true; + state.comment = true; + return index; + } + return -1; +} + +function hereStringOpenerLineEnd(input, index) { + let openerLineEnd = index + 2; + while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; + if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + return openerLineEnd; +} + +function hereStringClosingEnd(input, openerLineEnd, quote) { + for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { + let contentStart = lineStart; + while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; + if (input[contentStart] === quote && input[contentStart + 1] === '@') { + return contentStart + 2; + } + while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; + if (lineStart < input.length) lineStart += 1; + } + return -1; +} + +// Expandable here-strings execute their unescaped subexpressions while the +// string value is being formed, independently of any later consumer. +function collectHereStringSubexpressions(input, start, end, executablePayloads) { + for (let offset = start; offset < end; offset += 1) { + if (input[offset] === '`') { + offset += 1; + continue; + } + if (input[offset] !== '$' || input[offset + 1] !== '(') continue; + const group = readBalancedGroup(input, offset + 1, '(', ')'); + if (!group || group.end > end) break; + executablePayloads.push(group.body); + offset = group.end - 1; + } +} + +function applyReplacements(output, replacements) { + let normalized = ''; + let cursor = 0; + for (const replacement of replacements) { + normalized += output.slice(cursor, replacement.start).join(''); + normalized += replacement.value; + cursor = replacement.end; + } + return normalized + output.slice(cursor).join(''); +} + function normalizeHereStrings(input, executablePayloads = []) { const output = [...input]; const replacements = []; - let ordinaryQuote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length - 1; index += 1) { - const char = input[index]; - const next = input[index + 1]; - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - if (ordinaryQuote === "'") { - if (char === "'" && input[index + 1] === "'") index += 1; - else if (char === "'") ordinaryQuote = null; - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (ordinaryQuote === '"') { - if (char === '"') ordinaryQuote = null; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - - if (input[index] !== '@' || (input[index + 1] !== "'" && input[index + 1] !== '"')) { - if (char === "'" || char === '"') ordinaryQuote = char; + const inertEnd = consumeInertChar(state, input, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } const quote = input[index + 1]; - let openerLineEnd = index + 2; - while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; - if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + if (input[index] !== '@' || (quote !== "'" && quote !== '"')) { + const regionEnd = openInertRegion(state, input, index); + if (regionEnd !== -1) index = regionEnd; + continue; + } + + const openerLineEnd = hereStringOpenerLineEnd(input, index); if (input[openerLineEnd] !== '\n') { if (openerLineEnd >= input.length) break; continue; } - let closingEnd = -1; - for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { - let contentStart = lineStart; - while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; - if (input[contentStart] === quote && input[contentStart + 1] === '@') { - closingEnd = contentStart + 2; - break; - } - while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; - if (lineStart < input.length) lineStart += 1; - } - + const closingEnd = hereStringClosingEnd(input, openerLineEnd, quote); const contentEnd = closingEnd === -1 ? input.length : closingEnd - 2; const content = input.slice(openerLineEnd + 1, contentEnd); @@ -206,21 +287,8 @@ function normalizeHereStrings(input, executablePayloads = []) { start: index, value: `'${content.replace(/'/g, "''")}'`, }); - - // Expandable here-strings execute their unescaped subexpressions while the - // string value is being formed, independently of any later consumer. if (quote === '"') { - for (let offset = openerLineEnd + 1; offset < contentEnd; offset += 1) { - if (input[offset] === '`') { - offset += 1; - continue; - } - if (input[offset] !== '$' || input[offset + 1] !== '(') continue; - const group = readBalancedGroup(input, offset + 1, '(', ')'); - if (!group || group.end > contentEnd) break; - executablePayloads.push(group.body); - offset = group.end - 1; - } + collectHereStringSubexpressions(input, openerLineEnd + 1, contentEnd, executablePayloads); } if (closingEnd === -1) break; @@ -228,94 +296,25 @@ function normalizeHereStrings(input, executablePayloads = []) { } if (replacements.length === 0) return output.join(''); - let normalized = ''; - let cursor = 0; - for (const replacement of replacements) { - normalized += output.slice(cursor, replacement.start).join(''); - normalized += replacement.value; - cursor = replacement.end; + return applyReplacements(output, replacements); +} + +function blankRange(output, start, end) { + for (let offset = start; offset <= end; offset += 1) { + if (output[offset] !== '\n' && output[offset] !== '\r') output[offset] = ' '; } - normalized += output.slice(cursor).join(''); - return normalized; } function stripPowerShellComments(input) { const output = [...input]; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') { - lineComment = false; - } else { - output[index] = ' '; - } - continue; - } - - if (blockComment) { - if (char === '#' && next === '>') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = false; - index += 1; - } else if (char !== '\n' && char !== '\r') { - output[index] = ' '; - } - continue; - } - - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && next === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = true; - index += 1; - continue; - } - - if (char === '#') { - output[index] = ' '; - lineComment = true; - } + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end === -1) continue; + if (state.comment) blankRange(output, index, end); + index = end; } return output.join(''); @@ -328,71 +327,17 @@ function stripPowerShellComments(input) { */ function readBalancedGroup(input, openingIndex, open, close) { let depth = 1; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = openingIndex + 1; index < input.length; index += 1) { + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end !== -1) { + index = end; + continue; + } + const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - if (char === open) { depth += 1; } else if (char === close) { @@ -645,8 +590,11 @@ function staticCommandResult(body) { return command && /^[A-Za-z_][\w./\\-]*$/.test(command) ? command : null; } -function staticStringArrayResult(body) { - const input = String(body || ''); +/** + * Split an argument list on top-level commas, keeping quotes and nested + * parentheses intact. Returns null for unbalanced input. + */ +function splitStaticArrayItems(input) { const items = []; let item = ''; let quote = null; @@ -668,17 +616,25 @@ function staticStringArrayResult(body) { item += char; continue; } - if (!quote && char === '(') depth += 1; - if (!quote && char === ')') depth -= 1; - if (!quote && depth === 0 && char === ',') { - items.push(item); - item = ''; - continue; + if (!quote) { + if (char === '(') depth += 1; + else if (char === ')') depth -= 1; + else if (depth === 0 && char === ',') { + items.push(item); + item = ''; + continue; + } } item += char; } if (quote || depth !== 0) return null; items.push(item); + return items; +} + +function staticStringArrayResult(body) { + const items = splitStaticArrayItems(String(body || '')); + if (!items) return null; const values = items.map(value => staticScalarResult(value)); return values.length > 0 && values.every(value => value !== null) ? values.join(' ') @@ -735,203 +691,272 @@ function isPipedToPowerShellStdin(input, end) { * blocks while masking every container from the outer statement pass. `$()` * also executes inside double quotes. Other containers are literal there. */ -function extractExecutableContainers(input, options = {}) { - const bodies = []; - const deferredFunctions = []; - const masked = [...input]; - let quote = null; - let bracedVariable = false; - let context = ''; - let contextTruncated = false; +const CONTEXT_CLAUSE_KEYWORDS = /^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i; - const resetContext = () => { - context = ''; - contextTruncated = false; +function createContainerScan(input, options) { + return { + input, + options, + bodies: [], + deferredFunctions: [], + masked: [...input], + quote: null, + bracedVariable: false, + context: '', + contextTruncated: false, }; +} - const appendContext = value => { - for (const contextChar of value) { - if (contextChar === ';' || contextChar === '}') { - resetContext(); - } else if (contextChar === '\n' || contextChar === '\r') { - const clause = currentClause(context); - if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { - if (context && !context.endsWith(' ')) context += ' '; - } else { - resetContext(); - } - } else if (/\s/.test(contextChar)) { - if (context && !context.endsWith(' ')) context += ' '; +function resetContainerContext(scan) { + scan.context = ''; + scan.contextTruncated = false; +} + +function appendContextSpace(scan) { + if (scan.context && !scan.context.endsWith(' ')) scan.context += ' '; +} + +function appendContainerContext(scan, value) { + for (const contextChar of value) { + if (contextChar === ';' || contextChar === '}') { + resetContainerContext(scan); + } else if (contextChar === '\n' || contextChar === '\r') { + if (CONTEXT_CLAUSE_KEYWORDS.test(currentClause(scan.context))) { + appendContextSpace(scan); } else { - context += contextChar; - } - if (context.length > MAX_CONTEXT_LENGTH) { - context = context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); - contextTruncated = true; + resetContainerContext(scan); } + } else if (/\s/.test(contextChar)) { + appendContextSpace(scan); + } else { + scan.context += contextChar; } + if (scan.context.length > MAX_CONTEXT_LENGTH) { + scan.context = scan.context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); + scan.contextTruncated = true; + } + } +} + +function maskRange(masked, start, end) { + for (let offset = start; offset < end; offset += 1) masked[offset] = ' '; +} + +function consumeBracedVariableChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + appendContainerContext(scan, input[index + 1]); + return index + 1; + } + if (char === '}') { + scan.context += char; + scan.bracedVariable = false; + } else { + appendContainerContext(scan, char); + } + return index; +} + +// A backtick outside quotes folds the escaped character into the clause +// context; inside quotes it only protects the next character. +function consumeContainerEscape(scan, index) { + const { input } = scan; + if (!scan.quote && index + 1 < input.length) { + const escaped = input[index + 1]; + appendContainerContext(scan, escaped === '\n' || escaped === '\r' ? ' ' : escaped); + if (escaped === '\r' && input[index + 2] === '\n') return index + 2; + } + return index + 1; +} + +/** + * Consume characters that cannot open a container: `${}` names, single-quoted + * text, backtick escapes, and quote delimiters. Returns the last consumed + * index, or -1 for a live character. + */ +function consumeContainerInertChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (scan.bracedVariable) return consumeBracedVariableChar(scan, index); + if (scan.quote === "'") { + if (char === "'" && input[index + 1] === "'") return index + 1; + if (char === "'") scan.quote = null; + return index; + } + if (char === '`') return consumeContainerEscape(scan, index); + if (!scan.quote && char === "'") { + scan.quote = "'"; + appendContainerContext(scan, ' '); + return index; + } + if (!scan.quote && char === '$' && input[index + 1] === '{') { + appendContainerContext(scan, '${'); + scan.bracedVariable = true; + return index + 1; + } + if (char === '"') { + scan.quote = scan.quote === '"' ? null : '"'; + if (scan.quote === '"') appendContainerContext(scan, ' '); + return index; + } + return -1; +} + +function containerDescriptor(input, index, flags) { + const { isSubexpression, isArrayExpression, isScriptBlock } = flags; + return { + ...flags, + isHashtable: isScriptBlock && input[index - 1] === '@', + openingIndex: isSubexpression || isArrayExpression ? index + 1 : index, + open: isScriptBlock ? '{' : '(', + close: isScriptBlock ? '}' : ')', }; +} + +/** + * Classify the live character at `index`. Returns a container descriptor, or + * null after folding a non-container character into the clause context. + */ +function readContainerOpener(scan, index) { + const { input, quote } = scan; + const char = input[index]; + const isSubexpression = char === '$' && input[index + 1] === '('; + if (quote === '"' && !isSubexpression) return null; + + const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; + const isGroupingExpression = !quote && char === '('; + const isScriptBlock = !quote && char === '{'; + if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { + if (!quote) appendContainerContext(scan, char); + return null; + } + const isCmdPayloadGroup = isGroupingExpression && + /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(scan.context)); + if (isCmdPayloadGroup) { + appendContainerContext(scan, char); + return null; + } + return containerDescriptor(input, index, { + isSubexpression, + isArrayExpression, + isGroupingExpression, + isScriptBlock, + }); +} + +function queueCreatedScriptBlock(scan, group, clause, invokedAfter) { + const { options } = scan; + const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test(clause); + if (!createsScriptBlock || !(invokedAfter || options.executeBareScriptBlocks)) return; + const scalarReference = variableReference(group.body); + const scriptText = staticStringResult(group.body) || + (scalarReference ? options.staticScalars?.get(scalarReference) : null); + if (scriptText) { + scan.bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); + } +} + +function queueContainerBody(scan, container, group, info) { + const { options } = scan; + const { isScriptBlock, isHashtable } = container; + const { prefix, invokesContainer, invokedAfter } = info; + const clause = currentClause(prefix); + const shouldScan = scan.contextTruncated || !isScriptBlock || isHashtable || invokedAfter || + isExecutableScriptBlock(prefix, options); + if (shouldScan) { + const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(clause); + scan.bodies.push({ + body: group.body, + options: { + executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || + invokedAfter || executesNestedScriptBlocks || + (!isScriptBlock && invokesContainer), + }, + }); + } else { + const functionName = deferredScriptBlockName(prefix); + if (functionName) scan.deferredFunctions.push({ body: group.body, functionName }); + } + queueCreatedScriptBlock(scan, group, clause, invokedAfter); +} + +function staticContainerCommand(scan, container, group, info) { + const clause = currentClause(info.prefix); + if (container.isSubexpression || info.invokesContainer) { + const output = staticOutputResult(group.body); + if (output !== null || !container.isSubexpression) return output; + const scalarReference = variableReference(group.body); + return scalarReference ? scan.options.staticScalars?.get(scalarReference) ?? null : null; + } + if (/^(?:start-process|saps|start)\b/i.test(clause)) return staticStringArrayResult(group.body); + if (/^new-object\b/i.test(clause)) return staticTypeNameResult(group.body); + if (isPipedToPowerShellStdin(scan.input, group.end)) return staticScalarResult(group.body); + return staticCommandResult(group.body); +} + +function resolveContainerCommand(scan, container, group, info) { + const resolvedCommand = container.isScriptBlock + ? null + : staticContainerCommand(scan, container, group, info); + if (resolvedCommand || container.isScriptBlock) return resolvedCommand; + const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( + maskQuotedStrings(group.body) + ); + const dynamic = invokesDynamicResult(info.prefix, { + groupingExpression: container.isGroupingExpression, + }); + return dynamic && !executableBlockExpression ? DYNAMIC_EXECUTION_MARKER : null; +} + +function recordContainer(scan, container, group, index) { + const withinDoubleQuote = scan.quote === '"'; + const prefix = scan.context; + const invokesContainer = invokesContainerResult(prefix, { + groupingExpression: container.isGroupingExpression, + }); + const invokedAfter = isInvokedAfterContainer(scan.input, group.end); + queueContainerBody(scan, container, group, { prefix, invokesContainer, invokedAfter }); + maskRange(scan.masked, index, group.end); + + const resolvedCommand = resolveContainerCommand(scan, container, group, { prefix, invokesContainer }); + if (resolvedCommand) { + scan.masked[index] = resolvedCommand; + if (!withinDoubleQuote) appendContainerContext(scan, resolvedCommand); + } else if (container.isScriptBlock) { + if (invokesContainer) { + scan.context = prefix; + } else { + resetContainerContext(scan); + } + } else if (!withinDoubleQuote) { + appendContainerContext(scan, ' '); + } +} + +function extractExecutableContainers(input, options = {}) { + const scan = createContainerScan(input, options); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - - if (bracedVariable) { - if (char === '`' && index + 1 < input.length) { - appendContext(input[index + 1]); - index += 1; - } else if (char === '}') { - context += char; - bracedVariable = false; - } else { - appendContext(char); - } + const inertEnd = consumeContainerInertChar(scan, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - if (!quote && index + 1 < input.length) { - const escaped = input[index + 1]; - appendContext(escaped === '\n' || escaped === '\r' ? ' ' : escaped); - if (escaped === '\r' && input[index + 2] === '\n') index += 1; - } - index += 1; - continue; - } + const container = readContainerOpener(scan, index); + if (!container) continue; - if (!quote && char === "'") { - quote = "'"; - appendContext(' '); - continue; - } - - if (!quote && char === '$' && input[index + 1] === '{') { - appendContext('${'); - bracedVariable = true; - index += 1; - continue; - } - - if (char === '"') { - quote = quote === '"' ? null : '"'; - if (quote === '"') appendContext(' '); - continue; - } - - const isSubexpression = char === '$' && input[index + 1] === '('; - if (quote === '"' && !isSubexpression) continue; - - const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; - const isGroupingExpression = !quote && char === '('; - const isScriptBlock = !quote && char === '{'; - const isHashtable = isScriptBlock && input[index - 1] === '@'; - const isCmdPayloadGroup = isGroupingExpression && - /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(context)); - if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { - if (!quote) appendContext(char); - continue; - } - if (isCmdPayloadGroup) { - appendContext(char); - continue; - } - - const openingIndex = isSubexpression || isArrayExpression ? index + 1 : index; - const open = isScriptBlock ? '{' : '('; - const close = isScriptBlock ? '}' : ')'; - const group = readBalancedGroup(input, openingIndex, open, close); + const group = readBalancedGroup(input, container.openingIndex, container.open, container.close); if (!group) { - for (let offset = index; offset < input.length; offset += 1) masked[offset] = ' '; + maskRange(scan.masked, index, input.length); break; } - - const withinDoubleQuote = quote === '"'; - const prefix = context; - const invokesContainer = invokesContainerResult(prefix, { - groupingExpression: isGroupingExpression, - }); - const invokedAfter = isInvokedAfterContainer(input, group.end); - const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test( - currentClause(prefix) - ); - const shouldScan = contextTruncated || !isScriptBlock || isHashtable || invokedAfter || - isExecutableScriptBlock(prefix, options); - if (shouldScan) { - const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(currentClause(prefix)); - bodies.push({ - body: group.body, - options: { - executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || - invokedAfter || executesNestedScriptBlocks || - (!isScriptBlock && invokesContainer), - }, - }); - } else { - const functionName = deferredScriptBlockName(prefix); - if (functionName) deferredFunctions.push({ body: group.body, functionName }); - } - if (createsScriptBlock && (invokedAfter || options.executeBareScriptBlocks)) { - const scalarReference = variableReference(group.body); - const scriptText = staticStringResult(group.body) || - (scalarReference ? options.staticScalars?.get(scalarReference) : null); - if (scriptText) { - bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); - } - } - for (let offset = index; offset < group.end; offset += 1) { - masked[offset] = ' '; - } - let resolvedCommand = null; - if (!isScriptBlock) { - if (isSubexpression || invokesContainer) { - resolvedCommand = staticOutputResult(group.body); - if (resolvedCommand === null && isSubexpression) { - const scalarReference = variableReference(group.body); - if (scalarReference) { - resolvedCommand = options.staticScalars?.get(scalarReference) ?? null; - } - } - } else if (/^(?:start-process|saps|start)\b/i.test(currentClause(prefix))) { - resolvedCommand = staticStringArrayResult(group.body); - } else if (/^new-object\b/i.test(currentClause(prefix))) { - resolvedCommand = staticTypeNameResult(group.body); - } else if (isPipedToPowerShellStdin(input, group.end)) { - resolvedCommand = staticScalarResult(group.body); - } else { - resolvedCommand = staticCommandResult(group.body); - } - } - const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( - maskQuotedStrings(group.body) - ); - if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, { - groupingExpression: isGroupingExpression, - }) && !executableBlockExpression) { - resolvedCommand = DYNAMIC_EXECUTION_MARKER; - } - if (resolvedCommand) { - masked[index] = resolvedCommand; - if (!withinDoubleQuote) appendContext(resolvedCommand); - } else if (isScriptBlock) { - if (invokesContainer) { - context = prefix; - } else { - resetContext(); - } - } else if (!withinDoubleQuote) { - appendContext(' '); - } + recordContainer(scan, container, group, index); index = group.end - 1; } - return { bodies, deferredFunctions, outer: masked.join('') }; + return { bodies: scan.bodies, deferredFunctions: scan.deferredFunctions, outer: scan.masked.join('') }; } /** @@ -939,181 +964,226 @@ function extractExecutableContainers(input, options = {}) { * are interpreted before token comparison so `Rem`ove-Item` normalizes to the * command PowerShell executes. Backslashes remain ordinary characters. */ -function parseStatements(input) { - const statements = []; - let statement = []; - let segment = []; - let segmentQuotedTokens = []; - let segmentQuoteKinds = []; - let segmentTokenSources = []; - let segmentInlineValueQuoteKinds = []; - let word = ''; - let wordSource = ''; - let wordHasQuotedContent = false; - let wordHasUnquotedContent = false; - let wordQuoteKind = null; - let wordInlineValueQuoteKind = null; - let wordInlineValueQuoteClosed = false; - let quote = null; - let parenDepth = 0; - let callOperatorPending = false; +function createWordState() { + return { + text: '', + source: '', + hasQuotedContent: false, + hasUnquotedContent: false, + quoteKind: null, + inlineValueQuoteKind: null, + inlineValueQuoteClosed: false, + }; +} - const flushWord = () => { - if (word) { - segment.push(word); - segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent); - segmentQuoteKinds.push( - wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null - ); - segmentTokenSources.push(wordSource); - segmentInlineValueQuoteKinds.push( - wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed' - ? wordInlineValueQuoteKind - : null - ); +function createSegmentState() { + return { + tokens: [], + quotedTokens: [], + quoteKinds: [], + tokenSources: [], + inlineValueQuoteKinds: [], + }; +} + +function createStatementParser() { + return { + statements: [], + statement: [], + segment: createSegmentState(), + word: createWordState(), + quote: null, + parenDepth: 0, + callOperatorPending: false, + }; +} + +function markInlineValueMixed(word) { + if (word.inlineValueQuoteClosed) word.inlineValueQuoteKind = 'mixed'; +} + +function appendUnquotedChar(word, char) { + word.text += char; + word.source += char; + word.hasUnquotedContent = true; +} + +function appendQuotedChar(word, char) { + word.text += char; + word.source += char; + word.hasQuotedContent = true; +} + +function flushWord(parser) { + const { word, segment } = parser; + if (word.text) { + const quotedOnly = word.hasQuotedContent && !word.hasUnquotedContent; + segment.tokens.push(word.text); + segment.quotedTokens.push(quotedOnly); + segment.quoteKinds.push(quotedOnly ? word.quoteKind : null); + segment.tokenSources.push(word.source); + segment.inlineValueQuoteKinds.push( + word.inlineValueQuoteClosed && word.inlineValueQuoteKind !== 'mixed' + ? word.inlineValueQuoteKind + : null + ); + } + parser.word = createWordState(); +} + +function flushSegment(parser) { + flushWord(parser); + const { segment } = parser; + if (segment.tokens.length) { + Object.defineProperties(segment.tokens, { + invokedByCallOperator: { value: parser.callOperatorPending }, + quotedTokens: { value: segment.quotedTokens }, + quoteKinds: { value: segment.quoteKinds }, + tokenSources: { value: segment.tokenSources }, + inlineValueQuoteKinds: { value: segment.inlineValueQuoteKinds }, + }); + parser.statement.push(segment.tokens); + parser.callOperatorPending = false; + } + parser.segment = createSegmentState(); +} + +function flushStatement(parser) { + flushSegment(parser); + if (parser.statement.length) parser.statements.push(parser.statement); + parser.statement = []; +} + +// Each consume* helper returns the last input index it consumed. +function consumeSingleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === "'" && input[index + 1] === "'") { + word.text += "'"; + word.source += "''"; + return index + 1; + } + if (char === "'") { + parser.quote = null; + if (word.inlineValueQuoteKind === "'") word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeDoubleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === '"') { + parser.quote = null; + if (word.inlineValueQuoteKind === '"') word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeBacktickEscape(parser, input, index) { + const { word } = parser; + if (index + 1 >= input.length) { + word.text += '`'; + word.source += '`'; + return index; + } + const escaped = input[index + 1]; + word.source += `\`${escaped}`; + if (escaped === '\n' || escaped === '\r') { + if (escaped === '\r' && input[index + 2] === '\n') { + word.source += '\n'; + return index + 2; } - word = ''; - wordSource = ''; - wordHasQuotedContent = false; - wordHasUnquotedContent = false; - wordQuoteKind = null; - wordInlineValueQuoteKind = null; - wordInlineValueQuoteClosed = false; - }; - const flushSegment = () => { - flushWord(); - if (segment.length) { - Object.defineProperties(segment, { - invokedByCallOperator: { value: callOperatorPending }, - quotedTokens: { value: segmentQuotedTokens }, - quoteKinds: { value: segmentQuoteKinds }, - tokenSources: { value: segmentTokenSources }, - inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds }, - }); - statement.push(segment); - callOperatorPending = false; - } - segment = []; - segmentQuotedTokens = []; - segmentQuoteKinds = []; - segmentTokenSources = []; - segmentInlineValueQuoteKinds = []; - }; - const flushStatement = () => { - flushSegment(); - if (statement.length) statements.push(statement); - statement = []; - }; + return index + 1; + } + markInlineValueMixed(word); + word.text += escaped; + if (parser.quote) word.hasQuotedContent = true; + else word.hasUnquotedContent = true; + return index + 1; +} + +function openWordQuote(parser, char) { + const { word } = parser; + if (word.inlineValueQuoteClosed) { + word.inlineValueQuoteKind = 'mixed'; + } else if (word.inlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word.text)) { + word.inlineValueQuoteKind = char; + } + parser.quote = char; + word.hasQuotedContent = true; + word.quoteKind = word.quoteKind === null || word.quoteKind === char ? char : 'mixed'; +} + +// Parentheses, statement and pipeline separators, the call operator, and +// whitespace. Returns false when `char` is an ordinary word character. +function consumeStructuralChar(parser, char) { + if (char === '(') { + markInlineValueMixed(parser.word); + parser.parenDepth += 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (char === ')' && parser.parenDepth > 0) { + markInlineValueMixed(parser.word); + parser.parenDepth -= 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (parser.parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { + flushStatement(parser); + return true; + } + if (parser.parenDepth === 0 && char === '|') { + flushSegment(parser); + return true; + } + if (parser.parenDepth === 0 && char === '&') { + if (parser.word.text || parser.segment.tokens.length) flushStatement(parser); + parser.callOperatorPending = true; + return true; + } + if (/\s/.test(char)) { + flushWord(parser); + return true; + } + return false; +} + +function parseStatements(input) { + const parser = createStatementParser(); for (let index = 0; index < input.length; index += 1) { const char = input[index]; - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - word += "'"; - wordSource += "''"; - index += 1; - } else if (char === "'") { - quote = null; - if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === "'") { + index = consumeSingleQuotedChar(parser, input, index); continue; } - if (char === '`') { - if (index + 1 >= input.length) { - word += '`'; - wordSource += '`'; - continue; - } - const escaped = input[index + 1]; - wordSource += `\`${escaped}`; - index += 1; - if (escaped === '\n' || escaped === '\r') { - if (escaped === '\r' && input[index + 1] === '\n') { - wordSource += '\n'; - index += 1; - } - } else { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += escaped; - if (quote) wordHasQuotedContent = true; - else wordHasUnquotedContent = true; - } + index = consumeBacktickEscape(parser, input, index); continue; } - - if (quote === '"') { - if (char === '"') { - quote = null; - if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === '"') { + index = consumeDoubleQuotedChar(parser, input, index); continue; } - if (char === "'" || char === '"') { - if (wordInlineValueQuoteClosed) { - wordInlineValueQuoteKind = 'mixed'; - } else if (wordInlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word)) { - wordInlineValueQuoteKind = char; - } - quote = char; - wordHasQuotedContent = true; - wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed'; + openWordQuote(parser, char); continue; } + if (consumeStructuralChar(parser, char)) continue; - if (char === '(') { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth += 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - if (char === ')' && parenDepth > 0) { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth -= 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - - if (parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { - flushStatement(); - continue; - } - if (parenDepth === 0 && char === '|') { - flushSegment(); - continue; - } - if (parenDepth === 0 && char === '&') { - if (word || segment.length) flushStatement(); - callOperatorPending = true; - continue; - } - if (/\s/.test(char)) { - flushWord(); - continue; - } - - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += char; - wordSource += char; - wordHasUnquotedContent = true; + markInlineValueMixed(parser.word); + appendUnquotedChar(parser.word, char); } - flushStatement(); - return statements; + flushStatement(parser); + return parser.statements; } function maskQuotedStrings(input) { @@ -1267,84 +1337,101 @@ function staticPipelineInput(tokens, state, findings) { return null; } +function scanEncodedCommandFlag(tokens, index, scan) { + const { findings, scanState } = scan; + const token = tokens[index]; + const inlinePayload = parameterValue(token); + let encodedPayload = inlinePayload || tokens[index + 1]; + const payloadIndex = index + 1; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if ((inlinePayload && inlineQuoteKind !== "'") || + (!inlinePayload && encodedPayload && quoteKind !== "'")) { + const source = inlinePayload + ? parameterValue(tokens.tokenSources?.[index] || token) + : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; + const expanded = expandStaticDoubleQuotedString( + source || encodedPayload, + scanState, + findings + ); + if (expanded === null) return; + encodedPayload = expanded; + } + const decoded = decodeUtf16LeBase64(encodedPayload); + if (decoded !== null) addNestedScan(decoded, scan.depth, findings, scan.analysis, {}, scanState); +} + +// Inline `-Command:value` payloads. Returns null when expansion is unresolved. +function resolveInlineCommandPayload(tokens, index, scan) { + const token = tokens[index]; + const inlinePayload = parameterValue(token); + const rest = tokens.slice(index + 1); + if (tokens.inlineValueQuoteKinds?.[index] === "'") return [inlinePayload, ...rest].join(' '); + const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); + const expanded = expandStaticDoubleQuotedString( + inlineSource || inlinePayload, + scan.scanState, + scan.findings + ); + return expanded === null ? null : [expanded, ...rest].join(' '); +} + +// `-Command value` payloads. Returns null when expansion is unresolved. +function resolvePositionalCommandPayload(tokens, payloadIndex, payload, scan) { + const { findings, scanState } = scan; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + if (tokens[payloadIndex] && quoteKind !== "'") { + const expanded = expandStaticDoubleQuotedString( + tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], + scanState, + findings + ); + return expanded === null ? null : [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); + } + const payloadReference = quoteKind === "'" ? null : variableReference(payload); + if (!payloadReference) return payload; + const staticValue = scanState?.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return null; + } + return staticValue; +} + +function scanCommandFlag(tokens, index, scan, upstreamTokens) { + const { findings, scanState } = scan; + const inlinePayload = parameterValue(tokens[index]); + const rawPayload = inlinePayload + ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') + : tokens.slice(index + 1).join(' '); + const pipelinePayload = rawPayload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; + const payload = inlinePayload + ? resolveInlineCommandPayload(tokens, index, scan) + : resolvePositionalCommandPayload(tokens, index + 1, rawPayload, scan); + if (payload === null) return; + if (pipelinePayload || (payload && payload !== '-')) { + addNestedScan( + pipelinePayload || payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } +} + function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upstreamTokens = null) { + const scan = { depth, findings, analysis, scanState }; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; - if (isEncodedCommandFlag(token)) { - const inlinePayload = parameterValue(token); - let encodedPayload = inlinePayload || tokens[index + 1]; - const payloadIndex = index + 1; - const quoteKind = tokens.quoteKinds?.[payloadIndex]; - const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; - if ((inlinePayload && inlineQuoteKind !== "'") || - (!inlinePayload && encodedPayload && quoteKind !== "'")) { - const source = inlinePayload - ? parameterValue(tokens.tokenSources?.[index] || token) - : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; - const expanded = expandStaticDoubleQuotedString( - source || encodedPayload, - scanState, - findings - ); - if (expanded === null) return; - encodedPayload = expanded; - } - const decoded = decodeUtf16LeBase64(encodedPayload); - if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState); + scanEncodedCommandFlag(tokens, index, scan); return; } - if (isCommandFlag(token)) { - const inlinePayload = parameterValue(token); - let payload = inlinePayload - ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') - : tokens.slice(index + 1).join(' '); - const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; - const payloadIndex = index + 1; - const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; - if (inlinePayload && inlineQuoteKind !== "'") { - const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); - const expanded = expandStaticDoubleQuotedString( - inlineSource || inlinePayload, - scanState, - findings - ); - if (expanded === null) return; - payload = [expanded, ...tokens.slice(index + 1)].join(' '); - } else if (inlinePayload) { - payload = [inlinePayload, ...tokens.slice(index + 1)].join(' '); - } else if (tokens[payloadIndex] && tokens.quoteKinds?.[payloadIndex] !== "'") { - const expanded = expandStaticDoubleQuotedString( - tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], - scanState, - findings - ); - if (expanded === null) return; - payload = [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); - } else { - const payloadReference = tokens.quoteKinds?.[payloadIndex] === "'" - ? null - : variableReference(payload); - if (payloadReference) { - const staticValue = scanState?.staticScalars.get(payloadReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - payload = staticValue; - } - } - if (pipelinePayload || (payload && payload !== '-')) { - addNestedScan( - pipelinePayload || payload, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - scanState - ); - } + scanCommandFlag(tokens, index, scan, upstreamTokens); return; } } @@ -1377,111 +1464,105 @@ function splitCmdSegments(payload) { return segments; } -function scanCmdWords(inputWords, depth, findings, analysis, scanState, wrapperDepth = 0) { - if (wrapperDepth > 64) { - findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); - return; - } - - let words = inputWords.filter(Boolean).map(word => String(word)); - if (words.length === 0) return; - words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); - words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); - +function stripCmdRedirections(words) { while (words.length > 0 && /^\d*(?:>>?|<>?|< index > 0 && /^else$/i.test(word)); + const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); + scanCmdWords(trueBranch.slice(cmdIfCommandIndex(trueBranch)), scan, wrapperDepth + 1); + if (elseIndex !== -1) { + scanCmdWords(words.slice(elseIndex + 1), scan, wrapperDepth + 1); + } +} + +// Words after `start [options] [title] ...`, skipping options that take a +// value and a leading window title when a known command follows it. +function cmdStartTarget(inputWords) { + let words = inputWords.slice(1); + while (words.length > 0 && /^\//.test(words[0])) { + const option = words.shift().toLowerCase(); + if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); + } + const knownCommands = new Set([ + ...CMD_DELETE_COMMANDS, + ...POWERSHELL_COMMANDS, + 'call', + 'cmd', + 'for', + 'if', + 'start', + ]); + if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { + const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); + if (commandIndex > 0) words = words.slice(commandIndex); + } + return words; +} + +function scanCmdWords(inputWords, scan, wrapperDepth = 0) { + if (wrapperDepth > 64) { + scan.findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + + const words = inputWords.filter(Boolean).map(word => String(word)); + if (words.length === 0) return; + words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); + words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); + stripCmdRedirections(words); if (words.length === 0) return; - let firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); + const firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); if (firstCommand === 'if') { - const elseIndex = words.findIndex((word, index) => index > 0 && /^else$/i.test(word)); - const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); - let commandIndex = 1; - if (/^\/i$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^not$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^(?:exist|defined|errorlevel|cmdextversion)$/i.test(trueBranch[commandIndex])) { - commandIndex += 2; - } else if (/^(?:equ|neq|lss|leq|gtr|geq)$/i.test(trueBranch[commandIndex + 1])) { - commandIndex += 3; - } else { - commandIndex += 1; - } - scanCmdWords( - trueBranch.slice(commandIndex), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - if (elseIndex !== -1) { - scanCmdWords( - words.slice(elseIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + scanCmdIf(words, scan, wrapperDepth); return; } if (firstCommand === 'for') { const doIndex = words.findIndex(word => /^do$/i.test(word)); - if (doIndex !== -1) { - scanCmdWords( - words.slice(doIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + if (doIndex !== -1) scanCmdWords(words.slice(doIndex + 1), scan, wrapperDepth + 1); return; } if (firstCommand === 'call') { - scanCmdWords(words.slice(1), depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(words.slice(1), scan, wrapperDepth + 1); return; } if (firstCommand === 'start') { - words = words.slice(1); - while (words.length > 0 && /^\//.test(words[0])) { - const option = words.shift().toLowerCase(); - if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); - } - const knownCommands = new Set([ - ...CMD_DELETE_COMMANDS, - ...POWERSHELL_COMMANDS, - 'call', - 'cmd', - 'for', - 'if', - 'start', - ]); - if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { - const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); - if (commandIndex > 0) words = words.slice(commandIndex); - } - scanCmdWords(words, depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(cmdStartTarget(words), scan, wrapperDepth + 1); return; } if (POWERSHELL_COMMANDS.has(firstCommand) || firstCommand === 'cmd') { addNestedScan( [firstCommand, ...words.slice(1)].join(' '), - depth, - findings, - analysis, + scan.depth, + scan.findings, + scan.analysis, { executeBareScriptBlocks: true }, - scanState + scan.scanState ); return; } if (CMD_DELETE_COMMANDS.has(firstCommand) && words.slice(1).some(word => /^[-/]s$/i.test(word))) { - findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); + scan.findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); } } @@ -1493,14 +1574,9 @@ function scanCmd(tokens, depth, findings, analysis, scanState) { .slice(flagIndex + 1) .filter(token => token !== '--%') .join(' '); + const scan = { depth, findings, analysis, scanState }; for (const segment of splitCmdSegments(payload)) { - scanCmdWords( - segment.trim().split(/\s+/), - depth, - findings, - analysis, - scanState - ); + scanCmdWords(segment.trim().split(/\s+/), scan); } } @@ -1558,41 +1634,43 @@ function normalizeArgumentList(parts) { return payload.replace(/\s*,\s*/g, ' ').trim(); } -function scanStartProcess(tokens, depth, findings, analysis, scanState) { - const command = commandBasename(tokens[0]); - if (!['start-process', 'saps', 'start'].includes(command)) return; - if (tokens.slice(1).some((token, index) => - !(tokens.quotedTokens || [])[index + 1] && +function hasSplattedStartProcessArguments(tokens, quotedTokens) { + return tokens.slice(1).some((token, index) => + !quotedTokens[index + 1] && /^@(?:(?:global|script|local|private):)?[A-Za-z_][\w-]*$/i.test(token) - )) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - - let executable = null; - let argumentParts = null; - const quotedTokens = tokens.quotedTokens || []; + ); +} +function startProcessFilePath(tokens, quotedTokens) { for (let index = 1; index < tokens.length; index += 1) { if (quotedTokens[index]) continue; - const parameter = startProcessParameterName(tokens[index]); - if (parameter !== 'filepath') continue; - executable = parameterValue(tokens[index]) || tokens[index + 1] || null; - break; + if (startProcessParameterName(tokens[index]) !== 'filepath') continue; + return parameterValue(tokens[index]) || tokens[index + 1] || null; } + return null; +} +function startProcessArgumentListParts(tokens, quotedTokens, index) { + const inlineValue = parameterValue(tokens[index]); + const end = tokens.findIndex( + (candidate, candidateIndex) => candidateIndex > index && + !quotedTokens[candidateIndex] && startProcessParameterName(candidate) + ); + const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); + return inlineValue ? [inlineValue, ...remaining] : remaining; +} + +// Walk named parameters once: pick up `-ArgumentList` and, when `-FilePath` +// was absent, the first positional value as the executable. +function startProcessNamedTarget(tokens, quotedTokens, filePath) { + let executable = filePath; + let argumentParts = null; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; const quoted = quotedTokens[index] === true; const parameter = quoted ? null : startProcessParameterName(token); if (parameter === 'argumentlist') { - const inlineValue = parameterValue(token); - const end = tokens.findIndex( - (candidate, candidateIndex) => candidateIndex > index && - !quotedTokens[candidateIndex] && startProcessParameterName(candidate) - ); - const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); - argumentParts = inlineValue ? [inlineValue, ...remaining] : remaining; + argumentParts = startProcessArgumentListParts(tokens, quotedTokens, index); break; } if (parameter) { @@ -1600,48 +1678,65 @@ function scanStartProcess(tokens, depth, findings, analysis, scanState) { continue; } if (!quoted && token.startsWith('-')) continue; - if (executable !== null) continue; - if (executable === null) { - executable = token; - } - } - - if (argumentParts === null && executable !== null) { - let executableSeen = false; - for (let index = 1; index < tokens.length; index += 1) { - const token = tokens[index]; - const parameter = quotedTokens[index] ? null : startProcessParameterName(token); - if (parameter) { - if (parameter === 'filepath') executableSeen = true; - if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; - continue; - } - if (!executableSeen && token === executable) { - executableSeen = true; - continue; - } - if (executableSeen) { - argumentParts = tokens.slice(index); - break; - } - if (!quotedTokens[index] && token.startsWith('-')) continue; + if (executable === null) executable = token; + } + return { executable, argumentParts }; +} + +// Positional arguments are everything after the executable once named +// parameters and their values are skipped. +function startProcessPositionalArguments(tokens, quotedTokens, executable) { + let executableSeen = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const parameter = quotedTokens[index] ? null : startProcessParameterName(token); + if (parameter) { + if (parameter === 'filepath') executableSeen = true; + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + continue; } + if (!executableSeen && token === executable) { + executableSeen = true; + continue; + } + if (executableSeen) return tokens.slice(index); + } + return null; +} + +function startProcessTarget(tokens, quotedTokens) { + const filePath = startProcessFilePath(tokens, quotedTokens); + const target = startProcessNamedTarget(tokens, quotedTokens, filePath); + if (target.argumentParts === null && target.executable !== null) { + target.argumentParts = startProcessPositionalArguments(tokens, quotedTokens, target.executable); + } + return target; +} + +function scanStartProcess(tokens, depth, findings, analysis, scanState) { + const command = commandBasename(tokens[0]); + if (!['start-process', 'saps', 'start'].includes(command)) return; + const quotedTokens = tokens.quotedTokens || []; + if (hasSplattedStartProcessArguments(tokens, quotedTokens)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + const { executable, argumentParts } = startProcessTarget(tokens, quotedTokens); const nestedCommand = commandBasename(executable); + if (!POWERSHELL_COMMANDS.has(nestedCommand) && nestedCommand !== 'cmd') return; + let argumentList = argumentParts ? normalizeArgumentList(argumentParts) : ''; - if (POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') { - const argumentReference = variableReference(argumentList); - if (argumentReference) { - const staticValue = scanState.staticScalars.get(argumentReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - argumentList = staticValue; + const argumentReference = variableReference(argumentList); + if (argumentReference) { + const staticValue = scanState.staticScalars.get(argumentReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + argumentList = staticValue; } - if ((POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') && argumentList) { + if (argumentList) { addNestedScan( `${executable} ${argumentList}`, depth, @@ -1868,18 +1963,14 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat } } -function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { - const raw = normalizeSmartQuotes(command); - if (!raw.trim()) return; +const DIRECT_COMMAND_FINDINGS = Object.freeze({ + 'clear-content': RULE_IDS.CLEAR_CONTENT, + clc: RULE_IDS.CLEAR_CONTENT, + 'clear-disk': RULE_IDS.CLEAR_DISK, + 'format-volume': RULE_IDS.FORMAT_VOLUME, +}); - const state = scanState || createScanState(); - - const hereStringExpressions = []; - const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); - const withoutComments = stripPowerShellComments(normalizedHereStrings); - collectStaticScalarAssignments(withoutComments, state); - const unquoted = maskQuotedStrings(withoutComments); - scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); +function scanStaticDotNetCalls(unquoted, findings, state) { if (/\[\s*(?:system\.)?io\.directory\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { findings.add(RULE_IDS.DOTNET_DIRECTORY_DELETE); } @@ -1891,6 +1982,201 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, while ((activatorMatch = activatorPattern.exec(unquoted)) !== null) { recordInvocation(state, `__class__:${activatorMatch[1].toLowerCase()}`); } +} + +function recordInvokedBlockVariables(unquoted, state) { + const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; + let invokedBlockMatch; + while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { + recordInvocation(state, invokedBlockMatch[1].toLowerCase()); + } +} + +function scanAliasInvocation(commandName, tokens, executable, scan) { + const { state } = scan; + const aliasTarget = state.aliases.get(commandName); + if (aliasTarget) { + addNestedScan( + [aliasTarget, ...tokens.slice(1)].join(' '), + scan.depth, + scan.findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } + if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { + for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { + state.aliases.set(definition.name, definition.value); + } + } +} + +function recordNewObjectClass(tokens, quotedTokens, scan) { + const { findings, state } = scan; + let className = newObjectClassName(tokens, quotedTokens); + const classReference = variableReference(className); + if (classReference) { + const staticClassName = state.staticScalars.get(classReference); + if (staticClassName === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + className = null; + } else { + className = staticClassName; + } + } + if (className && /^[A-Za-z_][\w-]*$/.test(className)) { + recordInvocation(state, `__class__:${className.toLowerCase()}`); + } +} + +function recordClassInvocations(commandName, tokens, executable, scan) { + const { state } = scan; + const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); + if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); + if (commandName === 'new-object') recordNewObjectClass(tokens, executable.quotedTokens, scan); + const invokedVariable = commandName.match( + /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i + ); + if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); + if (commandName === '.' && tokens[1]) { + recordInvocation(state, commandBasename(tokens[1])); + } +} + +// `& $variable ...` where the variable holds a static command string. +function scanCallOperatorReference(tokens, upstreamTokens, scan) { + const { findings, state } = scan; + const invokedReference = variableReference(tokens[0]); + if (!invokedReference || state.deferredFunctions.has(invokedReference)) return; + const commandValue = state.staticScalars.get(invokedReference); + if (commandValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { + scanNestedPowerShell( + [commandValue, ...tokens.slice(1)], + scan.depth, + findings, + scan.analysis, + state, + upstreamTokens + ); + } else { + addNestedScan( + [commandValue, ...tokens.slice(1)].join(' '), + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanInvokeExpression(tokens, scan) { + const { findings, state } = scan; + let payload = tokens.slice(1).join(' '); + const payloadReference = variableReference(payload); + if (payloadReference) { + const staticValue = state.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + payload = staticValue; + } + if (payload) { + addNestedScan( + payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanCommandFamily(commandName, tokens, upstreamTokens, scan) { + const { findings, state } = scan; + if (POWERSHELL_COMMANDS.has(commandName)) { + scanNestedPowerShell(tokens, scan.depth, findings, scan.analysis, state, upstreamTokens); + } else if (commandName === 'cmd') { + scanCmd(tokens, scan.depth, findings, scan.analysis, state); + } else if (commandName === 'invoke-expression' || commandName === 'iex') { + scanInvokeExpression(tokens, scan); + } else if (Object.hasOwn(DIRECT_COMMAND_FINDINGS, commandName)) { + findings.add(DIRECT_COMMAND_FINDINGS[commandName]); + } +} + +/** + * Scan one pipeline segment. Returns null for inert segments, otherwise + * whether the segment deletes and whether it carries `-Recurse`. + */ +function scanStatementSegment(statement, index, scan) { + const { findings, state } = scan; + const segmentTokens = statement[index]; + const executable = executableSegment(segmentTokens); + const tokens = executable.tokens; + if (tokens.length === 0) return null; + if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) return null; + + const commandName = commandBasename(tokens[0]); + recordInvocation(state, commandName); + scanAliasInvocation(commandName, tokens, executable, scan); + recordClassInvocations(commandName, tokens, executable, scan); + markPowerShellElevation(tokens, scan.analysis); + scanStartProcess(tokens, scan.depth, findings, scan.analysis, state); + scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); + if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + if (segmentTokens.invokedByCallOperator) { + scanCallOperatorReference(tokens, statement[index - 1], scan); + } + scanCommandFamily(commandName, tokens, statement[index - 1], scan); + + return { + deletes: scanDeleteSegment(tokens, findings, executable.quotedTokens), + recurses: tokens.some( + (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && + isEnabledSwitch(token, 'recurse') + ), + }; +} + +function scanStatement(statement, scan) { + const deleteSegments = new Set(); + const recurseSegments = new Set(); + + for (let index = 0; index < statement.length; index += 1) { + const result = scanStatementSegment(statement, index, scan); + if (!result) continue; + if (result.deletes) deleteSegments.add(index); + if (result.recurses) recurseSegments.add(index); + } + + const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); + if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { + scan.findings.add(RULE_IDS.PIPELINE_RECURSE); + } +} + +function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { + const raw = normalizeSmartQuotes(command); + if (!raw.trim()) return; + + const state = scanState || createScanState(); + const scan = { depth, findings, analysis, state }; + + const hereStringExpressions = []; + const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); + const withoutComments = stripPowerShellComments(normalizedHereStrings); + collectStaticScalarAssignments(withoutComments, state); + const unquoted = maskQuotedStrings(withoutComments); + scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); + scanStaticDotNetCalls(unquoted, findings, state); for (const payload of hereStringExpressions) { addNestedScan(payload, depth, findings, analysis, { executeBareScriptBlocks: true }, state); } @@ -1902,152 +2188,14 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, for (const definition of deferredFunctions) { registerDeferredFunction(state, { ...definition, depth }); } - const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; - let invokedBlockMatch; - while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { - recordInvocation(state, invokedBlockMatch[1].toLowerCase()); - } + recordInvokedBlockVariables(unquoted, state); for (const entry of bodies) { addNestedScan(entry.body, depth, findings, analysis, entry.options, state); } for (const statement of parseStatements(outer)) { - const deleteSegments = new Set(); - const recurseSegments = new Set(); - - for (let index = 0; index < statement.length; index += 1) { - const segmentTokens = statement[index]; - const executable = executableSegment(segmentTokens); - const tokens = executable.tokens; - if (tokens.length === 0) continue; - if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) continue; - const commandName = commandBasename(tokens[0]); - recordInvocation(state, commandName); - const aliasTarget = state.aliases.get(commandName); - if (aliasTarget) { - addNestedScan( - [aliasTarget, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { - for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { - state.aliases.set(definition.name, definition.value); - } - } - const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); - if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); - if (commandName === 'new-object') { - let className = newObjectClassName(tokens, executable.quotedTokens); - const classReference = variableReference(className); - if (classReference) { - const staticClassName = state.staticScalars.get(classReference); - if (staticClassName === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - className = null; - } else { - className = staticClassName; - } - } - if (className && /^[A-Za-z_][\w-]*$/.test(className)) { - recordInvocation(state, `__class__:${className.toLowerCase()}`); - } - } - const invokedVariable = commandName.match( - /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i - ); - if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); - if (commandName === '.' && tokens[1]) { - recordInvocation(state, commandBasename(tokens[1])); - } - markPowerShellElevation(tokens, analysis); - scanStartProcess(tokens, depth, findings, analysis, state); - scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); - if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } - - const invokedReference = segmentTokens.invokedByCallOperator - ? variableReference(tokens[0]) - : null; - if (invokedReference && !state.deferredFunctions.has(invokedReference)) { - const commandValue = state.staticScalars.get(invokedReference); - if (commandValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { - scanNestedPowerShell( - [commandValue, ...tokens.slice(1)], - depth, - findings, - analysis, - state, - statement[index - 1] - ); - } else { - addNestedScan( - [commandValue, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } - - if (POWERSHELL_COMMANDS.has(commandName)) { - scanNestedPowerShell(tokens, depth, findings, analysis, state, statement[index - 1]); - } else if (commandName === 'cmd') { - scanCmd(tokens, depth, findings, analysis, state); - } else if (commandName === 'invoke-expression' || commandName === 'iex') { - let payload = tokens.slice(1).join(' '); - const payloadReference = variableReference(payload); - if (payloadReference) { - const staticValue = state.staticScalars.get(payloadReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - payload = ''; - } else { - payload = staticValue; - } - } - if (payload) { - addNestedScan( - payload, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } else if (commandName === 'clear-content' || commandName === 'clc') { - findings.add(RULE_IDS.CLEAR_CONTENT); - } else if (commandName === 'clear-disk') { - findings.add(RULE_IDS.CLEAR_DISK); - } else if (commandName === 'format-volume') { - findings.add(RULE_IDS.FORMAT_VOLUME); - } - - if (scanDeleteSegment(tokens, findings, executable.quotedTokens)) deleteSegments.add(index); - if (tokens.some( - (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && - isEnabledSwitch(token, 'recurse') - )) { - recurseSegments.add(index); - } - } - - const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); - if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { - findings.add(RULE_IDS.PIPELINE_RECURSE); - } + scanStatement(statement, scan); } - } function resolveDeferredFunctions(findings, analysis, state) { diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index a2241770d..a5dec6fbb 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -94,6 +94,114 @@ function extractCommandSubstitutions(input, options = {}) { return [...iterateCommandSubstitutions(input, options)]; } +// --- Shared span readers ------------------------------------------------------ +// The group extractors below all need the same quote-aware skipping of +// `$(...)`, backtick and `(...)` spans. Each reader takes the index of the +// first character inside the span and returns where scanning should resume. + +function parenDelta(ch) { + if (ch === '(') return 1; + if (ch === ')') return -1; + return 0; +} + +/** + * Toggle quote state for a quote character at the current position. + * Returns true when `ch` opened or closed a quote. + */ +function toggleQuote(ch, prev, quotes) { + if (ch === "'" && !quotes.inDouble && prev !== '\\') { + quotes.inSingle = !quotes.inSingle; + return true; + } + if (ch === '"' && !quotes.inSingle && prev !== '\\') { + quotes.inDouble = !quotes.inDouble; + return true; + } + return false; +} + +/** + * Index just past the `)` that closes a paren span whose first inner character + * is at `start`, honoring quotes and escapes inside the span. + */ +function findParenSpanEnd(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\' && !quotes.inSingle) { + i += 2; + continue; + } + if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { + depth += parenDelta(inner); + } + i += 1; + } + return i; +} + +/** + * Index of the backtick that closes a span whose first inner character is at + * `start`, or `source.length` when the span is unterminated. + */ +function findBacktickEnd(source, start) { + let i = start; + while (i < source.length && source[i] !== '`') { + if (source[i] === '\\' && i + 1 < source.length) { + i += 2; + continue; + } + i += 1; + } + return i; +} + +/** + * Top-level scan step shared by the group extractors: escapes, quote state, + * `$(...)` and backtick spans. Returns the last index consumed so a `for` + * loop's `i++` resumes after it, or -1 when the caller must inspect `source[i]`. + */ +function skipOuterSpan(source, i, quotes) { + const ch = source[i]; + if (ch === '\\' && !quotes.inSingle) return i + 1; + if (toggleQuote(ch, source[i - 1], quotes)) return i; + if (quotes.inSingle || quotes.inDouble) return i; + if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2) - 1; + if (ch === '`') return findBacktickEnd(source, i + 1); + return -1; +} + +/** + * Read a `(...)` subshell body starting just inside the opening paren. Returns + * the body text and the index of the closing paren (or where input ran out). + */ +function readSubshellBody(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let body = ''; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\' && !quotes.inSingle) { + // Keep the escape and its operand together; a trailing backslash at the + // end of an unterminated span is appended once and the scan stops. + body += source.slice(i, i + 2); + i = Math.min(i + 2, source.length); + continue; + } + if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { + depth += parenDelta(inner); + if (depth === 0) break; + } + body += inner; + i += 1; + } + return { body, end: i }; +} + /** * Extract bodies of plain `(...)` subshell groups. * @@ -114,118 +222,89 @@ function extractCommandSubstitutions(input, options = {}) { function extractSubshellGroups(input) { const source = String(input || ''); const groups = []; - let inSingle = false; - let inDouble = false; + const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { - const ch = source[i]; - const prev = source[i - 1]; - - if (ch === '\\' && !inSingle) { - i += 1; + const skipped = skipOuterSpan(source, i, quotes); + if (skipped !== -1) { + i = skipped; continue; } - - if (ch === "'" && !inDouble && prev !== '\\') { - inSingle = !inSingle; - continue; - } - - if (ch === '"' && !inSingle && prev !== '\\') { - inDouble = !inDouble; - continue; - } - - if (inSingle || inDouble) { - continue; - } - - if (ch === '$' && source[i + 1] === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 2; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '`') { - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - i += 2; - continue; - } - i += 1; - } - continue; - } - - if (ch === '(') { - let depth = 1; - let body = ''; - let bodyInSingle = false; - let bodyInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !bodyInSingle) { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { - bodyInSingle = !bodyInSingle; - } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { - bodyInDouble = !bodyInDouble; - } else if (!bodyInSingle && !bodyInDouble) { - if (inner === '(') { - depth += 1; - } else if (inner === ')') { - depth -= 1; - if (depth === 0) { - break; - } - } - } - body += inner; - i += 1; - } - if (body.trim()) { - groups.push(body); - groups.push(...extractSubshellGroups(body)); - } + if (source[i] !== '(') continue; + const { body, end } = readSubshellBody(source, i + 1); + i = end; + if (body.trim()) { + groups.push(body); + groups.push(...extractSubshellGroups(body)); } } return groups; } +/** + * Inside a brace body, `$(...)`, backtick and `(...)` spans are copied through + * verbatim so a `}` inside them cannot close the group. Returns the index just + * past the span, or -1 when `source[i]` does not open one. + */ +function findNestedSpanEnd(source, i) { + const ch = source[i]; + if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2); + if (ch === '`') return Math.min(findBacktickEnd(source, i + 1) + 1, source.length); + if (ch === '(') return findParenSpanEnd(source, i + 1); + return -1; +} + +// `{` is a reserved word only when followed by whitespace and preceded by the +// line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`). +function opensBraceGroup(source, i) { + if (source[i] !== '{' || !/\s/.test(source[i + 1] || '')) return false; + return i === 0 || /[\s;|&(]/.test(source[i - 1]); +} + +/** + * Read a `{ ...; }` brace body starting just after the opening brace. Returns + * the body text and the index of the closing brace (or where input ran out). + */ +function readBraceBody(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let body = ''; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + const prev = source[i - 1]; + if (inner === '\\' && !quotes.inSingle) { + body += source.slice(i, i + 2); + i = Math.min(i + 2, source.length); + continue; + } + if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) { + body += inner; + i += 1; + continue; + } + const spanEnd = findNestedSpanEnd(source, i); + if (spanEnd !== -1) { + body += source.slice(i, spanEnd); + i = spanEnd; + continue; + } + if (inner === '{' && /\s/.test(source[i + 1] || '')) { + // Match the outer-scan boundary rule for nested `{` so tokens like + // `foo{` (no boundary, but followed by space via `foo{ bar`) cannot + // bump nested depth. + if (/[\s;|&(]/.test(prev)) depth += 1; + } else if (inner === '}' && (prev === ';' || /\s/.test(prev))) { + depth -= 1; + if (depth === 0) break; + } + body += inner; + i += 1; + } + return { body, end: i }; +} + /** * Extract bodies of `{ ...; }` brace groups. * @@ -250,228 +329,24 @@ function extractSubshellGroups(input) { function extractBraceGroups(input) { const source = String(input || ''); const groups = []; - let inSingle = false; - let inDouble = false; + const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { - const ch = source[i]; - const prev = source[i - 1]; - - if (ch === '\\' && !inSingle) { - i += 1; + const skipped = skipOuterSpan(source, i, quotes); + if (skipped !== -1) { + i = skipped; continue; } - - if (ch === "'" && !inDouble && prev !== '\\') { - inSingle = !inSingle; + if (source[i] === '(') { + i = findParenSpanEnd(source, i + 1) - 1; continue; } - - if (ch === '"' && !inSingle && prev !== '\\') { - inDouble = !inDouble; - continue; - } - - if (inSingle || inDouble) { - continue; - } - - if (ch === '$' && source[i + 1] === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 2; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '`') { - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - i += 2; - continue; - } - i += 1; - } - continue; - } - - if (ch === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '{' && /\s/.test(source[i + 1] || '')) { - const prevIsBoundary = i === 0 || /[\s;|&(]/.test(prev); - if (!prevIsBoundary) continue; - - let depth = 1; - let body = ''; - let bodyInSingle = false; - let bodyInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !bodyInSingle) { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { - bodyInSingle = !bodyInSingle; - body += inner; - i += 1; - continue; - } - if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { - bodyInDouble = !bodyInDouble; - body += inner; - i += 1; - continue; - } - if (bodyInSingle || bodyInDouble) { - body += inner; - i += 1; - continue; - } - // Skip $(...) spans — a quoted `}` or `}`-as-text inside a - // substitution body must not close the enclosing brace group. - if (inner === '$' && source[i + 1] === '(') { - body += inner + source[i + 1]; - let subDepth = 1; - let subInSingle = false; - let subInDouble = false; - i += 2; - while (i < source.length && subDepth > 0) { - const c = source[i]; - const p = source[i - 1]; - body += c; - if (c === '\\' && !subInSingle && i + 1 < source.length) { - body += source[i + 1]; - i += 2; - continue; - } - if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle; - else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble; - else if (!subInSingle && !subInDouble) { - if (c === '(') subDepth += 1; - else if (c === ')') subDepth -= 1; - } - i += 1; - } - continue; - } - // Skip backtick spans for the same reason. - if (inner === '`') { - body += inner; - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - body += source[i] + source[i + 1]; - i += 2; - continue; - } - body += source[i]; - i += 1; - } - if (i < source.length) { - body += source[i]; - i += 1; - } - continue; - } - // Skip plain (...) subshell spans for the same reason. - if (inner === '(') { - body += inner; - let subDepth = 1; - let subInSingle = false; - let subInDouble = false; - i += 1; - while (i < source.length && subDepth > 0) { - const c = source[i]; - const p = source[i - 1]; - body += c; - if (c === '\\' && !subInSingle && i + 1 < source.length) { - body += source[i + 1]; - i += 2; - continue; - } - if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle; - else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble; - else if (!subInSingle && !subInDouble) { - if (c === '(') subDepth += 1; - else if (c === ')') subDepth -= 1; - } - i += 1; - } - continue; - } - if (inner === '{' && /\s/.test(source[i + 1] || '')) { - // Match the outer-scan boundary rule for nested `{` so - // tokens like `foo{` (no boundary, but followed by space - // via `foo{ bar`) cannot bump nested depth. - const nestedPrevIsBoundary = /[\s;|&(]/.test(innerPrev); - if (nestedPrevIsBoundary) depth += 1; - } else if (inner === '}' && (innerPrev === ';' || /\s/.test(innerPrev))) { - depth -= 1; - if (depth === 0) { - break; - } - } - body += inner; - i += 1; - } - if (body.trim()) { - groups.push(body); - groups.push(...extractBraceGroups(body)); - } + if (!opensBraceGroup(source, i)) continue; + const { body, end } = readBraceBody(source, i + 1); + i = end; + if (body.trim()) { + groups.push(body); + groups.push(...extractBraceGroups(body)); } } diff --git a/scripts/operator-readiness-dashboard.js b/scripts/operator-readiness-dashboard.js index 4de990840..6f4f393ef 100644 --- a/scripts/operator-readiness-dashboard.js +++ b/scripts/operator-readiness-dashboard.js @@ -63,6 +63,36 @@ function normalizeRelativePrefix(value) { return normalized ? `${normalized}/` : ''; } +// Flags that take a value, as `--flag value` or `--flag=value`. +const VALUE_FLAGS = Object.freeze({ + '--format': (parsed, value) => { parsed.format = value.toLowerCase(); }, + '--write': (parsed, value) => { parsed.writePath = path.resolve(value); }, + '--root': (parsed, value) => { parsed.root = path.resolve(value); }, + '--repo': (parsed, value) => { parsed.repos.push(value); }, + '--allow-untracked': (parsed, value) => { parsed.allowUntracked.push(value); }, + '--max-open-prs': (parsed, value) => { + parsed.thresholds.maxOpenPrs = parseIntegerFlag(value, '--max-open-prs'); + }, + '--max-open-issues': (parsed, value) => { + parsed.thresholds.maxOpenIssues = parseIntegerFlag(value, '--max-open-issues'); + }, + '--max-dirty-files': (parsed, value) => { + parsed.thresholds.maxDirtyFiles = parseIntegerFlag(value, '--max-dirty-files'); + }, + '--generated-at': (parsed, value) => { parsed.generatedAt = value; }, +}); + +// Flags that stand alone. They never accept an inline `=value`. +const BOOLEAN_FLAGS = Object.freeze({ + '--help': parsed => { parsed.help = true; }, + '-h': parsed => { parsed.help = true; }, + '--json': parsed => { parsed.format = 'json'; }, + '--markdown': parsed => { parsed.format = 'markdown'; }, + '--skip-github': parsed => { parsed.skipGithub = true; }, + '--use-env-github-token': parsed => { parsed.useEnvGithubToken = true; }, + '--exit-code': parsed => { parsed.exitCode = true; }, +}); + function parseArgs(argv) { const args = argv.slice(2); const parsed = { @@ -81,133 +111,18 @@ function parseArgs(argv) { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + const separator = arg.indexOf('='); + const name = separator === -1 ? arg : arg.slice(0, separator); - if (arg === '--help' || arg === '-h') { - parsed.help = true; + if (Object.hasOwn(VALUE_FLAGS, name)) { + const value = separator === -1 ? readValue(args, index, arg) : arg.slice(separator + 1); + if (separator === -1) index += 1; + VALUE_FLAGS[name](parsed, value); continue; } - if (arg === '--format') { - parsed.format = readValue(args, index, arg).toLowerCase(); - index += 1; - continue; - } - - if (arg.startsWith('--format=')) { - parsed.format = arg.slice('--format='.length).toLowerCase(); - continue; - } - - if (arg === '--json') { - parsed.format = 'json'; - continue; - } - - if (arg === '--markdown') { - parsed.format = 'markdown'; - continue; - } - - if (arg === '--write') { - parsed.writePath = path.resolve(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--write=')) { - parsed.writePath = path.resolve(arg.slice('--write='.length)); - continue; - } - - if (arg === '--root') { - parsed.root = path.resolve(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--root=')) { - parsed.root = path.resolve(arg.slice('--root='.length)); - continue; - } - - if (arg === '--repo') { - parsed.repos.push(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--repo=')) { - parsed.repos.push(arg.slice('--repo='.length)); - continue; - } - - if (arg === '--skip-github') { - parsed.skipGithub = true; - continue; - } - - if (arg === '--allow-untracked') { - parsed.allowUntracked.push(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--allow-untracked=')) { - parsed.allowUntracked.push(arg.slice('--allow-untracked='.length)); - continue; - } - - if (arg === '--max-open-prs') { - parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-open-prs=')) { - parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs'); - continue; - } - - if (arg === '--max-open-issues') { - parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-open-issues=')) { - parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues'); - continue; - } - - if (arg === '--max-dirty-files') { - parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-dirty-files=')) { - parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files'); - continue; - } - - if (arg === '--use-env-github-token') { - parsed.useEnvGithubToken = true; - continue; - } - - if (arg === '--generated-at') { - parsed.generatedAt = readValue(args, index, arg); - index += 1; - continue; - } - - if (arg.startsWith('--generated-at=')) { - parsed.generatedAt = arg.slice('--generated-at='.length); - continue; - } - - if (arg === '--exit-code') { - parsed.exitCode = true; + if (separator === -1 && Object.hasOwn(BOOLEAN_FLAGS, arg)) { + BOOLEAN_FLAGS[arg](parsed); continue; } @@ -341,129 +256,72 @@ function agentShieldEnterpriseEvidence(roadmap) { return 'AgentShield enterprise PR evidence is mirrored in the GA roadmap'; } +// Roadmap markers, newest milestone first. Each tier is [markers, message] +// and the first tier with any marker present wins. +const ANNOUNCEMENT_GATE_READY_MARKERS = [ + 'announcementGateReady` is `true', + 'Native GitHub payments announcement gate is ready', + 'd3d62df83fa075660fa4530c3e0edc311a4355fe', +]; +const SELECTED_TARGET_GATE_MARKERS = [ + 'selected-target official announcement gate', + 'billing gate env-file operator path', + '72119a1', + '16a5bb3', + 'select-ready-target', + 'f14ed2fe-a219-470c-8119-63429e197027', +]; +const PROVENANCE_COUNT_MARKERS = ['d5f60db', 'Marketplace-source provenance counts']; +const TARGET_ACCOUNT_READBACK_MARKERS = ['target account billing readback', '632e059']; +const WRANGLER_OAUTH_READBACK_MARKERS = ['Wrangler OAuth readback', '42653f9']; +const WEBHOOK_PROVENANCE_MARKERS = ['Marketplace webhook provenance', '2859678']; +const KV_READBACK_MARKERS = ['billing:kv-readback', '95d0bec']; +const PRODUCTION_READBACK_MARKERS = ['production Marketplace readback state', 'eb69412']; +const PROMOTION_TRACE_MARKERS = [ + 'hosted promotion judge audit traces', + 'operator-visible promotion output values', +]; + +const ECC_TOOLS_EVIDENCE_TIERS = [ + [ANNOUNCEMENT_GATE_READY_MARKERS, 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, non-breaking operator bearer path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler selected-target readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [SELECTED_TARGET_GATE_MARKERS, 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [['69ca535', 'team feedback controls', 'e56fc1a'], 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [PROVENANCE_COUNT_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, and sanitized Marketplace plan/action provenance counts are mirrored in the GA roadmap'], + [TARGET_ACCOUNT_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [WRANGLER_OAUTH_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [WEBHOOK_PROVENANCE_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [KV_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and aggregate production billing KV readback are mirrored in the GA roadmap'], + [PRODUCTION_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and production KV readback state are mirrored in the GA roadmap'], + [PROMOTION_TRACE_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, and hosted promotion judge audit traces are mirrored in the GA roadmap'], +]; +const ECC_TOOLS_EVIDENCE_FALLBACK = 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, and harness-route policy linking are mirrored in the GA roadmap'; + +const ECC_TOOLS_GAP_TIERS = [ + [ANNOUNCEMENT_GATE_READY_MARKERS, 'repeat KV readback and selected-target announcement gate immediately before launch; keep native-payments copy behind the final release, plugin, URL, and owner-approval gates'], + [[...SELECTED_TARGET_GATE_MARKERS, 'old "no Marketplace-managed Pro target billing-state" blocker is cleared'], 'obtain or rotate the local/internal INTERNAL_API_SECRET bearer-token path, via exported env or ignored --env-file, then run the live selected-target billing announcement gate before publishing native-payments copy'], + [['1Password CLI authorization timed out', 'Cloudflare API auth returned `Authentication error [code: 10000]`'], 'authorize Cloudflare API or 1Password CLI access, configure the target Marketplace Pro account and INTERNAL_API_SECRET, create or replay Marketplace Pro webhook state, then rerun target readback and the live announcement gate'], + [['Wrangler OAuth now works', '6904e4fb-bec7-4787-90e2-759f077a628c'], 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, configure the target account and INTERNAL_API_SECRET, then rerun target readback and the live announcement gate'], + [PROVENANCE_COUNT_MARKERS, 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --wrangler --wrangler-bin ./node_modules/.bin/wrangler --account --require-ready`, followed by the live announcement gate'], + [TARGET_ACCOUNT_READBACK_MARKERS, 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --account --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'], + [WRANGLER_OAUTH_READBACK_MARKERS, 'create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'], + [WEBHOOK_PROVENANCE_MARKERS, 'replace the invalid Cloudflare credential, create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` and the live announcement gate'], + [KV_READBACK_MARKERS, 'create or verify a Marketplace-managed Pro billing-state, then run the official live announcement gate'], + [PRODUCTION_READBACK_MARKERS, 'complete Marketplace purchase/webhook readback, then run the live announcement gate'], + [PROMOTION_TRACE_MARKERS, 'live Marketplace test-account readback pending'], +]; +const ECC_TOOLS_GAP_FALLBACK = 'live Marketplace test-account readback, hosted promotion telemetry, and richer operator review UX pending'; + +function roadmapTierMessage(roadmap, tiers, fallback) { + const tier = tiers.find(([markers]) => markers.some(marker => roadmap.includes(marker))); + return tier ? tier[1] : fallback; +} + function eccToolsNextLevelEvidence(roadmap) { - if (roadmap.includes('announcementGateReady` is `true') - || roadmap.includes('Native GitHub payments announcement gate is ready') - || roadmap.includes('d3d62df83fa075660fa4530c3e0edc311a4355fe')) { - return 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, non-breaking operator bearer path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler selected-target readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('selected-target official announcement gate') - || roadmap.includes('billing gate env-file operator path') - || roadmap.includes('72119a1') - || roadmap.includes('16a5bb3') - || roadmap.includes('select-ready-target') - || roadmap.includes('f14ed2fe-a219-470c-8119-63429e197027')) { - return 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('69ca535') - || roadmap.includes('team feedback controls') - || roadmap.includes('e56fc1a')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('d5f60db') - || roadmap.includes('Marketplace-source provenance counts')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, and sanitized Marketplace plan/action provenance counts are mirrored in the GA roadmap'; - } - - if (roadmap.includes('target account billing readback') - || roadmap.includes('632e059')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('Wrangler OAuth readback') - || roadmap.includes('42653f9')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('Marketplace webhook provenance') - || roadmap.includes('2859678')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('billing:kv-readback') - || roadmap.includes('95d0bec')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and aggregate production billing KV readback are mirrored in the GA roadmap'; - } - - if (roadmap.includes('production Marketplace readback state') - || roadmap.includes('eb69412')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and production KV readback state are mirrored in the GA roadmap'; - } - - if (roadmap.includes('hosted promotion judge audit traces') - || roadmap.includes('operator-visible promotion output values')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, and hosted promotion judge audit traces are mirrored in the GA roadmap'; - } - - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, and harness-route policy linking are mirrored in the GA roadmap'; + return roadmapTierMessage(roadmap, ECC_TOOLS_EVIDENCE_TIERS, ECC_TOOLS_EVIDENCE_FALLBACK); } function eccToolsNextLevelGap(roadmap) { - if (roadmap.includes('announcementGateReady` is `true') - || roadmap.includes('Native GitHub payments announcement gate is ready') - || roadmap.includes('d3d62df83fa075660fa4530c3e0edc311a4355fe')) { - return 'repeat KV readback and selected-target announcement gate immediately before launch; keep native-payments copy behind the final release, plugin, URL, and owner-approval gates'; - } - - if (roadmap.includes('selected-target official announcement gate') - || roadmap.includes('billing gate env-file operator path') - || roadmap.includes('72119a1') - || roadmap.includes('16a5bb3') - || roadmap.includes('select-ready-target') - || roadmap.includes('f14ed2fe-a219-470c-8119-63429e197027') - || roadmap.includes('old "no Marketplace-managed Pro target billing-state" blocker is cleared')) { - return 'obtain or rotate the local/internal INTERNAL_API_SECRET bearer-token path, via exported env or ignored --env-file, then run the live selected-target billing announcement gate before publishing native-payments copy'; - } - - if (roadmap.includes('1Password CLI authorization timed out') - || roadmap.includes('Cloudflare API auth returned `Authentication error [code: 10000]`')) { - return 'authorize Cloudflare API or 1Password CLI access, configure the target Marketplace Pro account and INTERNAL_API_SECRET, create or replay Marketplace Pro webhook state, then rerun target readback and the live announcement gate'; - } - - if (roadmap.includes('Wrangler OAuth now works') - || roadmap.includes('6904e4fb-bec7-4787-90e2-759f077a628c')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, configure the target account and INTERNAL_API_SECRET, then rerun target readback and the live announcement gate'; - } - - if (roadmap.includes('d5f60db') - || roadmap.includes('Marketplace-source provenance counts')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --wrangler --wrangler-bin ./node_modules/.bin/wrangler --account --require-ready`, followed by the live announcement gate'; - } - - if (roadmap.includes('target account billing readback') - || roadmap.includes('632e059')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --account --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'; - } - - if (roadmap.includes('Wrangler OAuth readback') - || roadmap.includes('42653f9')) { - return 'create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'; - } - - if (roadmap.includes('Marketplace webhook provenance') - || roadmap.includes('2859678')) { - return 'replace the invalid Cloudflare credential, create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` and the live announcement gate'; - } - - if (roadmap.includes('billing:kv-readback') - || roadmap.includes('95d0bec')) { - return 'create or verify a Marketplace-managed Pro billing-state, then run the official live announcement gate'; - } - - if (roadmap.includes('production Marketplace readback state') - || roadmap.includes('eb69412')) { - return 'complete Marketplace purchase/webhook readback, then run the live announcement gate'; - } - - if (roadmap.includes('hosted promotion judge audit traces') - || roadmap.includes('operator-visible promotion output values')) { - return 'live Marketplace test-account readback pending'; - } - - return 'live Marketplace test-account readback, hosted promotion telemetry, and richer operator review UX pending'; + return roadmapTierMessage(roadmap, ECC_TOOLS_GAP_TIERS, ECC_TOOLS_GAP_FALLBACK); } function supplyChainLocalProtectionEvidence({ roadmap, scripts }) { @@ -624,30 +482,43 @@ function buildGrowthSummary(rootDir) { }; } -function buildRequirements(rootDir, platformReport) { - const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'); - const publicationReadiness = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md'); - const namingMatrix = readText(rootDir, 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md'); - const releasePublicationChecklist = readText(rootDir, 'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md'); - const releaseUrlLedger = readText(rootDir, 'docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md'); - const publicationEvidenceMay19 = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md'); - const hypergrowthCommandCenter = readText(rootDir, 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'); - const partnerSponsorTalksPack = readText(rootDir, 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md'); - const releaseVideoProduction = readText(rootDir, 'docs/releases/2.0.0-rc.1/video-suite-production.md'); - const ownerQueueCleanup = readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-queue-cleanup-2026-05-18.md'); - const ownerApprovalPacket = readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md'); - const previewManifest = readText(rootDir, 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md'); - const previewPackSmoke = readText(rootDir, 'scripts/preview-pack-smoke.js'); - const releaseVideoSuite = readText(rootDir, 'scripts/release-video-suite.js'); - const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md'); - const observabilityReadiness = readText(rootDir, 'docs/architecture/observability-readiness.md'); - const stalePrSalvage = readText(rootDir, 'docs/stale-pr-salvage-ledger.md'); - const legacyInventory = readText(rootDir, 'docs/legacy-artifact-inventory.md'); - const supplyChainRunbook = readText(rootDir, 'docs/security/supply-chain-incident-response.md'); - const supplyChainWorkflow = readText(rootDir, '.github/workflows/supply-chain-watch.yml'); +// Three-way readiness text: fully ready, partially ready, or neither. +function tiered(ready, partial, [whenReady, whenPartial, otherwise]) { + if (ready) return whenReady; + return partial ? whenPartial : otherwise; +} + +const TIERED_STATUS = ['current', 'in_progress', 'not_complete']; + +function readReleaseDocs(rootDir) { const packageJson = readPackage(rootDir); - const scripts = packageJson.scripts || {}; - const legacyContext = { stalePrSalvage, legacyInventory, roadmap }; + return { + roadmap: readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'), + publicationReadiness: readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md'), + namingMatrix: readText(rootDir, 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md'), + releasePublicationChecklist: readText(rootDir, 'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md'), + releaseUrlLedger: readText(rootDir, 'docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md'), + publicationEvidenceMay19: readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md'), + hypergrowthCommandCenter: readText(rootDir, 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'), + partnerSponsorTalksPack: readText(rootDir, 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md'), + releaseVideoProduction: readText(rootDir, 'docs/releases/2.0.0-rc.1/video-suite-production.md'), + ownerQueueCleanup: readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-queue-cleanup-2026-05-18.md'), + ownerApprovalPacket: readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md'), + previewManifest: readText(rootDir, 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md'), + previewPackSmoke: readText(rootDir, 'scripts/preview-pack-smoke.js'), + releaseVideoSuite: readText(rootDir, 'scripts/release-video-suite.js'), + progressSync: readText(rootDir, 'docs/architecture/progress-sync-contract.md'), + observabilityReadiness: readText(rootDir, 'docs/architecture/observability-readiness.md'), + stalePrSalvage: readText(rootDir, 'docs/stale-pr-salvage-ledger.md'), + legacyInventory: readText(rootDir, 'docs/legacy-artifact-inventory.md'), + supplyChainRunbook: readText(rootDir, 'docs/security/supply-chain-incident-response.md'), + supplyChainWorkflow: readText(rootDir, '.github/workflows/supply-chain-watch.yml'), + scripts: packageJson.scripts || {}, + }; +} + +function assessReadiness(rootDir, docs) { + const { scripts, previewManifest, previewPackSmoke, publicationEvidenceMay19 } = docs; const previewPackManifestReady = includesAll(previewManifest, [ 'publication-readiness.md', 'release-notes.md', @@ -664,7 +535,7 @@ function buildRequirements(rootDir, platformReport) { ]); const hermesArtifactsReady = fileExists(rootDir, 'docs/HERMES-SETUP.md') && fileExists(rootDir, 'skills/hermes-imports/SKILL.md'); - const hypergrowthCommandCenterReady = includesAll(hypergrowthCommandCenter, [ + const hypergrowthCommandCenterReady = includesAll(docs.hypergrowthCommandCenter, [ 'harness-native operator system', '$1,728/mo', '$10,000/mo', @@ -678,13 +549,13 @@ function buildRequirements(rootDir, platformReport) { ]); const releaseVideoSuiteReady = scripts['release:video-suite'] === 'node scripts/release-video-suite.js' && fileExists(rootDir, 'scripts/release-video-suite.js') - && includesAll(releaseVideoProduction, [ + && includesAll(docs.releaseVideoProduction, [ 'ECC 2.0 Video Suite Production Manifest', 'Primary launch video', 'Self-Eval Gate', 'timeline', ]) - && includesAll(releaseVideoSuite, [ + && includesAll(docs.releaseVideoSuite, [ 'ecc.release-video-suite.v1', 'video-source-assets-present', 'video-release-artifacts-present', @@ -698,7 +569,7 @@ function buildRequirements(rootDir, platformReport) { 'zero detected black-frame segments', 'primary rough render self-eval passed', ]); - const partnerSponsorTalksReady = includesAll(partnerSponsorTalksPack, [ + const partnerSponsorTalksReady = includesAll(docs.partnerSponsorTalksPack, [ 'Sponsor Outbound', 'Platform Partner DM', 'Consulting Intro', @@ -706,7 +577,7 @@ function buildRequirements(rootDir, platformReport) { 'GitHub Discussion Announcement', 'Do Not Send Or Publish If', ]); - const ownerApprovalPacketReady = includesAll(ownerApprovalPacket, [ + const ownerApprovalPacketReady = includesAll(docs.ownerApprovalPacket, [ 'Owner Approval Packet', 'Decision Register', 'GitHub prerelease', @@ -718,75 +589,99 @@ function buildRequirements(rootDir, platformReport) { 'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.' ]) && includesAll(previewManifest, ['owner-approval-packet-2026-05-19.md']); - const githubLive = !platformReport.github.skipped && platformReport.github.totals.errors === 0; - const ownerWideOpenPrs = extractLabeledCount(ownerQueueCleanup, 'Owner-wide open PRs after cleanup'); - const ownerWideOpenIssues = extractLabeledCount(ownerQueueCleanup, 'Owner-wide open issues after cleanup'); - const trackedPrQueueCurrent = githubLive - && platformReport.github.totals.openPrs <= platformReport.thresholds.maxOpenPrs; - const trackedIssueQueueCurrent = githubLive - && platformReport.github.totals.openIssues <= platformReport.thresholds.maxOpenIssues; - const ownerPrQueueCurrent = ownerWideOpenPrs === null - || ownerWideOpenPrs <= platformReport.thresholds.maxOpenPrs; - const ownerIssueQueueCurrent = ownerWideOpenIssues === null - || ownerWideOpenIssues <= platformReport.thresholds.maxOpenIssues; - const ownerPrEvidence = ownerWideOpenPrs === null + return { + previewPackManifestReady, + previewPackSmokeReady, + hermesArtifactsReady, + hypergrowthCommandCenterReady, + releaseVideoSuiteReady, + releaseVideoPublishCandidatesReady, + partnerSponsorTalksReady, + ownerApprovalPacketReady, + }; +} + +// PR and issue budgets share one shape: a tracked-repo count from the live +// sweep plus an optional owner-wide count from the queue cleanup ledger. +function queueBudgetRequirement(platformReport, githubLive, spec) { + const { id, requirement, noun, queueNoun, trackedCount, ownerCount, threshold } = spec; + const trackedCurrent = githubLive && trackedCount <= threshold; + const ownerCurrent = ownerCount === null || ownerCount <= threshold; + const ownerEvidence = ownerCount === null ? '' - : `; ${ownerWideOpenPrs} owner-wide open PRs after cleanup`; - const ownerIssueEvidence = ownerWideOpenIssues === null - ? '' - : `; ${ownerWideOpenIssues} owner-wide open issues after cleanup`; + : `; ${ownerCount} owner-wide open ${noun} after cleanup`; + + return buildRequirement( + id, + requirement, + ownerCount === null + ? 'scripts/platform-audit.js live GitHub sweep' + : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', + trackedCurrent && ownerCurrent ? 'current' : 'in_progress', + githubLive + ? `${trackedCount} open ${noun} across ${platformReport.github.repos.length} tracked repos${ownerEvidence}` + : 'live GitHub queue readback was skipped or failed', + trackedCurrent && ownerCurrent + ? 'repeat platform:audit and owner-wide gh search before release' + : `run live platform:audit and owner-wide gh search, then drain ${queueNoun} queue` + ); +} + +function githubQueueRequirements(platformReport, ownerQueueCleanup) { + const { totals } = platformReport.github; + const githubLive = !platformReport.github.skipped && totals.errors === 0; const discussionsCurrent = githubLive - && platformReport.github.totals.discussionsNeedingMaintainerTouch === 0 - && platformReport.github.totals.discussionsMissingAcceptedAnswer === 0; + && totals.discussionsNeedingMaintainerTouch === 0 + && totals.discussionsMissingAcceptedAnswer === 0; return [ - buildRequirement( - 'public-pr-budget', - 'Keep public PRs below 20', - ownerWideOpenPrs === null - ? 'scripts/platform-audit.js live GitHub sweep' - : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', - trackedPrQueueCurrent && ownerPrQueueCurrent ? 'current' : 'in_progress', - githubLive - ? `${platformReport.github.totals.openPrs} open PRs across ${platformReport.github.repos.length} tracked repos${ownerPrEvidence}` - : 'live GitHub queue readback was skipped or failed', - trackedPrQueueCurrent && ownerPrQueueCurrent - ? 'repeat platform:audit and owner-wide gh search before release' - : 'run live platform:audit and owner-wide gh search, then drain PR queue' - ), - buildRequirement( - 'public-issue-budget', - 'Keep public issues below 20', - ownerWideOpenIssues === null - ? 'scripts/platform-audit.js live GitHub sweep' - : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', - trackedIssueQueueCurrent && ownerIssueQueueCurrent ? 'current' : 'in_progress', - githubLive - ? `${platformReport.github.totals.openIssues} open issues across ${platformReport.github.repos.length} tracked repos${ownerIssueEvidence}` - : 'live GitHub queue readback was skipped or failed', - trackedIssueQueueCurrent && ownerIssueQueueCurrent - ? 'repeat platform:audit and owner-wide gh search before release' - : 'run live platform:audit and owner-wide gh search, then drain issue queue' - ), + queueBudgetRequirement(platformReport, githubLive, { + id: 'public-pr-budget', + requirement: 'Keep public PRs below 20', + noun: 'PRs', + queueNoun: 'PR', + trackedCount: totals.openPrs, + ownerCount: extractLabeledCount(ownerQueueCleanup, 'Owner-wide open PRs after cleanup'), + threshold: platformReport.thresholds.maxOpenPrs, + }), + queueBudgetRequirement(platformReport, githubLive, { + id: 'public-issue-budget', + requirement: 'Keep public issues below 20', + noun: 'issues', + queueNoun: 'issue', + trackedCount: totals.openIssues, + ownerCount: extractLabeledCount(ownerQueueCleanup, 'Owner-wide open issues after cleanup'), + threshold: platformReport.thresholds.maxOpenIssues, + }), buildRequirement( 'repository-discussions', 'Respond and manage repository discussions', 'scripts/platform-audit.js discussion summary', discussionsCurrent ? 'current' : 'in_progress', githubLive - ? `${platformReport.github.totals.discussionsNeedingMaintainerTouch} need maintainer touch; ${platformReport.github.totals.discussionsMissingAcceptedAnswer} answerable discussions missing accepted answer` + ? `${totals.discussionsNeedingMaintainerTouch} need maintainer touch; ${totals.discussionsMissingAcceptedAnswer} answerable discussions missing accepted answer` : 'live discussion readback was skipped or failed', discussionsCurrent ? 'repeat before release' : 'respond, answer, or route remaining discussions' ), + ]; +} + +function releasePackRequirements(rootDir, docs, readiness) { + const { scripts } = docs; + const { previewPackManifestReady, previewPackSmokeReady, hermesArtifactsReady } = readiness; + const previewPackReady = previewPackManifestReady && previewPackSmokeReady; + const hermesReady = hermesArtifactsReady && previewPackSmokeReady; + const dashboardScriptReady = scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js'; + + return [ buildRequirement( 'completion-dashboard', 'Build ITO-44 completion dashboard into a repeatable command', 'npm run operator:dashboard', - scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' - && fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') + dashboardScriptReady && fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') ? 'complete' : 'in_progress', - scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' + dashboardScriptReady ? 'operator:dashboard package script exists' : 'operator:dashboard package script missing', 'keep generated dashboard attached to publication evidence' @@ -795,13 +690,13 @@ function buildRequirements(rootDir, platformReport) { 'ecc-preview-pack', 'ECC 2.0 preview pack ready', 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md', - previewPackManifestReady && previewPackSmokeReady ? 'current' : previewPackManifestReady ? 'in_progress' : 'not_complete', - previewPackManifestReady && previewPackSmokeReady - ? 'preview pack manifest and deterministic smoke gate are in-tree' - : previewPackManifestReady - ? 'preview pack manifest is in-tree' - : 'preview pack manifest is incomplete', - previewPackManifestReady && previewPackSmokeReady + tiered(previewPackReady, previewPackManifestReady, TIERED_STATUS), + tiered(previewPackReady, previewPackManifestReady, [ + 'preview pack manifest and deterministic smoke gate are in-tree', + 'preview pack manifest is in-tree', + 'preview pack manifest is incomplete', + ]), + previewPackReady ? 'repeat clean-checkout preview-pack smoke before publication' : 'final clean-checkout release approval and publish evidence still pending' ), @@ -809,13 +704,13 @@ function buildRequirements(rootDir, platformReport) { 'hermes-specialized-skills', 'Include Hermes specialized skills safely', 'docs/HERMES-SETUP.md and skills/hermes-imports/SKILL.md', - hermesArtifactsReady && previewPackSmokeReady ? 'current' : hermesArtifactsReady ? 'in_progress' : 'not_complete', - hermesArtifactsReady && previewPackSmokeReady - ? 'Hermes setup/import artifacts are covered by preview-pack smoke' - : hermesArtifactsReady - ? 'Hermes setup and import skill are present' - : 'Hermes setup/import artifacts missing', - hermesArtifactsReady && previewPackSmokeReady + tiered(hermesReady, hermesArtifactsReady, TIERED_STATUS), + tiered(hermesReady, hermesArtifactsReady, [ + 'Hermes setup/import artifacts are covered by preview-pack smoke', + 'Hermes setup and import skill are present', + 'Hermes setup/import artifacts missing', + ]), + hermesReady ? 'repeat preview-pack smoke before release review' : 'final preview-pack smoke and release review pending' ), @@ -823,8 +718,8 @@ function buildRequirements(rootDir, platformReport) { 'naming-and-plugin-publication', 'Prepare name-change, Claude plugin, and Codex plugin paths', 'naming-and-publication-matrix plus release-name-plugin-publication checklist plus publication-readiness', - includesAll(namingMatrix, ['Claude plugin', 'Codex plugin', 'npm package', 'Publication Paths']) - && includesAll(releasePublicationChecklist, [ + includesAll(docs.namingMatrix, ['Claude plugin', 'Codex plugin', 'npm package', 'Publication Paths']) + && includesAll(docs.releasePublicationChecklist, [ 'Ship `v2.0.0-rc.1` as **ECC**', 'affaan-m/ECC', 'ecc-universal', @@ -832,12 +727,20 @@ function buildRequirements(rootDir, platformReport) { 'codex plugin marketplace add', 'Do not rename the npm package until rc.1 is published' ]) - && includesAll(publicationReadiness, ['Claude plugin', 'Codex plugin']) + && includesAll(docs.publicationReadiness, ['Claude plugin', 'Codex plugin']) ? 'in_progress' : 'not_complete', 'naming matrix, release publication checklist, and plugin readiness gates exist', 'real tag/push, marketplace submission, and final channel choice remain approval-gated' ), + ]; +} + +function releaseCopyRequirements(rootDir, docs, readiness) { + const { partnerSponsorTalksReady, ownerApprovalPacketReady } = readiness; + const urlLedgerReady = includesAll(docs.releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']); + + return [ buildRequirement( 'release-notes-and-notifications', 'Prepare release notes, articles, tweets, and push notifications', @@ -847,10 +750,10 @@ function buildRequirements(rootDir, platformReport) { && fileExists(rootDir, 'docs/releases/2.0.0-rc.1/linkedin-post.md') ? 'in_progress' : 'not_complete', - includesAll(releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']) + urlLedgerReady ? 'release notes, X thread, LinkedIn draft, and URL ledger are present' : 'release notes, X thread, and LinkedIn draft are present', - includesAll(releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']) + urlLedgerReady ? 'final live release/npm/plugin/billing URLs and publish approval still pending' : 'URL-backed refresh and publish approval still pending' ), @@ -866,6 +769,26 @@ function buildRequirements(rootDir, platformReport) { ? 'review owner approvals from the final release commit before any publication or outbound action' : 'add the owner decision sheet before publication review' ), + ].concat(growthRequirements(readiness), [ + buildRequirement( + 'partner-sponsor-talks-pack', + 'Prepare sponsor, partner, consulting, podcast, talk, and Discussion copy', + 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md', + partnerSponsorTalksReady ? 'in_progress' : 'not_complete', + partnerSponsorTalksReady + ? 'sponsor outbound, platform partner DM, consulting intro, talk/podcast pitch, GitHub Discussion announcement, CTA hooks, and do-not-send gate are drafted' + : 'partner, sponsor, consulting, talk, or discussion copy is missing', + partnerSponsorTalksReady + ? 'replace final URLs after publication gates, then get explicit approval before outbound or personal-account posts' + : 'draft the full outbound pack and approval gate' + ), + ]); +} + +function growthRequirements(readiness) { + const { hypergrowthCommandCenterReady, releaseVideoSuiteReady, releaseVideoPublishCandidatesReady } = readiness; + + return [ buildRequirement( 'hypergrowth-command-center', 'Create a second-phase hypergrowth release command center', @@ -882,30 +805,27 @@ function buildRequirements(rootDir, platformReport) { 'release-video-suite', 'Produce the ECC 2.0 release video suite', 'docs/releases/2.0.0-rc.1/video-suite-production.md and npm run release:video-suite', - releaseVideoPublishCandidatesReady ? 'current' : releaseVideoSuiteReady ? 'in_progress' : 'not_complete', - releaseVideoPublishCandidatesReady - ? 'video-suite gate is ready with 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates, primary self-eval, and zero detected black-frame segments recorded in May 19 evidence' - : releaseVideoSuiteReady - ? 'video production manifest and deterministic video-suite gate are wired for launch video, short clips, captions, timeline, and self-eval evidence' - : 'video production manifest or release:video-suite gate is incomplete', - releaseVideoPublishCandidatesReady - ? 'final owner approval, upload, and public video URLs remain approval-gated' - : releaseVideoSuiteReady - ? 'render final owner-approved MP4s, captions, platform reframes, and editable timeline before posting' - : 'wire release:video-suite and production manifest before final content work' - ), - buildRequirement( - 'partner-sponsor-talks-pack', - 'Prepare sponsor, partner, consulting, podcast, talk, and Discussion copy', - 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md', - partnerSponsorTalksReady ? 'in_progress' : 'not_complete', - partnerSponsorTalksReady - ? 'sponsor outbound, platform partner DM, consulting intro, talk/podcast pitch, GitHub Discussion announcement, CTA hooks, and do-not-send gate are drafted' - : 'partner, sponsor, consulting, talk, or discussion copy is missing', - partnerSponsorTalksReady - ? 'replace final URLs after publication gates, then get explicit approval before outbound or personal-account posts' - : 'draft the full outbound pack and approval gate' + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, TIERED_STATUS), + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, [ + 'video-suite gate is ready with 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates, primary self-eval, and zero detected black-frame segments recorded in May 19 evidence', + 'video production manifest and deterministic video-suite gate are wired for launch video, short clips, captions, timeline, and self-eval evidence', + 'video production manifest or release:video-suite gate is incomplete', + ]), + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, [ + 'final owner approval, upload, and public video URLs remain approval-gated', + 'render final owner-approved MP4s, captions, platform reframes, and editable timeline before posting', + 'wire release:video-suite and production manifest before final content work', + ]) ), + ]; +} + +function programRequirements(rootDir, docs) { + const { roadmap, scripts, progressSync } = docs; + const legacyContext = { stalePrSalvage: docs.stalePrSalvage, legacyInventory: docs.legacyInventory, roadmap }; + const observabilityScriptReady = scripts['observability:ready'] === 'node scripts/observability-readiness.js'; + + return [ buildRequirement( 'agentshield-enterprise-iteration', 'Advance AgentShield enterprise iteration', @@ -946,11 +866,11 @@ function buildRequirements(rootDir, platformReport) { 'observability-for-self-use', 'Provide ECC 2.0 observability for self-use', 'observability readiness gate', - scripts['observability:ready'] === 'node scripts/observability-readiness.js' - && includesAll(observabilityReadiness, ['observability-readiness.js']) + observabilityScriptReady + && includesAll(docs.observabilityReadiness, ['observability-readiness.js']) ? 'complete' : 'in_progress', - scripts['observability:ready'] === 'node scripts/observability-readiness.js' + observabilityScriptReady ? 'observability:ready command and readiness doc exist' : 'observability readiness command missing', 'runtime/dashboard implementation can continue after release gates' @@ -959,8 +879,8 @@ function buildRequirements(rootDir, platformReport) { 'supply-chain-local-protection', 'Keep Mini Shai-Hulud/TanStack protection loop current', 'supply-chain watch plus runbook plus AgentShield package-manager hardening', - includesAll(supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) - && includesAll(supplyChainWorkflow, ['supply-chain-advisory-sources.js', 'supply-chain-advisory-sources.json']) + includesAll(docs.supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) + && includesAll(docs.supplyChainWorkflow, ['supply-chain-advisory-sources.js', 'supply-chain-advisory-sources.json']) && scripts['security:advisory-sources'] === 'node scripts/ci/supply-chain-advisory-sources.js' && fileExists(rootDir, '.github/workflows/supply-chain-watch.yml') ? 'current' @@ -971,6 +891,18 @@ function buildRequirements(rootDir, platformReport) { ]; } +function buildRequirements(rootDir, platformReport) { + const docs = readReleaseDocs(rootDir); + const readiness = assessReadiness(rootDir, docs); + + return [ + ...githubQueueRequirements(platformReport, docs.ownerQueueCleanup), + ...releasePackRequirements(rootDir, docs, readiness), + ...releaseCopyRequirements(rootDir, docs, readiness), + ...programRequirements(rootDir, docs), + ]; +} + function buildReport(options) { const rootDir = path.resolve(options.root); const generatedAt = options.generatedAt || new Date().toISOString(); diff --git a/tests/ci/pr-burst-guard-workflow.test.js b/tests/ci/pr-burst-guard-workflow.test.js new file mode 100644 index 000000000..0ebe1f82b --- /dev/null +++ b/tests/ci/pr-burst-guard-workflow.test.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const exec = promisify(execFile); +const root = path.resolve(__dirname, '../..'); + +test('actual CLI closes six excess PRs, locks them, blocks with owner identity, and deduplicates alerts over HTTP', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr-guard-')); + const mutations = []; + let issue; + let permission = 'read'; + const rows = Array.from({ length: 11 }, (_, i) => ({ number: i + 1, + user: { id: 42, login: 'contributor' }, state: 'open', draft: i === 4, + created_at: new Date(Date.UTC(2026, 9, 14, 19, 0, i)).toISOString(), + body: '::error::untrusted PR body', title: '$(untrusted title)' })); + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let text = ''; + for await (const chunk of req) text += chunk; + const body = text ? JSON.parse(text) : undefined; + const send = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; + if (req.method !== 'GET') mutations.push({ method: req.method, route: url.pathname, body, token: req.headers.authorization }); + if (url.pathname === '/repos/owner/repo') return send({ owner: { login: 'owner', type: 'User' } }); + if (url.pathname === '/user') { + assert.equal(req.headers.authorization, 'Bearer owner-token'); + return send({ login: 'owner' }); + } + if (req.method === 'GET' && url.pathname.endsWith('/pulls/11')) return send(rows[10]); + if (url.pathname.includes('/collaborators/')) return send({ permission }); + if (req.method === 'GET' && url.pathname.endsWith('/pulls')) return send(rows.slice().reverse()); + if (url.pathname.endsWith('/issues')) { + if (req.method === 'GET') return send(issue ? [issue] : []); + issue = { number: 88, user: { login: 'github-actions[bot]' }, ...body }; + return send(issue); + } + if (url.pathname.endsWith('/issues/88') && req.method === 'PATCH') { issue = { ...issue, ...body }; return send(issue); } + if (/\/issues\/\d+\/comments$/.test(url.pathname)) return send(req.method === 'GET' ? [] : {}); + if (req.method === 'PATCH' && /\/pulls\/\d+$/.test(url.pathname)) { + const pr = rows.find(row => row.number === Number(url.pathname.split('/').pop())); + pr.state = body.state; return send(pr); + } + if (req.method === 'PUT') { res.statusCode = 204; return res.end(); } + res.statusCode = 404; send({ message: 'fixture route missing' }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + // Test-only transport redirection. Production offers no API host override. + const preload = path.join(tmp, 'transport.cjs'); + fs.writeFileSync(preload, `const original = global.fetch; global.fetch = (url, options) => original('http://127.0.0.1:${port}' + new URL(url).pathname + new URL(url).search, options);`); + const event = path.join(tmp, 'event.json'); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', sender: { login: 'owner' }, pull_request: { number: 11 } })); + const summary = path.join(tmp, 'summary.md'); + const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo', + GITHUB_TOKEN: 'moderation-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token', + GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary }; + const run = () => exec(process.execPath, ['scripts/ci/pr-burst-guard.js'], { cwd: root, env }); + const first = JSON.parse((await run()).stdout); + assert.equal(first.count, 11); + assert.match(first.blockStatus, /^Blocked/); + await run(); + assert.equal(mutations.filter(m => m.route.endsWith('/issues') && m.method === 'POST').length, 1); + assert.equal(mutations.filter(m => /\/pulls\/\d+$/.test(m.route)).length, 6); + assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 6); + assert(mutations.some(m => m.route === '/user/blocks/contributor' && m.token === 'Bearer owner-token')); + assert(mutations.filter(m => m.route.startsWith('/repos/')).every(m => m.token === 'Bearer moderation-token')); + assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|moderation-token/); + assert(issue.body.includes('@affaan-m @haelyra')); + + delete env.PR_MODERATION_BLOCK_TOKEN; + await assert.rejects(run(), error => { + assert.equal(error.code, 1); + assert.match(JSON.parse(error.stdout).blockStatus, /not configured/); + return true; + }); + permission = 'write'; + assert.equal(JSON.parse((await run()).stdout).exempt, true); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/ci/pr-burst-guard.test.js b/tests/ci/pr-burst-guard.test.js new file mode 100644 index 000000000..36902dc4e --- /dev/null +++ b/tests/ci/pr-burst-guard.test.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { evaluate, moderate, githubAPI, listPages, strongestWindow, capExcess } = require('../../scripts/ci/pr-burst-guard'); + +const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, maxOpen: 10, deniedUserIDs: [999], notify: ['maintainer'] }; +const start = Date.parse('2026-10-14T19:00:00Z'); +const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' }, + created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra }); +const pulls = n => Array.from({ length: n }, (_, i) => pull(i + 1)); + +function fixture(n, options = {}) { + const rows = options.rows || pulls(n); + const calls = []; + let issue; + const api = async (method, route, body) => { + calls.push({ method, route, body }); + if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } }; + if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n); + if (route.includes('/collaborators/')) return { permission: options.permission || 'read' }; + if (route.includes('/pulls?')) return rows.filter(p => !route.includes('state=open') || p.state === 'open').slice().reverse(); + if (route.includes('/issues?')) return issue ? [issue] : []; + if (/\/issues\/\d+\/comments\?/.test(route) && method === 'GET') return options.comments || []; + if (route.endsWith('/issues') && method === 'POST') { + issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body }; + return issue; + } + if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; } + if (route.endsWith('/comments') && method === 'POST') return {}; + if (method === 'PATCH') return {}; + if (method === 'PUT') return {}; + throw new Error(`Unexpected ${method} ${route}`); + }; + const args = { api, repository: 'owner/repo', number: n, action: 'opened', policy, ...options }; + return { args, calls, api }; +} + +test('five allowed, sixth closed, ten not blocked, eleventh escalates', () => { + for (const n of [5, 6, 10, 11]) { + const result = evaluate(pulls(n), pull(n), policy, start + n * 1000); + assert.equal(result.count, n); + assert.equal(result.block, n > 10); + assert.deepEqual(result.excess.map(p => p.number), Array.from({ length: Math.max(0, n - 5) }, (_, i) => i + 6)); + } +}); + +test('counts closed and draft PRs by stable user ID, excludes other authors and out-of-window rows', () => { + const rows = [...pulls(5), pull(6, { state: 'closed', draft: true }), pull(7, { user: { id: 99 } }), + pull(8, { created_at: new Date(start - 21 * 60000).toISOString() })]; + assert.equal(evaluate(rows, pull(6), policy, start + 6000).count, 6); +}); + +test('delayed events retain their creation window and deterministic number tie-break', () => { + const rows = pulls(11).map(p => ({ ...p, created_at: new Date(start).toISOString() })); + assert.equal(evaluate(rows.reverse(), rows[0], policy, start).block, true); + assert.deepEqual(evaluate(rows, rows[0], policy, start).excess.map(p => p.number), [6, 7, 8, 9, 10, 11]); +}); + +test('moderation closes and locks excess, then deduplicates maintainer alert', async () => { + const f = fixture(6); + await moderate(f.args); + await moderate(f.args); + assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 1); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/6') && c.body.state === 'closed')); + assert(f.calls.some(c => c.method === 'PUT' && c.route.endsWith('/issues/6/lock'))); +}); + +test('no owner credential reports blocking unavailable and still notifies', async () => { + const f = fixture(11); + const result = await moderate(f.args); + assert.match(result.blockStatus, /not configured/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('not configured'))); +}); + +test('owner token identity checked before personal block, escalation notifies exactly once', async () => { + const ownerCalls = []; + const blockAPI = async (method, route) => { + ownerCalls.push({ method, route }); + if (route === '/user') return { login: 'owner' }; + return {}; + }; + const f = fixture(6, { blockAPI }); + await moderate(f.args); + f.args.number = 11; + // Use a second snapshot while retaining the alert ledger. + const original = f.args.api; + f.args.api = (method, route, body) => route.endsWith('/pulls/11') ? pull(11) + : route.includes('/pulls?') ? pulls(11).reverse() : original(method, route, body); + await moderate(f.args); + await moderate(f.args); + assert(ownerCalls.some(c => c.method === 'PUT' && c.route === '/user/blocks/contributor')); + assert.equal(f.calls.filter(c => c.route.endsWith('/comments')).length, 1); +}); + +test('wrong personal identity never blocks and records the failure', async () => { + const f = fixture(11, { blockAPI: async (method, route) => { + assert.equal(route, '/user'); return { login: 'someone-else' }; + } }); + assert.match((await moderate(f.args)).blockStatus, /owner identity/); +}); + +test('write/admin collaborators bypass; actor identity cannot exempt an outsider', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const f = fixture(11, { permission }); + assert.equal((await moderate(f.args)).exempt, true); + assert.equal(f.calls.filter(c => ['POST', 'PATCH', 'PUT'].includes(c.method)).length, 0); + } + const f = fixture(6, { actor: 'owner' }); + assert.equal((await moderate(f.args)).count, 6); +}); + +test('denylisted user is closed on reopen even outside the creation window', async () => { + const f = fixture(1, { action: 'reopened', rows: [pull(1, { user: { id: 999, login: 'denied' } })] }); + assert.equal((await moderate(f.args)).denied, true); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/1'))); +}); + +test('dry run performs no mutations or owner-token calls', async () => { + const f = fixture(11, { dryRun: true, blockAPI: async () => { throw new Error('secret used'); } }); + assert.equal((await moderate(f.args)).block, true); + assert(f.calls.every(c => c.method === 'GET')); +}); + +test('REST transport rejects off-host paths and never retries mutations', async () => { + const api = githubAPI('test-token', async () => { throw new Error('must not fetch'); }); + await assert.rejects(api('GET', '//attacker.test'), /path/); + let attempts = 0; + const limited = githubAPI('token', async () => { + attempts++; return { ok: false, status: 403 }; + }); + await assert.rejects(limited('PUT', '/user/blocks/example'), /403/); + assert.equal(attempts, 1); +}); + +test('out-of-order older event sees newer burst and retains historical peak', async () => { + const rows = [...pulls(11), pull(12, { created_at: new Date(start + 30 * 60000).toISOString() })]; + assert.equal(strongestWindow(rows, pull(6), policy, start + 6000).count, 11); + const f = fixture(6, { rows }); + assert.equal((await moderate(f.args)).block, true); +}); + +test('separate bursts close excess from each window even if pending events disappear', () => { + const rows = [...pulls(6), ...pulls(6).map(p => ({ ...p, number: p.number + 6, + created_at: new Date(start + 60 * 60000 + p.number * 1000).toISOString() }))]; + const result = strongestWindow(rows, pull(6), policy, start + 6000); + assert.equal(result.count, 6); + assert.deepEqual(result.excess.map(p => p.number), [6, 12]); +}); + +test('pagination includes page two and refuses silent truncation', async () => { + let calls = 0; + const rows = await listPages(async () => { calls++; return calls === 1 ? pulls(100) : pulls(1); }, '/repos/o/r/pulls?state=all'); + assert.equal(rows.length, 101); + assert.equal(calls, 2); + await assert.rejects(listPages(async () => pulls(100), '/repos/o/r/pulls?state=all'), /Pagination limit/); +}); + +test('permission errors fail safely; outsider404 permits moderation', async () => { + for (const status of [403, 404]) { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (route.includes('/collaborators/')) throw Object.assign(new Error('permission lookup failed'), { status }); + return original(method, route, body); + }; + if (status === 403) await assert.rejects(moderate(f.args), /permission lookup/); + else assert.equal((await moderate(f.args)).count, 6); + } +}); + +test('closure failure still creates an alert with enforcement errors', async () => { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (method === 'PATCH' && route.endsWith('/pulls/6')) throw new Error('close denied'); + return original(method, route, body); + }; + assert.match((await moderate(f.args)).errors[0], /close denied/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied'))); +}); + +// Spread PRs one hour apart so the burst window never fires; only the open-PR cap applies. +const spread = (n, extra = {}) => Array.from({ length: n }, (_, i) => pull(i + 1, + { created_at: new Date(start + i * 3600000).toISOString(), ...extra })); + +test('open-PR cap keeps the oldest ten open and closes the rest, newest first', () => { + const result = capExcess(spread(12), spread(12)[11], policy); + assert.equal(result.openCount, 12); + assert.deepEqual(result.capExcess.map(p => p.number), [11, 12]); + const closed = spread(12).map((p, i) => i < 3 ? { ...p, state: 'closed' } : p); + assert.equal(capExcess(closed, closed[11], policy).openCount, 9); + assert.equal(capExcess(spread(12), spread(12)[11], policy, new Set([11, 12])).capExcess.length, 0); + assert.equal(capExcess(spread(12, { user: { id: 7, login: 'other' } }), pull(1), policy).openCount, 1); +}); + +test('cap closes with an explanatory comment, no lock, no maintainer alert, and never comments twice', async () => { + const rows = spread(11); + const f = fixture(11, { rows }); + const result = await moderate(f.args); + assert.equal(result.count, 1); + assert.deepEqual(result.capExcess.map(p => p.number), [11]); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/11') && c.body.state === 'closed')); + const comment = f.calls.find(c => c.method === 'POST' && c.route.endsWith('/issues/11/comments')); + assert.match(comment.body.body, /11 open pull requests.*limit of 10/); + assert.equal(f.calls.filter(c => c.route.endsWith('/lock')).length, 0); + assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 0); + const again = fixture(11, { rows, comments: [{ user: { login: 'github-actions[bot]' }, body: comment.body.body }] }); + await moderate(again.args); + assert.equal(again.calls.filter(c => c.method === 'POST').length, 0); + // A contributor-authored lookalike comment cannot suppress the explanation. + const spoofed = fixture(11, { rows, comments: [{ user: { login: 'contributor' }, body: comment.body.body }] }); + await moderate(spoofed.args); + assert.equal(spoofed.calls.filter(c => c.method === 'POST').length, 1); +}); + +test('cap counts open PRs after burst closures, so one run never closes a PR twice', async () => { + const rows = [...spread(9), ...pulls(6).map(p => ({ ...p, number: p.number + 9, + created_at: new Date(start + 24 * 3600000 + p.number * 1000).toISOString() }))]; + const f = fixture(15, { rows }); + const result = await moderate(f.args); + assert.equal(result.count, 6); + assert.deepEqual(result.excess.map(p => p.number), [15]); + assert.equal(result.openCount, 14); + assert.deepEqual(result.capExcess.map(p => p.number), [11, 12, 13, 14]); + assert.equal(f.calls.filter(c => c.method === 'PATCH' && c.route.endsWith('/pulls/15')).length, 1); +}); + +test('cap dry run reports the excess without mutating; collaborators are exempt from the cap', async () => { + const f = fixture(12, { rows: spread(12), dryRun: true }); + assert.deepEqual((await moderate(f.args)).capExcess.map(p => p.number), [11, 12]); + assert(f.calls.every(c => c.method === 'GET')); + const w = fixture(12, { rows: spread(12), permission: 'write' }); + assert.equal((await moderate(w.args)).exempt, true); + assert.equal(w.calls.filter(c => c.method !== 'GET').length, 0); +}); + +test('policy requires a positive integer maxOpen', () => { + assert.throws(() => capExcess([], pull(1), { ...policy, maxOpen: 0 }), /maxOpen/); + assert.throws(() => capExcess([], pull(1), { ...policy, maxOpen: undefined }), /maxOpen/); +}); + +test('reports only moderation metadata, never PR bodies or titles', async () => { + const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) }); + assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/); +}); diff --git a/tests/ci/run-all.test.js b/tests/ci/run-all.test.js index 32e016977..69008181b 100644 --- a/tests/ci/run-all.test.js +++ b/tests/ci/run-all.test.js @@ -177,6 +177,97 @@ const tests = [ fs.rmSync(root, { recursive: true, force: true }); } }]), + ['worker pool runs every file, keeps per-file output grouped, and keeps the summary last', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-pool-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(testsDir); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + for (const [name, failures] of [['a', 0], ['b', 1], ['c', 0]]) { + fs.writeFileSync(path.join(testsDir, `${name}.test.js`), [ + `console.log('BEGIN ${name}');`, + `console.log('Passed: 2, Failed: ${failures}');`, + `console.log('END ${name}');`, + ].join('\n')); + } + const env = { ...process.env, ECC_TEST_JOBS: '3', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_ONLY; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /Running 3 files with 3 parallel jobs/); + for (const name of ['a', 'b', 'c']) { + assert.match(result.stdout, new RegExp(`━━━ Running ${name}\\.test\\.js ━━━\\n(?:.*\\n)*?BEGIN ${name}\\n[^━]*END ${name}`), `output for ${name} stays grouped`); + } + assert.match(result.stdout, /✗ b\.test\.js reported 1 failed tests/); + assert.match(result.stdout, /Files: 2 passed, 1 failed/); + assert.ok(result.stdout.trimEnd().endsWith('Passed: 6, Failed: 1')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], + ['ECC_TEST_SERIAL files run one at a time after the pool drains', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-serial-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(testsDir); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + const marker = path.join(root, 'serial-marker'); + // Pool files sleep briefly; the serial file asserts nothing else is still running by + // checking that both pool files already wrote their markers. + for (const name of ['pool-a', 'pool-b']) { + fs.writeFileSync(path.join(testsDir, `${name}.test.js`), [ + "setTimeout(() => {", + ` require('fs').writeFileSync(${JSON.stringify(path.join(root, `${name}.done`))}, '1');`, + " console.log('Passed: 1, Failed: 0');", + '}, 150);', + ].join('\n')); + } + fs.writeFileSync(path.join(testsDir, 'opencode-dist.test.js'), [ + "const fs = require('fs');", + `const done = ['pool-a', 'pool-b'].filter(n => fs.existsSync(${JSON.stringify(root)} + '/' + n + '.done')).length;`, + `fs.writeFileSync(${JSON.stringify(marker)}, String(done));`, + "console.log(done === 2 ? 'Passed: 1, Failed: 0' : 'Passed: 0, Failed: 1');", + 'process.exitCode = done === 2 ? 0 : 1;', + ].join('\n')); + const env = { ...process.env, ECC_TEST_JOBS: '3', ECC_TEST_SERIAL: 'opencode-dist', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_ONLY; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stdout); + assert.match(result.stdout, /serial after the pool: opencode-dist/); + assert.match(result.stdout, /Running 1 serial files after the pool/); + assert.strictEqual(fs.readFileSync(marker, 'utf8'), '2'); + assert.match(result.stdout, /Files: 3 passed, 0 failed/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], + ['ECC_TEST_ONLY keeps just the files whose path contains a pattern', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-only-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(path.join(testsDir, 'lib'), { recursive: true }); + fs.mkdirSync(path.join(testsDir, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + fs.writeFileSync(path.join(testsDir, 'lib', 'package-manager.test.js'), "console.log('Passed: 1, Failed: 0');"); + fs.writeFileSync(path.join(testsDir, 'hooks', 'hooks.test.js'), "console.log('Passed: 1, Failed: 0');"); + fs.writeFileSync(path.join(testsDir, 'lib', 'other.test.js'), "console.log('Passed: 0, Failed: 1'); process.exitCode = 1;"); + const env = { ...process.env, ECC_TEST_ONLY: 'package-manager, hooks/hooks.test', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_JOBS; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stdout); + assert.match(result.stdout, /Only patterns: package-manager, hooks\/hooks\.test/); + assert.ok(!result.stdout.includes('other.test.js'), 'unmatched file must not run'); + assert.match(result.stdout, /Files: 2 passed, 0 failed/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], ]; let failed = 0; diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js index ace7ccbdb..1eda199f9 100644 --- a/tests/ci/validators.test.js +++ b/tests/ci/validators.test.js @@ -1039,6 +1039,21 @@ function runTests() { cleanupTestDir(testDir); })) passed++; else failed++; + if (test('unescapes inline JS in one pass so an escaped backslash before n stays literal', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + // Shell body `console.log('\\n')` is a valid program; a sequential unescape turned it into a raw newline. + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ id: 'test:escaped-backslash-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(\'\\\\n\')"' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 0, `Should pass escaped-backslash inline JS, stderr: ${result.stderr}`); + cleanupTestDir(testDir); + })) passed++; else failed++; + if (test('validates array command format', () => { const testDir = createTestDir(); const hooksFile = path.join(testDir, 'hooks.json'); diff --git a/tests/commands/multi-model-wrapper-tokens.test.js b/tests/commands/multi-model-wrapper-tokens.test.js new file mode 100644 index 000000000..f604d758b --- /dev/null +++ b/tests/commands/multi-model-wrapper-tokens.test.js @@ -0,0 +1,95 @@ +'use strict'; + +// Regression test for #3595: the multi-* commands ship codeagent-wrapper +// calls copied from ccg-workflow templates. ccg-workflow's installer +// substitutes {{LITE_MODE_FLAG}} and {{GEMINI_MODEL_FLAG}} at install time; +// ECC has no such step, so an unrendered token reaches bash as a literal +// argument and the wrapper exits 1 on every call. + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const commandsDir = path.join(repoRoot, 'commands'); +const docsDir = path.join(repoRoot, 'docs'); + +const WRAPPER = 'codeagent-wrapper'; +const TEMPLATE_TOKEN = /\{\{[A-Z0-9_]+\}\}/; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed++; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function listMultiCommandFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(fileName => /^multi-.*\.md$/.test(fileName)) + .map(fileName => path.join(dir, fileName)) + .sort(); +} + +function listTranslatedCommandDirs() { + return fs + .readdirSync(docsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(docsDir, entry.name, 'commands')) + .filter(dir => fs.existsSync(dir)); +} + +function findWrapperTokenLines(filePath) { + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + const hits = []; + lines.forEach((line, index) => { + if (line.includes(WRAPPER) && TEMPLATE_TOKEN.test(line)) { + hits.push(`${path.relative(repoRoot, filePath)}:${index + 1}`); + } + }); + return hits; +} + +console.log('\n=== Testing multi-* command wrapper invocations ===\n'); + +test('token scanner flags an unrendered ccg-workflow placeholder', () => { + const line = '~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - "$PWD"'; + assert.ok(TEMPLATE_TOKEN.test(line)); + assert.ok(!TEMPLATE_TOKEN.test('~/.claude/bin/codeagent-wrapper --backend codex - "$PWD"')); +}); + +test('commands/multi-*.md exist and call codeagent-wrapper', () => { + const files = listMultiCommandFiles(commandsDir); + assert.ok(files.length >= 5, `expected at least 5 multi-* commands, found ${files.length}`); + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + assert.ok(content.includes(WRAPPER), `${path.relative(repoRoot, file)} does not call ${WRAPPER}`); + } +}); + +test('commands/multi-*.md pass no {{TEMPLATE}} tokens to codeagent-wrapper', () => { + const hits = listMultiCommandFiles(commandsDir).flatMap(findWrapperTokenLines); + assert.deepStrictEqual(hits, [], `unrendered template tokens at:\n${hits.join('\n')}`); +}); + +test('translated docs/*/commands/multi-*.md pass no {{TEMPLATE}} tokens to codeagent-wrapper', () => { + const hits = listTranslatedCommandDirs().flatMap(listMultiCommandFiles).flatMap(findWrapperTokenLines); + assert.deepStrictEqual(hits, [], `unrendered template tokens at:\n${hits.join('\n')}`); +}); + +if (failed > 0) { + console.log(`\nFailed: ${failed}`); + process.exit(1); +} + +console.log(`\nPassed: ${passed}`); diff --git a/tests/docker/plugin-setup-harness.test.js b/tests/docker/plugin-setup-harness.test.js index 3e7ee7221..c1decd7ac 100644 --- a/tests/docker/plugin-setup-harness.test.js +++ b/tests/docker/plugin-setup-harness.test.js @@ -274,10 +274,9 @@ test('uses one shell-free focused runner across Linux, macOS, and Windows', () = const packageJson = read(files.packageJson); const platformRunner = read(files.platformRunner); - assert.match( - ci, - /os:\s*\[ubuntu-latest,\s*windows-latest,\s*macos-latest\]/ - ); + for (const os of ['ubuntu-latest', 'windows-latest', 'macos-latest']) { + assert.match(ci, new RegExp(`os:\\s*${os},[^}]*scope:\\s*full`), `${os} must run the full suite`); + } assert.match( packageJson, /"test:plugin-setup-platform":\s*"node docker\/plugin-setup\/run-platform-tests\.js"/ diff --git a/tests/lib/agent-tool-map.test.js b/tests/lib/agent-tool-map.test.js new file mode 100644 index 000000000..50ba31664 --- /dev/null +++ b/tests/lib/agent-tool-map.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { CLAUDE_TO_PI_TOOLS, CLAUDE_MODEL_TIERS, mapToolToPi } = require('../../scripts/lib/agent-tool-map'); + +// Pi built-ins per @earendil-works/pi-coding-agent dist/core/tools/index.d.ts. +// `powershell` is Windows-only and never emitted; `ls` has no Claude source. +const PI_BUILTINS = new Set(['read', 'bash', 'powershell', 'edit', 'write', 'grep', 'find', 'ls']); +const PI_READ_ONLY = new Set(['read', 'grep', 'find', 'ls']); +const CLAUDE_READ_ONLY = ['Read', 'Grep', 'Glob']; + +test('the table covers exactly the six core Claude tools', () => { + assert.deepEqual(Object.keys(CLAUDE_TO_PI_TOOLS).sort(), ['Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write']); +}); + +test('every target is a documented Pi built-in and powershell is never emitted', () => { + for (const [claudeTool, piTool] of Object.entries(CLAUDE_TO_PI_TOOLS)) { + assert.ok(PI_BUILTINS.has(piTool), `${claudeTool} -> ${piTool} is not a Pi built-in`); + assert.notEqual(piTool, 'powershell', `${claudeTool} must not map to powershell`); + } +}); + +test('read-only Claude tools never gain shell or write authority in Pi', () => { + for (const claudeTool of CLAUDE_READ_ONLY) { + const mapped = mapToolToPi(claudeTool); + assert.equal(mapped.unsupported, false); + assert.ok(PI_READ_ONLY.has(mapped.tool), `${claudeTool} -> ${mapped.tool} escalates a read-only tool`); + } +}); + +test('mapped tools come back with their Pi name, unsupported false, and no note', () => { + assert.deepEqual(mapToolToPi('Read'), { tool: 'read', unsupported: false }); + assert.deepEqual(mapToolToPi('Grep'), { tool: 'grep', unsupported: false }); + assert.deepEqual(mapToolToPi('Glob'), { tool: 'find', unsupported: false }); + assert.deepEqual(mapToolToPi('Bash'), { tool: 'bash', unsupported: false }); + assert.deepEqual(mapToolToPi('Edit'), { tool: 'edit', unsupported: false }); + assert.deepEqual(mapToolToPi('Write'), { tool: 'write', unsupported: false }); +}); + +test('surrounding whitespace is trimmed before lookup', () => { + assert.deepEqual(mapToolToPi(' Read '), { tool: 'read', unsupported: false }); + assert.deepEqual(mapToolToPi('\tBash\n'), { tool: 'bash', unsupported: false }); +}); + +test('tool names are case sensitive, so a lowercase or shouted name is not guessed', () => { + for (const name of ['read', 'BASH', 'write', 'Glob ']) { + const mapped = mapToolToPi(name); + if (name.trim() === 'Glob') continue; + assert.equal(mapped.unsupported, true, `${JSON.stringify(name)} should be unsupported`); + assert.equal(mapped.tool, null); + } +}); + +test('WebSearch and WebFetch are deliberately unmapped', () => { + for (const name of ['WebSearch', 'WebFetch']) { + assert.deepEqual(mapToolToPi(name), { + tool: null, + unsupported: true, + note: `unmapped tool: ${name} (not a Pi built-in)` + }); + } +}); + +test('MCP tools are unsupported with a note that names the tool and the server step', () => { + const name = 'mcp__context7__query-docs'; + const mapped = mapToolToPi(name); + assert.equal(mapped.tool, null); + assert.equal(mapped.unsupported, true); + assert.ok(mapped.note.includes(name)); + assert.match(mapped.note, /configure the MCP server explicitly/); + assert.doesNotMatch(mapped.note, /^unmapped tool:/); +}); + +test('an mcp__ prefix after whitespace is still recognised as an MCP tool', () => { + assert.match(mapToolToPi(' mcp__x__y ').note, /^MCP tool mcp__x__y /); +}); + +test('unknown tools are reported, not dropped or guessed', () => { + assert.deepEqual(mapToolToPi('Task'), { + tool: null, + unsupported: true, + note: 'unmapped tool: Task (not a Pi built-in)' + }); +}); + +test('prototype property names are not treated as table entries', () => { + for (const name of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'valueOf']) { + const mapped = mapToolToPi(name); + assert.equal(mapped.tool, null, `${name} leaked a prototype member as a tool`); + assert.equal(mapped.unsupported, true); + assert.equal(typeof mapped.note, 'string'); + } +}); + +test('non-string and empty input is coerced and reported rather than thrown', () => { + for (const input of [undefined, null, 42, '', ' ']) { + const mapped = mapToolToPi(input); + assert.equal(mapped.tool, null, `${String(input)} should not map`); + assert.equal(mapped.unsupported, true); + assert.match(mapped.note, /^unmapped tool: /); + } + assert.ok(mapToolToPi(undefined).note.includes('undefined')); + assert.ok(mapToolToPi(42).note.includes('42')); +}); + +test('a result either carries a Pi tool or is unsupported, never both or neither', () => { + const inputs = [...Object.keys(CLAUDE_TO_PI_TOOLS), 'WebFetch', 'mcp__a__b', 'Task', '', 'read']; + for (const input of inputs) { + const mapped = mapToolToPi(input); + assert.equal(mapped.tool !== null, !mapped.unsupported, `${JSON.stringify(input)}`); + assert.equal('note' in mapped, mapped.unsupported, `${JSON.stringify(input)} note presence`); + } +}); + +test('the tables are frozen and the model tiers are the three Claude tiers in order', () => { + assert.ok(Object.isFrozen(CLAUDE_TO_PI_TOOLS)); + assert.ok(Object.isFrozen(CLAUDE_MODEL_TIERS)); + assert.deepEqual(CLAUDE_MODEL_TIERS, ['haiku', 'sonnet', 'opus']); + assert.throws(() => { + CLAUDE_TO_PI_TOOLS.WebFetch = 'bash'; + }, TypeError); + assert.equal(mapToolToPi('WebFetch').unsupported, true); +}); diff --git a/tests/lib/compute-sponsor.test.js b/tests/lib/compute-sponsor.test.js new file mode 100644 index 000000000..22c44a9dd --- /dev/null +++ b/tests/lib/compute-sponsor.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const computeSponsor = require('../../scripts/lib/compute-sponsor'); + +const { ITO_COMPUTE_URL, getComputeSponsorCopy } = computeSponsor; +const URL_TOKEN_PATTERN = /https?:\/\/[^\s<>"'`(){}\\]+/g; + +function urlsIn(text) { + return (text.match(URL_TOKEN_PATTERN) || []).map(token => token.replace(/[.,;:!?]+$/, '')); +} + +test('the compute URL is the exact Ito route with no path, query, hash, or credentials', () => { + const parsed = new URL(ITO_COMPUTE_URL); + assert.equal(parsed.protocol, 'https:'); + assert.equal(parsed.hostname, 'compute.itomarkets.com'); + assert.equal(parsed.port, ''); + assert.equal(parsed.username, ''); + assert.equal(parsed.password, ''); + assert.equal(parsed.pathname, '/'); + assert.equal(parsed.search, ''); + assert.equal(parsed.hash, ''); + assert.equal(ITO_COMPUTE_URL, 'https://compute.itomarkets.com'); +}); + +test('the copy links the compute URL exactly once and links nowhere else', () => { + assert.deepEqual(urlsIn(getComputeSponsorCopy()), [ITO_COMPUTE_URL]); +}); + +test('the copy names Ito as the preferred sponsor and keeps other providers open', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /^Run or self-host any open-source model\./); + assert.match(copy, /Itô is ECC's preferred compute sponsor/); + assert.match(copy, /Any GPU provider works\./); +}); + +test('the copy keeps the sponsorship link passive', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /This sponsorship link is passive/); + assert.match(copy, /does not invoke an RFQ, reserve capacity, provision compute, or configure serving/); +}); + +test('the copy keeps the ecc ito find bridge separate, opt-in, and non-reserving', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /Separately, the opt-in "ecc ito find" bridge/); + assert.match(copy, /explicitly configured canonical Itô CLI/); + assert.match(copy, /submits a live authenticated RFQ; it does not reserve capacity\./); +}); + +test('the copy does not claim managed inference is live', () => { + assert.match(getComputeSponsorCopy(), /Managed inference through Itô is not live yet\.$/); +}); + +test('the copy is a single trimmed line of printable text', () => { + const copy = getComputeSponsorCopy(); + assert.equal(typeof copy, 'string'); + assert.ok(copy.length > 0); + assert.equal(copy, copy.trim()); + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(copy, /[\x00-\x1f\x7f]/); + assert.doesNotMatch(copy, / {2}/); +}); + +test('the copy is deterministic across calls', () => { + assert.equal(getComputeSponsorCopy(), getComputeSponsorCopy()); +}); + +test('the module surface is frozen and exports only the URL and the copy builder', () => { + assert.ok(Object.isFrozen(computeSponsor)); + assert.deepEqual(Object.keys(computeSponsor).sort(), ['ITO_COMPUTE_URL', 'getComputeSponsorCopy']); + assert.throws(() => { + computeSponsor.ITO_COMPUTE_URL = 'https://example.invalid/'; + }, TypeError); + assert.equal(computeSponsor.ITO_COMPUTE_URL, 'https://compute.itomarkets.com'); +}); diff --git a/tests/lib/cursor-agent-names.test.js b/tests/lib/cursor-agent-names.test.js new file mode 100644 index 000000000..bbff29622 --- /dev/null +++ b/tests/lib/cursor-agent-names.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const { toCursorAgentFileName, toCursorAgentRelativePath } = require('../../scripts/lib/cursor-agent-names'); + +test('a bare file name gets the ecc- prefix', () => { + assert.equal(toCursorAgentFileName('planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentFileName('code-reviewer.md'), 'ecc-code-reviewer.md'); +}); + +test('an already prefixed name is left alone, so the transform is idempotent', () => { + assert.equal(toCursorAgentFileName('ecc-planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentFileName(toCursorAgentFileName('planner.md')), 'ecc-planner.md'); +}); + +test('empty and missing names pass through unchanged', () => { + assert.equal(toCursorAgentFileName(''), ''); + assert.equal(toCursorAgentFileName(undefined), undefined); + assert.equal(toCursorAgentFileName(null), null); +}); + +test('only a leading, lowercase ecc- counts as the prefix', () => { + assert.equal(toCursorAgentFileName('my-ecc-agent.md'), 'ecc-my-ecc-agent.md'); + assert.equal(toCursorAgentFileName('ECC-planner.md'), 'ecc-ECC-planner.md'); + assert.equal(toCursorAgentFileName('ecc.md'), 'ecc-ecc.md'); + assert.equal(toCursorAgentFileName('ecc_planner.md'), 'ecc-ecc_planner.md'); +}); + +test('a relative path keeps its directories and renames only the last segment', () => { + assert.equal(toCursorAgentRelativePath('review/planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('a/b/c.md'), path.join('a', 'b', 'ecc-c.md')); + assert.equal(toCursorAgentRelativePath('ecc-review/planner.md'), path.join('ecc-review', 'ecc-planner.md')); +}); + +test('a path without directories behaves like a bare file name', () => { + assert.equal(toCursorAgentRelativePath('planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentRelativePath('ecc-planner.md'), 'ecc-planner.md'); +}); + +test('backslash and repeated separators are normalized to the platform separator', () => { + assert.equal(toCursorAgentRelativePath('review\\planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('review//planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('review\\\\sub/planner.md'), path.join('review', 'sub', 'ecc-planner.md')); +}); + +test('a trailing separator is dropped and the last real segment is renamed', () => { + assert.equal(toCursorAgentRelativePath('review/planner.md/'), path.join('review', 'ecc-planner.md')); +}); + +test('empty, missing, and separator-only paths come back unchanged', () => { + assert.equal(toCursorAgentRelativePath(''), ''); + assert.equal(toCursorAgentRelativePath(undefined), undefined); + assert.equal(toCursorAgentRelativePath(null), null); + assert.equal(toCursorAgentRelativePath('/'), '/'); + assert.equal(toCursorAgentRelativePath('\\\\'), '\\\\'); +}); + +test('the result is always relative to the destination root', () => { + // The executor joins the result under the target agents directory, so a + // leading separator must not be able to re-root the destination. + const result = toCursorAgentRelativePath('/etc/planner.md'); + assert.equal(result, path.join('etc', 'ecc-planner.md')); + assert.equal(path.isAbsolute(result), false); +}); + +test('dot segments are not interpreted; the transform renames, it does not resolve', () => { + // The executor walks real directory entries, so these never reach the + // transform in practice. Pin that it does not quietly collapse them either. + assert.equal(toCursorAgentRelativePath('./planner.md'), path.join('.', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('../planner.md'), path.join('..', 'ecc-planner.md')); +}); + +test('the relative path transform composes the file name transform', () => { + for (const file of ['planner.md', 'ecc-planner.md', 'README']) { + assert.equal(toCursorAgentRelativePath(`nested/${file}`), path.join('nested', toCursorAgentFileName(file))); + } +}); diff --git a/tests/lib/feedback-links.test.js b/tests/lib/feedback-links.test.js new file mode 100644 index 000000000..c3777bc45 --- /dev/null +++ b/tests/lib/feedback-links.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { FEEDBACK_ROUTES, exitFeedbackLines, getFeedbackPayload, problemReportLines } = require('../../scripts/lib/feedback-links'); + +const ISSUE_TEMPLATE_DIR = path.join(__dirname, '..', '..', '.github', 'ISSUE_TEMPLATE'); +const ISSUES_NEW_URL = 'https://github.com/affaan-m/ECC/issues/new'; + +test('every route is a new-issue URL on the ECC repository with only a template query', () => { + assert.deepEqual(Object.keys(FEEDBACK_ROUTES).sort(), ['feature', 'feedback', 'problem']); + for (const [name, route] of Object.entries(FEEDBACK_ROUTES)) { + const parsed = new URL(route); + assert.equal(parsed.protocol, 'https:', `${name} protocol`); + assert.equal(parsed.hostname, 'github.com', `${name} host`); + assert.equal(parsed.pathname, '/affaan-m/ECC/issues/new', `${name} path`); + assert.equal(parsed.username, '', `${name} username`); + assert.equal(parsed.password, '', `${name} password`); + assert.equal(parsed.hash, '', `${name} hash`); + assert.deepEqual([...parsed.searchParams.keys()], ['template'], `${name} query keys`); + } +}); + +test('every route points at an issue template that exists in the repository', () => { + for (const [name, route] of Object.entries(FEEDBACK_ROUTES)) { + const template = new URL(route).searchParams.get('template'); + assert.match(template, /^[a-z-]+\.yml$/, `${name} template name`); + assert.ok(fs.existsSync(path.join(ISSUE_TEMPLATE_DIR, template)), `${name} route names ${template}, which is not in .github/ISSUE_TEMPLATE`); + } +}); + +test('each route maps to the intended template', () => { + assert.equal(FEEDBACK_ROUTES.problem, `${ISSUES_NEW_URL}?template=install-problem.yml`); + assert.equal(FEEDBACK_ROUTES.feedback, `${ISSUES_NEW_URL}?template=quick-feedback.yml`); + assert.equal(FEEDBACK_ROUTES.feature, `${ISSUES_NEW_URL}?template=feature-request.yml`); +}); + +test('the route table is frozen', () => { + assert.ok(Object.isFrozen(FEEDBACK_ROUTES)); + assert.throws(() => { + FEEDBACK_ROUTES.problem = 'https://example.invalid/'; + }, TypeError); + assert.throws(() => { + FEEDBACK_ROUTES.extra = 'https://example.invalid/'; + }, TypeError); + assert.equal(FEEDBACK_ROUTES.problem, `${ISSUES_NEW_URL}?template=install-problem.yml`); +}); + +test('the payload declares the public schema and no diagnostics upload', () => { + assert.deepEqual(getFeedbackPayload(), { + schemaVersion: 'ecc.feedback.v1', + privacy: 'public-github', + diagnosticsUploaded: false, + routes: { ...FEEDBACK_ROUTES } + }); +}); + +test('the payload survives a JSON round trip unchanged', () => { + const payload = getFeedbackPayload(); + assert.deepEqual(JSON.parse(JSON.stringify(payload)), payload); +}); + +test('each payload is a fresh copy so a caller cannot alter the route table', () => { + const first = getFeedbackPayload(); + const second = getFeedbackPayload(); + assert.notEqual(first, second); + assert.notEqual(first.routes, second.routes); + assert.deepEqual(first, second); + + first.routes.problem = 'https://example.invalid/'; + first.diagnosticsUploaded = true; + assert.equal(getFeedbackPayload().routes.problem, FEEDBACK_ROUTES.problem); + assert.equal(getFeedbackPayload().diagnosticsUploaded, false); +}); + +test('problem report lines carry the problem route and the redaction warning', () => { + const lines = problemReportLines(); + assert.equal(lines.length, 3); + assert.match(lines[0], /public GitHub issue/); + assert.equal(lines[1], FEEDBACK_ROUTES.problem); + assert.match(lines[2], /does not upload diagnostics/); + assert.match(lines[2], /Redact paths, repository names, prompts, and secrets/); +}); + +test('exit feedback lines carry the feedback route and promise not to block uninstall', () => { + const lines = exitFeedbackLines(); + assert.equal(lines.length, 3); + assert.match(lines[0], /public GitHub issue/); + assert.equal(lines[1], FEEDBACK_ROUTES.feedback); + assert.match(lines[2], /does not upload diagnostics/); + assert.match(lines[2], /block uninstall/); +}); + +test('every line is a single printable line, so join("\\n") output stays well formed', () => { + for (const line of [...problemReportLines(), ...exitFeedbackLines()]) { + assert.equal(typeof line, 'string'); + assert.ok(line.length > 0); + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(line, /[\x00-\x1f\x7f]/); + assert.equal(line, line.trim()); + } +}); + +test('line arrays are rebuilt per call', () => { + const lines = problemReportLines(); + lines[1] = 'tampered'; + assert.equal(problemReportLines()[1], FEEDBACK_ROUTES.problem); + assert.notEqual(exitFeedbackLines(), exitFeedbackLines()); +}); diff --git a/tests/lib/invocation-environment.test.js b/tests/lib/invocation-environment.test.js new file mode 100644 index 000000000..509ec5508 --- /dev/null +++ b/tests/lib/invocation-environment.test.js @@ -0,0 +1,106 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveInvocationEnvironment } = require('../../scripts/lib/invocation-environment'); + +const PROBE = 'ECC_INVOCATION_ENVIRONMENT_PROBE'; + +function withProbe(value, fn) { + const saved = process.env[PROBE]; + process.env[PROBE] = value; + try { + return fn(); + } finally { + if (saved === undefined) { + delete process.env[PROBE]; + } else { + process.env[PROBE] = saved; + } + } +} + +test('with no options the ambient process environment is returned as a copy', () => { + withProbe('ambient', () => { + const resolved = resolveInvocationEnvironment(); + assert.notEqual(resolved, process.env); + assert.deepEqual(resolved, { ...process.env }); + assert.equal(resolved[PROBE], 'ambient'); + }); +}); + +test('an empty options object behaves like no options', () => { + withProbe('ambient', () => { + assert.deepEqual(resolveInvocationEnvironment({}), { ...process.env }); + }); +}); + +test('mutating the copy never writes back to process.env', () => { + const resolved = resolveInvocationEnvironment(); + resolved[PROBE] = 'leaked'; + delete resolved.PATH; + assert.equal(process.env[PROBE], undefined); + assert.equal(typeof process.env.PATH, 'string'); +}); + +test('the copy is a snapshot and does not see later process.env changes', () => { + const resolved = resolveInvocationEnvironment(); + withProbe('later', () => { + assert.equal(resolved[PROBE], undefined); + }); +}); + +test('an explicit env is copied, not shared, and replaces the ambient environment', () => { + withProbe('ambient', () => { + const source = { HOME: '/scoped/home', PATH: '/scoped/bin' }; + const resolved = resolveInvocationEnvironment({ env: source }); + assert.notEqual(resolved, source); + assert.deepEqual(resolved, source); + assert.equal(resolved[PROBE], undefined); + + resolved.EXTRA = '1'; + assert.equal(source.EXTRA, undefined); + }); +}); + +test('only own enumerable keys of an explicit env are copied', () => { + const source = Object.create({ INHERITED: 'yes' }); + source.OWN = 'yes'; + Object.defineProperty(source, 'HIDDEN', { value: 'yes', enumerable: false }); + assert.deepEqual(resolveInvocationEnvironment({ env: source }), { OWN: 'yes' }); +}); + +test('env: null and env: undefined mean an empty environment, not the ambient one', () => { + withProbe('ambient', () => { + assert.deepEqual(resolveInvocationEnvironment({ env: null }), {}); + assert.deepEqual(resolveInvocationEnvironment({ env: undefined }), {}); + }); +}); + +test('an explicit env wins over homeDir', () => { + const resolved = resolveInvocationEnvironment({ env: { A: '1' }, homeDir: '/scoped' }); + assert.deepEqual(resolved, { A: '1' }); +}); + +test('a scoped homeDir gives an empty environment so ambient variables cannot leak into the install', () => { + withProbe('ambient', () => { + const resolved = resolveInvocationEnvironment({ homeDir: '/scoped/home' }); + assert.deepEqual(resolved, {}); + assert.equal(resolved.HOME, undefined); + assert.equal(resolved[PROBE], undefined); + }); +}); + +test('a blank or non-string homeDir does not count as scoped', () => { + withProbe('ambient', () => { + for (const homeDir of ['', ' ', '\n', null, undefined, 42, true]) { + assert.deepEqual(resolveInvocationEnvironment({ homeDir }), { ...process.env }, `homeDir ${JSON.stringify(homeDir)} should fall back to the ambient environment`); + } + }); +}); + +test('each call returns a distinct object', () => { + assert.notEqual(resolveInvocationEnvironment(), resolveInvocationEnvironment()); + assert.notEqual(resolveInvocationEnvironment({ homeDir: '/scoped' }), resolveInvocationEnvironment({ homeDir: '/scoped' })); +}); diff --git a/tests/run-all.js b/tests/run-all.js index d03efc67c..5e436c4fa 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -const { spawnSync } = require('child_process'); +const { spawn, spawnSync } = require('child_process'); const path = require('path'); const fs = require('fs'); @@ -8,10 +8,23 @@ const testsDir = __dirname; const repoRoot = path.resolve(testsDir, '..'); const TEST_GLOB = 'tests/**/*.test.js'; const filter = (((process.argv || [])[2]) || '').split(path.sep).join('/'); -const skipPatterns = (process.env.ECC_TEST_SKIP || '') +const splitPatterns = value => (value || '') .split(',') .map(s => s.trim()) .filter(Boolean); +// ECC_TEST_SKIP drops files whose path contains any pattern; ECC_TEST_ONLY keeps +// just the files whose path contains one. CI's package-manager lanes use ONLY to +// run the handful of suites that read CLAUDE_CODE_PACKAGE_MANAGER. +const skipPatterns = splitPatterns(process.env.ECC_TEST_SKIP); +const onlyPatterns = splitPatterns(process.env.ECC_TEST_ONLY); +// ECC_TEST_SERIAL names suites that mutate the shared checkout (for example +// the opencode dist build and the installers that copy from it). The pool +// holds them back and runs them one at a time after every other file. +const serialPatterns = splitPatterns(process.env.ECC_TEST_SERIAL); +// ECC_TEST_JOBS above 1 runs files through a worker pool of that many +// processes. The default is 1: the original synchronous loop, unchanged. +// Output stays grouped per file whatever the pool size. +const jobs = Math.max(1, Math.floor(Number(process.env.ECC_TEST_JOBS)) || 1); function matchesTestGlob(relativePath) { const normalized = relativePath.split(path.sep).join('/'); @@ -21,6 +34,9 @@ function matchesTestGlob(relativePath) { if (skipPatterns.some(pattern => normalized.includes(pattern))) { return false; } + if (onlyPatterns.length > 0 && !onlyPatterns.some(pattern => normalized.includes(pattern))) { + return false; + } if (typeof path.matchesGlob === 'function') { return path.matchesGlob(normalized, TEST_GLOB); } @@ -114,6 +130,14 @@ if (skipPatterns.length > 0) { console.log(`Skipped patterns: ${skipPatterns.join(', ')}`); console.log(); } +if (onlyPatterns.length > 0) { + console.log(`Only patterns: ${onlyPatterns.join(', ')}`); + console.log(); +} +if (jobs > 1) { + console.log(`Running ${testFiles.length} files with ${jobs} parallel jobs${serialPatterns.length ? `, serial after the pool: ${serialPatterns.join(', ')}` : ''}`); + console.log(); +} if (testFiles.length === 0) { console.log(`✗ No test files matched ${TEST_GLOB}${filter ? ` with filter "${filter}"` : ''}`); @@ -126,31 +150,54 @@ let filesPassed = 0; let filesFailed = 0; const failedFiles = []; -for (const testFile of testFiles) { - const testPath = path.join(testsDir, testFile); - const displayPath = testFile.split(path.sep).join('/'); - - if (!fs.existsSync(testPath)) { - console.log(`WARNING Skipping ${displayPath} (file not found)`); - continue; - } - - console.log(`\n━━━ Running ${displayPath} ━━━`); - +function childEnvFor() { const childEnv = { ...process.env }; - for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) { + // Git context would leak into fixtures; ONLY and JOBS are runner controls and + // must not reach suites that spawn the runner themselves. + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX', 'ECC_TEST_ONLY', 'ECC_TEST_JOBS', 'ECC_TEST_SERIAL']) { delete childEnv[key]; } + return childEnv; +} - const result = spawnSync('node', [testPath], { +function runTestFileSync(testFile) { + return spawnSync('node', [path.join(testsDir, testFile)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - env: childEnv + env: childEnvFor() }); +} +function runTestFile(testFile) { + const testPath = path.join(testsDir, testFile); + const childEnv = childEnvFor(); + return new Promise(resolve => { + let stdout = ''; + let stderr = ''; + let child; + try { + child = spawn('node', [testPath], { stdio: ['pipe', 'pipe', 'pipe'], env: childEnv }); + } catch (error) { + resolve({ error, status: null, signal: null, stdout, stderr }); + return; + } + // spawnSync with piped stdio and no input hands the child a closed stdin; + // spawn leaves it open, and a suite that reads stdin would wait forever. + child.stdin.end(); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', error => resolve({ error, status: null, signal: null, stdout, stderr })); + child.on('close', (status, signal) => resolve({ error: null, status, signal, stdout, stderr })); + }); +} + +function reportTestFile(displayPath, result) { const stdout = result.stdout || ''; const stderr = result.stderr || ''; + console.log(`\n━━━ Running ${displayPath} ━━━`); if (stdout) console.log(stdout); if (stderr) console.log(stderr); @@ -188,25 +235,71 @@ for (const testFile of testFiles) { } } -const totalTests = totalPassed + totalFailed; +function pendingTestFiles() { + return testFiles.filter(testFile => { + if (fs.existsSync(path.join(testsDir, testFile))) return true; + console.log(`WARNING Skipping ${testFile.split(path.sep).join('/')} (file not found)`); + return false; + }); +} -console.log('\n╔' + '═'.repeat(BOX_W) + '╗'); -console.log(boxLine(' Final Results')); -console.log('╠' + '═'.repeat(BOX_W) + '╣'); -console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`)); -console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`)); -console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`)); -console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`)); -if (failedFiles.length > 0) { - console.log('╠' + '═'.repeat(BOX_W) + '╣'); - for (const name of failedFiles.slice(0, 10)) { - console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W))); - } - if (failedFiles.length > 10) { - console.log(boxLine(` ... and ${failedFiles.length - 10} more`)); +function runAllSync() { + for (const testFile of pendingTestFiles()) { + reportTestFile(testFile.split(path.sep).join('/'), runTestFileSync(testFile)); } } -console.log('╚' + '═'.repeat(BOX_W) + '╝'); -console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`); -process.exitCode = totalFailed > 0 ? 1 : 0; +function isSerial(testFile) { + const normalized = testFile.split(path.sep).join('/'); + return serialPatterns.some(pattern => normalized.includes(pattern)); +} + +async function runAllParallel() { + const pending = pendingTestFiles(); + const queue = pending.filter(testFile => !isSerial(testFile)); + const serial = pending.filter(isSerial); + const worker = async () => { + while (queue.length > 0) { + const testFile = queue.shift(); + const result = await runTestFile(testFile); + reportTestFile(testFile.split(path.sep).join('/'), result); + } + }; + await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); + if (serial.length > 0) console.log(`\nRunning ${serial.length} serial files after the pool`); + for (const testFile of serial) { + reportTestFile(testFile.split(path.sep).join('/'), await runTestFile(testFile)); + } +} + +function finish() { + const totalTests = totalPassed + totalFailed; + + console.log('\n╔' + '═'.repeat(BOX_W) + '╗'); + console.log(boxLine(' Final Results')); + console.log('╠' + '═'.repeat(BOX_W) + '╣'); + console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`)); + console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`)); + console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`)); + console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`)); + if (failedFiles.length > 0) { + console.log('╠' + '═'.repeat(BOX_W) + '╣'); + for (const name of failedFiles.slice(0, 10)) { + console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W))); + } + if (failedFiles.length > 10) { + console.log(boxLine(` ... and ${failedFiles.length - 10} more`)); + } + } + console.log('╚' + '═'.repeat(BOX_W) + '╝'); + console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`); + + process.exitCode = totalFailed > 0 ? 1 : 0; +} + +if (jobs > 1) { + runAllParallel().then(finish); +} else { + runAllSync(); + finish(); +}