Skip to main content

vulkro fix

Fix suggestions in three tiers, strictest first. By default vulkro fix is read-only: it prints a unified diff (or a machine-applicable JSON envelope) and never modifies your files; you apply it. Two opt-in tiers extend this: --write applies a closed, deterministic allowlist of templated rewrites in place, and --ai adds advisory, re-scan-validated AI diffs for findings no deterministic template covers.

vulkro fix . # unified diffs on stdout (read-only)
vulkro fix . --kind hardcoded-secret
vulkro fix . --format json # agent / MCP-consumable schema
vulkro fix . --dry-run # preview the deterministic apply tier
vulkro fix . --write # apply the deterministic allowlist in place
vulkro fix . --ai # add advisory AI diffs (still read-only)
vulkro fix . --ai --write # also apply the verified AI diffs

What it rewrites

The read-only suggestion set is intentionally narrow - only suggestions whose rewrite is mechanical. --kind filters to one of them:

KindRewrite
cors-wildcardReplace a wildcard CORS origin (*) with an explicit allow-list placeholder.
debug-onFlip DEBUG = True / debug=true to off.
hardcoded-secretReplace a literal secret with a runtime env lookup (os.environ[...] / process.env...). Rotate the exposed value too.
jwt-verify-disabledRe-enable JWT signature verification (flip the exact verify / verify_signature literal back on).

Anything else yields no suggestion: Vulkro does not guess at fixes it cannot mechanically prove safe.

Applying a fix

vulkro fix . > fixes.patch
git apply fixes.patch # review first; selectively apply hunks by hand

Applying in place: --write and --dry-run

--write resolves a closed allowlist of templated rewrites and applies them in place atomically (temp file + rename). The catalog: cookie Secure / HttpOnly / SameSite flags (JS options object and Python set_cookie kwargs), MD5 / SHA1 to SHA-256, non-crypto random token to CSPRNG, removing an explicit CSRF opt-out, parameterizing the narrowest obvious string-built SQL, yaml.load to yaml.safe_load, hardening dangerous lxml XMLParser flags, removing none from a JWT algorithms list (when another algorithm remains), flipping exact debug=True literals, fixing insecure Django cookie settings, and upgrading http:// to https:// for a short allowlist of major API hosts known to serve TLS. CORS wildcard origins always refuse with a pointer to the read-only placeholder diff (the real origin is deployment-specific).

Every template re-inspects the actual source line at apply time and refuses on any ambiguity, multi-byte slice risk, multi-detector collision, or a line that changed since the scan. Refusals are printed with an actionable reason; they are not errors. There is no LLM in this path: it hard-refuses rather than guess.

--dry-run previews the same tier without touching any file and prints an "N fixes would apply, M refused" summary to stderr. It conflicts with --write.

JSON schema (--format json)

--format json emits a stable, versioned envelope for agents and MCP consumers. Each fix carries the originating finding id, the rule, file/line, a prose explanation, and a git apply-ready unified diff:

{
"schema_version": 1,
"generated_by": "vulkro 0.11.0",
"count": 1,
"fixes": [
{
"finding_id": "a1b2c3d4",
"rule": "cors-wildcard",
"file": "src/app.py",
"line": 42,
"explanation": "Replace wildcard CORS origin with an explicit allowlist. ...",
"patch_format": "unified-diff",
"diff": "--- a/src/app.py\n+++ b/src/app.py\n@@ -42,1 +42,1 @@\n-CORS(app, origins=[\"*\"])\n+CORS(app, origins=[\"https://your-app.example\"])\n"
}
]
}

schema_version is bumped on any breaking change, so a consumer can pin against it. patch_format is always unified-diff today. The bumps so far are additive: with --write / --dry-run the envelope gains applied / refused / summary fields and schema_version becomes 2; with --ai it gains an ai_suggested array and becomes 3 (see below). Without those flags the envelope stays byte-for-byte the version 1 shape.

Agent fix loop

The JSON envelope is the contract for an autonomous fix loop. An agent (driving Vulkro directly, or alongside vulkro mcp serve) can:

  1. vulkro scan . --format json to get findings.
  2. vulkro fix . --format json to get the machine-applicable diffs.
  3. Apply a selected diff with git apply.
  4. Re-run vulkro scan and confirm the finding is gone.

vulkro fix <dir> works from any working directory: the diff paths are relative to the scanned root, so apply them with git apply from that root.

The same loop runs entirely inside vulkro mcp serve via the suggest_fixes({scan_id}) tool, which returns this identical envelope for a prior scan_project call.

In the read-only default, Vulkro stays read-only throughout: step 3 is the agent's action, not Vulkro's.

The AI tier: --ai

Advisory, local, and never a scan input

The AI fix tier is opt-in and advisory. It runs against a local model by default (Ollama, qwen2.5-coder:7b), it never changes a deterministic scan result, finding, severity, or exit code, and the published benchmark is AI-free. The model proposes; the deterministic scanner judges.

Deterministic templates cover only rewrites that are provably safe. For every finding the allowlist has no safe rewrite for, --ai asks the resolved local model to draft a minimal patch, then validates that draft before showing it as anything more than a guess. --ai alone is read-only: it prints the advisory diffs and applies nothing.

How a draft is produced

  • Only findings with no safe deterministic template reach the AI tier; the templates own the rest. One proposal is made per file:line, so a multi-detector collision on a single line is handled once.
  • The model sees a bounded window: the finding span plus three context lines on each side. It must return a whole-line replacement for that hunk; an unparseable reply is a refusal, not an error.

The deterministic re-scan oracle

Every draft passes through a validation loop with the deterministic engine as the primary oracle:

  1. Stale check. The hunk must still byte-match what the scan saw. If the source changed since the scan, the draft is refused rather than spliced.
  2. Parse-validity gate. The patched file is parsed with the grammar for its extension (Python, JavaScript, TypeScript, Go, and Java files). A patch that introduces new syntax errors relative to the original file is refused outright. This closes a loophole: a patch that breaks parsing can make the re-scan find nothing, which would otherwise let a broken edit "clear" the finding. File types without a grammar check skip this gate and rely on the re-scan oracle alone.
  3. Re-scan. The patch is applied to the file, the full deterministic engine re-scans the project working copy, and the original file is always restored afterwards (a guard restores it even if the re-scan errors, so validation never leaves your tree modified). The gate requires both that the originating finding is no longer reported and that no new finding appeared anywhere in the project.
  4. Subordinate AI review. Only after the re-scan fully passes, a second AI pass reviews the patch (is it correct and behaviour-preserving?). The review is fail-safe: a provider error or an unparseable verdict fails it. Because it is only ever consulted after a passing re-scan, it can downgrade a would-be accept to a refusal but can never turn a re-scan failure into a pass.

A fix is shown as verified only when every gate passes; otherwise it is shown as refused with an actionable reason. A refused AI fix is a refusal, not an error, and never changes the exit code.

Verified means exactly this: a fresh deterministic re-scan no longer reports the finding, no new finding appeared, and the file still parses. It does not claim the project compiles or that its tests pass. Review a verified AI diff the way you would review any patch.

Accept and refuse, side by side

An insecure pickle.loads deserialization has no deterministic template. A draft that swaps it for json.loads clears the finding on re-scan, parses cleanly, and passes review, so it verifies. A draft that merely shuffles the call into a local variable leaves the finding in place, so it refuses:

# [b7e2f1a9] AI fix (advisory) (app/loader.py:12) - verified: finding cleared, no new findings, AI review passed
--- a/app/loader.py
+++ b/app/loader.py
@@ -11,3 +11,3 @@
def load(data):
- import pickle
- return pickle.loads(data)
+ import json
+ return json.loads(data)

# [c4d90e3b] AI fix (advisory) (app/cache.py:31) - refused: the deterministic scanner still reports the finding after applying the fix; the model's change did not resolve it. Refusing.

Other refusal reasons you will see: the fix cleared the finding but introduced new deterministic findings (trading one issue for another), the patched file introduces new syntax errors, the source changed since the scan, or the re-scan passed but the AI review rejected the patch.

--ai --write

--ai --write applies only the verified AI diffs, each printed first, and only after the deterministic templates have run. If an earlier template shifted the lines since validation, the AI fix is skipped as stale rather than wrong-spliced. Applying an AI fix never changes the exit code: the deterministic tier owns it.

Model selection and offline behaviour

The provider resolves exactly like vulkro triage, highest precedence first:

  1. Per-run flags --ai-model / --ai-url (OpenAI-compatible base url, for example --ai-url http://127.0.0.1:11434/v1).
  2. Env vars VULKRO_AI_MODEL, VULKRO_AI_URL, and VULKRO_AI_KEY (a cloud bearer token; only meaningful for a cloud url, never persisted).
  3. The saved selection from vulkro ai use.
  4. The smart default: qwen2.5-coder:7b on a local Ollama runtime (a constrained machine falls back to phi4-mini:3.8b).

Configure and self-test the layer with vulkro ai (sub-actions: setup, list, use, pull, status, test, off). Under VULKRO_OFFLINE a loopback model is allowed (nothing leaves the machine); a cloud url is refused. If the AI layer is off or the runtime is unreachable, the tier is skipped with a stderr note and the deterministic fixes are unaffected.

AI entries in the JSON envelope

With --ai, the envelope gains an additive ai_suggested array and schema_version becomes 3 (only when at least one AI diff was actually proposed; otherwise the envelope is byte-for-byte the non-AI shape). Every entry is permanently labelled so a consumer can never mistake it for a deterministic result:

{
"finding_id": "b7e2f1a9",
"provider": "ollama",
"patch_format": "unified-diff",
"diff": "--- a/app/loader.py\n+++ b/app/loader.py\n...",
"advisory": true,
"non_deterministic": true,
"excluded_from_benchmark": true,
"validated": true,
"validation": {
"finding_cleared": true,
"new_findings": 0,
"rechecked_scope": "project",
"ai_review_passed": true,
"ai_review_reason": null
}
}

validated is true only for a verified fix; a refused one carries the gate detail in validation (and ai_review_reason when the second gate was the one that refused).

Exit codes

ModeExit codes
Default (read-only)0 always. Informational, not a gate.
--dry-run0 nothing would apply; 1 at least one fix would apply.
--write0 nothing needed fixing; 1 at least one fix applied; 2 IO failure, or only refusals when a write was requested.
--aiNever changes the exit code of the mode it is combined with. A refused AI fix is a refusal, not an error.