secure-code-agent docs
History

Changelog

All notable changes to secure-code-agent are documented here. Format follows Keep a Changelog. Semver pre-1.0 — config schema may evolve.

0.13.1 — 2026-10-30

Coverage was reported to code scanning as a failed run.

Fixed — SARIF executionSuccessful is the run, not the coverage

Docs

0.13.2 — 2026-10-30

Docs

0.13.0 — 2026-10-05

Two ways a grade was issued without the evidence to support it.

Coverage knows which languages it actually read

A tool-unavailable notice bought a perfect score.

Security — a control finding is not evidence that anything was examined (D33, amended)

text per_category: { ...all null..., "policy_docs": 5.0 } overall: 5.0 letter: A+ loc_scanned: 0

0.12.12 — 2026-10-04

A run that scanned nothing reported a perfect score.

Security — nothing scanned is ungraded, not perfect (D33)

run overall loc_scanned
honest 0.00 (F) 3
paths.exclude_patterns: ["**"] 5.00 (A+) 0
paths.test_patterns: ["**"] 5.00 (A+) 0
paths.docs_patterns: ["**"] 5.00 (A+) 0
an empty directory 5.00 (A+) 0

Security — the denominator is a grading key too (D32, amended)

Fixed — exclude_patterns did not stop the scan (D34)

trivy invocation result
the repository root, as the adapter gave it fails after minutes of backoff
--skip-dirs calibration/.corpus exit 0 in 51s

Fixed — a required scanner failed for months and would not say why

text FATAL remote Maven repository returned 429 Too Many Requests for https://repo.maven.apache.org/... Retry-After: 1313. To avoid this, populate the local Maven cache before scanning.

A cause and a remedy, held in r.stderr and discarded. The adapter's other two failure paths — timeout and bad exit — both carry that text; the one path with no diagnosis of its own was the one that threw it away. It also explains twenty-minute trivy invocations in the local suite: it was retrying against Maven with backoff. - The empty-output failure now reports the exit code and what trivy said. The exit code is reported rather than judged: trivy returns 1 both for a fatal error and, with --exit-code, for findings, so output decides success (D30) and a trivy that returns 1 with usable SARIF is still parsed. - Second half, one layer up: evaluate_coverage composed its failure strings from the outcome alone, so coverage.failures — what the require_scanners gate reports and what the summary prints — said did not complete: failed while ScannerExecution.reason held the cause in the same record. A diagnosis that only reaches a JSON field nobody reads is not a diagnosis. Both halves are the same mistake in the same direction: the tool had the evidence and reported its absence.

Fixed

Internal

0.12.11 — 2026-10-03

The audited repository could set its own grade.

Security — the audited tree does not grade itself (D32)

Fixed

Added

Changed

0.12.10 — 2026-10-02

A repository could run code on the auditing machine through preflight.

Security — an untrusted config does not choose a scanner's command (D29)

0.12.9 — 2026-09-20

One tool, two answers. A run reported 5.00 (A+) — no findings in the pillar and 68 to fix in the work order, about the same repository in the same minute. The 68 were the findings the operator had declared.

Fixed — declared findings are not patch targets (D28)

0.12.8 — 2026-09-19

The work order, as facts. generate writes Markdown, which is right for the operator and the agent and wrong for a tool. maintainability-agent embeds this work order in an HTML report, had only prose, and wrapped it in <pre> — so a reader who chose HTML got raw Markdown inside a rendered page.

Added — --work-order-json / outputs.work_order_json_path (D27)

0.12.7 — 2026-09-18

The declaration now says why on the page. 0.12.6 shipped capabilities with the disclosure missing from the artifact the disclosure lives in.

Fixed — a declared axis prints the reason the project stated (D25)

0.12.6 — 2026-09-18

A project can say what it is. A tool that runs external analyzers imports subprocess and spawns them, and the scanner reports that correctly on every run forever. Until now the only ways to stop it were exclude_patterns and --skip, which delete the observation from the report, or a .scignore.yaml suppression, which states a reason and then expires annually for a fact that has not changed.

Added — capabilities (D24)

"capabilities": {
  "spawns_processes": "Runs the analyzer pool as subprocesses. ADR 006."
}

Known names: spawns_processes, parses_untrusted_xml, fetches_remote_urls, uses_nondeterministic_randomness.

A declaration asserts an architecture, not its correctness. parses_untrusted_xml says the project reads XML it did not write; it does not say the parser is hardened, and this tool cannot check that. The findings stay in the report for that reason.

0.12.5 — 2026-09-17

A repository with no lockfile can be graded again. osv-scanner reports "no package sources found" with exit code 128. The adapter's comment called that an internal error and the code treated it as a scanner failure, which held coverage at partial forever for any repository that declares its dependencies nowhere osv-scanner reads — this one included.

Fixed — nothing to scan is not a failure to scan (D23)

For maintainability-agent users. A security posture of unverified on a repository with no lockfile was most likely this. Upgrade and re-run.

0.12.4 — 2026-09-14

Code scanning gets alerts, not the whole record. Since 0.12.2 made finding paths relative, GitHub has been able to place this tool's SARIF results on pull-request lines. Every test-tree assert became a review thread, and repositories that require resolved conversations could not merge.

Fixed — the uploaded SARIF leaves out suppressed results (D22)

The SARIF this tool writes marks test-tree and documentation findings, and .scignore.yaml suppressions, with the standard suppressions field. GitHub code scanning opens an alert for every uploaded result and does not act on that field.

What you will see. On the first upload from your default branch after upgrading, code scanning closes the alerts for test-tree findings, documentation findings and reviewed suppressions, because the new upload no longer contains them. Alerts for live findings in shipped source are unaffected. If you upload this tool's SARIF with your own workflow step, point it at the new output.

0.12.3 — 2026-09-14

The documented suppression works. paths: ["tests/"] is the example README, docs/design.md and the skill show for Bandit's B101, and it has never suppressed a single finding.

Fixed — a paths: glob means what an exclude pattern means (D21)

.scignore.yaml paths: globs were matched with bare fnmatch, while exclude_patterns, test_patterns and docs_patterns use this tool's own matcher. Under fnmatch a trailing / means nothing, so tests/ matched a file literally named tests/ and no finding anywhere. paths: now uses the same matcher:

What widens. Entries that name a directory with a trailing /, or a bare file name, now cover what they appear to name. Entries that give a full path, which is the common form, are unchanged. Every paths: entry in this repository's .scignore.yaml and in maintainability-agent's selects exactly the files it selected before. A bare-name entry such as conftest.py now also covers pkg/conftest.py. To mean the root only, write a leading slash: /conftest.py.

A suppression and an exclusion are now tested to agree on every case the exclude matcher is pinned to, every shipped default, and every paths: example in the documentation. The README entry runs end to end against the real Bandit.

0.12.2 — 2026-09-14

A reviewed suppression suppresses. paths: entries in .scignore.yaml never matched Bandit, gitleaks, Semgrep or any other adapter-built finding, so findings an operator had reviewed reported as live — five criticals on maintainability-agent. The defect was not in any one scanner: Finding.file_path had no single convention. D20 gives it one.

Fixed — finding paths are repository-relative, set in one place (D20)

Scanner._rooted made adapter-built paths absolute, while Checkov, Trivy and --sarif-import findings stayed relative. findings.anchor now converts every finding once, as the CLI collects it: a POSIX path relative to the repository root, or absolute for a location outside it. _rooted is gone.

What that fixes, consumer by consumer:

Tested over every registered scanner, through the real CLI pipeline, in both path shapes; plus real Bandit and gitleaks runs against an absolute and a . target. Each compatibility rule and each root join was mutated and caught.

Migration — nothing to do, and one thing worth doing

Fingerprint values change for every finding whose path used to be absolute: every adapter that builds findings through Scanner._make_finding — Bandit, gitleaks, Semgrep, RuboCop, njsscan, gosec, hadolint, OSV-Scanner, pip-audit, npm audit, TruffleHog, Scorecard and the built-in rules. Findings that were already relative to the scan root keep their ids; Checkov's were, as observed on maintainability-agent. JSON reports, SARIF and the Markdown report show the new ids and relative paths. What was already recorded keeps working:

Two things do not carry over. A --verify-against before-report written by 0.12.1 or earlier compares fingerprints, so re-run the before audit on 0.12.2. And a glob that relied on the checkout's own directory names, such as */my-clone/src/*, no longer matches — it only ever matched on that one machine.

Documentation — D3 amended: maintainability-agent runs this tool

Decided 2026-09-13. maintainability-agent 3.7.0+ runs secure-code-agent on every audit instead of only reading a security-pillar.json something else wrote, because nothing outside CI ever wrote one. The security-pillar.json contract is unchanged, there is no package dependency between the tools (MA 3.7.2), and --security-pillar still hands MA a document from a pipeline that runs this tool itself. docs/decisions.md D3 and docs/ma-integration.md say so. This project's [mcp] extra pins mcp<2 and must not share an environment with MA's MCP server.

0.12.1 — 2026-09-12

One contract, not two. 0.12.0 shipped --changed-only and left six documents describing it as reserved. A feature the docs deny is a feature nobody uses.

Fixed — the shipped contract is the documented contract

--changed-only is real. docs/product-intent.md, docs/design.md, docs/architecture.md (two places), README.md, skills/secure-code-agent/SKILL.md and the Copilot prompt all said it was reserved and exited 2.

test_agent_guidance.py had encoded the old refusal, so it passed while the guidance was wrong — it now fails on the stale wording instead, and a non-vacuity check requires the skill to actually recommend the flag. The flag had also never been exercised behaviourally; test_plan_features.py named it in a docstring and tested none of it. It now has a fixture proving the report is scoped, the grade is withheld, the whole tree is still scanned, and an unresolvable ref is an error.

Fixed — every surface says the same thing about an untriaged run

The terminal led with N finding(s), none triaged while the Markdown report and the PR comment still led with the letter. One audit said two different things depending on where it was read, and the report is the one a reviewer opens. Both now route through Verdict.headline().

Fixed — documentation that described older behaviour

docs/work-orders.md said §FIX and §REVIEW cap at 40 blocks. They cap at 12 and 12, with snippets trimmed to 3 lines and the §ACCEPT table to 8 rules.

The 0.12.0 note claimed "the differentiator is measurable". It is measurable at file granularity: a rewritten session model inside a cited file still reads as conformant. Walked back to what --verify-against actually reports. Whether a bounded work order keeps an agent in scope remains unmeasured — product-intent §8 Q2 stays open.

Note on release dates

0.11.0, 0.11.1 and 0.12.0 were dated 2026-09-11 here and shipped 2026-09-12 UTC. Corrected. Seven releases inside about twenty-four hours is a real cadence and not a typo; the dates should at least be true.

0.12.0 — 2026-09-12

Four review objections closed. Blast radius is measurable at file granularity, a PR-shaped audit exists, an untriaged run stops leading with a letter, and there is a chat door.

Note what this does not establish. Whether a bounded work order actually keeps an agent inside its scope is still unmeasured — product-intent §8 Q2 remains open. What shipped is the instrument: --verify-against now reports which files changed outside the cited set. It does not judge whether a change was appropriate, and it says nothing at line granularity.

Added — scope conformance in --verify-against

"Only verify is mechanical, and only for silencing and regressions — not for 'did you rewrite the session model.'"

Partly mechanical now, at file granularity. The work order cites files; the repository knows which files changed. --verify-against reports which changed files were never cited, as scope: conformant or scope: EXCEEDED.

A rewritten session model inside a cited file still reads as conformant. This narrows the question from "did it stay in scope" to "did it touch anything it was not asked about", which is the answerable half.

The JSON report records the commit it was taken at, which is the input this needs. Where it cannot be answered — no commit in the before-report, not a git repository — the scope reports itself unknown, never conformant. A failed measurement must not read as a clean result.

Exit semantics are unchanged: scope is reported, not gated. Changing what fails CI inside a reporting change is the defect this project keeps finding in other people's tools.

Added — --changed-only REF

"A PR-shaped tool that cannot do a PR-shaped audit is missing the run people will actually schedule."

The whole tree is still scanned — scanners read trees, not diffs, and Semgrep's cross-file dataflow is the obvious casualty of doing otherwise — and the report is scoped to files changed since REF. The grade is withheld, because a run that looks at less must not score better. A ref git cannot resolve is an error, not an empty diff.

Added — an MCP server

"It is named agent and there is no chat door."

pip install 'secure-code-agent[mcp]', then secure-code-agent-mcp. Tools: audit_repository, preflight, agent_info.

It never audits unasked — audit_repository without action="run" returns the question. It returns the work order first, because the score is second class. It drives the CLI by subprocess rather than reimplementing it, so there is one answer to "what did you find". No report is written into the audited tree; only .secure-code/history.jsonl is appended, because that is how the trend works.

Changed — an untriaged run leads with the work, not a letter

"A staff engineer who sees Django at F turns the gate off."

On a first run with findings and no baseline the headline now reads N finding(s), none triaged — a starting position, not a grade. The number is still in the report, the JSON and the trend; it is simply not the first thing said about a repository nobody has triaged.

Triage is decoupled from evidence: it does not withhold the grade. evidence_reasons answers did we look; triage answers did you review. Folding them together made a verified grade unreachable on first contact for any repository with one finding.

0.11.1 — 2026-09-12

Fixed — a vulnerable dependency was filed under "documentation"

requirements.txt matches the documentation pattern **/*.txt, and the axis split read the path before the category — so every CVE in a pip manifest was reported on the documentation axis. Eighteen of them on a six-file tree.

Classification now reads the category first, so a dependency advisory reaches the dependencies axis wherever the manifest lives, including under docs/ and tests/. Neither axis is scored, so no grade moves; what moves is the label, and the label is the product.

Found by building the --demo fixture in 0.11.0, shipped as a known defect in that release rather than fixed mid-flight.

0.11.0 — 2026-09-12

The first five minutes, and a work order that is a prompt again.

Added — --demo

secure-code-agent --demo generates a small application carrying seven real defects, audits it, and writes the work order. A fresh install pointed at any repository used to report coverage: FAILED and four missing scanners; pointed at this repository it reported 0 to fix, 0 to review and 1,108 suppression candidates, because a security tool's tests are deliberately vulnerable fixtures. Both are correct and neither demonstrates anything.

The fixture is assembled at runtime, never shipped — writing it into the package would put vulnerable source in every site-packages and make this project's own audit flag its own demo.

Added — install guidance where the failure happens

A coverage failure now names the install command for each missing scanner, from the same source --preflight uses. The guidance existed and the failure path printed only the names, so a first run was a scavenger hunt. The tool still installs nothing; naming a command is not running one.

Changed — the work order is a prompt, not a backlog

Django PyGoat
previously 1,954 lines 1,602 lines
now 254 229

Ordinary repositories land at 85–145 lines. §FIX caps at 12 findings, §REVIEW is one line per finding, quoted snippets cap at 3 lines — Django's twelve §FIX blocks had carried 212 lines inside code fences — per-finding metadata collapses from eight bullets to one standards line, and the constraints go from 30 lines to 10.

All ten hard constraints survive, and a test asserts each one is still present. Nothing is dropped silently: every truncation is stated and points at the JSON report.

Fixed — exclude patterns that matched nothing

A directory pattern holding a glob was inert: *.egg-info/ matched nothing while src/pkg.egg-info/ was scanned, and so were build-*/ and test_*/. The directory branch did no globbing at all. **/dir/ was one instance of that class, fixed earlier; this is the class.

Multi-segment patterns (calibration/.corpus/) are matched as a consecutive run of components — a regression introduced by the first fix and caught when the repository inventory started walking nineteen cloned corpus repositories it was configured to exclude.

Fixed — documentation that described an older tool

Every doc now carries a > Status: header naming the version it describes, and a test enforces it. README told adopters to pin @v0.3.0 — seven releases stale. docs/ma-integration.md documented schema_version: 1 and "a field may be added without a bump", contradicting the shipped contract and the rule agreed with the consumer.

Known

A dependency CVE on a requirements.txt is filed on the documentation axis, because the axis split reads the file extension. A dependency finding is not documentation whatever the manifest is called. Not fixed here.

0.10.0 — 2026-09-11

The scoring model changed, and it now says so. Two normalizer decisions (D16, D17) closed D5, which had been open since the first week. A scoring_model field lets a consumer tell the models apart.

Changed — the grade is a density, and secrets is a count

The normalizer was sqrt(LOC/1000), which under-corrected for size, so the ranking followed how big a repository is rather than how much is wrong with it. Flask carried 2.2× Django's finding density and normalized at half the value; grade correlated −0.37 with size. It is now a straight per-kLOC density, and the grade slope moved 0.5 → 1.3 with it (D16).

secrets is the exception and normalizes by sqrt(LOC/1000) (D17). OWASP Juice Shop carries four hardcoded API keys and three private keys and graded B+ under straight density, because 115,340 lines divided seven committed credentials down to nothing — while Flask, with no secrets at all, graded F. A committed private key is one committed private key whether the repository is a thousand lines or a million. Damped, not exempted: an absolute count failed Django on two low-confidence hits.

Every grade moves. A repository with committed secrets scores lower; a large repository with diffuse low-severity findings scores higher.

Added — scoring_model, and security-pillar.json schema 2

A scoring model can change without the document carrying it changing shape — same fields, a different number for the same repository. schema_version is now 2 and carries a top-level scoring_model integer, currently 2. v2 is v1 plus that one field; every v1 key keeps its name, type and meaning.

maintainability-agent keyed trend comparability on our release version, which opened a new series on every release including ones that changed no scoring. It now keys on scoring_model when present and falls back to the version when absent, so old documents keep working (D19).

SCORING_MODEL is pinned to a digest of the weight tables, the slope, the letter bands and the output of the real score() over a fixed matrix, so changing a constant or a formula without bumping it fails CI.

Fixed — Bandit's eval/exec were filed as OS command injection

B102 was mapped to CWE-78 and B307 had no curated entry, so it inherited CWE-78 from Bandit. CWE-78 is OS command injection — the shell weakness B602 covers. Both are CWE-95 now, matching what semgrep, RuboCop and the built-in rules already used.

This was not only a label. Corroboration merges across scanners on a shared CWE, so B102 never merged with the built-in sca.python.eval on the same exec(compile(...)) line and one defect scored twice; Flask carried four such pairs. is_top25 now follows the documented CWE-95 → CWE-94 ChildOf relationship, so describing the weakness accurately is not what removes its Top-25 weight.

Fixed — stored analysis output was scanned as source

This tool's own output is now excluded by default anywhere in a tree, along with .secure-code/ and .maintainability/. _own_artifacts only ever removed the paths the current run was about to write, so an archived copy was read as source: 556,808 LOC and 550 findings here, and on maintainability-agent 957,219 LOC of stored audit output about other repositories, which diluted five genuine criticals to an A−. A stored report quotes findings verbatim, so it manufactures findings about findings and inflates the LOC denominator at once.

Patterns are derived from DEFAULT_OUTPUTS, so adding an output cannot leave a readable artifact behind. vendor/ and third_party/ are deliberately not excluded — vendored code is deployed code (D18).

Fixed — a variable reference is not a credential

gitleaks.curl-auth-user fires CRITICAL on curl -sS -u "$SONAR_TOKEN:" in a GitHub Actions workflow, where no credential is present. Such a finding is now demoted to §REVIEW with the reason stated, checked by reading the source line back because gitleaks runs --redact and the variable name is exactly what got redacted.

Never to §ACCEPT: it still escalates through fail_on_category: [secrets] from any axis. curl -u "$USER:hunter2Passw0rd" stays in §FIX — half a line done correctly must not launder the other half. An unreadable file, an out-of-range line, or a history-only finding all leave the finding where it was.

Fixed — --target silently audited the wrong tree

--target names an agent for --init-agent-standards. On an audit run it was accepted and discarded, so secure-code-agent --target /some/repo audited the current directory and printed a grade for it. Now refused, with the working command in the message.

Calibration — D5 is closed

The corpus gained a bad end: five applications written to be vulnerable (OWASP PyGoat, NodeGoat, railsgoat, WebGoat, Juice Shop), cloned and read, never executed. Without them there was nothing to place D and F against, which is why the band question stayed open.

value
AUC (maintained scores above vulnerable) 1.00
separation +1.26
maintained median 3.41 (B)
vulnerable median 0.00 (F)
Spearman(LOC, grade) −0.05

Two band edges are measured: D/F at 1.00 falls inside the gap between the populations, and the centre lands in B. A−, B and C hold no observation at all, and no larger corpus fixes that — at that end the difference between two repositories is five findings versus eight in twenty thousand lines. Those edges are presentational granularity, not measured thresholds. A B+ is not meaningfully better than an A−.

test_scoring_drift.py now pins actual values as well as properties. Every assertion in it had been relational, so a full model retune passed it untouched.

0.9.0 — 2026-09-11

Two behaviour changes. The default gate is no longer empty, and the LOC denominator no longer counts lockfiles or documentation data.

Changed — the default gate is a ratchet

DEFAULT gates was {}. An absent gate cannot trip, so every audit "passed" — a 200,000-line repository carrying SQL injection, shell=True, pickle.loads and eval reported gate PASS out of the box. It now reports gate FAIL.

The default is {"fail_on_new": true}. Severity-based defaults were measured and rejected (D15): ["critical"] caught none of four known-vulnerable control repositories, and ["critical","high"] failed five of ten well-maintained ones while still missing SQL injection and pickle.loads, which Bandit rates medium. The ratchet reads no severity at all and so inherits none of that — a false positive is baselined once and never asked about again.

An operator who writes a gates block gets exactly what they wrote; the default applies only when none is written. "gates": {} still means report-only, explicitly.

Fixed — a first run was reported as a regression

The gate said 2 new finding(s) since baseline with no baseline in existence, claiming those findings appeared after one. On a first run the truth is that there is nothing to compare against, and that is now what it says, along with what to do next.

load() returned an empty mapping for a baseline that was absent, unreadable or genuinely empty, so a corrupt baseline silently turned an established repository back into a first run and failed the build with a message blaming the code. The three states are now distinguished and each says which it is.

Fixed — the denominator counted things that are not code

docs/scoring.md defines the divisor as "scanned code volume". Two kinds of line were in it that are not:

Grades move down where non-code was inflating a denominator — fastapi 2.47 (C) to 1.09 (D) on a tree that is 23,764 lines of code rather than 35,655. No repository improved. The examined-corpus median holds at 3.36, in the B band, so the calibration target is unaffected.

sqrt(LOC) is unchanged. Per-kLOC was measured and is worse on the case that matters: it grades a 200,000-line repository containing SQL injection A+ rather than A−.

0.8.0 — 2026-09-11

Fixed — "gate PASS" was printed when no gate existed

Out of the box, with no configuration, a 200,000-line repository containing SQL injection, shell=True command injection, pickle.loads, MD5 and eval reported:

score 4.06 — grade withheld (A- unverified)  ·  gate PASS

Every word is technically defensible. The grade was withheld. No gate tripped, because DEFAULT gates is {} and an absent gate cannot trip. And the line still tells a reader their repository is fine. The work order was correct at the same moment — seven findings in §FIX, the SQL injection and the shell=True among them — so the first-class output worked and the summary undid it.

Three states now, where there were two:

gate FAIL            — a configured gate tripped
gate PASS            — a configured gate held
gate NOT CONFIGURED  — nothing was checked

An unconfigured run says so in words and points at the work order. gate.configured and gate.enforced are in the JSON report for consumers that read passed as a security signal.

passed is unchanged. No gate tripped, which is true, and flipping it would fail every ungated CI job that exits 0 today — a behaviour change hiding inside a reporting fix. Inert configuration is still not enforcement: an empty fail_on_severity list and a min_score of 0 both read NOT CONFIGURED.

Recorded — D15: a scanner's severity is not a measure of consequence

Four problems solved or attempted on this project turned out to be one problem: the score grading Bandit's talkativeness, B105 scoring 0 useful hits in 22, a proposed severity floor, and proposed default gates.

A scanner's severity answers "how confident am I that this pattern is present", not "how bad is this in your code" — Bandit rates SQL injection medium and hashlib.md5 high. Six candidate floor rules were measured against the corpus and four known-vulnerable controls; none separates. Two candidate default gates likewise: critical-only catches none of four vulnerable controls, critical+high fails five of ten well-maintained repositories.

Severity remains a weight in the score and a filter an operator configures. Nothing may use it to identify the dangerous findings. The full data is in docs/decisions.md D15, including the one mechanism measured to work — fail_on_new, which ignores severity entirely and therefore does not inherit the flaw.

0.7.0 — 2026-09-11

Nine defects, all found by running the tool against repository shapes and content the calibration corpus does not contain. Every one survived 526 tests and a clean self-audit.

Fixed — targets that are not a tidy repository full of code

Fixed — the corroboration merge was hiding findings

Bandit files B602 (shell=True), B603 (subprocess call) and B607 (partial executable path) all under CWE-78. Keying the merge on the CWE collapsed them, so subprocess.call('ls', shell=True) reported B607 alone with the shell=True buried inside it as a footnote — the more serious of the two, missing from the work order.

A shared CWE is no longer sufficient. Two rules from the same scanner merge only through the hand-checked alias table; different scanners still merge on a shared CWE, which is the cross-tool corroboration the function exists for.

test_two_different_weaknesses_at_one_line_stay_two_findings has asserted this since the merge was written and passed throughout, because its fixtures carried no CWE. Reading Bandit's CWEs in 0.6.0 gave them one and turned a real test into a false assurance.

Fixed — a category nothing could read was graded 5.0

A Dockerfile with USER root and chmod 777, beside Terraform with a public-read bucket and a 0.0.0.0/0 ingress rule, scored config_iac 5.0 with neither checkov nor hadolint installed.

A hand-written list claimed builtin_rules covered secrets and config_iac — it has never had a rule for either — and omitted supply_chain, which it does cover. The set is derived from the standards map now, so it cannot drift from the rules it describes.

Fixed — Terraform lines were not counted

checkov is in the floor and reads .tf, so its findings were scored against a denominator that excluded every file it had read. .tf and .tfvars join DEFAULT_INCLUDE_EXTS.

Fixed — SARIF raised thousands of alerts for test fixtures

SARIF is consumed by code-scanning platforms that turn each result into an alert, and suppressions is the field they honour. We wrote properties.suppressed instead — a field of our own invention no consumer reads. So findings the operator had explicitly suppressed in .scignore.yaml still became alerts, and so did every test-tree and documentation finding.

On a large real repository that was 4,929 results of which 4,847 were test fixtures — 4,847 alerts for deliberately-vulnerable test data, burying 82 findings in the shipped source. The same audit now raises 82. Suppressed, not omitted: every finding stays in the document with its location, axis and justification.

Fixed — semgrep resolved but did not run

Semgrep deprecated python -m semgrep in 1.38.0: it prints a notice, exits 0, and analyses nothing. The adapter declared that fallback, which fires whenever the module is importable but the binary is off PATH — the normal state after installing njsscan, which pulls semgrep in as a dependency.

Coverage caught it, so no grade was ever claimed on a scanner that had not run, but it reported "semgrep failed" to operators whose situation was "semgrep is not on PATH". bandit, njsscan, checkov and pip-audit all still run correctly under python -m and keep the fallback; a test now asserts any declared python_module actually exposes a __main__.

Known, measured, not changed

A repository with large checked-in JSON artifacts scores better for having them: the same tree measured A- (4.02) with 879k lines of generated reports counted and D (1.57) without. docs/scoring.md defines LOC as "scanned code volume", which those lines are not. The fix is the same open question as the sqrt(LOC) size bias — what the denominator should measure — and is recorded rather than guessed at.

0.6.0 — 2026-09-11

Work orders are the first-class output

The remediation prompt is now written on every run, alongside the report. _resolve_outputs read config for the report and nothing at all for the prompt, so the artifact that grades your code was always written and the artifact that fixes it needed --prompt-output. prompt_path had been declared in DEFAULT_OUTPUTS the whole time and the resolver never read it; four of six keys were dead the same way. Set any outputs.*_path to null to disable one — which the loader used to reject despite the docstring promising it.

Findings are tiered: §FIX (patch it), §REVIEW (confirm first, reason stated per finding), §ACCEPT (test tree and documentation — propose a suppression, do not patch). A flat list gave a shell=True command injection and a PASSWORD_FIELD = "password" name-match identical billing.

A noisy rule is demoted, never dropped. Bandit's B105 produced zero useful hits out of 22 across the calibration corpus and still caught a planted hardcoded credential, so the matched value is judged as well as the rule.

Work-order paths are repository-relative. §ACCEPT is summarised by rule with a drafted suppression, and §FIX/§REVIEW cap at 40 blocks and say what they left out — auditing this repository produced a 541KB, 15,390-line work order before that, now 4.8KB and 111 lines.

New — --verify-against, proving the work order helped

secure-code-agent . --verify-against secure-code-report.json

Re-audits and reports what was fixed, what is still open, what was silenced rather than repaired, and what this work introduced. Exits nonzero unless the run passes.

Silencing is the case worth having. Lint disable has been in the README's anti-pattern table since the first commit and hard constraint 6 forbids it; forbidding is not detecting. Bandit honours # nosec itself, so a silenced finding stops arriving and reads as repaired. The source is now read back around the reported line.

Test-tree and documentation findings are reported but never required, since §ACCEPT tells the agent not to patch them.

New — scan history and trend

Every run appends one line to .secure-code/history.jsonl and prints the movement: 3.90 (B+) — up 3.86 from 0.04 (F), 3 scored runs. Append-only; a malformed line costs one run rather than the history. A run too thinly covered to grade records null and is skipped rather than plotted as a collapse to zero.

Changed — repeats of one rule saturate

This moves scores. Sorted worst-first, a rule's k-th hit now counts weight / sqrt(k). One rule firing 56 times is one fact observed 56 times, not 56 independent defects; distinct rules still add in full.

Grading Django, FastAPI, httpx and Flask all F was measuring how talkative Bandit is on large Python codebases. Django's "hardcoded passwords" are EMAIL_HOST_PASSWORD = "" and SECRET_KEY = ""; its "SQL injection" hits include an ImproperlyConfigured error message and a parameterised cursor.execute. Corpus effect: httpx 0.13 F → 2.58 B−, fastapi 0.00 F → 2.47 C, requests 2.54 → 3.98 B+. No repository scored lower.

Fixed — 86% of findings carried no CWE

Bandit publishes a CWE for every plugin and gosec for every rule, and both were discarded — gosec went as far as formatting its CWE into the message text and never setting canonical_cwe, so scoring, the Top-25 multiplier and the SARIF taxonomy all saw nothing. Corpus CWE coverage 14% → 100%; OWASP is derived from the CWE where the curated map is silent, reaching 62%, and stays null where the standard has no category rather than being invented.

Fixed — exclude_patterns never applied to gitleaks findings

gitleaks reports repository-relative paths and every other scanner reports absolute ones. Every consumer that asked "where is this?" resolved a relative path against the process working directory, so the answer came back "not excluded" — silently, and fail-open. Four tests/certs/*.key files held requests at F and six documentation examples held flask there. Paths are now anchored at the one constructor every adapter passes through.

Separately, **/ did not match at depth zero, so **/*_test.go never matched a root-level context_test.go.

Fixed — gitleaks could not see the working tree

detect --source scans commits only, so a plaintext key sitting uncommitted produced "no leaks found". Both gitleaks dir and gitleaks git now run; the overlap merges.

Fixed — the tool audited its own output

secure-code-report.md lands in the audited tree and the next run scanned it, finding a "secret" at line 11,529 of its own report. Two identical audits of an unchanged repository returned 0.00 and 4.25. The run now drops the paths it is about to write — and their lines — from both its findings and its LOC denominator.

Fixed — other

Calibration — the study was measuring the wrong population

Six of fourteen corpus repositories scored near-perfectly because nobody had looked at them: Python produced 712–5,107 real findings each, everything else 0–4. njsscan and rubocop were not installed; Go and Java cannot be read by this floor at all (D12). The four unreadable repositories had their own median of 5.00 and were holding the corpus median up.

The study now reports the distribution over repositories a language scanner actually read — examined median 3.38, in the B band, which is the target — and names what it excluded. No band edge needed moving.

njsscan and rubocop are required for a valid calibration run.

0.5.0 — 2026-09-10

Fixed — 0.4.0 reports itself as 0.3.0

__version__ was a literal in src/secure_code_audit/__init__.py and the version was also a literal in pyproject.toml. They drifted: pyproject moved to 0.4.0 and the package did not.

The released 0.4.0 wheel on PyPI contains __version__ = "0.3.0". Verified by downloading it. Every artifact that build produces names the wrong producer: SARIF tool.driver.version, the JSON report's version, the Markdown report header, --version, and security-pillar.json's producer.version.

The release workflow verified the tag against pyproject's line and never read the package, so it checked the half that was right and shipped the half that was wrong.

PyPI is immutable, so 0.4.0 stays wrong. If you have artifacts produced by 0.4.0, their producer version is understated by one release. Upgrade to 0.5.0, which is the first build whose artifacts name themselves correctly.

The fix removes the duplication rather than policing it. pyproject.toml now declares dynamic = ["version"] and reads secure_code_audit.__version__, so there is one literal and the two cannot disagree. The release workflow reads the package. A first attempt used importlib.metadata instead and was worse — it reports whatever distribution happens to be installed, which in a working checkout was a stale 0.1.0, and provenance that depends on the reader's install state is not provenance.

Added

0.4.0 — 2026-09-09

Correction. This build reports itself as 0.3.0 in every artifact it produces — see 0.5.0 above. The release is otherwise as described; only the producer version is wrong, and PyPI cannot be amended in place.

All eight v0.3.0 release blockers are closed, and so are the four structural problems the architecture audit ranked (architecture.md §2, §3, §4). Two decisions remain open on purpose and are named at the foot of this section.

Changed — behaviour that will move existing results

Pre-1.0, and these are corrections rather than features, but a repository audited with 0.3.0 can score differently under 0.4.0:

Added

That entry originally continued "nothing in the floor reads JavaScript, Go, Ruby or Java without the network". It was asserted without running any of those tools, and two of them existed — see D12 below, which is the same mistake caught a second time and is why the coverage check is now automated per language. - product-intent.md §5 principle 2 amended. It read as an unqualified refusal to author rules, which we already violated twice over. The bound that replaces it: our ruleset is the offline floor, not a competitor — language primitives in, framework rules out, enforced by a test rather than by review. See D9.

Added — the coverage check, and the tools it found

Fixed — defects found by the tool auditing itself

Fixed — remaining known defects

Security

Fixed

Documentation

Still open, deliberately

Neither is a defect, and neither can be settled by a refactor — both are about whether the number this tool reports means anything.

0.3.0 — 2026-08-09

Coverage-integrity release. A clean finding set and successful scanner coverage are now reported and gated separately.

Added

Fixed

Documentation

0.2.0 — 2026-05-13

The Tier-2 release. Six new scanners ship as full adapters with standards mappings and mocked-subprocess test coverage.

Added — Tier-2 scanners

Added — standards mappings

New (scanner, rule_id) → CWE/OWASP/ASVS/SSDF mappings for all six Tier-2 scanners. Scorecard checks get per-check mappings for Branch-Protection, Signed-Releases, Pinned-Dependencies, Token-Permissions, Security-Policy, Dangerous-Workflow plus a wildcard fallback.

Fixed

Refactored

Tests

0.1.0 — 2026-05-13

Initial public release.

Added

Known limitations