Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,35 @@ jobs:
- name: go build
run: go build ./...

# The remediation tier runs a target's build and tests in bubblewrap with
# no network and no file system beyond an export of the patched tree. A
# host without bubblewrap or unprivileged user namespaces refuses the
# build (fail closed), and there TestTheBuildHasNoNetwork could only prove
# the refusal. This lane provides both, and ANVIL_SANDBOX_REQUIRED makes a
# missing sandbox a test failure, so CI proves the confinement itself
# (docs/controls.md, U12). Ubuntu 24.04 restricts unprivileged user
# namespaces through AppArmor; the sysctl lifts that for this runner only.
- name: Build sandbox (bubblewrap) for the remediation tier
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq bubblewrap
if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --unshare-all --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib \
--symlink usr/lib64 /lib64 --proc /proc --dev /dev -- true
echo "ANVIL_SANDBOX_REQUIRED=1" >> "$GITHUB_ENV"

- name: go test
run: go test -race -count=1 ./...

- name: The build sandbox's confinement really ran, and passed
run: |
out=$(go test -count=1 -v -run '^TestTheBuildHasNoNetwork$' ./internal/remediation/ 2>&1)
echo "$out"
echo "$out" | grep -q -- '--- PASS: TestTheBuildHasNoNetwork' || { echo "::error::TestTheBuildHasNoNetwork did not pass"; exit 1; }
if echo "$out" | grep -q 'refuses the sandbox'; then echo "::error::the sandbox test took the fail-closed branch"; exit 1; fi

# THE SECOND LANE, and it is not redundant with the one above.
#
# internal/dast/authz/phase1_scopebytes_race_test.go carries
Expand Down
75 changes: 64 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ An open-source, **profit-free**, self-hostable system that finds vulnerabilities
code repositories and proposes fixes — using locally-served open-weight models, and never browsing the
live web at inference time.

**Status (2026-10-03): Phases 0–6 of 10 are built; Phase 7, remediation, is next.** Phase 5's evaluation
decided the model tier: the small detection model did not earn its place (`eval/register.yaml`), so the owner
shrank it to recall rules, and Phase 6 built Lane B that way, with no ranker (none was measured beating chance).
`anvil scan --repo` runs both lanes into one SAST half: Lane A matches dependencies through Trivy when the
operator enables its database, and Lane B runs its rule pack over the first-party source and records every
match as an `unconfirmed` finding for the coding agent's triage gate. `anvil scan --host` matches a host
inventory through Anvil's own comparator. Every scan seals the SAST half, writes the record and its findings to
the store, marks each finding new, persisting, regressed or fixed against earlier scans, and writes the record
as SARIF 2.1.0. What is not built yet: remediation and its triage gate, the dynamic tier running live (it is
built but has never touched a kernel or a target), and the release packages.
**Status (2026-10-04): Phases 0–7 of 10 are built; Phase 8, the dynamic tier running live, is next.** Phase 5's
evaluation decided the model tier: the small detection model did not earn its place (`eval/register.yaml`), so the
owner shrank it to recall rules, and Phase 6 built Lane B that way, with no ranker. `anvil scan --repo` runs both
lanes into one SAST half: Lane A matches dependencies through Trivy when the operator enables its database, and Lane B
runs its rule pack over the first-party source and records every match as an `unconfirmed` finding. `anvil scan
--host` matches a host inventory through Anvil's own comparator. Every scan seals the SAST half, writes the record and
its findings to the store, marks each finding new, persisting, regressed or fixed against earlier scans, and writes
the record as SARIF 2.1.0. Phase 7 built remediation (`anvil remediate`, below): it turns a sealed audit's findings
into draft pull requests whose body is the evidence, and never merges. **No model has run it yet:** the owner
deferred every GPU run (patch quality, the task-card comparison and the triage gate's precision) to after Phase 9,
so its quality is unmeasured, its triage gate is off by default, and it has opened pull requests only on an
in-process stand-in. What is not built yet: the dynamic tier running live (it is built but has never touched a
kernel or a target), so no fix can yet be labelled verified, and the release packages.
A fresh clone admits no real advisory feed until an operator acquires and certifies the publishers' licence
texts (`mirror/README.md`); `testdata/lanea-fixture` is an offline snapshot that proves the chain without one.

Expand Down Expand Up @@ -57,7 +60,7 @@ Two detection lanes, one audit record, and a remediation tier that proposes and
on Anvil's own candidates, and none of them are labelled.
- **Dynamic tier.** Ships as a **separate artifact** (`anvil-dast`), separately installed, requiring
explicit attestation before it probes anything.
- **Remediation.** Proposes patches. It does not merge them.
- **Remediation.** Proposes patches as draft pull requests. It does not merge them.

## What Lane B covers

Expand Down Expand Up @@ -89,6 +92,56 @@ timeout) is recorded as incomplete coverage. Measured on
2026-10-03 on five sample repositories, the selection produces 0 to 225 candidates a full scan (curl is the most;
`eval/results/candidates-per-scan.json`), under the budget of 500. Every scan records its count.

## Remediation

`anvil remediate` runs one cycle of the coding agent: it enqueues every sealed audit still inside its claim window,
then for each fix group (one finding plus up to four in the same file and function) leases the findings, asks the
triage gate about the unconfirmed ones, generates SEARCH/REPLACE edits, anchors them in the exact scanned blob and
applies them with `git apply --3way`, climbs a validation ladder, commits with trailers, and opens a draft pull
request from a fork. It needs a configuration block; it is off without one, and it names no model of its own:

```yaml
remediation:
enabled: true
endpoint: {url: http://127.0.0.1:8080/v1, model: <your coder>, tier: local} # local, own or public
triage: off # off (default), record, or gate
budgetTokens: 120000 # optional: the queue cut defers what does not fit
forge: {tokenFile: /etc/anvil/fork-token} # optional: no token, no pull requests, commits only
targets:
- locator: "repo:myapp" # the scan's --target name
source: /srv/checkouts/myapp # the checkout that was scanned; cloned, never written
build: [go, build, ./...] # an argument vector, run in bubblewrap with no network
readOnly: [/usr/local/go] # directories the build may read (a toolchain); never one holding a secret
test: [go, test, ./...]
repository: owner/myapp
fork: anvil-bot/myapp
forkRemote: https://github.com/anvil-bot/myapp.git
```

What it guarantees, each held by a test (`docs/gates/remediation.json`):

- **Nothing merges.** The forge client can reach six routes and none merges; the push token is refused if it can
write to the upstream at all, so Anvil pushes to a fork. Pull requests are drafts, at most three open per
repository.
- **The model only proposes.** Generation runs in its own process whose whole environment is one marker variable
(no credential, no proxy) and whose only socket is the model endpoint; every string from the scanned repository
reaches the prompt inside a fence it cannot close. A public hosted endpoint is refused unless allowed, and
warns loudly when it is.
- **The target is untrusted.** git runs with hooks, fsmonitor, external diff and credential helpers off and no
system or global configuration; the build and tests run in bubblewrap, in an export of the patched tree with no
`.git`, with no network and no file system beyond that export and read-only system directories (or not at all
where bubblewrap or unprivileged namespaces are missing); a patch may touch only source files its findings are
in, never a build, CI, dependency, configuration or repository-control file.
- **A pass never claims more than its rung.** Each rung says what it proves and what it does not; a clean rescan
can only lower a label. Only the exploit oracle (a stored reproduction and its mutants no longer triggering) earns
"verified fixed"; until the dynamic tier runs live, every proposal is labelled unverified-security.
- **Re-runs are idempotent.** The idempotency key (audit, fingerprint, base commit) rides in each commit's
trailers, so a crash between commit and bookkeeping is recovered, never redone.

What it does not yet know: whether its fixes are any good. The patch-quality measurement (50 cases of a local reproducible-vulnerability corpus,
bars signed by the owner: at least 5% verified fixed passes, under 2% fails) waits until after Phase 9, as does the
triage gate's precision on Juliet. `anvil triage PATH` runs that gate over a tree without recording anything.

## Three rules that are enforced in code, not documentation

1. **Never auto-merge a patch.** The best measured security-patch rate on real CVEs is 34.0%.
Expand Down
166 changes: 166 additions & 0 deletions cmd/anvil/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import (
"github.com/Susquehanna-Syntax/Anvil/internal/ingest/poller"
"github.com/Susquehanna-Syntax/Anvil/internal/laneb"
"github.com/Susquehanna-Syntax/Anvil/internal/recall"
"github.com/Susquehanna-Syntax/Anvil/internal/remediation"
"github.com/Susquehanna-Syntax/Anvil/internal/scan"
"github.com/Susquehanna-Syntax/Anvil/internal/settings"
"github.com/Susquehanna-Syntax/Anvil/internal/store"
Expand All @@ -72,6 +73,8 @@ const usage = `usage:
anvil scan --host --inventory FILE|- [--out FILE] [--event EVENT] [--full] [--policy FILE]
anvil findings [--target LOCATOR] [--all]
anvil recall PATH [FILE...]
anvil remediate
anvil triage PATH [FILE...]
anvil feeds import DIR
anvil dispatch (--repo PATH | --host --inventory FILE) --event EVENT [--full]
anvil daemon [--once]
Expand All @@ -82,6 +85,13 @@ anvil scan --host does not collect: it reads the inventory the host collector
(cmd/anvil-host-collector) writes when it runs under its systemd unit.
anvil recall prints Lane B's candidates for a tree as JSON and records nothing;
with FILEs, only candidates in those repository-relative files.
anvil remediate runs the remediation tier once: it enqueues sealed audits,
proposes fixes as draft pull requests (never merging), syncs open drafts and
re-checks merged fixes. It needs remediation.enabled and a model endpoint in
the configuration file.
anvil triage runs Lane B on a tree as anvil recall does, then asks the
configured model's triage gate about every candidate, printing one JSON line
per verdict; it records nothing. The triage-precision measurement drives it.
`

func main() {
Expand All @@ -106,6 +116,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return cmdFindings(ctx, args[1:], stdout, stderr)
case "recall":
return cmdRecall(ctx, args[1:], stdout, stderr)
case "remediate":
return cmdRemediate(ctx, args[1:], stdout, stderr)
case "triage":
return cmdTriage(ctx, args[1:], stdout, stderr)
case "generate-isolated":
// The generation process (internal/remediation, isolate.go): started
// by `anvil remediate` with an environment of one marker variable. It
// is not a command for people, so it is not in the usage text.
return remediation.ServeGeneration(ctx, os.Stdin, stdout)
case "feeds":
return cmdFeeds(ctx, args[1:], stdout, stderr)
case "dispatch":
Expand Down Expand Up @@ -267,6 +286,153 @@ func cmdScan(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return exitRefused
}

// cmdRemediate runs one remediation cycle and prints its report as JSON.
func cmdRemediate(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fs, configPath := newFlags("remediate", stderr)
if err := fs.Parse(args); err != nil || fs.NArg() > 0 {
fmt.Fprintf(stderr, "anvil remediate takes no arguments\n\n%s", usage)
return exitUsage
}
st, err := open(ctx, *configPath)
if err != nil {
fmt.Fprintf(stderr, "anvil remediate: %v\n", err)
return exitError
}
defer st.close()
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(stderr, "anvil remediate: %v\n", err)
return exitError
}
c, warnings, err := remediation.FromSettings(st.store, st.cfg, exe, laneBConfig(st.cfg))
switch {
case errors.Is(err, remediation.ErrNotEnabled):
fmt.Fprintf(stderr, "anvil remediate: %v\n", err)
return exitRefused
case err != nil:
fmt.Fprintf(stderr, "anvil remediate: %v\n", err)
return exitError
}
for _, w := range warnings {
fmt.Fprintf(stderr, "\n*** %s ***\n\n", w)
}
out := map[string]any{}
enq, err := c.EnqueuePending(ctx)
if err != nil {
fmt.Fprintf(stderr, "anvil remediate: enqueueing: %v\n", err)
return exitError
}
out["enqueued"] = enq
cyc, err := c.Cycle(ctx)
out["cycle"] = cyc
if err != nil {
fmt.Fprintf(stderr, "anvil remediate: %v\n", err)
return exitError
}
if c.Forge != nil {
days := time.Duration(st.cfg.Remediation.StaleAfterDays) * 24 * time.Hour
if err := remediation.SyncDrafts(ctx, st.store, c.Forge, days, time.Now()); err != nil {
fmt.Fprintf(stderr, "anvil remediate: syncing drafts: %v\n", err)
return exitError
}
}
rc, err := remediation.Recheck(ctx, st.store, nil, func(string) (remediation.ReplayTarget, bool) { return remediation.ReplayTarget{}, false }, time.Now())
if err != nil {
fmt.Fprintf(stderr, "anvil remediate: re-check: %v\n", err)
return exitError
}
out["recheck"] = rc
if acc, err := remediation.AcceptanceRates(ctx, st.store); err == nil {
out["acceptance"] = acc
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return exitError
}
return exitClean
}

// cmdTriage runs the triage gate over Lane B's candidates for a tree, with
// the same input builder, prompt and parser the controller uses, and records
// nothing.
func cmdTriage(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fs, configPath := newFlags("triage", stderr)
if err := fs.Parse(args); err != nil || fs.NArg() < 1 {
fmt.Fprintf(stderr, "anvil triage: give a PATH\n\n%s", usage)
return exitUsage
}
cfg, err := settings.Load(*configPath)
if err != nil {
fmt.Fprintf(stderr, "anvil triage: %v\n", err)
return exitError
}
lb := laneBConfig(cfg)
if lb == nil {
fmt.Fprint(stderr, "anvil triage: Lane B is off in the configuration\n")
return exitRefused
}
if !cfg.Remediation.Enabled {
fmt.Fprintf(stderr, "anvil triage: %v\n", remediation.ErrNotEnabled)
return exitRefused
}
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(stderr, "anvil triage: %v\n", err)
return exitError
}
r := cfg.Remediation
ep := remediation.Endpoint{URL: r.EndpointURL, Model: r.Model, Tier: remediation.Tier(r.Tier), AllowPublic: r.AllowPublic, APIKeyFile: r.APIKeyFile}
warnings, err := ep.Check()
if err != nil {
fmt.Fprintf(stderr, "anvil triage: %v\n", err)
return exitError
}
for _, w := range warnings {
fmt.Fprintf(stderr, "\n*** %s ***\n\n", w)
}
gen := remediation.IsolatedGenerator{Exe: exe, Args: []string{"generate-isolated"}, Endpoint: ep}
lane, err := laneb.Prepare(ctx, *lb, fs.Arg(0))
if err != nil {
fmt.Fprintf(stderr, "anvil triage: %v\n", err)
return exitMissing
}
if only := fs.Args()[1:]; len(only) > 0 {
lane = lane.Only(only)
}
out, err := lane.Run(ctx, "triage:"+fs.Arg(0))
if err != nil {
fmt.Fprintf(stderr, "anvil triage: %v\n", err)
return exitError
}
for _, p := range out.Problems {
fmt.Fprintf(stderr, "anvil triage: %s\n", p)
}
enc := json.NewEncoder(stdout)
for _, res := range out.Results {
in := remediation.TriageInputOf(res)
line := 0
if l := res.Locations; len(l) > 0 && l[0].PhysicalLocation != nil && l[0].PhysicalLocation.Region != nil {
line = l[0].PhysicalLocation.Region.StartLine
}
j, err := remediation.Judge(ctx, gen, in)
row := map[string]any{"fingerprint": in.Fingerprint, "path": in.Path, "startLine": line, "ruleId": in.RuleID,
"cwe": in.CWE, "symbol": in.Symbol}
if err != nil {
row["error"] = err.Error()
} else {
row["verdict"], row["reason"], row["promptTokens"], row["seconds"] = j.Verdict, j.Reason, j.PromptTokens, j.Elapsed.Seconds()
}
if err := enc.Encode(row); err != nil {
return exitError
}
}
if len(out.Problems) > 0 {
return exitRefused
}
return exitClean
}

// laneBConfig is Lane B's configuration from the operator's file, or nil when
// the file turns it off.
func laneBConfig(cfg settings.Settings) *laneb.Config {
Expand Down
17 changes: 17 additions & 0 deletions cmd/anvil/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,20 @@ func TestTheFullScanTimerIsTheMapsDesign(t *testing.T) {
}
}
}

// TestRemediateRefusesWhenTheTierIsOff: with no remediation block, anvil
// remediate is a refusal, never a quiet success.
func TestRemediateRefusesWhenTheTierIsOff(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "anvil.yml")
if err := os.WriteFile(cfg, []byte("version: 1\nstateDir: "+dir+"\n"), 0o600); err != nil {
t.Fatal(err)
}
var out, errb bytes.Buffer
if code := run(context.Background(), []string{"remediate", "--config", cfg}, &out, &errb); code != exitRefused {
t.Fatalf("exit %d: %s", code, errb.String())
}
if !strings.Contains(errb.String(), "not enabled") {
t.Fatalf("stderr %q", errb.String())
}
}
Loading
Loading