agentci-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SECURITY.md +23 -0
- package/action.yml +42 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +718 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +638 -0
- package/dist/index.js.map +1 -0
- package/docs/demo.svg +47 -0
- package/docs/demo.tape +34 -0
- package/docs/real-world-findings.md +79 -0
- package/docs/rules.md +33 -0
- package/docs/threat-model.md +32 -0
- package/examples/hardened/.github/workflows/ai-agent.yml +18 -0
- package/examples/vulnerable/.github/workflows/ai-agent.yml +29 -0
- package/package.json +71 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts","../src/detect.ts","../src/report.ts","../src/rules.ts","../src/sarif.ts","../src/scanner.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nexport type AgentciConfig = {\n /** Rule ids to suppress everywhere, e.g. \"agentci/unpinned-ai-action\". */\n ignore: string[];\n /** Workflow path globs (relative to scan root) to exclude from reporting. */\n ignorePaths: string[];\n};\n\nconst EMPTY: AgentciConfig = { ignore: [], ignorePaths: [] };\n\nconst CONFIG_FILENAMES = [\"agentci.config.json\", \".agentcirc.json\"];\n\n/** Load config from an explicit path, or by discovery in the scan root. */\nexport async function loadConfig(\n root: string,\n explicitPath?: string,\n): Promise<AgentciConfig> {\n const candidates = explicitPath\n ? [path.resolve(explicitPath)]\n : CONFIG_FILENAMES.map((name) => path.join(root, name));\n\n for (const file of candidates) {\n let raw: string;\n try {\n raw = await fs.readFile(file, \"utf8\");\n } catch {\n continue;\n }\n const parsed = JSON.parse(raw) as Partial<AgentciConfig>;\n return {\n ignore: toStringArray(parsed.ignore),\n ignorePaths: toStringArray(parsed.ignorePaths),\n };\n }\n return EMPTY;\n}\n\nfunction toStringArray(value: unknown): string[] {\n return Array.isArray(value) ? value.map((item) => String(item)) : [];\n}\n\n/**\n * Inline, file-level suppression directives read from raw workflow text:\n * # agentci-ignore <rule-id> [<rule-id> ...] [-- reason]\n * # agentci-ignore-all [-- reason]\n *\n * Findings are reported at job/step granularity rather than per line, so\n * suppression is scoped to the whole file. The optional `-- reason` is for\n * humans and is ignored by the parser.\n */\nexport function parseInlineIgnores(raw: string): {\n all: boolean;\n rules: Set<string>;\n} {\n const rules = new Set<string>();\n let all = false;\n\n for (const line of raw.split(\"\\n\")) {\n if (/#\\s*agentci-ignore-all\\b/i.test(line)) {\n all = true;\n continue;\n }\n const match = /#\\s*agentci-ignore\\s+([^\\n]+)/i.exec(line);\n if (!match) continue;\n const spec = match[1].split(\"--\")[0]; // strip the optional \"-- reason\"\n for (const id of spec.split(/[\\s,]+/)) {\n if (id) rules.add(id);\n }\n }\n\n return { all, rules };\n}\n\n/** Minimal glob match: `*` matches within a path segment, `**` across segments. */\nexport function matchesPath(glob: string, target: string): boolean {\n const pattern = glob\n .split(\"**\")\n .map((part) =>\n part\n .split(\"*\")\n .map((segment) => segment.replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\"))\n .join(\"[^/]*\"),\n )\n .join(\".*\");\n return new RegExp(`^${pattern}$`).test(target);\n}\n","// Precision is the whole game for a workflow linter: cry wolf on ordinary CI\n// and it gets uninstalled after the first run. We therefore detect AI coding-\n// agent usage only from *specific, load-bearing* signals — known agent actions,\n// agent CLI invocations, and provider credentials / model identifiers — and\n// never from generic words like \"agent\", \"ai\", \"node\", \"codex\", or \"mcp\" that\n// legitimately appear in self-hosted runner labels (\"build-agent\"), user-agent\n// headers, action slugs (\"datadog/agent-action\"), and ordinary tooling.\n//\n// To add support for a new agent, add a specific pattern here (PRs welcome).\nexport const AI_AGENT_PATTERNS = [\n // Known AI coding-agent GitHub Actions (matched in `uses:`)\n /anthropics\\/claude-code(?:-base)?-action/i,\n /\\banthropics\\/[\\w.-]*claude/i,\n /\\baider-ai\\/aider\\b/i,\n /\\bsweepai\\//i,\n /(?:all-hands-ai|opendevin)\\/(?:openhands|opendevin)/i,\n /\\bcontinuedev\\//i,\n /\\bblock\\/goose\\b|\\bgoose-ai\\//i,\n /\\bgithub\\/copilot[\\w-]*agent/i,\n /\\bopenai\\/codex[\\w-]*/i,\n\n // Agent CLIs / tools (product names specific enough to be low false-positive)\n /\\bclaude-code\\b/i,\n /@anthropic-ai\\/claude-code\\b/i,\n /\\bclaude\\b/i, // CLI binary + product name; rare as a literal token in non-AI CI\n /\\baider\\b/i,\n /\\bchatgpt\\b/i,\n /\\bcodex\\s+(?:exec|run|--)/i, // openai codex CLI (bare \"codex\" is too generic)\n /\\bollama\\s+run\\b/i,\n /\\bcursor-agent\\b/i,\n /\\bllm\\s+(?:-m|--model)\\b/i, // simonw llm CLI (bare \"llm\" is too generic)\n\n // Provider credentials / endpoints / SDKs / model identifiers\n /\\bANTHROPIC_API_KEY\\b/i,\n /\\bOPENAI_API_KEY\\b/i,\n /\\bGEMINI_API_KEY\\b/i,\n /api\\.(?:anthropic|openai)\\.com/i,\n /@anthropic-ai\\//i,\n /\\bclaude-(?:3|4|opus|sonnet|haiku)\\b/i,\n /\\bgpt-(?:4|4o|5)\\b/i,\n\n // Explicit agent phrasing\n /\\bai[ -]agents?\\b/i,\n /\\bcoding agents?\\b/i,\n /\\bautonomous agents?\\b/i,\n /\\bllm[ -]agents?\\b/i,\n /\\bmodel context protocol\\b/i,\n];\n\nconst UNTRUSTED_CONTEXT_PATTERNS = [\n /github\\.event\\.pull_request\\.(body|title|head\\.ref|head\\.sha)/i,\n /github\\.event\\.issue\\.(body|title)/i,\n /github\\.event\\.comment\\.body/i,\n /github\\.event\\.review\\.body/i,\n /github\\.event\\.head_commit\\.message/i,\n /github\\.head_ref/i,\n /github\\.ref_name/i,\n];\n\nconst SECRET_PATTERNS = [\n /secrets\\./i,\n /GITHUB_TOKEN/i,\n /\\b[A-Z0-9_]*TOKEN\\b/,\n /\\b[A-Z0-9_]*KEY\\b/,\n];\nconst SHELL_PATTERNS = [\n /\\bshell\\b/i,\n /\\bbash\\b/i,\n /\\bsh\\b/i,\n /\\bcurl\\b/i,\n /\\bwget\\b/i,\n /\\bnpx\\b/i,\n /\\bpython\\b/i,\n /\\bnode\\b/i,\n /\\bexec\\b/i,\n];\n\nexport function looksLikeAiUsage(value: string): boolean {\n return AI_AGENT_PATTERNS.some((pattern) => pattern.test(value));\n}\n\nexport function containsUntrustedGitHubContext(value: string): boolean {\n return UNTRUSTED_CONTEXT_PATTERNS.some((pattern) => pattern.test(value));\n}\n\nexport function containsSecretReference(value: string): boolean {\n return SECRET_PATTERNS.some((pattern) => pattern.test(value));\n}\n\nexport function containsShellAccess(value: string): boolean {\n return SHELL_PATTERNS.some((pattern) => pattern.test(value));\n}\n\nexport function isPinnedAction(uses: string): boolean {\n const ref = uses.split(\"@\")[1];\n return Boolean(ref && /^[a-f0-9]{40}$/i.test(ref));\n}\n","import pc from \"picocolors\";\nimport type { Finding, ScanResult } from \"./types.js\";\n\n/**\n * Render the GitHub Actions `$GITHUB_OUTPUT` lines for a scan, so downstream\n * steps can branch on the result (e.g. comment on a PR when critical > 0).\n */\nexport function formatGithubOutputs(\n result: ScanResult,\n sarifPath?: string,\n): string {\n return (\n [\n `findings=${result.findings.length}`,\n `critical=${result.summary.critical}`,\n `high=${result.summary.high}`,\n `medium=${result.summary.medium}`,\n `low=${result.summary.low}`,\n `sarif-path=${sarifPath ?? \"\"}`,\n ].join(\"\\n\") + \"\\n\"\n );\n}\n\nexport function renderTextReport(result: ScanResult): string {\n const lines = [\n \"AgentCI Guard scan\",\n `Workflows: ${result.workflow_count}`,\n `Findings: ${result.findings.length}`,\n `Summary: critical=${result.summary.critical} high=${result.summary.high} medium=${result.summary.medium} low=${result.summary.low}`,\n \"\",\n ];\n\n for (const finding of result.findings) {\n lines.push(`${label(finding.severity)} ${finding.rule_id}`);\n lines.push(\n `File: ${finding.file}${finding.job ? ` / job: ${finding.job}` : \"\"}${finding.step ? ` / step: ${finding.step}` : \"\"}`,\n );\n lines.push(`Evidence: ${finding.evidence}`);\n lines.push(`Why: ${finding.why}`);\n lines.push(\"Fix:\");\n for (const fix of finding.fix) lines.push(`- ${fix}`);\n lines.push(\"\");\n }\n\n return lines.join(\"\\n\");\n}\n\nexport function renderMarkdownReport(result: ScanResult): string {\n return [\n \"# AgentCI Guard Scan\",\n \"\",\n `- Workflows: ${result.workflow_count}`,\n `- Findings: ${result.findings.length}`,\n `- Critical: ${result.summary.critical}`,\n `- High: ${result.summary.high}`,\n `- Medium: ${result.summary.medium}`,\n `- Low: ${result.summary.low}`,\n \"\",\n ...result.findings.flatMap(renderFindingMarkdown),\n \"\",\n ].join(\"\\n\");\n}\n\nfunction renderFindingMarkdown(finding: Finding): string[] {\n return [\n `## ${finding.severity.toUpperCase()} ${finding.rule_id}`,\n \"\",\n `**File:** ${finding.file}`,\n finding.job ? `**Job:** ${finding.job}` : \"\",\n finding.step ? `**Step:** ${finding.step}` : \"\",\n `**Evidence:** \\`${finding.evidence.replace(/`/g, \"'\")}\\``,\n \"\",\n finding.why,\n \"\",\n \"**Fix:**\",\n \"\",\n ...finding.fix.map((fix) => `- ${fix}`),\n \"\",\n ].filter(Boolean);\n}\n\nfunction label(severity: string): string {\n if (severity === \"critical\") return pc.red(\"[CRITICAL]\");\n if (severity === \"high\") return pc.red(\"[HIGH]\");\n if (severity === \"medium\") return pc.yellow(\"[MEDIUM]\");\n return pc.cyan(\"[LOW]\");\n}\n","import type { Severity } from \"./types.js\";\n\nexport type RuleDefinition = {\n id: string;\n title: string;\n severity: Severity;\n why: string;\n fix: string[];\n};\n\nexport const RULES: Record<string, RuleDefinition> = {\n \"agentci/untrusted-ai-write-token\": {\n id: \"agentci/untrusted-ai-write-token\",\n title:\n \"Untrusted event content can reach an AI agent with write permissions\",\n severity: \"critical\",\n why: \"An attacker can place prompt-injection text in a PR, issue, or comment. If that text reaches an AI agent with repository write permissions, the agent can be induced to modify code, comments, workflows, or releases.\",\n fix: [\n \"Do not run privileged AI agents on untrusted triggers.\",\n \"Use read-only GITHUB_TOKEN permissions for untrusted events.\",\n \"Require maintainer approval before running the agent.\",\n \"Sanitize and summarize untrusted content before passing it to an agent.\",\n ],\n },\n \"agentci/pull-request-target-ai\": {\n id: \"agentci/pull-request-target-ai\",\n title: \"AI agent runs on pull_request_target\",\n severity: \"critical\",\n why: \"pull_request_target runs in the base repository security context and can expose write tokens or secrets to workflows influenced by an untrusted pull request.\",\n fix: [\n \"Use pull_request with read-only permissions for untrusted code.\",\n \"Split analysis into a read-only job and a separate maintainer-approved write job.\",\n \"Avoid checking out untrusted PR head code in pull_request_target.\",\n ],\n },\n \"agentci/ai-with-secrets\": {\n id: \"agentci/ai-with-secrets\",\n title: \"AI agent job has access to secrets\",\n severity: \"medium\",\n why: \"Secrets mounted into an AI-agent job can be exfiltrated if untrusted prompt content influences tool use, shell commands, or generated output. Most AI actions require a provider key, so this is a baseline exposure to review rather than a vulnerability on its own — it becomes high-risk when combined with untrusted input or write permissions (see agentci/untrusted-ai-write-token).\",\n fix: [\n \"Do not expose secrets to agent jobs that process untrusted content.\",\n \"Use short-lived scoped tokens.\",\n \"Move secret-bearing actions behind manual approval.\",\n ],\n },\n \"agentci/untrusted-input-in-prompt\": {\n id: \"agentci/untrusted-input-in-prompt\",\n title:\n \"Untrusted GitHub event content is passed into an AI prompt or command\",\n severity: \"high\",\n why: \"PR bodies, issue bodies, comments, branch names, and commit messages are attacker-controlled in common workflows and can contain prompt-injection instructions.\",\n fix: [\n \"Avoid inserting raw GitHub event text into prompts.\",\n \"Use structured extraction and length limits.\",\n \"Add prompt-injection filtering before AI execution.\",\n \"Run the agent with read-only permissions.\",\n ],\n },\n \"agentci/ai-shell-access\": {\n id: \"agentci/ai-shell-access\",\n title: \"AI agent has shell or arbitrary command access\",\n severity: \"high\",\n why: \"Shell access allows a compromised agent prompt to inspect the workspace, call network endpoints, or alter build artifacts.\",\n fix: [\n \"Disable shell tools for untrusted events.\",\n \"Run in a sandbox with no secrets.\",\n \"Restrict network and filesystem access.\",\n ],\n },\n \"agentci/broad-write-permissions\": {\n id: \"agentci/broad-write-permissions\",\n title: \"Workflow grants broad write permissions near AI usage\",\n severity: \"medium\",\n why: \"Broad write scopes increase blast radius if an AI-agent step is influenced by untrusted input.\",\n fix: [\n \"Set default permissions to read-only.\",\n \"Grant write scopes only in narrowly scoped jobs.\",\n \"Prefer job-level permissions over workflow-level write permissions.\",\n ],\n },\n \"agentci/unpinned-ai-action\": {\n id: \"agentci/unpinned-ai-action\",\n title: \"AI-related action is not pinned to a commit SHA\",\n severity: \"medium\",\n why: \"Tag-pinned third-party actions can change over time. AI-agent actions often receive privileged context, so supply-chain drift matters.\",\n fix: [\n \"Pin third-party actions to full commit SHAs.\",\n \"Review updates explicitly.\",\n \"Prefer first-party or internally mirrored actions for privileged jobs.\",\n ],\n },\n \"agentci/unsafe-checkout\": {\n id: \"agentci/unsafe-checkout\",\n title:\n \"Workflow checks out untrusted pull request head in a privileged context\",\n severity: \"high\",\n why: \"Checking out attacker-controlled code in a privileged workflow can let malicious build scripts or configuration affect the agent job.\",\n fix: [\n \"Do not checkout PR head code inside pull_request_target.\",\n \"Use read-only analysis jobs.\",\n \"Disable install/build scripts before trust is established.\",\n ],\n },\n};\n\nexport const SEVERITY_ORDER: Severity[] = [\"low\", \"medium\", \"high\", \"critical\"];\n","import { RULES } from \"./rules.js\";\nimport type { Finding, SarifLog } from \"./types.js\";\n\nexport function toSarif(findings: Finding[]): SarifLog {\n const usedRules = Object.values(RULES).filter((rule) =>\n findings.some((finding) => finding.rule_id === rule.id),\n );\n return {\n version: \"2.1.0\",\n $schema: \"https://json.schemastore.org/sarif-2.1.0.json\",\n runs: [\n {\n tool: {\n driver: {\n name: \"AgentCI Guard\",\n informationUri: \"https://github.com/David-Wu1119/agentci-guard\",\n rules: usedRules.map((rule) => ({\n id: rule.id,\n name: rule.title,\n shortDescription: { text: rule.title },\n fullDescription: { text: rule.why },\n help: {\n text: rule.fix.join(\" \"),\n markdown: rule.fix.map((fix) => `- ${fix}`).join(\"\\n\"),\n },\n defaultConfiguration: { level: sarifLevel(rule.severity) },\n })),\n },\n },\n results: findings.map((finding) => ({\n ruleId: finding.rule_id,\n level: sarifLevel(finding.severity),\n message: { text: `${finding.title}: ${finding.evidence}` },\n locations: [\n {\n physicalLocation: {\n artifactLocation: { uri: finding.file },\n region: { startLine: 1 },\n },\n },\n ],\n })),\n },\n ],\n };\n}\n\nfunction sarifLevel(severity: string): \"note\" | \"warning\" | \"error\" {\n if (severity === \"critical\" || severity === \"high\") return \"error\";\n if (severity === \"medium\") return \"warning\";\n return \"note\";\n}\n","import path from \"node:path\";\nimport fg from \"fast-glob\";\nimport YAML from \"yaml\";\nimport {\n containsSecretReference,\n containsShellAccess,\n containsUntrustedGitHubContext,\n isPinnedAction,\n looksLikeAiUsage,\n} from \"./detect.js\";\nimport { RULES, SEVERITY_ORDER } from \"./rules.js\";\nimport { loadConfig, matchesPath, parseInlineIgnores } from \"./config.js\";\nimport type {\n Finding,\n ScanOptions,\n ScanResult,\n Severity,\n WorkflowFile,\n} from \"./types.js\";\nimport fs from \"node:fs/promises\";\n\ntype WorkflowMap = Record<string, unknown>;\ntype FindingContext = {\n file: string;\n job?: string;\n step?: string;\n evidence: string;\n};\n\nexport async function scanRepository(\n root: string,\n options: Partial<ScanOptions> = {},\n): Promise<ScanResult> {\n const cwd = options.cwd ?? process.cwd();\n const scanRoot = path.resolve(cwd, root);\n const config = await loadConfig(scanRoot, options.configPath);\n const workflows = await loadWorkflowFiles(scanRoot);\n const findings = workflows\n .flatMap((workflow) => scanWorkflow(workflow, scanRoot))\n .filter(\n (finding) =>\n !config.ignore.includes(finding.rule_id) &&\n !config.ignorePaths.some((glob) => matchesPath(glob, finding.file)),\n );\n return {\n scanned_at: new Date().toISOString(),\n root: scanRoot,\n workflow_count: workflows.length,\n findings,\n summary: summarize(findings),\n };\n}\n\nexport async function loadWorkflowFiles(root: string): Promise<WorkflowFile[]> {\n const entries = await fg([\".github/workflows/*.{yml,yaml}\"], {\n cwd: root,\n dot: true,\n absolute: true,\n });\n const workflows: WorkflowFile[] = [];\n for (const file of entries.sort()) {\n const raw = await fs.readFile(file, \"utf8\");\n try {\n workflows.push({ path: file, raw, document: YAML.parse(raw) });\n } catch (error) {\n workflows.push({\n path: file,\n raw,\n document: {\n __parse_error: error instanceof Error ? error.message : String(error),\n },\n });\n }\n }\n return workflows;\n}\n\nexport function scanWorkflow(workflow: WorkflowFile, root: string): Finding[] {\n const doc = isRecord(workflow.document) ? workflow.document : {};\n const file = path.relative(root, workflow.path);\n const findings: Finding[] = [];\n\n if (\"__parse_error\" in doc) {\n findings.push(\n makeFinding(\"agentci/untrusted-input-in-prompt\", {\n file,\n evidence: `YAML parse error: ${String(doc.__parse_error)}`,\n }),\n );\n return findings;\n }\n\n const triggers = normalizeTriggers(doc.on ?? doc[\"on\"]);\n const jobs = isRecord(doc.jobs) ? doc.jobs : {};\n const workflowPermissions = normalizePermissions(doc.permissions);\n const workflowIsUntrusted = triggers.some(isUntrustedTrigger);\n const isPullRequestTarget = triggers.includes(\"pull_request_target\");\n\n for (const [jobName, rawJob] of Object.entries(jobs)) {\n if (!isRecord(rawJob)) continue;\n const jobPermissions = {\n ...workflowPermissions,\n ...normalizePermissions(rawJob.permissions),\n };\n const jobText = JSON.stringify(rawJob);\n const steps = Array.isArray(rawJob.steps) ? rawJob.steps : [];\n const jobUsesAi = looksLikeAiUsage(jobText);\n const jobHasWrite = hasWritePermission(jobPermissions);\n const jobHasSecrets = containsSecretReference(jobText);\n // Untrusted event content inside an `if:` condition is a guard (e.g.\n // `contains(github.event.comment.body, '@claude')`), not a value that\n // reaches the agent. Only treat it as a sink when it appears elsewhere.\n const jobHasUntrusted = containsUntrustedGitHubContext(\n JSON.stringify(stripGuards(rawJob)),\n );\n\n if (jobUsesAi && isPullRequestTarget) {\n findings.push(\n makeFinding(\"agentci/pull-request-target-ai\", {\n file,\n job: jobName,\n evidence: \"on: pull_request_target + AI usage\",\n }),\n );\n }\n if (jobUsesAi && workflowIsUntrusted && jobHasWrite && jobHasUntrusted) {\n findings.push(\n makeFinding(\"agentci/untrusted-ai-write-token\", {\n file,\n job: jobName,\n evidence:\n \"untrusted trigger + AI usage + write permissions + untrusted GitHub event context\",\n }),\n );\n }\n if (jobUsesAi && jobHasSecrets) {\n findings.push(\n makeFinding(\"agentci/ai-with-secrets\", {\n file,\n job: jobName,\n evidence:\n \"AI job references secrets or token-like environment variables\",\n }),\n );\n }\n if (jobUsesAi && jobHasWrite) {\n findings.push(\n makeFinding(\"agentci/broad-write-permissions\", {\n file,\n job: jobName,\n evidence: `permissions: ${JSON.stringify(jobPermissions)}`,\n }),\n );\n }\n\n for (const [index, rawStep] of steps.entries()) {\n if (!isRecord(rawStep)) continue;\n const stepName =\n typeof rawStep.name === \"string\" ? rawStep.name : `step ${index + 1}`;\n const stepText = JSON.stringify(rawStep);\n const stepUses = typeof rawStep.uses === \"string\" ? rawStep.uses : \"\";\n const stepRun = typeof rawStep.run === \"string\" ? rawStep.run : \"\";\n const stepUsesAi = looksLikeAiUsage(stepText);\n const stepUntrustedText = JSON.stringify(stripGuards(rawStep));\n\n if (stepUsesAi && containsUntrustedGitHubContext(stepUntrustedText)) {\n findings.push(\n makeFinding(\"agentci/untrusted-input-in-prompt\", {\n file,\n job: jobName,\n step: stepName,\n evidence: shrink(stepText),\n }),\n );\n }\n if (\n stepUsesAi &&\n (containsShellAccess(stepRun) || containsShellAccess(stepText))\n ) {\n findings.push(\n makeFinding(\"agentci/ai-shell-access\", {\n file,\n job: jobName,\n step: stepName,\n evidence: shrink(stepText),\n }),\n );\n }\n if (\n stepUsesAi &&\n stepUses &&\n !isPinnedAction(stepUses) &&\n !isLocalAction(stepUses)\n ) {\n findings.push(\n makeFinding(\"agentci/unpinned-ai-action\", {\n file,\n job: jobName,\n step: stepName,\n evidence: `uses: ${stepUses}`,\n }),\n );\n }\n if (\n isPullRequestTarget &&\n stepUses.includes(\"actions/checkout\") &&\n stepText.includes(\"github.event.pull_request.head\")\n ) {\n findings.push(\n makeFinding(\"agentci/unsafe-checkout\", {\n file,\n job: jobName,\n step: stepName,\n evidence: shrink(stepText),\n }),\n );\n }\n }\n }\n\n const ignores = parseInlineIgnores(workflow.raw);\n const visible = ignores.all\n ? []\n : findings.filter((finding) => !ignores.rules.has(finding.rule_id));\n return dedupe(visible);\n}\n\nexport function hasFindingAtOrAbove(\n findings: Finding[],\n severity: Severity,\n): boolean {\n return findings.some(\n (finding) =>\n SEVERITY_ORDER.indexOf(finding.severity) >=\n SEVERITY_ORDER.indexOf(severity),\n );\n}\n\nfunction makeFinding(ruleId: string, context: FindingContext): Finding {\n const rule = RULES[ruleId];\n if (!rule) throw new Error(`Unknown rule: ${ruleId}`);\n const id = `${ruleId}:${context.file}:${context.job ?? \"\"}:${context.step ?? \"\"}`;\n return {\n id,\n rule_id: rule.id,\n title: rule.title,\n severity: rule.severity,\n file: context.file,\n job: context.job,\n step: context.step,\n message: rule.title,\n why: rule.why,\n fix: rule.fix,\n evidence: context.evidence,\n };\n}\n\nfunction normalizeTriggers(raw: unknown): string[] {\n if (typeof raw === \"string\") return [raw];\n if (Array.isArray(raw))\n return raw.filter((item): item is string => typeof item === \"string\");\n if (isRecord(raw)) return Object.keys(raw);\n return [];\n}\n\nfunction isUntrustedTrigger(trigger: string): boolean {\n return [\n \"pull_request\",\n \"pull_request_target\",\n \"issue_comment\",\n \"issues\",\n \"discussion\",\n \"discussion_comment\",\n \"workflow_run\",\n ].includes(trigger);\n}\n\nfunction normalizePermissions(raw: unknown): Record<string, string> {\n if (typeof raw === \"string\") return { contents: raw };\n if (!isRecord(raw)) return {};\n return Object.fromEntries(\n Object.entries(raw).filter(\n (entry): entry is [string, string] => typeof entry[1] === \"string\",\n ),\n );\n}\n\n// Write scopes that actually let an agent alter the repository — code,\n// releases, PRs, issues, packages, deployments. Scopes like `id-token`\n// (OIDC), `actions`, `checks`, `statuses`, `pages`, and `security-events`\n// grant `write` but do not let a prompt-injected agent modify the repo, so\n// they must not trip the AI-write-token / broad-write rules.\nconst SENSITIVE_WRITE_SCOPES = new Set([\n \"contents\",\n \"pull-requests\",\n \"issues\",\n \"packages\",\n \"deployments\",\n]);\n\nfunction hasWritePermission(permissions: Record<string, string>): boolean {\n return Object.entries(permissions).some(([scope, level]) => {\n if (level !== \"write\" && level !== \"write-all\") return false;\n // `permissions: write-all` normalizes to contents:write-all (all scopes).\n return level === \"write-all\" || SENSITIVE_WRITE_SCOPES.has(scope);\n });\n}\n\n/**\n * Deep-clone a workflow node with every `if:` condition removed. Untrusted\n * event references inside an `if:` are guards, not values that reach the agent.\n */\nfunction stripGuards(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(stripGuards);\n if (isRecord(value)) {\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(value)) {\n if (key === \"if\") continue;\n out[key] = stripGuards(val);\n }\n return out;\n }\n return value;\n}\n\nfunction isRecord(value: unknown): value is WorkflowMap {\n return Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction isLocalAction(uses: string): boolean {\n return uses.startsWith(\"./\") || uses.startsWith(\"docker://\");\n}\n\nfunction shrink(value: string): string {\n return value.length > 500 ? `${value.slice(0, 500)}...` : value;\n}\n\nfunction dedupe(findings: Finding[]): Finding[] {\n const seen = new Set<string>();\n return findings.filter((finding) => {\n const key = `${finding.rule_id}:${finding.file}:${finding.job ?? \"\"}:${finding.step ?? \"\"}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nfunction summarize(findings: Finding[]): Record<Severity, number> {\n return {\n low: findings.filter((finding) => finding.severity === \"low\").length,\n medium: findings.filter((finding) => finding.severity === \"medium\").length,\n high: findings.filter((finding) => finding.severity === \"high\").length,\n critical: findings.filter((finding) => finding.severity === \"critical\")\n .length,\n };\n}\n"],"mappings":"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","names":["path","fs","path"]}
|
package/docs/demo.svg
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="468" viewBox="0 0 760 468" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15">
|
|
2
|
+
<defs>
|
|
3
|
+
<style>
|
|
4
|
+
.bg { fill: #282a36; }
|
|
5
|
+
.bar { fill: #21222c; }
|
|
6
|
+
.t { fill: #f8f8f2; }
|
|
7
|
+
.dim { fill: #6272a4; }
|
|
8
|
+
.green { fill: #50fa7b; }
|
|
9
|
+
.cyan { fill: #8be9fd; }
|
|
10
|
+
.crit { fill: #ff5555; font-weight: 700; }
|
|
11
|
+
.high { fill: #ffb86c; font-weight: 700; }
|
|
12
|
+
.med { fill: #f1fa8c; font-weight: 700; }
|
|
13
|
+
.rule { fill: #f8f8f2; }
|
|
14
|
+
</style>
|
|
15
|
+
</defs>
|
|
16
|
+
|
|
17
|
+
<rect class="bg" x="0" y="0" width="760" height="468" rx="10"/>
|
|
18
|
+
<rect class="bar" x="0" y="0" width="760" height="34" rx="10"/>
|
|
19
|
+
<rect class="bar" x="0" y="22" width="760" height="12"/>
|
|
20
|
+
<circle cx="20" cy="17" r="6" fill="#ff5f56"/>
|
|
21
|
+
<circle cx="40" cy="17" r="6" fill="#ffbd2e"/>
|
|
22
|
+
<circle cx="60" cy="17" r="6" fill="#27c93f"/>
|
|
23
|
+
<text x="380" y="22" text-anchor="middle" class="dim">agentci-guard</text>
|
|
24
|
+
|
|
25
|
+
<text x="24" y="66"><tspan class="green">$</tspan> <tspan class="t">agentci scan .</tspan></text>
|
|
26
|
+
|
|
27
|
+
<text x="24" y="98" class="cyan">AgentCI Guard scan</text>
|
|
28
|
+
<text x="24" y="120" class="t">Workflows: <tspan class="t">1</tspan> Findings: <tspan class="t">9</tspan></text>
|
|
29
|
+
<text x="24" y="142" class="dim">Summary: <tspan class="crit">critical=2</tspan> <tspan class="high">high=4</tspan> <tspan class="med">medium=3</tspan> low=0</text>
|
|
30
|
+
|
|
31
|
+
<text x="24" y="180"><tspan class="crit">[CRITICAL]</tspan> <tspan class="rule">agentci/pull-request-target-ai</tspan></text>
|
|
32
|
+
<text x="44" y="200" class="dim">.github/workflows/ai-agent.yml · job: claude</text>
|
|
33
|
+
|
|
34
|
+
<text x="24" y="232"><tspan class="crit">[CRITICAL]</tspan> <tspan class="rule">agentci/untrusted-ai-write-token</tspan></text>
|
|
35
|
+
<text x="44" y="252" class="dim">untrusted trigger + AI + write token + untrusted event content</text>
|
|
36
|
+
|
|
37
|
+
<text x="24" y="284"><tspan class="high">[HIGH]</tspan> <tspan class="rule">agentci/ai-shell-access</tspan></text>
|
|
38
|
+
<text x="44" y="304" class="dim">AI step can run shell / arbitrary commands</text>
|
|
39
|
+
|
|
40
|
+
<text x="24" y="336"><tspan class="high">[HIGH]</tspan> <tspan class="rule">agentci/untrusted-input-in-prompt</tspan></text>
|
|
41
|
+
<text x="44" y="356" class="dim">github.event.pull_request.body passed into the prompt</text>
|
|
42
|
+
|
|
43
|
+
<text x="24" y="388"><tspan class="med">[MEDIUM]</tspan> <tspan class="rule">agentci/unpinned-ai-action</tspan></text>
|
|
44
|
+
<text x="44" y="408" class="dim">uses: anthropics/claude-code-action@v1 (not SHA-pinned)</text>
|
|
45
|
+
|
|
46
|
+
<text x="24" y="444"><tspan class="dim">exit code</tspan> <tspan class="high">2</tspan> <tspan class="dim">— findings at or above --fail-on=high</tspan></text>
|
|
47
|
+
</svg>
|
package/docs/demo.tape
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Canonical demo recording for AgentCI Guard.
|
|
2
|
+
#
|
|
3
|
+
# Render the GIF with:
|
|
4
|
+
# brew install vhs # https://github.com/charmbracelet/vhs
|
|
5
|
+
# vhs docs/demo.tape # writes docs/demo.gif
|
|
6
|
+
#
|
|
7
|
+
# Run from the repo root after `pnpm build` so dist/ exists.
|
|
8
|
+
|
|
9
|
+
Output docs/demo.gif
|
|
10
|
+
|
|
11
|
+
Set Shell bash
|
|
12
|
+
Set FontSize 16
|
|
13
|
+
Set Width 1100
|
|
14
|
+
Set Height 780
|
|
15
|
+
Set Padding 20
|
|
16
|
+
Set Theme "Dracula"
|
|
17
|
+
|
|
18
|
+
# Alias the local build to the published command name (hidden from the recording).
|
|
19
|
+
Hide
|
|
20
|
+
Type "alias agentci='node dist/cli.js'"
|
|
21
|
+
Enter
|
|
22
|
+
Type "clear"
|
|
23
|
+
Enter
|
|
24
|
+
Show
|
|
25
|
+
|
|
26
|
+
Type "agentci scan examples/vulnerable"
|
|
27
|
+
Sleep 800ms
|
|
28
|
+
Enter
|
|
29
|
+
Sleep 4s
|
|
30
|
+
|
|
31
|
+
Type "agentci explain agentci/untrusted-ai-write-token"
|
|
32
|
+
Sleep 800ms
|
|
33
|
+
Enter
|
|
34
|
+
Sleep 4s
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Real-World Findings
|
|
2
|
+
|
|
3
|
+
A scan of public GitHub repositories that run AI coding agents in CI, to (a)
|
|
4
|
+
validate AgentCI Guard against real workflows and (b) get an honest read on how
|
|
5
|
+
common the risky patterns actually are.
|
|
6
|
+
|
|
7
|
+
## Method
|
|
8
|
+
|
|
9
|
+
- **Corpus:** 75 public repositories whose `.github/workflows/*.yml` reference
|
|
10
|
+
`anthropics/claude-code-action`, discovered via GitHub code search.
|
|
11
|
+
- **Tool:** `agentci scan` at the commit this document ships in.
|
|
12
|
+
- **What's counted:** findings at job/step granularity, aggregated by severity
|
|
13
|
+
and rule. No repository is named here (see "Responsible use").
|
|
14
|
+
|
|
15
|
+
## Results
|
|
16
|
+
|
|
17
|
+
| Severity | Findings |
|
|
18
|
+
| --- | ---: |
|
|
19
|
+
| Critical | 13 |
|
|
20
|
+
| High | 69 |
|
|
21
|
+
| Medium | 225 |
|
|
22
|
+
| Low | 0 |
|
|
23
|
+
|
|
24
|
+
Repositories by their **worst** finding (of 75 scanned):
|
|
25
|
+
|
|
26
|
+
| Worst severity | Repos | Share |
|
|
27
|
+
| --- | ---: | ---: |
|
|
28
|
+
| Critical | 8 | 11% |
|
|
29
|
+
| High | 32 | 43% |
|
|
30
|
+
| Medium | 35 | 47% |
|
|
31
|
+
| Clean | 0 | 0% |
|
|
32
|
+
|
|
33
|
+
By rule:
|
|
34
|
+
|
|
35
|
+
| Count | Rule |
|
|
36
|
+
| ---: | --- |
|
|
37
|
+
| 90 | `agentci/unpinned-ai-action` |
|
|
38
|
+
| 82 | `agentci/ai-with-secrets` |
|
|
39
|
+
| 57 | `agentci/ai-shell-access` |
|
|
40
|
+
| 53 | `agentci/broad-write-permissions` |
|
|
41
|
+
| 11 | `agentci/untrusted-input-in-prompt` |
|
|
42
|
+
| 11 | `agentci/untrusted-ai-write-token` |
|
|
43
|
+
| 2 | `agentci/pull-request-target-ai` |
|
|
44
|
+
| 1 | `agentci/unsafe-checkout` |
|
|
45
|
+
|
|
46
|
+
**Read this as:** the *medium* findings (unpinned actions, a provider key
|
|
47
|
+
present in the job) are near-universal hygiene items, not alarms. The signal
|
|
48
|
+
worth acting on is the small **critical** set — an AI agent with repo-write
|
|
49
|
+
scope on an untrusted trigger, with untrusted event content reaching it.
|
|
50
|
+
|
|
51
|
+
## The tool found its own false positives first
|
|
52
|
+
|
|
53
|
+
The first pass reported **59 criticals**. Auditing those against real,
|
|
54
|
+
well-configured repositories surfaced three over-firing patterns, each since
|
|
55
|
+
fixed:
|
|
56
|
+
|
|
57
|
+
1. **`id-token: write` treated as repo-write.** OIDC token minting can't modify
|
|
58
|
+
a repo; counting it inflated the write-token and broad-write rules. Now only
|
|
59
|
+
`contents` / `pull-requests` / `issues` / `packages` / `deployments` count.
|
|
60
|
+
2. **Untrusted content in an `if:` guard treated as a prompt sink.** A
|
|
61
|
+
`contains(github.event.comment.body, '@claude')` gate is a guard, not a value
|
|
62
|
+
that reaches the agent. `if:` conditions are now excluded from sink detection.
|
|
63
|
+
3. **"AI job has a secret" rated high.** Almost every AI action needs a provider
|
|
64
|
+
key, so this is a baseline exposure to review, not a vulnerability on its own.
|
|
65
|
+
Recalibrated to medium.
|
|
66
|
+
|
|
67
|
+
After these fixes: **59 → 13 criticals.** The remaining drop in the headline
|
|
68
|
+
"share of repos affected" (100% → 53% high-or-critical) is the false alarms
|
|
69
|
+
leaving.
|
|
70
|
+
|
|
71
|
+
## Responsible use
|
|
72
|
+
|
|
73
|
+
AgentCI Guard flags **patterns in workflow YAML**, not proven exploits. Several
|
|
74
|
+
repositories that match a critical pattern have author-side mitigations a static
|
|
75
|
+
scanner cannot see — output allowlists, `author_association` gates, fork checks,
|
|
76
|
+
or SHA-pinned actions. Treat a finding as "review this," not "this is hacked."
|
|
77
|
+
|
|
78
|
+
For that reason, this document reports only aggregates. Genuinely exploitable
|
|
79
|
+
cases should be reported privately to the maintainer, not published.
|
package/docs/rules.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Rules
|
|
2
|
+
|
|
3
|
+
## `agentci/untrusted-ai-write-token`
|
|
4
|
+
|
|
5
|
+
Untrusted trigger content reaches an AI agent with repository write permissions.
|
|
6
|
+
|
|
7
|
+
## `agentci/pull-request-target-ai`
|
|
8
|
+
|
|
9
|
+
An AI agent runs on `pull_request_target`.
|
|
10
|
+
|
|
11
|
+
## `agentci/ai-with-secrets`
|
|
12
|
+
|
|
13
|
+
An AI-agent job references secrets or token-like environment variables.
|
|
14
|
+
|
|
15
|
+
## `agentci/untrusted-input-in-prompt`
|
|
16
|
+
|
|
17
|
+
Raw PR, issue, comment, review, branch, or commit text is passed into an AI prompt or shell command.
|
|
18
|
+
|
|
19
|
+
## `agentci/ai-shell-access`
|
|
20
|
+
|
|
21
|
+
An AI-agent job has shell or arbitrary command access.
|
|
22
|
+
|
|
23
|
+
## `agentci/broad-write-permissions`
|
|
24
|
+
|
|
25
|
+
Workflow or job permissions grant write scopes near AI usage.
|
|
26
|
+
|
|
27
|
+
## `agentci/unpinned-ai-action`
|
|
28
|
+
|
|
29
|
+
An AI-related third-party action is not pinned to a full commit SHA.
|
|
30
|
+
|
|
31
|
+
## `agentci/unsafe-checkout`
|
|
32
|
+
|
|
33
|
+
A privileged workflow checks out untrusted PR head code.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Threat Model
|
|
2
|
+
|
|
3
|
+
AgentCI Guard scans GitHub Actions workflows for static patterns that make AI coding-agent jobs dangerous.
|
|
4
|
+
|
|
5
|
+
## In Scope
|
|
6
|
+
|
|
7
|
+
- GitHub Actions workflow YAML under `.github/workflows/`
|
|
8
|
+
- AI action and AI CLI usage
|
|
9
|
+
- Untrusted GitHub event fields passed into prompts, environment variables, or shell commands
|
|
10
|
+
- Privileged triggers such as `pull_request_target`
|
|
11
|
+
- Repository write permissions
|
|
12
|
+
- Secret and token exposure
|
|
13
|
+
- Shell access and untrusted checkout patterns
|
|
14
|
+
- SARIF output for GitHub code scanning
|
|
15
|
+
|
|
16
|
+
## Out of Scope
|
|
17
|
+
|
|
18
|
+
- Runtime sandboxing
|
|
19
|
+
- Full taint tracking through arbitrary scripts
|
|
20
|
+
- GitLab, CircleCI, Buildkite, or other CI systems
|
|
21
|
+
- Proving that an LLM did or did not follow a prompt injection
|
|
22
|
+
- Secret scanning inside repository contents
|
|
23
|
+
- Dynamic analysis of downloaded third-party actions
|
|
24
|
+
|
|
25
|
+
## Failure Modes
|
|
26
|
+
|
|
27
|
+
- YAML can call external scripts that hide AI usage from static analysis.
|
|
28
|
+
- Wrapper actions can invoke AI agents without obvious names.
|
|
29
|
+
- A workflow can be safe despite matching a conservative high-risk pattern.
|
|
30
|
+
- A workflow can be unsafe in ways not represented in YAML.
|
|
31
|
+
|
|
32
|
+
Treat AgentCI Guard as a high-signal review gate, not a formal proof.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: hardened-ai-agent
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
read-only-analysis:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- name: Build trusted summary
|
|
14
|
+
run: |
|
|
15
|
+
git diff --name-only origin/main...HEAD > changed-files.txt
|
|
16
|
+
- name: Run read-only agent on trusted summary
|
|
17
|
+
run: |
|
|
18
|
+
echo "Agent receives changed-files.txt, not raw PR body/comment text"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: vulnerable-ai-agent
|
|
2
|
+
on:
|
|
3
|
+
pull_request_target:
|
|
4
|
+
types: [opened, synchronize]
|
|
5
|
+
issue_comment:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
pull-requests: write
|
|
10
|
+
issues: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
claude:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
19
|
+
- name: Run Claude Code
|
|
20
|
+
uses: anthropics/claude-code-action@v1
|
|
21
|
+
env:
|
|
22
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
23
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
24
|
+
with:
|
|
25
|
+
prompt: |
|
|
26
|
+
User request:
|
|
27
|
+
${{ github.event.comment.body }}
|
|
28
|
+
- name: Shell agent fallback
|
|
29
|
+
run: npx claude-code "${{ github.event.pull_request.body }}"
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentci-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI and GitHub Action that detects unsafe AI coding-agent usage in CI/CD workflows.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentci": "dist/cli.js",
|
|
8
|
+
"agentci-guard": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"action.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"SECURITY.md",
|
|
22
|
+
"docs",
|
|
23
|
+
"examples"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
28
|
+
"format:check": "prettier \"**/*.{md,json,yml,yaml,ts}\" --check",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"prepare": "npm run build",
|
|
32
|
+
"prepublishOnly": "npm run check",
|
|
33
|
+
"agentci": "tsx src/cli.ts"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"github-actions",
|
|
37
|
+
"ai-agent",
|
|
38
|
+
"ci-security",
|
|
39
|
+
"prompt-injection",
|
|
40
|
+
"supply-chain-security",
|
|
41
|
+
"sarif",
|
|
42
|
+
"llm-security"
|
|
43
|
+
],
|
|
44
|
+
"homepage": "https://github.com/David-Wu1119/agentci-guard#readme",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/David-Wu1119/agentci-guard.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/David-Wu1119/agentci-guard/issues"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20.18.0"
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "pnpm@10.11.0",
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"commander": "^14.0.2",
|
|
59
|
+
"fast-glob": "^3.3.3",
|
|
60
|
+
"picocolors": "^1.1.1",
|
|
61
|
+
"yaml": "^2.8.2"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^22.19.3",
|
|
65
|
+
"prettier": "^3.8.3",
|
|
66
|
+
"tsup": "^8.5.1",
|
|
67
|
+
"tsx": "^4.21.0",
|
|
68
|
+
"typescript": "^5.9.3",
|
|
69
|
+
"vitest": "^4.0.15"
|
|
70
|
+
}
|
|
71
|
+
}
|