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:
| Kind | Rewrite |
|---|---|
cors-wildcard | Replace a wildcard CORS origin (*) with an explicit allow-list placeholder. |
debug-on | Flip DEBUG = True / debug=true to off. |
hardcoded-secret | Replace a literal secret with a runtime env lookup (os.environ[...] / process.env...). Rotate the exposed value too. |
jwt-verify-disabled | Re-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:
vulkro scan . --format jsonto get findings.vulkro fix . --format jsonto get the machine-applicable diffs.- Apply a selected
diffwithgit apply. - Re-run
vulkro scanand 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
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:
- 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.
- 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.
- 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.
- 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:
- Per-run flags
--ai-model/--ai-url(OpenAI-compatible base url, for example--ai-url http://127.0.0.1:11434/v1). - Env vars
VULKRO_AI_MODEL,VULKRO_AI_URL, andVULKRO_AI_KEY(a cloud bearer token; only meaningful for a cloud url, never persisted). - The saved selection from
vulkro ai use. - The smart default:
qwen2.5-coder:7bon a local Ollama runtime (a constrained machine falls back tophi4-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
| Mode | Exit codes |
|---|---|
| Default (read-only) | 0 always. Informational, not a gate. |
--dry-run | 0 nothing would apply; 1 at least one fix would apply. |
--write | 0 nothing needed fixing; 1 at least one fix applied; 2 IO failure, or only refusals when a write was requested. |
--ai | Never changes the exit code of the mode it is combined with. A refused AI fix is a refusal, not an error. |