fix(sandbox): make check_file_restriction phase-aware (#2968) - #2975
Conversation
check_file_restriction reported can_write from the role layer (shared/egg_restrictions/patterns.py) only, ignoring the gateway's phase-layer push gate (gateway/phase_filter.py). It therefore returned can_write:true for paths the phase gate rejects -- e.g. the refiner writing .egg-state/drafts/*-plan.md during refine, which is reserved to the plan phase. On pipeline-8cf1f000 a reviewer trusted the tool and NACKed the producer for a "false gateway claim" that was in fact a true phase-gate block, burning a v1->v4 BRC cycle. can_write is now the conjunction of BOTH gateway push gates, so it predicts push acceptance. Split verdicts (role_can_write, phase_allows, blocked_by, phase) show which gate fires; a phase block carries no alternative_role and tells the agent to defer to the owning phase. - New shared/egg_restrictions/phase_patterns.py mirrors the gateway's phase data + matching logic for phase-blind callers (the MCP tool runs in the sandbox, nowhere near the gateway). A gateway-side parity test pins the mirror to the live PhaseFilter so it cannot drift. Consolidating the gateway onto this constant (parallel to #1903 for the role layer) is left as a follow-up to keep this change off the security-critical push path. - Optional `phase` tool arg; defaults to EGG_PHASE. An unset phase makes the phase layer a no-op (role-only, pre-#2968 behavior). Also repoints two test_restrictions_handlers.py cases that went stale in #2936 (the coder now authors its own tests, so it is no longer blocked from tests/) onto docs/guide.md, and adds the previously-missing inverse gateway test that refine blocks *-plan.md.
There was a problem hiding this comment.
Approve — non-blocking suggestions.
The two-layer conjunction (can_write = role_can_write AND phase_allows) is correct, the mirror module is a faithful 1:1 port of PhaseFileRestriction.is_file_allowed, the parity test (TestPhaseLayerSharedMirrorParity) closes the drift door for every PipelinePhase enum value, and the new tool-side test class covers the #2968 reviewer scenario plus the role-priority / batch / explicit-override / no-phase-env permutations. Backward compatibility is preserved (no EGG_PHASE ⇒ role-only verdict). The path-traversal sentinel (__INVALID_PATH_TRAVERSAL__) is handled identically on both layers. The in-passing repointing of stale test_x.py / guide.md paths from #2936 is legitimate scope and the test names match the new realities.
The findings below are all non-blocking — most are documentation/UX nits, with two worth addressing in a follow-up.
Worth a follow-up
-
Reviewer UX gap re. the PR's "subsumes the secondary ask" claim. The PR description says this fix "subsumes the issue's secondary ask" (reviewer being told the producer would be phase-blocked). That's only true if the reviewer remembers to pass
role="<producer_role>"andphase="<producer_phase>"explicitly. The default behavior —rolefromEGG_AGENT_ROLE,phasefromEGG_PHASE— gives the reviewer a verdict for their own role/phase, which is not what they care about when adjudicating a producer's proposal. Neither the tool docstring (sandbox/egg_agent_tools/tools/sdlc.py) nordocs/reference/agent-tools.mdcalls this out. Suggest: add a "When reviewing another agent's proposal, passroleandphaseexplicitly" sentence to the tool description and the reference docs row. -
Mirror ≠ gateway on unknown phase + case sensitivity. In
shared/egg_restrictions/phase_patterns.py::phase_file_verdict:phase.lower()is case-insensitive; gateway'sPipelinePhase(phase)is case-sensitive ("IMPLEMENT"would raise → fail-closed at the gateway, returnallowed=Truehere).- Unknown phase strings return
(True, None)here; the gateway fails closed (rejects the file incheck_phase_file_restrictions).
In production this doesn't bite because the orchestrator always sets
EGG_PHASEto a canonical lowercase value (kubernetes_spawner.py:904), but the parity test only walksfor p in PipelinePhase: ... phase_file_verdict(p.value, path)— so neither divergence has a test that would catch a regression if someone passes an unexpected string. Suggest either: (a) make the mirror raise on unknown phase to match the gateway, or (b) extend the parity test with a handful of off-enum strings ("IMPLEMENT","unknown","") and assert both layers fail closed the same way.
Minor / documentation
-
Schema description for
pathonly mentions the role layer._CHECK_FILE_RESTRICTION_SCHEMAinsandbox/egg_agent_tools/tools/sdlc.pystill references "shared/egg_restrictions/patterns.py" for the source of truth onpath. Now that this verb consults both layers, that pointer is incomplete; add a parenthetical pointing at.egg/phase-permissions.json/gateway/phase_filter.pyfor the phase layer. -
No type validation on
phase. The handler accepts whatever the caller passes via thephasearg — a non-string would propagate tophase.lower()and raiseAttributeErrorrather than returning a structured error. Low risk because the schema declares itstring, but the rest of the handler is defensive about types; this would be consistent. -
Stale "except checkpoints" description in
.egg/phase-permissions.json. Theimplementrow's description says it allows.egg-state/"except checkpoints" but the actualblocked_patternsalso excludeagent-outputs/andagent-anchors/from the block (via the carve-out patterns). The description undersells what's allowed. Out-of-scope to fix in this PR but worth a note for the next time.egg/phase-permissions.jsonis touched. -
Dead
"pr"entry in.egg/phase-permissions.json. Theprrow inphase_file_restrictionsis unreachable now thatPipelinePhaseno longer has aPRmember (the JSON loader skips it via the try/except). Mostly cosmetic but it's a real bit-rot trap if someone re-introduces a PR-like phase. -
APPLY divergence is acknowledged in the PR but the mirror's silence is implicit. The Python fallback in
gateway/phase_filter.py::_get_default_phase_file_restrictionsrestricts APPLY per #1557, but the JSON has no APPLY row → the mirror has no APPLY row → APPLY returns(True, None). This is correct in production because the JSON is always present, but the mirror diverges from the fallback. A# APPLY intentionally omitted: gateway JSON has no apply row; the Python fallback only runs if the JSON is absentcomment inphase_patterns.pynearPHASE_FILE_PATTERNSwould save the next reader a trip through the gateway. -
PhaseFilePatterndoesn't expose a__repr__/__eq__story for test diagnostics. Frozen dataclass gives free__eq__/__repr__, but the immutable-tuple fields print as long flat tuples in failure messages. Pure ergonomics — not worth changing unless the parity test starts failing and the diff is unreadable. -
phase_file_verdictreturns(True, None)forNoneand empty string"". This is intentional (backward compat withget_phase()which returnsNone-or-string), but an empty string is plausibly a bug at the call site. Not worth changing; flagging in case it ever becomes load-bearing. -
Reviewer scenario test fixture.
TestCheckFileRestrictionPhaseexercises the producer case but not the reviewer-impersonates-producer case (reviewer role + explicitrole="coder"+phase="implement"). Adding one test that asserts the reviewer using explicit args sees the producer's phase verdict would directly validate the #2968 secondary fix that the PR description claims is subsumed.
None of these are blockers — the patch lands the #2968 fix cleanly and the test suite is more than enough to catch regressions in the conjunction logic. Ship it; the follow-ups are documentation polish and one reviewer-UX nudge.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Fail-closed mirror on unknown/off-canonical phases (matches gateway). - Reject non-string `phase` arg with HandlerError. - Schema description now mentions both gateway push gates explicitly. - Reviewer UX nudge added to tool description + agent-tools.md row. - New parity test: shared mirror fails closed for off-canonical phases. - New handler test: reviewer impersonates producer via explicit args. - Drop dead "pr" entries from .egg/phase-permissions.json.
|
Thanks for the review. Summary of dispositions: Fixed in-PR (commit 988017b):
Disagree (reasoning):
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Approve — feedback addressed cleanly.
The follow-up commit 988017b resolves the substantive items from my prior review and the disagreement dispositions are defensible.
Verification of fixes
-
Off-canonical phase fail-closed (item 2, the only one with real security weight).
shared/egg_restrictions/phase_patterns.py:200-209now coerces throughPipelinePhase(phase)and returns(False, "Unknown phase …")onValueError, matchinggateway/phase_filter.py:736-746. The newtest_shared_mirror_fails_closed_for_off_canonical_phase(gateway/tests/test_phase_filter_restrictions.py:771-787) cross-checks both layers for"IMPLEMENT","unknown","pr","REFINE"across a draft and a code path, and theshared/tests/test_phase_patterns.py:46-72rewrites assert the exact reason-string suffix the mirror emits. The earlier divergence (phase.lower()would silently map"REFINE"→"refine"and return an allow path; gateway would block) is now closed. -
Reviewer-impersonates-producer test (item 10).
sandbox/tests/test_restrictions_handlers.py:184-207setsEGG_AGENT_ROLE=reviewer_code,EGG_PHASE=implement, then asks with explicitrole="refiner",phase="refine"against_PLAN_DRAFTand assertsphase_allows=False,blocked_by="phase",alternative_role=None. This is exactly the producer-impersonation scenario #2968's secondary ask describes, and it pins the env-override-by-args contract so a future regression in argument precedence would be caught. -
Reviewer UX nudge (item 1). The tool decorator description at
sandbox/egg_agent_tools/tools/sdlc.py:269-274and thedocs/reference/agent-tools.md:98row both now spell out that a reviewer's default-args check diverges from the gateway's verdict for the producer. -
Type validation on
phase(item 4).sandbox/egg_agent_tools/handlers/restrictions.py:160-165rejects truthy non-stringphasewith a structuredHandlerError, covered bytest_non_string_phase_rejected. The check runs afterreq.get("phase") or get_phase(), so falsy non-strings (0,[]) get normalised throughget_phase()and don't trip — that's consistent with how the rest of the handler treats falsy inputs as "absent." -
Path schema description (item 3) and dead
prentries (item 6). Schema description now references both gates;.egg/phase-permissions.jsonno longer carries thephases.pr/phase_file_restrictions.prblocks. The loader atgateway/phase_filter.py:367-373, 396-404was already silently skipping unknown-phase entries viatry/except, so removing the rows is a no-op behaviourally and a real cleanup textually.
Disposition disagreements — reasonable
Items 5 (out-of-scope JSON description), 7 (APPLY divergence — already documented in module docstring lines 31-33 and PHASE_FILE_PATTERNS comment 115-121), and 8 (dataclass __repr__ ergonomics) are fine to defer.
Non-blocking — minor inaccuracy in the item 9 disposition
The response to item 9 (empty string "" for phase) says the current behaviour "is consistent with the gateway, which only enforces the phase gate when the request has a phase." That's not quite right — PhaseFilter.check_phase_file_restrictions("", [path]) hits isinstance("", str) → True → PipelinePhase("") raises ValueError → fails closed. The mirror returns (True, None) for "", so an explicit empty-string caller of the mirror diverges from the gateway. In practice this is unreachable because the handler at restrictions.py:159 does req.get("phase") or get_phase(), and get_phase() itself returns None for an empty EGG_PHASE (sandbox/egg_agent_tools/handlers/_gateway.py:171), so "" is normalised to None before it ever reaches phase_file_verdict. The action (leave it alone) is defensible — I flagged this as "not worth changing" originally — but the reasoning in the disposition undersells a real (if dead) divergence. If you ever expose phase_file_verdict to a path that doesn't go through the handler's or chain, tighten this up.
Non-blocking nit
from egg_contracts.models import PipelinePhase is done lazily inside phase_file_verdict to dodge import-order surprises. That's fine, but in the batch path (_check_one called per path) you pay the sys.modules lookup on every call. Pull it to a module-level import — the parity test already imports PipelinePhase at module scope without circular-import drama, so the defensive lazy import is solving a hypothetical problem.
Patch is correct, security-critical divergence is closed and parity-tested, and the reviewer UX gap is documented in both the tool description and the reference table. Ship it.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Pull PipelinePhase import from inside phase_file_verdict to module
scope. Peer module shared/egg_restrictions/patterns.py already imports
egg_contracts.agent_roles at module scope without circular-import
drama, and the parity test imports PipelinePhase at module scope too,
so the defensive lazy import was solving a hypothetical problem and
paying a sys.modules lookup on every batch _check_one call.
- Tighten phase_file_verdict docstring to acknowledge the small,
unreachable-in-practice divergence vs. the gateway on explicit
phase="" (gateway fails closed via PipelinePhase(""); mirror
returns (True, None)). Documents how to close the divergence if a
future caller exposes phase_file_verdict to a path that doesn't
pre-normalise "" → None.
|
Thanks for the second-pass review. Both items addressed. fixed-in-PR (commit 0629692):
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Approve — both prior nits addressed cleanly.
Re-review of the delta since 988017b (single commit 0629692).
Verification
-
Module-level
PipelinePhaseimport (prior nit).shared/egg_restrictions/phase_patterns.py:41now importsfrom egg_contracts.models import PipelinePhaseat module scope; the lazy import insidephase_file_verdictis gone. No circular-import risk:egg_contracts.modelsonly pulls stdlib +pydantic(verified by grep onmodels.py), peer moduleshared/egg_restrictions/patterns.py:22already importsegg_contracts.agent_rolesat module scope, andgateway/tests/test_phase_filter_restrictions.pyimportsPipelinePhaseat module scope too. The batch path (_check_oneper path inrestrictions.py) no longer pays the per-callsys.moduleslookup. Smoke-imported and ranshared/tests/test_phase_patterns.py(13 passed) + the parity tests ingateway/tests/test_phase_filter_restrictions.py(TestPhaseLayerSharedMirrorParity::test_parity_with_phase_filter_for_every_phase+test_shared_mirror_fails_closed_for_off_canonical_phase, both green). -
Docstring honesty on the
""divergence (prior nit).phase_patterns.py:180-189now states explicitly that the gateway fails closed on an explicit empty string (PipelinePhase("")raises) while the mirror returns(True, None), and documents (a) why this is unreachable in practice —restrictions.py:159doesreq.get("phase") or get_phase()andget_phase()returnsNonefor an emptyEGG_PHASE— and (b) the one-line fix (drop theor emptybranch in the early-return) for a future caller that bypasses that normalisation chain. Reads accurately; no behaviour change.
Delta scope
- No production-code logic change (the
if not phase: return True, Noneearly-return on line 204 is unchanged). - No new tests required: the parity tests added in
988017balready pin the unknown-phase fail-closed behaviour, and the empty-string branch is documented-as-divergent rather than re-engineered.
Nothing else changed. Both items I flagged at 988017b are resolved correctly and within scope.
— Authored by egg
|
egg review completed. View run logs 7 previous review(s) hidden. |
Closes #2968.
Problem
mcp__sdlc__check_file_restrictioncomputedcan_writefrom the role layer (shared/egg_restrictions/patterns.py) only. But the gateway gates every push on two independent layers, both of which must allow a file:shared/egg_restrictions/patterns.pygateway/phase_filter.py(.egg/phase-permissions.json)The tool was blind to the phase layer, so it returned
can_write: truefor paths the phase gate rejects at push time — e.g. arefinerwriting.egg-state/drafts/*-plan.mdduring the refine phase, which the refine whitelist reserves to the plan phase (*analysis*only). Onpipeline-8cf1f000a reviewer trusted the tool'scan_write: trueand NACKed the producer for a "false gateway claim" that was in fact a true phase-gate block, burning a v1→v4 BRC cycle (~32 min) before the producer rediscovered the constraint by trial commit.Fix
can_writeis now the conjunction of both gates, so it predicts what the gateway will actually accept on push. Split verdicts make the cause legible to producers and reviewers:{ "ok": true, "role": "refiner", "path": ".egg-state/drafts/p-plan.md", "phase": "refine", "can_write": false, "role_can_write": true, "phase_allows": false, "blocked_by": "phase", // "role" | "phase" | null "alternative_role": null, // phase blocks are reserved phase-wide "reason": "phase 'refine' blocks ... at the gateway phase gate (gateway/phase_filter.py) ..." }A phase-layer block is a real gateway block, not a false agent claim — so once a reviewer runs the (now phase-aware) tool it sees the same
can_write: falseand the disagreement disappears at the source. This largely subsumes the issue's secondary ask (reviewer should treat a confirmed phase-gate block as authoritative).Changes
shared/egg_restrictions/phase_patterns.py—PHASE_FILE_PATTERNS+phase_file_verdict(phase, path), a 1:1 mirror of the gateway'sPhaseFileRestriction.is_file_allowedlogic and.egg/phase-permissions.jsondata, for phase-blind callers (the MCP tool runs in the sandbox, nowhere near the gateway). Reuses the sharedmatch_patternmatcher.sandbox/egg_agent_tools/handlers/restrictions.py—check_file_restrictionANDs the role result withphase_file_verdict(EGG_PHASE, path); addsphase/role_can_write/phase_allows/blocked_by. Role block takes message priority (it may be delegable); phase block points at the owning phase and sets noalternative_role.sandbox/egg_agent_tools/tools/sdlc.py— optionalphasearg (defaults toEGG_PHASE); description rewritten so a phase block reads as a real gateway block.*-plan.mdin refine →blocked_by: "phase"); a gateway parity test comparing the shared mirror against the realPhaseFilterfor every phase (CI drift guard); the previously-missing inversetest_refine_blocks_plan_drafts.docs/reference/agent-tools.md— documents the two-layer semantics and new fields.Backward compatible: with no
EGG_PHASEset, the phase layer is a no-op andcan_writereduces to the prior role-only result.Deliberately out of scope
phase_patterns.pyas the single source of truth (parallel to Make patterns.py the single source of truth for file restrictions; derive phase-permissions.json and _IMPLEMENT_READONLY_DIRS from it #1903, which did this for the role layer) — and resolving the latentapply-phase divergence (the Python fallback restrictsapplyper Add SDLC pipeline support for Jira epics #1557, but the live JSON has noapplyrow, so it's currently unrestricted) — is a clean follow-up. The parity test guards against drift in the meantime.Note: stale tests fixed in passing
test_blocked_path_for_coderandtest_batch_formwent stale in #2936 (2026-06-02, "let the coder author its own tests" — coder is no longer blocked fromtests/). They're red onmainat HEAD but slipped past changeset-narrowedmake test. Since this PR adds tests to that same file,make test-allwould trip them, so they're repointed atdocs/guide.md(genuinely coder-blocked) with a#2936comment.Test plan
shared/tests/test_phase_patterns.py,sandbox/tests/test_restrictions_handlers.py,gateway/tests/test_phase_filter_restrictions.py)make lintcleanmake test-all