From a2726f6832c690d90dc2f7cebae6aeba1d79efd3 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:44:51 -0400 Subject: [PATCH 01/12] feat: add picomatch dependency Add picomatch for glob pattern matching in protected files feature. Also adds @types/picomatch for TypeScript support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 59 ++++++++++++++++++++++++++++++++++++++++++----- package.json | 4 +++- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66b16a6..3e6c533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@actions/core": "^1.11.1", "@actions/exec": "^1.1.1", - "@actions/github": "^6.0.0" + "@actions/github": "^6.0.0", + "picomatch": "^4.0.4" }, "devDependencies": { "@types/jest": "^29.5.12", "@types/node": "^20.14.0", + "@types/picomatch": "^4.0.3", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "@vercel/ncc": "^0.38.1", @@ -1526,6 +1528,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1869,6 +1878,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3867,6 +3889,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -4179,6 +4214,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4445,13 +4493,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" diff --git a/package.json b/package.json index 63a7f5a..7b122a2 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "dependencies": { "@actions/core": "^1.11.1", "@actions/exec": "^1.1.1", - "@actions/github": "^6.0.0" + "@actions/github": "^6.0.0", + "picomatch": "^4.0.4" }, "devDependencies": { "@types/jest": "^29.5.12", "@types/node": "^20.14.0", + "@types/picomatch": "^4.0.3", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "@vercel/ncc": "^0.38.1", From 27421efd8a47bf5b56f16b3b3a64013491dccfa2 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:45:35 -0400 Subject: [PATCH 02/12] feat: implement protected files checker Add src/protected-files.ts with: - Default protected patterns for CI config, dependency manifests, agent instructions, and access control files - gitignore-style pattern matching via picomatch (last match wins, negation with ! prefix) - Config resolution merging built-in defaults with user patterns - Category classification for violation reporting - checkProtectedFiles scans all create_pull_request actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/protected-files.ts | 175 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/protected-files.ts diff --git a/src/protected-files.ts b/src/protected-files.ts new file mode 100644 index 0000000..977b0ac --- /dev/null +++ b/src/protected-files.ts @@ -0,0 +1,175 @@ +import picomatch from 'picomatch'; +import { AgentOutput, CreatePullRequestAction } from './types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ProtectedFilesConfig { + action: 'block' | 'warn'; + patterns: string[]; + overrideDefaults: boolean; +} + +export interface ProtectedFileViolation { + path: string; + matchedPattern: string; + category: string; +} + +export interface ProtectedFilesResult { + passed: boolean; + violations: ProtectedFileViolation[]; + checkedFiles: number; + protectedAction: 'block' | 'warn'; +} + +// --------------------------------------------------------------------------- +// Built-in defaults +// --------------------------------------------------------------------------- + +export const DEFAULT_PROTECTED_PATTERNS: readonly string[] = [ + '.github/workflows/**', + '.github/actions/**', + 'CODEOWNERS', + 'AGENTS.md', + '.claude/**', + '.codex/**', + '.github/copilot-instructions.md', + 'package.json', + 'package-lock.json', + 'go.mod', + 'go.sum', + 'requirements.txt', + 'Pipfile.lock', + 'Gemfile.lock', + 'pnpm-lock.yaml', + 'yarn.lock', +]; + +// --------------------------------------------------------------------------- +// Category classification +// --------------------------------------------------------------------------- + +const CATEGORY_MAP: { pattern: string; category: string }[] = [ + { pattern: '.github/workflows/**', category: 'CI config' }, + { pattern: '.github/actions/**', category: 'CI config' }, + { pattern: 'package.json', category: 'Dependency manifest' }, + { pattern: 'package-lock.json', category: 'Dependency manifest' }, + { pattern: 'go.mod', category: 'Dependency manifest' }, + { pattern: 'go.sum', category: 'Dependency manifest' }, + { pattern: 'requirements.txt', category: 'Dependency manifest' }, + { pattern: 'Pipfile.lock', category: 'Dependency manifest' }, + { pattern: 'Gemfile.lock', category: 'Dependency manifest' }, + { pattern: 'pnpm-lock.yaml', category: 'Dependency manifest' }, + { pattern: 'yarn.lock', category: 'Dependency manifest' }, + { pattern: 'AGENTS.md', category: 'Agent instructions' }, + { pattern: '.claude/**', category: 'Agent instructions' }, + { pattern: '.codex/**', category: 'Agent instructions' }, + { pattern: '.github/copilot-instructions.md', category: 'Agent instructions' }, + { pattern: 'CODEOWNERS', category: 'Access control' }, +]; + +/** + * Classify a matched pattern into a human-readable category. + * Known built-in patterns map to specific categories; anything else is "Custom". + */ +export function classifyPattern(matchedPattern: string): string { + for (const entry of CATEGORY_MAP) { + if (entry.pattern === matchedPattern) { + return entry.category; + } + } + return 'Custom'; +} + +// --------------------------------------------------------------------------- +// Pattern matching (gitignore-style, last match wins) +// --------------------------------------------------------------------------- + +export function isFileProtected( + filepath: string, + patterns: string[] +): { protected: boolean; matchedPattern?: string } { + let isProtected = false; + let matchedPattern: string | undefined; + + for (const pattern of patterns) { + if (pattern.startsWith('!')) { + const negated = pattern.slice(1); + if (picomatch.isMatch(filepath, negated, { dot: true })) { + isProtected = false; + matchedPattern = undefined; + } + } else { + if (picomatch.isMatch(filepath, pattern, { dot: true })) { + isProtected = true; + matchedPattern = pattern; + } + } + } + + return { protected: isProtected, matchedPattern }; +} + +// --------------------------------------------------------------------------- +// Config resolution +// --------------------------------------------------------------------------- + +/** + * Merge built-in defaults with user-supplied patterns. + * When `overrideDefaults` is true the built-in list is skipped entirely. + */ +export function resolveProtectedConfig(config: ProtectedFilesConfig): string[] { + const base: string[] = config.overrideDefaults ? [] : [...DEFAULT_PROTECTED_PATTERNS]; + return [...base, ...config.patterns]; +} + +// --------------------------------------------------------------------------- +// Main checker +// --------------------------------------------------------------------------- + +/** + * Scan all `create_pull_request` actions in the agent output for files + * that match the protected pattern list. + */ +export function checkProtectedFiles( + output: AgentOutput, + config: ProtectedFilesConfig +): ProtectedFilesResult { + const patterns = resolveProtectedConfig(config); + const violations: ProtectedFileViolation[] = []; + let checkedFiles = 0; + + if (!output || !Array.isArray(output.actions)) { + return { passed: true, violations: [], checkedFiles: 0, protectedAction: config.action }; + } + + for (const action of output.actions) { + if (action.type !== 'create_pull_request') continue; + + const prAction = action as CreatePullRequestAction; + if (!prAction.files) continue; + + for (const filepath of Object.keys(prAction.files)) { + checkedFiles++; + const result = isFileProtected(filepath, patterns); + if (result.protected && result.matchedPattern) { + violations.push({ + path: filepath, + matchedPattern: result.matchedPattern, + category: classifyPattern(result.matchedPattern), + }); + } + } + } + + const passed = config.action === 'warn' || violations.length === 0; + + return { + passed, + violations, + checkedFiles, + protectedAction: config.action, + }; +} From 09c065b2a1c54b1a1487c3ab239f0bf627dec862 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:46:31 -0400 Subject: [PATCH 03/12] feat: integrate protected files into pipeline - Add Phase 2 (protected files check) between validation and sanitization - Read new inputs: protected-files, protected-files-action, protected-files-override-defaults - Block or warn when protected files are modified in create_pull_request - Renumber subsequent phases (sanitization -> 3, threats -> 4, exec -> 5) - Add three new inputs to action.yml with descriptions and defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- action.yml | 12 ++++++++++++ src/main.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/action.yml b/action.yml index 5912b11..9a9b8bc 100644 --- a/action.yml +++ b/action.yml @@ -42,6 +42,18 @@ inputs: description: 'Additional regex patterns to scan for sensitive data (one per line)' required: false default: '' + protected-files: + description: 'Glob patterns for protected files (one per line). Uses .gitignore-compatible syntax via picomatch. Merged with built-in defaults unless override is set. Use ! prefix to create exceptions.' + required: false + default: '' + protected-files-action: + description: 'Action when protected files are modified: "block" (fail the check) or "warn" (annotate but continue)' + required: false + default: 'block' + protected-files-override-defaults: + description: 'When true, built-in default patterns are NOT applied. Only user-provided patterns are used.' + required: false + default: 'false' threat-detection: description: 'Enable AI-powered threat detection via Copilot CLI. Scans for prompt injection, credential leaks, malicious code, and social engineering. Requires Copilot CLI on the runner.' required: false diff --git a/src/main.ts b/src/main.ts index 4d5ac93..0ced7dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import * as github from '@actions/github'; import { readFileSync } from 'fs'; import { AgentOutput } from './types'; import { validateOutput, ValidationConstraints } from './validator'; +import { checkProtectedFiles, ProtectedFilesConfig } from './protected-files'; import { sanitizeOutput } from './sanitizer'; import { detectThreats } from './threat-detector'; import { executeActions } from './executor'; @@ -24,6 +25,15 @@ async function run(): Promise { .split('\n') .map((p) => p.trim()) .filter(Boolean); + const protectedFilesAction = core.getInput('protected-files-action') || 'block'; + const protectedFilesPatterns = core + .getInput('protected-files') + .split('\n') + .map((p) => p.trim()) + .filter(Boolean); + const protectedFilesOverrideDefaults = core.getBooleanInput( + 'protected-files-override-defaults' + ); const dryRun = core.getBooleanInput('dry-run'); const failOnSanitize = core.getBooleanInput('fail-on-sanitize'); const threatDetection = core.getBooleanInput('threat-detection'); @@ -57,8 +67,39 @@ async function run(): Promise { core.info(`All ${validation.passed} action(s) passed constraint validation`); core.endGroup(); - // Phase 2: Sanitize secrets - core.startGroup('Phase 2: Secret sanitization'); + // Phase 2: Protected files check + core.startGroup('Phase 2: Protected files check'); + const protectedConfig: ProtectedFilesConfig = { + action: protectedFilesAction as 'block' | 'warn', + patterns: protectedFilesPatterns, + overrideDefaults: protectedFilesOverrideDefaults, + }; + const protectedResult = checkProtectedFiles(output, protectedConfig); + + if (!protectedResult.passed) { + for (const v of protectedResult.violations) { + const msg = `PROTECTED [${v.category}] ${v.path} (matched: ${v.matchedPattern})`; + if (protectedConfig.action === 'block') { + core.error(msg); + } else { + core.warning(msg); + } + } + + if (protectedConfig.action === 'block') { + core.endGroup(); + setOutputs({ blocked: protectedResult.violations.length, applied: 0, sanitized: 0 }); + core.setFailed( + `${protectedResult.violations.length} file(s) blocked by protected files policy` + ); + return; + } + } + core.info(`Checked ${protectedResult.checkedFiles} file(s) across PR actions`); + core.endGroup(); + + // Phase 3: Sanitize secrets + core.startGroup('Phase 3: Secret sanitization'); const sanitization = sanitizeOutput(output, customPatterns); if (sanitization.redactedCount > 0) { @@ -77,9 +118,9 @@ async function run(): Promise { } core.endGroup(); - // Phase 3: AI threat detection (optional) + // Phase 4: AI threat detection (optional) if (threatDetection) { - core.startGroup('Phase 3: AI threat detection'); + core.startGroup('Phase 4: AI threat detection'); const threats = await detectThreats(sanitization.output); if (threats.enabled) { @@ -101,8 +142,8 @@ async function run(): Promise { core.endGroup(); } - // Phase 4: Execute (or dry-run) - core.startGroup(threatDetection ? 'Phase 4: Execution' : 'Phase 3: Execution'); + // Phase 5: Execute (or dry-run) + core.startGroup(threatDetection ? 'Phase 5: Execution' : 'Phase 4: Execution'); if (dryRun) { core.info('DRY RUN: Actions validated and sanitized but NOT applied'); setOutputs({ blocked: 0, applied: 0, sanitized: sanitization.redactedCount }); From 1747fceee2fadf3f383504aca2c9576ef2156ef0 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:48:17 -0400 Subject: [PATCH 04/12] test: comprehensive protected files tests Add 57 tests covering: - Default pattern verification (CI config, deps, agent instructions, access control) - Custom patterns extending defaults - Negation patterns creating exceptions - Override defaults mode (only user patterns apply) - Actions without files are skipped - Non-PR actions pass through - Mixed action types (only create_pull_request checked) - Category classification correctness - Warn mode (violations reported but passed) - Block mode (violations cause failure) - Glob pattern specifics (** vs *, nested paths) - Dotfile matching (.github/, .claude/) - Safe file pass-through (src/index.ts, README.md) - Multiple violations in single PR - Last-match-wins pattern ordering - Empty/whitespace pattern filtering Also fixes isFileProtected to skip empty patterns gracefully (picomatch throws on empty strings). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/__tests__/protected-files.test.ts | 588 ++++++++++++++++++++++++++ src/protected-files.ts | 7 +- 2 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/protected-files.test.ts diff --git a/src/__tests__/protected-files.test.ts b/src/__tests__/protected-files.test.ts new file mode 100644 index 0000000..883c892 --- /dev/null +++ b/src/__tests__/protected-files.test.ts @@ -0,0 +1,588 @@ +import { + checkProtectedFiles, + isFileProtected, + resolveProtectedConfig, + classifyPattern, + DEFAULT_PROTECTED_PATTERNS, + ProtectedFilesConfig, +} from '../protected-files'; +import { AgentOutput } from '../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const blockConfig: ProtectedFilesConfig = { + action: 'block', + patterns: [], + overrideDefaults: false, +}; + +const warnConfig: ProtectedFilesConfig = { + action: 'warn', + patterns: [], + overrideDefaults: false, +}; + +function makePrOutput(files: Record): AgentOutput { + return { + actions: [ + { + type: 'create_pull_request', + title: 'Fix things', + body: 'Details', + head: 'fix/stuff', + files, + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DEFAULT_PROTECTED_PATTERNS', () => { + it('includes CI config patterns', () => { + expect(DEFAULT_PROTECTED_PATTERNS).toContain('.github/workflows/**'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('.github/actions/**'); + }); + + it('includes dependency manifest patterns', () => { + expect(DEFAULT_PROTECTED_PATTERNS).toContain('package.json'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('package-lock.json'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('go.mod'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('go.sum'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('requirements.txt'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('Pipfile.lock'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('Gemfile.lock'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('pnpm-lock.yaml'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('yarn.lock'); + }); + + it('includes agent instructions patterns', () => { + expect(DEFAULT_PROTECTED_PATTERNS).toContain('AGENTS.md'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('.claude/**'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('.codex/**'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('.github/copilot-instructions.md'); + }); + + it('includes access control patterns', () => { + expect(DEFAULT_PROTECTED_PATTERNS).toContain('CODEOWNERS'); + }); +}); + +describe('classifyPattern', () => { + it('classifies CI config patterns', () => { + expect(classifyPattern('.github/workflows/**')).toBe('CI config'); + expect(classifyPattern('.github/actions/**')).toBe('CI config'); + }); + + it('classifies dependency manifest patterns', () => { + expect(classifyPattern('package.json')).toBe('Dependency manifest'); + expect(classifyPattern('go.mod')).toBe('Dependency manifest'); + expect(classifyPattern('yarn.lock')).toBe('Dependency manifest'); + }); + + it('classifies agent instructions patterns', () => { + expect(classifyPattern('AGENTS.md')).toBe('Agent instructions'); + expect(classifyPattern('.claude/**')).toBe('Agent instructions'); + expect(classifyPattern('.github/copilot-instructions.md')).toBe('Agent instructions'); + }); + + it('classifies access control patterns', () => { + expect(classifyPattern('CODEOWNERS')).toBe('Access control'); + }); + + it('classifies unknown patterns as Custom', () => { + expect(classifyPattern('src/**/*.secret')).toBe('Custom'); + expect(classifyPattern('deploy/**')).toBe('Custom'); + }); +}); + +describe('isFileProtected', () => { + it('matches exact file names', () => { + const result = isFileProtected('package.json', ['package.json']); + expect(result.protected).toBe(true); + expect(result.matchedPattern).toBe('package.json'); + }); + + it('matches glob patterns with **', () => { + const result = isFileProtected('.github/workflows/ci.yml', ['.github/workflows/**']); + expect(result.protected).toBe(true); + expect(result.matchedPattern).toBe('.github/workflows/**'); + }); + + it('matches nested paths with **', () => { + const result = isFileProtected('.github/workflows/sub/deploy.yml', ['.github/workflows/**']); + expect(result.protected).toBe(true); + }); + + it('does not match unrelated paths', () => { + const result = isFileProtected('src/index.ts', ['.github/workflows/**', 'package.json']); + expect(result.protected).toBe(false); + expect(result.matchedPattern).toBeUndefined(); + }); + + it('matches dotfiles when dot: true is used', () => { + const result = isFileProtected('.claude/config.json', ['.claude/**']); + expect(result.protected).toBe(true); + }); + + it('matches dotfiles in .codex directory', () => { + const result = isFileProtected('.codex/settings.yaml', ['.codex/**']); + expect(result.protected).toBe(true); + }); + + it('supports negation patterns', () => { + const patterns = ['package.json', 'package-lock.json', '!package-lock.json']; + const result = isFileProtected('package-lock.json', patterns); + expect(result.protected).toBe(false); + expect(result.matchedPattern).toBeUndefined(); + }); + + it('supports last-match-wins semantics', () => { + // First match protects, then negation unprotects, then another match re-protects + const patterns = ['*.json', '!package.json', 'package.json']; + const result = isFileProtected('package.json', patterns); + expect(result.protected).toBe(true); + expect(result.matchedPattern).toBe('package.json'); + }); + + it('negation after match unprotects the file', () => { + const patterns = ['*.lock', '!yarn.lock']; + const yarnResult = isFileProtected('yarn.lock', patterns); + expect(yarnResult.protected).toBe(false); + + const npmResult = isFileProtected('package-lock.json', patterns); + // *.lock does not match package-lock.json (no ** crossing directories) + // Actually *.lock does match package-lock.json since it contains "lock" - wait + // picomatch: *.lock matches any single segment ending in .lock + // package-lock.json does NOT end in .lock, so *.lock won't match it + expect(npmResult.protected).toBe(false); + }); + + it('handles empty pattern list', () => { + const result = isFileProtected('anything.ts', []); + expect(result.protected).toBe(false); + }); + + it('* does not cross directory boundaries', () => { + const result = isFileProtected('src/deep/package.json', ['*.json']); + expect(result.protected).toBe(false); + }); + + it('** crosses directory boundaries', () => { + const result = isFileProtected('src/deep/package.json', ['**/*.json']); + expect(result.protected).toBe(true); + }); +}); + +describe('resolveProtectedConfig', () => { + it('includes defaults when overrideDefaults is false', () => { + const patterns = resolveProtectedConfig({ + action: 'block', + patterns: [], + overrideDefaults: false, + }); + expect(patterns).toEqual(expect.arrayContaining([...DEFAULT_PROTECTED_PATTERNS])); + }); + + it('appends user patterns after defaults', () => { + const patterns = resolveProtectedConfig({ + action: 'block', + patterns: ['deploy/**'], + overrideDefaults: false, + }); + expect(patterns).toContain('deploy/**'); + expect(patterns).toContain('package.json'); + // User pattern comes after defaults + expect(patterns.indexOf('deploy/**')).toBeGreaterThan( + patterns.indexOf('package.json') + ); + }); + + it('skips defaults when overrideDefaults is true', () => { + const patterns = resolveProtectedConfig({ + action: 'block', + patterns: ['my-config.yml'], + overrideDefaults: true, + }); + expect(patterns).toEqual(['my-config.yml']); + expect(patterns).not.toContain('package.json'); + }); + + it('returns empty array when overrideDefaults is true and no user patterns', () => { + const patterns = resolveProtectedConfig({ + action: 'block', + patterns: [], + overrideDefaults: true, + }); + expect(patterns).toEqual([]); + }); +}); + +describe('checkProtectedFiles', () => { + describe('default patterns in block mode', () => { + it('blocks CI config files', () => { + const output = makePrOutput({ '.github/workflows/ci.yml': 'name: CI' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('.github/workflows/ci.yml'); + expect(result.violations[0].category).toBe('CI config'); + }); + + it('blocks dependency manifest files', () => { + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('package.json'); + expect(result.violations[0].category).toBe('Dependency manifest'); + }); + + it('blocks agent instructions files', () => { + const output = makePrOutput({ 'AGENTS.md': '# agents' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].category).toBe('Agent instructions'); + }); + + it('blocks access control files', () => { + const output = makePrOutput({ CODEOWNERS: '* @team' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].category).toBe('Access control'); + }); + + it('blocks .claude directory files', () => { + const output = makePrOutput({ '.claude/config.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].category).toBe('Agent instructions'); + }); + + it('blocks .codex directory files', () => { + const output = makePrOutput({ '.codex/settings.yaml': 'key: val' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + }); + + it('blocks copilot instructions', () => { + const output = makePrOutput({ '.github/copilot-instructions.md': '# instructions' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].category).toBe('Agent instructions'); + }); + }); + + describe('safe files', () => { + it('passes source code files', () => { + const output = makePrOutput({ 'src/index.ts': 'console.log("hi")' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + }); + + it('passes README.md', () => { + const output = makePrOutput({ 'README.md': '# readme' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + }); + + it('passes arbitrary nested files', () => { + const output = makePrOutput({ + 'src/utils/helper.ts': 'export {}', + 'docs/guide.md': '# guide', + }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + expect(result.checkedFiles).toBe(2); + }); + }); + + describe('custom patterns', () => { + it('extends defaults with user patterns', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: ['deploy/**'], + overrideDefaults: false, + }; + const output = makePrOutput({ 'deploy/prod.yaml': 'config' }); + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(false); + expect(result.violations[0].category).toBe('Custom'); + }); + + it('user negation creates exception for defaults', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: ['!package-lock.json'], + overrideDefaults: false, + }; + const output = makePrOutput({ 'package-lock.json': '{}' }); + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + }); + }); + + describe('override defaults', () => { + it('only applies user patterns when overrideDefaults is true', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: ['deploy/**'], + overrideDefaults: true, + }; + // package.json would be blocked by defaults but not by user patterns + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(true); + }); + + it('still blocks files matching user patterns', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: ['deploy/**'], + overrideDefaults: true, + }; + const output = makePrOutput({ 'deploy/prod.yaml': 'config' }); + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(false); + }); + + it('passes everything when overrideDefaults true and no patterns', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: [], + overrideDefaults: true, + }; + const output = makePrOutput({ 'package.json': '{}', '.github/workflows/ci.yml': 'ci' }); + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + }); + }); + + describe('warn mode', () => { + it('reports violations but still passes', () => { + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, warnConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('package.json'); + expect(result.protectedAction).toBe('warn'); + }); + + it('passes with zero violations', () => { + const output = makePrOutput({ 'src/app.ts': 'code' }); + const result = checkProtectedFiles(output, warnConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + }); + }); + + describe('block mode', () => { + it('fails when violations exist', () => { + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.protectedAction).toBe('block'); + }); + + it('passes when no violations exist', () => { + const output = makePrOutput({ 'src/app.ts': 'code' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + }); + }); + + describe('multiple violations', () => { + it('reports all protected files in a single PR', () => { + const output = makePrOutput({ + 'package.json': '{}', + '.github/workflows/deploy.yml': 'deploy', + 'CODEOWNERS': '* @admin', + 'src/safe.ts': 'safe code', + }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(3); + expect(result.checkedFiles).toBe(4); + + const paths = result.violations.map((v) => v.path); + expect(paths).toContain('package.json'); + expect(paths).toContain('.github/workflows/deploy.yml'); + expect(paths).toContain('CODEOWNERS'); + }); + }); + + describe('action types filtering', () => { + it('skips issue actions (no files to check)', () => { + const output: AgentOutput = { + actions: [ + { type: 'create_issue', title: 'Bug', body: 'Details' }, + { type: 'issue_comment', issue_number: 1, body: 'Comment' }, + { type: 'add_labels', issue_number: 1, labels: ['bug'] }, + ], + }; + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + expect(result.checkedFiles).toBe(0); + }); + + it('only checks create_pull_request actions', () => { + const output: AgentOutput = { + actions: [ + { type: 'create_issue', title: 'Bug', body: 'Details' }, + { + type: 'create_pull_request', + title: 'Fix', + body: 'Details', + head: 'fix/thing', + files: { 'package.json': '{}' }, + }, + ], + }; + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.checkedFiles).toBe(1); + }); + + it('skips create_pull_request without files', () => { + const output: AgentOutput = { + actions: [ + { + type: 'create_pull_request', + title: 'Fix', + body: 'Details', + head: 'fix/thing', + }, + ], + }; + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + expect(result.checkedFiles).toBe(0); + }); + + it('checks multiple create_pull_request actions', () => { + const output: AgentOutput = { + actions: [ + { + type: 'create_pull_request', + title: 'PR 1', + body: 'Details', + head: 'fix/a', + files: { 'package.json': '{}' }, + }, + { + type: 'create_pull_request', + title: 'PR 2', + body: 'Details', + head: 'fix/b', + files: { '.github/workflows/ci.yml': 'ci' }, + }, + ], + }; + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(2); + expect(result.checkedFiles).toBe(2); + }); + }); + + describe('edge cases', () => { + it('handles null output gracefully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = checkProtectedFiles(null as any, blockConfig); + expect(result.passed).toBe(true); + expect(result.checkedFiles).toBe(0); + }); + + it('handles output with missing actions array', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = checkProtectedFiles({} as any, blockConfig); + expect(result.passed).toBe(true); + expect(result.checkedFiles).toBe(0); + }); + + it('handles empty files map', () => { + const output = makePrOutput({}); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(true); + expect(result.checkedFiles).toBe(0); + }); + + it('pattern order matters: last match wins with conflicting patterns', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: [], + overrideDefaults: false, + }; + // Default patterns protect package.json; add negation then re-protect + config.patterns = ['!package.json', 'package.json']; + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, config); + // The last pattern 'package.json' re-protects it + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + }); + + it('empty string patterns are harmless', () => { + const config: ProtectedFilesConfig = { + action: 'block', + patterns: ['', ' '], + overrideDefaults: true, + }; + const output = makePrOutput({ 'src/app.ts': 'code' }); + // Empty strings passed through won't match normal file paths + const result = checkProtectedFiles(output, config); + expect(result.passed).toBe(true); + }); + }); + + describe('glob pattern specifics', () => { + it('** matches deeply nested workflow files', () => { + const output = makePrOutput({ + '.github/workflows/nested/deep/deploy.yml': 'deploy', + }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + }); + + it('.github/actions/** matches action subdirectories', () => { + const output = makePrOutput({ + '.github/actions/my-action/action.yml': 'name: my-action', + }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].category).toBe('CI config'); + }); + + it('matches all default dependency manifests', () => { + const depFiles = [ + 'package.json', + 'package-lock.json', + 'go.mod', + 'go.sum', + 'requirements.txt', + 'Pipfile.lock', + 'Gemfile.lock', + 'pnpm-lock.yaml', + 'yarn.lock', + ]; + for (const file of depFiles) { + const output = makePrOutput({ [file]: 'content' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].path).toBe(file); + expect(result.violations[0].category).toBe('Dependency manifest'); + } + }); + }); +}); diff --git a/src/protected-files.ts b/src/protected-files.ts index 977b0ac..5f7a238 100644 --- a/src/protected-files.ts +++ b/src/protected-files.ts @@ -94,10 +94,13 @@ export function isFileProtected( let isProtected = false; let matchedPattern: string | undefined; - for (const pattern of patterns) { + for (const raw of patterns) { + const pattern = raw.trim(); + if (!pattern) continue; + if (pattern.startsWith('!')) { const negated = pattern.slice(1); - if (picomatch.isMatch(filepath, negated, { dot: true })) { + if (negated && picomatch.isMatch(filepath, negated, { dot: true })) { isProtected = false; matchedPattern = undefined; } From 4fc91feb1457f974d0cc182802443c228bf350e7 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:48:48 -0400 Subject: [PATCH 05/12] style: fix prettier formatting in tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/__tests__/protected-files.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/__tests__/protected-files.test.ts b/src/__tests__/protected-files.test.ts index 883c892..94b7a55 100644 --- a/src/__tests__/protected-files.test.ts +++ b/src/__tests__/protected-files.test.ts @@ -197,9 +197,7 @@ describe('resolveProtectedConfig', () => { expect(patterns).toContain('deploy/**'); expect(patterns).toContain('package.json'); // User pattern comes after defaults - expect(patterns.indexOf('deploy/**')).toBeGreaterThan( - patterns.indexOf('package.json') - ); + expect(patterns.indexOf('deploy/**')).toBeGreaterThan(patterns.indexOf('package.json')); }); it('skips defaults when overrideDefaults is true', () => { @@ -405,7 +403,7 @@ describe('checkProtectedFiles', () => { const output = makePrOutput({ 'package.json': '{}', '.github/workflows/deploy.yml': 'deploy', - 'CODEOWNERS': '* @admin', + CODEOWNERS: '* @admin', 'src/safe.ts': 'safe code', }); const result = checkProtectedFiles(output, blockConfig); From 279660e7590fc46fa3151c424fb57a17d3bde3c9 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:48:54 -0400 Subject: [PATCH 06/12] chore: rebuild dist bundle Rebuilt with ncc after adding protected files feature. Includes picomatch dependency bundled into dist/index.js. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/index.js | 2644 ++++++++++++++++++++++++++++++++++++++++++++- dist/licenses.txt | 25 + 2 files changed, 2663 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index 07ee87b..227783e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7406,6 +7406,2453 @@ function onceStrict (fn) { } +/***/ }), + +/***/ 4006: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const pico = __nccwpck_require__(8016); +const utils = __nccwpck_require__(4059); + +function picomatch(glob, options, returnState = false) { + // default to os.platform() + if (options && (options.windows === null || options.windows === undefined)) { + // don't mutate the original options object + options = { ...options, windows: utils.isWindows() }; + } + + return pico(glob, options, returnState); +} + +Object.assign(picomatch, pico); +module.exports = picomatch; + + +/***/ }), + +/***/ 5595: +/***/ ((module) => { + +"use strict"; + + +const WIN_SLASH = '\\\\/'; +const WIN_NO_SLASH = `[^${WIN_SLASH}]`; + +const DEFAULT_MAX_EXTGLOB_RECURSION = 0; + +/** + * Posix glob regex + */ + +const DOT_LITERAL = '\\.'; +const PLUS_LITERAL = '\\+'; +const QMARK_LITERAL = '\\?'; +const SLASH_LITERAL = '\\/'; +const ONE_CHAR = '(?=.)'; +const QMARK = '[^/]'; +const END_ANCHOR = `(?:${SLASH_LITERAL}|$)`; +const START_ANCHOR = `(?:^|${SLASH_LITERAL})`; +const DOTS_SLASH = `${DOT_LITERAL}{1,2}${END_ANCHOR}`; +const NO_DOT = `(?!${DOT_LITERAL})`; +const NO_DOTS = `(?!${START_ANCHOR}${DOTS_SLASH})`; +const NO_DOT_SLASH = `(?!${DOT_LITERAL}{0,1}${END_ANCHOR})`; +const NO_DOTS_SLASH = `(?!${DOTS_SLASH})`; +const QMARK_NO_DOT = `[^.${SLASH_LITERAL}]`; +const STAR = `${QMARK}*?`; +const SEP = '/'; + +const POSIX_CHARS = { + DOT_LITERAL, + PLUS_LITERAL, + QMARK_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + QMARK, + END_ANCHOR, + DOTS_SLASH, + NO_DOT, + NO_DOTS, + NO_DOT_SLASH, + NO_DOTS_SLASH, + QMARK_NO_DOT, + STAR, + START_ANCHOR, + SEP +}; + +/** + * Windows glob regex + */ + +const WINDOWS_CHARS = { + ...POSIX_CHARS, + + SLASH_LITERAL: `[${WIN_SLASH}]`, + QMARK: WIN_NO_SLASH, + STAR: `${WIN_NO_SLASH}*?`, + DOTS_SLASH: `${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$)`, + NO_DOT: `(?!${DOT_LITERAL})`, + NO_DOTS: `(?!(?:^|[${WIN_SLASH}])${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, + NO_DOT_SLASH: `(?!${DOT_LITERAL}{0,1}(?:[${WIN_SLASH}]|$))`, + NO_DOTS_SLASH: `(?!${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, + QMARK_NO_DOT: `[^.${WIN_SLASH}]`, + START_ANCHOR: `(?:^|[${WIN_SLASH}])`, + END_ANCHOR: `(?:[${WIN_SLASH}]|$)`, + SEP: '\\' +}; + +/** + * POSIX Bracket Regex + */ + +const POSIX_REGEX_SOURCE = { + __proto__: null, + alnum: 'a-zA-Z0-9', + alpha: 'a-zA-Z', + ascii: '\\x00-\\x7F', + blank: ' \\t', + cntrl: '\\x00-\\x1F\\x7F', + digit: '0-9', + graph: '\\x21-\\x7E', + lower: 'a-z', + print: '\\x20-\\x7E ', + punct: '\\-!"#$%&\'()\\*+,./:;<=>?@[\\]^_`{|}~', + space: ' \\t\\r\\n\\v\\f', + upper: 'A-Z', + word: 'A-Za-z0-9_', + xdigit: 'A-Fa-f0-9' +}; + +module.exports = { + DEFAULT_MAX_EXTGLOB_RECURSION, + MAX_LENGTH: 1024 * 64, + POSIX_REGEX_SOURCE, + + // regular expressions + REGEX_BACKSLASH: /\\(?![*+?^${}(|)[\]])/g, + REGEX_NON_SPECIAL_CHARS: /^[^@![\].,$*+?^{}()|\\/]+/, + REGEX_SPECIAL_CHARS: /[-*+?.^${}(|)[\]]/, + REGEX_SPECIAL_CHARS_BACKREF: /(\\?)((\W)(\3*))/g, + REGEX_SPECIAL_CHARS_GLOBAL: /([-*+?.^${}(|)[\]])/g, + REGEX_REMOVE_BACKSLASH: /(?:\[.*?[^\\]\]|\\(?=.))/g, + + // Replace globs with equivalent patterns to reduce parsing time. + REPLACEMENTS: { + __proto__: null, + '***': '*', + '**/**': '**', + '**/**/**': '**' + }, + + // Digits + CHAR_0: 48, /* 0 */ + CHAR_9: 57, /* 9 */ + + // Alphabet chars. + CHAR_UPPERCASE_A: 65, /* A */ + CHAR_LOWERCASE_A: 97, /* a */ + CHAR_UPPERCASE_Z: 90, /* Z */ + CHAR_LOWERCASE_Z: 122, /* z */ + + CHAR_LEFT_PARENTHESES: 40, /* ( */ + CHAR_RIGHT_PARENTHESES: 41, /* ) */ + + CHAR_ASTERISK: 42, /* * */ + + // Non-alphabetic chars. + CHAR_AMPERSAND: 38, /* & */ + CHAR_AT: 64, /* @ */ + CHAR_BACKWARD_SLASH: 92, /* \ */ + CHAR_CARRIAGE_RETURN: 13, /* \r */ + CHAR_CIRCUMFLEX_ACCENT: 94, /* ^ */ + CHAR_COLON: 58, /* : */ + CHAR_COMMA: 44, /* , */ + CHAR_DOT: 46, /* . */ + CHAR_DOUBLE_QUOTE: 34, /* " */ + CHAR_EQUAL: 61, /* = */ + CHAR_EXCLAMATION_MARK: 33, /* ! */ + CHAR_FORM_FEED: 12, /* \f */ + CHAR_FORWARD_SLASH: 47, /* / */ + CHAR_GRAVE_ACCENT: 96, /* ` */ + CHAR_HASH: 35, /* # */ + CHAR_HYPHEN_MINUS: 45, /* - */ + CHAR_LEFT_ANGLE_BRACKET: 60, /* < */ + CHAR_LEFT_CURLY_BRACE: 123, /* { */ + CHAR_LEFT_SQUARE_BRACKET: 91, /* [ */ + CHAR_LINE_FEED: 10, /* \n */ + CHAR_NO_BREAK_SPACE: 160, /* \u00A0 */ + CHAR_PERCENT: 37, /* % */ + CHAR_PLUS: 43, /* + */ + CHAR_QUESTION_MARK: 63, /* ? */ + CHAR_RIGHT_ANGLE_BRACKET: 62, /* > */ + CHAR_RIGHT_CURLY_BRACE: 125, /* } */ + CHAR_RIGHT_SQUARE_BRACKET: 93, /* ] */ + CHAR_SEMICOLON: 59, /* ; */ + CHAR_SINGLE_QUOTE: 39, /* ' */ + CHAR_SPACE: 32, /* */ + CHAR_TAB: 9, /* \t */ + CHAR_UNDERSCORE: 95, /* _ */ + CHAR_VERTICAL_LINE: 124, /* | */ + CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279, /* \uFEFF */ + + /** + * Create EXTGLOB_CHARS + */ + + extglobChars(chars) { + return { + '!': { type: 'negate', open: '(?:(?!(?:', close: `))${chars.STAR})` }, + '?': { type: 'qmark', open: '(?:', close: ')?' }, + '+': { type: 'plus', open: '(?:', close: ')+' }, + '*': { type: 'star', open: '(?:', close: ')*' }, + '@': { type: 'at', open: '(?:', close: ')' } + }; + }, + + /** + * Create GLOB_CHARS + */ + + globChars(win32) { + return win32 === true ? WINDOWS_CHARS : POSIX_CHARS; + } +}; + + +/***/ }), + +/***/ 8265: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const constants = __nccwpck_require__(5595); +const utils = __nccwpck_require__(4059); + +/** + * Constants + */ + +const { + MAX_LENGTH, + POSIX_REGEX_SOURCE, + REGEX_NON_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_BACKREF, + REPLACEMENTS +} = constants; + +/** + * Helpers + */ + +const expandRange = (args, options) => { + if (typeof options.expandRange === 'function') { + return options.expandRange(...args, options); + } + + args.sort(); + const value = `[${args.join('-')}]`; + + try { + /* eslint-disable-next-line no-new */ + new RegExp(value); + } catch (ex) { + return args.map(v => utils.escapeRegex(v)).join('..'); + } + + return value; +}; + +/** + * Create the message for a syntax error + */ + +const syntaxError = (type, char) => { + return `Missing ${type}: "${char}" - use "\\\\${char}" to match literal characters`; +}; + +const splitTopLevel = input => { + const parts = []; + let bracket = 0; + let paren = 0; + let quote = 0; + let value = ''; + let escaped = false; + + for (const ch of input) { + if (escaped === true) { + value += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + value += ch; + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + value += ch; + continue; + } + + if (quote === 0) { + if (ch === '[') { + bracket++; + } else if (ch === ']' && bracket > 0) { + bracket--; + } else if (bracket === 0) { + if (ch === '(') { + paren++; + } else if (ch === ')' && paren > 0) { + paren--; + } else if (ch === '|' && paren === 0) { + parts.push(value); + value = ''; + continue; + } + } + } + + value += ch; + } + + parts.push(value); + return parts; +}; + +const isPlainBranch = branch => { + let escaped = false; + + for (const ch of branch) { + if (escaped === true) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (/[?*+@!()[\]{}]/.test(ch)) { + return false; + } + } + + return true; +}; + +const normalizeSimpleBranch = branch => { + let value = branch.trim(); + let changed = true; + + while (changed === true) { + changed = false; + + if (/^@\([^\\()[\]{}|]+\)$/.test(value)) { + value = value.slice(2, -1); + changed = true; + } + } + + if (!isPlainBranch(value)) { + return; + } + + return value.replace(/\\(.)/g, '$1'); +}; + +const hasRepeatedCharPrefixOverlap = branches => { + const values = branches.map(normalizeSimpleBranch).filter(Boolean); + + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + const a = values[i]; + const b = values[j]; + const char = a[0]; + + if (!char || a !== char.repeat(a.length) || b !== char.repeat(b.length)) { + continue; + } + + if (a === b || a.startsWith(b) || b.startsWith(a)) { + return true; + } + } + } + + return false; +}; + +const parseRepeatedExtglob = (pattern, requireEnd = true) => { + if ((pattern[0] !== '+' && pattern[0] !== '*') || pattern[1] !== '(') { + return; + } + + let bracket = 0; + let paren = 0; + let quote = 0; + let escaped = false; + + for (let i = 1; i < pattern.length; i++) { + const ch = pattern[i]; + + if (escaped === true) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + continue; + } + + if (quote === 1) { + continue; + } + + if (ch === '[') { + bracket++; + continue; + } + + if (ch === ']' && bracket > 0) { + bracket--; + continue; + } + + if (bracket > 0) { + continue; + } + + if (ch === '(') { + paren++; + continue; + } + + if (ch === ')') { + paren--; + + if (paren === 0) { + if (requireEnd === true && i !== pattern.length - 1) { + return; + } + + return { + type: pattern[0], + body: pattern.slice(2, i), + end: i + }; + } + } + } +}; + +const getStarExtglobSequenceOutput = pattern => { + let index = 0; + const chars = []; + + while (index < pattern.length) { + const match = parseRepeatedExtglob(pattern.slice(index), false); + + if (!match || match.type !== '*') { + return; + } + + const branches = splitTopLevel(match.body).map(branch => branch.trim()); + if (branches.length !== 1) { + return; + } + + const branch = normalizeSimpleBranch(branches[0]); + if (!branch || branch.length !== 1) { + return; + } + + chars.push(branch); + index += match.end + 1; + } + + if (chars.length < 1) { + return; + } + + const source = chars.length === 1 + ? utils.escapeRegex(chars[0]) + : `[${chars.map(ch => utils.escapeRegex(ch)).join('')}]`; + + return `${source}*`; +}; + +const repeatedExtglobRecursion = pattern => { + let depth = 0; + let value = pattern.trim(); + let match = parseRepeatedExtglob(value); + + while (match) { + depth++; + value = match.body.trim(); + match = parseRepeatedExtglob(value); + } + + return depth; +}; + +const analyzeRepeatedExtglob = (body, options) => { + if (options.maxExtglobRecursion === false) { + return { risky: false }; + } + + const max = + typeof options.maxExtglobRecursion === 'number' + ? options.maxExtglobRecursion + : constants.DEFAULT_MAX_EXTGLOB_RECURSION; + + const branches = splitTopLevel(body).map(branch => branch.trim()); + + if (branches.length > 1) { + if ( + branches.some(branch => branch === '') || + branches.some(branch => /^[*?]+$/.test(branch)) || + hasRepeatedCharPrefixOverlap(branches) + ) { + return { risky: true }; + } + } + + for (const branch of branches) { + const safeOutput = getStarExtglobSequenceOutput(branch); + if (safeOutput) { + return { risky: true, safeOutput }; + } + + if (repeatedExtglobRecursion(branch) > max) { + return { risky: true }; + } + } + + return { risky: false }; +}; + +/** + * Parse the given input string. + * @param {String} input + * @param {Object} options + * @return {Object} + */ + +const parse = (input, options) => { + if (typeof input !== 'string') { + throw new TypeError('Expected a string'); + } + + input = REPLACEMENTS[input] || input; + + const opts = { ...options }; + const max = typeof opts.maxLength === 'number' ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + + let len = input.length; + if (len > max) { + throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + } + + const bos = { type: 'bos', value: '', output: opts.prepend || '' }; + const tokens = [bos]; + + const capture = opts.capture ? '' : '?:'; + + // create constants based on platform, for windows or posix + const PLATFORM_CHARS = constants.globChars(opts.windows); + const EXTGLOB_CHARS = constants.extglobChars(PLATFORM_CHARS); + + const { + DOT_LITERAL, + PLUS_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOT_SLASH, + NO_DOTS_SLASH, + QMARK, + QMARK_NO_DOT, + STAR, + START_ANCHOR + } = PLATFORM_CHARS; + + const globstar = opts => { + return `(${capture}(?:(?!${START_ANCHOR}${opts.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }; + + const nodot = opts.dot ? '' : NO_DOT; + const qmarkNoDot = opts.dot ? QMARK : QMARK_NO_DOT; + let star = opts.bash === true ? globstar(opts) : STAR; + + if (opts.capture) { + star = `(${star})`; + } + + // minimatch options support + if (typeof opts.noext === 'boolean') { + opts.noextglob = opts.noext; + } + + const state = { + input, + index: -1, + start: 0, + dot: opts.dot === true, + consumed: '', + output: '', + prefix: '', + backtrack: false, + negated: false, + brackets: 0, + braces: 0, + parens: 0, + quotes: 0, + globstar: false, + tokens + }; + + input = utils.removePrefix(input, state); + len = input.length; + + const extglobs = []; + const braces = []; + const stack = []; + let prev = bos; + let value; + + /** + * Tokenizing helpers + */ + + const eos = () => state.index === len - 1; + const peek = state.peek = (n = 1) => input[state.index + n]; + const advance = state.advance = () => input[++state.index] || ''; + const remaining = () => input.slice(state.index + 1); + const consume = (value = '', num = 0) => { + state.consumed += value; + state.index += num; + }; + + const append = token => { + state.output += token.output != null ? token.output : token.value; + consume(token.value); + }; + + const negate = () => { + let count = 1; + + while (peek() === '!' && (peek(2) !== '(' || peek(3) === '?')) { + advance(); + state.start++; + count++; + } + + if (count % 2 === 0) { + return false; + } + + state.negated = true; + state.start++; + return true; + }; + + const increment = type => { + state[type]++; + stack.push(type); + }; + + const decrement = type => { + state[type]--; + stack.pop(); + }; + + /** + * Push tokens onto the tokens array. This helper speeds up + * tokenizing by 1) helping us avoid backtracking as much as possible, + * and 2) helping us avoid creating extra tokens when consecutive + * characters are plain text. This improves performance and simplifies + * lookbehinds. + */ + + const push = tok => { + if (prev.type === 'globstar') { + const isBrace = state.braces > 0 && (tok.type === 'comma' || tok.type === 'brace'); + const isExtglob = tok.extglob === true || (extglobs.length && (tok.type === 'pipe' || tok.type === 'paren')); + + if (tok.type !== 'slash' && tok.type !== 'paren' && !isBrace && !isExtglob) { + state.output = state.output.slice(0, -prev.output.length); + prev.type = 'star'; + prev.value = '*'; + prev.output = star; + state.output += prev.output; + } + } + + if (extglobs.length && tok.type !== 'paren') { + extglobs[extglobs.length - 1].inner += tok.value; + } + + if (tok.value || tok.output) append(tok); + if (prev && prev.type === 'text' && tok.type === 'text') { + prev.output = (prev.output || prev.value) + tok.value; + prev.value += tok.value; + return; + } + + tok.prev = prev; + tokens.push(tok); + prev = tok; + }; + + const extglobOpen = (type, value) => { + const token = { ...EXTGLOB_CHARS[value], conditions: 1, inner: '' }; + + token.prev = prev; + token.parens = state.parens; + token.output = state.output; + token.startIndex = state.index; + token.tokensIndex = tokens.length; + const output = (opts.capture ? '(' : '') + token.open; + + increment('parens'); + push({ type, value, output: state.output ? '' : ONE_CHAR }); + push({ type: 'paren', extglob: true, value: advance(), output }); + extglobs.push(token); + }; + + const extglobClose = token => { + const literal = input.slice(token.startIndex, state.index + 1); + const body = input.slice(token.startIndex + 2, state.index); + const analysis = analyzeRepeatedExtglob(body, opts); + + if ((token.type === 'plus' || token.type === 'star') && analysis.risky) { + const safeOutput = analysis.safeOutput + ? (token.output ? '' : ONE_CHAR) + (opts.capture ? `(${analysis.safeOutput})` : analysis.safeOutput) + : undefined; + const open = tokens[token.tokensIndex]; + + open.type = 'text'; + open.value = literal; + open.output = safeOutput || utils.escapeRegex(literal); + + for (let i = token.tokensIndex + 1; i < tokens.length; i++) { + tokens[i].value = ''; + tokens[i].output = ''; + delete tokens[i].suffix; + } + + state.output = token.output + open.output; + state.backtrack = true; + + push({ type: 'paren', extglob: true, value, output: '' }); + decrement('parens'); + return; + } + + let output = token.close + (opts.capture ? ')' : ''); + let rest; + + if (token.type === 'negate') { + let extglobStar = star; + + if (token.inner && token.inner.length > 1 && token.inner.includes('/')) { + extglobStar = globstar(opts); + } + + if (extglobStar !== star || eos() || /^\)+$/.test(remaining())) { + output = token.close = `)$))${extglobStar}`; + } + + if (token.inner.includes('*') && (rest = remaining()) && /^\.[^\\/.]+$/.test(rest)) { + // Any non-magical string (`.ts`) or even nested expression (`.{ts,tsx}`) can follow after the closing parenthesis. + // In this case, we need to parse the string and use it in the output of the original pattern. + // Suitable patterns: `/!(*.d).ts`, `/!(*.d).{ts,tsx}`, `**/!(*-dbg).@(js)`. + // + // Disabling the `fastpaths` option due to a problem with parsing strings as `.ts` in the pattern like `**/!(*.d).ts`. + const expression = parse(rest, { ...options, fastpaths: false }).output; + + output = token.close = `)${expression})${extglobStar})`; + } + + if (token.prev.type === 'bos') { + state.negatedExtglob = true; + } + } + + push({ type: 'paren', extglob: true, value, output }); + decrement('parens'); + }; + + /** + * Fast paths + */ + + if (opts.fastpaths !== false && !/(^[*!]|[/()[\]{}"])/.test(input)) { + let backslashes = false; + + let output = input.replace(REGEX_SPECIAL_CHARS_BACKREF, (m, esc, chars, first, rest, index) => { + if (first === '\\') { + backslashes = true; + return m; + } + + if (first === '?') { + if (esc) { + return esc + first + (rest ? QMARK.repeat(rest.length) : ''); + } + if (index === 0) { + return qmarkNoDot + (rest ? QMARK.repeat(rest.length) : ''); + } + return QMARK.repeat(chars.length); + } + + if (first === '.') { + return DOT_LITERAL.repeat(chars.length); + } + + if (first === '*') { + if (esc) { + return esc + first + (rest ? star : ''); + } + return star; + } + return esc ? m : `\\${m}`; + }); + + if (backslashes === true) { + if (opts.unescape === true) { + output = output.replace(/\\/g, ''); + } else { + output = output.replace(/\\+/g, m => { + return m.length % 2 === 0 ? '\\\\' : (m ? '\\' : ''); + }); + } + } + + if (output === input && opts.contains === true) { + state.output = input; + return state; + } + + state.output = utils.wrapOutput(output, state, options); + return state; + } + + /** + * Tokenize input until we reach end-of-string + */ + + while (!eos()) { + value = advance(); + + if (value === '\u0000') { + continue; + } + + /** + * Escaped characters + */ + + if (value === '\\') { + const next = peek(); + + if (next === '/' && opts.bash !== true) { + continue; + } + + if (next === '.' || next === ';') { + continue; + } + + if (!next) { + value += '\\'; + push({ type: 'text', value }); + continue; + } + + // collapse slashes to reduce potential for exploits + const match = /^\\+/.exec(remaining()); + let slashes = 0; + + if (match && match[0].length > 2) { + slashes = match[0].length; + state.index += slashes; + if (slashes % 2 !== 0) { + value += '\\'; + } + } + + if (opts.unescape === true) { + value = advance(); + } else { + value += advance(); + } + + if (state.brackets === 0) { + push({ type: 'text', value }); + continue; + } + } + + /** + * If we're inside a regex character class, continue + * until we reach the closing bracket. + */ + + if (state.brackets > 0 && (value !== ']' || prev.value === '[' || prev.value === '[^')) { + if (opts.posix !== false && value === ':') { + const inner = prev.value.slice(1); + if (inner.includes('[')) { + prev.posix = true; + + if (inner.includes(':')) { + const idx = prev.value.lastIndexOf('['); + const pre = prev.value.slice(0, idx); + const rest = prev.value.slice(idx + 2); + const posix = POSIX_REGEX_SOURCE[rest]; + if (posix) { + prev.value = pre + posix; + state.backtrack = true; + advance(); + + if (!bos.output && tokens.indexOf(prev) === 1) { + bos.output = ONE_CHAR; + } + continue; + } + } + } + } + + if ((value === '[' && peek() !== ':') || (value === '-' && peek() === ']')) { + value = `\\${value}`; + } + + if (value === ']' && (prev.value === '[' || prev.value === '[^')) { + value = `\\${value}`; + } + + if (opts.posix === true && value === '!' && prev.value === '[') { + value = '^'; + } + + prev.value += value; + append({ value }); + continue; + } + + /** + * If we're inside a quoted string, continue + * until we reach the closing double quote. + */ + + if (state.quotes === 1 && value !== '"') { + value = utils.escapeRegex(value); + prev.value += value; + append({ value }); + continue; + } + + /** + * Double quotes + */ + + if (value === '"') { + state.quotes = state.quotes === 1 ? 0 : 1; + if (opts.keepQuotes === true) { + push({ type: 'text', value }); + } + continue; + } + + /** + * Parentheses + */ + + if (value === '(') { + increment('parens'); + push({ type: 'paren', value }); + continue; + } + + if (value === ')') { + if (state.parens === 0 && opts.strictBrackets === true) { + throw new SyntaxError(syntaxError('opening', '(')); + } + + const extglob = extglobs[extglobs.length - 1]; + if (extglob && state.parens === extglob.parens + 1) { + extglobClose(extglobs.pop()); + continue; + } + + push({ type: 'paren', value, output: state.parens ? ')' : '\\)' }); + decrement('parens'); + continue; + } + + /** + * Square brackets + */ + + if (value === '[') { + if (opts.nobracket === true || !remaining().includes(']')) { + if (opts.nobracket !== true && opts.strictBrackets === true) { + throw new SyntaxError(syntaxError('closing', ']')); + } + + value = `\\${value}`; + } else { + increment('brackets'); + } + + push({ type: 'bracket', value }); + continue; + } + + if (value === ']') { + if (opts.nobracket === true || (prev && prev.type === 'bracket' && prev.value.length === 1)) { + push({ type: 'text', value, output: `\\${value}` }); + continue; + } + + if (state.brackets === 0) { + if (opts.strictBrackets === true) { + throw new SyntaxError(syntaxError('opening', '[')); + } + + push({ type: 'text', value, output: `\\${value}` }); + continue; + } + + decrement('brackets'); + + const prevValue = prev.value.slice(1); + if (prev.posix !== true && prevValue[0] === '^' && !prevValue.includes('/')) { + value = `/${value}`; + } + + prev.value += value; + append({ value }); + + // when literal brackets are explicitly disabled + // assume we should match with a regex character class + if (opts.literalBrackets === false || utils.hasRegexChars(prevValue)) { + continue; + } + + const escaped = utils.escapeRegex(prev.value); + state.output = state.output.slice(0, -prev.value.length); + + // when literal brackets are explicitly enabled + // assume we should escape the brackets to match literal characters + if (opts.literalBrackets === true) { + state.output += escaped; + prev.value = escaped; + continue; + } + + // when the user specifies nothing, try to match both + prev.value = `(${capture}${escaped}|${prev.value})`; + state.output += prev.value; + continue; + } + + /** + * Braces + */ + + if (value === '{' && opts.nobrace !== true) { + increment('braces'); + + const open = { + type: 'brace', + value, + output: '(', + outputIndex: state.output.length, + tokensIndex: state.tokens.length + }; + + braces.push(open); + push(open); + continue; + } + + if (value === '}') { + const brace = braces[braces.length - 1]; + + if (opts.nobrace === true || !brace) { + push({ type: 'text', value, output: value }); + continue; + } + + let output = ')'; + + if (brace.dots === true) { + const arr = tokens.slice(); + const range = []; + + for (let i = arr.length - 1; i >= 0; i--) { + tokens.pop(); + if (arr[i].type === 'brace') { + break; + } + if (arr[i].type !== 'dots') { + range.unshift(arr[i].value); + } + } + + output = expandRange(range, opts); + state.backtrack = true; + } + + if (brace.comma !== true && brace.dots !== true) { + const out = state.output.slice(0, brace.outputIndex); + const toks = state.tokens.slice(brace.tokensIndex); + brace.value = brace.output = '\\{'; + value = output = '\\}'; + state.output = out; + for (const t of toks) { + state.output += (t.output || t.value); + } + } + + push({ type: 'brace', value, output }); + decrement('braces'); + braces.pop(); + continue; + } + + /** + * Pipes + */ + + if (value === '|') { + if (extglobs.length > 0) { + extglobs[extglobs.length - 1].conditions++; + } + push({ type: 'text', value }); + continue; + } + + /** + * Commas + */ + + if (value === ',') { + let output = value; + + const brace = braces[braces.length - 1]; + if (brace && stack[stack.length - 1] === 'braces') { + brace.comma = true; + output = '|'; + } + + push({ type: 'comma', value, output }); + continue; + } + + /** + * Slashes + */ + + if (value === '/') { + // if the beginning of the glob is "./", advance the start + // to the current index, and don't add the "./" characters + // to the state. This greatly simplifies lookbehinds when + // checking for BOS characters like "!" and "." (not "./") + if (prev.type === 'dot' && state.index === state.start + 1) { + state.start = state.index + 1; + state.consumed = ''; + state.output = ''; + tokens.pop(); + prev = bos; // reset "prev" to the first token + continue; + } + + push({ type: 'slash', value, output: SLASH_LITERAL }); + continue; + } + + /** + * Dots + */ + + if (value === '.') { + if (state.braces > 0 && prev.type === 'dot') { + if (prev.value === '.') prev.output = DOT_LITERAL; + const brace = braces[braces.length - 1]; + prev.type = 'dots'; + prev.output += value; + prev.value += value; + brace.dots = true; + continue; + } + + if ((state.braces + state.parens) === 0 && prev.type !== 'bos' && prev.type !== 'slash') { + push({ type: 'text', value, output: DOT_LITERAL }); + continue; + } + + push({ type: 'dot', value, output: DOT_LITERAL }); + continue; + } + + /** + * Question marks + */ + + if (value === '?') { + const isGroup = prev && prev.value === '('; + if (!isGroup && opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { + extglobOpen('qmark', value); + continue; + } + + if (prev && prev.type === 'paren') { + const next = peek(); + let output = value; + + if ((prev.value === '(' && !/[!=<:]/.test(next)) || (next === '<' && !/<([!=]|\w+>)/.test(remaining()))) { + output = `\\${value}`; + } + + push({ type: 'text', value, output }); + continue; + } + + if (opts.dot !== true && (prev.type === 'slash' || prev.type === 'bos')) { + push({ type: 'qmark', value, output: QMARK_NO_DOT }); + continue; + } + + push({ type: 'qmark', value, output: QMARK }); + continue; + } + + /** + * Exclamation + */ + + if (value === '!') { + if (opts.noextglob !== true && peek() === '(') { + if (peek(2) !== '?' || !/[!=<:]/.test(peek(3))) { + extglobOpen('negate', value); + continue; + } + } + + if (opts.nonegate !== true && state.index === 0) { + negate(); + continue; + } + } + + /** + * Plus + */ + + if (value === '+') { + if (opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { + extglobOpen('plus', value); + continue; + } + + if ((prev && prev.value === '(') || opts.regex === false) { + push({ type: 'plus', value, output: PLUS_LITERAL }); + continue; + } + + if ((prev && (prev.type === 'bracket' || prev.type === 'paren' || prev.type === 'brace')) || state.parens > 0) { + push({ type: 'plus', value }); + continue; + } + + push({ type: 'plus', value: PLUS_LITERAL }); + continue; + } + + /** + * Plain text + */ + + if (value === '@') { + if (opts.noextglob !== true && peek() === '(' && peek(2) !== '?') { + push({ type: 'at', extglob: true, value, output: '' }); + continue; + } + + push({ type: 'text', value }); + continue; + } + + /** + * Plain text + */ + + if (value !== '*') { + if (value === '$' || value === '^') { + value = `\\${value}`; + } + + const match = REGEX_NON_SPECIAL_CHARS.exec(remaining()); + if (match) { + value += match[0]; + state.index += match[0].length; + } + + push({ type: 'text', value }); + continue; + } + + /** + * Stars + */ + + if (prev && (prev.type === 'globstar' || prev.star === true)) { + prev.type = 'star'; + prev.star = true; + prev.value += value; + prev.output = star; + state.backtrack = true; + state.globstar = true; + consume(value); + continue; + } + + let rest = remaining(); + if (opts.noextglob !== true && /^\([^?]/.test(rest)) { + extglobOpen('star', value); + continue; + } + + if (prev.type === 'star') { + if (opts.noglobstar === true) { + consume(value); + continue; + } + + const prior = prev.prev; + const before = prior.prev; + const isStart = prior.type === 'slash' || prior.type === 'bos'; + const afterStar = before && (before.type === 'star' || before.type === 'globstar'); + + if (opts.bash === true && (!isStart || (rest[0] && rest[0] !== '/'))) { + push({ type: 'star', value, output: '' }); + continue; + } + + const isBrace = state.braces > 0 && (prior.type === 'comma' || prior.type === 'brace'); + const isExtglob = extglobs.length && (prior.type === 'pipe' || prior.type === 'paren'); + if (!isStart && prior.type !== 'paren' && !isBrace && !isExtglob) { + push({ type: 'star', value, output: '' }); + continue; + } + + // strip consecutive `/**/` + while (rest.slice(0, 3) === '/**') { + const after = input[state.index + 4]; + if (after && after !== '/') { + break; + } + rest = rest.slice(3); + consume('/**', 3); + } + + if (prior.type === 'bos' && eos()) { + prev.type = 'globstar'; + prev.value += value; + prev.output = globstar(opts); + state.output = prev.output; + state.globstar = true; + consume(value); + continue; + } + + if (prior.type === 'slash' && prior.prev.type !== 'bos' && !afterStar && eos()) { + state.output = state.output.slice(0, -(prior.output + prev.output).length); + prior.output = `(?:${prior.output}`; + + prev.type = 'globstar'; + prev.output = globstar(opts) + (opts.strictSlashes ? ')' : '|$)'); + prev.value += value; + state.globstar = true; + state.output += prior.output + prev.output; + consume(value); + continue; + } + + if (prior.type === 'slash' && prior.prev.type !== 'bos' && rest[0] === '/') { + const end = rest[1] !== void 0 ? '|$' : ''; + + state.output = state.output.slice(0, -(prior.output + prev.output).length); + prior.output = `(?:${prior.output}`; + + prev.type = 'globstar'; + prev.output = `${globstar(opts)}${SLASH_LITERAL}|${SLASH_LITERAL}${end})`; + prev.value += value; + + state.output += prior.output + prev.output; + state.globstar = true; + + consume(value + advance()); + + push({ type: 'slash', value: '/', output: '' }); + continue; + } + + if (prior.type === 'bos' && rest[0] === '/') { + prev.type = 'globstar'; + prev.value += value; + prev.output = `(?:^|${SLASH_LITERAL}|${globstar(opts)}${SLASH_LITERAL})`; + state.output = prev.output; + state.globstar = true; + consume(value + advance()); + push({ type: 'slash', value: '/', output: '' }); + continue; + } + + // remove single star from output + state.output = state.output.slice(0, -prev.output.length); + + // reset previous token to globstar + prev.type = 'globstar'; + prev.output = globstar(opts); + prev.value += value; + + // reset output with globstar + state.output += prev.output; + state.globstar = true; + consume(value); + continue; + } + + const token = { type: 'star', value, output: star }; + + if (opts.bash === true) { + token.output = '.*?'; + if (prev.type === 'bos' || prev.type === 'slash') { + token.output = nodot + token.output; + } + push(token); + continue; + } + + if (prev && (prev.type === 'bracket' || prev.type === 'paren') && opts.regex === true) { + token.output = value; + push(token); + continue; + } + + if (state.index === state.start || prev.type === 'slash' || prev.type === 'dot') { + if (prev.type === 'dot') { + state.output += NO_DOT_SLASH; + prev.output += NO_DOT_SLASH; + + } else if (opts.dot === true) { + state.output += NO_DOTS_SLASH; + prev.output += NO_DOTS_SLASH; + + } else { + state.output += nodot; + prev.output += nodot; + } + + if (peek() !== '*') { + state.output += ONE_CHAR; + prev.output += ONE_CHAR; + } + } + + push(token); + } + + while (state.brackets > 0) { + if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', ']')); + state.output = utils.escapeLast(state.output, '['); + decrement('brackets'); + } + + while (state.parens > 0) { + if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', ')')); + state.output = utils.escapeLast(state.output, '('); + decrement('parens'); + } + + while (state.braces > 0) { + if (opts.strictBrackets === true) throw new SyntaxError(syntaxError('closing', '}')); + state.output = utils.escapeLast(state.output, '{'); + decrement('braces'); + } + + if (opts.strictSlashes !== true && (prev.type === 'star' || prev.type === 'bracket')) { + push({ type: 'maybe_slash', value: '', output: `${SLASH_LITERAL}?` }); + } + + // rebuild the output if we had to backtrack at any point + if (state.backtrack === true) { + state.output = ''; + + for (const token of state.tokens) { + state.output += token.output != null ? token.output : token.value; + + if (token.suffix) { + state.output += token.suffix; + } + } + } + + return state; +}; + +/** + * Fast paths for creating regular expressions for common glob patterns. + * This can significantly speed up processing and has very little downside + * impact when none of the fast paths match. + */ + +parse.fastpaths = (input, options) => { + const opts = { ...options }; + const max = typeof opts.maxLength === 'number' ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + const len = input.length; + if (len > max) { + throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + } + + input = REPLACEMENTS[input] || input; + + // create constants based on platform, for windows or posix + const { + DOT_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOTS, + NO_DOTS_SLASH, + STAR, + START_ANCHOR + } = constants.globChars(opts.windows); + + const nodot = opts.dot ? NO_DOTS : NO_DOT; + const slashDot = opts.dot ? NO_DOTS_SLASH : NO_DOT; + const capture = opts.capture ? '' : '?:'; + const state = { negated: false, prefix: '' }; + let star = opts.bash === true ? '.*?' : STAR; + + if (opts.capture) { + star = `(${star})`; + } + + const globstar = opts => { + if (opts.noglobstar === true) return star; + return `(${capture}(?:(?!${START_ANCHOR}${opts.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }; + + const create = str => { + switch (str) { + case '*': + return `${nodot}${ONE_CHAR}${star}`; + + case '.*': + return `${DOT_LITERAL}${ONE_CHAR}${star}`; + + case '*.*': + return `${nodot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + + case '*/*': + return `${nodot}${star}${SLASH_LITERAL}${ONE_CHAR}${slashDot}${star}`; + + case '**': + return nodot + globstar(opts); + + case '**/*': + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${ONE_CHAR}${star}`; + + case '**/*.*': + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + + case '**/.*': + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${DOT_LITERAL}${ONE_CHAR}${star}`; + + default: { + const match = /^(.*?)\.(\w+)$/.exec(str); + if (!match) return; + + const source = create(match[1]); + if (!source) return; + + return source + DOT_LITERAL + match[2]; + } + } + }; + + const output = utils.removePrefix(input, state); + let source = create(output); + + if (source && opts.strictSlashes !== true) { + source += `${SLASH_LITERAL}?`; + } + + return source; +}; + +module.exports = parse; + + +/***/ }), + +/***/ 8016: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const scan = __nccwpck_require__(1781); +const parse = __nccwpck_require__(8265); +const utils = __nccwpck_require__(4059); +const constants = __nccwpck_require__(5595); +const isObject = val => val && typeof val === 'object' && !Array.isArray(val); + +/** + * Creates a matcher function from one or more glob patterns. The + * returned function takes a string to match as its first argument, + * and returns true if the string is a match. The returned matcher + * function also takes a boolean as the second argument that, when true, + * returns an object with additional information. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch(glob[, options]); + * + * const isMatch = picomatch('*.!(*a)'); + * console.log(isMatch('a.a')); //=> false + * console.log(isMatch('a.b')); //=> true + * ``` + * @name picomatch + * @param {String|Array} `globs` One or more glob patterns. + * @param {Object=} `options` + * @return {Function=} Returns a matcher function. + * @api public + */ + +const picomatch = (glob, options, returnState = false) => { + if (Array.isArray(glob)) { + const fns = glob.map(input => picomatch(input, options, returnState)); + const arrayMatcher = str => { + for (const isMatch of fns) { + const state = isMatch(str); + if (state) return state; + } + return false; + }; + return arrayMatcher; + } + + const isState = isObject(glob) && glob.tokens && glob.input; + + if (glob === '' || (typeof glob !== 'string' && !isState)) { + throw new TypeError('Expected pattern to be a non-empty string'); + } + + const opts = options || {}; + const posix = opts.windows; + const regex = isState + ? picomatch.compileRe(glob, options) + : picomatch.makeRe(glob, options, false, true); + + const state = regex.state; + delete regex.state; + + let isIgnored = () => false; + if (opts.ignore) { + const ignoreOpts = { ...options, ignore: null, onMatch: null, onResult: null }; + isIgnored = picomatch(opts.ignore, ignoreOpts, returnState); + } + + const matcher = (input, returnObject = false) => { + const { isMatch, match, output } = picomatch.test(input, regex, options, { glob, posix }); + const result = { glob, state, regex, posix, input, output, match, isMatch }; + + if (typeof opts.onResult === 'function') { + opts.onResult(result); + } + + if (isMatch === false) { + result.isMatch = false; + return returnObject ? result : false; + } + + if (isIgnored(input)) { + if (typeof opts.onIgnore === 'function') { + opts.onIgnore(result); + } + result.isMatch = false; + return returnObject ? result : false; + } + + if (typeof opts.onMatch === 'function') { + opts.onMatch(result); + } + return returnObject ? result : true; + }; + + if (returnState) { + matcher.state = state; + } + + return matcher; +}; + +/** + * Test `input` with the given `regex`. This is used by the main + * `picomatch()` function to test the input string. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.test(input, regex[, options]); + * + * console.log(picomatch.test('foo/bar', /^(?:([^/]*?)\/([^/]*?))$/)); + * // { isMatch: true, match: [ 'foo/', 'foo', 'bar' ], output: 'foo/bar' } + * ``` + * @param {String} `input` String to test. + * @param {RegExp} `regex` + * @return {Object} Returns an object with matching info. + * @api public + */ + +picomatch.test = (input, regex, options, { glob, posix } = {}) => { + if (typeof input !== 'string') { + throw new TypeError('Expected input to be a string'); + } + + if (input === '') { + return { isMatch: false, output: '' }; + } + + const opts = options || {}; + const format = opts.format || (posix ? utils.toPosixSlashes : null); + let match = input === glob; + let output = (match && format) ? format(input) : input; + + if (match === false) { + output = format ? format(input) : input; + match = output === glob; + } + + if (match === false || opts.capture === true) { + if (opts.matchBase === true || opts.basename === true) { + match = picomatch.matchBase(input, regex, options, posix); + } else { + match = regex.exec(output); + } + } + + return { isMatch: Boolean(match), match, output }; +}; + +/** + * Match the basename of a filepath. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.matchBase(input, glob[, options]); + * console.log(picomatch.matchBase('foo/bar.js', '*.js'); // true + * ``` + * @param {String} `input` String to test. + * @param {RegExp|String} `glob` Glob pattern or regex created by [.makeRe](#makeRe). + * @return {Boolean} + * @api public + */ + +picomatch.matchBase = (input, glob, options) => { + const regex = glob instanceof RegExp ? glob : picomatch.makeRe(glob, options); + return regex.test(utils.basename(input)); +}; + +/** + * Returns true if **any** of the given glob `patterns` match the specified `string`. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.isMatch(string, patterns[, options]); + * + * console.log(picomatch.isMatch('a.a', ['b.*', '*.a'])); //=> true + * console.log(picomatch.isMatch('a.a', 'b.*')); //=> false + * ``` + * @param {String|Array} str The string to test. + * @param {String|Array} patterns One or more glob patterns to use for matching. + * @param {Object} [options] See available [options](#options). + * @return {Boolean} Returns true if any patterns match `str` + * @api public + */ + +picomatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); + +/** + * Parse a glob pattern to create the source string for a regular + * expression. + * + * ```js + * const picomatch = require('picomatch'); + * const result = picomatch.parse(pattern[, options]); + * ``` + * @param {String} `pattern` + * @param {Object} `options` + * @return {Object} Returns an object with useful properties and output to be used as a regex source string. + * @api public + */ + +picomatch.parse = (pattern, options) => { + if (Array.isArray(pattern)) return pattern.map(p => picomatch.parse(p, options)); + return parse(pattern, { ...options, fastpaths: false }); +}; + +/** + * Scan a glob pattern to separate the pattern into segments. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.scan(input[, options]); + * + * const result = picomatch.scan('!./foo/*.js'); + * console.log(result); + * { prefix: '!./', + * input: '!./foo/*.js', + * start: 3, + * base: 'foo', + * glob: '*.js', + * isBrace: false, + * isBracket: false, + * isGlob: true, + * isExtglob: false, + * isGlobstar: false, + * negated: true } + * ``` + * @param {String} `input` Glob pattern to scan. + * @param {Object} `options` + * @return {Object} Returns an object with + * @api public + */ + +picomatch.scan = (input, options) => scan(input, options); + +/** + * Compile a regular expression from the `state` object returned by the + * [parse()](#parse) method. + * + * ```js + * const picomatch = require('picomatch'); + * const state = picomatch.parse('*.js'); + * // picomatch.compileRe(state[, options]); + * + * console.log(picomatch.compileRe(state)); + * //=> /^(?:(?!\.)(?=.)[^/]*?\.js)$/ + * ``` + * @param {Object} `state` + * @param {Object} `options` + * @param {Boolean} `returnOutput` Intended for implementors, this argument allows you to return the raw output from the parser. + * @param {Boolean} `returnState` Adds the state to a `state` property on the returned regex. Useful for implementors and debugging. + * @return {RegExp} + * @api public + */ + +picomatch.compileRe = (state, options, returnOutput = false, returnState = false) => { + if (returnOutput === true) { + return state.output; + } + + const opts = options || {}; + const prepend = opts.contains ? '' : '^'; + const append = opts.contains ? '' : '$'; + + let source = `${prepend}(?:${state.output})${append}`; + if (state && state.negated === true) { + source = `^(?!${source}).*$`; + } + + const regex = picomatch.toRegex(source, options); + if (returnState === true) { + regex.state = state; + } + + return regex; +}; + +/** + * Create a regular expression from a parsed glob pattern. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.makeRe(state[, options]); + * + * const result = picomatch.makeRe('*.js'); + * console.log(result); + * //=> /^(?:(?!\.)(?=.)[^/]*?\.js)$/ + * ``` + * @param {String} `state` The object returned from the `.parse` method. + * @param {Object} `options` + * @param {Boolean} `returnOutput` Implementors may use this argument to return the compiled output, instead of a regular expression. This is not exposed on the options to prevent end-users from mutating the result. + * @param {Boolean} `returnState` Implementors may use this argument to return the state from the parsed glob with the returned regular expression. + * @return {RegExp} Returns a regex created from the given pattern. + * @api public + */ + +picomatch.makeRe = (input, options = {}, returnOutput = false, returnState = false) => { + if (!input || typeof input !== 'string') { + throw new TypeError('Expected a non-empty string'); + } + + let parsed = { negated: false, fastpaths: true }; + + if (options.fastpaths !== false && (input[0] === '.' || input[0] === '*')) { + parsed.output = parse.fastpaths(input, options); + } + + if (!parsed.output) { + parsed = parse(input, options); + } + + return picomatch.compileRe(parsed, options, returnOutput, returnState); +}; + +/** + * Create a regular expression from the given regex source string. + * + * ```js + * const picomatch = require('picomatch'); + * // picomatch.toRegex(source[, options]); + * + * const { output } = picomatch.parse('*.js'); + * console.log(picomatch.toRegex(output)); + * //=> /^(?:(?!\.)(?=.)[^/]*?\.js)$/ + * ``` + * @param {String} `source` Regular expression source string. + * @param {Object} `options` + * @return {RegExp} + * @api public + */ + +picomatch.toRegex = (source, options) => { + try { + const opts = options || {}; + return new RegExp(source, opts.flags || (opts.nocase ? 'i' : '')); + } catch (err) { + if (options && options.debug === true) throw err; + return /$^/; + } +}; + +/** + * Picomatch constants. + * @return {Object} + */ + +picomatch.constants = constants; + +/** + * Expose "picomatch" + */ + +module.exports = picomatch; + + +/***/ }), + +/***/ 1781: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + + +const utils = __nccwpck_require__(4059); +const { + CHAR_ASTERISK, /* * */ + CHAR_AT, /* @ */ + CHAR_BACKWARD_SLASH, /* \ */ + CHAR_COMMA, /* , */ + CHAR_DOT, /* . */ + CHAR_EXCLAMATION_MARK, /* ! */ + CHAR_FORWARD_SLASH, /* / */ + CHAR_LEFT_CURLY_BRACE, /* { */ + CHAR_LEFT_PARENTHESES, /* ( */ + CHAR_LEFT_SQUARE_BRACKET, /* [ */ + CHAR_PLUS, /* + */ + CHAR_QUESTION_MARK, /* ? */ + CHAR_RIGHT_CURLY_BRACE, /* } */ + CHAR_RIGHT_PARENTHESES, /* ) */ + CHAR_RIGHT_SQUARE_BRACKET /* ] */ +} = __nccwpck_require__(5595); + +const isPathSeparator = code => { + return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; +}; + +const depth = token => { + if (token.isPrefix !== true) { + token.depth = token.isGlobstar ? Infinity : 1; + } +}; + +/** + * Quickly scans a glob pattern and returns an object with a handful of + * useful properties, like `isGlob`, `path` (the leading non-glob, if it exists), + * `glob` (the actual pattern), `negated` (true if the path starts with `!` but not + * with `!(`) and `negatedExtglob` (true if the path starts with `!(`). + * + * ```js + * const pm = require('picomatch'); + * console.log(pm.scan('foo/bar/*.js')); + * { isGlob: true, input: 'foo/bar/*.js', base: 'foo/bar', glob: '*.js' } + * ``` + * @param {String} `str` + * @param {Object} `options` + * @return {Object} Returns an object with tokens and regex source string. + * @api public + */ + +const scan = (input, options) => { + const opts = options || {}; + + const length = input.length - 1; + const scanToEnd = opts.parts === true || opts.scanToEnd === true; + const slashes = []; + const tokens = []; + const parts = []; + + let str = input; + let index = -1; + let start = 0; + let lastIndex = 0; + let isBrace = false; + let isBracket = false; + let isGlob = false; + let isExtglob = false; + let isGlobstar = false; + let braceEscaped = false; + let backslashes = false; + let negated = false; + let negatedExtglob = false; + let finished = false; + let braces = 0; + let prev; + let code; + let token = { value: '', depth: 0, isGlob: false }; + + const eos = () => index >= length; + const peek = () => str.charCodeAt(index + 1); + const advance = () => { + prev = code; + return str.charCodeAt(++index); + }; + + while (index < length) { + code = advance(); + let next; + + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + code = advance(); + + if (code === CHAR_LEFT_CURLY_BRACE) { + braceEscaped = true; + } + continue; + } + + if (braceEscaped === true || code === CHAR_LEFT_CURLY_BRACE) { + braces++; + + while (eos() !== true && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + advance(); + continue; + } + + if (code === CHAR_LEFT_CURLY_BRACE) { + braces++; + continue; + } + + if (braceEscaped !== true && code === CHAR_DOT && (code = advance()) === CHAR_DOT) { + isBrace = token.isBrace = true; + isGlob = token.isGlob = true; + finished = true; + + if (scanToEnd === true) { + continue; + } + + break; + } + + if (braceEscaped !== true && code === CHAR_COMMA) { + isBrace = token.isBrace = true; + isGlob = token.isGlob = true; + finished = true; + + if (scanToEnd === true) { + continue; + } + + break; + } + + if (code === CHAR_RIGHT_CURLY_BRACE) { + braces--; + + if (braces === 0) { + braceEscaped = false; + isBrace = token.isBrace = true; + finished = true; + break; + } + } + } + + if (scanToEnd === true) { + continue; + } + + break; + } + + if (code === CHAR_FORWARD_SLASH) { + slashes.push(index); + tokens.push(token); + token = { value: '', depth: 0, isGlob: false }; + + if (finished === true) continue; + if (prev === CHAR_DOT && index === (start + 1)) { + start += 2; + continue; + } + + lastIndex = index + 1; + continue; + } + + if (opts.noext !== true) { + const isExtglobChar = code === CHAR_PLUS + || code === CHAR_AT + || code === CHAR_ASTERISK + || code === CHAR_QUESTION_MARK + || code === CHAR_EXCLAMATION_MARK; + + if (isExtglobChar === true && peek() === CHAR_LEFT_PARENTHESES) { + isGlob = token.isGlob = true; + isExtglob = token.isExtglob = true; + finished = true; + if (code === CHAR_EXCLAMATION_MARK && index === start) { + negatedExtglob = true; + } + + if (scanToEnd === true) { + while (eos() !== true && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + code = advance(); + continue; + } + + if (code === CHAR_RIGHT_PARENTHESES) { + isGlob = token.isGlob = true; + finished = true; + break; + } + } + continue; + } + break; + } + } + + if (code === CHAR_ASTERISK) { + if (prev === CHAR_ASTERISK) isGlobstar = token.isGlobstar = true; + isGlob = token.isGlob = true; + finished = true; + + if (scanToEnd === true) { + continue; + } + break; + } + + if (code === CHAR_QUESTION_MARK) { + isGlob = token.isGlob = true; + finished = true; + + if (scanToEnd === true) { + continue; + } + break; + } + + if (code === CHAR_LEFT_SQUARE_BRACKET) { + while (eos() !== true && (next = advance())) { + if (next === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + advance(); + continue; + } + + if (next === CHAR_RIGHT_SQUARE_BRACKET) { + isBracket = token.isBracket = true; + isGlob = token.isGlob = true; + finished = true; + break; + } + } + + if (scanToEnd === true) { + continue; + } + + break; + } + + if (opts.nonegate !== true && code === CHAR_EXCLAMATION_MARK && index === start) { + negated = token.negated = true; + start++; + continue; + } + + if (opts.noparen !== true && code === CHAR_LEFT_PARENTHESES) { + isGlob = token.isGlob = true; + + if (scanToEnd === true) { + while (eos() !== true && (code = advance())) { + if (code === CHAR_LEFT_PARENTHESES) { + backslashes = token.backslashes = true; + code = advance(); + continue; + } + + if (code === CHAR_RIGHT_PARENTHESES) { + finished = true; + break; + } + } + continue; + } + break; + } + + if (isGlob === true) { + finished = true; + + if (scanToEnd === true) { + continue; + } + + break; + } + } + + if (opts.noext === true) { + isExtglob = false; + isGlob = false; + } + + let base = str; + let prefix = ''; + let glob = ''; + + if (start > 0) { + prefix = str.slice(0, start); + str = str.slice(start); + lastIndex -= start; + } + + if (base && isGlob === true && lastIndex > 0) { + base = str.slice(0, lastIndex); + glob = str.slice(lastIndex); + } else if (isGlob === true) { + base = ''; + glob = str; + } else { + base = str; + } + + if (base && base !== '' && base !== '/' && base !== str) { + if (isPathSeparator(base.charCodeAt(base.length - 1))) { + base = base.slice(0, -1); + } + } + + if (opts.unescape === true) { + if (glob) glob = utils.removeBackslashes(glob); + + if (base && backslashes === true) { + base = utils.removeBackslashes(base); + } + } + + const state = { + prefix, + input, + start, + base, + glob, + isBrace, + isBracket, + isGlob, + isExtglob, + isGlobstar, + negated, + negatedExtglob + }; + + if (opts.tokens === true) { + state.maxDepth = 0; + if (!isPathSeparator(code)) { + tokens.push(token); + } + state.tokens = tokens; + } + + if (opts.parts === true || opts.tokens === true) { + let prevIndex; + + for (let idx = 0; idx < slashes.length; idx++) { + const n = prevIndex ? prevIndex + 1 : start; + const i = slashes[idx]; + const value = input.slice(n, i); + if (opts.tokens) { + if (idx === 0 && start !== 0) { + tokens[idx].isPrefix = true; + tokens[idx].value = prefix; + } else { + tokens[idx].value = value; + } + depth(tokens[idx]); + state.maxDepth += tokens[idx].depth; + } + if (idx !== 0 || value !== '') { + parts.push(value); + } + prevIndex = i; + } + + if (prevIndex && prevIndex + 1 < input.length) { + const value = input.slice(prevIndex + 1); + parts.push(value); + + if (opts.tokens) { + tokens[tokens.length - 1].value = value; + depth(tokens[tokens.length - 1]); + state.maxDepth += tokens[tokens.length - 1].depth; + } + } + + state.slashes = slashes; + state.parts = parts; + } + + return state; +}; + +module.exports = scan; + + +/***/ }), + +/***/ 4059: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; +/*global navigator*/ + + +const { + REGEX_BACKSLASH, + REGEX_REMOVE_BACKSLASH, + REGEX_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_GLOBAL +} = __nccwpck_require__(5595); + +exports.isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); +exports.hasRegexChars = str => REGEX_SPECIAL_CHARS.test(str); +exports.isRegexChar = str => str.length === 1 && exports.hasRegexChars(str); +exports.escapeRegex = str => str.replace(REGEX_SPECIAL_CHARS_GLOBAL, '\\$1'); +exports.toPosixSlashes = str => str.replace(REGEX_BACKSLASH, '/'); + +exports.isWindows = () => { + if (typeof navigator !== 'undefined' && navigator.platform) { + const platform = navigator.platform.toLowerCase(); + return platform === 'win32' || platform === 'windows'; + } + + if (typeof process !== 'undefined' && process.platform) { + return process.platform === 'win32'; + } + + return false; +}; + +exports.removeBackslashes = str => { + return str.replace(REGEX_REMOVE_BACKSLASH, match => { + return match === '\\' ? '' : match; + }); +}; + +exports.escapeLast = (input, char, lastIdx) => { + const idx = input.lastIndexOf(char, lastIdx); + if (idx === -1) return input; + if (input[idx - 1] === '\\') return exports.escapeLast(input, char, idx - 1); + return `${input.slice(0, idx)}\\${input.slice(idx)}`; +}; + +exports.removePrefix = (input, state = {}) => { + let output = input; + if (output.startsWith('./')) { + output = output.slice(2); + state.prefix = './'; + } + return output; +}; + +exports.wrapOutput = (input, state = {}, options = {}) => { + const prepend = options.contains ? '' : '^'; + const append = options.contains ? '' : '$'; + + let output = `${prepend}(?:${input})${append}`; + if (state.negated === true) { + output = `(?:^(?!${output}).*$)`; + } + return output; +}; + +exports.basename = (path, { windows } = {}) => { + const segs = path.split(windows ? /[\\/]/ : '/'); + const last = segs[segs.length - 1]; + + if (last === '') { + return segs[segs.length - 2]; + } + + return last; +}; + + /***/ }), /***/ 770: @@ -44666,6 +47113,7 @@ const core = __importStar(__nccwpck_require__(7484)); const github = __importStar(__nccwpck_require__(3228)); const fs_1 = __nccwpck_require__(9896); const validator_1 = __nccwpck_require__(6985); +const protected_files_1 = __nccwpck_require__(4399); const sanitizer_1 = __nccwpck_require__(8820); const threat_detector_1 = __nccwpck_require__(2386); const executor_1 = __nccwpck_require__(8282); @@ -44686,6 +47134,13 @@ async function run() { .split('\n') .map((p) => p.trim()) .filter(Boolean); + const protectedFilesAction = core.getInput('protected-files-action') || 'block'; + const protectedFilesPatterns = core + .getInput('protected-files') + .split('\n') + .map((p) => p.trim()) + .filter(Boolean); + const protectedFilesOverrideDefaults = core.getBooleanInput('protected-files-override-defaults'); const dryRun = core.getBooleanInput('dry-run'); const failOnSanitize = core.getBooleanInput('fail-on-sanitize'); const threatDetection = core.getBooleanInput('threat-detection'); @@ -44715,8 +47170,35 @@ async function run() { } core.info(`All ${validation.passed} action(s) passed constraint validation`); core.endGroup(); - // Phase 2: Sanitize secrets - core.startGroup('Phase 2: Secret sanitization'); + // Phase 2: Protected files check + core.startGroup('Phase 2: Protected files check'); + const protectedConfig = { + action: protectedFilesAction, + patterns: protectedFilesPatterns, + overrideDefaults: protectedFilesOverrideDefaults, + }; + const protectedResult = (0, protected_files_1.checkProtectedFiles)(output, protectedConfig); + if (!protectedResult.passed) { + for (const v of protectedResult.violations) { + const msg = `PROTECTED [${v.category}] ${v.path} (matched: ${v.matchedPattern})`; + if (protectedConfig.action === 'block') { + core.error(msg); + } + else { + core.warning(msg); + } + } + if (protectedConfig.action === 'block') { + core.endGroup(); + setOutputs({ blocked: protectedResult.violations.length, applied: 0, sanitized: 0 }); + core.setFailed(`${protectedResult.violations.length} file(s) blocked by protected files policy`); + return; + } + } + core.info(`Checked ${protectedResult.checkedFiles} file(s) across PR actions`); + core.endGroup(); + // Phase 3: Sanitize secrets + core.startGroup('Phase 3: Secret sanitization'); const sanitization = (0, sanitizer_1.sanitizeOutput)(output, customPatterns); if (sanitization.redactedCount > 0) { core.warning(`Sanitized ${sanitization.redactedCount} field(s): ${sanitization.redactedFields.join(', ')}`); @@ -44731,9 +47213,9 @@ async function run() { core.info('No sensitive patterns detected'); } core.endGroup(); - // Phase 3: AI threat detection (optional) + // Phase 4: AI threat detection (optional) if (threatDetection) { - core.startGroup('Phase 3: AI threat detection'); + core.startGroup('Phase 4: AI threat detection'); const threats = await (0, threat_detector_1.detectThreats)(sanitization.output); if (threats.enabled) { if (!threats.passed) { @@ -44752,8 +47234,8 @@ async function run() { } core.endGroup(); } - // Phase 4: Execute (or dry-run) - core.startGroup(threatDetection ? 'Phase 4: Execution' : 'Phase 3: Execution'); + // Phase 5: Execute (or dry-run) + core.startGroup(threatDetection ? 'Phase 5: Execution' : 'Phase 4: Execution'); if (dryRun) { core.info('DRY RUN: Actions validated and sanitized but NOT applied'); setOutputs({ blocked: 0, applied: 0, sanitized: sanitization.redactedCount }); @@ -44796,6 +47278,156 @@ function setOutputs(counts) { run(); +/***/ }), + +/***/ 4399: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.DEFAULT_PROTECTED_PATTERNS = void 0; +exports.classifyPattern = classifyPattern; +exports.isFileProtected = isFileProtected; +exports.resolveProtectedConfig = resolveProtectedConfig; +exports.checkProtectedFiles = checkProtectedFiles; +const picomatch_1 = __importDefault(__nccwpck_require__(4006)); +// --------------------------------------------------------------------------- +// Built-in defaults +// --------------------------------------------------------------------------- +exports.DEFAULT_PROTECTED_PATTERNS = [ + '.github/workflows/**', + '.github/actions/**', + 'CODEOWNERS', + 'AGENTS.md', + '.claude/**', + '.codex/**', + '.github/copilot-instructions.md', + 'package.json', + 'package-lock.json', + 'go.mod', + 'go.sum', + 'requirements.txt', + 'Pipfile.lock', + 'Gemfile.lock', + 'pnpm-lock.yaml', + 'yarn.lock', +]; +// --------------------------------------------------------------------------- +// Category classification +// --------------------------------------------------------------------------- +const CATEGORY_MAP = [ + { pattern: '.github/workflows/**', category: 'CI config' }, + { pattern: '.github/actions/**', category: 'CI config' }, + { pattern: 'package.json', category: 'Dependency manifest' }, + { pattern: 'package-lock.json', category: 'Dependency manifest' }, + { pattern: 'go.mod', category: 'Dependency manifest' }, + { pattern: 'go.sum', category: 'Dependency manifest' }, + { pattern: 'requirements.txt', category: 'Dependency manifest' }, + { pattern: 'Pipfile.lock', category: 'Dependency manifest' }, + { pattern: 'Gemfile.lock', category: 'Dependency manifest' }, + { pattern: 'pnpm-lock.yaml', category: 'Dependency manifest' }, + { pattern: 'yarn.lock', category: 'Dependency manifest' }, + { pattern: 'AGENTS.md', category: 'Agent instructions' }, + { pattern: '.claude/**', category: 'Agent instructions' }, + { pattern: '.codex/**', category: 'Agent instructions' }, + { pattern: '.github/copilot-instructions.md', category: 'Agent instructions' }, + { pattern: 'CODEOWNERS', category: 'Access control' }, +]; +/** + * Classify a matched pattern into a human-readable category. + * Known built-in patterns map to specific categories; anything else is "Custom". + */ +function classifyPattern(matchedPattern) { + for (const entry of CATEGORY_MAP) { + if (entry.pattern === matchedPattern) { + return entry.category; + } + } + return 'Custom'; +} +// --------------------------------------------------------------------------- +// Pattern matching (gitignore-style, last match wins) +// --------------------------------------------------------------------------- +function isFileProtected(filepath, patterns) { + let isProtected = false; + let matchedPattern; + for (const raw of patterns) { + const pattern = raw.trim(); + if (!pattern) + continue; + if (pattern.startsWith('!')) { + const negated = pattern.slice(1); + if (negated && picomatch_1.default.isMatch(filepath, negated, { dot: true })) { + isProtected = false; + matchedPattern = undefined; + } + } + else { + if (picomatch_1.default.isMatch(filepath, pattern, { dot: true })) { + isProtected = true; + matchedPattern = pattern; + } + } + } + return { protected: isProtected, matchedPattern }; +} +// --------------------------------------------------------------------------- +// Config resolution +// --------------------------------------------------------------------------- +/** + * Merge built-in defaults with user-supplied patterns. + * When `overrideDefaults` is true the built-in list is skipped entirely. + */ +function resolveProtectedConfig(config) { + const base = config.overrideDefaults ? [] : [...exports.DEFAULT_PROTECTED_PATTERNS]; + return [...base, ...config.patterns]; +} +// --------------------------------------------------------------------------- +// Main checker +// --------------------------------------------------------------------------- +/** + * Scan all `create_pull_request` actions in the agent output for files + * that match the protected pattern list. + */ +function checkProtectedFiles(output, config) { + const patterns = resolveProtectedConfig(config); + const violations = []; + let checkedFiles = 0; + if (!output || !Array.isArray(output.actions)) { + return { passed: true, violations: [], checkedFiles: 0, protectedAction: config.action }; + } + for (const action of output.actions) { + if (action.type !== 'create_pull_request') + continue; + const prAction = action; + if (!prAction.files) + continue; + for (const filepath of Object.keys(prAction.files)) { + checkedFiles++; + const result = isFileProtected(filepath, patterns); + if (result.protected && result.matchedPattern) { + violations.push({ + path: filepath, + matchedPattern: result.matchedPattern, + category: classifyPattern(result.matchedPattern), + }); + } + } + } + const passed = config.action === 'warn' || violations.length === 0; + return { + passed, + violations, + checkedFiles, + protectedAction: config.action, + }; +} + + /***/ }), /***/ 8820: diff --git a/dist/licenses.txt b/dist/licenses.txt index 9a6ed47..4e70454 100644 --- a/dist/licenses.txt +++ b/dist/licenses.txt @@ -486,6 +486,31 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +picomatch +MIT +The MIT License (MIT) + +Copyright (c) 2017-present, Jon Schlinkert. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + tunnel MIT The MIT License (MIT) From 1acea3f1ec830e7f3287e0202827d7db2291bd45 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:55:12 -0400 Subject: [PATCH 07/12] fix(main): warn mode now surfaces violations as annotations The violation logging gate checked !protectedResult.passed, but checkProtectedFiles sets passed=true in warn mode regardless of violations. This meant warnings were never emitted. Changed the gate to check protectedResult.violations.length > 0 so both block errors and warn annotations are surfaced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 0ced7dd..6125a38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -76,7 +76,7 @@ async function run(): Promise { }; const protectedResult = checkProtectedFiles(output, protectedConfig); - if (!protectedResult.passed) { + if (protectedResult.violations.length > 0) { for (const v of protectedResult.violations) { const msg = `PROTECTED [${v.category}] ${v.path} (matched: ${v.matchedPattern})`; if (protectedConfig.action === 'block') { From 87132c376f820a2df98fdb1b55bac2472a194a0f Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:55:32 -0400 Subject: [PATCH 08/12] fix(protected-files): normalize file paths to prevent pattern bypass Agent output paths like ./package.json, sub/../CODEOWNERS, or .github//workflows//ci.yml could bypass picomatch patterns while Git API would normalize them to protected locations. Now paths are normalized with path.posix.normalize before matching, and paths that escape the repo root (../ or /) are flagged as security violations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/protected-files.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/protected-files.ts b/src/protected-files.ts index 5f7a238..9f1bf6d 100644 --- a/src/protected-files.ts +++ b/src/protected-files.ts @@ -1,3 +1,4 @@ +import path from 'path'; import picomatch from 'picomatch'; import { AgentOutput, CreatePullRequestAction } from './types'; @@ -156,7 +157,21 @@ export function checkProtectedFiles( for (const filepath of Object.keys(prAction.files)) { checkedFiles++; - const result = isFileProtected(filepath, patterns); + + // Normalize to prevent bypass via ./, ../, or // in paths + const normalized = path.posix.normalize(filepath); + + // Reject paths that escape the repo root + if (normalized.startsWith('../') || normalized.startsWith('/')) { + violations.push({ + path: filepath, + matchedPattern: '', + category: 'Security', + }); + continue; + } + + const result = isFileProtected(normalized, patterns); if (result.protected && result.matchedPattern) { violations.push({ path: filepath, From 35e6b8d2c32c60b0485c3fcf3e4bb33ac69fbde1 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:56:01 -0400 Subject: [PATCH 09/12] fix(main): validate protected-files-action input The input was cast with "as block | warn" which provides no runtime protection. A typo like "Block" or "warnings" silently disables blocking. Now the input is validated at startup and the action fails fast with a clear message for invalid values. The unsafe type cast is removed since TS narrows the type after the guard clause. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 6125a38..cd8c613 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,12 @@ async function run(): Promise { .map((p) => p.trim()) .filter(Boolean); const protectedFilesAction = core.getInput('protected-files-action') || 'block'; + if (protectedFilesAction !== 'block' && protectedFilesAction !== 'warn') { + core.setFailed( + `Invalid protected-files-action: "${protectedFilesAction}". Must be "block" or "warn".` + ); + return; + } const protectedFilesPatterns = core .getInput('protected-files') .split('\n') @@ -70,7 +76,7 @@ async function run(): Promise { // Phase 2: Protected files check core.startGroup('Phase 2: Protected files check'); const protectedConfig: ProtectedFilesConfig = { - action: protectedFilesAction as 'block' | 'warn', + action: protectedFilesAction, patterns: protectedFilesPatterns, overrideDefaults: protectedFilesOverrideDefaults, }; From aed5e01342e5a05c9b4ba750de4336ca42b3551e Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:56:26 -0400 Subject: [PATCH 10/12] fix(protected-files): default patterns match at any directory depth Dependency manifest patterns like package.json only matched at the repo root. In monorepos, apps/web/package.json would bypass protection. Updated all manifest patterns to use **/ prefix so they match at any depth. Updated CATEGORY_MAP to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/protected-files.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/protected-files.ts b/src/protected-files.ts index 9f1bf6d..1893305 100644 --- a/src/protected-files.ts +++ b/src/protected-files.ts @@ -37,15 +37,15 @@ export const DEFAULT_PROTECTED_PATTERNS: readonly string[] = [ '.claude/**', '.codex/**', '.github/copilot-instructions.md', - 'package.json', - 'package-lock.json', - 'go.mod', - 'go.sum', - 'requirements.txt', - 'Pipfile.lock', - 'Gemfile.lock', - 'pnpm-lock.yaml', - 'yarn.lock', + '**/package.json', + '**/package-lock.json', + '**/go.mod', + '**/go.sum', + '**/requirements.txt', + '**/Pipfile.lock', + '**/Gemfile.lock', + '**/pnpm-lock.yaml', + '**/yarn.lock', ]; // --------------------------------------------------------------------------- @@ -55,15 +55,15 @@ export const DEFAULT_PROTECTED_PATTERNS: readonly string[] = [ const CATEGORY_MAP: { pattern: string; category: string }[] = [ { pattern: '.github/workflows/**', category: 'CI config' }, { pattern: '.github/actions/**', category: 'CI config' }, - { pattern: 'package.json', category: 'Dependency manifest' }, - { pattern: 'package-lock.json', category: 'Dependency manifest' }, - { pattern: 'go.mod', category: 'Dependency manifest' }, - { pattern: 'go.sum', category: 'Dependency manifest' }, - { pattern: 'requirements.txt', category: 'Dependency manifest' }, - { pattern: 'Pipfile.lock', category: 'Dependency manifest' }, - { pattern: 'Gemfile.lock', category: 'Dependency manifest' }, - { pattern: 'pnpm-lock.yaml', category: 'Dependency manifest' }, - { pattern: 'yarn.lock', category: 'Dependency manifest' }, + { pattern: '**/package.json', category: 'Dependency manifest' }, + { pattern: '**/package-lock.json', category: 'Dependency manifest' }, + { pattern: '**/go.mod', category: 'Dependency manifest' }, + { pattern: '**/go.sum', category: 'Dependency manifest' }, + { pattern: '**/requirements.txt', category: 'Dependency manifest' }, + { pattern: '**/Pipfile.lock', category: 'Dependency manifest' }, + { pattern: '**/Gemfile.lock', category: 'Dependency manifest' }, + { pattern: '**/pnpm-lock.yaml', category: 'Dependency manifest' }, + { pattern: '**/yarn.lock', category: 'Dependency manifest' }, { pattern: 'AGENTS.md', category: 'Agent instructions' }, { pattern: '.claude/**', category: 'Agent instructions' }, { pattern: '.codex/**', category: 'Agent instructions' }, From 310c4eaa2512a344c537621fba1338bce435ec3e Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:58:23 -0400 Subject: [PATCH 11/12] test(protected-files): add tests for path normalization, traversal, and monorepo depth Added test suites: - path normalization: ./ prefix, ../ traversal, // double slashes - path traversal rejection: ../ escape, absolute paths, normalized escape - monorepo depth matching: nested package.json, go.mod, all manifests - warn mode emits violations: violations array populated in warn mode Updated existing tests to use **/ prefix for dependency manifest patterns to match the new defaults. 69 tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/__tests__/protected-files.test.ts | 159 +++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 16 deletions(-) diff --git a/src/__tests__/protected-files.test.ts b/src/__tests__/protected-files.test.ts index 94b7a55..9bf3fac 100644 --- a/src/__tests__/protected-files.test.ts +++ b/src/__tests__/protected-files.test.ts @@ -49,15 +49,15 @@ describe('DEFAULT_PROTECTED_PATTERNS', () => { }); it('includes dependency manifest patterns', () => { - expect(DEFAULT_PROTECTED_PATTERNS).toContain('package.json'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('package-lock.json'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('go.mod'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('go.sum'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('requirements.txt'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('Pipfile.lock'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('Gemfile.lock'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('pnpm-lock.yaml'); - expect(DEFAULT_PROTECTED_PATTERNS).toContain('yarn.lock'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/package.json'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/package-lock.json'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/go.mod'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/go.sum'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/requirements.txt'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/Pipfile.lock'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/Gemfile.lock'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/pnpm-lock.yaml'); + expect(DEFAULT_PROTECTED_PATTERNS).toContain('**/yarn.lock'); }); it('includes agent instructions patterns', () => { @@ -79,9 +79,9 @@ describe('classifyPattern', () => { }); it('classifies dependency manifest patterns', () => { - expect(classifyPattern('package.json')).toBe('Dependency manifest'); - expect(classifyPattern('go.mod')).toBe('Dependency manifest'); - expect(classifyPattern('yarn.lock')).toBe('Dependency manifest'); + expect(classifyPattern('**/package.json')).toBe('Dependency manifest'); + expect(classifyPattern('**/go.mod')).toBe('Dependency manifest'); + expect(classifyPattern('**/yarn.lock')).toBe('Dependency manifest'); }); it('classifies agent instructions patterns', () => { @@ -195,9 +195,9 @@ describe('resolveProtectedConfig', () => { overrideDefaults: false, }); expect(patterns).toContain('deploy/**'); - expect(patterns).toContain('package.json'); + expect(patterns).toContain('**/package.json'); // User pattern comes after defaults - expect(patterns.indexOf('deploy/**')).toBeGreaterThan(patterns.indexOf('package.json')); + expect(patterns.indexOf('deploy/**')).toBeGreaterThan(patterns.indexOf('**/package.json')); }); it('skips defaults when overrideDefaults is true', () => { @@ -207,7 +207,7 @@ describe('resolveProtectedConfig', () => { overrideDefaults: true, }); expect(patterns).toEqual(['my-config.yml']); - expect(patterns).not.toContain('package.json'); + expect(patterns).not.toContain('**/package.json'); }); it('returns empty array when overrideDefaults is true and no user patterns', () => { @@ -318,7 +318,7 @@ describe('checkProtectedFiles', () => { it('user negation creates exception for defaults', () => { const config: ProtectedFilesConfig = { action: 'block', - patterns: ['!package-lock.json'], + patterns: ['!**/package-lock.json'], overrideDefaults: false, }; const output = makePrOutput({ 'package-lock.json': '{}' }); @@ -544,6 +544,133 @@ describe('checkProtectedFiles', () => { }); }); + describe('path normalization', () => { + it('normalizes ./ prefix before matching', () => { + const output = makePrOutput({ './package.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('./package.json'); + expect(result.violations[0].category).toBe('Dependency manifest'); + }); + + it('normalizes ../ traversal before matching', () => { + const output = makePrOutput({ 'sub/../CODEOWNERS': '* @team' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('sub/../CODEOWNERS'); + expect(result.violations[0].category).toBe('Access control'); + }); + + it('normalizes double slashes before matching', () => { + const output = makePrOutput({ '.github//workflows//ci.yml': 'name: CI' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('.github//workflows//ci.yml'); + expect(result.violations[0].category).toBe('CI config'); + }); + }); + + describe('path traversal rejection', () => { + it('rejects paths that escape the repo root with ../', () => { + const output = makePrOutput({ '../../../etc/passwd': 'root:x:0:0' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].matchedPattern).toBe(''); + expect(result.violations[0].category).toBe('Security'); + }); + + it('rejects absolute paths', () => { + const output = makePrOutput({ '/absolute/path': 'content' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].matchedPattern).toBe(''); + expect(result.violations[0].category).toBe('Security'); + }); + + it('rejects paths that normalize to ../', () => { + const output = makePrOutput({ 'a/../../secret': 'content' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].matchedPattern).toBe(''); + expect(result.violations[0].category).toBe('Security'); + }); + }); + + describe('monorepo depth matching', () => { + it('matches package.json in nested directories', () => { + const output = makePrOutput({ 'apps/web/package.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('apps/web/package.json'); + expect(result.violations[0].category).toBe('Dependency manifest'); + }); + + it('matches go.mod in nested directories', () => { + const output = makePrOutput({ 'packages/core/go.mod': 'module example' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].path).toBe('packages/core/go.mod'); + expect(result.violations[0].category).toBe('Dependency manifest'); + }); + + it('matches all dependency manifests at nested depth', () => { + const depFiles = [ + 'apps/web/package.json', + 'services/api/package-lock.json', + 'libs/core/go.mod', + 'backend/go.sum', + 'python/app/requirements.txt', + 'services/worker/Pipfile.lock', + 'ruby/Gemfile.lock', + 'frontend/pnpm-lock.yaml', + 'monorepo/packages/ui/yarn.lock', + ]; + for (const file of depFiles) { + const output = makePrOutput({ [file]: 'content' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].path).toBe(file); + expect(result.violations[0].category).toBe('Dependency manifest'); + } + }); + + it('still matches root-level manifests', () => { + const output = makePrOutput({ 'package.json': '{}' }); + const result = checkProtectedFiles(output, blockConfig); + expect(result.passed).toBe(false); + expect(result.violations[0].path).toBe('package.json'); + }); + }); + + describe('warn mode emits violations', () => { + it('returns violations array when action is warn', () => { + const output = makePrOutput({ + 'package.json': '{}', + '.github/workflows/ci.yml': 'ci', + }); + const result = checkProtectedFiles(output, warnConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(2); + expect(result.violations.map((v) => v.path)).toContain('package.json'); + expect(result.violations.map((v) => v.path)).toContain('.github/workflows/ci.yml'); + }); + + it('returns empty violations array when no files are protected', () => { + const output = makePrOutput({ 'src/index.ts': 'code' }); + const result = checkProtectedFiles(output, warnConfig); + expect(result.passed).toBe(true); + expect(result.violations).toHaveLength(0); + }); + }); + describe('glob pattern specifics', () => { it('** matches deeply nested workflow files', () => { const output = makePrOutput({ From f4f6c7c039b637df44f7aa9e8dcf3330722dcf50 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 15 Apr 2026 11:58:50 -0400 Subject: [PATCH 12/12] chore: rebuild dist bundle Rebuilt after all four security fixes (warn mode gate, path normalization, input validation, monorepo depth matching). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/index.js | 56 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/dist/index.js b/dist/index.js index 227783e..5e3b9f8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -47135,6 +47135,10 @@ async function run() { .map((p) => p.trim()) .filter(Boolean); const protectedFilesAction = core.getInput('protected-files-action') || 'block'; + if (protectedFilesAction !== 'block' && protectedFilesAction !== 'warn') { + core.setFailed(`Invalid protected-files-action: "${protectedFilesAction}". Must be "block" or "warn".`); + return; + } const protectedFilesPatterns = core .getInput('protected-files') .split('\n') @@ -47178,7 +47182,7 @@ async function run() { overrideDefaults: protectedFilesOverrideDefaults, }; const protectedResult = (0, protected_files_1.checkProtectedFiles)(output, protectedConfig); - if (!protectedResult.passed) { + if (protectedResult.violations.length > 0) { for (const v of protectedResult.violations) { const msg = `PROTECTED [${v.category}] ${v.path} (matched: ${v.matchedPattern})`; if (protectedConfig.action === 'block') { @@ -47294,6 +47298,7 @@ exports.classifyPattern = classifyPattern; exports.isFileProtected = isFileProtected; exports.resolveProtectedConfig = resolveProtectedConfig; exports.checkProtectedFiles = checkProtectedFiles; +const path_1 = __importDefault(__nccwpck_require__(6928)); const picomatch_1 = __importDefault(__nccwpck_require__(4006)); // --------------------------------------------------------------------------- // Built-in defaults @@ -47306,15 +47311,15 @@ exports.DEFAULT_PROTECTED_PATTERNS = [ '.claude/**', '.codex/**', '.github/copilot-instructions.md', - 'package.json', - 'package-lock.json', - 'go.mod', - 'go.sum', - 'requirements.txt', - 'Pipfile.lock', - 'Gemfile.lock', - 'pnpm-lock.yaml', - 'yarn.lock', + '**/package.json', + '**/package-lock.json', + '**/go.mod', + '**/go.sum', + '**/requirements.txt', + '**/Pipfile.lock', + '**/Gemfile.lock', + '**/pnpm-lock.yaml', + '**/yarn.lock', ]; // --------------------------------------------------------------------------- // Category classification @@ -47322,15 +47327,15 @@ exports.DEFAULT_PROTECTED_PATTERNS = [ const CATEGORY_MAP = [ { pattern: '.github/workflows/**', category: 'CI config' }, { pattern: '.github/actions/**', category: 'CI config' }, - { pattern: 'package.json', category: 'Dependency manifest' }, - { pattern: 'package-lock.json', category: 'Dependency manifest' }, - { pattern: 'go.mod', category: 'Dependency manifest' }, - { pattern: 'go.sum', category: 'Dependency manifest' }, - { pattern: 'requirements.txt', category: 'Dependency manifest' }, - { pattern: 'Pipfile.lock', category: 'Dependency manifest' }, - { pattern: 'Gemfile.lock', category: 'Dependency manifest' }, - { pattern: 'pnpm-lock.yaml', category: 'Dependency manifest' }, - { pattern: 'yarn.lock', category: 'Dependency manifest' }, + { pattern: '**/package.json', category: 'Dependency manifest' }, + { pattern: '**/package-lock.json', category: 'Dependency manifest' }, + { pattern: '**/go.mod', category: 'Dependency manifest' }, + { pattern: '**/go.sum', category: 'Dependency manifest' }, + { pattern: '**/requirements.txt', category: 'Dependency manifest' }, + { pattern: '**/Pipfile.lock', category: 'Dependency manifest' }, + { pattern: '**/Gemfile.lock', category: 'Dependency manifest' }, + { pattern: '**/pnpm-lock.yaml', category: 'Dependency manifest' }, + { pattern: '**/yarn.lock', category: 'Dependency manifest' }, { pattern: 'AGENTS.md', category: 'Agent instructions' }, { pattern: '.claude/**', category: 'Agent instructions' }, { pattern: '.codex/**', category: 'Agent instructions' }, @@ -47408,7 +47413,18 @@ function checkProtectedFiles(output, config) { continue; for (const filepath of Object.keys(prAction.files)) { checkedFiles++; - const result = isFileProtected(filepath, patterns); + // Normalize to prevent bypass via ./, ../, or // in paths + const normalized = path_1.default.posix.normalize(filepath); + // Reject paths that escape the repo root + if (normalized.startsWith('../') || normalized.startsWith('/')) { + violations.push({ + path: filepath, + matchedPattern: '', + category: 'Security', + }); + continue; + } + const result = isFileProtected(normalized, patterns); if (result.protected && result.matchedPattern) { violations.push({ path: filepath,