diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e3c72a26..f3e38eb3c 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 4c20db77a..7b015111b 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) @@ -1778,6 +1782,60 @@ 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 + +- **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.json b/plugins/magpie-security/hooks/forbidden-terms.json new file mode 100644 index 000000000..8e3a78239 --- /dev/null +++ b/plugins/magpie-security/hooks/forbidden-terms.json @@ -0,0 +1,97 @@ +[ + { + "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/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..e474c60c1 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.test.ts @@ -0,0 +1,980 @@ +// 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"})}\' -R apache/airflow', + { 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('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); + }); + }); +}); 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..ebc0f6496 --- /dev/null +++ b/plugins/magpie-security/hooks/public-surface-scrub.ts @@ -0,0 +1,1443 @@ +// 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), + })); + +/** + * Consecutive words a candidate public text may share with a private text + * before it is reported as a verbatim copy. Aligned with magpie_vcs.publication.VERBATIM_WORDS. + */ +export const VERBATIM_WORDS = 8; + +/** + * Tokenizes text into lowercase alphanumeric words. + */ +function extractWords(text: string): string[] { + const matches = text.toLowerCase().match(/[a-z0-9]+(?:'[a-z]+)?/g); + return matches ? Array.from(matches) : []; +} + +/** + * Computes consecutive n-grams (shingles) of words from text. + */ +function computeShingles(text: string, width: number): Set { + const words = extractWords(text); + const shingles = new Set(); + if (words.length < width) { + return shingles; + } + for (let i = 0; i <= words.length - width; i++) { + shingles.add(words.slice(i, i + width).join(' ')); + } + return shingles; +} + +/** + * Finds all unique forbidden embargo terms that appear in the candidate text. + */ +export function findForbiddenTerms( + text: string, + extraOptions?: { reporterNames?: string[]; privateTexts?: 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); + } + } + + // Check reporter names if provided + if (extraOptions?.reporterNames) { + for (const reporter of extraOptions.reporterNames) { + if (reporter && reporter.trim()) { + const escaped = reporter.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const reporterRx = new RegExp(`\\b${escaped}\\b`, 'i'); + if (reporterRx.test(text)) { + matched.push('reporter name'); + } + } + } + } + + // Check verbatim private text shingles if provided + if (extraOptions?.privateTexts && extraOptions.privateTexts.length > 0) { + const candidateShingles = computeShingles(text, VERBATIM_WORDS); + if (candidateShingles.size > 0) { + for (const privateText of extraOptions.privateTexts) { + if (!privateText) continue; + const privateShingles = computeShingles(privateText, VERBATIM_WORDS); + for (const s of candidateShingles) { + if (privateShingles.has(s)) { + matched.push('verbatim private text'); + break; + } + } + } + } + } + + return matched; +} + +export type FileReader = (filePath: string) => Promise; + +export interface ProjectRepos { + trackerRepo?: string; + upstreamRepo?: string; + isTrackerCheckout?: boolean; + reporterNames?: string[]; + privateTexts?: string[]; +} + +/** + * 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 content (Markdown table or YAML syntax) to extract + * tracker_repo and upstream_repo slugs without assuming naming conventions. + */ +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) ?? + content.match(/\|\s*`?local_upstream`?\s*\|\s*`?([^`|\r\n]+)`?/i) ?? + content.match(/^\s*local_upstream:\s*`?([^`\r\n#]+)`?/im); + const upstreamSlug = cleanRepoSlug(upstreamMatch?.[1]); + if (upstreamSlug) { + result.upstreamRepo = upstreamSlug; + } + + return result; +} + +/** + * Resolves repository identity from /project.md in precedence order: + * 1. .apache-magpie-local/project.md + * 2. .git/apache-magpie/project.md + * 3. .apache-magpie-overrides/project.md + * 4. ../.apache-magpie-overrides/project.md (if in sibling directory) + */ +export async function resolveProjectConfig(readFile?: FileReader, cwd?: string): Promise { + if (!readFile) { + return {}; + } + + const prefix = cwd ? `${cwd.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', + '~/.config/apache-magpie/user.md', + ]; + + for (const path of candidatePaths) { + try { + const content = await readFile(path); + if (content && typeof content === 'string') { + const repos = parseProjectManifest(content); + if (repos.trackerRepo || repos.upstreamRepo) { + // isTrackerCheckout is only true if project.md was found directly in the current checkout, + // never when probing parent/sibling directories (../) or external directories + repos.isTrackerCheckout = !path.startsWith('..') && !path.includes('/..'); + return repos; + } + } + } catch { + // Continue to next candidate + } + } + + return {}; +} + +/** + * Splits a composite shell command line into individual subcommands on + * separators (;, &&, ||, |, \n, \r), respecting single and double quotes. + */ +export function splitShellCommands(commandLine: string): string[] { + const commands: string[] = []; + if (!commandLine || typeof commandLine !== 'string') { + return commands; + } + + let current = ''; + let inSingle = false; + let inDouble = false; + let isEscaped = false; + + 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 === '\n' || char === '\r' || char === ';') { + if (current.trim()) { + commands.push(current.trim()); + } + current = ''; + continue; + } + if (char === '&') { + if (next === '&') { + if (current.trim()) { + commands.push(current.trim()); + } + current = ''; + i++; // skip second & + continue; + } + // Single & is a background separator (unless part of >& redirection) + const prev = i > 0 ? commandLine[i - 1] : ''; + if (prev !== '>') { + if (current.trim()) { + commands.push(current.trim()); + } + current = ''; + continue; + } + } + if (char === '|' && next === '|') { + if (current.trim()) { + commands.push(current.trim()); + } + current = ''; + i++; // skip second | + continue; + } + if (char === '|') { + if (current.trim()) { + commands.push(current.trim()); + } + current = ''; + continue; + } + } + + current += char; + } + + if (current.trim()) { + commands.push(current.trim()); + } + + return commands; +} + +/** + * Tokenizes a single subcommand line into an argv array according to POSIX shell + * quoting and escaping rules. + * Handles single quotes ('...'), double quotes ("..." with \", \\, \$, \`), + * and unquoted whitespace separation. + */ +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) { + // In double quotes, only \", \\, \$, \`, \newline are escape sequences + 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[]; + hasDynamicInput: boolean; +} + +/** + * Parses a single subcommand using the shell-words argv tokenizer. + * Identifies git/gh invocations and extracts messages, referenced files, + * target repositories, and git working directories. + */ +/** + * 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; +} + +export function parseSubcommand(subcommand: string): ParsedSubcommand { + const result: ParsedSubcommand = { + raw: subcommand, + isPublicSurfaceCommand: false, + commandType: '', + extractedTexts: [], + referencedFiles: [], + hasDynamicInput: false, + }; + + const trimmed = subcommand.trim(); + if (!trimmed) { + return result; + } + + if (hasDynamicShellConstructs(trimmed)) { + result.hasDynamicInput = true; + } + + const tokens = tokenizeShellWords(trimmed); + if (tokens.length === 0) { + return result; + } + + // Check leading environment variable assignments (e.g. GH_REPO=owner/repo cmd ...) + let cmdIdx = 0; + while (cmdIdx < tokens.length && /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(tokens[cmdIdx])) { + const envToken = tokens[cmdIdx]; + const ghRepoMatch = envToken.match(/^GH_REPO=([^\s]+)$/i); + if (ghRepoMatch) { + result.targetRepo = ghRepoMatch[1].toLowerCase(); + } + cmdIdx++; + } + if (cmdIdx >= tokens.length) { + return result; + } + + const binary = tokens[cmdIdx]; + + // 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') { + 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!) + if (token === '-C' && i + 1 < tokens.length) { + gitCwd = tokens[i + 1]; + i++; // skip directory argument + continue; + } + if (token.startsWith('-C=')) { + gitCwd = token.slice(3); + continue; + } + + // Skip options that take an argument: -c =, --config-env =, etc. + if (token === '-c' || token === '--config-env') { + i++; // skip config value + continue; + } + + // Skip other flags + if (token.startsWith('-')) { + continue; + } + + // First non-flag token is git subcommand + subcmdIndex = i; + break; + } + + if (gitCwd) { + result.gitCwd = gitCwd; + } + + if (subcmdIndex === -1) { + return result; + } + + const subcmd = tokens[subcmdIndex]; + + // 1. git commit + if (subcmd === 'commit') { + result.isPublicSurfaceCommand = true; + result.commandType = 'git commit'; + + for (let i = subcmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + + 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; + } + + // -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; + } + + // Clustered flags with boolean commit flags preceding m or F (e.g. -am, -sam, -aF, -saF) + const clusteredMatch = token.match(/^-[asvqenS]+([mF])(.*)$/); + if (clusteredMatch) { + const flag = clusteredMatch[1]; + const rest = clusteredMatch[2]; + if (flag === 'm') { + if (rest.length > 0) { + result.extractedTexts.push(rest); + } else if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + } + continue; + } + if (flag === 'F') { + const file = rest.length > 0 ? rest : (i + 1 < tokens.length ? tokens[++i] : ''); + if (file === '-') { + result.hasDynamicInput = true; + } else if (file) { + result.referencedFiles.push(file); + } + continue; + } + } + } + + return result; + } + + // 2. git tag + if (subcmd === 'tag') { + const hasMsg = tokens.slice(subcmdIndex + 1).some((t) => + t === '-m' || t === '-F' || t.startsWith('-m') || t.startsWith('-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; + } + + // Clustered flags + const clusteredMatch = token.match(/^-[asvqenS]+([mF])(.*)$/); + if (clusteredMatch) { + const flag = clusteredMatch[1]; + const rest = clusteredMatch[2]; + if (flag === 'm') { + if (rest.length > 0) { + result.extractedTexts.push(rest); + } else if (i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + } + continue; + } + if (flag === 'F') { + const file = rest.length > 0 ? rest : (i + 1 < tokens.length ? tokens[++i] : ''); + if (file === '-') { + result.hasDynamicInput = true; + } else if (file) { + result.referencedFiles.push(file); + } + continue; + } + } + } + + return result; + } + } + + return result; + } + + // Handle gh binary in tokens + if (binary === 'gh') { + // Extract target repo: -R , -R=, --repo , --repo=, -R + // Linear scan skipping arguments to other flags to prevent reading --repo from inside body/title + 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(); + i++; + continue; + } + if (token.startsWith('-R=')) { + result.targetRepo = token.slice(3).toLowerCase(); + continue; + } + if (token.startsWith('--repo=')) { + result.targetRepo = token.slice(7).toLowerCase(); + continue; + } + if (token.startsWith('-R') && token.length > 2) { + result.targetRepo = token.slice(2).toLowerCase(); + continue; + } + const urlMatch = token.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/(?:pull|issues)\/\d+/i); + if (urlMatch) { + result.targetRepo = urlMatch[1].toLowerCase(); + continue; + } + // Skip flags that take an argument so body or title text cannot be mistaken for flags + if ((token === '-b' || token === '--body' || token === '-t' || token === '--title' || + token === '-F' || token === '--body-file' || token === '-f' || token === '--field' || + token === '--raw-field' || token === '--input') && i + 1 < tokens.length) { + i++; + continue; + } + } + + const ghSub1 = tokens[cmdIdx + 1]; + const ghSub2 = tokens[cmdIdx + 2]; + + // gh pr create + if (ghSub1 === 'pr' && ghSub2 === 'create') { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh pr create'; + + if (tokens.some((t) => t === '--fill' || t === '--fill-first')) { + result.hasDynamicInput = true; + } + + 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('--title=')) { + result.extractedTexts.push(token.slice('--title='.length)); + continue; + } + if (token.startsWith('-t') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + 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('--body-file=')) { + const file = token.slice('--body-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; + } + } + + return result; + } + + // gh pr edit + if (ghSub1 === 'pr' && ghSub2 === '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('--title=')) { + result.extractedTexts.push(token.slice('--title='.length)); + continue; + } + if (token.startsWith('-t') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if ((token === '-b' || token === '--body') && i + 1 < tokens.length) { + result.extractedTexts.push(tokens[i + 1]); + i++; + continue; + } + if (token.startsWith('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + 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('--body-file=')) { + const file = token.slice('--body-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; + } + } + + return result; + } + + // gh pr review + if (ghSub1 === 'pr' && ghSub2 === '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('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + continue; + } + if ((token === '--body-file' || token === '-F') && i + 1 < tokens.length) { + const file = tokens[i + 1]; + if (file === '-') { + result.hasDynamicInput = true; + } else { + result.referencedFiles.push(file); + } + i++; + continue; + } + if (token.startsWith('--body-file=')) { + const file = token.slice('--body-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; + } + } + + return result; + } + + // gh pr comment / gh issue comment + if ((ghSub1 === 'pr' || ghSub1 === 'issue') && ghSub2 === '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('--body=')) { + result.extractedTexts.push(token.slice('--body='.length)); + continue; + } + if (token.startsWith('-b') && token.length > 2) { + result.extractedTexts.push(token.slice(2)); + 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('--body-file=')) { + const file = token.slice('--body-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; + } + } + + return result; + } + + // gh api + if (ghSub1 === 'api') { + const isGraphql = tokens.includes('graphql'); + + for (let i = cmdIdx + 2; i < tokens.length; i++) { + const token = tokens[i]; + if (token.startsWith('repos/')) { + const slug = token.slice('repos/'.length).split('/')[0] + '/' + token.slice('repos/'.length).split('/')[1]; + result.targetRepo = slug.toLowerCase(); + continue; + } + if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/.test(token) && !token.startsWith('-')) { + result.targetRepo = token.toLowerCase(); + continue; + } + + // Handle --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; + } + + if ((token === '-f' || token === '-F' || token === '--field' || token === '--raw-field') && i + 1 < tokens.length) { + const fieldArg = tokens[i + 1]; + const match = fieldArg.match(/^(?:body|title|query)=(.*)$/s); + if (match) { + const val = match[1]; + if (val.startsWith('@')) { + result.referencedFiles.push(val.slice(1)); + } else { + result.extractedTexts.push(val); + } + } + i++; + continue; + } + const fieldMatch = token.match(/^(?:--field|--raw-field)=(?:body|title|query)=(.*)$/s); + if (fieldMatch) { + const val = fieldMatch[1]; + if (val.startsWith('@')) { + result.referencedFiles.push(val.slice(1)); + } else { + result.extractedTexts.push(val); + } + continue; + } + + if (isGraphql && token.includes('mutation')) { + result.extractedTexts.push(token); + } + } + + if (result.extractedTexts.length > 0 || result.referencedFiles.length > 0 || result.hasDynamicInput) { + result.isPublicSurfaceCommand = true; + result.commandType = 'gh api'; + } + + return result; + } + } + + return result; +} + +export interface EvaluationOptions { + readFile?: FileReader; + env?: Record; + projectConfig?: ProjectRepos; + privateTexts?: string[]; + reporterNames?: string[]; +} + +/** + * Generates an actionable, maintainer-aligned blocking explanation. + * Does NOT teach the agent how to bypass; instructs asking the maintainer. + */ +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}.`, + '', + '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'); +} + +/** + * Inspects a directory's git remote URLs to determine if it is tracker, upstream, or third-party. + * Compares against declared trackerRepo and upstreamRepo (including contributor forks). + */ +async function inspectDirectoryRemote( + dir: string | undefined, + projectConfig: ProjectRepos, + readFile?: FileReader +): Promise<{ isTracker: boolean; isUpstream: boolean; determined: boolean }> { + 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(); + // Extract url lines under [remote ...] + 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 matchesRepo = (url: string, slug?: string, repoName?: string): boolean => { + if (!url) return false; + const cleanUrl = url.toLowerCase().replace(/\.git$/, ''); + if (slug && cleanUrl.includes(slug)) { + return true; + } + if (repoName && (cleanUrl.endsWith(`/${repoName}`) || cleanUrl.endsWith(`:${repoName}`))) { + return true; + } + return false; + }; + + const isTracker = trackerSlug ? urls.some((u) => matchesRepo(u, trackerSlug, trackerRepoName)) : false; + const isUpstream = upstreamSlug ? urls.some((u) => matchesRepo(u, upstreamSlug, upstreamRepoName)) : false; + + return { + isTracker, + isUpstream, + determined: true, + }; + } catch { + return { isTracker: false, isUpstream: false, determined: false }; + } +} + +/** + * 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 + * 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 + const subcommands = splitShellCommands(commandLine); + + let virtualCwd: string | undefined = undefined; + + for (const rawSub of subcommands) { + const parsed = parseSubcommand(rawSub); + + // Track cd transitions + if (parsed.cdDir) { + virtualCwd = resolvePath(virtualCwd, parsed.cdDir); + continue; + } + + if (!parsed.isPublicSurfaceCommand) { + continue; + } + + const effectiveDir = parsed.gitCwd || virtualCwd; + + // Resolve project configuration (tracker_repo, upstream_repo) for this subcommand's scope + const projectConfig = options?.projectConfig ?? (await resolveProjectConfig(options?.readFile, effectiveDir)); + const trackerRepo = projectConfig.trackerRepo?.toLowerCase(); + const upstreamRepo = projectConfig.upstreamRepo?.toLowerCase(); + const upstreamRepoName = upstreamRepo?.includes('/') ? upstreamRepo.split('/')[1] : upstreamRepo; + + // 1. Exemption: Subcommand explicitly targets the private security tracker + if (parsed.targetRepo && trackerRepo && parsed.targetRepo === trackerRepo) { + continue; + } + + // 2. 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) { + continue; + } + + // 3. For commands without explicit repo flag, determine repository from git context + if (!parsed.targetRepo) { + if (options?.readFile) { + const remoteInfo = await inspectDirectoryRemote(effectiveDir, projectConfig, options.readFile); + 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 + } + } else { + // If remote is not determined and no tracker/upstream is configured, allow unrelated repos + if (!trackerRepo && !upstreamRepo) { + continue; + } + } + } + } + + // If dynamic input (stdin -, heredoc, command substitution, variable, pipe, gh pr create --fill, git commit --amend without message) is present + if (parsed.hasDynamicInput) { + // First check if the commandLine itself explicitly carries forbidden terms + const dynamicTerms = findForbiddenTerms(commandLine, { + reporterNames: options?.reporterNames ?? projectConfig.reporterNames, + privateTexts: options?.privateTexts ?? projectConfig.privateTexts, + }); + if (dynamicTerms.length > 0) { + const uniqueTerms = Array.from(new Set(dynamicTerms)); + return { + blocked: true, + matchedTerms: uniqueTerms, + reason: generateBlockingReason(parsed.commandType, uniqueTerms), + }; + } + // If the published content cannot be determined statically, 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 ).`, + }; + } + + // Collect all text candidates for this subcommand + const candidateTexts: string[] = [...parsed.extractedTexts]; + + // Read and inspect any referenced message files (-F / --body-file) + 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, reporter names, and verbatim private text + const combinedText = candidateTexts.join('\n'); + const matched = findForbiddenTerms(combinedText, { + reporterNames: options?.reporterNames ?? projectConfig.reporterNames, + privateTexts: options?.privateTexts ?? projectConfig.privateTexts, + }); + + if (matched.length > 0) { + // If scope could not be determined deterministically as tracker, fail closed + const uniqueTerms = Array.from(new Set(matched)); + return { + blocked: true, + matchedTerms: uniqueTerms, + reason: generateBlockingReason(parsed.commandType, uniqueTerms), + }; + } + } + + return { blocked: false, matchedTerms: [] }; +} + +/** + * Event hook registration entry point for Claude Code. + */ +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 } : (e?.env ?? {}); + 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 ---