Skip to content

Add a runtime shape guard for the duplicate-role-map check - #414

Merged
haksungjang merged 1 commit into
mainfrom
fix-role-priority-runtime-shape-guard-387
Sep 6, 2026
Merged

Add a runtime shape guard for the duplicate-role-map check#414
haksungjang merged 1 commit into
mainfrom
fix-role-priority-runtime-shape-guard-387

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Summary

The test protecting core.security._ROLE_PRIORITY from a second copy (test_role_priority_has_one_home.py) walks the AST for ast.Dict literals. A copy written as a list of (role, grade) pairs, a dict comprehension, dict(zip(...)), an enum.IntEnum, or a dict assembled by a helper function at runtime never produces an ast.Dict node, so none of those shapes trip it.

This adds a second guard (test_role_priority_runtime_shape_guard.py) that imports every module under api, core, services, tasks, integrations, notifications, and schemas, and judges the value each module-level name resolves to after import rather than the statement that produced it. core.security._ROLE_PRIORITY is exempted by object identity (is), not by name or path, so a second object holding identical values still fails.

Design notes

  • The two guards are kept independent (CLAUDE.md hardening rule feat(api): accept ?release= as a snapshot anchor on detail reads #8). This file does not import from the AST guard's internals for its main check, and each guard's ability to work alone is verified explicitly: test_ast_guard_alone_misses_every_evasion_shape proves the AST guard is blind to all five evasion shapes, and test_runtime_guard_alone_is_not_needed_to_catch_a_plain_dict_literal proves the AST guard still catches a plain literal on its own.
  • Five evasion shapes (tuple list, dict comprehension, dict(zip(...)), enum.IntEnum, runtime-assembled dict) are built as synthetic modules and confirmed, by breaking, to trip the new guard (hardening rule feat(releases): surface the branch a snapshot came from, and filter by it #7).
  • test_a_second_object_with_the_same_values_still_fails proves the exemption is identity-based, not value-based: a dict(_ROLE_PRIORITY) copy is still flagged.
  • I manually appended a real duplicate map to core/readiness.py, confirmed the new guard failed with the expected message, then reverted it (not part of this diff) — see verification below.
  • A full sweep of the 348 modules under the search dirs imports cleanly with no DATABASE_URL/REDIS_URL/SECRET_KEY set at all, so no skip-list entries were needed. The skip mechanism (SKIPPED_MODULES: dict[str, str]) is still in place, with a test enforcing every entry states a reason, for future modules that grow import-time side effects.

Verification

  • pytest tests/unit/test_role_priority_runtime_shape_guard.py — 15 passed
  • pytest tests/unit/test_role_priority_has_one_home.py tests/unit/test_closed_status_vocabulary.py — unaffected, still green
  • ruff check / mypy on the new file — clean
  • node tools/em-dash/lint.mjs — clean
  • Mutation check: injecting _ROLE_ORDER_DUPLICATE_FOR_MUTATION_TEST = {"viewer": 1, "developer": 2} into core/readiness.py makes test_role_priority_has_no_lookalike_anywhere_else fail with the expected message; reverted before committing.
  • pytest tests/unit (full suite, local, against a Postgres/Redis instance shared with other concurrent agents) — 3 pre-existing failures in test_admin_user_service.py/test_dashboard_service.py unrelated to this change (row-count assertions sensitive to concurrent writes from other agents' test runs); all 3 pass in isolation, confirming shared-DB contention rather than a regression from this change.

Closes #387

The existing test walks the AST for ast.Dict literals, so a copy of
_ROLE_PRIORITY written as a list of pairs, a dict comprehension,
dict(zip(...)), an enum.IntEnum, or a runtime-assembled dict never
produces an ast.Dict node and passes unnoticed.

Add a second guard that imports every module under api, core,
services, tasks, integrations, notifications, and schemas and judges
the value each module-level name resolves to, not the statement that
built it. core.security._ROLE_PRIORITY is exempted by object identity
so a second object holding the same values still fails. Both guards
are kept, and each is verified to work independently: five synthetic
copies (one per evasion shape) are confirmed to slip past the AST
guard and be caught by the runtime one, and a plain dict literal is
confirmed to still be caught by the AST guard on its own.

Closes #387
@haksungjang
haksungjang force-pushed the fix-role-priority-runtime-shape-guard-387 branch from ef260c9 to 3f6747f Compare September 6, 2026 16:10
@haksungjang
haksungjang merged commit 501d243 into main Sep 6, 2026
25 checks passed
@haksungjang
haksungjang deleted the fix-role-priority-runtime-shape-guard-387 branch September 6, 2026 21:05
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.

The duplicate-role-map guard only recognises dictionary literals

1 participant