Skip to content

fix(apply): lint reads the same artifact shape apply consumes (A3)#2

Merged
henleda merged 1 commit into
mainfrom
fix-lint-wrapped-service-policy
Jul 27, 2026
Merged

fix(apply): lint reads the same artifact shape apply consumes (A3)#2
henleda merged 1 commit into
mainfrom
fix-lint-wrapped-service-policy

Conversation

@henleda

@henleda henleda commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes a false positive in the pre-apply policy linter. Spotted from real scan output:

  triage missing-authz-scenario-003 -> service_policy(full*), malicious_user(partial)
    ⚠ lint deny-unauth-x402-scenario-control: no DENY rule — refine will correct at apply

That policy has a DENY. The linter just couldn't see it.

Cause

generate emits a service-policy artifact in one of two shapes — a bare spec, or a full XC
create body {metadata, spec}. apply_from_scan handles both, and says so:

# Generated artifacts vary — some are full {metadata, spec} objects, some a bare spec.
if isinstance(art.get("spec"), dict) and art["spec"]:
    spec, src_meta = art["spec"], (art.get("metadata") or {})

lint_service_policy didn't. It read spec["rule_list"] straight off the artifact, which on a
wrapped one is Nonerule_list sits a level deeper — so it saw zero rules and returned
["no DENY rule"].

In a 12-policy crAPI/x402 run, 6 policies were wrapped and all 6 drew the false positive:

WRAPPED  ['no DENY rule']  deny-negative-pay-amount.json
WRAPPED  ['no DENY rule']  deny-unauth-x402-scenario-control.json
bare     []                deny-sqli-login-trigger.json

deny-unauth-x402-scenario-control contains rules[0].spec.action == "DENY" followed by an
ALLOW — exactly the shape the linter exists to approve. The cost isn't cosmetic: noise at a
50% false-positive rate trains an operator to skim past the one warning that matters.

Fix

Adds artifact_spec() — the same unwrap rule apply_from_scan uses — and applies it in
lint_service_policy, so the two can't drift apart again. Re-linting the artifacts from a real
run: 6 flagged → 0.

Scope

Deliberately not applied to lint_api_schema. Its consumer (console/app.py
_dispatch_action) uploads the artifact to XC's object store verbatim, without unwrapping —
so a wrapped api_schema genuinely would be broken and the linter's complaint there is a true
positive. Every api_schema artifact on disk is bare today; unwrapping in the linter would only
hide a future breakage. Noted rather than papered over.

Verification

  • 4 regression tests covering both directions: a wrapped policy with a DENY lints clean, and a
    wrapped policy with no DENY, or with an ALLOW before the DENY, is still caught — the fix
    must not blunt the linter.
  • Full suite 190 pass, ruff clean.
  • Re-linted every service_policy.*.json artifact on disk: 0 flagged.

Note

Pre-existing, from d751aa1 (the original A3 linter) — not introduced by #1. Whether #1's
switch to a full scan transcript is why it became noticeable is unclear: with ~10 findings the
lint lines may or may not have fallen inside the old 40-line window.

🤖 Generated with Claude Code

`generate` emits a service-policy artifact in one of two shapes — a bare spec, or a full XC
create body `{metadata, spec}`. `apply_from_scan` handles both and says so in a comment; the
linter did not. It read `spec["rule_list"]` straight off the artifact, which on a wrapped one
is None because rule_list sits a level deeper, so it saw zero rules and reported

    ⚠ lint deny-unauth-x402-scenario-control: no DENY rule — refine will correct at apply

for a policy whose rules[0].spec.action is "DENY". In a 12-policy crAPI/x402 run, 6 policies
were wrapped and all 6 drew the false positive — noise that trains an operator to ignore the
one warning that matters.

Adds `artifact_spec()` — the same unwrap rule apply_from_scan uses — and applies it in
lint_service_policy, so the two cannot drift apart again. Re-linting the artifacts from a real
run: 6 flagged -> 0.

Deliberately NOT applied to lint_api_schema: its consumer (console `_dispatch_action`) uploads
the artifact to XC's object store verbatim without unwrapping, so a wrapped api_schema really
would be broken and the linter's complaint there is a true positive. Every api_schema artifact
on disk is bare today; unwrapping in the linter would only hide a future breakage.

Regression tests cover both directions — a wrapped policy with a DENY lints clean, and a
wrapped policy that has no DENY, or an ALLOW before the DENY, is still caught. 190 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henleda henleda self-assigned this Jul 27, 2026
@henleda
henleda merged commit caba33e into main Jul 27, 2026
4 checks passed
@henleda
henleda deleted the fix-lint-wrapped-service-policy branch July 27, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant