test(session-control): pin containment_snapshot keys to authorize_target refusals - #7099
Conversation
…get refusals `authorize_target` refuses admission from an inline constraint set; `containment_snapshot` (kirodotdev#5978) re-derives the same predicates so the drain can re-assert them. Two hand-maintained spellings of one set, with nothing tying them together. Drift fails OPEN. A refusal added to `authorize_target` alone is enforced at enqueue and never re-checked at delivery, reopening the enqueue->drain window kirodotdev#5978 closed for that constraint only, with nothing red. The existing `assert snap == {...}` pins the snapshot's shape but is not derived from `authorize_target`, so it catches a new snapshot key and misses a new refusal -- it is red in the safe direction and silent in the dangerous one. Takes option 2 from the issue: a parity test rather than the structural refactor of the admission path, which is a wider change than a test needs to be. The refusal set is read from `authorize_target`'s source via AST rather than hand-listed. A hand-listed copy would be a third spelling of the same set, free to drift from the other two -- the failure this pins, reproduced inside the test that pins it. Two classification tables carry the mapping, so a new refusal must be declared either a containment constraint (with the snapshot key that re-asserts it) or explicitly not one (with a reason). Five tests: the parse finds what it claims to, every refusal is classified, no mapping outlives its refusal, every containment refusal has a snapshot key, and no snapshot key lacks a refusal. Verified by injecting each of the four drift directions and confirming the suite goes red for each, then reverting: an unclassified new refusal (the fail-open case), a containment refusal with no snapshot key, a deleted snapshot key, and a renamed refusal leaving a stale mapping. Test-only; no source file is touched. Refs kirodotdev#5994, kirodotdev#5978
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All claims verified against the base tree: 17 literal deny codes in Design-Verdict: PASS A self-guarding, source-derived parity pin that makes the fail-open drift red; test-only, reversible, and every claimed drift direction verifiably fails. [DESIGN-REVIEWED] 8424b9e |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 file). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: test-only parity test pinning authorize_target refusal codes to containment_snapshot keys via AST parse. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 file). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: test-only — adds AST-parity guards pinning authorize_target refusals to containment_snapshot keys (#5994), no production code touched. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 file). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: test-only addition to test/test_queue_drain_revalidation.py, no production file touched; it parses authorize_target's own source for literal deny(..., code) refusals and asserts each is either mapped to a containment_snapshot key or classified as non-containment, so a refusal added at admission but not re-asserted at drain fails CI instead of failing open. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: test-file-only additive parity tests pinning authorize_target refusal codes to containment_snapshot keys. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
Problem / Motivation
src/kiro_crew/dashboard/session_control.pycarries two hand-maintained spellings of the same per-slot containment constraint set:authorize_target(L888–1047) refuses admission from an inline set of checks.containment_snapshot(L283–333), added by fix(dashboard): re-assert queued prompt containment at drain (#5911) #5978, re-derives the same predicates as independent inline reads so_drop_stale_admissionscan compare admission-time state against drain-time state.Nothing ties the two together. A refusal added to
authorize_targetdoes not appear in the snapshot by construction, so it is enforced at enqueue and never re-asserted at delivery.Why it matters
Drift fails open. An eighth refusal added to
authorize_targetalone reopens the enqueue→drain TOCTOU that #5978 closed — for that one constraint, with nothing red. A queued prompt rides past a boundary the enqueue side enforces. That is the failure class #5911 was about.The file's existing coverage does not catch it.
test_human_typed_enqueue_stamps_admission_snapshotassertssnap == {...}with the seven keys spelled out, which pins the snapshot's shape but is not derived fromauthorize_target. So it is:containment_snapshot(the safe direction), andauthorize_target(the dangerous one).What changed (motivation → approach → change)
Goal: make divergence between the two constraint sets fail CI instead of failing open.
Approach. The issue offers two shapes and states either is acceptable, with the first preferred. This takes option 2 — the parity test. Option 1 (extracting a shared predicate table both functions consume) is a structural refactor of the admission path through a deny-by-default, SEL-audited chokepoint; it is the better end state but a wider change than closing this axis requires, and it does not need to block the pin.
Change. A new section in
test/test_queue_drain_revalidation.py, alongside the drain-re-validation tests it belongs with:The refusal set is read from
authorize_target's source by AST, not hand-listed. A hand-listed copy would be a third spelling of the same set, free to drift from the other two — reproducing the exact failure the test exists to pin._authorize_target_refusal_codes()walksinspect.getsource(sc.authorize_target)fordeny(..., code)literals. Onedenyre-raises a resolution failure withexc.coderather than a literal; it carries no new constraint, so a non-literal code is skipped rather than failing the parse.Two classification tables carry the semantics the parse cannot infer:
_TARGET_CONTAINMENT_REFUSALS— the 7 target-side refusals, each mapped to the snapshot key that re-asserts it (linked_session_target→linked,mirrored_target→mirrored,crew_mode_target→crew,ephemeral_target→ephemeral,app_scoped_target→app,unattended_target→unattended,workspace_mismatch→workspace)._NON_CONTAINMENT_REFUSALS— the 10 that need no drain-time check, with the reason inline: the 7 caller-side refusals are a property of who is asking rather than of the target slot, andsession_control_disabled/target_not_found/self_targetare not containment at all.Naming alone cannot do this:
self_targetandtarget_not_foundend in or containtargetbut are not containment constraints, whileworkspace_mismatchis one and does not match the pattern. Requiring an explicit classification is the point — a new refusal cannot be added without someone deciding which side it falls on.A header comment tells the next person exactly what to do when they add a refusal, so the test's failure message and the code's guidance agree.
Tests
Five tests, all in
test/test_queue_drain_revalidation.py. Each locks in one direction of drift:test_the_parse_finds_the_refusals_it_is_asked_to_pindeny(..., "code"), the extraction silently returns less and every test below passes while pinning nothingtest_every_refusal_is_classifiedauthorize_targetthat nobody classifiedtest_every_classified_refusal_still_existstest_every_containment_refusal_has_a_snapshot_keytest_the_snapshot_carries_no_unmapped_constraintmirror_identityandmirror_unverifiedare excluded via_SNAPSHOT_NON_CONSTRAINT_KEYS: both are set by the mirror probe to tell the drain how to comparemirrored, and neither has its own refusal.Local results: 32 passed, 0 failed (27 before this change).
isort,flake8, andblackclean on the CI scope.mypyis unaffected — CI runs it againstsrc/kiro_crew/only.Manual verification
The tests were confirmed to fail for the reasons claimed, not to pass vacuously. Each of the four drift directions was injected into
session_control.py, the suite run, and the change reverted:quarantined_targetinauthorize_target, unclassified — the fail-open case"crew"deleted fromcontainment_snapshotcrew_mode_targetrenamed inauthorize_target, mapping left stalegit diffafter the sequence confirmedsession_control.pybyte-identical toHEAD; only the test file is modified in this PR.Related Issues
Closes #5994
Refs #5978, #5911
Checklist