Skip to content

Single-shot enhance never writes security_classification, so --exploitable-only/--exploitable-all select zero units and --limit loses its ordering #321

Description

@gadievron

Summary

--exploitable-only and --exploitable-all filter units by security_classification. Single-shot
enhance never writes that field — its result dict is a fixed six-key literal that does not include it,
and the single-shot prompt never asks for it. So after a single-shot enhance those flags select
zero units, always.

analyzer.py states the opposite contract in its own error message: "single-shot mode must populate
llm_context.security_classification". Nothing does.

The producer cannot emit the field

libs/openant-core/utilities/context_enhancer.py:349-356 — the whole of what single-shot writes:

                unit["llm_context"] = {
                    "missing_dependencies": analysis.get("missing_dependencies", []),
                    "additional_callers": analysis.get("additional_callers", []),
                    "data_flow": analysis.get("data_flow", {}),
                    "imports": analysis.get("imports", []),
                    "reasoning": analysis.get("reasoning", ""),
                    "confidence": analysis.get("confidence", 0.5)
                }

This is a whitelist, not a passthrough. Even a model that volunteers security_classification has it
dropped here. The single-shot prompt does not request it either — grepping the prompt body for
security_classification returns nothing.

Executed at b5019628

Real context_enhancer and real analyzer, with the LLM call stubbed (no API call, no spend). The
stub deliberately volunteered "security_classification": "exploitable" in its JSON response:

llm_context keys: ['additional_callers', 'confidence', 'data_flow', 'imports',
                   'missing_dependencies', 'reasoning']
classification: None
kept by --exploitable-all: 0

The volunteered value is discarded by the six-key literal, and the filter keeps nothing.

The consumer is fine; only the producer is missing

core/analyzer.py:59-74 already reads both shapes — it was written to fix the mirror-image bug, and
its docstring says so:

single-shot enhance writes unit['llm_context'] and historically had no classification at all

The author believed the producer had since been fixed. It has not. The reader is correct and reads a
key nobody writes.

The regression test is green because its input is hand-built

libs/openant-core/tests/test_enhance_resilience.py:283-285:

        # single-shot unit: classification lives under llm_context
        u = {"id": "x", "llm_context": {"security_classification": "exploitable"}}
        assert _unit_security_classification(u) == "exploitable"

That unit shape is constructed in the test, not produced by enhance_unit. The test exercises the
reader against an input the producer cannot generate, so it passes while the path is broken. Any
fixture built from the real producer would fail it.

Operator-visible symptom

core/enhancer.py:154,167:

    context_key = "agent_context" if mode == "agentic" else "llm_context"
    cls = ctx.get("security_classification", "unknown")

Every single-shot run therefore prints [Enhance] Classifications: {'unknown': N}. The stderr warning
in analyzer.py fires only when classified == 0 — which in single-shot is always — so it is a
permanent warning rather than an exception report, and it does not stop the run from analysing zero
units.

Second consequence: --limit loses its ordering

analyzer.py:98-119 (_apply_limit) prioritises by the same field. With every unit unclassified the
priority key is constant, so --limit N degrades to a head-slice in whatever order units arrive —
the behaviour the function exists to avoid.

Not documented as a reduced-capability mode

Single-shot is offered as a first-class fast/cheap option (cli.py:1413-1415,
apps/openant-cli/cmd/enhance.go:43), not as degraded. The one line that points the other way,
parsers/go/test_pipeline.py:977 ("Requires agentic mode to have classified units"), describes a
different filter inside a per-parser harness that reads agent_context only — and it is contradicted
by analyzer.py:546-548, which asserts the single-shot contract for the CLI path.

Suggested fix

  1. Add security_classification to the single-shot result dict at context_enhancer.py:349-356, and
    ask for it in the single-shot prompt, so the field the reader at analyzer.py:59-74 already
    handles is actually produced.
  2. Alternatively, if single-shot is meant to be classification-free, make that explicit: have
    --exploitable-only/--exploitable-all fail fast with a message naming the mode, rather than
    silently selecting zero units, and drop the contract sentence at analyzer.py:546-548.
  3. Rebuild the test_enhance_resilience.py:283-285 fixture from enhance_unit's real output rather
    than a hand-written dict, so the test can detect this.

Direction: today the filter is a total false negative on single-shot datasets — it analyses zero
units where it should analyse some. Fixing it increases the number of units examined, which is the
safe direction for a scanner.

What I am not claiming

  • I am not claiming this affects agentic mode. Agentic writes agent_context with the field
    present, and the filter works there.
  • I am not claiming a specific vulnerability was missed in a real engagement. The evidence is the
    executed producer/filter pair above, not an observed incident.
  • I have not measured how many users run single-shot with --exploitable-only.

Grounding curve (added 2026-08-22)

commit producer (6 keys) prompt omits it reader _apply_limit contract sentence test verdict
0d729f6 (initial) true true no filter absent absent absent FAILS
d710b90 (PR #23) true true reads agent_context only absent absent absent PARTIAL
ad17f3f (PR #133) true true true true true true FULL
b5019628 (HEAD) true true true true true true FULL

Attribution, and this is the part worth a maintainer's attention. The producer legs are
original codeenhance_unit's six-key dict is ^0d729f6, unchanged for the life of the repo.
Everything else was introduced by merged PR #133 (ad17f3f): it fixed the reader to be
mode-agnostic (_unit_security_classification, analyzer.py:59-74), added _apply_limit's
classification-based prioritisation, wrote the "single-shot mode must populate
llm_context.security_classification" contract sentence, and added the regression test that passes
on a hand-built unit
— without ever checking that enhance_unit could produce that shape.

So between PR #23 and PR #133 the filter also matched zero single-shot units, but for the
mirror-image reason this issue sets aside (the reader consulted agent_context alone). PR #133 is
what turned a reader bug into a producer bug
, and is where the fix and the test rebuild belong.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions