Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agentpreflightignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Deliberately unsafe regression fixture. It is scanned directly in tests and demos.
test/fixtures/unsafe-repo
15 changes: 15 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: Bug report
about: Report a reproducible false positive, false negative, or runtime issue
labels: bug
---

## What happened?

## Minimal safe reproduction

Do not include credentials, private code, or destructive payloads.

## Expected behavior

## Environment
11 changes: 11 additions & 0 deletions .github/ISSUE_TEMPLATE/rule_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: Rule request
about: Propose a new detection or remediation rule
labels: rule
---

## Threat pattern

## Why existing rules do not cover it

## Safe example and expected finding
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: ${{ matrix.node-version }}
- run: npm test
- run: npm run lint
- run: node src/cli.js scan test/fixtures/safe-repo --fail-on low
- uses: ./
with:
mode: all
fail-on: critical
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changelog

## 0.2.0

- Added scoped discovery for agent guidance, MCP configuration, scripts, package manifests, and GitHub Actions workflows.
- Added changed-files mode, severity thresholds, stable finding IDs, inline reviewed suppressions, JSON, and SARIF output.
- Added the `K14-coder/agent-preflight` composite GitHub Action.
- Added nine high-signal rules and a deliberately unsafe fixture repository.
3 changes: 3 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Code of Conduct

Be respectful, specific, and security-conscious. Do not post exploit payloads, credentials, private repositories, or personal information in issues, discussions, or pull requests. Report vulnerabilities through the process in [SECURITY.md](SECURITY.md).
144 changes: 123 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,149 @@
# agent-preflight

> Scan a repository for risky instructions before giving it to an AI coding agent.
> Stop risky agent instructions before they reach Codex, Claude Code, Cursor, an MCP host, or a CI runner.

[![CI](https://github.com/K14-coder/agent-preflight/actions/workflows/ci.yml/badge.svg)](https://github.com/K14-coder/agent-preflight/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Local only](https://img.shields.io/badge/privacy-local--only-0f766e)](#privacy-model)

AI coding tools read instructions and execute workflows with the permissions you give them. `agent-preflight` is a local-first security gate that scans the files most likely to influence that behavior: agent guidance, MCP configuration, package scripts, installers, and GitHub Actions workflows.

It never executes an MCP server, evaluates a script, uploads repository content, or requires an API key.

## The 30-second setup

Add a pull-request gate to your repository:

```yaml
name: Agent preflight
on:
pull_request:
paths:
- "**/*.md"
- "**/*.json"
- "**/*.yml"
- "**/*.yaml"
- "**/*.sh"

permissions:
contents: read

jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: K14-coder/agent-preflight@v0.2.0
with:
mode: changed
base: ${{ github.event.pull_request.base.sha }}
fail-on: high
```

The action fails on new high- or critical-severity findings and exposes `score` and `findings` as step outputs.

`agent-preflight` is a local, zero-dependency CLI for a simple question: *is this repository asking my agent to do something surprising?* It looks for common prompt-injection, credential-discovery, destructive-command, encoded-execution, and symbolic-link patterns before an agent receives broad filesystem or shell access.
## What a finding looks like

```text
CRITICAL APF002 AGENTS.md:4:6
Remote content is piped directly into a shell.
Fix: Download, inspect, checksum, and run a pinned artifact instead of piping remote content to a shell.
```

Try the deliberately unsafe demo repository from a checkout:

```bash
git clone https://github.com/K14-coder/agent-preflight.git
cd agent-preflight
node src/cli.js /path/to/untrusted-repository --json
npm test
npm run demo
```

It exits with code `2` when it finds a high-severity pattern, which makes it usable in pre-commit hooks and CI. Once published to npm, it can be invoked with `npx agent-preflight`.
## Scan modes

## What it checks
```bash
# All agent-facing surfaces in a repository
node src/cli.js scan /path/to/repository --fail-on high

# Only files changed from a reviewed base
node src/cli.js scan . --mode changed --base origin/main --fail-on high

# Integrate with another tool or upload results to GitHub code scanning
node src/cli.js scan . --format sarif --output agent-preflight.sarif
```

To upload SARIF in GitHub Actions:

```yaml
- run: node src/cli.js scan . --format sarif --output agent-preflight.sarif --fail-on none
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: agent-preflight.sarif
```

- Downloads piped into a shell
- Encoded payloads that appear to execute
- Forced recursive deletion
- Attempts to discover common credential locations
- Attempts to direct agents around earlier safeguards
- Symbolic links that deserve manual review
## What it scans

This is a lightweight heuristic, not a security guarantee. Review findings and run unknown code in an isolated environment.
By default, the scanner limits itself to agent-facing surfaces so normal application code does not create noise:

For an intentional fixture or reviewed false positive, append `agent-preflight: allow` on the same line. Keep suppressions rare and explain them in review.
- `AGENTS.md`, `CLAUDE.md`, `SKILL.md`, `README.md`, and other Markdown guidance
- MCP JSON/YAML configuration
- `package.json` and installer or shell scripts
- GitHub Actions workflow files

## Development
Use `--all-files` when auditing a repository more broadly.

## Built-in checks

| Rule | Risk | Default severity |
| --- | --- | --- |
| `APF001` | Instruction override | High |
| `APF002` | Remote content piped to a shell | Critical |
| `APF003` | Encoded payload execution | Critical |
| `APF004` | Recursive forced deletion | High |
| `APF005` | Credential discovery | High |
| `APF006` | Potential credential exfiltration | Critical |
| `APF007` | Dynamic evaluation of external content | Medium |
| `APF008` | MCP shell launcher | Medium |
| `APF009` | Unpinned GitHub Action | Medium |
| `APF010` | Write-capable workflow token | Medium |
| `APF011` | Hidden Unicode control character | High |
| `APF012` | Remote instruction loading | High |

## Policy and suppressions

Choose the policy that fits the environment:

```bash
npm test
--fail-on critical # only block the highest-risk findings
--fail-on high # default
--fail-on medium # use for hardening programs
--fail-on none # report only
```

For a reviewed, intentional exception, add a narrow source-line suppression:

```text
agent-preflight: allow=APF009
```

## Privacy
Suppressions are intentionally local and visible in code review. A clean scan is not a security guarantee; review any finding and run untrusted repositories in an isolated environment.

The CLI never sends repository content, filenames, telemetry, or diagnostics over the network. It only reads the directory you pass to it.
To omit an intentional fixture or generated directory, add a repository-relative path to `.agentpreflightignore`. Directory entries apply to their contents; keep ignores narrow and explain them in review.

## Contributing
## Privacy model

Issues and focused pull requests are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) and report vulnerabilities through [SECURITY.md](SECURITY.md).
`agent-preflight` is offline by design. It makes no network requests, collects no telemetry, and reads only the repository you explicitly scan. It does not start MCP servers or execute detected commands.

## Keywords
## Roadmap

- Baseline files and finding-delta reports for large existing repositories
- Reusable policy packs for Codex, Claude Code, Cursor, and MCP deployments
- Signed npm package and GitHub release automation
- More syntax-aware rules with focused false-positive regression fixtures

## Contributing

AI agent security, coding agent safety, prompt injection detection, Claude Code security, Codex security, Cursor security, repository supply-chain security.
Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md). Rule proposals need a safe reproduction fixture and an expected finding ID.

## License

Expand Down
37 changes: 37 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: "Agent Preflight"
description: "Block risky AI-agent instructions, MCP configurations, scripts, and workflows before they merge."
author: "agent-preflight contributors"
branding:
icon: shield
color: purple
inputs:
mode:
description: "Scan all agent-facing files or only files changed from the supplied base ref."
required: false
default: all
base:
description: "Git ref or commit SHA used when mode is changed."
required: false
default: ""
fail-on:
description: "Minimum severity that fails the action: critical, high, medium, low, or none."
required: false
default: high
format:
description: "Output format: text, json, or sarif."
required: false
default: text
outputs:
score:
description: "Risk score from 0 to 100."
findings:
description: "Number of findings."
runs:
using: composite
steps:
- id: scan
shell: bash
run: |
args=(scan "$GITHUB_WORKSPACE" --mode "${{ inputs.mode }}" --fail-on "${{ inputs.fail-on }}" --format "${{ inputs.format }}")
if [ -n "${{ inputs.base }}" ]; then args+=(--base "${{ inputs.base }}"); fi
node "$GITHUB_ACTION_PATH/src/cli.js" "${args[@]}"
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "agent-preflight",
"version": "0.1.0",
"description": "Scan a repository for risky instructions before handing it to an AI coding agent.",
"version": "0.2.0",
"description": "A local-first security gate for AI coding agent instructions, MCP configs, scripts, and workflows.",
"type": "module",
"bin": { "agent-preflight": "./src/cli.js" },
"scripts": { "test": "node --test", "lint": "node --check src/*.js" },
"keywords": ["ai-agents", "agent-security", "prompt-injection", "coding-agents", "repository-security", "claude-code", "codex", "cursor", "supply-chain-security"],
"scripts": { "test": "node --test", "lint": "node --check src/*.js", "demo": "node src/cli.js scan test/fixtures/unsafe-repo --fail-on none" },
"keywords": ["ai-agents", "agent-security", "prompt-injection", "coding-agents", "repository-security", "claude-code", "codex", "cursor", "mcp-security", "github-actions", "supply-chain-security"],
"files": ["src", "action.yml", "README.md", "LICENSE"],
"engines": { "node": ">=20" },
"license": "MIT"
}
66 changes: 54 additions & 12 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { scanRepository } from "./scan.js";
import { changedFiles } from "./git.js";
import { textReport, sarifReport } from "./reporters.js";
import { scanRepository, shouldFail } from "./scan.js";

const args = process.argv.slice(2);
const target = args.find((argument) => !argument.startsWith("-")) || ".";
const result = scanRepository(path.resolve(target));
if (args.includes("--json")) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else if (result.findings.length === 0) {
console.log("agent-preflight: no known high-risk repository instructions found.");
} else {
console.log(`agent-preflight: risk score ${result.score}/100`);
for (const finding of result.findings) console.log(`${finding.severity.toUpperCase()} ${finding.file}:${finding.line} ${finding.rule} - ${finding.message}`);
const HELP = `agent-preflight scan [path] [options]

Options:
--mode all|changed Scan all agent-facing files or only files changed from --base
--base <ref> Git ref used by changed mode (for example origin/main)
--all-files Scan every text file, not only agent-facing surfaces
--format text|json|sarif Output format (default: text)
--output <file> Write JSON or SARIF output to a file
--fail-on <severity> critical, high, medium, low, or none (default: high)
--help Show this help

Suppress a reviewed finding on its source line with: agent-preflight: allow=APF001`;

function parse(argv) {
const options = { mode: "all", format: "text", failOn: "high", allFiles: false };
const positional = [];
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--mode") options.mode = argv[++index];
else if (argument === "--base") options.base = argv[++index];
else if (argument === "--format" || argument === "--json") options.format = argument === "--json" ? "json" : argv[++index];
else if (argument === "--output") options.output = argv[++index];
else if (argument === "--fail-on") options.failOn = argv[++index];
else if (argument === "--all-files") options.allFiles = true;
else if (argument === "--help" || argument === "-h") options.help = true;
else if (!argument.startsWith("-")) positional.push(argument);
else throw new Error(`Unknown option: ${argument}`);
}
return { target: positional[0] || ".", options };
}
if (result.findings.some((finding) => finding.severity === "high")) process.exitCode = 2;

function main() {
const command = process.argv[2] === "scan" ? "scan" : "scan";
const start = command === "scan" && process.argv[2] === "scan" ? 3 : 2;
const { target, options } = parse(process.argv.slice(start));
if (options.help) return console.log(HELP);
if (!["all", "changed"].includes(options.mode)) throw new Error("--mode must be all or changed");
if (!["text", "json", "sarif"].includes(options.format)) throw new Error("--format must be text, json, or sarif");
const root = path.resolve(target);
const changed = options.mode === "changed" ? changedFiles(root, options.base) : null;
if (options.mode === "changed" && changed === null) throw new Error("Could not determine changed files. Supply --base inside a Git repository.");
const result = scanRepository(root, { changedFiles: changed || undefined, allFiles: options.allFiles });
const payload = options.format === "sarif" ? sarifReport(result) : result;
const output = options.format === "text" ? textReport(result) : `${JSON.stringify(payload, null, 2)}\n`;
if (options.output) fs.writeFileSync(path.resolve(options.output), output);
else process.stdout.write(`${output.endsWith("\n") ? output : `${output}\n`}`);
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `score=${result.score}\nfindings=${result.findings.length}\n`);
if (shouldFail(result, options.failOn)) process.exitCode = 2;
}

try { main(); } catch (error) { console.error(`agent-preflight: ${error.message}`); process.exitCode = 1; }
11 changes: 11 additions & 0 deletions src/git.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { execFileSync } from "node:child_process";

export function changedFiles(root, base) {
const range = base ? `${base}...HEAD` : "HEAD~1...HEAD";
try {
return execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", range], { cwd: root, encoding: "utf8" })
.split("\n").map((file) => file.trim()).filter(Boolean);
} catch {
return null;
}
}
20 changes: 20 additions & 0 deletions src/reporters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const LEVELS = { critical: "error", high: "error", medium: "warning", low: "note" };

export function textReport(result) {
const count = result.findings.length;
const header = `agent-preflight scanned ${result.scannedFiles.length} agent-facing files | risk score ${result.score}/100 | ${count} finding${count === 1 ? "" : "s"}`;
if (!count) return `${header}\nNo findings at the selected policy level.`;
return [header, "", ...result.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.ruleId} ${finding.file}:${finding.line}:${finding.column}\n ${finding.message}\n Fix: ${finding.remediation}`)].join("\n");
}

export function sarifReport(result) {
const rules = new Map();
for (const finding of result.findings) {
if (!rules.has(finding.ruleId)) rules.set(finding.ruleId, { id: finding.ruleId, name: finding.title, shortDescription: { text: finding.message }, help: { text: finding.remediation }, defaultConfiguration: { level: LEVELS[finding.severity] } });
}
return {
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
version: "2.1.0",
runs: [{ tool: { driver: { name: "agent-preflight", informationUri: "https://github.com/K14-coder/agent-preflight", rules: [...rules.values()] } }, results: result.findings.map((finding) => ({ ruleId: finding.ruleId, level: LEVELS[finding.severity], message: { text: `${finding.message} ${finding.remediation}` }, partialFingerprints: { agentPreflight: finding.fingerprint }, locations: [{ physicalLocation: { artifactLocation: { uri: finding.file }, region: { startLine: finding.line, startColumn: finding.column } } }] })) }]
};
}
Loading
Loading