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.
@@ -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
+ }