fix(action-surface): one permission list per action - #418
Merged
Conversation
A manifest row that listed `scopes:` and declared no `authority:` block at either site turned `verify --base` into `Internal error` (exit 4) on a legal manifest. The action's permissions were spelled twice and the two spellings disagreed: the action lens took the row's list when it had one and the source's auth scopes otherwise, while `_assess_authority` took the row's list only where a reviewed authority record existed. `CapabilityFactV1._semantic_projection_is_consistent` requires the two to project one list, so where they disagreed, rebuilding a capability fact from a serialized `ActionFact` raised — and that rebuild happens on exactly one route, the MCP capability comparison against a base scan. A plain `scan` never reaches it, which is why no sample and no scan-level test saw this. Declaring authority once per source (#410 increment 3) closed the reviewed half of that divergence by normalizing both manifest sites into one record. This closes the rest: `resolve_action_scopes` is the single derivation, read by the action lens and the authority dimension alike, rather than teaching the capability builder to paper over a disagreement it would then have to keep tolerating. Ten of the twenty-eight shapes in the new sweep raised before it. Because the shared rule is the one the lens already applied, no shape that resolves today resolves differently — every list that moves belongs to a shape that was exit 4. Publishing the row's list on the authority dimension is also what would let `scopes: [crm.read]` erase a `crm.write` grant the source proves, with a `structural` status and nothing raised. The subset rule a reviewed authority is held to now reads the *resolved* list, so a bare `scopes:` list is held to it too and reports `conflicting_authority_evidence` against `actions[].scopes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pengfei-threemoonslab
force-pushed
the
claude/hungry-villani-368127
branch
from
August 26, 2026 01:58
141d3a0 to
7794749
Compare
Review of the resolver caught it introducing a byte-identical copy of the lens's `_normalize_strings` — a fresh second spelling of a rule, in the change whose whole argument is that a rule with two spellings is a defect. Same body, same output, and nothing forcing them to stay that way. The primitive moves to `core/action_semantics.py`, the leaf module both readers already import, under a name that fits both lists an `action_surface.actions` row carries: `scopes` and `risk_tags` normalize identically because comparing them is what decides whether a declaration matches, broadens, or narrows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
scopes:and declared noauthority:block at either site turnedverify --baseintoInternal error(exit 4) on a legal manifest. The action's permissions were spelled twice and the two spellings disagreed: the action lens took the row's list when it had one and the source's auth scopes otherwise, while_assess_authoritytook the row's list only where a reviewed authority record existed.CapabilityFactV1._semantic_projection_is_consistentrequires the two to project one list, so where they disagreed, rebuilding a capability fact from a serializedActionFactraised.main.core.semantic_assessment.resolve_action_scopesis the single derivation, read by the action lens (replacing_declared_scope_strings) and by the authority dimension. The alternative — havecapability_fact_from_action_factread the semantic authority instead ofrequired_scopes— was rejected: it keeps two permission lists on one action (one published asrequired_scopes, one as the authority evidence) and leaves the invariant asserting a guarantee the engine no longer makes.scopes: [crm.read]erase acrm.writegrant the source proves, with astructuralstatus and nothing raised. The subset rule a reviewed authority is held to now reads the resolved list (_scopes_narrow_source), so a barescopes:list is held to it too and reportsconflicting_authority_evidenceagainstactions[].scopes. Adding the dropped scope back closes it; broadening is still a broadening.test_the_action_fact_and_the_assessment_publish_one_permission_list) explicitly excluded the bare-scopesshape because it did not hold. It now covers it, and its stale carve-out comment is gone.Pre-existing on
main(found while reviewing the #410 increment-3 branch; reproduces onmainboth before and after #417 landed).Type
Verification
CI is authoritative for
python -m ruff check .,python -m compileall -q src tests, andpython -m pytest.Additional local checks run, all on the rebased tree (branch is rebased onto
6f55acc3, the merge of #417):pytest -n auto -m "not perf" --ignore=tests/test_adapter_static_only.py— green; plustests/test_adapter_static_only.pyandtests/test_latency_budget.py -m perfas separate runs, both green.ruff check .clean.src/.git stash push -- src/then re-run: all four tests intests/test_action_scope_projection.pyfail, including the realverify --baseone at exit 4 with the exactCapabilityFactV1.authority.scopes must project semantic authoritymessage.checks/mcp_permissions.pyreachescapability_fact_from_action_factonly when a base diff reference exists — so the new coverage is a realverify --baserun over the reported shape, not a unit assertion. It also asserts both reports' action surfaces are non-empty MCP rows, because an empty action surface would pass the test while proving nothing.src/to measure the 10 divergences quoted above, and again after, to confirm all 28 agree. (Clear.git/agents-shipgate/base-scans/before any such comparison — a cached base scan from a different build silently disables the diff and hides the crash.)Release-readiness notes
docs/checks.md— none added or changedSTABILITY.md— no field added, removed, or retyped;docs/manifest-v0.1.jsonmoves only by one regenerateddescriptionNotes for the reviewer
authority.scopes" suggests. The concern with this direction was churn toauthority_hashfor every repo using bare scopes. It does not happen, because the shared rule is the one the lens already applied: every shape that resolves today resolves identically, and every list that moves belongs to a shape that was exit 4. No sample golden, fixture, or test value moved — nosamples/*/shipgate.yamlrow uses a barescopes:list, andtests/test_evidence_backed_pass.pyrequires anauthorityblock, which is why this survived.SHIP-AUTH-SCOPE-COVERAGE-MISSINGreads the assessment's scopes, so a row declaringscopes:against a manifest whosepermissions.scopesdoes not cover them now raises the review item it always should have. The divergence, not the check, was keeping it quiet. Called out in the CHANGELOG.🤖 Generated with Claude Code