Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fb1a038
feat(patch): integrate Auto Patcher into OpenAnt
elaav Jul 28, 2026
7dc3952
docs: add README section for `openant patch`
elaav Jul 28, 2026
1a4b645
fix(ci): address gitleaks and Ruff failures
elaav Jul 28, 2026
c4ef3e5
fix(patch): align with current master conventions
elaav Jul 30, 2026
55399ff
fix(trust): fail closed on unavailable trust evidence
elaav Jul 30, 2026
c68f06c
Fix F-29 fail-closed handling for malformed fenced diffs
elaav Jul 30, 2026
84c1400
F-25: Refine duplicate_assignment to use diff-local evidence
elaav Jul 30, 2026
6d7c600
F-18: Prevent symlink escapes during repository grounding
elaav Jul 30, 2026
7a4688e
Fix F-01: remove repository fail-open fallback when repo_root is absent
elaav Jul 30, 2026
ba23567
fix(patch): resolve F-36, F-38, F-41, F-44 and F-45 diff parsing issues
elaav Jul 30, 2026
49be0af
fix(ci): allowlist historical curl fixture for gitleaks
elaav Jul 30, 2026
0067dc0
fix F-30: add first-class symbol definition pass
elaav Jul 30, 2026
1c25d31
fix(F-31,F-35): harden vulnerability markdown rendering
elaav Jul 30, 2026
576dd00
fix F-39: prevent stale trust reports after failed reruns
elaav Jul 30, 2026
c5a454a
docs(readme): add comprehensive Auto Patcher documentation
elaav Jul 30, 2026
f7215de
docs(auto-patcher): add recommendation policy documentation
elaav Jul 30, 2026
0cf42d1
docs: improve Auto Patcher README quick start and messaging
elaav Jul 30, 2026
55853ba
Add CVE fetch and render support
elaav Jul 30, 2026
4bb535d
feat(patch): integrate OpenAnt LLM configuration and interactive prov…
elaav Jul 30, 2026
e8427ac
feat(patch): add bounded candidate selection
elaav Aug 2, 2026
2dc1c49
feat(patch): add deterministic candidate enrichment
elaav Aug 2, 2026
e8c1768
feat(patch): add deterministic evidence fusion
elaav Aug 2, 2026
270b63e
feat(patch): render repository understanding context
elaav Aug 2, 2026
5d88da5
feat(patch): integrate repository understanding pipeline
elaav Aug 2, 2026
612c806
feat(patch): add isolated patch workspace
elaav Aug 2, 2026
2fae7ca
feat(patch): derive pre-patch evidence anchors
elaav Aug 2, 2026
1f49184
feat(patch): evaluate post-patch evidence anchors
elaav Aug 3, 2026
d8e96a1
feat(patch): integrate post-patch vulnerability investigation
elaav Aug 3, 2026
790ac0c
feat(patch): add deterministic post-patch semantic verification
elaav Aug 3, 2026
27580ef
polish(patch): improve Trust Report clarity
elaav Aug 3, 2026
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
10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ paths = [
regexes = [
'''(1234567890|LEAKED|EXAMPLE|FAKE)''',
]

[[allowlists]]
description = "Fake credential in curl CVE-2022-27774 fixture (curl-auth-user; historical commit fb1a038, since replaced)"
condition = "AND"
paths = [
'''libs/openant-core/tests/patch/fixtures/examples/curl-cve-2022-27774\.md''',
]
regexes = [
'''alice:s3cr3t''',
]
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Providers accept a custom `base_url` for OpenAI-compatible / Anthropic-compatibl

#### Adding a new provider adapter

OpenAnt's adapter layer is a small Python recipe — one Python file implementing the `LLMAdapter` Protocol, one factory for the contract-test harness, plus a registry entry — and that alone is enough to run the adapter from a hand-authored config. To also have it offered by the `openant setup llm` wizard and pass its pre-save probe, add a few Go touch-points in `apps/openant-cli/cmd/setup.go` (the supported-provider list, a probe `case`, the per-phase default-model maps) plus a Go probe function. The 12 contract tests run automatically against your adapter once it's wired in. See [`docs/features/llm-providers/HOW_TO_ADD_AN_ADAPTER.md`](docs/features/llm-providers/HOW_TO_ADD_AN_ADAPTER.md) for the full recipe.
OpenAnt's adapter layer is a small Python recipe — one Python file implementing the `LLMAdapter` Protocol, one factory for the contract-test harness, plus a registry entry — and that alone is enough to run the adapter from a hand-authored config. To also have it offered by the `openant setup llm` wizard and pass its pre-save probe, add a few Go touch-points in `apps/openant-cli/cmd/setup.go` (the supported-provider list, a probe `case`, the per-phase default-model maps) plus a Go probe function. The 12 contract tests run automatically against your adapter once it's wired in.

### Python runtime

Expand Down Expand Up @@ -189,6 +189,10 @@ Or run the full pipeline in one command:
openant scan --verify
```

### 3. Remediate a finding

Generate a candidate patch and an independent Trust Report for a specific finding — see [Auto Patcher](#auto-patcher) below.

### Working with multiple projects

The pipeline operates on one project at a time. Running `openant init` sets the newly initialized project as the active one, so all subsequent commands target it by default.
Expand All @@ -212,14 +216,84 @@ openant project show # details of active project
openant project switch <org/repo> # switch active project
```

## Auto Patcher

Auto Patcher exists to answer one question: **does this AI-generated patch deserve to be trusted?** Generating a candidate patch is only the first step. Auto Patcher focuses on producing the evidence humans need to decide whether that patch should be trusted and deployed.

Given a specific finding from an OpenAnt scan, it generates a candidate patch and then subjects that patch to independent, adversarial scrutiny, producing a Trust Report that states whether the patch is fit to deploy — backed by the evidence behind that call. It does not autofix your repository: the patch and its Trust Report are written to disk for a human to review, and the target repository is never modified.

### Why AI-generated patches can't be trusted at face value

A patch produced by an LLM can look correct — it compiles, it touches the right function, it reads like a competent fix — without actually closing the vulnerability. It may narrow the attack surface without eliminating it, fix the described case while missing an adjacent one, or apply cleanly against one version of a file and silently fail against another. Fluent output is not verified output, and asking the same model that wrote a patch whether the patch is good doesn't close that gap — it just repeats the same blind spot.

### What Auto Patcher does differently

Rather than returning a single "here's the fix," every candidate patch goes through a trust-building process:

```
Generate a candidate patch
Challenge it from an adversarial perspective
Collect deterministic evidence — does it apply cleanly? does it introduce obvious defects?
Produce a recommendation, backed by the evidence collected above
```

The adversarial pass is a distinct reasoning step whose only job is to argue the patch doesn't hold up — not to confirm that it does. The deterministic checks (applying the patch against the real repository, scanning the diff for hygiene issues) never rely on an LLM's opinion of its own work. The final recommendation is computed from all of this evidence by a fixed decision policy, not read off an LLM's self-reported confidence.

### Philosophy

- **Never communicate more certainty than the evidence supports.** A check that didn't run is reported as unverified, never as a quiet pass.
- **Recommendations come from deterministic policy, not from an LLM's self-assessment.** Each of the four possible recommendations is computed from evidence gates a human can audit — not a model grading its own patch.
- **Every recommendation ships with the evidence behind it.** The Trust Report separates what was mechanically verified from what is heuristic, adversarial-review judgment, so a reviewer never has to guess which is which.
- **The deployment decision stays with a human.** Auto Patcher never applies a patch to the target repository — it produces a recommendation for someone accountable to act on.

### Quick start

Auto Patcher runs against a finding already produced by an OpenAnt scan (`openant scan` / `openant build-output`) whose verdict is patch-eligible — `confirmed`, `agreed`, `vulnerable`, or `bypassable`.

To find an eligible finding's id, check the `findings` array in your project's `pipeline_output.json` (written by `openant build-output`) — the snippet below requires [`jq`](https://jqlang.org/):

```bash
jq -r '.findings[] | "\(.id)\t\(.stage2_verdict // .stage1_verdict)"' pipeline_output.json
```

Pick an id whose verdict is one of the four above, then pass it to `patch` (`VULN-001` below is a placeholder — use the id you found):

```bash
LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-... openant patch --finding-id VULN-001
```

`LLM_PROVIDER` (`anthropic` or `openai`) and the matching API key must be set explicitly. This is configured independently of OpenAnt's own `--llm-config` system, and deliberately does not fall back to a mock LLM unless you ask for one.

### The Trust Report

Each run writes two files under `patch/` in the project's scan directory:

- `{finding-id}-vulnerability.md` — the finding as rendered into the input Auto Patcher worked from.
- `{finding-id}-trust-report.md` — the Trust Report.

The Trust Report leads with a single recommendation — **Deploy After Validation**, **Deploy With Caution**, **Manual Review Required**, or **Do Not Apply** — followed by the evidence behind it: whether the patch applies to the repository, whether adversarial review turned up a remaining exploit path, whether tests already cover the affected code, and what deployment risk the change carries. Each item is marked as either a deterministic check or a heuristic judgment.

### Known limitations

- Auto Patcher is an early-stage capability — its own reports are labeled MVP output today.
- Some evidence signals (impact analysis, existing-test discovery) currently run meaningfully only on Python codebases; on other languages they report as not applicable rather than being silently skipped.
- Auto Patcher's LLM access supports Anthropic and OpenAI only; unlike `--llm-config`, it does not support Google.
- This is a decision aid for a human reviewer, not a replacement for manual security review.

## Roadmap

Things on the list, in no particular order:

- **More provider adapters.** Ollama (local models), vLLM, Cohere, Mistral, Groq, Amazon Bedrock, Azure OpenAI — each is a small Python adapter recipe (plus a few Go wizard/probe touch-points if you want it offered by `openant setup llm`) per the contributor guide. Lower the barrier to local / on-prem inference.
- **Subscription-based auth.** ChatGPT / Codex, Claude Pro / Max, and Gemini Advanced subscriptions don't currently grant API quota — users have to maintain a separate API-tier key per provider. OAuth-based adapters that ride the consumer subscription would close that gap.
- **Cross-provider tool-call quirks.** All three shipped adapters support tool calling, but the long tail (parallel tool calls, strict-mode schema enforcement, retry semantics on partial JSON) behaves differently per provider. Real-world scans surface these — PRs welcome.
- **More languages.** The supported-languages list above is current coverage. Rust, Java, C#, and Swift come up frequently.
- **More languages.** The supported-languages list above is current coverage. Rust, Java, and C# come up frequently.
- **Hosted scan service.** Knostic offers free scans for OSS projects today via the form linked above; a self-serve API for trusted partners is a future possibility.

PRs welcome on any of these — open an issue first if the scope is non-trivial so we can align before you build.
Expand Down
2 changes: 1 addition & 1 deletion apps/openant-cli/cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func runAnalyze(cmd *cobra.Command, args []string) {
analyzeLLMConfig, analyzeWorkers, analyzeCheckpoint, analyzeBackoff,
)

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey())
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey(), nil)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
Expand Down
2 changes: 1 addition & 1 deletion apps/openant-cli/cmd/buildoutput.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func runBuildOutput(cmd *cobra.Command, args []string) {
pyArgs = append(pyArgs, "--processing-level", buildOutputProcessingLevel)
}

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey())
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey(), nil)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
Expand Down
2 changes: 1 addition & 1 deletion apps/openant-cli/cmd/dynamictest.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func runDynamicTest(cmd *cobra.Command, args []string) {
pyArgs = append(pyArgs, "--llm-config", dynamicTestLLMConfig)
}

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey())
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey(), nil)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
Expand Down
2 changes: 1 addition & 1 deletion apps/openant-cli/cmd/enhance.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func runEnhance(cmd *cobra.Command, args []string) {
pyArgs = append(pyArgs, "--limit", fmt.Sprintf("%d", enhanceLimit))
}

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey())
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey(), nil)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
Expand Down
4 changes: 2 additions & 2 deletions apps/openant-cli/cmd/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var parseCmd = &cobra.Command{
Long: `Parse extracts analyzable code units from a repository.

The output is a JSON dataset that can be fed into the analyze command.
Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, and PHP repositories.
Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift repositories.

If no repository path is given, the active project is used (see: openant init).`,
Args: cobra.MaximumNArgs(1),
Expand Down Expand Up @@ -120,7 +120,7 @@ func runParse(cmd *cobra.Command, args []string) {

pyArgs := buildParsePyArgs(repoPath, parseOutput, datasetName, parseLanguage, parseLevel, manifestPath, parseFresh)

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey())
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey(), nil)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
Expand Down
224 changes: 224 additions & 0 deletions apps/openant-cli/cmd/patch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package cmd

import (
"fmt"
"os"
"regexp"

"github.com/knostic/open-ant-cli/internal/output"
"github.com/knostic/open-ant-cli/internal/python"
"github.com/spf13/cobra"
)

var patchCmd = &cobra.Command{
Use: "patch [pipeline-output-path]",
Short: "Generate and evaluate a candidate remediation for a finding or a known CVE",
Long: `Patch invokes the merged Auto Patcher engine to generate a candidate
remediation and produce a Trust Report judging whether that candidate should
be trusted. Two entry points converge on the same pipeline and the same
Trust Report format:

openant patch --finding-id <id> remediate an OpenAnt-detected Finding
openant patch --cve CVE-YYYY-NNNN --repo-root <path> remediate a known CVE advisory

The Trust Report is treated as an opaque artifact: it is written under the
active scan directory but never parsed, scored, or reinterpreted. A
CVE-sourced report additionally discloses that its input was a public
advisory, not an OpenAnt Finding, and that advisory claims are not
repository-verified facts.

Requires LLM_PROVIDER to be set explicitly (e.g. LLM_PROVIDER=anthropic) so
a run never silently falls back to a mock LLM. Set LLM_PROVIDER=mock only
if you intentionally want a mock run.

If no path is given for --finding-id, the active project's
pipeline_output.json is used. --cve requires --repo-root, which defaults to
the active project's repo path when not given explicitly.`,
Args: cobra.MaximumNArgs(1),
Run: runPatch,
}

var (
patchFindingID string
patchCVE string
patchRepoRoot string
patchOutput string
)

func init() {
patchCmd.Flags().StringVar(&patchFindingID, "finding-id", "", "ID of the finding to remediate (mutually exclusive with --cve)")
patchCmd.Flags().StringVar(&patchCVE, "cve", "", "CVE identifier to fetch from NVD and remediate (mutually exclusive with --finding-id)")
patchCmd.Flags().StringVar(&patchRepoRoot, "repo-root", "", "Path to the target repository root (defaults to the active project's repo path; required for --cve)")
patchCmd.Flags().StringVarP(&patchOutput, "output", "o", "", "Output directory (default: active scan directory)")
}

var cveIDPattern = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`)

func buildPatchPyArgs(pipelineOutputPath, findingID, repoRoot, outputDir string) []string {
pyArgs := []string{"patch", pipelineOutputPath, "--finding-id", findingID}
if repoRoot != "" {
pyArgs = append(pyArgs, "--repo-root", repoRoot)
}
if outputDir != "" {
pyArgs = append(pyArgs, "--output", outputDir)
}
return pyArgs
}

func buildPatchCVEPyArgs(cve, repoRoot, outputDir string) []string {
pyArgs := []string{"patch", "--cve", cve, "--repo-root", repoRoot}
if outputDir != "" {
pyArgs = append(pyArgs, "--output", outputDir)
}
return pyArgs
}

func runPatch(cmd *cobra.Command, args []string) {
if patchFindingID == "" && patchCVE == "" {
output.PrintError("openant patch requires either --finding-id <id> or --cve <CVE-ID>")
os.Exit(2)
}
if patchFindingID != "" && patchCVE != "" {
output.PrintError("--finding-id and --cve are mutually exclusive; pass exactly one")
os.Exit(2)
}

if patchCVE != "" {
runPatchCVE(args)
return
}
runPatchFinding(args)
}

func runPatchFinding(args []string) {
pipelineOutputPath, ctx, err := resolveFileArg(args, "pipeline_output.json")
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

if _, err := os.Stat(pipelineOutputPath); err != nil {
output.PrintError("pipeline_output.json not found. Run 'openant build-output' first.")
os.Exit(2)
}

outputDir := patchOutput
if outputDir == "" && ctx != nil {
outputDir = ctx.ScanDir
}

repoRoot := patchRepoRoot
if repoRoot == "" && ctx != nil {
repoRoot = ctx.RepoPath
}

// Resolved before ensurePython() so a missing/declined provider fails
// fast, without paying for a venv/dependency bootstrap first.
llmEnv, err := resolvePatchLLMEnv()
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

rt, err := ensurePython()
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

pyArgs := buildPatchPyArgs(pipelineOutputPath, patchFindingID, repoRoot, outputDir)

// Auto Patcher's LLM calls are independently configured via LLM_PROVIDER /
// OPENAI_API_KEY / ANTHROPIC_API_KEY -- never OpenAnt's own --api-key or
// llm_providers config, so no legacy API key is forwarded here. llmEnv
// carries only what resolvePatchLLMEnv resolved (explicit env passthrough
// needs nothing added; interactive selection adds LLM_PROVIDER + the
// chosen provider's key) into the Python subprocess's environment only.
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, "", llmEnv)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

printPatchResultAndExit(result)
}

func runPatchCVE(args []string) {
if len(args) > 0 {
output.PrintError("openant patch --cve does not take a pipeline-output-path argument")
os.Exit(2)
}
if !cveIDPattern.MatchString(patchCVE) {
output.PrintError(fmt.Sprintf("invalid CVE identifier %q: expected format CVE-YYYY-NNNN", patchCVE))
os.Exit(2)
}

// resolveProject() is optional here (unlike Finding mode, where a
// missing active project with no positional arg is a hard error) --
// it's only ever used as a convenience default for --repo-root/--output,
// never required on its own.
ctx, ctxErr := resolveProject()

repoRoot := patchRepoRoot
if repoRoot == "" && ctxErr == nil {
repoRoot = ctx.RepoPath
}
if repoRoot == "" {
output.PrintError("--cve requires --repo-root <path> (no active project to default to)")
os.Exit(2)
}
if _, err := os.Stat(repoRoot); err != nil {
output.PrintError(fmt.Sprintf("--repo-root does not exist: %s", repoRoot))
os.Exit(2)
}

outputDir := patchOutput
if outputDir == "" && ctxErr == nil {
outputDir = ctx.ScanDir
}

// Resolved before ensurePython() so a missing/declined provider fails
// fast, without paying for a venv/dependency bootstrap first.
llmEnv, err := resolvePatchLLMEnv()
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

rt, err := ensurePython()
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

pyArgs := buildPatchCVEPyArgs(patchCVE, repoRoot, outputDir)

// Same deliberate omission as Finding mode: Auto Patcher's LLM calls are
// configured independently via LLM_PROVIDER / OPENAI_API_KEY /
// ANTHROPIC_API_KEY, never OpenAnt's own --api-key. llmEnv carries only
// what resolvePatchLLMEnv resolved into the Python subprocess's
// environment only.
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, "", llmEnv)
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

printPatchResultAndExit(result)
}

func printPatchResultAndExit(result *python.InvokeResult) {
if result.Envelope.Status == "interrupted" {
os.Exit(130)
} else if jsonOutput {
output.PrintJSON(result.Envelope)
} else if result.Envelope.Status == "success" {
if data, ok := result.Envelope.Data.(map[string]any); ok {
output.PrintPatchSummary(data)
}
} else {
output.PrintErrors(result.Envelope.Errors)
}

os.Exit(result.ExitCode)
}
Loading
Loading