diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b72670774..01d2e0b32 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\.(json|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 0187bc2b9..827a469cd 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -35,6 +35,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) @@ -1783,6 +1787,70 @@ 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`, `gh pr create|edit|review|comment`, `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. +- **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/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..93236f540 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.test.ts @@ -0,0 +1,1469 @@ +// 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, + 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'); + assertDeepStrictEqual(tagM.extractedTexts, ['Release v1.0.0']); + + const tagF = parseSubcommand('git tag -F tag-notes.txt v1.0.0'); + assertStrictEqual(tagF.isPublicSurfaceCommand, true); + assertDeepStrictEqual(tagF.referencedFiles, ['tag-notes.txt']); + + // Lightweight tag without message is not a public surface publishing command + const bareTag = parseSubcommand('git tag v1.0.0'); + assertStrictEqual(bareTag.isPublicSurfaceCommand, false); + }); + + 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 pr 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', async () => { + const gitConfig = ` +[remote "origin"] + url = https://github.com/third-party/utility-lib.git +`; + const res = await evaluatePublicSurfaceCommand( + 'git commit -m "fix(generate-cve-json): tidy"', + { + readFile: async (p: string) => { + if (p.includes('.git/config')) return gitConfig; + return null; + }, + } + ); + assertStrictEqual(res.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('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')); + }); + }); +}); 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..445ec6e42 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.ts @@ -0,0 +1,2072 @@ +// 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) { + return merged; + } + } + } catch { + // Continue to next candidate + } + } + + return merged; +} + +export interface ShellSubcommand { + raw: string; + separatorAfter?: string; +} + +/** + * 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 pushCurrent = (sep?: string) => { + const trimmed = current.trim(); + if (trimmed) { + if (isEnclosedInMatchingGroup(trimmed)) { + // 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) { + 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) { + 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') { + 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); + 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; +} + +/** + * 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 trimmed; + } + const cleanDir = dir.trim().replace(/[\\/]+$/, ''); + const cleanPath = trimmed.replace(/^\.[\\/]+/, '').replace(/^[\\/]+/, ''); + return `${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; +} + +/** + * 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; +} + +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); + } + } + } + + // 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 (t === 'command') { + cmdIdx++; + if (cmdIdx < tokens.length && tokens[cmdIdx] === '-p') { + cmdIdx++; + } + } else if (t === 'env') { + cmdIdx++; + while (cmdIdx < tokens.length && tokens[cmdIdx].startsWith('-')) { + if (tokens[cmdIdx] === '-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]); + + // 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. + // Otherwise, ignore git/gh plain arguments (e.g. grep git notes.md, which gh, brew upgrade gh). + if (binary !== 'git' && binary !== 'gh' && binary !== 'cd') { + if (KNOWN_WRAPPERS.has(binary)) { + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t.startsWith('-')) { + continue; // skip wrapper options + } + if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(t)) { + continue; // skip inline env assignments + } + const innerCmd = getCommandBasename(t); + if (innerCmd === 'git' || innerCmd === 'gh') { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = `wrapped command (${binary} ${innerCmd})`; + return result; + } + break; // First command-position argument after wrapper was not git/gh + } + } + 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 command for tracking directory state + if (binary === 'cd' && cmdIdx + 1 < tokens.length) { + result.cdDir = tokens[cmdIdx + 1]; + return result; + } + + // Find git binary in tokens + if (binary === 'git') { + // Fail closed if GIT_DIR or GIT_WORK_TREE environment variables are supplied + if (leadingEnv['GIT_DIR'] || leadingEnv['GIT_WORK_TREE']) { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = 'git (with GIT_DIR/GIT_WORK_TREE)'; + return result; + } + + let subcmdIndex = -1; + let gitCwd: string | undefined = undefined; + + // 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= + // Fail closed as repository context is redirected and cannot be verified statically + if (token === '--git-dir' || token === '--work-tree') { + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = `git (with ${token})`; + return result; + } + if (token.startsWith('--git-dir=') || token.startsWith('--work-tree=')) { + const flag = token.split('=')[0]; + result.isPublicSurfaceCommand = true; + result.hasDynamicInput = true; + result.commandType = `git (with ${flag})`; + return result; + } + + // 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 = '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) + // Matches -C, -c, attached -CHEAD, -cHEAD, --reuse-message, etc. + 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 = tokens.slice(subcmdIndex + 1).some((t) => + t === '-m' || t === '-F' || t.startsWith('-m') || t.startsWith('-F') || + t.startsWith('--message') || t.startsWith('--file') + ); + 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; + } + + // -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); + } + i++; + } + continue; + } + if (token.startsWith('--file=')) { + const file = token.slice('--file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + + // -m , -m, --message , --message= + if (token === '-m' || token === '--message') { + if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + } + continue; + } + if (token.startsWith('--message=')) { + result.extractedTexts.push(token.slice('--message='.length)); + continue; + } + if (token.startsWith('-m') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + + // Handle clustered short flags ending in m or F: e.g. -am"msg", -am 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]); + i++; + } + } 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); + } + i++; + } + } + continue; + } + } + + return result; + } + + // 2. git tag + if (subcmd === 'tag') { + const hasMsg = tokens.slice(subcmdIndex + 1).some((t) => + t === '-m' || t === '-F' || + (t.startsWith('-') && !t.startsWith('--') && (t.includes('m') || t.includes('F'))) || + t.startsWith('--message') || t.startsWith('--file') + ); + if (hasMsg) { + result.isPublicSurfaceCommand = true; + result.commandType = 'git tag'; + + for (let i = subcmdIndex + 1; i < tokens.length; i++) { + 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); + } + i++; + } + continue; + } + if (token.startsWith('--file=')) { + const file = token.slice('--file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + + // -m , -m, --message , --message= + if (token === '-m' || token === '--message') { + if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + } + continue; + } + if (token.startsWith('--message=')) { + result.extractedTexts.push(token.slice('--message='.length)); + continue; + } + if (token.startsWith('-m') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + + // Handle clustered flags like -am '...', -sm '...' + 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]); + i++; + } + } 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); + } + i++; + } + } + continue; + } + } + + 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; + } + if ((token === '-t' || token === '--title') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-t') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--title=')) { + result.extractedTexts.push(token.slice('--title='.length)); + continue; + } + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if ((token === '-F' || token === '--body-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + 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++) { + const token = tokens[i]; + if ((token === '-t' || token === '--title') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-t') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--title=')) { + result.extractedTexts.push(token.slice('--title='.length)); + continue; + } + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if ((token === '-F' || token === '--body-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + 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 token = tokens[i]; + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if ((token === '-F' || token === '--body-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + 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 token = tokens[i]; + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if ((token === '-F' || token === '--body-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + } + return result; + } + + // 5. gh issue comment + if (sub1 === 'issue' && sub2 === 'comment') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr comment'; + + for (let i = cmdIdx + 3; i < tokens.length; i++) { + const token = tokens[i]; + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if ((token === '-F' || token === '--body-file') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('-F') && token.length > 2) { + const file = token.slice(2); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-file='.length); + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + continue; + } + } + 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 + if (token.startsWith('repos/')) { + const parts = token.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; + } + if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/.test(token)) { + result.targetRepo = token.toLowerCase(); + break; + } + + // First positional argument encountered is not an endpoint (e.g. 'graphql') + 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 fieldName = fieldArg.slice(0, eqIdx); + const val = fieldArg.slice(eqIdx + 1); + + // In GraphQL, inspect EVERY field argument (variables, query, etc.) + // In REST calls, inspect body and title + if (isGraphql || fieldName === 'body' || fieldName === 'title' || fieldName === 'query') { + 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 fieldName = attachedFieldMatch[2]; + const val = attachedFieldMatch[3]; + if (isGraphql || fieldName === 'body' || fieldName === 'title' || fieldName === 'query') { + 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 fieldName = fieldMatch[1]; + const val = fieldMatch[2]; + if (isGraphql || fieldName === 'body' || fieldName === 'title' || fieldName === 'query') { + 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 worktreeConfigPath = resolvePath(resolvedGitDir, 'config'); + const raw = await readFile(worktreeConfigPath); + if (raw && typeof raw === 'string') { + configContent = raw; + } + } + } + } 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 { + 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', + '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 = undefined; + const seenRedirectedFiles = new Set(); + let hasPotentiallyMutatingPriorCommand = false; + + for (const item of subcommands) { + const rawSub = item.raw; + const parsed = parseSubcommand(rawSub); + + // Track cd transitions: only update virtualCwd across sequential operators (&&, ;, \n) + if (parsed.cdDir) { + if (item.separatorAfter === '&&' || item.separatorAfter === ';' || item.separatorAfter === '\n' || !item.separatorAfter) { + virtualCwd = resolvePath(virtualCwd, parsed.cdDir); + } + 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; + } + 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) { + 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`) or cannot be verified + // because upstream_repo is not configured, do NOT exempt! Fail closed as dynamic input. + const isDynamicTarget = /[$`]/.test(parsed.targetRepo); + const cannotVerifyTarget = !upstreamRepo; + if (isDynamicTarget || cannotVerifyTarget) { + parsed.hasDynamicInput = true; + } else { + continue; + } + } + + // 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) { + continue; // Safe tracker repository + } + if (!remoteInfo.isUpstream) { + continue; // Known non-upstream remote (e.g. unrelated third-party repo, adopter docs), allow through + } + } + } + + // 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), + }; + } + } + + 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 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, { + 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 86f8dd92f..26bbd0583 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: 7331 +measured_tokens: 7415 ---