secure-code-agent docs
Product and intent

secure-code-agent — Product Intent

Status: v0.13.2 — 2026-10-30. The single source of truth for why this exists, who it serves, and what it refuses to become. Companion docs: design.md for how it is built, architecture.md for where the build diverges from the design, threat-model.md for what it defends against.

1. Intent in one sentence

Give a repository with AI coding agents in the loop a deterministic security gate, and give the agent a bounded task brief so that fixing a finding does not become an unreviewed rewrite of the security-critical code around it.

Every part of that sentence constrains the product. Deterministic rules out an LLM in the audit path. Gate rules out an advisory dashboard. Bounded task brief is the differentiator — it is the thing no existing scanner ships.

2. The problem

Security scanners are a solved-enough problem. Semgrep, Bandit, CodeQL, Trivy, Gitleaks, and OSV are mature, well-maintained, and better at finding defects than anything this project would write. The problem is not detection.

The problem is what happens after detection, once an AI agent is the one holding the patch. Pointed at a security finding, agents exhibit a consistent and documented set of failure modes:

Anti-pattern What the agent actually does
Crypto roulette "Replace MD5 with SHA-256" → rewrites the hashing module using a library it saw in training data.
Auth-flow rewrite "Fix the IDOR" → refactors the session model. Now there is an unaudited new auth path.
Validation softening "Make the tests pass after the fix" → weakens the regex or removes the bounds check.
Test deletion "The security test is failing" → deletes the test.
Lint disable "This rule fires repeatedly" → # nosec, # noqa, eslint-disable everywhere.
Scope creep "I fixed the SQLi" → followed by 600 lines of unrelated refactoring.
Dependency thrash "Bumping the vulnerable package" → introduces twelve unrelated new dependencies.
Silent behavior change "It works now" → same input, different output. Downstream callers break.

Each of these turns a known, scoped, one-line defect into an unknown, unscoped, unreviewed change to security-critical code. The net security posture can be worse after the fix than before it, and the review burden lands on a human who was already saturated — which is why the agent was doing it.

Existing scanners stop at "here is a list of findings." None of them ship a prompt back to the agent that says fix only these findings, do not touch crypto, auth, validation, or logging, preserve behavior, add a test that fails before and passes after.

That gap is the product.

3. Who this is for

Primary: the engineer who owns a repository where agents write code. They need a CI gate that fails honestly, and an artifact they can hand to Claude Code, Codex, Cursor, Copilot, or an SDK agent that constrains the fix. They are not a security specialist and should not need to be.

Secondary: the security engineer standardizing multiple repositories. They care about the standards anchoring — CWE, OWASP Top 10, OWASP ASVS, NIST SSDF — because they report against frameworks, not scanner rule ids. They need SARIF out and plain files in git, not a SaaS they must procure.

Secondary: the platform/CI owner. They care that it is one binary, runs the same locally and in CI, has a meaningful exit code, and never phones home.

Explicitly not for: teams looking for a SAST engine, a runtime defense, a vulnerability management SaaS, or a compliance dashboard. See §6.

4. What success looks like

The product is working when all of the following hold. These are the criteria the design already implies; they are written down here so future features can be judged against them.

  1. A green gate means something. The gate cannot pass while a required scanner failed to run. A clean finding set with a broken toolchain must be reported as incomplete, never as safe. This is non-negotiable and was the subject of the v0.3.0 coverage-integrity work.
  2. The audit is reproducible. Same inputs, same commit, same scanner versions → same score, same exit code. No LLM in the audit path, no network dependency in scoring, no wall-clock dependence except suppression expiry.
  3. The remediation prompt actually bounds the agent. The patch that comes back touches the named findings and little else, preserves behavior, and carries a test that fails pre-fix and passes post-fix.
  4. A finding survives a reformat. Baseline identity is stable across whitespace-only edits, so adopting the tool on an existing repository does not produce a wall of false "new" findings.
  5. Suppressions cannot rot. Every suppression carries a reason and an expiry, and an expired one becomes a hard failure rather than silently continuing to hide a defect.
  6. The tool is honest about its own limits. Documented behavior matches executable behavior. Claims that cannot be substantiated are removed rather than softened.

Criterion 3 is currently unmeasured. We assert the prompt bounds the agent because it encodes the anti-patterns in §2 as explicit constraints, but no empirical evaluation has been run. This is stated as an open question in §8 rather than claimed as a result.

4a. What it promises

Adopted from maintainability-agent's product intent, in the same shape and with the same second column, because a promise that cannot be falsified is a slogan. Where a promise reads differently here, it is because the failure mode differs — most sharply at P3, where this tool's score is a rate over findings, so withholding a scanner makes the number go up.

The pair is deliberate: MA's ADR-007 delegates the Security pillar to this tool, and two tools whose promises are worded differently cannot be read together.

Promise Violated by
P1 The audit is deterministic: same tree, same config, same pinned scanner versions in — same findings, coverage and score out. The analysis invokes no language model. Two runs disagreeing on identical inputs, or a model in the gate path
P2 The same rubric applies to every repository, and the rubric is readable in source A repository-specific code path changing a weight or a band
P3 Neither withholding nor supplying evidence can improve the reported grade. The score is a rate over findings, so removing a scanner removes findings and the raw number rises — coverage is a separate axis for exactly this reason, and a grade is withheld when it cannot be supported. The audited tree's own configuration is not evidence: it cannot re-label a finding's severity or category (D32) Any input whose removal or addition raises the graded field. Measured twice at 0.00/F to 5.00/A+ — once by disabling scanners, and once by an in-tree severity_overrides, which is what added the second half of this promise
P4 The overall score is the worst category printed beside it, never their mean A report where the arithmetic does not check
P5 The remediation prompt names only findings the audit actually produced A prompt instruction with no corresponding finding
P6 Every empirical claim in this repository is reproducible from checked-in pinned inputs A quoted number that cannot be re-derived offline
P7 A score is issued only where enough was examined to support it, and never as a consequence of not looking A number a reader with the repository in front of them would call absurd
P8 Every report states what examined it — which scanners ran, which did not, at what version, and over what scope A reported value with no attributable source, or a run whose coverage cannot be recovered from its output

P6 was unmet and now is not. The calibration study (calibration.md) originally ran Semgrep in its default online mode, so its registry rules were unpinned and its numbers could not be re-derived. It has been re-run against sca-offline@1.1.0 (357b8d7e6652) and now re-derives exactly, at the cost of breadth — a note the study carries, because bands chosen from a pinned offline run do not transfer to an online one.

P3 and P7 are why the pillar artifact withholds a condition. A repository where nothing could run reports zero findings, and zero findings grades 5.0. Handing that number to maintainability-agent would launder an unscanned repository into a pillar report that looks measured, so condition is null whenever coverage is incomplete. See ma-integration.md.

5. Product principles

Each principle exists to settle a class of future argument. When a proposed feature conflicts with one, the principle wins unless it is explicitly revised here.

  1. Deterministic first, AI optional. The audit never calls an LLM. The remediation prompt is a generated file the operator chooses to hand to an agent. Consequence: no "AI triage" of findings, no LLM-assisted severity scoring, no model in the gate path.

  2. Orchestrate, do not reimplement. We shell out to best-in-class scanners and normalize their output. Consequence: no custom AST analysis, and no parallel ruleset competing with Semgrep or Bandit.

Amended 2026-09-09 — the line is a bound, not a refusal. This read as a blanket ban on authoring rules, and we author rules: the built-in regex pack, and the offline Semgrep ruleset that exists because the registry rules an online run fetches are licensed for internal, non-competing, non-SaaS use only. Left unqualified, the principle would have been violated the moment we shipped either.

The bound that replaces the ban: our ruleset is the offline floor, not a competitor. Online, the maintained registries do the job, because online is where they are available. Offline, our rules answer the highest-consequence patterns so that "no network" does not mean "no coverage."

That bound is enforceable because it is about rule shape: the offline set takes language primitives — shell=True, eval, pickle.loads, hashlib.md5, verify=False — and refuses framework-specific rules. Primitives do not change and cost almost nothing to keep correct. Framework rules rot with every framework release, and framework breadth is exactly what the online path is for. See D9.

Parity with a registry remains a non-goal. A one-maintainer project cannot maintain thousands of rules, and a stale ruleset claiming to be a standard is worse than a small one honestly labelled.

  1. Never overstate coverage. Findings and scanner coverage are separate axes and are reported separately. A scanner that was unavailable, timed out, crashed, produced unparseable output, or was excluded from the run fails coverage rather than reading as clean. Consequence: the honest answer is sometimes "we do not know," and the product must be able to say it.

  2. Never install anything. A security gate that downloads and executes binaries to satisfy its own coverage requirement is the supply-chain risk it exists to catch. Acquisition stays with the operator's package manager or a pinned CI action. Consequence: onboarding friction is accepted as the price of not being a malware delivery vector.

  3. Standards-anchored, not scanner-anchored. Findings map to CWE, OWASP Top 10, OWASP ASVS, and NIST SSDF. Consequence: operators see which standard is failing rather than which scanner shouted, and reports survive swapping one scanner for another.

  4. Plain files, no lock-in. Markdown, JSON, SARIF 2.1.0, PR comment, baseline — all plain files in the repository. Consequence: no hosted service, no account, no telemetry, ever.

  5. Bounded remediation. The prompt forbids touching crypto, auth, validation, logging, and tests unless a finding names them as the defect. Consequence: the product deliberately refuses to help an agent do a large refactor, even when the refactor might be correct.

  6. Say the true thing. Documentation describes shipped behavior. When a claim cannot be substantiated it is removed, not hedged. Consequence: release notes will sometimes read as retractions, and that is correct.

  7. Standalone first, delegate second. This tool is designed to be run on its own. Feeding maintainability-agent's Security pillar is one thing it does (D3), not what it is for: a repository that has never heard of that project installs this one, runs it, and gets the whole product. Consequence: it owns its own environment. Its dependency constraints are chosen for a tool standing alone, and it is never obliged to share an interpreter, a virtualenv, or a resolved dependency set with anything it integrates with — including its sibling.

The concrete case, so the consequence is not abstract: this project's mcp extra requires mcp>=1.0,<2 and maintainability-agent requires mcp>=2. Those cannot both be satisfied in one environment, and that is not a defect in either. Two independent products pin independently. The integration between them is a file — security-pillar.json — precisely so that it never becomes a shared dependency graph. An environment that needs both installs both, separately.

6. Scope boundaries

In scope. Static analysis orchestration; dependency and supply-chain posture; secret detection; IaC and container configuration; standards mapping; deterministic scoring and gating; baseline and suppression lifecycle; SARIF in and out; bounded remediation prompt generation; per-agent standing-instruction files.

Out of scope, permanently.

Deferred, not rejected. These are tracked so that "not yet" is not confused with "never," and so a contributor knows what a good proposal looks like. This is the product roadmap: a shipped entry is struck through and says so rather than being deleted, because what was once deferred and why is part of the record. Two entries below had shipped without being struck, which is how a roadmap stops being one.

7. Positioning

This is not a competitor to Semgrep, Bandit, Trivy, or CodeQL. It is a layer above them, and it is strictly worse than any of them at finding defects. Its value is in four things they do not do together:

  1. Normalizing many scanners into one standards-anchored finding schema.
  2. Turning that into a deterministic, configurable gate with a real exit code.
  3. Reporting scanner coverage as a first-class result, so a green build cannot silently mean an empty one.
  4. Emitting a bounded remediation prompt for the agent that will do the fix.

It is the security sibling of maintainability-agent: same shape — deterministic CI gate, plain-file outputs, per-host skill bundle — different concern.

Sibling, not component. That sentence describes a family resemblance, not a dependency. This tool is not the security half of that one: it installs, runs, gates and reports with that project absent, and most of its users will never install it. Where the two are used together, one writes a file and the other reads it — see principle 9 in §5 and D3.

8. Open product questions

Recorded rather than resolved. Each of these should be answered deliberately rather than settled by whichever feature lands first.

An answered question keeps its number, its original text and its verdict. The numbers are cited from source comments and test docstrings, so renumbering would silently break them; and the reasoning that made something a question is worth keeping beside the answer. A verdict of declined or deferred is an answer — the failure mode here is a question that stays open because nobody wanted to be the one to close it.

  1. Should the deliverable be one verdict or two numbers? Today a report carries a letter grade and a coverage status, and the reader must combine them. The score's null state is "perfect" — a repository where nothing ran still grades A+ — which is why coverage had to be added beside it. See architecture.md §5.

Deferred 2026-10-02. D16 and D17 settled the score model on measurement three weeks ago; reopening how it is presented now would churn the one part of this that is finally calibrated. It belongs at v1.0, when the config schema locks and the output contract is the thing being frozen. 2. Does the remediation prompt measurably bound agent behavior? Criterion 3 in §4 is asserted from first principles and has never been evaluated. An honest answer needs a fixture repository, a set of seeded findings, and patches from several agents scored against the constraint list.

Scheduled 2026-10-02, as feature-sized work rather than a backlog line. Design fixed 2026-10-03 in remediation-prompt-evaluation.md: hypothesis, fixture requirements, the two arms, the scoring rubric (which is remediation.md's own ten constraints), blinding, and — written before any run — the four ways the result would be worthless. Not yet run; the method is fixed in advance so that it cannot be chosen to suit the answer. It is the only success criterion in §4 with no evidence behind it, and it is the central one. The deliverable is a fixture repository, a seeded finding set, and patches from several agents scored against the constraint list — a measurement, not an argument. 3. Who owns the standards mapping? It is compiled into the package today, so adding a rule requires a release and operators cannot extend it. Shipping it as data with an operator overlay is a product decision about how much customization to invite.

Answered 2026-10-02 — ship it as data, with an operator overlay. Built the same day: data/standards.yaml plus a standards_overlay config key. The overlay may add or correct a rule's CWE, OWASP id, ASVS section, SSDF practice and prose, and is refused if it names severity, confidence or category — see D32's scope note, which this is. Decided together with standards.py's size finding, which is this same problem seen from the other side: 705 lines, roughly 390 of them a hand-written table that needs a package release to extend, with the (scanner, "*") wildcard absorbing the coverage gap. One change answers the product question and clears the file finding. 4. How far does "never install anything" extend? It is settled for scanner binaries. It is not settled for whether the tool should offer to verify installed scanner versions against pinned expectations, which is adjacent to supply-chain assurance and might belong here.

Deferred 2026-10-02. Nothing is pulling on it — no operator has asked, and no finding depends on it. Recorded so it is not mistaken for settled. 5. What is the adoption path for a repository with thousands of existing findings? Baseline plus expiring suppressions is the current answer, but it has not been exercised on a large legacy codebase.

Deferred 2026-10-02, on this document's own terms: §6 refuses to build for a repository that is not actually being dogfooded, and no large legacy codebase is. The answer arrives with the first one that is. 6. Should preflight advise on a required scanner the operator switched off? --preflight --json builds a row per selected scanner, each carrying a remedy from the adapter's own unavailable_fix_hint(). A scanner that is required but not selected never gets a row: it appears only as a name in required_not_selected, with no advice attached. A host that wants to tell its user what to do must therefore write that advice itself — which is the thing this tool exists to stop other tools having to invent.

Raised by maintainability-agent, whose Decision 14 puts scanner knowledge in this tool, so MA deliberately wrote no remedy of its own and Grok's audit of MA 4.0.0 flagged MA as silent in that case. Verified here against cli.py: the row loop covers selected only.

Undecided, and the shape of an answer is not obvious. "Switched off" is an operator's choice, so advice about it reads closer to your config and your gate disagree than to install this tool — which may make it a gate-reporting concern rather than a preflight one.

Deferred 2026-10-02. By its own text the shape of an answer is not obvious, and nothing since has changed that. It stays open rather than being closed for tidiness. 7. Should a host be able to ask for a preflight that never reads the target's config? There is --config and there is --trust-target-config, but nothing that declines the audited tree's configuration outright: with --config omitted, config_mod.load discovers secure-code-agent.json inside the target.

A host asking before its user has consented to a run — MA's chat door does exactly this — cannot let the target's config decide anything. So MA writes an empty {} file and points --config at it. That works, and it is a workaround for a gap in this tool's surface. D29 closed the execution half of the same concern; this is the half that remains.

Raised by maintainability-agent (4.0.1 / D222). Verified here against cli.py. A flag of this tool's own — MA suggests --ignore-target-config — would replace the workaround if it belongs in the product.

Undecided, and not urgent on the consumer's own account. Asked directly whether the empty-file route is painful or merely inelegant, MA answered inelegant: a few lines in its _security_delegate.py, written to a temp directory outside the target, working against every release from 0.12.10. So this is a question about whether a cleaner surface is worth a third config mode, not about unblocking anything — recorded that way so nobody later reads it as a consumer waiting on us.

Answered 2026-10-02 — declined. Asked directly, the consumer called the empty-file route inelegant rather than painful: a few lines in its own _security_delegate.py, working against every release from 0.12.10. A third config mode is not worth a cleaner surface for a single caller, and declining a question is as much of an answer as building the flag. Reopen if a second host hits the same edge. 8. Does exit-code policy belong in _exec or in each adapter? _exec takes an allowed_exits tuple and does nothing with it: both branches return the CompletedProcess unchanged, so the parameter has no effect on any run. Fourteen adapters pass it, and several then write the same tuple a second time as a live check — allowed_exits=(0, 183) sitting beside if r.returncode not in (0, 183). That is the two-sources-of-truth shape this repository keeps finding, except here one of the two sources is inert.

The cost is visible in bandit_scanner.py, which declares (0, 1) to _exec and then checks no exit code at all beyond the timeout sentinel. A bandit internal error is caught only indirectly, by the JSON parse failing — which it probably does, but that is luck rather than a guard, and the adapter reads as though it had one.

Two coherent answers, and they differ in where knowledge lives. Delete the parameter, and each adapter keeps stating its own exit codes, which is what already happens; or make _exec enforce it, and the fourteen inline checks collapse into the one declaration. The second is the stronger design and the larger change: _exec returns a CompletedProcess, so enforcement needs a way to say "this exit code is not acceptable" that fifteen call sites can act on without each re-deriving it.

Found by the test seat while writing the first direct tests for _execution.py, which is the kind of thing a paired test finds and an indirect one does not. The docstring now states the truth; the parameter and its call sites are untouched pending this answer.

Answered 2026-10-02 — delete the parameter. Recorded as D30. The inline checks are what actually run, and each is correct for its own tool. Making _exec enforce the tuple means inventing a way to say "this exit code is not acceptable" that fifteen call sites must then act on, for no change in behaviour. So the parameter goes — and bandit_scanner.py gains the returncode guard it never had, because removing a false guard without adding the real one loses information rather than clarifying it. 9. Should a curated OWASP id outrank one derived from an overriding CWE? _resolve_standards lets an adapter's cwe_override beat the curated map for the CWE, but takes OWASP as entry.owasp_top10 or owasp_for_cwe(canonical_cwe) — so the curated entry still supplies OWASP even when its CWE was just overruled. With cwe_override="CWE-22" on bandit's B608, the finding reports CWE-22 (path traversal) alongside A03:2021-Injection, where the overriding CWE would have derived A01:2021-Broken Access Control.

It may be right: the curated row is reviewed and the derivation is a lookup table, so preferring the reviewed value is defensible. It may equally be a seam nobody chose, where an override is honoured for one field and ignored for the field computed from it. Either way a reader of the report sees a CWE and an OWASP category that disagree about what kind of defect it is.

Pinned by test_curated_owasp_wins_over_the_one_derived_from_an_overriding_cwe so the behaviour cannot drift while the question is open. Semgrep is the adapter that uses cwe_override, so it is the one whose output this decides.

Answered 2026-10-02 — the override wins both fields. Recorded as D31. A finding reporting CWE-22 beside A03 Injection is incoherent to whoever reads it, and an adapter asserting a more specific CWE is asserting the weakness rather than just its number. Semgrep is the only caller, so the change is small. The pinned corpus was going to be re-measured before and after, on the assumption that moving findings between OWASP categories moves D17's published figures. Checked instead of assumed, and it does not: scoring.py never reads owasp_top10, it is not an input to make_fingerprint, and suppressions do not match on it. The grade, the baseline identity and the calibration study are all untouched, so no corpus run was needed.