diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4ba1b4e2..ac5c9827a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -338,6 +338,12 @@ repos: # already corrected and staged-ready instead of as an instruction. - repo: local hooks: + - id: check-forbidden-terms-sync + name: check-forbidden-terms-sync (canonical scrub terms vs publication.py) + language: system + entry: python3 tools/dev/sync-forbidden-terms.py --check + files: ^(tools/vcs/src/magpie_vcs/publication\.py|tools/dev/sync-forbidden-terms\.py|plugins/magpie-security/hooks/forbidden-terms\.ts)$ + pass_filenames: false - id: check-family-plugins name: check-family-plugins (marketplace plugins vs frontmatter) language: system diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 89af72c8a..04746a2e9 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -36,6 +36,10 @@ - [Extensible — any skill can contribute a guard](#extensible--any-skill-can-contribute-a-guard) - [Install (user-scope)](#install-user-scope-1) - [Verify](#verify-1) + - [Public-surface scrub mod (Claude Code plugin / hook)](#public-surface-scrub-mod-claude-code-plugin--hook) + - [Term canonicalization](#term-canonicalization) + - [Scoping and fail-closed gates](#scoping-and-fail-closed-gates) + - [Trade-offs and runtime characteristics](#trade-offs-and-runtime-characteristics) - [Sandbox-error hint hook](#sandbox-error-hint-hook) - [Why install it](#why-install-it) - [Why install it user-scope, not project-scope](#why-install-it-user-scope-not-project-scope-1) @@ -1850,6 +1854,73 @@ Expected: a JSON object with `permissionDecision: "deny"` and a reason mentioning the `mention` guard. A plain command (`{"tool_input":{"command":"ls"}}`) produces no output and `exit=0`. +## Public-surface scrub mod (Claude Code plugin / hook) + +For Claude Code harnesses using plugins (such as `magpie-security`), +a deterministic TypeScript mod hook +([`plugins/magpie-security/hooks/public-surface-scrub.ts`](../../plugins/magpie-security/hooks/public-surface-scrub.ts)) +intercepts `Bash` tool calls (`git commit`, `git tag`, `git merge`, `gh pr create|edit|review|comment|merge|close`, `gh issue create|edit|comment|close`, `gh release create`, `gh api`) +before execution to prevent accidental early disclosure of security vulnerabilities on public surfaces. + +### Term canonicalization + +The mod does not maintain a private list of forbidden terms. The forbidden +disclosure terms and regex patterns are derived directly from the canonical +single source of truth: `magpie_vcs.publication.DEFAULT_TERMS` +in `tools/vcs/src/magpie_vcs/publication.py` (via `tools/dev/sync-forbidden-terms.py` +and tracked by the pre-commit sync check). + +### Scoping and fail-closed gates + +- **Private tracker exemption**: Subcommands explicitly targeting the private security + tracker repository (`tracker_repo` in `project.md`, e.g. `-R airflow-s/airflow-s`) + are exempt. When working directory context falls back to `$.session.root()` (i.e. `cwd` + is omitted in the tool call event), the tracker cwd exemption is disabled to protect + against undetected in-session `cd ../` transitions, falling through to the + deterministic term scan. +- **Upstream repository protection**: Subcommands targeting the public codebase + (`upstream_repo` in `project.md`) or operating within an upstream working tree + are scrubbed. Commands targeting unrelated third-party or adopter documentation + repositories pass unchecked. +- **`cd ` transition tracking**: Tracks working directory transitions across + composite subcommands (e.g. `cd ../upstream && git commit -m "..."`). +- **Deterministic git remote verification**: Repository scope is determined by + reading `.git/config` remotes rather than directory-name substring heuristics. +- **Fail-closed on indeterminate scope**: If the target repository scope cannot + be verified deterministically, commands containing embargo terms fail closed. +- **Fail-closed on unreadable files**: Invocations referencing message files + (`-F ` / `--file` / `--body-file`) fail closed if the referenced file cannot + be read. +- **Fail-closed on dynamic input**: Commands constructing commit or PR content + dynamically via variables (`$VAR`), command substitution (`$(...)`), stdin pipes + (`-`), or automatic reuse (`git commit --amend` without an explicit message, + `gh pr create --fill`) cannot be verified statically and fail closed. The block + explanation instructs providing text explicitly or via an inspectable message file + (`-F `). + +### Trade-offs and runtime characteristics + +The public-surface scrub mod is a deterministic guard against **accidental** +public disclosure of embargoed security information, not a boundary against +deliberate circumvention. A command running in a shell can always write to +temporary files, pipe through external helpers, or execute dynamic subshells +that cannot be fully predicted before execution. The hook documents these +boundaries rather than chasing dynamic time-of-check-to-time-of-use (TOCTOU) +scenarios: it fails closed on unresolvable shapes and indeterminate inputs, +while relying on collaborator review for anything outside its deterministic +static scope. + +- **Unsandboxed execution**: Claude Code mods and hooks execute directly in the + local Node runtime outside the container sandbox. +- **File inspection**: The hook inspects message files specified by `-F ` + via the Claude Code plugin filesystem capability (`$.fs.read()`). +- **Human-only escape hatch**: When legitimate post-coordinated-disclosure + maintenance requires mentioning CVE IDs or vulnerability terms on public surfaces, + a human maintainer sets `MAGPIE_ALLOW_EMBARGO_TERMS=1` in the environment + before starting the session. The escape hatch serves as a zero-token deterministic + guard against accidental leaks; inline environment variables passed to the command + line (e.g. `MAGPIE_ALLOW_EMBARGO_TERMS=1 git commit ...`) are ignored by the hook. + ## Sandbox-error hint hook Companion to the *Sandbox-bypass visibility hook* above — a diff --git a/plugins/magpie-security/.codex-plugin/plugin.json b/plugins/magpie-security/.codex-plugin/plugin.json new file mode 100644 index 000000000..407cdf5b1 --- /dev/null +++ b/plugins/magpie-security/.codex-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "magpie-security", + "description": "Apache Magpie \u2014 security-issue handling lifecycle \u2014 import through CVE publication, triage, sync, dedup, invalidate. Maintainer-only.", + "version": "0.9.0.dev202610251812", + "author": { + "name": "Apache Magpie", + "url": "https://magpie.apache.org/" + }, + "homepage": "https://magpie.apache.org/", + "repository": "https://github.com/apache/magpie", + "license": "Apache-2.0", + "skills": "./skills", + "hooks": "./hooks/codex-hooks.json" +} diff --git a/plugins/magpie-security/hooks/codex-hooks.json b/plugins/magpie-security/hooks/codex-hooks.json new file mode 100644 index 000000000..b0fac4109 --- /dev/null +++ b/plugins/magpie-security/hooks/codex-hooks.json @@ -0,0 +1,4 @@ +{ + "description": "Apache Magpie \u2014 no Codex hooks. hooks.json holds Claude Code mods only.", + "hooks": {} +} diff --git a/plugins/magpie-security/hooks/forbidden-terms.ts b/plugins/magpie-security/hooks/forbidden-terms.ts new file mode 100644 index 000000000..a2a6cc3e9 --- /dev/null +++ b/plugins/magpie-security/hooks/forbidden-terms.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Generated from tools/vcs/src/magpie_vcs/publication.py:DEFAULT_TERMS. Do not edit. + +export interface CanonicalForbiddenTerm { + name: string; + pattern: string; + flags: string; +} + +export const CANONICAL_FORBIDDEN_TERMS: ReadonlyArray = [ + + { name: "CVE id", pattern: "\\bCVE-\\d{4}-\\d{4,}\\b", flags: "i" }, + { name: "CVE", pattern: "\\bCVEs?\\b", flags: "i" }, + { name: "vulnerability", pattern: "\\bvulnerab(?:le|ility|ilities)\\b", flags: "i" }, + { name: "security fix", pattern: "\\bsecurity[\\s_-]+fix(?:es|ed)?\\b", flags: "i" }, + { name: "advisory", pattern: "\\badvisor(?:y|ies)\\b", flags: "i" }, + { name: "security@", pattern: "\\bsecurity@", flags: "i" }, + { name: "CVE-tool URL", pattern: "cveprocess\\.apache\\.org", flags: "i" }, + { name: "exploit", pattern: "\\bexploit(?:s|able|ed)?\\b", flags: "i" }, + { name: "SSRF", pattern: "\\bSSRF\\b", flags: "i" }, + { name: "XSS", pattern: "\\bXSS\\b", flags: "i" }, + { name: "CSRF", pattern: "\\bCSRF\\b", flags: "i" }, + { name: "RCE", pattern: "\\bRCE\\b", flags: "i" }, + { name: "SQL injection", pattern: "\\bSQL[\\s_-]+injection\\b", flags: "i" }, + { name: "path traversal", pattern: "\\b(?:path|directory)[\\s_-]+traversal\\b", flags: "i" }, + { name: "remote code execution", pattern: "\\b(?:remote|arbitrary)[\\s_-]+code[\\s_-]+execution\\b", flags: "i" }, + { name: "privilege escalation", pattern: "\\bprivilege[\\s_-]+escalation\\b", flags: "i" }, + { name: "auth bypass", pattern: "\\b(?:auth|authentication|authorization)[\\s_-]+bypass\\b", flags: "i" }, + { name: "buffer overflow", pattern: "\\b(?:buffer|heap|stack)[\\s_-]+overflow\\b", flags: "i" }, + { name: "use-after-free", pattern: "\\buse[\\s_-]+after[\\s_-]+free\\b", flags: "i" }, +]; diff --git a/plugins/magpie-security/hooks/hooks.json b/plugins/magpie-security/hooks/hooks.json new file mode 100644 index 000000000..9be5875fa --- /dev/null +++ b/plugins/magpie-security/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://code.claude.com/schemas/hooks.json", + "description": "Apache Magpie — Claude Code mod for deterministic public-surface scrub against unredacted security embargo leaks.", + "modules": [ + "./public-surface-scrub.ts" + ] +} diff --git a/plugins/magpie-security/hooks/public-surface-scrub.test.ts b/plugins/magpie-security/hooks/public-surface-scrub.test.ts new file mode 100644 index 000000000..da9e25451 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.test.ts @@ -0,0 +1,2311 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, test } from 'claude-code/testing'; +import { + evaluatePublicSurfaceCommand, + findForbiddenTerms, + inspectDirectoryRemote, + parseProjectManifest, + parseSubcommand, + register, + resolvePath, + resolveProjectConfig, + splitShellCommands, +} from './public-surface-scrub.ts'; + +function assert(condition: any, message?: string): void { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + +function assertStrictEqual(actual: any, expected: any, message?: string): void { + if (actual !== expected) { + throw new Error(message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`); + } +} + +function assertDeepStrictEqual(actual: any, expected: any): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Expected deep equality: ${JSON.stringify(expected)} !== ${JSON.stringify(actual)}`); + } +} + +describe('Deterministic Public-Surface Scrub mod (family:security)', () => { + describe('findForbiddenTerms', () => { + test('returns empty array on neutral behavioral changes', () => { + assertDeepStrictEqual(findForbiddenTerms('Fix asset graph view leaking DAGs outside user permissions'), []); + assertDeepStrictEqual(findForbiddenTerms('Add access_key and connection_string to DEFAULT_SENSITIVE_FIELDS'), []); + assertDeepStrictEqual(findForbiddenTerms('Improve xcom value handling in extra links API'), []); + assertDeepStrictEqual(findForbiddenTerms(''), []); + assertDeepStrictEqual(findForbiddenTerms(null as any), []); + }); + + test('detects CVE references', () => { + const hits = findForbiddenTerms('Fix CVE-2026-12345 in connection test'); + assert(hits.includes('CVE id')); + const bareHits = findForbiddenTerms('Address issue reported as cve'); + assert(bareHits.includes('CVE')); + }); + + test('detects vulnerability and security fix keywords (spaced and hyphenated)', () => { + assert(findForbiddenTerms('Fix critical vulnerability in parsing logic').includes('vulnerability')); + assert(findForbiddenTerms('close vulnerability').includes('vulnerability')); + assert(findForbiddenTerms('Security fix for webserver endpoints').includes('security fix')); + assert(findForbiddenTerms('Apply security-fix for session timeout').includes('security fix')); + assert(findForbiddenTerms('security advisory draft').includes('advisory')); + assert(findForbiddenTerms('Security advisory update').includes('advisory')); + assert(findForbiddenTerms('Contact security@apache.org for details').includes('security@')); + }); + + test('detects vulnerability classes with whitespace and hyphens', () => { + assert(findForbiddenTerms('Prevent SSRF in HTTP provider').includes('SSRF')); + assert(findForbiddenTerms('fix: SSRF').includes('SSRF')); + assert(findForbiddenTerms('Mitigate stored XSS in log view').includes('XSS')); + assert(findForbiddenTerms('Add CSRF token validation').includes('CSRF')); + assert(findForbiddenTerms('Prevent SQL injection in search filter').includes('SQL injection')); + assert(findForbiddenTerms('Mitigate SQL-injection in query filter').includes('SQL injection')); + assert(findForbiddenTerms('Fix path traversal in file download').includes('path traversal')); + assert(findForbiddenTerms('Prevent path-traversal in file handler').includes('path traversal')); + assert(findForbiddenTerms('Prevent remote code execution in worker').includes('remote code execution')); + assert(findForbiddenTerms('Close RCE vector in serializer').includes('RCE')); + assert(findForbiddenTerms('Fix remote-code-execution vulnerability').includes('remote code execution')); + assert(findForbiddenTerms('fix: Remote-Code-Execution bug').includes('remote code execution')); + assert(findForbiddenTerms('Fix privilege escalation in role mapper').includes('privilege escalation')); + assert(findForbiddenTerms('Prevent privilege-escalation bug').includes('privilege escalation')); + assert(findForbiddenTerms('Fix auth bypass in REST API').includes('auth bypass')); + assert(findForbiddenTerms('Mitigate auth-bypass vulnerability').includes('auth bypass')); + assert(findForbiddenTerms('auth_bypass').includes('auth bypass')); + assert(findForbiddenTerms('Buffer overflow in memory reader').includes('buffer overflow')); + assert(findForbiddenTerms('Use-after-free in parser loop').includes('use-after-free')); + }); + + test('returns multiple matched terms uniquely', () => { + const terms = findForbiddenTerms('Fix CVE-2026-9999: critical SSRF vulnerability allowing auth-bypass'); + assert(terms.includes('CVE id')); + assert(terms.includes('SSRF')); + assert(terms.includes('vulnerability')); + assert(terms.includes('auth bypass')); + }); + }); + + describe('parseProjectManifest & resolveProjectConfig', () => { + test('extracts tracker_repo and upstream_repo from markdown tables', () => { + const sample = ` +# Project Manifest +| Key | Value | Purpose | +|---|---|---| +| \`tracker_repo\` | \`airflow-s/airflow-s\` | Private security tracker | +| \`upstream_repo\` | \`apache/airflow\` | Public codebase where fixes land | +`; + const parsed = parseProjectManifest(sample); + assertStrictEqual(parsed.trackerRepo, 'airflow-s/airflow-s'); + assertStrictEqual(parsed.upstreamRepo, 'apache/airflow'); + }); + + test('extracts tracker_repo and upstream_repo from yaml format', () => { + const sample = ` +tracker_repo: kafka-s/kafka-s +upstream_repo: apache/kafka +`; + const parsed = parseProjectManifest(sample); + assertStrictEqual(parsed.trackerRepo, 'kafka-s/kafka-s'); + assertStrictEqual(parsed.upstreamRepo, 'apache/kafka'); + }); + + test('ignores TODO placeholders in project.md template', () => { + const sample = ` +| tracker_repo | TODO: e.g. foo-s/foo-s | +| upstream_repo | TODO: e.g. apache/foo | +`; + const parsed = parseProjectManifest(sample); + assertStrictEqual(parsed.trackerRepo, undefined); + assertStrictEqual(parsed.upstreamRepo, undefined); + }); + + test('ignores "not used" descriptions and extracts clean slugs with annotations', () => { + const sample = ` +| tracker_repo | not used — Apache Magpie has no private security tracker; reports follow the ASF default process via security@apache.org | +| upstream_repo | apache/magpie (derived from the upstream git remote) | +`; + const parsed = parseProjectManifest(sample); + assertStrictEqual(parsed.trackerRepo, undefined); + assertStrictEqual(parsed.upstreamRepo, 'apache/magpie'); + }); + + test('resolveProjectConfig probes in precedence order and sets isTrackerCheckout false for parent dirs', async () => { + const mockFiles: Record = { + '.apache-magpie-overrides/project.md': '| tracker_repo | overrides-s/tracker |\n| upstream_repo | apache/overrides |', + }; + + const resolved = await resolveProjectConfig(async (p) => mockFiles[p]); + assertStrictEqual(resolved.trackerRepo, 'overrides-s/tracker'); + assertStrictEqual(resolved.upstreamRepo, 'apache/overrides'); + assertStrictEqual(resolved.isTrackerCheckout, true); + + // Probing via parent/sibling directory (../) must NOT mark current checkout as tracker + const siblingFiles: Record = { + '../.apache-magpie-local/project.md': '| tracker_repo | tracker-s/tracker-s |\n| upstream_repo | apache/upstream |', + }; + const siblingResolved = await resolveProjectConfig(async (p) => siblingFiles[p]); + assertStrictEqual(siblingResolved.trackerRepo, 'tracker-s/tracker-s'); + assertStrictEqual(siblingResolved.isTrackerCheckout, false); + }); + }); + + describe('splitShellCommands', () => { + test('splits on semicolons, conditional operators, pipes, and newlines', () => { + const raw = 'git status; git commit -m "Clean"\ngh pr create -t "Title" && git tag v1.0 || exit 1'; + const parts = splitShellCommands(raw); + assertDeepStrictEqual(parts, [ + 'git status', + 'git commit -m "Clean"', + 'gh pr create -t "Title"', + 'git tag v1.0', + 'exit 1', + ]); + }); + + test('preserves semicolons and newlines inside quoted strings', () => { + const raw = 'git commit -m "Header line; second part\nThird line" ; git push'; + const parts = splitShellCommands(raw); + assertStrictEqual(parts.length, 2); + assertStrictEqual(parts[0], 'git commit -m "Header line; second part\nThird line"'); + assertStrictEqual(parts[1], 'git push'); + }); + }); + + describe('resolvePath', () => { + test('resolves relative path against git -C directory without node:path', () => { + assertStrictEqual(resolvePath('/path/to/upstream', 'msg.txt'), '/path/to/upstream/msg.txt'); + assertStrictEqual(resolvePath('/path/to/upstream/', 'msg.txt'), '/path/to/upstream/msg.txt'); + assertStrictEqual(resolvePath('/path/to/upstream', './msg.txt'), '/path/to/upstream/msg.txt'); + assertStrictEqual(resolvePath('/path/to/upstream', '"msg.txt"'), '/path/to/upstream/msg.txt'); + assertStrictEqual(resolvePath('/path/to/upstream', '/abs/msg.txt'), '/abs/msg.txt'); + assertStrictEqual(resolvePath(undefined, 'msg.txt'), 'msg.txt'); + }); + }); + + describe('parseSubcommand', () => { + test('identifies git commit with -m and combined flags', () => { + const parsed = parseSubcommand('git commit -m "Fix asset graph view"'); + assertStrictEqual(parsed.isPublicSurfaceCommand, true); + assertStrictEqual(parsed.commandType, 'git commit'); + assertDeepStrictEqual(parsed.extractedTexts, ['Fix asset graph view']); + + const am = parseSubcommand('git commit -am "Fix view"'); + assertDeepStrictEqual(am.extractedTexts, ['Fix view']); + + const sam = parseSubcommand('git commit -sam "Signed and added"'); + assertDeepStrictEqual(sam.extractedTexts, ['Signed and added']); + }); + + test('identifies attached short options without whitespace (-m"...", -m\'...\', -mval, -Fpath)', () => { + const attachedM = parseSubcommand('git commit -m"Fix view"'); + assertDeepStrictEqual(attachedM.extractedTexts, ['Fix view']); + + const attachedSingleM = parseSubcommand("git commit -m'Fix view'"); + assertDeepStrictEqual(attachedSingleM.extractedTexts, ['Fix view']); + + const attachedBareM = parseSubcommand('git commit -mFixView'); + assertDeepStrictEqual(attachedBareM.extractedTexts, ['FixView']); + + const attachedF = parseSubcommand('git commit -Fmsg.txt'); + assertDeepStrictEqual(attachedF.referencedFiles, ['msg.txt']); + + const attachedT = parseSubcommand('gh pr create -t"Title" -b"Body"'); + assert(attachedT.extractedTexts.includes('Title')); + assert(attachedT.extractedTexts.includes('Body')); + }); + + test('identifies git tag with annotated message or file', () => { + const tagM = parseSubcommand('git tag -a v1.0.0 -m "Release v1.0.0"'); + assertStrictEqual(tagM.isPublicSurfaceCommand, true); + assertStrictEqual(tagM.commandType, 'git tag'); + assert(tagM.extractedTexts.includes('v1.0.0')); + assert(tagM.extractedTexts.includes('Release v1.0.0')); + + const tagF = parseSubcommand('git tag -F tag-notes.txt v1.0.0'); + assertStrictEqual(tagF.isPublicSurfaceCommand, true); + assert(tagF.extractedTexts.includes('v1.0.0')); + assertDeepStrictEqual(tagF.referencedFiles, ['tag-notes.txt']); + + // Lightweight tag creation scans the tag name for forbidden disclosure terms + const bareTag = parseSubcommand('git tag v1.0.0'); + assertStrictEqual(bareTag.isPublicSurfaceCommand, true); + assertDeepStrictEqual(bareTag.extractedTexts, ['v1.0.0']); + }); + + test('identifies gh pr edit and gh pr review', () => { + const edit = parseSubcommand('gh pr edit 123 --title "New Title" --body "New Body"'); + assertStrictEqual(edit.isPublicSurfaceCommand, true); + assertStrictEqual(edit.commandType, 'gh pr edit'); + assert(edit.extractedTexts.includes('New Title')); + assert(edit.extractedTexts.includes('New Body')); + + const review = parseSubcommand('gh pr review 123 --body "Approved changes"'); + assertStrictEqual(review.isPublicSurfaceCommand, true); + assertStrictEqual(review.commandType, 'gh pr review'); + assertDeepStrictEqual(review.extractedTexts, ['Approved changes']); + }); + + test('identifies gh issue comment', () => { + const comment = parseSubcommand('gh issue comment 456 --body "Under investigation"'); + assertStrictEqual(comment.isPublicSurfaceCommand, true); + assertStrictEqual(comment.commandType, 'gh issue comment'); + assertDeepStrictEqual(comment.extractedTexts, ['Under investigation']); + }); + + test('identifies gh api comment creation with -f body=... and quoted key-value pairs', () => { + const apiCmd = parseSubcommand('gh api repos/apache/airflow/issues/1/comments -f body="Fixed problem"'); + assertStrictEqual(apiCmd.isPublicSurfaceCommand, true); + assertStrictEqual(apiCmd.commandType, 'gh api'); + assertStrictEqual(apiCmd.targetRepo, 'apache/airflow'); + assertDeepStrictEqual(apiCmd.extractedTexts, ['Fixed problem']); + + const apiQuotedPair = parseSubcommand('gh api repos/apache/airflow/issues/1/comments -f "body=Fixed problem"'); + assertDeepStrictEqual(apiQuotedPair.extractedTexts, ['Fixed problem']); + + const apiField = parseSubcommand('gh api repos/apache/airflow/issues/1/comments --field "body=Fixed problem"'); + assertDeepStrictEqual(apiField.extractedTexts, ['Fixed problem']); + + const apiRawField = parseSubcommand("gh api repos/apache/airflow/issues/1/comments --raw-field 'body=Fixed problem'"); + assertDeepStrictEqual(apiRawField.extractedTexts, ['Fixed problem']); + }); + + test('extracts git -C directory and -R target repo', () => { + const parsed = parseSubcommand('git -C /home/repo commit -m "Fix"'); + assertStrictEqual(parsed.gitCwd, '/home/repo'); + + const ghParsed = parseSubcommand('gh pr create -R apache/airflow -t "T" -b "B"'); + assertStrictEqual(ghParsed.targetRepo, 'apache/airflow'); + }); + + test('detects dynamic inputs (heredoc, command substitution, variables, stdin pipe, process substitution)', () => { + assertStrictEqual(parseSubcommand("git commit -F - << 'EOF'\nEOF").hasDynamicInput, true); + assertStrictEqual(parseSubcommand('git commit -m "$(cat msg.txt)"').hasDynamicInput, true); + assertStrictEqual(parseSubcommand('git commit -m "`echo test`"').hasDynamicInput, true); + assertStrictEqual(parseSubcommand('git commit -m "$COMMIT_MSG"').hasDynamicInput, true); + assertStrictEqual(parseSubcommand('git commit -F -').hasDynamicInput, true); + assertStrictEqual(parseSubcommand('git commit -F <(echo test)').hasDynamicInput, true); + }); + }); + + describe('evaluatePublicSurfaceCommand', () => { + const defaultProject = { + trackerRepo: 'airflow-s/airflow-s', + upstreamRepo: 'apache/airflow', + }; + + test('allows clean neutral commit', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -m "Fix asset graph view"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, false); + assertDeepStrictEqual(res.matchedTerms, []); + }); + + test('blocks git commit containing CVE reference', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -m "Fix CVE-2026-1234 in webserver"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + assert(res.reason?.includes('Apache Magpie Security Guard')); + assert(res.reason?.includes('Confidentiality Rule')); + }); + + test('blocks git commit with attached -m"..." (no whitespace)', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -m"Fix CVE-2026-5555"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('blocks git commit with attached -am"..." flag', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -am"Prevent critical SSRF bug"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('blocks git tag with forbidden terms in annotated message', async () => { + const res = await evaluatePublicSurfaceCommand('git tag -a v1.0.0 -m "Release with CVE-2026-1111 fix"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('blocks gh pr edit containing forbidden terms', async () => { + const res = await evaluatePublicSurfaceCommand('gh pr edit 50 -R apache/airflow --title "Fix CVE-2026-9999"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('blocks gh pr review containing forbidden terms', async () => { + const res = await evaluatePublicSurfaceCommand('gh pr review 50 -R apache/airflow --body "Looks good, fixes the SSRF"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('blocks gh issue comment containing forbidden terms', async () => { + const res = await evaluatePublicSurfaceCommand('gh issue comment 50 -R apache/airflow -b "Confirmed auth-bypass vulnerability"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('auth bypass')); + assert(res.matchedTerms.includes('vulnerability')); + }); + + test('blocks gh api comment creation with forbidden terms', async () => { + const res = await evaluatePublicSurfaceCommand('gh api repos/apache/airflow/issues/1/comments -f body="Fixed CVE-2026-1000"', { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('per-subcommand isolation: blocks leaking subcommand even if previous targets tracker', async () => { + const multiCmd = 'gh pr view 1 -R airflow-s/airflow-s; gh pr comment 2 -R apache/airflow -b "Fixes CVE-2026-9999 SSRF"'; + const res = await evaluatePublicSurfaceCommand(multiCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('newline separation: detects command separated by newline', async () => { + const newlineCmd = 'git status\ngit commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(newlineCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('multi-command sequence: git commit clean && gh pr create leaking', async () => { + const seqCmd = 'git commit -m "Neutral message" && gh pr create -R apache/airflow -t "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(seqCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('fails closed if referenced message file cannot be read', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -F missing.txt', { + projectConfig: defaultProject, + readFile: async () => null, + }); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('Referenced message file cannot be read') || res.reason?.includes('could not be read')); + assert(res.reason?.includes('missing.txt')); + }); + + test('resolves file path relative to git -C directory', async () => { + const mockFiles: Record = { + '/upstream/msg.txt': 'Neutral headline\n\nFixes critical SSRF vulnerability.', + }; + const res = await evaluatePublicSurfaceCommand('git -C /upstream commit -F msg.txt', { + projectConfig: defaultProject, + readFile: async (p) => mockFiles[p], + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('SSRF')); + assert(res.matchedTerms.includes('vulnerability')); + }); + + test('blocks dynamic input pipelines when forbidden terms appear in command line', async () => { + const pipeCmd = 'echo "Fixes critical SSRF vulnerability" | gh pr create -R apache/airflow -t "Fix" --body-file -'; + const res = await evaluatePublicSurfaceCommand(pipeCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('fails closed on indeterminate dynamic inputs (variables, command substitution, stdin, --fill)', async () => { + // Variable in commit message + const varCmd = await evaluatePublicSurfaceCommand('git commit -m "$COMMIT_MSG"', { + projectConfig: defaultProject, + }); + assertStrictEqual(varCmd.blocked, true); + assert(varCmd.matchedTerms.includes('dynamic input')); + assert(varCmd.reason?.includes('indeterminate dynamic input')); + + // Command substitution + const subCmd = await evaluatePublicSurfaceCommand('git commit -m "$(cat /tmp/msg.txt)"', { + projectConfig: defaultProject, + }); + assertStrictEqual(subCmd.blocked, true); + assert(subCmd.matchedTerms.includes('dynamic input')); + + // Piped stdin + const stdinCmd = await evaluatePublicSurfaceCommand('cat msg.txt | git commit -F -', { + projectConfig: defaultProject, + }); + assertStrictEqual(stdinCmd.blocked, true); + assert(stdinCmd.matchedTerms.includes('dynamic input')); + + // gh pr create --fill + const fillCmd = await evaluatePublicSurfaceCommand('gh pr create -R apache/airflow --fill', { + projectConfig: defaultProject, + }); + assertStrictEqual(fillCmd.blocked, true); + assert(fillCmd.matchedTerms.includes('dynamic input')); + + // Process substitution <(...) + const procSubCmd = await evaluatePublicSurfaceCommand('git commit -F <(echo test)', { + projectConfig: defaultProject, + }); + assertStrictEqual(procSubCmd.blocked, true); + assert(procSubCmd.matchedTerms.includes('dynamic input')); + }); + + test('handles git commit --amend: blocks indeterminate reuse; allows clean explicit message', async () => { + // Amend without message flag reuses unverified prior commit -> blocked + const bareAmend = await evaluatePublicSurfaceCommand('git commit --amend', { + projectConfig: defaultProject, + }); + assertStrictEqual(bareAmend.blocked, true); + assert(bareAmend.matchedTerms.includes('dynamic input')); + + const noEditAmend = await evaluatePublicSurfaceCommand('git commit --amend --no-edit', { + projectConfig: defaultProject, + }); + assertStrictEqual(noEditAmend.blocked, true); + assert(noEditAmend.matchedTerms.includes('dynamic input')); + + // Amend with explicit clean message -> allowed + const cleanAmend = await evaluatePublicSurfaceCommand('git commit --amend -m "Update asset graph view"', { + projectConfig: defaultProject, + }); + assertStrictEqual(cleanAmend.blocked, false); + + // Amend with explicit leaking message -> blocked on terms + const leakingAmend = await evaluatePublicSurfaceCommand('git commit --amend -m "Fix CVE-2026-9999 SSRF"', { + projectConfig: defaultProject, + }); + assertStrictEqual(leakingAmend.blocked, true); + assert(leakingAmend.matchedTerms.includes('CVE id')); + assert(leakingAmend.matchedTerms.includes('SSRF')); + }); + + test('allows commands explicitly targeting the private security tracker', async () => { + const trackerCmd = 'gh pr comment 45 -R airflow-s/airflow-s -b "Confirmed SSRF fix for CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(trackerCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, false); + }); + + test('allows commands targeting other repositories (adopter docs, framework, etc.) and scopes strictly to upstream_repo', async () => { + // Adopter docs repo is not upstreamRepo -> passes unchecked + const docsRepoCmd = 'gh pr comment 45 -R apache/airflow-site -b "Update CVE-2026-1234 advisory docs"'; + const docsRes = await evaluatePublicSurfaceCommand(docsRepoCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(docsRes.blocked, false, 'docsRes should not be blocked'); + + // Framework repo is not upstreamRepo -> passes unchecked + const frameworkCmd = 'gh pr comment 1 -R apache/magpie -b "fix(generate-cve-json): CVE parser"'; + const frameworkRes = await evaluatePublicSurfaceCommand(frameworkCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(frameworkRes.blocked, false, 'frameworkRes should not be blocked'); + + // Upstream repo is strictly blocked + const upstreamCmd = 'gh pr comment 45 -R apache/airflow -b "Fix CVE-2026-1234"'; + const upstreamRes = await evaluatePublicSurfaceCommand(upstreamCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(upstreamRes.blocked, true, 'upstreamRes should be blocked'); + assert(upstreamRes.matchedTerms.includes('CVE id')); + + // If upstream_repo happens to contain 'private' or end in '-s', it does NOT fail open on naming + const upstreamWithPrivate = 'gh pr comment 45 -R myorg/foo-private -b "Fix SSRF"'; + const resPrivate = await evaluatePublicSurfaceCommand(upstreamWithPrivate, { + projectConfig: { trackerRepo: 'other-s/other-s', upstreamRepo: 'myorg/foo-private' }, + }); + assertStrictEqual(resPrivate.blocked, true, 'resPrivate should be blocked'); + assert(resPrivate.matchedTerms.includes('SSRF')); + + // Custom tracker repo not ending in '-s' is properly exempt + const customTrackerCmd = 'gh pr comment 1 -R apache/foo-security -b "Investigating CVE-2026-9999"'; + const customTrackerRes = await evaluatePublicSurfaceCommand(customTrackerCmd, { + projectConfig: { trackerRepo: 'apache/foo-security', upstreamRepo: 'apache/foo' }, + }); + assertStrictEqual(customTrackerRes.blocked, false, 'customTrackerRes should not be blocked'); + + // Clean commit in unconfigured repository passes + const unconfiguredCleanRes = await evaluatePublicSurfaceCommand('git commit -m "refactor(json): handle schema validation"', { + projectConfig: {}, + }); + assertStrictEqual(unconfiguredCleanRes.blocked, false, 'unconfiguredCleanRes should not be blocked'); + + // Leaking commit in unconfigured repository fails closed + const unconfiguredLeakingRes = await evaluatePublicSurfaceCommand('git commit -m "fix(generate-cve-json): validate CVE schema"', { + projectConfig: {}, + }); + assertStrictEqual(unconfiguredLeakingRes.blocked, true, 'unconfiguredLeakingRes should be blocked'); + + // Commands targeting non-upstream repos pass unchecked + const magpiePrRes = await evaluatePublicSurfaceCommand('gh pr create -R apache/magpie -t "fix(generate-cve-json): fix vulnerability test"', { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(magpiePrRes.blocked, false, 'magpiePrRes should not be blocked'); + + // git -C in unrelated repo (verified via remote inspection) passes; git -C in upstream repo blocks + const mockGitConfigs: Record = { + '/home/user/magpie/.git/config': '[remote "origin"]\nurl = https://github.com/apache/magpie.git\n', + '/home/user/airflow/.git/config': '[remote "origin"]\nurl = https://github.com/apache/airflow.git\n', + }; + const unrelatedGitCwd = await evaluatePublicSurfaceCommand('git -C /home/user/magpie commit -m "fix: CVE test"', { + projectConfig: defaultProject, + readFile: async (p) => mockGitConfigs[p], + }); + assertStrictEqual(unrelatedGitCwd.blocked, false, 'unrelatedGitCwd should not be blocked'); + + const upstreamGitCwd = await evaluatePublicSurfaceCommand('git -C /home/user/airflow commit -m "fix: CVE-2026-1234"', { + projectConfig: defaultProject, + readFile: async (p) => mockGitConfigs[p], + }); + assertStrictEqual(upstreamGitCwd.blocked, true, 'upstreamGitCwd should be blocked'); + + // Fork of upstream repo (e.g. contributor/airflow) MUST be blocked — cannot fail open! + const forkGitConfigs: Record = { + '/home/user/fork/.git/config': '[remote "origin"]\nurl = https://github.com/contributor/airflow.git\n', + }; + const forkGitCwd = await evaluatePublicSurfaceCommand('git -C /home/user/fork commit -m "Fix CVE-2026-1234 in fork"', { + projectConfig: defaultProject, + readFile: async (p) => forkGitConfigs[p], + }); + assertStrictEqual(forkGitCwd.blocked, true, 'forkGitCwd should be blocked'); + assert(forkGitCwd.matchedTerms.includes('CVE id')); + + // PR creation targeting a fork of upstream MUST be blocked + const forkPrRes = await evaluatePublicSurfaceCommand('gh pr create -R contributor/airflow -t "Fix CVE-2026-1234"', { + projectConfig: defaultProject, + }); + assertStrictEqual(forkPrRes.blocked, true, 'forkPrRes should be blocked'); + assert(forkPrRes.matchedTerms.includes('CVE id')); + + // Git worktree with .git file pointing to gitdir: ... must be inspected and blocked if upstream + const worktreeConfigs: Record = { + '/home/user/worktree/.git': 'gitdir: /home/user/main-repo/.git/worktrees/fix-branch\n', + '/home/user/main-repo/.git/worktrees/fix-branch/config': '[remote "origin"]\nurl = https://github.com/apache/airflow.git\n', + }; + const worktreeRes = await evaluatePublicSurfaceCommand('git -C /home/user/worktree commit -m "Fix CVE-2026-1234"', { + projectConfig: defaultProject, + readFile: async (p) => worktreeConfigs[p], + }); + assertStrictEqual(worktreeRes.blocked, true, 'worktreeRes should be blocked'); + assert(worktreeRes.matchedTerms.includes('CVE id')); + }); + + test('tracks cd across sub-commands and blocks upstream leaks', async () => { + const mockGitConfigs: Record = { + '/workspace/upstream/.git/config': '[remote "origin"]\nurl = https://github.com/apache/airflow.git\n', + }; + const chainedCdCmd = 'cd /workspace/upstream && git commit -m "Fix CVE-2026-9999"'; + const res = await evaluatePublicSurfaceCommand(chainedCdCmd, { + projectConfig: defaultProject, + readFile: async (p) => mockGitConfigs[p], + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('git -c config option does not collide with -C directory option', async () => { + const configCmd = 'git -c user.name=bot commit -m "Neutral message"'; + const res = await evaluatePublicSurfaceCommand(configCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, false); + }); + + test('handles escaped quotes in commit messages', async () => { + const escapedQuoteCmd = 'git commit -m "Refactor \\"parser\\" for CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(escapedQuoteCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('--repo inside a body cannot forge tracker exemption', async () => { + const fakeRepoBody = 'gh pr create -R apache/airflow -b "--repo airflow-s/airflow-s" -t "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(fakeRepoBody, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('dynamic input fail-closed reason mentions -F guidance', async () => { + const dynamicCmd = 'git commit -m "$(cat /tmp/msg.txt)"'; + const res = await evaluatePublicSurfaceCommand(dynamicCmd, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('-F ')); + }); + + test('escape hatch: cannot be bypassed from command-line injection', async () => { + const cmdWithInlineEnv = 'MAGPIE_ALLOW_EMBARGO_TERMS=1 git commit -m "CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmdWithInlineEnv, { + projectConfig: defaultProject, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + + const cmdWithFlag = 'git commit -m "CVE-2026-1234 --allow-embargo-terms"'; + const res2 = await evaluatePublicSurfaceCommand(cmdWithFlag, { + projectConfig: defaultProject, + }); + assertStrictEqual(res2.blocked, true); + assert(res2.matchedTerms.includes('CVE id')); + }); + + test('escape hatch: honored ONLY when set in environment by human', async () => { + const cmd = 'git commit -m "Backport CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: defaultProject, + env: { MAGPIE_ALLOW_EMBARGO_TERMS: '1' }, + }); + assertStrictEqual(res.blocked, false); + }); + + test('blocking reason does NOT instruct agent how to bypass; tells to ask maintainer', async () => { + const res = await evaluatePublicSurfaceCommand('git commit -m "Fix CVE-2026-1234"', { + projectConfig: defaultProject, + }); + assert(res.reason?.includes('consult an Apache Magpie security-team maintainer to review and set the environment bypass')); + assert(!res.reason?.includes('MAGPIE_ALLOW_EMBARGO_TERMS')); + assert(!res.reason?.includes('--allow-embargo-terms')); + }); + }); + + describe('CLI parser & scoping enhancements (PR #1676 review #3)', () => { + test('extracts target repo from PR and issue URLs', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res1 = await evaluatePublicSurfaceCommand( + 'gh pr comment https://github.com/apache/airflow/pull/1 -b "Fix CVE-2026-1234 SSRF"', + { readFile: async () => mockProject } + ); + assertStrictEqual(res1.blocked, true); + assert(res1.reason?.includes('CVE id')); + + const res2 = await evaluatePublicSurfaceCommand( + 'gh pr edit https://github.com/apache/airflow/pull/1 --body "Fix CVE-2026-1234 SSRF"', + { readFile: async () => mockProject } + ); + assertStrictEqual(res2.blocked, true); + }); + + test('extracts target repo from leading GH_REPO= environment variable', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res = await evaluatePublicSurfaceCommand( + 'GH_REPO=apache/airflow gh pr comment 1 -b "Fix CVE-2026-1234 SSRF"', + { readFile: async () => mockProject } + ); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('SSRF')); + }); + + test('inspects referenced file in gh pr review --body-file', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res = await evaluatePublicSurfaceCommand( + 'gh pr review 1 -R apache/airflow --comment --body-file /path/to/review.md', + { + readFile: async (p: string) => { + if (p.includes('project.md')) return mockProject; + if (p.includes('review.md')) return 'Patch addresses CVE-2026-9999'; + return null; + }, + } + ); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('CVE id')); + }); + + test('inspects gh api --input and -F body=@file', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const resInput = await evaluatePublicSurfaceCommand( + 'gh api repos/apache/airflow/issues/1/comments --input /path/to/payload.json', + { + readFile: async (p: string) => { + if (p.includes('project.md')) return mockProject; + if (p.includes('payload.json')) return '{"body": "Fix CVE-2026-1234 SSRF"}'; + return null; + }, + } + ); + assertStrictEqual(resInput.blocked, true); + + const resAtFile = await evaluatePublicSurfaceCommand( + 'gh api repos/apache/airflow/issues/1/comments -F body=@msg.md', + { + readFile: async (p: string) => { + if (p.includes('project.md')) return mockProject; + if (p.includes('msg.md')) return 'Fix CVE-2026-1234 SSRF'; + return null; + }, + } + ); + assertStrictEqual(resAtFile.blocked, true); + }); + + test('inspects gh api graphql mutation for forbidden terms', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res = await evaluatePublicSurfaceCommand( + 'gh api graphql -f query="mutation{addComment(input:{body:\\\"CVE-2026-1234 SSRF\\\"})}"', + { readFile: async () => mockProject } + ); + assertStrictEqual(res.blocked, true); + }); + + test('splits subcommands on single & separator', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res = await evaluatePublicSurfaceCommand( + 'git -C ../airflow status & git -C ../airflow commit -m "Fix CVE-2026-1234 SSRF"', + { readFile: async () => mockProject } + ); + assertStrictEqual(res.blocked, true); + }); + + test('allows literal single-quoted $VAR like -m \'costs $HOME\'', async () => { + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + const res = await evaluatePublicSurfaceCommand( + "git commit -m 'costs $HOME'", + { readFile: async () => mockProject } + ); + assertStrictEqual(res.blocked, false); + }); + + test('allows commands in unrelated repository without Magpie config when remote is determined and message is neutral', async () => { + const gitConfig = ` +[remote "origin"] + url = https://github.com/third-party/utility-lib.git +`; + // When upstreamRepo is unknown, unconfigured repos fall through to term scan (Blocking #2 fix). + // Neutral commits pass: + const res = await evaluatePublicSurfaceCommand( + 'git commit -m "fix(generate-json): tidy"', + { + readFile: async (p: string) => { + if (p.includes('.git/config')) return gitConfig; + return null; + }, + } + ); + assertStrictEqual(res.blocked, false); + + // Verified non-upstream remote (when upstreamRepo is configured) exempts even terms like cve: + const verifiedRes = await evaluatePublicSurfaceCommand( + 'git commit -m "fix(generate-cve-json): tidy"', + { + projectConfig: { + trackerRepo: 'apache/magpie-tracker', + upstreamRepo: 'apache/airflow', + }, + readFile: async (p: string) => { + if (p.includes('.git/config')) return gitConfig; + return null; + }, + } + ); + assertStrictEqual(verifiedRes.blocked, false); + }); + }); + + describe('Claude Code hook integration (register)', () => { + test('registers hook with Bash tool filter and allows clean command', async () => { + let registeredFilter: any = null; + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + if (typeof filterOrHandler === 'function') { + registeredHook = filterOrHandler; + } else { + registeredFilter = filterOrHandler; + registeredHook = handler; + } + } + }; + + register(on); + assert(registeredHook, 'tool.call hook must be registered'); + assertStrictEqual(registeredFilter?.tool, 'Bash'); + + let nextCalled = false; + const mockEvent = { command: 'git commit -m "Fix asset graph view"' }; + const next = (e: any) => { + nextCalled = true; + return { success: true, event: e }; + }; + + const result = await registeredHook( + { fs: { read: async () => '' }, env: { get: async () => undefined } }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, true); + assertStrictEqual(result.success, true); + }); + + test('intercepts and blocks leaking Bash tool call returning deny', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + + register(on); + + const mockProject = ` +| Key | Value | +| tracker_repo | airflow-s/airflow-s | +| upstream_repo | apache/airflow | +`; + + let nextCalled = false; + const mockEvent = { + command: 'gh pr create -R apache/airflow -t "Fix CVE-2026-1111: SSRF vulnerability"', + }; + const next = () => { + nextCalled = true; + }; + + const result = await registeredHook( + { fs: { read: async () => mockProject }, env: { get: async () => undefined } }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, false, 'next() must NOT be called on blocked commands'); + assert(result.deny, 'Deny explanation must be returned'); + assert(result.deny.includes('Apache Magpie Security Guard')); + assert(result.deny.includes('CVE id')); + assert(result.deny.includes('SSRF')); + }); + + test('honors MAGPIE_ALLOW_EMBARGO_TERMS=1 via $.env.get in register()', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + + register(on); + + let nextCalled = false; + const mockEvent = { + command: 'gh pr create -R apache/airflow -t "fix(generate-cve-json): handle CVE fields"', + }; + const next = (e: any) => { + nextCalled = true; + return { forwarded: e }; + }; + + const result = await registeredHook( + { + fs: { read: async () => '' }, + env: { get: async (k: string) => (k === 'MAGPIE_ALLOW_EMBARGO_TERMS' ? '1' : undefined) }, + }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, true, 'next() must be called when MAGPIE_ALLOW_EMBARGO_TERMS=1 is set'); + assertStrictEqual(result.forwarded.command, 'gh pr create -R apache/airflow -t "fix(generate-cve-json): handle CVE fields"'); + }); + + test('register() blocks leaking command in upstream clone resolved via user.md without project.md', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + + register(on); + + const mockUserMd = '\n| Key | Value |\n| environment.upstream_clone | /home/dev/upstream-airflow |\n'; + const mockGitConfig = '\n[remote "origin"]\n url = https://github.com/apache/airflow.git\n'; + let nextCalled = false; + const mockEvent = { + command: 'git -C /home/dev/upstream-airflow commit -m "Fix CVE-2026-1234 SSRF"', + }; + const next = () => { + nextCalled = true; + }; + + const result = await registeredHook( + { + fs: { + read: async (p: string) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return mockGitConfig; + return null; + }, + }, + env: { get: async () => undefined }, + }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, false, 'next() must NOT be called on blocked commands in upstream clone'); + assert(result.deny, 'Deny explanation must be returned'); + assert(result.deny.includes('CVE id')); + assert(result.deny.includes('SSRF')); + }); + + test('intercepts bare git commit in upstream clone seeded from $.session.root() or e.cwd', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + register(on); + + const mockUserMd = '\n| Key | Value |\n| environment.upstream_clone | /home/dev/upstream-airflow |\n'; + const mockGitConfig = '\n[remote "origin"]\n url = https://github.com/apache/airflow.git\n'; + let nextCalled = false; + const mockEvent = { + command: 'git commit -m "Fix CVE-2026-1234 SSRF"', + cwd: '/home/dev/upstream-airflow', + }; + const next = () => { + nextCalled = true; + }; + + const result = await registeredHook( + { + fs: { + read: async (p: string) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return mockGitConfig; + return null; + }, + }, + session: { + root: async () => '/home/dev/upstream-airflow', + }, + env: { get: async () => undefined }, + }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, false, 'next() must NOT be called on bare commit in upstream clone'); + assert(result.deny, 'Deny explanation must be returned'); + assert(result.deny.includes('CVE id')); + assert(result.deny.includes('SSRF')); + }); + + test('intercepts bare git commit when only $.session.root() is provided (e.cwd undefined)', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + register(on); + + const mockUserMd = '\n| Key | Value |\n| environment.upstream_clone | /home/dev/upstream-airflow |\n'; + const mockGitConfig = '\n[remote "origin"]\n url = https://github.com/apache/airflow.git\n'; + let nextCalled = false; + const mockEvent = { + command: 'git commit -m "Fix CVE-2026-1234 SSRF"', + }; + const next = () => { + nextCalled = true; + }; + + const result = await registeredHook( + { + fs: { + read: async (p: string) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return mockGitConfig; + return null; + }, + }, + session: { + root: async () => '/home/dev/upstream-airflow', + }, + env: { get: async () => undefined }, + }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, false, 'next() must NOT be called on bare commit in upstream clone'); + assert(result.deny, 'Deny explanation must be returned'); + assert(result.deny.includes('CVE id')); + assert(result.deny.includes('SSRF')); + }); + + test('allows bare git commit on private tracker repo when event provides explicit tracker cwd', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + register(on); + + const mockProjectMd = 'tracker_repo: apache/airflow-security\nupstream_repo: apache/airflow\n'; + const mockTrackerGitConfig = '\n[remote "origin"]\n url = https://github.com/apache/airflow-security.git\n'; + let nextCalled = false; + const mockEvent = { + cwd: '/home/dev/tracker', + command: 'git commit -m "Fix CVE-2026-1234 SSRF in private tracker"', + }; + const next = (e: any) => { + nextCalled = true; + return { success: true }; + }; + + const result = await registeredHook( + { + fs: { + read: async (p: string) => { + if (p.includes('project.md')) return mockProjectMd; + if (p.includes('/home/dev/tracker/.git/config') || p === '.git/config') return mockTrackerGitConfig; + return null; + }, + }, + session: { + root: async () => '/home/dev/tracker', + }, + env: { get: async () => undefined }, + }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, true, 'next() must be called for verified tracker cwd commit'); + + // Under session root fallback (no event cwd), tracker exemption is disabled (Blocking #1 fix) + let fallbackNextCalled = false; + const fallbackEvent = { + command: 'git commit -m "Fix CVE-2026-1234 SSRF in private tracker"', + }; + const fallbackResult = await registeredHook( + { + fs: { + read: async (p: string) => { + if (p.includes('project.md')) return mockProjectMd; + if (p.includes('/home/dev/tracker/.git/config') || p === '.git/config') return mockTrackerGitConfig; + return null; + }, + }, + session: { + root: async () => '/home/dev/tracker', + }, + env: { get: async () => undefined }, + }, + fallbackEvent, + () => { fallbackNextCalled = true; } + ); + assertStrictEqual(fallbackNextCalled, false, 'session root fallback must NOT exempt tracker from term scan'); + assertStrictEqual(typeof fallbackResult?.deny, 'string', 'must deny when session root fallback encounters CVE'); + }); + + test('passes non-string command through to next() unchanged', async () => { + let registeredHook: any = null; + const on = (event: string, filterOrHandler: any, handler?: any) => { + if (event === 'tool.call') { + registeredHook = typeof filterOrHandler === 'function' ? filterOrHandler : handler; + } + }; + + register(on); + + let nextCalled = false; + const mockEvent = { command: 12345 }; + const next = (e: any) => { + nextCalled = true; + return { forwarded: e }; + }; + + const result = await registeredHook( + { fs: { read: async () => '' }, env: { get: async () => undefined } }, + mockEvent, + next + ); + assertStrictEqual(nextCalled, true); + }); + }); + + describe('Review #4 hardening & edge-case prevention', () => { + test('identifies upstream clone from user.md environment.upstream_clone without project.md', async () => { + const mockUserMd = ` +| Key | Value | +| environment.upstream_clone | /home/dev/upstream-airflow | +`; + const mockGitConfig = ` +[remote "origin"] + url = https://github.com/apache/airflow.git +`; + const res = await evaluatePublicSurfaceCommand('git -C /home/dev/upstream-airflow commit -m "Fix CVE-2026-1234 SSRF"', { + readFile: async (p: string) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return mockGitConfig; + return null; + }, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('never exempts gh api graphql mutations even when executed in tracker checkout', async () => { + const trackerGitConfig = ` +[remote "origin"] + url = https://github.com/airflow-s/airflow-s.git +`; + const res = await evaluatePublicSurfaceCommand('git -C /tracker status && gh api graphql -f query=\'mutation{addComment(input:{body:"Fix SSRF"})}\'', { + readFile: async (p: string) => { + if (p.includes('.git/config')) return trackerGitConfig; + return null; + }, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('inspects custom field names in gh api graphql mutations', async () => { + const res = await evaluatePublicSurfaceCommand("gh api graphql -f customVar='CVE-2026-9999 SSRF'", { + readFile: async () => null, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + assert(res.matchedTerms.includes('SSRF')); + }); + + test('inspects git commit --trailer', async () => { + const res1 = await evaluatePublicSurfaceCommand('git commit -m "Clean message" --trailer "Fixes: CVE-2026-1234"', { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res1.blocked, true); + assert(res1.matchedTerms.includes('CVE id')); + + const res2 = await evaluatePublicSurfaceCommand('git commit -m "Clean message" --trailer="Fixes: SSRF"', { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res2.blocked, true); + assert(res2.matchedTerms.includes('SSRF')); + }); + + test('marks message reuse flags as dynamic input (-C, -c, --reuse-message, --fixup, --squash)', async () => { + for (const flag of ['-C HEAD', '-c HEAD', '--reuse-message=HEAD', '--reedit-message=HEAD', '--fixup=HEAD', '--squash=HEAD']) { + const res = await evaluatePublicSurfaceCommand(`git commit ${flag}`, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true, `Expected ${flag} to be blocked as dynamic input`); + assert(res.matchedTerms.includes('dynamic input')); + } + }); + + test('inspects clustered git tag flags like git tag -am', async () => { + const res = await evaluatePublicSurfaceCommand('git tag -am "Release CVE-2026-1234" v1.0', { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('isolates cd across pipeline separator so pipe does not leak directory context', async () => { + const pipeCmd = 'cd ../tracker | gh pr comment 1 -R apache/airflow -b "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(pipeCmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('marks same-command file redirection into message file as dynamic input', async () => { + const redirstr = 'printf "Fix CVE-2026-1234" > msg.txt; gh pr comment 1 -R apache/airflow -F msg.txt'; + const res = await evaluatePublicSurfaceCommand(redirstr, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async () => 'stale neutral content', + }); + assertStrictEqual(res.blocked, true); + }); + }); + + describe('Review #5 hardening & shape fixes', () => { + test('tracker URL in PR/issue comment body does not exempt upstream command', async () => { + const cmd = + 'gh pr comment 1 -R apache/airflow -b "Fix CVE-2026-1234, see https://github.com/airflow-s/airflow-s/issues/9"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true, 'Must block disclosure text even when body cites tracker issue URL'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('positional PR/issue URL still correctly sets target repo', async () => { + // Positional tracker URL is exempt + const trackerCmd = 'gh pr comment https://github.com/airflow-s/airflow-s/pull/9 -b "Fix CVE-2026-1234"'; + const resTracker = await evaluatePublicSurfaceCommand(trackerCmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(resTracker.blocked, false); + + // Positional upstream URL is blocked + const upstreamCmd = 'gh pr comment https://github.com/apache/airflow/pull/9 -b "Fix CVE-2026-1234"'; + const resUpstream = await evaluatePublicSurfaceCommand(upstreamCmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(resUpstream.blocked, true); + assert(resUpstream.matchedTerms.includes('CVE id')); + }); + + test('GH_REPO= is ignored for git commands (git ignores GH_REPO)', async () => { + const cmd = 'GH_REPO=airflow-s/airflow-s git commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async (p) => (p.includes('.git/config') ? '[remote "origin"]\nurl = https://github.com/apache/airflow.git' : ''), + }); + assertStrictEqual(res.blocked, true, 'GH_REPO before git must not exempt commit in upstream repo'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('GH_REPO= does not exempt gh api graphql mutation', async () => { + const cmd = + 'GH_REPO=airflow-s/airflow-s gh api graphql -f query=\'mutation{addComment(input:{subjectId:"node123",body:"Fix CVE-2026-1234 SSRF"})}\''; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true, 'GraphQL mutation must not be exempted by GH_REPO or targetRepo'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('gh api repos/{owner}/{repo}/... placeholders inherit cwd remote and do not bypass', async () => { + const cmd = 'gh api repos/{owner}/{repo}/issues/1/comments -f body="Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async (p) => (p.includes('.git/config') ? '[remote "origin"]\nurl = https://github.com/apache/airflow.git' : ''), + }); + assertStrictEqual(res.blocked, true, 'Literal {owner}/{repo} must inherit upstream cwd remote and block disclosure'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('gh pr create -f and --fill-verbose are recognized as dynamic input', async () => { + for (const flag of ['-f', '--fill-verbose']) { + const cmd = `gh pr create -R apache/airflow ${flag}`; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true, `Expected ${flag} to be blocked as dynamic input`); + assert(res.matchedTerms.includes('dynamic input')); + } + }); + + test('git commit attached reuse flags -CHEAD and -cHEAD are recognized as dynamic input', async () => { + for (const flag of ['-CHEAD', '-cHEAD']) { + const cmd = `git commit ${flag}`; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(res.blocked, true, `Expected ${flag} to be blocked as dynamic input`); + assert(res.matchedTerms.includes('dynamic input')); + } + }); + + test('gh api attached field forms -fbody=... and -Fbody=@file are parsed', async () => { + const inlineCmd = 'gh api repos/apache/airflow/issues/1/comments -fbody="Fix CVE-2026-1234 SSRF"'; + const resInline = await evaluatePublicSurfaceCommand(inlineCmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + }); + assertStrictEqual(resInline.blocked, true, 'Attached -fbody= must be inspected'); + assert(resInline.matchedTerms.includes('CVE id')); + + const fileCmd = 'gh api repos/apache/airflow/issues/1/comments -Fbody=@cve_msg.txt'; + const resFile = await evaluatePublicSurfaceCommand(fileCmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async () => 'Fix CVE-2026-1234 SSRF', + }); + assertStrictEqual(resFile.blocked, true, 'Attached -Fbody=@file must read referenced file and block'); + assert(resFile.matchedTerms.includes('CVE id')); + }); + + test('unwraps command wrappers and subshells (command, env, parentheses)', async () => { + const cmds = [ + '( git commit -m "Fix CVE-2026-1234" )', + 'command git commit -m "Fix CVE-2026-1234"', + 'command -p git commit -m "Fix CVE-2026-1234"', + 'env FOO=bar git commit -m "Fix CVE-2026-1234"', + ]; + + for (const cmd of cmds) { + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async (p) => (p.includes('.git/config') ? '[remote "origin"]\nurl = https://github.com/apache/airflow.git' : ''), + }); + assertStrictEqual(res.blocked, true, `Command wrapper/subshell "${cmd}" must be unwrapped and inspected`); + assert(res.matchedTerms.includes('CVE id')); + } + }); + + test('fails closed when git or gh is invoked via unknown wrapper (e.g. sudo git)', async () => { + const cmd = 'sudo git commit -m "Neutral message"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }, + readFile: async (p) => (p.includes('.git/config') ? '[remote "origin"]\nurl = https://github.com/apache/airflow.git' : ''), + }); + assertStrictEqual(res.blocked, true, 'Unknown command wrapper with git must fail closed'); + assert(res.matchedTerms.includes('dynamic input')); + }); + }); + + describe('Review #6 hardening & fail-closed defaults', () => { + const config = { + trackerRepo: 'airflow-s/airflow-s', + upstreamRepo: 'apache/airflow', + upstreamClonePath: '/home/user/upstream', + }; + + const trackerGitConfig = '[remote "origin"]\nurl = https://github.com/airflow-s/airflow-s.git'; + const upstreamGitConfig = '[remote "origin"]\nurl = https://github.com/apache/airflow.git'; + + // 1. Subshell grouping and directory transition tracking + test('tracks cd across subshell parentheses and blocks leaking commit inside subshell', async () => { + const cmd = '(cd /home/user/upstream && git commit -m "Fix CVE-2026-1234")'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => (p.includes('/home/user/upstream') ? upstreamGitConfig : trackerGitConfig), + }); + assertStrictEqual(res.blocked, true, 'Subshell group with cd to upstream must update directory and block commit'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('tracks cd across brace grouping { ... } and blocks leaking commit', async () => { + const cmd = '{ cd /home/user/upstream; git commit -m "Fix CVE-2026-1234"; }'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => (p.includes('/home/user/upstream') ? upstreamGitConfig : trackerGitConfig), + }); + assertStrictEqual(res.blocked, true, 'Brace group with cd to upstream must update directory and block commit'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 2. Binary basename matching (/usr/bin/git, ~/bin/gh, ./git) + test('recognizes absolute and relative binary paths as git and gh commands', async () => { + const testCases = [ + '/usr/bin/git commit -m "Fix CVE-2026-1234"', + '~/bin/gh pr comment 1 -R apache/airflow -b "Fix CVE-2026-1234"', + './git commit -m "Fix CVE-2026-1234"', + ]; + + for (const cmd of testCases) { + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true, `Binary path invocation "${cmd}" must be recognized and scrubbed`); + assert(res.matchedTerms.includes('CVE id')); + } + }); + + // 3. Command-position checking: arguments to non-wrappers are allowed + test('allows git and gh as plain arguments to non-wrapper commands', async () => { + const allowedCmds = [ + 'grep git notes.md', + 'which gh', + 'brew upgrade gh', + 'cat git_guide.txt', + ]; + + for (const cmd of allowedCmds) { + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, false, `Command "${cmd}" with git/gh argument must not be blocked`); + } + }); + + test('wrapped git/gh command gives specific wrapped-command blocking message', async () => { + const cmd = 'sudo git commit -m "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('sudo') || res.reason?.includes('wrapped')); + }); + + // 4. Git repository redirection flags (--git-dir, --work-tree, GIT_DIR, GIT_WORK_TREE) + test('fails closed on --git-dir and --work-tree redirection flags', async () => { + const redirectionCmds = [ + 'git --git-dir=/up/.git --work-tree=/up commit -m "Fix CVE-2026-1234"', + 'git --git-dir /up/.git commit -m "Fix CVE-2026-1234"', + 'GIT_DIR=/up/.git git commit -m "Fix CVE-2026-1234"', + 'GIT_WORK_TREE=/up git commit -m "Fix CVE-2026-1234"', + ]; + + for (const cmd of redirectionCmds) { + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, `Git redirection command "${cmd}" must fail closed`); + } + }); + + // 5. Option values in gh api are skipped when extracting target repository + test('skips gh api option values before extracting target repository', async () => { + const files: Record = { + 'payloads/comment.json': JSON.stringify({ body: 'Fix CVE-2026-1234 vulnerability' }), + }; + const cmd = 'gh api --input payloads/comment.json repos/apache/airflow/issues/1/comments'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => { + for (const [k, v] of Object.entries(files)) { + if (p.endsWith(k) || p === k) return v; + } + return trackerGitConfig; + }, + }); + assertStrictEqual(res.blocked, true, 'gh api with --input must extract real endpoint and scrub payload'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 6. GraphQL mutation in --input or @file treated as mutation (never exempt) + test('treats gh api graphql with --input as mutation and blocks on forbidden terms from tracker', async () => { + const files: Record = { + 'query.json': JSON.stringify({ query: 'mutation { addComment(input: { body: "Fix CVE-2026-1234" }) { clientMutationId } }' }), + }; + const cmd = 'gh api graphql --input query.json'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => { + if (p.endsWith('query.json') || p === 'query.json') return files['query.json']; + return trackerGitConfig; + }, + }); + assertStrictEqual(res.blocked, true, 'GraphQL query in --input file must be treated as mutation and scrubbed'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 7. Git remote URL parsing in inspectDirectoryRemote + test('inspectDirectoryRemote handles quoted URLs, trailing slashes, and comments', async () => { + const configs = [ + '[remote "origin"]\nurl = "https://github.com/apache/airflow.git"', + '[remote "origin"]\nurl = https://github.com/apache/airflow/', + '[remote "origin"]\nurl = https://github.com/apache/airflow.git # primary remote', + ]; + + for (const cfg of configs) { + const cmd = 'git commit -m "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => cfg, + }); + assertStrictEqual(res.blocked, true, `Commit with config "${cfg}" must match upstream and be blocked`); + } + }); + + test('inspectDirectoryRemote returns determined: false for unparsable remote URLs', async () => { + const cmd = 'git commit -m "Clean commit message"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: { trackerRepo: 'airflow-s/airflow-s' }, + readFile: async () => '[remote "origin"]\nurl = invalid-remote-url-without-slug', + }); + // In unparsable remote without project.md upstream, clean commit passes without false positive + assertStrictEqual(res.blocked, false); + }); + + // 8. Fails closed when public surface command reads file modified earlier on same line + test('fails closed when file read via -F was written earlier on the same line', async () => { + const files: Record = { + 'msg.md': 'Neutral text initially', + }; + + const cmd = 'cp private.md msg.md; gh pr comment 1 -R apache/airflow -F msg.md'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => { + if (p.endsWith('msg.md') || p === 'msg.md') return files['msg.md']; + return trackerGitConfig; + }, + }); + assertStrictEqual(res.blocked, true, 'cp before gh -F on same line must fail closed as dynamic input'); + assert(res.matchedTerms.includes('dynamic input')); + }); + + test('allows safe read-only commands before file read on same line', async () => { + const files: Record = { + 'msg.md': 'Safe functional fix description', + }; + + const cmd = 'echo "Logging step"; gh pr comment 1 -R apache/airflow -F msg.md'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => { + if (p.endsWith('msg.md') || p === 'msg.md') return files['msg.md']; + return trackerGitConfig; + }, + }); + assertStrictEqual(res.blocked, false, 'echo before gh -F with neutral message should be allowed'); + }); + + // 9. Unresolved target repo (-R "$REPO") falls through to dynamic input check + test('fails closed when target repository is dynamic variable -R "$REPO"', async () => { + const cmd = 'gh pr comment 1 -R "$REPO" -b "Neutral text"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Dynamic target -R "$REPO" must fail closed'); + assert(res.matchedTerms.includes('dynamic input')); + }); + + test('blocks forbidden terms even when target repo is dynamic -R "$REPO"', async () => { + const cmd = 'gh pr comment 1 -R "$REPO" -b "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Dynamic target with CVE must block on CVE'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 10. Accidental-leak framing in blocking reason + test('blocking reason includes accidental-leak non-perimeter framing', async () => { + const cmd = 'git commit -m "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.reason?.includes('guard against accidental public disclosure')); + }); + }); + + describe('Review #7 maintainer feedback & edge-case hardening', () => { + const config = { + trackerRepo: 'airflow-s/airflow-s', + upstreamRepo: 'apache/airflow', + upstreamClonePath: '/home/user/upstream', + }; + + const trackerGitConfig = '[remote "origin"]\nurl = https://github.com/airflow-s/airflow-s.git'; + const upstreamGitConfig = '[remote "origin"]\nurl = https://github.com/apache/airflow.git'; + + // 1. Bare commands with seeded cwd + test('bare git commit in upstream clone with no project.md blocks on CVE using options.cwd', async () => { + const cmd = 'git commit -m "Fix CVE-2026-1234 SSRF"'; + const mockUserMd = '\n| Key | Value |\n| environment.upstream_clone | /home/dev/upstream-airflow |\n'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/upstream-airflow', + readFile: async (p) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return upstreamGitConfig; + return null; + }, + }); + assertStrictEqual(res.blocked, true, 'Bare git commit in upstream clone must be evaluated against upstream'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 2. Subshell ( ... ) vs brace group { ...; } cwd persistence + test('restores virtualCwd after subshell group (cd ...) so subsequent command is evaluated in upstream', async () => { + const cmd = '(cd ../tracker && gh issue view 1); git commit -m "Fix CVE-2026-1234 SSRF"'; + const mockUserMd = '\n| Key | Value |\n| environment.upstream_clone | /home/dev/upstream-airflow |\n'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/upstream-airflow', + readFile: async (p) => { + if (p.includes('user.md')) return mockUserMd; + if (p.includes('/home/dev/upstream-airflow/.git/config')) return upstreamGitConfig; + if (p.includes('tracker/.git/config')) return trackerGitConfig; + return null; + }, + }); + assertStrictEqual(res.blocked, true, 'Subshell cd must not carry over; subsequent commit runs in upstream and blocks'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('preserves virtualCwd across brace group { cd ...; } so subsequent command runs in tracker', async () => { + const cmd = '{ cd ../tracker; gh issue view 1; }; git commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/upstream-airflow', + projectConfig: config, + readFile: async (p) => { + if (p.includes('tracker/.git/config')) return trackerGitConfig; + return upstreamGitConfig; + }, + }); + assertStrictEqual(res.blocked, false, 'Brace group cd must carry over; subsequent commit runs in tracker and is allowed'); + }); + + // 3. Wrapper arguments (timeout 30 git, nice -n 10 git, sudo -u x git) + test('treats line as wrapped when git appears after wrapper arguments (timeout 30 git)', async () => { + const cmd = 'timeout 30 git commit -m "Neutral functional change"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('wrapped command')); + }); + + test('treats line as wrapped when git appears after nice options (nice -n 10 git)', async () => { + const cmd = 'nice -n 10 git commit -m "Neutral functional change"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('wrapped command')); + }); + + test('treats line as wrapped when git appears after sudo flags (sudo -u dev git)', async () => { + const cmd = 'sudo -u dev git commit -m "Neutral functional change"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('wrapped command')); + }); + + test('treats line as wrapped when gh appears after timeout options (timeout 30 gh pr comment)', async () => { + const cmd = 'timeout 30 gh pr comment 1 -R apache/airflow -b "Neutral comment"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('wrapped command')); + }); + + // 4. Shell control transparent prefixes and eval / bash -c + test('inspects git commit following shell control prefix (if ... then git commit)', async () => { + const cmd = 'if true; then git commit -m "Fix CVE-2026-1234"; fi'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('inspects gh following shell loop prefix (for ... do gh pr comment)', async () => { + const cmd = 'for x in 1; do gh pr comment 1 -R apache/airflow -b "Fix CVE-2026-1234"; done'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('inspects git commit following negation prefix (! git commit)', async () => { + const cmd = '! git commit -m "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('fails closed on bash -c invoking git', async () => { + const cmd = 'bash -c "git commit -m \\"Routine functional change\\""'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('dynamic input')); + }); + + test('fails closed on eval invoking git', async () => { + const cmd = 'eval "git commit -m \\"Routine functional change\\""'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('dynamic input')); + }); + + // 5. gh api leading slash and API host normalization + test('normalizes gh api leading slash (/repos/apache/airflow/...) and blocks on CVE', async () => { + const cmd = 'gh api /repos/apache/airflow/issues/1/comments -f body="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('normalizes gh api full URL (https://api.github.com/repos/apache/airflow/...) and blocks on CVE', async () => { + const cmd = 'gh api https://api.github.com/repos/apache/airflow/issues/1/comments -f body="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('scans, not exempts, literal gh api endpoint without owner/repo placeholders from tracker checkout', async () => { + const cmd = 'gh api /user -f note="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Literal unparsed endpoint must not be exempted by tracker cwd'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('allows neutral literal gh api endpoint without owner/repo placeholders', async () => { + const cmd = 'gh api /user -f note="Routine status update"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, false, 'Neutral literal endpoint should pass'); + }); + + test('does not misparse /orgs/... as target repository and blocks on CVE', async () => { + const cmd = 'gh api /orgs/apache/teams -f note="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Literal orgs endpoint must not be exempted as a third-party repo'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('allows neutral /orgs/... endpoint call', async () => { + const cmd = 'gh api /orgs/apache/teams -f note="Routine team setup"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, false, 'Neutral orgs endpoint should pass'); + }); + + // 6. REST calls scan all field names + test('scans non-body/title REST field (-f summary=...) and blocks on CVE', async () => { + const cmd = 'gh api repos/apache/airflow/issues -f summary="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('scans attached non-body/title REST field (-fnote=...) and blocks on CVE', async () => { + const cmd = 'gh api repos/apache/airflow/issues -fnote="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 7. Heredoc bodies and unclosed quotes + test('consumes heredoc body with internal quote and checks subsequent file-reading command', async () => { + const files: Record = { 'msg.md': 'Body content' }; + const cmd = "cat > msg.md <<'EOF'\nIt doesn't matter\nEOF\ngh pr create -t ok --body-file msg.md -R apache/airflow"; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async (p) => { + if (p.endsWith('msg.md') || p === 'msg.md') return files['msg.md']; + return upstreamGitConfig; + }, + }); + assertStrictEqual(res.blocked, true, 'Heredoc write to file on same line must fail closed as dynamic input'); + assert(res.matchedTerms.includes('dynamic input')); + }); + + test('consumes heredoc body with internal quote and evaluates subsequent git commit', async () => { + const cmd = "cat > msg.md <<'EOF'\nIt doesn't matter\nEOF\ngit commit -m \"Fix CVE-2026-1234 SSRF\""; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Subsequent git commit must be parsed and blocked on CVE'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('consumes heredoc body with backslash-escaped delimiter <<\\EOF and evaluates subsequent git commit', async () => { + const cmd = "cat > msg.md <<\\EOF\nIt doesn't matter\nEOF\ngit commit -m \"Fix CVE-2026-1234 SSRF\""; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Subsequent git commit after <<\\EOF must be parsed and blocked on CVE'); + assert(res.matchedTerms.includes('CVE id')); + }); + + test('fails closed on unclosed quote when git appears on command line', async () => { + const cmd = 'git commit -m "Fix CVE-2026-1234'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => upstreamGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Unclosed quote with git binary must fail closed'); + assert(res.matchedTerms.includes('unclosed quote')); + }); + + // 8. Repository without Magpie config (unknown upstream) + test('in unconfigured repository, gh command with neutral message passes term scan', async () => { + const cmd = 'gh pr comment 1 -R someone/other -b "thanks"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: {}, // No upstreamRepo configured + readFile: async () => null, + }); + assertStrictEqual(res.blocked, false, 'Neutral comment in unconfigured repo should pass'); + }); + + test('in unconfigured repository, gh command with CVE is blocked by term scan', async () => { + const cmd = 'gh pr comment 1 -R someone/other -b "Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: {}, // No upstreamRepo configured + readFile: async () => null, + }); + assertStrictEqual(res.blocked, true, 'CVE in unconfigured repo should be caught by term scan'); + assert(res.matchedTerms.includes('CVE id')); + }); + + // 9. Scope-restricted --git-dir and GIT_DIR + test('allows non-public-surface git command using --git-dir (git --git-dir=.git status)', async () => { + const cmd = 'git --git-dir=.git status'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, false, 'git status is not a public-surface command and should pass'); + }); + + test('allows neutral git commit using --git-dir', async () => { + const cmd = 'git --git-dir=.git commit -m "Refactor parser utilities"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, false, 'Neutral commit with --git-dir should pass term scan'); + }); + + test('blocks git commit using --git-dir when commit message contains forbidden term', async () => { + const cmd = 'git --git-dir=.git commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + projectConfig: config, + readFile: async () => trackerGitConfig, + }); + assertStrictEqual(res.blocked, true, 'Commit with --git-dir must be checked and blocked on CVE'); + assert(res.matchedTerms.includes('CVE id')); + }); + }); + + describe('Proactive Maintainer Hardening (Round 8)', () => { + const config = { + trackerRepo: 'apache/magpie-tracker', + upstreamRepo: 'apache/airflow', + upstreamClonePath: '/home/dev/upstream', + }; + const trackerGitConfig = '[remote "origin"]\nurl = https://github.com/apache/magpie-tracker\n'; + const upstreamGitConfig = '[remote "origin"]\nurl = https://github.com/apache/airflow\n'; + + const mockFs: Record = { + '/tracker/.git/config': trackerGitConfig, + '/home/dev/upstream/.git/config': upstreamGitConfig, + }; + const mockReadFile = async (p: string) => mockFs[p] || null; + + test('1. cd || exit 1 updates virtualCwd and blocks CVE commit', async () => { + const cmd = 'cd /home/dev/upstream || exit 1; git commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'cd || exit 1 must update virtualCwd to upstream and block CVE commit'); + }); + + test('2. gh api /v3/repos/... normalizes endpoint and blocks CVE comment', async () => { + const cmd = 'gh api /v3/repos/apache/airflow/issues/1/comments -f body="Fix CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'gh api /v3/repos/ must normalize to upstream and block CVE'); + }); + + test('3. env -C updates directory and blocks CVE commit', async () => { + const cmd = 'env -C /home/dev/upstream git commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'env -C must update directory context or fail closed'); + }); + + test('4. git merge -m with CVE is classified and blocked', async () => { + const cmd = 'git merge --no-ff feature/fix -m "Merge security fix for CVE-2026-1234"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'git merge -m must be inspected and blocked on CVE'); + }); + + test('5. git -C with .. path segments collapses and matches upstreamClonePath', async () => { + const cmd = 'git -C /home/dev/tracker/../upstream commit -m "Fix CVE-2026-1234 SSRF"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/tracker', + projectConfig: { + trackerRepo: 'apache/magpie-tracker', + upstreamRepo: 'apache/airflow', + upstreamClonePath: '/home/dev/upstream', + }, + readFile: async () => null, + }); + assertStrictEqual(res.blocked, true, 'Relative path with .. must collapse and match upstreamClonePath'); + }); + }); + + describe('Review #8 maintainer feedback & edge-case hardening', () => { + const config = { + trackerRepo: 'apache/magpie-tracker', + upstreamRepo: 'apache/airflow', + upstreamClonePath: '/home/dev/upstream', + }; + + const mockReadFile = async (p: string) => { + if (p.includes('/tracker/.git/config') || p === '.git/config') { + return '[remote "origin"]\nurl = https://github.com/apache/magpie-tracker.git\n'; + } + if (p.includes('/upstream/.git/config')) { + return '[remote "origin"]\nurl = https://github.com/apache/airflow.git\n'; + } + return null; + }; + + // 1. Blocking #1: Session root fallback disables tracker exemption and runs term scan + test('1. session root fallback bare commit runs term scan and blocks on CVE', async () => { + const cmd = 'git commit -m "Fix CVE-2026-1111 in auth module"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/tracker', + isSessionRootFallback: true, + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'Fallback session root must NOT exempt tracker remote from term scan'); + assert(res.matchedTerms.includes('CVE id') || res.matchedTerms.includes('CVE')); + + // Neutral commit passes term scan even under session root fallback + const neutral = await evaluatePublicSurfaceCommand('git commit -m "Refactor auth module"', { + cwd: '/tracker', + isSessionRootFallback: true, + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(neutral.blocked, false, 'Neutral commit under fallback session root must pass term scan'); + + // Verified explicit tracker cwd (non-fallback) exempts + const verified = await evaluatePublicSurfaceCommand('git commit -m "Fix CVE-2026-1111 in auth module"', { + cwd: '/tracker', + isSessionRootFallback: false, + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(verified.blocked, false, 'Verified tracker cwd exempts commit'); + }); + + // 2. Blocking #2: Missing or placeholder upstreamRepo falls through to term scan + test('2. missing upstreamRepo falls through to term scan on bare commit', async () => { + const unconfiguredReadFile = async (p: string) => { + if (p.includes('.git/config')) { + return '[remote "origin"]\nurl = https://github.com/some-fork/airflow.git\n'; + } + return null; + }; + + const res = await evaluatePublicSurfaceCommand('git commit -m "Fix CVE-2026-1234 vulnerability"', { + cwd: '/some-fork', + projectConfig: { + trackerRepo: 'apache/magpie-tracker', + // upstreamRepo is undefined/unconfigured! + }, + readFile: unconfiguredReadFile, + }); + assertStrictEqual(res.blocked, true, 'Unknown upstream repo must fail closed to term scan'); + assert(res.matchedTerms.includes('CVE id') || res.matchedTerms.includes('CVE')); + + // Neutral commit passes term scan + const neutral = await evaluatePublicSurfaceCommand('git commit -m "Neutral refactor"', { + cwd: '/some-fork', + projectConfig: { + trackerRepo: 'apache/magpie-tracker', + }, + readFile: unconfiguredReadFile, + }); + assertStrictEqual(neutral.blocked, false, 'Neutral commit must pass when upstream is unknown'); + }); + + // 3. Edge Case #3: pushd and builtin cd directory tracking and safe read-only commands + test('3. pushd and builtin cd track directory changes across sequential operators', async () => { + const pushdCmd = 'pushd /home/dev/upstream && git commit -m "Fix CVE-2026-2222"'; + const pushdRes = await evaluatePublicSurfaceCommand(pushdCmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(pushdRes.blocked, true, 'pushd must update virtual directory to upstream'); + + const builtinCmd = 'builtin cd /home/dev/upstream && git commit -m "Fix CVE-2026-2222"'; + const builtinRes = await evaluatePublicSurfaceCommand(builtinCmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(builtinRes.blocked, true, 'builtin cd must update virtual directory to upstream'); + + // pushd is in SAFE_READONLY_COMMANDS so subsequent -F file read does not fail closed as dynamic input + const safeReadFile = async (p: string) => { + if (p.includes('msg.txt')) return 'Clean commit description'; + return mockReadFile(p); + }; + const safePushd = await evaluatePublicSurfaceCommand('pushd /home/dev/upstream && git commit -F msg.txt', { + cwd: '/tracker', + projectConfig: config, + readFile: safeReadFile, + }); + assertStrictEqual(safePushd.blocked, false, 'pushd should be safe read-only and allow neutral -F file read'); + }); + + // 4. Edge Case #4: missing gh write subcommands (issue create/edit, pr merge, release create, pr/issue close) + test('4. inspects gh write subcommands and blocks on forbidden terms', async () => { + // gh issue create -t / -b + const issCreate = await evaluatePublicSurfaceCommand('gh issue create -R apache/airflow -t "Security vulnerability in webserver" -b "Details on CVE-2026-3333"'); + assertStrictEqual(issCreate.blocked, true, 'gh issue create must inspect title and body'); + + // gh issue edit -b + const issEdit = await evaluatePublicSurfaceCommand('gh issue edit 42 -R apache/airflow -b "Updated advisory for CVE-2026-3333"'); + assertStrictEqual(issEdit.blocked, true, 'gh issue edit must inspect body'); + + // gh pr merge --subject / --body + const prMergeSub = await evaluatePublicSurfaceCommand('gh pr merge 101 -R apache/airflow --subject "Merge CVE-2026-3333 security fix"'); + assertStrictEqual(prMergeSub.blocked, true, 'gh pr merge --subject must be inspected'); + + const prMergeBody = await evaluatePublicSurfaceCommand('gh pr merge 101 -R apache/airflow --body "Addresses CVE-2026-3333"'); + assertStrictEqual(prMergeBody.blocked, true, 'gh pr merge --body must be inspected'); + + // gh release create -t / --notes + const relNotes = await evaluatePublicSurfaceCommand('gh release create v2.1.0 -R apache/airflow -t "Release 2.1.0" --notes "Fixes CVE-2026-3333"'); + assertStrictEqual(relNotes.blocked, true, 'gh release create --notes must be inspected'); + + const relTag = await evaluatePublicSurfaceCommand('gh release create v2.1.0-cve-2026-3333 -R apache/airflow --notes "Clean notes"'); + assertStrictEqual(relTag.blocked, true, 'gh release create tag name must be inspected'); + + // gh pr close -c / --comment + const prClose = await evaluatePublicSurfaceCommand('gh pr close 101 -R apache/airflow --comment "Closed in favor of CVE-2026-3333 patch"'); + assertStrictEqual(prClose.blocked, true, 'gh pr close --comment must be inspected'); + + // gh issue close -c / --comment + const issClose = await evaluatePublicSurfaceCommand('gh issue close 42 -R apache/airflow -c "Resolved via CVE-2026-3333 fix"'); + assertStrictEqual(issClose.blocked, true, 'gh issue close -c must be inspected'); + + // Neutral versions pass + const neutralIss = await evaluatePublicSurfaceCommand('gh issue create -R apache/airflow -t "Improve logging" -b "More debug logs"'); + assertStrictEqual(neutralIss.blocked, false, 'Neutral gh issue create passes'); + + const neutralMerge = await evaluatePublicSurfaceCommand('gh pr merge 101 -R apache/airflow --subject "Clean feature merge"'); + assertStrictEqual(neutralMerge.blocked, false, 'Neutral gh pr merge passes'); + + const neutralRel = await evaluatePublicSurfaceCommand('gh release create v2.1.0 -R apache/airflow -t "Release 2.1.0" -n "Release notes"'); + assertStrictEqual(neutralRel.blocked, false, 'Neutral gh release create passes'); + }); + + // 5. Edge Case #5: env -C composition and resolvePath argument order + test('5. env -C composes with git -C without overwriting directory context', async () => { + const cmd = 'env -C /home/dev/upstream git -C . commit -m "Fix CVE-2026-4444"'; + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/tracker', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(res.blocked, true, 'env -C composed with git -C . must preserve upstream directory'); + }); + + // 6. Edge Case #6: resolveProjectConfig reads user.md upstreamClonePath even when project.md defines both repos + test('6. resolveProjectConfig reads user.md upstream_clone when project.md defines both repos', async () => { + const mockConfigStore: Record = { + '.apache-magpie-overrides/project.md': '| tracker_repo | apache/magpie-tracker |\n| upstream_repo | apache/airflow |', + '/home/user/.config/apache-magpie/user.md': '| upstream_clone | /home/user/workspace/airflow |', + }; + const resolved = await resolveProjectConfig( + async (p) => mockConfigStore[p], + '/home/user/workspace/tracker', + { HOME: '/home/user' } + ); + assertStrictEqual(resolved.trackerRepo, 'apache/magpie-tracker'); + assertStrictEqual(resolved.upstreamRepo, 'apache/airflow'); + assertStrictEqual(resolved.upstreamClonePath, '/home/user/workspace/airflow'); + }); + + // 7. Edge Case #7: inspectDirectoryRemote reads commondir config for linked worktrees + test('7. inspectDirectoryRemote reads remotes from worktree common dir config', async () => { + const worktreeFs: Record = { + '/worktree/branch-fix/.git': 'gitdir: /repo/main/.git/worktrees/branch-fix', + '/repo/main/.git/worktrees/branch-fix/config': '[core]\n\tbare = false\n', + '/repo/main/.git/config': '[remote "origin"]\n\turl = https://github.com/apache/airflow.git\n', + }; + const res = await inspectDirectoryRemote( + '/worktree/branch-fix', + { upstreamRepo: 'apache/airflow' }, + async (p) => worktreeFs[p] + ); + assertStrictEqual(res.determined, true, 'Must resolve remote from worktree common dir config'); + assertStrictEqual(res.isUpstream, true, 'Must detect upstream repository in worktree'); + }); + + // 8. Nit #8: Scan git tag names and git merge branch refs + test('8. scans git tag names and git merge branch refs for forbidden terms', async () => { + // Lightweight tag with CVE name + const tagRes = await evaluatePublicSurfaceCommand('git tag CVE-2026-5555-fix', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(tagRes.blocked, true, 'Tag name containing CVE must be blocked'); + + // Annotated tag with CVE in name + const annoTagRes = await evaluatePublicSurfaceCommand('git tag -a CVE-2026-5555-fix -m "Release"', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(annoTagRes.blocked, true, 'Annotated tag with CVE in name must be blocked'); + + // Bare git merge of branch containing CVE + const mergeRes = await evaluatePublicSurfaceCommand('git merge cve-2026-5555-patch', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(mergeRes.blocked, true, 'git merge with CVE branch ref must be blocked'); + + // Neutral tag and merge pass + const neutralTag = await evaluatePublicSurfaceCommand('git tag v1.0.0', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(neutralTag.blocked, false, 'Neutral tag must pass'); + + const neutralMerge = await evaluatePublicSurfaceCommand('git merge feature/new-dashboard', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(neutralMerge.blocked, false, 'Neutral merge must pass'); + }); + + // 9. Nit #9: git commit -am ... --amend is not falsely flagged + test('9. git commit -am with --amend is not falsely flagged as dynamic reuse', async () => { + // Neutral commit message with -am and --amend passes + const neutralAmend = await evaluatePublicSurfaceCommand('git commit -am "Neutral amend message" --amend', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(neutralAmend.blocked, false, '-am with --amend provides explicit message and must pass'); + + // Message with CVE is still blocked + const cveAmend = await evaluatePublicSurfaceCommand('git commit -am "Fix CVE-2026-6666" --amend', { + cwd: '/home/dev/upstream', + projectConfig: config, + readFile: mockReadFile, + }); + assertStrictEqual(cveAmend.blocked, true, '-am with CVE must be blocked'); + }); + + // 10. Nit #10: Shared helpers verify clustered flags and message/body flags + test('10. shared helpers parse attached and clustered message and body flags', () => { + // commit clustered -sam + const sam = parseSubcommand('git commit -sam "Signed commit"'); + assertDeepStrictEqual(sam.extractedTexts, ['Signed commit']); + + // pr merge attached -b + const prMerge = parseSubcommand('gh pr merge 123 --subject "Subject" -b"Attached body"'); + assert(prMerge.extractedTexts.includes('Subject')); + assert(prMerge.extractedTexts.includes('Attached body')); + + // issue create attached -t and -b + const iss = parseSubcommand('gh issue create -t"Title" -b"Body"'); + assert(iss.extractedTexts.includes('Title')); + assert(iss.extractedTexts.includes('Body')); + }); + }); + +}); diff --git a/plugins/magpie-security/hooks/public-surface-scrub.ts b/plugins/magpie-security/hooks/public-surface-scrub.ts new file mode 100644 index 000000000..0931a5134 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.ts @@ -0,0 +1,2411 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { CANONICAL_FORBIDDEN_TERMS, type CanonicalForbiddenTerm } from './forbidden-terms.ts'; + +/** + * Single canonical forbidden-term regexes for public surfaces, derived directly + * from DEFAULT_TERMS in tools/vcs/src/magpie_vcs/publication.py. + */ +export const FORBIDDEN_TERM_RULES: ReadonlyArray<{ name: string; pattern: RegExp }> = + CANONICAL_FORBIDDEN_TERMS.map((term: CanonicalForbiddenTerm) => ({ + name: term.name, + pattern: new RegExp(term.pattern, term.flags), + })); + +/** + * Finds all unique forbidden embargo terms that appear in the candidate text. + */ +export function findForbiddenTerms(text: string): string[] { + if (!text || typeof text !== 'string') { + return []; + } + + const matched: string[] = []; + for (const rule of FORBIDDEN_TERM_RULES) { + if (rule.pattern.test(text)) { + matched.push(rule.name); + } + } + + return matched; +} + +export type FileReader = (filePath: string) => Promise; + +export interface ProjectRepos { + trackerRepo?: string; + upstreamRepo?: string; + upstreamClonePath?: string; + isTrackerCheckout?: boolean; +} + +/** + * Cleans and validates a repository slug (owner/repo). + * Filters out placeholders, TODOs, comments, and non-slug text like "not used". + */ +export function cleanRepoSlug(raw: string | undefined): string | undefined { + if (!raw || typeof raw !== 'string') { + return undefined; + } + const trimmed = raw.trim().replace(/^[`'"]+|[`'"]+$/g, '').trim().toLowerCase(); + if ( + trimmed.startsWith('todo') || + trimmed.startsWith('<') || + trimmed.startsWith('not used') || + trimmed.startsWith('not_used') || + trimmed === 'none' || + trimmed === 'n/a' || + trimmed === '-' || + !trimmed.includes('/') + ) { + return undefined; + } + const match = trimmed.match(/^([a-z0-9_.-]+\/[a-z0-9_.-]+)/); + return match ? match[1] : undefined; +} + +/** + * Parses project manifest or user config content (Markdown table or YAML syntax) + * to extract tracker_repo, upstream_repo, and environment.upstream_clone. + */ +export function parseProjectManifest(content: string): ProjectRepos { + const result: ProjectRepos = {}; + if (!content || typeof content !== 'string') { + return result; + } + + // Markdown table row: | `?tracker_repo`? | `?`? | + const trackerMatch = + content.match(/\|\s*`?tracker_repo`?\s*\|\s*`?([^`|\r\n]+)`?/i) ?? + content.match(/^\s*tracker_repo:\s*`?([^`\r\n#]+)`?/im); + const trackerSlug = cleanRepoSlug(trackerMatch?.[1]); + if (trackerSlug) { + result.trackerRepo = trackerSlug; + } + + // Markdown table row: | `?upstream_repo`? | `?`? | + const upstreamMatch = + content.match(/\|\s*`?upstream_repo`?\s*\|\s*`?([^`|\r\n]+)`?/i) ?? + content.match(/^\s*upstream_repo:\s*`?([^`\r\n#]+)`?/im); + const upstreamSlug = cleanRepoSlug(upstreamMatch?.[1]); + if (upstreamSlug) { + result.upstreamRepo = upstreamSlug; + } + + // user.md declares environment.upstream_clone or upstream_clone as a filesystem path + const upstreamCloneMatch = + content.match(/\|\s*`?(?:environment\.)?upstream_clone`?\s*\|\s*`?([^`|\r\n]+)`?/i) ?? + content.match(/^\s*(?:environment\.)?upstream_clone:\s*`?([^`\r\n#]+)`?/im) ?? + content.match(/^\s*upstream_clone:\s*`?([^`\r\n#]+)`?/im); + if (upstreamCloneMatch?.[1]) { + const rawPath = upstreamCloneMatch[1].trim().replace(/^[`'"]+|[`'"]+$/g, '').trim(); + if (rawPath && !rawPath.startsWith('TODO') && !rawPath.startsWith('<')) { + result.upstreamClonePath = rawPath; + } + } + + return result; +} + +/** + * Resolves repository identity from /project.md and user.md in precedence order: + * 1. .apache-magpie-local/project.md + * 2. .git/apache-magpie/project.md (and worktree common dir) + * 3. .apache-magpie-overrides/project.md + * 4. ../.apache-magpie-overrides/project.md (if in sibling directory) + * 5. user.md layers (including ~/.config/apache-magpie/user.md with $HOME expanded) + */ +export async function resolveProjectConfig( + readFile?: FileReader, + cwd?: string, + env?: { [key: string]: string | undefined } +): Promise { + if (!readFile) { + return {}; + } + + const prefix = cwd ? `${cwd.replace(/[\\/]+$/, '')}/` : ''; + const procEnv = typeof process !== 'undefined' ? process.env : undefined; + const homeDir = (env?.HOME || procEnv?.HOME || procEnv?.USERPROFILE || '').replace(/[\\/]+$/, ''); + + const candidatePaths = [ + `${prefix}.apache-magpie-local/project.md`, + `${prefix}.git/apache-magpie/project.md`, + `${prefix}.apache-magpie-overrides/project.md`, + '.apache-magpie-local/project.md', + '.git/apache-magpie/project.md', + '.apache-magpie-overrides/project.md', + '../.apache-magpie-local/project.md', + '../.apache-magpie-overrides/project.md', + `${prefix}.apache-magpie-local/user.md`, + `${prefix}.apache-magpie-overrides/user.md`, + '.apache-magpie-local/user.md', + '.apache-magpie-overrides/user.md', + ]; + + if (homeDir) { + candidatePaths.push(`${homeDir}/.config/apache-magpie/user.md`); + } + candidatePaths.push('~/.config/apache-magpie/user.md'); + + // Check git worktree gitdir pointer + try { + const gitFilePath = `${prefix}.git`; + const gitFileContent = await readFile(gitFilePath); + if (gitFileContent && typeof gitFileContent === 'string') { + const gitDirMatch = gitFileContent.match(/gitdir:\s*([^\r\n]+)/i); + if (gitDirMatch) { + const resolvedGitDir = resolvePath(cwd, gitDirMatch[1].trim()); + // In a worktree, gitdir is /worktrees/; the common dir is two levels up + const commonDir = resolvedGitDir.replace(/[\\/]worktrees[\\/][^\\/]+$/, ''); + candidatePaths.unshift(`${commonDir}/apache-magpie/project.md`); + } + } + } catch { + // Continue + } + + let merged: ProjectRepos = {}; + + for (const path of candidatePaths) { + try { + const content = await readFile(path); + if (content && typeof content === 'string') { + const repos = parseProjectManifest(content); + if (repos.trackerRepo && !merged.trackerRepo) { + merged.trackerRepo = repos.trackerRepo; + merged.isTrackerCheckout = !path.startsWith('..') && !path.includes('/..'); + } + if (repos.upstreamRepo && !merged.upstreamRepo) { + merged.upstreamRepo = repos.upstreamRepo; + } + if (repos.upstreamClonePath && !merged.upstreamClonePath) { + // Expand ~ in upstreamClonePath if needed + let clonePath = repos.upstreamClonePath; + if (clonePath.startsWith('~/') && homeDir) { + clonePath = `${homeDir}/${clonePath.slice(2)}`; + } + merged.upstreamClonePath = clonePath; + } + if (merged.trackerRepo && merged.upstreamRepo && merged.upstreamClonePath) { + return merged; + } + } + } catch { + // Continue to next candidate + } + } + + return merged; +} + +export interface ShellSubcommand { + raw: string; + separatorAfter?: string; + subshellDepthDeltaBefore?: number; + subshellDepthDeltaAfter?: number; + hasUnclosedQuoteWithBinary?: boolean; +} + +/** + * Helper to check if a string is completely enclosed in a matching pair of + * outer parentheses (...) or braces {...}. + */ +function isEnclosedInMatchingGroup(str: string): boolean { + const trimmed = str.trim(); + if ( + (!trimmed.startsWith('(') || !trimmed.endsWith(')')) && + (!trimmed.startsWith('{') || !trimmed.endsWith('}')) + ) { + return false; + } + const openChar = trimmed[0]; + const closeChar = openChar === '(' ? ')' : '}'; + let depth = 0; + let inS = false; + let inD = false; + let isEsc = false; + + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (isEsc) { + isEsc = false; + continue; + } + if (ch === '\\' && !inS) { + isEsc = true; + continue; + } + if (ch === "'" && !inD) { + inS = !inS; + continue; + } + if (ch === '"' && !inS) { + inD = !inD; + continue; + } + if (!inS && !inD) { + if (ch === openChar) { + depth++; + } else if (ch === closeChar) { + depth--; + if (depth === 0 && i < trimmed.length - 1) { + return false; + } + } + } + } + return depth === 0; +} + +/** + * Checks if a string has unbalanced parentheses or braces. + */ +function hasUnbalancedGroupDelimiters(str: string): boolean { + let p = 0; + let b = 0; + let inS = false; + let inD = false; + let isEsc = false; + + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + if (isEsc) { + isEsc = false; + continue; + } + if (ch === '\\' && !inS) { + isEsc = true; + continue; + } + if (ch === "'" && !inD) { + inS = !inS; + continue; + } + if (ch === '"' && !inS) { + inD = !inD; + continue; + } + if (!inS && !inD) { + if (ch === '(') p++; + else if (ch === ')') { + p--; + if (p < 0) return true; + } else if (ch === '{') b++; + else if (ch === '}') { + b--; + if (b < 0) return true; + } + } + } + return p !== 0 || b !== 0; +} + +/** + * Splits a composite shell command line into individual subcommands along with + * the trailing separator (&&, ;, \n, |, ||, &), respecting quoting rules and + * subshell grouping parentheses and braces. + */ +export function splitShellSubcommands(commandLine: string): ShellSubcommand[] { + const result: ShellSubcommand[] = []; + if (!commandLine || typeof commandLine !== 'string') { + return result; + } + + let current = ''; + let inSingle = false; + let inDouble = false; + let isEscaped = false; + let parenDepth = 0; + let braceDepth = 0; + const pendingHeredocs: { delim: string; stripTabs: boolean }[] = []; + + const pushCurrent = (sep?: string) => { + const trimmed = current.trim(); + if (trimmed) { + if (isEnclosedInMatchingGroup(trimmed)) { + const isSubshell = trimmed.startsWith('(') && trimmed.endsWith(')'); + // Strip outer group delimiters and recursively split subcommands inside the group + const inner = trimmed.slice(1, -1).trim(); + const innerSubs = splitShellSubcommands(inner); + if (innerSubs.length > 0) { + if (isSubshell) { + innerSubs[0].subshellDepthDeltaBefore = (innerSubs[0].subshellDepthDeltaBefore || 0) + 1; + innerSubs[innerSubs.length - 1].subshellDepthDeltaAfter = + (innerSubs[innerSubs.length - 1].subshellDepthDeltaAfter || 0) + 1; + } + innerSubs[innerSubs.length - 1].separatorAfter = sep; + result.push(...innerSubs); + } + } else { + result.push({ raw: trimmed, separatorAfter: sep }); + } + } + current = ''; + }; + + for (let i = 0; i < commandLine.length; i++) { + const char = commandLine[i]; + const next = commandLine[i + 1]; + + if (isEscaped) { + current += char; + isEscaped = false; + continue; + } + + if (char === '\\' && !inSingle) { + isEscaped = true; + current += char; + continue; + } + + if (char === "'" && !inDouble) { + inSingle = !inSingle; + current += char; + continue; + } + + if (char === '"' && !inSingle) { + inDouble = !inDouble; + current += char; + continue; + } + + if (!inSingle && !inDouble) { + // Check for heredoc operator << or <<- (excluding <<< herestring) + if (char === '<' && next === '<' && commandLine[i + 2] !== '<') { + let pos = i + 2; + let stripTabs = false; + if (commandLine[pos] === '-') { + stripTabs = true; + pos++; + } + while (pos < commandLine.length && (commandLine[pos] === ' ' || commandLine[pos] === '\t')) { + pos++; + } + let delim = ''; + if (pos < commandLine.length) { + const q = commandLine[pos]; + if (q === "'" || q === '"') { + pos++; + while (pos < commandLine.length && commandLine[pos] !== q) { + delim += commandLine[pos]; + pos++; + } + if (pos < commandLine.length && commandLine[pos] === q) { + pos++; + } + } else { + while ( + pos < commandLine.length && + !/[\s;&|()<>]/.test(commandLine[pos]) + ) { + delim += commandLine[pos]; + pos++; + } + } + } + if (delim) { + if (delim.startsWith('\\')) { + delim = delim.slice(1); + } + pendingHeredocs.push({ delim, stripTabs }); + } + current += commandLine.slice(i, pos); + i = pos - 1; + continue; + } + + if (char === '(') { + parenDepth++; + current += char; + continue; + } + if (char === ')') { + if (parenDepth > 0) parenDepth--; + current += char; + continue; + } + if (char === '{') { + braceDepth++; + current += char; + continue; + } + if (char === '}') { + if (braceDepth > 0) braceDepth--; + current += char; + continue; + } + + // Only split on operators when NOT inside parentheses or braces + if (parenDepth === 0 && braceDepth === 0) { + if (char === '\n' || char === '\r') { + // If there is a pending heredoc, consume its body line by line + if (pendingHeredocs.length > 0) { + current += char; + if (char === '\r' && next === '\n') { + current += next; + i++; + } + while (pendingHeredocs.length > 0 && i + 1 < commandLine.length) { + const target = pendingHeredocs[0]; + const lineStart = i + 1; + let lineEnd = commandLine.indexOf('\n', lineStart); + let hasCr = false; + if (lineEnd === -1) { + lineEnd = commandLine.length; + } else if (lineEnd > lineStart && commandLine[lineEnd - 1] === '\r') { + hasCr = true; + } + const rawLine = commandLine.slice(lineStart, hasCr ? lineEnd - 1 : lineEnd); + const checkLine = target.stripTabs ? rawLine.replace(/^\t+/, '') : rawLine.trim(); + + current += commandLine.slice(lineStart, lineEnd === commandLine.length ? lineEnd : lineEnd + 1); + i = lineEnd === commandLine.length ? lineEnd - 1 : lineEnd; + + if (checkLine === target.delim) { + pendingHeredocs.shift(); + } + } + pushCurrent('\n'); + continue; + } + + pushCurrent('\n'); + continue; + } + if (char === ';') { + pushCurrent(';'); + continue; + } + if (char === '&') { + if (next === '&') { + pushCurrent('&&'); + i++; // skip second & + continue; + } + // Single & is background separator unless part of >& redirection + const prev = i > 0 ? commandLine[i - 1] : ''; + if (prev !== '>') { + pushCurrent('&'); + continue; + } + } + if (char === '|' && next === '|') { + pushCurrent('||'); + i++; // skip second | + continue; + } + if (char === '|') { + pushCurrent('|'); + continue; + } + } + } + + current += char; + } + + pushCurrent(undefined); + + // Fail closed if a quote is still open at the end and commandLine contains git or gh + if ((inSingle || inDouble) && /\b(?:git|gh)\b/i.test(commandLine)) { + if (result.length > 0) { + result[result.length - 1].hasUnclosedQuoteWithBinary = true; + } else { + result.push({ raw: commandLine, hasUnclosedQuoteWithBinary: true }); + } + } + + return result; +} + +/** + * Splits a composite shell command line into individual subcommand strings. + */ +export function splitShellCommands(commandLine: string): string[] { + return splitShellSubcommands(commandLine).map((s) => s.raw); +} + +/** + * Tokenizes a single subcommand line into an argv array according to POSIX shell + * quoting and escaping rules. + */ +export function tokenizeShellWords(subcommand: string): string[] { + const tokens: string[] = []; + if (!subcommand || typeof subcommand !== 'string') { + return tokens; + } + + let current = ''; + let inSingle = false; + let inDouble = false; + let isEscaped = false; + let hasToken = false; + + for (let i = 0; i < subcommand.length; i++) { + const char = subcommand[i]; + + if (isEscaped) { + current += char; + hasToken = true; + isEscaped = false; + continue; + } + + if (char === '\\' && !inSingle) { + if (inDouble) { + const next = subcommand[i + 1]; + if (next === '"' || next === '\\' || next === '$' || next === '`' || next === '\n') { + isEscaped = true; + continue; + } + current += char; + hasToken = true; + } else { + isEscaped = true; + } + continue; + } + + if (char === "'" && !inDouble) { + inSingle = !inSingle; + hasToken = true; + continue; + } + + if (char === '"' && !inSingle) { + inDouble = !inDouble; + hasToken = true; + continue; + } + + if (/\s/.test(char) && !inSingle && !inDouble) { + if (hasToken || current.length > 0) { + tokens.push(current); + current = ''; + hasToken = false; + } + continue; + } + + current += char; + hasToken = true; + } + + if (hasToken || current.length > 0) { + tokens.push(current); + } + + return tokens; +} + +/** + * Normalizes path segments (collapsing . and ..) without requiring node:path. + */ +export function normalizePathSegments(p: string): string { + if (!p) return ''; + const isAbsSlash = p.startsWith('/') || p.startsWith('\\'); + const driveMatch = p.match(/^([a-zA-Z]:)[\\/]/); + const prefix = driveMatch ? `${driveMatch[1]}/` : isAbsSlash ? '/' : ''; + const rest = driveMatch ? p.slice(driveMatch[0].length) : isAbsSlash ? p.slice(1) : p; + + const parts = rest.split(/[\\/]+/); + const stack: string[] = []; + for (const part of parts) { + if (!part || part === '.') continue; + if (part === '..') { + if (stack.length > 0 && stack[stack.length - 1] !== '..') { + stack.pop(); + } else if (!prefix) { + stack.push('..'); + } + } else { + stack.push(part); + } + } + return prefix + stack.join('/'); +} + +/** + * Resolves a file path relative to an optional directory without requiring node:path. + */ +export function resolvePath(dir: string | undefined, filePath: string): string { + if (!filePath) return ''; + const trimmed = filePath.trim().replace(/^['"]|['"]$/g, ''); + if (!dir || trimmed.startsWith('/') || trimmed.startsWith('\\') || /^[a-zA-Z]:/.test(trimmed)) { + return normalizePathSegments(trimmed); + } + const cleanDir = dir.trim().replace(/[\\/]+$/, ''); + const cleanPath = trimmed.replace(/^\.[\\/]+/, '').replace(/^[\\/]+/, ''); + return normalizePathSegments(`${cleanDir}/${cleanPath}`); +} + +export interface ParsedSubcommand { + raw: string; + isPublicSurfaceCommand: boolean; + commandType: string; + targetRepo?: string; + gitCwd?: string; + cdDir?: string; + extractedTexts: string[]; + referencedFiles: string[]; + redirectedFiles: string[]; + hasDynamicInput: boolean; + isGraphqlMutation?: boolean; + isLiteralUnknownEndpoint?: boolean; + hasGitDirOrWorkTree?: boolean; +} + +/** + * Detects dynamic shell constructs that cannot be statically inspected: + * command substitution ($(...) or `...`), parameter expansion ($VAR or ${VAR}), + * process substitution (<(...)), and heredocs (<<). + * Correctly ignores literal $VAR inside single quotes (e.g. -m 'costs $HOME'). + */ +export function hasDynamicShellConstructs(subcommand: string): boolean { + let inSingle = false; + let inDouble = false; + let isEscaped = false; + + for (let i = 0; i < subcommand.length; i++) { + const char = subcommand[i]; + if (isEscaped) { + isEscaped = false; + continue; + } + if (char === '\\' && !inSingle) { + isEscaped = true; + continue; + } + if (char === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (char === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (!inSingle) { + if (char === '`') return true; + if (char === '$' && i + 1 < subcommand.length) { + const next = subcommand[i + 1]; + if (next === '(' || next === '{' || /[a-zA-Z_]/.test(next)) { + return true; + } + } + if (!inDouble) { + if (char === '<' && i + 1 < subcommand.length) { + const next = subcommand[i + 1]; + if (next === '<' || next === '(') { + return true; + } + } + } + } + } + return false; +} + +/** + * Extracts a target repository slug from positional PR or issue URLs: + * https://github.com///pull/ or /issues/ + * Ignores URLs that appear inside flag values or body arguments. + */ +function extractPositionalUrlRepo(tokens: string[], startIndex: number): string | undefined { + const urlRegex = /^https?:\/\/github\.com\/([a-z0-9_.-]+\/[a-z0-9_.-]+)\/(?:pull|issues)\/\d+$/i; + for (let i = startIndex; i < tokens.length; i++) { + const token = tokens[i]; + // Skip flags that take an argument + if ( + (token === '-b' || + token === '--body' || + token === '-t' || + token === '--title' || + token === '-F' || + token === '--body-file' || + token === '-R' || + token === '--repo' || + token === '-c' || + token === '--comment' || + token === '-a' || + token === '--assignee' || + token === '-B' || + token === '--base' || + token === '-H' || + token === '--head' || + token === '-l' || + token === '--label' || + token === '-m' || + token === '--milestone' || + token === '-p' || + token === '--project' || + token === '-r' || + token === '--reviewer') && + i + 1 < tokens.length + ) { + i++; // Skip the flag argument + continue; + } + if (token.startsWith('-')) { + continue; + } + const match = token.match(urlRegex); + if (match) { + return match[1].toLowerCase(); + } + } + return undefined; +} + +/** + * Checks if tokens array contains an explicit message or file flag. + * Handles -m, -F, --message, --file, and clustered short flags like -am, -sam. + */ +export function hasMessageOrFileFlag(tokens: string[]): boolean { + return tokens.some((t) => + t === '-m' || + t === '-F' || + t.startsWith('--message') || + t.startsWith('--file') || + (t.startsWith('-') && !t.startsWith('--') && (t.includes('m') || t.includes('F'))) + ); +} + +/** + * Parses message and file options (-m, -F, --message, --file, clustered -am) + * for git commit, git tag, git merge. + * Returns the updated index i. + */ +export function parseMessageAndFileFlags( + tokens: string[], + i: number, + result: ParsedSubcommand +): number { + const token = tokens[i]; + + // -F , -F, --file , --file= + if (token === '-F' || token === '--file') { + if (i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i + 1; + } + return i; + } + if (token.startsWith('--file=')) { + const file = token.slice('--file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + + // -m , -m, --message , --message= + if (token === '-m' || token === '--message') { + if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + return i; + } + if (token.startsWith('--message=')) { + result.extractedTexts.push(token.slice('--message='.length)); + return i; + } + if (token.startsWith('-m') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + return i; + } + + // Clustered short flags ending in m or F: e.g. -am"msg", -am msg, -sam msg + if (token.startsWith('-') && !token.startsWith('--') && (token.includes('m') || token.includes('F'))) { + const mIdx = token.indexOf('m'); + const fIdx = token.indexOf('F'); + if (mIdx !== -1) { + if (mIdx < token.length - 1) { + result.extractedTexts.push(token.slice(mIdx + 1)); + } else if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + } else if (fIdx !== -1) { + if (fIdx < token.length - 1) { + const file = token.slice(fIdx + 1); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + } else if (i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i + 1; + } + } + return i; + } + + return i; +} + +/** + * Parses body and body-file options (-b, --body, -F, --body-file) + * for gh commands (pr create/edit/review/comment/merge, issue create/edit/comment). + * Returns the updated index i. + */ +export function parseBodyFlags( + tokens: string[], + i: number, + result: ParsedSubcommand, + bodyFileFlag: string = '--body-file' +): number { + const token = tokens[i]; + + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + return i; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + return i; + } + if ((token === '-F' || token === bodyFileFlag) && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i + 1; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + if (token.startsWith(`${bodyFileFlag}=`)) { + const file = token.slice(`${bodyFileFlag}=`.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + + return i; +} + +/** + * Parses title options (-t, --title) for gh commands. + * Returns the updated index i. + */ +export function parseTitleFlags( + tokens: string[], + i: number, + result: ParsedSubcommand +): number { + const token = tokens[i]; + if ((token === '-t' || token === '--title') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + if (token.startsWith('-t') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + return i; + } + if (token.startsWith('--title=')) { + result.extractedTexts.push(token.slice('--title='.length)); + return i; + } + return i; +} + +/** + * Parses comment options (-c, --comment) for gh pr/issue close commands. + * Returns the updated index i. + */ +export function parseCommentFlags( + tokens: string[], + i: number, + result: ParsedSubcommand +): number { + const token = tokens[i]; + if ((token === '-c' || token === '--comment') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + if (token.startsWith('-c') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + return i; + } + if (token.startsWith('--comment=')) { + result.extractedTexts.push(token.slice('--comment='.length)); + return i; + } + return i; +} + +/** + * Parses release notes options (-n, --notes, -F, --notes-file) for gh release create. + * Returns the updated index i. + */ +export function parseReleaseNotesFlags( + tokens: string[], + i: number, + result: ParsedSubcommand +): number { + const token = tokens[i]; + if ((token === '-n' || token === '--notes') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + return i + 1; + } + if (token.startsWith('-n') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + return i; + } + if (token.startsWith('--notes=')) { + result.extractedTexts.push(token.slice('--notes='.length)); + return i; + } + if ((token === '-F' || token === '--notes-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i + 1; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + if (token.startsWith('--notes-file=')) { + const file = token.slice('--notes-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + return i; + } + return i; +} + +export function parseSubcommand(subcommand: string): ParsedSubcommand { + const result: ParsedSubcommand = { + raw: subcommand, + isPublicSurfaceCommand: false, + commandType: '', + extractedTexts: [], + referencedFiles: [], + redirectedFiles: [], + hasDynamicInput: false, + }; + + let trimmed = subcommand.trim(); + if (!trimmed) { + return result; + } + + // Strip subshell parentheses: ( cmd ) or (( cmd )) + while (trimmed.startsWith('(') && trimmed.endsWith(')')) { + trimmed = trimmed.slice(1, -1).trim(); + } + while (trimmed.startsWith('{') && trimmed.endsWith('}')) { + trimmed = trimmed.slice(1, -1).trim(); + } + if (!trimmed) { + return result; + } + + // Fail closed if group delimiters are unbalanced (opens a group it does not close, or closes without open) + if (hasUnbalancedGroupDelimiters(trimmed)) { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = 'unresolvable subshell'; + return result; + } + + if (hasDynamicShellConstructs(trimmed)) { + result.hasDynamicInput = true; + } + + const rawTokens = tokenizeShellWords(trimmed); + if (rawTokens.length === 0) { + return result; + } + + // Detect file redirection operators in subcommand (> or >>) + for (let i = 0; i < rawTokens.length; i++) { + const t = rawTokens[i]; + if ((t === '>' || t === '>>' || t === '1>' || t === '1>>') && i + 1 < rawTokens.length) { + result.redirectedFiles.push(rawTokens[i + 1]); + i++; + } else if (t.startsWith('>') || t.startsWith('>>') || t.startsWith('1>') || t.startsWith('1>>')) { + const target = t.replace(/^(?:1?>{1,2})/, ''); + if (target) { + result.redirectedFiles.push(target); + } + } + } + + // Transparent shell control prefixes that precede command invocations + const TRANSPARENT_SHELL_PREFIXES = new Set([ + 'then', + 'do', + 'else', + 'elif', + '!', + 'if', + 'while', + 'until', + '{', + '}', + 'builtin', + ]); + + // Unwrap common command wrappers (command, env) and environment variable assignments + let tokens = rawTokens; + const leadingEnv: Record = {}; + let cmdIdx = 0; + while (cmdIdx < tokens.length) { + const t = tokens[cmdIdx]; + if (TRANSPARENT_SHELL_PREFIXES.has(t)) { + cmdIdx++; + } else if (t === 'command') { + cmdIdx++; + if (cmdIdx < tokens.length && tokens[cmdIdx] === '-p') { + cmdIdx++; + } + } else if (t === 'env') { + cmdIdx++; + while (cmdIdx < tokens.length && tokens[cmdIdx].startsWith('-')) { + const envTok = tokens[cmdIdx]; + if ((envTok === '-C' || envTok === '--chdir') && cmdIdx + 1 < tokens.length) { + result.gitCwd = result.gitCwd ? resolvePath(result.gitCwd, tokens[cmdIdx + 1]) : tokens[cmdIdx + 1]; + cmdIdx += 2; + } else if (envTok.startsWith('--chdir=')) { + const dir = envTok.slice('--chdir='.length); + result.gitCwd = result.gitCwd ? resolvePath(result.gitCwd, dir) : dir; + cmdIdx++; + } else if (envTok.startsWith('-C') && envTok.length > 2) { + const dir = envTok.slice(2); + result.gitCwd = result.gitCwd ? resolvePath(result.gitCwd, dir) : dir; + cmdIdx++; + } else if (envTok === '-u' && cmdIdx + 1 < tokens.length) { + cmdIdx += 2; + } else { + cmdIdx++; + } + } + } else if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(t)) { + const eq = t.indexOf('='); + leadingEnv[t.slice(0, eq)] = t.slice(eq + 1); + cmdIdx++; + } else { + break; + } + } + + tokens = tokens.slice(cmdIdx); + cmdIdx = 0; + + if (tokens.length === 0) { + return result; + } + + const getCommandBasename = (cmd: string): string => { + return cmd.split(/[/\\]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? cmd.toLowerCase(); + }; + + const binary = getCommandBasename(tokens[0]); + + // Check for eval and sh/bash/zsh -c executing git or gh + if (binary === 'eval') { + const evalStr = tokens.slice(1).join(' '); + if (/\b(?:git|gh)\b/i.test(evalStr)) { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = 'eval (invoking git/gh)'; + return result; + } + } + + if (binary === 'sh' || binary === 'bash' || binary === 'zsh') { + const cIndex = tokens.findIndex((tok, idx) => idx > 0 && /-[a-zA-Z]*c/.test(tok)); + if (cIndex !== -1) { + const cmdStr = tokens.slice(cIndex + 1).join(' '); + if (/\b(?:git|gh)\b/i.test(cmdStr)) { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = `${binary} -c (invoking git/gh)`; + return result; + } + } + } + + // Known command wrappers that might invoke git or gh + const KNOWN_WRAPPERS = new Set([ + 'sudo', + 'nohup', + 'time', + 'nice', + 'timeout', + 'exec', + 'xargs', + 'builtin', + 'strace', + 'ltrace', + 'watch', + 'setsid', + ]); + + // If binary is neither 'git' nor 'gh' nor 'cd': + // If it's a known wrapper, check if git or gh appears in command position after wrapper options or arguments. + // Otherwise, ignore git/gh plain arguments (e.g. grep git notes.md, which gh, brew upgrade gh). + if (binary !== 'git' && binary !== 'gh' && binary !== 'cd' && binary !== 'pushd') { + if (KNOWN_WRAPPERS.has(binary)) { + for (let i = 1; i < tokens.length; i++) { + const innerCmd = getCommandBasename(tokens[i]); + if (innerCmd === 'git' || innerCmd === 'gh') { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = `wrapped command (${binary} ${innerCmd})`; + return result; + } + } + } + return result; + } + + // Check GH_REPO= environment variable: only apply to gh commands, NOT git, and NOT gh api! + if (leadingEnv['GH_REPO'] && binary === 'gh') { + const sub1 = tokens.length > 1 ? tokens[1] : ''; + if (sub1 !== 'api') { + result.targetRepo = leadingEnv['GH_REPO'].toLowerCase(); + } + } + + // Handle cd and pushd commands for tracking directory state + if ((binary === 'cd' || binary === 'pushd') && cmdIdx + 1 < tokens.length) { + result.cdDir = tokens[cmdIdx + 1]; + return result; + } + + // Find git binary in tokens + if (binary === 'git') { + if (leadingEnv['GIT_DIR'] || leadingEnv['GIT_WORK_TREE']) { + result.hasGitDirOrWorkTree = true; + } + + let subcmdIndex = -1; + let gitCwd: string | undefined = result.gitCwd; + + // Parse git global options before the git subcommand + for (let i = cmdIdx + 1; i < tokens.length; i++) { + const token = tokens[i]; + + // -C or -C= (case-sensitive; repeated -Cs compose!) + if (token === '-C' && i + 1 < tokens.length) { + gitCwd = gitCwd ? resolvePath(gitCwd, tokens[i + 1]) : tokens[i + 1]; + i++; // skip directory argument + continue; + } + if (token.startsWith('-C=')) { + const nextDir = token.slice(3); + gitCwd = gitCwd ? resolvePath(gitCwd, nextDir) : nextDir; + continue; + } + + // --git-dir , --git-dir=, --work-tree , --work-tree= + if (token === '--git-dir' || token === '--work-tree') { + result.hasGitDirOrWorkTree = true; + if (i + 1 < tokens.length) { + i++; // skip path argument + } + continue; + } + if (token.startsWith('--git-dir=') || token.startsWith('--work-tree=')) { + result.hasGitDirOrWorkTree = true; + continue; + } + + // Skip options that take an argument: -c =, --config-env =, etc. + if (token === '-c' || token === '--config-env') { + i++; // skip config value + continue; + } + + // Skip other flags + if (token.startsWith('-')) { + continue; + } + + // First non-flag token is git subcommand + subcmdIndex = i; + break; + } + + if (gitCwd) { + result.gitCwd = gitCwd; + } + + if (subcmdIndex === -1) { + return result; + } + + const subcmd = tokens[subcmdIndex]; + + // 1. git commit + if (subcmd === 'commit') { + result.isPublicSurfaceCommand = true; + result.commandType = result.hasGitDirOrWorkTree ? 'git commit (with --git-dir/GIT_DIR)' : 'git commit'; + + for (let i = subcmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + + // Message reuse options: dynamically reuse text from history -> flag as dynamic input (fail closed) + if ( + token === '-C' || + token === '-c' || + /^-[Cc].+/.test(token) || + token === '--reuse-message' || + token.startsWith('--reuse-message=') || + token === '--reedit-message' || + token.startsWith('--reedit-message=') || + token === '--fixup' || + token.startsWith('--fixup=') || + token === '--squash' || + token.startsWith('--squash=') + ) { + result.hasDynamicInput = true; + if ( + (token === '-C' || token === '-c' || token === '--reuse-message' || token === '--reedit-message' || token === '--fixup' || token === '--squash') && + i + 1 < tokens.length && + !tokens[i + 1].startsWith('-') + ) { + i++; + } + continue; + } + + if (token === '--amend') { + // If amending without explicit message or file, flag as dynamic reuse + const hasExplicitMessage = hasMessageOrFileFlag(tokens.slice(subcmdIndex + 1)); + if (!hasExplicitMessage) { + result.hasDynamicInput = true; + } + continue; + } + + // --trailer , --trailer= + if (token === '--trailer' && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('--trailer=')) { + result.extractedTexts.push(token.slice('--trailer='.length)); + continue; + } + + const nextIdx = parseMessageAndFileFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + + return result; + } + + // 2. git tag + if (subcmd === 'tag') { + const isTagListingOrDeleting = tokens.slice(subcmdIndex + 1).some((t) => + t === '-d' || t === '-D' || t === '--delete' || + t === '-v' || t === '--verify' || + t === '-l' || t === '--list' + ); + if (!isTagListingOrDeleting) { + let tagFound = false; + for (let i = subcmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + + const nextIdx = parseMessageAndFileFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + + if (token.startsWith('-')) { + if (token === '-u' && i + 1 < tokens.length) { + i++; // skip key-id argument + } + continue; + } + + // First non-flag positional argument is the tag name + if (!tagFound) { + result.extractedTexts.push(token); + tagFound = true; + } + } + + if (tagFound || result.extractedTexts.length > 0 || result.referencedFiles.length > 0 || result.hasDynamicInput) { + result.isPublicSurfaceCommand = true; + result.commandType = result.hasGitDirOrWorkTree ? 'git tag (with --git-dir/GIT_DIR)' : 'git tag'; + return result; + } + } + } + + // 3. git merge + if (subcmd === 'merge') { + const isMergeStateCommand = tokens.slice(subcmdIndex + 1).some((t) => + t === '--abort' || t === '--continue' || t === '--quit' + ); + if (!isMergeStateCommand) { + let hasBranchRef = false; + for (let i = subcmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + + const nextIdx = parseMessageAndFileFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + + if (token.startsWith('-')) { + if ((token === '-s' || token === '--strategy' || token === '-X' || token === '--strategy-option') && i + 1 < tokens.length) { + i++; // skip strategy argument + } + continue; + } + + // Non-flag arguments are merge source branch/commit refs + result.extractedTexts.push(token); + hasBranchRef = true; + } + + if (hasBranchRef || result.extractedTexts.length > 0 || result.referencedFiles.length > 0 || result.hasDynamicInput) { + result.isPublicSurfaceCommand = true; + result.commandType = result.hasGitDirOrWorkTree ? 'git merge (with --git-dir/GIT_DIR)' : 'git merge'; + return result; + } + } + } + } + + // Find gh binary in tokens + if (binary === 'gh') { + if (cmdIdx + 1 >= tokens.length) { + return result; + } + + // Extract repository flag (-R owner/repo, --repo owner/repo, -R=..., --repo=...) + for (let i = cmdIdx + 1; i < tokens.length; i++) { + const token = tokens[i]; + if ((token === '-R' || token === '--repo') && i + 1 < tokens.length) { + result.targetRepo = tokens[i + 1].toLowerCase(); + break; + } + if (token.startsWith('--repo=')) { + result.targetRepo = token.slice('--repo='.length).toLowerCase(); + break; + } + if (token.startsWith('-R=') || (token.startsWith('-R') && token.length > 2)) { + result.targetRepo = token.slice(token.startsWith('-R=') ? 3 : 2).toLowerCase(); + break; + } + } + + // Check PR/Issue URLs from positional arguments only (https://github.com/owner/repo/pull/123 or issues/123) + // Never matches URLs embedded inside -b/--body, -t/--title, or other flag arguments + if (!result.targetRepo) { + const positionalRepo = extractPositionalUrlRepo(tokens, cmdIdx + 1); + if (positionalRepo) { + result.targetRepo = positionalRepo; + } + } + + const sub1 = tokens[cmdIdx + 1]; + const sub2 = cmdIdx + 2 < tokens.length ? tokens[cmdIdx + 2] : ''; + + // 1. gh pr create + if (sub1 === 'pr' && sub2 === 'create') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr create'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const token = tokens[i]; + if (token === '-f' || token === '--fill' || token.startsWith('--fill-')) { + result.hasDynamicInput = true; + continue; + } + let nextIdx = parseTitleFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 2. gh pr edit + if (sub1 === 'pr' && sub2 === 'edit') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr edit'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + let nextIdx = parseTitleFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 3. gh pr review + if (sub1 === 'pr' && sub2 === 'review') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr review'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 4. gh pr comment + if (sub1 === 'pr' && sub2 === 'comment') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr comment'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 5. gh pr merge + if (sub1 === 'pr' && sub2 === 'merge') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr merge'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const token = tokens[i]; + if (token === '--subject' && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('--subject=')) { + result.extractedTexts.push(token.slice('--subject='.length)); + continue; + } + const nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 6. gh pr close + if (sub1 === 'pr' && sub2 === 'close') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr close'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const nextIdx = parseCommentFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 7. gh issue create + if (sub1 === 'issue' && sub2 === 'create') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh issue create'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + let nextIdx = parseTitleFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 8. gh issue edit + if (sub1 === 'issue' && sub2 === 'edit') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh issue edit'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + let nextIdx = parseTitleFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 9. gh issue comment + if (sub1 === 'issue' && sub2 === 'comment') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh issue comment'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const nextIdx = parseBodyFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 10. gh issue close + if (sub1 === 'issue' && sub2 === 'close') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh issue close'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const nextIdx = parseCommentFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + } + return result; + } + + // 11. gh release create + if (sub1 === 'release' && sub2 === 'create') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh release create'; + + let tagFound = false; + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const token = tokens[i]; + let nextIdx = parseTitleFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + nextIdx = parseReleaseNotesFlags(tokens, i, result); + if (nextIdx !== i) { + i = nextIdx; + continue; + } + if (token.startsWith('-')) { + if ((token === '--target' || token === '--discussion-category') && i + 1 < tokens.length) { + i++; + } + continue; + } + // First positional non-flag argument is the release tag + if (!tagFound) { + result.extractedTexts.push(token); + tagFound = true; + } + } + return result; + } + + // 6. gh api + if (sub1 === 'api') { + const isGraphql = tokens.some((t) => t === 'graphql' || t.includes('/graphql')); + + // Option flags for `gh api` that consume the next token as their value + const GH_API_VALUE_FLAGS = new Set([ + '--input', + '-f', + '-F', + '--field', + '--raw-field', + '-H', + '--header', + '-X', + '--method', + '-q', + '--jq', + '-t', + '--template', + '--cache', + '-p', + '--preview', + ]); + + for (let i = cmdIdx + 2; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.startsWith('-')) { + if (GH_API_VALUE_FLAGS.has(token) && i + 1 < tokens.length) { + i++; // skip option argument + } + continue; + } + + // First non-flag positional argument is the API endpoint + let endpoint = token.replace(/^(?:https?:\/\/[^/]+)?\/+/, ''); + // Strip optional v3 or api/v3 prefix + endpoint = endpoint.replace(/^(?:api\/)?v3\/+/, ''); + + if (endpoint.startsWith('repos/')) { + const parts = endpoint.slice('repos/'.length).split('/'); + if (parts.length >= 2) { + const rawSlug = `${parts[0]}/${parts[1]}`.toLowerCase(); + // Do NOT treat literal placeholders like repos/{owner}/{repo}/... as an explicit target + if (!rawSlug.includes('{')) { + result.targetRepo = rawSlug; + } + } + break; + } + const GITHUB_API_NON_REPO_ROUTES = new Set([ + 'orgs', + 'users', + 'user', + 'teams', + 'gists', + 'notifications', + 'search', + 'rate_limit', + 'emojis', + 'events', + 'feeds', + 'issues', + 'markdown', + 'meta', + 'app', + 'installations', + ]); + + const firstSegment = endpoint.split('/')[0].toLowerCase(); + if (!GITHUB_API_NON_REPO_ROUTES.has(firstSegment) && /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/.test(endpoint)) { + const parts = endpoint.split('/'); + result.targetRepo = `${parts[0]}/${parts[1]}`.toLowerCase(); + break; + } + + // If literal endpoint is not graphql and has no placeholders, mark it so cwd remote does not exempt it + if (!isGraphql) { + result.isLiteralUnknownEndpoint = true; + } + break; + } + + for (let i = cmdIdx + 2; i < tokens.length; i++) { + const token = tokens[i]; + + // --input , --input= + if (token === '--input' && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('--input=')) { + const file = token.slice('--input='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + + // -f =, -F =, --field =, --raw-field = + if ((token === '-f' || token === '-F' || token === '--field' || token === '--raw-field') && i + 1 < tokens.length) { + const fieldArg = tokens[i + 1]; + const eqIdx = fieldArg.indexOf('='); + if (eqIdx !== -1) { + const val = fieldArg.slice(eqIdx + 1); + if (val.startsWith('@')) { + result.referencedFiles.push(val.slice(1)); + } else { + result.extractedTexts.push(val); + } + } + i++; + continue; + } + + // Attached short field forms: -fbody="...", -Fbody=@file, -fquery="...", etc. + const attachedFieldMatch = token.match(/^-([fF])([a-zA-Z0-9_.-]+)=(.*)$/s); + if (attachedFieldMatch) { + const val = attachedFieldMatch[3]; + if (val.startsWith('@')) { + result.referencedFiles.push(val.slice(1)); + } else { + result.extractedTexts.push(val); + } + continue; + } + + const fieldMatch = token.match(/^(?:--field|--raw-field)=(.*?)=(.*)$/s); + if (fieldMatch) { + const val = fieldMatch[2]; + if (val.startsWith('@')) { + result.referencedFiles.push(val.slice(1)); + } else { + result.extractedTexts.push(val); + } + continue; + } + + if (isGraphql && token.includes('mutation')) { + result.extractedTexts.push(token); + result.isGraphqlMutation = true; + } + } + + // Check if GraphQL query string specifies a mutation + if (isGraphql) { + for (const t of result.extractedTexts) { + if (/\bmutation\b/i.test(t)) { + result.isGraphqlMutation = true; + break; + } + } + // If a GraphQL query is loaded from a file (--input or @file), + // we cannot verify statically without reading the file. + // Treat as a mutation so it is NEVER exempted from the tracker checkout. + if (!result.isGraphqlMutation && result.referencedFiles.length > 0) { + result.isGraphqlMutation = true; + } + } + + if (result.extractedTexts.length > 0 || result.referencedFiles.length > 0 || result.hasDynamicInput || result.isGraphqlMutation) { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh api'; + } + + return result; + } + } + + return result; +} + +/** + * Inspects a directory's .git/config to determine if it points to tracker_repo or upstream_repo. + * Strict exact owner/repo slug matching is enforced (no substring .includes). + * If both remotes exist, upstream takes precedence. + */ +export async function inspectDirectoryRemote( + dir: string | undefined, + projectConfig: ProjectRepos, + readFile?: FileReader +): Promise<{ isTracker: boolean; isUpstream: boolean; determined: boolean; detectedUpstreamSlug?: string }> { + if (!readFile) { + return { isTracker: false, isUpstream: false, determined: false }; + } + + const gitConfigPath = resolvePath(dir, '.git/config'); + let configContent: string | null = null; + try { + const raw = await readFile(gitConfigPath); + if (raw && typeof raw === 'string') { + configContent = raw; + } + } catch { + configContent = null; + } + + // If reading .git/config failed, check if .git is a worktree gitdir file + if (!configContent) { + try { + const gitFilePath = resolvePath(dir, '.git'); + const gitFileContent = await readFile(gitFilePath); + if (gitFileContent && typeof gitFileContent === 'string') { + const gitDirMatch = gitFileContent.match(/gitdir:\s*([^\r\n]+)/i); + if (gitDirMatch) { + const resolvedGitDir = resolvePath(dir, gitDirMatch[1].trim()); + const commonDir = resolvedGitDir.replace(/[\\/]worktrees[\\/][^\\/]+$/, ''); + const candidateConfigPaths = [ + resolvePath(commonDir, 'config'), + resolvePath(resolvedGitDir, 'config'), + ]; + for (const cfgPath of candidateConfigPaths) { + try { + const raw = await readFile(cfgPath); + if (raw && typeof raw === 'string' && /url\s*=/i.test(raw)) { + configContent = raw; + break; + } + if (!configContent && raw && typeof raw === 'string') { + configContent = raw; + } + } catch { + // Try next candidate + } + } + } + } + } catch { + configContent = null; + } + } + + if (!configContent) { + return { isTracker: false, isUpstream: false, determined: false }; + } + + try { + const configLower = configContent.toLowerCase(); + const urlMatches = configLower.matchAll(/url\s*=\s*([^\r\n]+)/g); + const urls: string[] = []; + for (const m of urlMatches) { + urls.push(m[1].trim()); + } + + if (urls.length === 0) { + return { isTracker: false, isUpstream: false, determined: false }; + } + + const trackerSlug = projectConfig.trackerRepo?.toLowerCase(); + const upstreamSlug = projectConfig.upstreamRepo?.toLowerCase(); + const upstreamRepoName = upstreamSlug?.includes('/') ? upstreamSlug.split('/')[1] : upstreamSlug; + const trackerRepoName = trackerSlug?.includes('/') ? trackerSlug.split('/')[1] : trackerSlug; + + const extractSlugFromUrl = (url: string): string | undefined => { + if (!url) return undefined; + let cleanUrl = url.trim(); + // Strip surrounding quotes + if ( + (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) || + (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) + ) { + cleanUrl = cleanUrl.slice(1, -1).trim(); + } + // Strip trailing inline comments (e.g. # comment or ; comment) + cleanUrl = cleanUrl.replace(/\s+[#;].*$/, '').trim(); + // Strip trailing slashes + cleanUrl = cleanUrl.replace(/\/+$/, ''); + // Strip trailing .git extension + cleanUrl = cleanUrl.replace(/\.git$/, ''); + cleanUrl = cleanUrl.toLowerCase(); + const match = cleanUrl.match(/(?:[:/])([a-z0-9_.-]+\/[a-z0-9_.-]+)$/); + return match ? match[1] : undefined; + }; + + let detectedUpstreamSlug: string | undefined = undefined; + for (const u of urls) { + const s = extractSlugFromUrl(u); + if (s && s !== trackerSlug) { + detectedUpstreamSlug = s; + break; + } + } + + // Upstream match takes precedence over tracker match + const isUpstream = upstreamSlug + ? urls.some((u) => { + const extracted = extractSlugFromUrl(u); + return extracted === upstreamSlug || (upstreamRepoName && extracted?.endsWith(`/${upstreamRepoName}`)); + }) + : false; + + const isTracker = !isUpstream && trackerSlug + ? urls.some((u) => { + const extracted = extractSlugFromUrl(u); + return extracted === trackerSlug || (trackerRepoName && extracted?.endsWith(`/${trackerRepoName}`)); + }) + : false; + + const anySlugParsed = urls.some((u) => extractSlugFromUrl(u) !== undefined); + + return { + isTracker, + isUpstream, + determined: anySlugParsed, + detectedUpstreamSlug, + }; + } catch { + return { isTracker: false, isUpstream: false, determined: false }; + } +} + +export interface EvaluationOptions { + cwd?: string; + isSessionRootFallback?: boolean; + env?: { [key: string]: string | undefined }; + readFile?: FileReader; + projectConfig?: ProjectRepos; +} + +/** + * Generates user-facing blocking reason without instructing agent how to bypass. + */ +export function generateBlockingReason(commandType: string, terms: string[]): string { + const termList = terms.map((t) => `\`${t}\``).join(', '); + return [ + `⛔ Apache Magpie Security Guard: Blocked public-surface command (${commandType}).`, + `Found forbidden security disclosure term(s): ${termList}.`, + '', + 'This hook is an automated guard against accidental public disclosure of embargoed security information,', + 'not a perimeter against deliberate circumvention.', + '', + 'Confidentiality Rule (AGENTS.md & docs/security/fixing-security-issues.md):', + 'Public commits, tags, PR descriptions, and comments on upstream repositories must describe the change in neutral functional terms without mentioning CVE IDs or vulnerability classes before the coordinated disclosure advisory has shipped.', + 'If this is legitimate post-coordinated-disclosure maintenance, consult an Apache Magpie security-team maintainer to review and set the environment bypass.', + ].join('\n'); +} + +/** + * Commands known to be purely read-only or harmless environment manipulations. + * If any preceding command on a composite shell line is NOT in this set, + * subsequent file reads (-F ) fail closed as dynamic input. + */ +const SAFE_READONLY_COMMANDS = new Set([ + 'cd', + 'pushd', + 'popd', + 'dirs', + 'pwd', + 'test', + '[', + 'echo', + 'printf', + 'cat', + 'ls', + 'true', + 'false', + ':', + 'export', + 'set', + 'unset', + 'readonly', +]); + +/** + * Evaluates whether a public-surface command violates embargo terms. + * Evaluates per subcommand, respects project.md config for tracker/upstream resolution, + * tracks cd directory transitions across subcommands, fails closed on unreadable files, + * dynamic pipelines, and indeterminate repositories, and honours only human-set environment escape hatches. + */ +export async function evaluatePublicSurfaceCommand( + commandLine: string, + options?: EvaluationOptions +): Promise<{ blocked: boolean; matchedTerms: string[]; reason?: string }> { + // Deterministic escape hatch: honour ONLY environment variable set by human before session + if (options?.env?.MAGPIE_ALLOW_EMBARGO_TERMS === '1') { + return { blocked: false, matchedTerms: [] }; + } + + if (!commandLine || typeof commandLine !== 'string') { + return { blocked: false, matchedTerms: [] }; + } + + // Split command line into individual subcommands preserving operators + const subcommands = splitShellSubcommands(commandLine); + + let virtualCwd: string | undefined = options?.cwd; + const subshellCwdStack: (string | undefined)[] = []; + const seenRedirectedFiles = new Set(); + let hasPotentiallyMutatingPriorCommand = false; + + for (const item of subcommands) { + if (item.subshellDepthDeltaBefore) { + for (let d = 0; d < item.subshellDepthDeltaBefore; d++) { + subshellCwdStack.push(virtualCwd); + } + } + + if (item.hasUnclosedQuoteWithBinary) { + return { + blocked: true, + matchedTerms: ['unclosed quote', 'dynamic input'], + reason: + '⛔ Apache Magpie Security Guard: Blocked command with unclosed quote containing git/gh binary to prevent quote-blinded bypass.', + }; + } + + const finishSubcommand = () => { + if (item.subshellDepthDeltaAfter) { + for (let d = 0; d < item.subshellDepthDeltaAfter; d++) { + virtualCwd = subshellCwdStack.pop(); + } + } + }; + + const rawSub = item.raw; + const parsed = parseSubcommand(rawSub); + + // Track cd transitions: update virtualCwd across sequential operators (&&, ;, \n, ||) + // Only exclude pipeline (|) and background (&) which run in subshells + if (parsed.cdDir) { + if (item.separatorAfter !== '|' && item.separatorAfter !== '&') { + virtualCwd = resolvePath(virtualCwd, parsed.cdDir); + } + finishSubcommand(); + continue; + } + + // Check if any referenced file was redirected into earlier in the same composite command, + // or if an earlier command in the same line could have modified files (e.g. cp, mv, tee) + if (parsed.referencedFiles.length > 0) { + if (hasPotentiallyMutatingPriorCommand) { + parsed.hasDynamicInput = true; + } + for (const ref of parsed.referencedFiles) { + const resolved = resolvePath(virtualCwd, ref); + if (seenRedirectedFiles.has(resolved) || seenRedirectedFiles.has(ref)) { + parsed.hasDynamicInput = true; + } + } + } + + // Record newly redirected files from this subcommand + for (const red of parsed.redirectedFiles) { + seenRedirectedFiles.add(resolvePath(virtualCwd, red)); + seenRedirectedFiles.add(red); + } + + // If this subcommand is not a public surface command, check if it could mutate files + if (!parsed.isPublicSurfaceCommand) { + const subWords = tokenizeShellWords(rawSub.trim()); + let priorBinary = ''; + for (const w of subWords) { + if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(w)) continue; + priorBinary = w.split(/[/\\]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? w.toLowerCase(); + break; + } + if (!priorBinary || !SAFE_READONLY_COMMANDS.has(priorBinary) || parsed.redirectedFiles.length > 0) { + hasPotentiallyMutatingPriorCommand = true; + } + finishSubcommand(); + continue; + } + + // Resolve git -C relative to virtualCwd, not bare + const effectiveDir = parsed.gitCwd ? resolvePath(virtualCwd, parsed.gitCwd) : virtualCwd; + + // Resolve project configuration (tracker_repo, upstream_repo, upstream_clone) for this subcommand's scope + const projectConfig = options?.projectConfig ?? (await resolveProjectConfig(options?.readFile, effectiveDir, options?.env)); + const trackerRepo = projectConfig.trackerRepo?.toLowerCase(); + let upstreamRepo = projectConfig.upstreamRepo?.toLowerCase(); + + // 1. Exemption: Subcommand explicitly targets the private security tracker + // GraphQL mutations address resources by global Node IDs, which the hook cannot resolve; + // therefore a GraphQL mutation is NEVER exempt, even if targeted at tracker or with GH_REPO= + if (parsed.targetRepo && trackerRepo && parsed.targetRepo === trackerRepo) { + if (!parsed.isGraphqlMutation) { + finishSubcommand(); + continue; + } + } + + // 2. Check if working directory is inside the configured upstream_clone path + let isWorkingDirUpstreamClone = false; + if (projectConfig.upstreamClonePath) { + const normClone = projectConfig.upstreamClonePath.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + const normDir = (effectiveDir || '.').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + if (normDir === normClone || normDir.startsWith(`${normClone}/`)) { + isWorkingDirUpstreamClone = true; + } + } + + // 3. Inspect directory remote from .git/config + let remoteInfo: { isTracker: boolean; isUpstream: boolean; determined: boolean; detectedUpstreamSlug?: string } = { + isTracker: false, + isUpstream: isWorkingDirUpstreamClone, + determined: isWorkingDirUpstreamClone, + }; + + if (options?.readFile) { + const inspected = await inspectDirectoryRemote(effectiveDir, projectConfig, options.readFile); + if (inspected.determined) { + remoteInfo = inspected; + if (isWorkingDirUpstreamClone) { + remoteInfo.isUpstream = true; + } + } + if (!upstreamRepo && isWorkingDirUpstreamClone && remoteInfo.detectedUpstreamSlug) { + upstreamRepo = remoteInfo.detectedUpstreamSlug.toLowerCase(); + } + } + + const upstreamRepoName = upstreamRepo?.includes('/') ? upstreamRepo.split('/')[1] : upstreamRepo; + + // 4. Repository scoping: Subcommands targeting upstream or a contributor's fork of upstream must be scrubbed. + // Subcommands targeting an explicit unrelated repository (e.g. adopter docs, framework) pass unchecked. + const isTargetUpstreamOrFork = + parsed.targetRepo && + upstreamRepo && + (parsed.targetRepo === upstreamRepo || + (upstreamRepoName && parsed.targetRepo.endsWith(`/${upstreamRepoName}`))); + + if (parsed.targetRepo && !isTargetUpstreamOrFork) { + // If the target repository contains shell variables ($VAR, `cmd`), fail closed as dynamic input. + const isDynamicTarget = /[$`]/.test(parsed.targetRepo); + if (isDynamicTarget) { + parsed.hasDynamicInput = true; + } else if (!upstreamRepo) { + // Upstream repo is not configured: cannot exempt, fall through to term scan instead of blocking + } else { + finishSubcommand(); + continue; // Verified non-upstream target repo + } + } + + // 5. For commands without explicit repo flag, determine repository from git context + if (!parsed.targetRepo) { + // GraphQL mutations address resources by global Node IDs, which the hook cannot resolve. + // Therefore, a GraphQL mutation is NEVER exempt, regardless of whether cwd is the tracker. + if (parsed.isGraphqlMutation) { + // Proceed directly to inspection + } else if ( + parsed.commandType.includes('git-dir') || + parsed.commandType.includes('work-tree') || + parsed.commandType.includes('GIT_DIR') || + parsed.commandType.includes('wrapped') + ) { + // Repository context is redirected or indeterminate — cwd remoteInfo cannot exempt it + // Proceed directly to inspection and dynamic-input fail-closed check + } else if (remoteInfo.determined) { + if (remoteInfo.isTracker && !parsed.isLiteralUnknownEndpoint) { + if (options?.isSessionRootFallback) { + // When scope comes from session-root fallback, cwd might not reflect an earlier cd ../. + // Do NOT apply tracker exemption; fall through to term scan! + } else { + finishSubcommand(); + continue; // Safe tracker repository + } + } else if (upstreamRepo && !remoteInfo.isUpstream && !parsed.isLiteralUnknownEndpoint) { + finishSubcommand(); + continue; // Known non-upstream remote (only safe when upstreamRepo is known) + } + } + } + + // If dynamic input (stdin -, heredoc, command substitution, variable, pipe, gh pr create --fill, git commit --amend/reuse) is present + if (parsed.hasDynamicInput) { + // First check if the commandLine itself explicitly carries forbidden terms + const dynamicTerms = findForbiddenTerms(commandLine); + if (dynamicTerms.length > 0) { + const uniqueTerms = Array.from(new Set(dynamicTerms)); + return { + blocked: true, + matchedTerms: uniqueTerms, + reason: generateBlockingReason(parsed.commandType, uniqueTerms), + }; + } + + // If this was a wrapped command (e.g. sudo git, nohup git), provide specific message + if (parsed.commandType.startsWith('wrapped command')) { + return { + blocked: true, + matchedTerms: ['dynamic input', 'wrapped command'], + reason: `⛔ Apache Magpie Security Guard: Blocked public-surface command (${parsed.commandType}). Invocations wrapped by sudo, nohup, timeout, xargs, etc. cannot be verified deterministically before publication. Run the git or gh command directly.`, + }; + } + + // Dynamic input without explicit terms in command line: fail closed + return { + blocked: true, + matchedTerms: ['dynamic input'], + reason: `⛔ Apache Magpie Security Guard: Blocked public-surface command (${parsed.commandType}) with indeterminate dynamic input. Content constructed from variables ($VAR), command substitution ($(..)), stdin (-), or automated reuse (--amend/--fill) cannot be verified deterministically before publication. Provide the text explicitly or via an inspectable file (-F ).`, + }; + } + + const candidateTexts: string[] = [...parsed.extractedTexts]; + + // Read and verify referenced message files + for (const file of parsed.referencedFiles) { + const resolvedPath = resolvePath(effectiveDir, file); + if (!options?.readFile) { + return { + blocked: true, + matchedTerms: [], + reason: `⛔ Apache Magpie Security Guard: Blocked public-surface command (${parsed.commandType}). Referenced message file cannot be read: \`${resolvedPath}\`.`, + }; + } + + try { + const content = await options.readFile(resolvedPath); + if (content === null || content === undefined || typeof content !== 'string') { + return { + blocked: true, + matchedTerms: [], + reason: `⛔ Apache Magpie Security Guard: Blocked public-surface command (${parsed.commandType}). Referenced message file could not be read: \`${resolvedPath}\`.`, + }; + } + candidateTexts.push(content); + } catch { + return { + blocked: true, + matchedTerms: [], + reason: `⛔ Apache Magpie Security Guard: Blocked public-surface command (${parsed.commandType}). Referenced message file could not be read: \`${resolvedPath}\`.`, + }; + } + } + + // Check for forbidden terms + const combinedText = candidateTexts.join('\n'); + const matched = findForbiddenTerms(combinedText); + + if (matched.length > 0) { + const uniqueTerms = Array.from(new Set(matched)); + return { + blocked: true, + matchedTerms: uniqueTerms, + reason: generateBlockingReason(parsed.commandType, uniqueTerms), + }; + } + + finishSubcommand(); + } + + return { blocked: false, matchedTerms: [] }; +} + +/** + * Registers the PreToolUse hook for Claude Code. + * Intercepts Bash tool calls, parses commands, verifies embargo terms, + * and returns { deny: reason } when embargo terms are detected. + */ +export function register(on: any): void { + on('tool.call', { tool: 'Bash' }, async ($: any, e: any, next: any) => { + const command = e?.command; + + if (typeof command === 'string') { + let initialCwd: string | undefined = e?.cwd || e?.directory; + let isSessionRootFallback = false; + if (!initialCwd) { + try { + initialCwd = await $.session.root(); + isSessionRootFallback = true; + } catch { + // $.session may be absent in mock harnesses + } + } + let allowBypass: string | undefined; + try { + allowBypass = await $.env.get('MAGPIE_ALLOW_EMBARGO_TERMS'); + } catch { + // $.env may be absent in mock harnesses + } + const hookEnv = allowBypass ? { MAGPIE_ALLOW_EMBARGO_TERMS: allowBypass } : {}; + const evaluation = await evaluatePublicSurfaceCommand(command, { + cwd: initialCwd, + isSessionRootFallback, + env: hookEnv, + readFile: async (filePath: string) => { + try { + return await $.fs.read(filePath); + } catch { + return null; + } + }, + }); + + if (evaluation.blocked && evaluation.reason) { + return { + deny: evaluation.reason, + }; + } + } + + if (typeof next === 'function') { + return next(e); + } + }); +} + +export default register; diff --git a/plugins/magpie-security/skills/issue-fix/SKILL.md b/plugins/magpie-security/skills/issue-fix/SKILL.md index cc08d51d9..dd96b3d17 100644 --- a/plugins/magpie-security/skills/issue-fix/SKILL.md +++ b/plugins/magpie-security/skills/issue-fix/SKILL.md @@ -23,7 +23,7 @@ capability: - capability:resolve surface_hash: sha256:550eabe3b9bb3689 license: Apache-2.0 -measured_tokens: 7386 +measured_tokens: 7494 ---