fix(platformctl): bind apply approval to Forgejo PR #162
No reviewers
Labels
No labels
W6d-automerge-calibration
agent/claude-code
agent/codex
agent/hermes
agent/iskra
agent/ollama
agent/patchwarden
automerge-candidate
class/security-sensitive
cutover-gate
dependency/blocked
dependency/blocks-others
dependency/cross-repo
dependency/needs-confirmation
domain:agents
domain:ci
domain:docs
domain:forgejo
domain:infra
domain:memory
domain:runtime
domain:signal
domain:ux
flow/architecture
flow/blocked
flow/deployed
flow/done
flow/implementation
flow/intake
flow/maintained
flow/observed
flow/ready
flow/refining
flow/retired
flow/review
iterating
judge/codex-candidate
judge/hermes-candidate
judge/low-confidence
judge/needs-refinement
judge/operator-needed
judge/p0
judge/p1
judge/p2
judge/p3
judge/park
judge/patchwarden-candidate
judge/stale-priority
kind/adr
kind/bug
kind/chore
kind/feature
kind/infra
kind/ops
kind/refactor
kind/research
large-impact
merge/auto
merge/manual
merge/manual-dependency-conflict
merge/manual-failing-tests
merge/manual-merge-conflict
merge/manual-missing-review
merge/manual-operator-preference
merge/manual-red-zone
merge/manual-security-sensitive
merge/manual-unclear-scope
merge/manual-unknown
meta
mode:operator-only
mode:patchwarden-iskra-approved
mode:safe-auto
needs-operator-decision
needs-triage
not-ready
observed/erroring
observed/needs-followup
observed/pending
observed/retire-candidate
observed/unused
observed/used
operator-emotional
owner-attention
phase/02
phase/03
priority:p0
priority:p1
priority:p2
priority:p3
proposed
ready-for-agent
ready-for-operator
recovery
review:claude-reviewed
review:codex-reviewed
review:dziadek-reviewed
review:needs-human
risk/exposure
risk/process
risk/product
risk/runtime
safety:external-write
safety:no-prod-mutation
safety:prod-impact
safety:secret-touch
size/large
size/medium
size/small
size/tiny
size/unknown
source/adr
source/agent-generated
source/manual
source/operator-chat
source/voice-note
status:blocked
status:codex-ready
status:merged:pending-evidence
status:needs-evidence
status:operator-needed
status:parked
tier/full
tier/lite
tier/stacked
tier:0-platform-substrate
tier:1-iskra-value-layer
tier:2-tools-products-modules
type:bug
type:chore
type:docs
type:feat
type:policy
type:research
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
pdurlej/platform!162
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "codex/issues/142-apply-approval-binding"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Canary status: missing — fire canary 3+3 manually before merge
Canary Context Pack
Product story
Phase 3 apply must never treat an arbitrary SHA as production approval. This slice makes approval verification real and bound to Forgejo PR identity before any later runtime path can use it.
What changed
platformctl apply.--approved-prso the operator/action can bind apply to one merged PR.Why it changed
PR #160 review found that the apply path was too broad and approval binding was not reviewable inside the larger runtime mutation PR.
Files touched
control-plane/platformctl/apply.pycontrol-plane/platformctl/cli.pycontrol-plane/platformctl/tests/test_apply_phase3.pyRelevant context
Split from superseded PR #160. Stacked on #159 /
codex/issues/142-phase3-plan.Runtime evidence
No runtime mutation in this PR. Local tests only.
Known constraints
Requires
PLATFORMCTL_FORGEJO_TOKENor actor-scoped token env at apply time.Explicit out-of-scope
Plan provenance, compose execution, status artifacts, and Forgejo Actions wiring are later split PRs.
Requested decision
Approve this as the approval-binding foundation for the stacked apply split.
Merge blockers
Any path that accepts an unmerged PR, wrong actor identity, or arbitrary SHA as approval.
Spec sources read
AGENTS.md— repo workflow, canary, security-sensitive lane.control-plane/platformctl/apply.py— apply approval gate.control-plane/platformctl/cli.py— apply CLI contract.control-plane/platformctl/tests/test_apply_phase3.py— new contract tests.Refs #142
Supersedes part of #160
Ralph review (5-iter chmurowy) — ITERATE_BLOCKER 3/9
Niezależny 5-iter ralph chain (kimi-k2.6 planner → glm-5.1 critic → deepseek-v4-pro red-team → kimi-k2.6 synth → deepseek-v4-pro arbiter) na tym PR. Verdict + drafted patches poniżej.
Per-dim scoring
Evidence:
~/Iskra-i-Piotr/05 System/Swarmheart Backups/ralph-phase3-apply/162/(full chain).BLOCKER 1 — Remove paginated search fallback
WHAT:
verify_approved_sha(lines 183-207) ma fallback który gdyapproved_pr is Nonesearches ostatnich 250 closed PRs. Akceptuje arbitrary historical merge-commit SHA jako approval.WHY: Red-team confirmed-exploitable:
Public historical merge SHA = golden ticket dla arbitrary production change. Bezpośrednio łamie deklarowany kontrakt PR-a: "approval must be bound to a specific Forgejo PR identity".
HOW (drafted patch w
apply.pylinie 151-209):Delete entire
for page in range(...)block + final fallback return.VERIFY (add to
tests/test_apply_phase3.py):BLOCKER 2 — Decouple
_pull_filesfrom approval decisionWHAT:
_check_specific_pr(linia 147) calls_pull_filesjako część successful approval return. Jeśli/filesendpoint fails (500, timeout, malformed JSON), całe approval is rejected.WHY: Forgejo API flakiness convertuje valid SHA↔PR approval w hard rejection → pressure na operator do użycia
--break-glassdla otherwise legitimate changes. Files lookup jest enrichment, nie gating decision.HOW (drafted patch w
apply.pylinie 115-148):VERIFY:
BLOCKER 3 — Sanitize Forgejo error bodies (defense-in-depth)
WHAT:
_forgejo_get_json(linia 79) embedsbody[:200]z Forgejo error response wRuntimeError. Body flows doApprovalCheck.detail→ result dict → audit logs.WHY: Forgejo error responses mogą zawierać: internal paths, user IDs, w trybie debug nawet tokens, stack traces. Audit logs będą emit'owane do CI logs (potentially public). Defense-in-depth.
HOW (drafted patch w
apply.pylinie 64-81):Plus apply
_safe_error_summarywverify_approved_shafinal except clause (zamiaststr(exc)na linii 209).VERIFY:
Follow-up issues (file as separate)
BREAK-GLASS-HARDEN-01— Require secondary approval for --break-glassPR-BIND-01— Verify approved PR base.ref matches deployment branchACTOR-ID-01— Bind apply actor identity to PR merger/reviewersERROR-CODE-01— Machine-readable approval failure exit codesAction items
apply.py+ testscodex/issues/142-apply-approval-binding— ralph batch 2026-05-10, claude-opus-4.7 (Pan Herbata) dispatching via codex identity
Ralph review (5-iter chmurowy) — ITERATE_BLOCKER 3/9
Niezależny 5-iter ralph chain (kimi-k2.6 planner → glm-5.1 critic → deepseek-v4-pro red-team → kimi-k2.6 synth → deepseek-v4-pro arbiter) na tym PR. Verdict + drafted patches poniżej.
Per-dim scoring
Evidence:
~/Iskra-i-Piotr/05 System/Swarmheart Backups/ralph-phase3-apply/162/(full chain).BLOCKER 1 — Remove paginated search fallback
WHAT:
verify_approved_sha(lines 183-207) ma fallback który gdyapproved_pr is Nonesearches ostatnich 250 closed PRs. Akceptuje arbitrary historical merge-commit SHA jako approval.WHY: Red-team confirmed-exploitable:
Public historical merge SHA = golden ticket dla arbitrary production change. Bezpośrednio łamie deklarowany kontrakt PR-a: "approval must be bound to a specific Forgejo PR identity".
HOW (drafted patch w
apply.pylinie 151-209):Delete entire
for page in range(...)block + final fallback return.VERIFY (add to
tests/test_apply_phase3.py):BLOCKER 2 — Decouple
_pull_filesfrom approval decisionWHAT:
_check_specific_pr(linia 147) calls_pull_filesjako część successful approval return. Jeśli/filesendpoint fails (500, timeout, malformed JSON), całe approval is rejected.WHY: Forgejo API flakiness convertuje valid SHA↔PR approval w hard rejection → pressure na operator do użycia
--break-glassdla otherwise legitimate changes. Files lookup jest enrichment, nie gating decision.HOW (drafted patch w
apply.pylinie 115-148):VERIFY:
BLOCKER 3 — Sanitize Forgejo error bodies (defense-in-depth)
WHAT:
_forgejo_get_json(linia 79) embedsbody[:200]z Forgejo error response wRuntimeError. Body flows doApprovalCheck.detail→ result dict → audit logs.WHY: Forgejo error responses mogą zawierać: internal paths, user IDs, w trybie debug nawet tokens, stack traces. Audit logs będą emit'owane do CI logs (potentially public). Defense-in-depth.
HOW (drafted patch w
apply.pylinie 64-81):Plus apply
_safe_error_summarywverify_approved_shafinal except clause (zamiaststr(exc)na linii 209).VERIFY:
Follow-up issues (file as separate)
BREAK-GLASS-HARDEN-01— Require secondary approval for --break-glassPR-BIND-01— Verify approved PR base.ref matches deployment branchACTOR-ID-01— Bind apply actor identity to PR merger/reviewersERROR-CODE-01— Machine-readable approval failure exit codesAction items
apply.py+ testscodex/issues/142-apply-approval-binding— ralph batch 2026-05-10, claude-opus-4.7 (Pan Herbata) dispatching via codex identity
Ralph review (5-iter chmurowy) — ITERATE_BLOCKER 3/9Niezależny 5-iter ralph chain (kimi-k2.6 planner → glm-5.1 critic → deepseek-v4-pro red-team → kimi-k2.6 synth → deepseek-v4-pro arbiter) na tym PR. Verdict + drafted patches poniżej.### Per-dim scoring- Correctness: 4/9- Security: 2/9 ⚠️ (arbitrary-SHA bypass + error body leak)- Observability: 5/9- Test coverage: 7/9- Scope discipline: 8/9Evidence:
~/Iskra-i-Piotr/05 System/Swarmheart Backups/ralph-phase3-apply/162/(full chain).---## BLOCKER 1 — Remove paginated search fallbackWHAT:verify_approved_sha(lines 183-207) ma fallback który gdyapproved_pr is Nonesearches ostatnich 250 closed PRs. Akceptuje arbitrary historical merge-commit SHA jako approval.WHY: Red-team confirmed-exploitable:bash# Insider z platform access:SHA=$(git log --merges --format=%H origin/main | tail -1) # public!platformctl apply malicious-plan.json --approved $SHA # ✓ approvedPublic historical merge SHA = golden ticket dla arbitrary production change. Bezpośrednio łamie deklarowany kontrakt PR-a: "approval must be bound to a specific Forgejo PR identity".HOW (drafted patch wapply.pylinie 151-209):pythondef verify_approved_sha( approved_sha: str, *, approved_pr: int | None = None, forgejo_host: str = DEFAULT_FORGEJO_HOST, forgejo_repo: str = DEFAULT_FORGEJO_REPO, token: str | None = None, search_pages: int = 5, # kept for compat; unused after fallback removal) -> ApprovalCheck: """Verify SHA matches a merged Forgejo PR. Requires explicit --approved-pr.""" if not _valid_sha(approved_sha): return ApprovalCheck(False, "approved SHA must be a 40-character hex commit") if approved_pr is None: # SECURITY: search fallback removed (ralph #162 BLOCKER 1). # Arbitrary historical merge SHAs must NOT satisfy approval. return ApprovalCheck( False, "--approved-pr is required (search fallback removed; bind apply to explicit PR)", ) api_token = _forgejo_token(token) if not api_token: return ApprovalCheck(False, "PLATFORMCTL_FORGEJO_TOKEN is required for apply approval verification") try: repo_path = _repo_api_path(forgejo_repo) return _check_specific_pr( approved_sha, approved_pr, repo_path=repo_path, host=forgejo_host, token=api_token, ) except (RuntimeError, ValueError) as exc: return ApprovalCheck(False, _safe_error_summary(exc))Delete entirefor page in range(...)block + final fallback return.VERIFY (add totests/test_apply_phase3.py):pythondef test_verify_approved_sha_requires_approved_pr(): """approved_pr=None must reject — search fallback removed (ralph BLOCKER 1).""" result = verify_approved_sha("a" * 40, approved_pr=None) assert not result.approved assert "--approved-pr is required" in result.detaildef test_verify_approved_sha_with_pr_still_works(mocker): """Explicit --approved-pr path unchanged.""" mocker.patch("...module._check_specific_pr", return_value=ApprovalCheck(True, "ok", pr_number=42)) mocker.patch.dict(os.environ, {"PLATFORMCTL_FORGEJO_TOKEN": "fake"}) result = verify_approved_sha("a" * 40, approved_pr=42) assert result.approved is True---## BLOCKER 2 — Decouple_pull_filesfrom approval decisionWHAT:_check_specific_pr(linia 147) calls_pull_filesjako część successful approval return. Jeśli/filesendpoint fails (500, timeout, malformed JSON), całe approval is rejected.WHY: Forgejo API flakiness convertuje valid SHA↔PR approval w hard rejection → pressure na operator do użycia--break-glassdla otherwise legitimate changes. Files lookup jest enrichment, nie gating decision.HOW (drafted patch wapply.pylinie 115-148):pythondef _check_specific_pr( approved_sha: str, approved_pr: int, *, repo_path: str, host: str, token: str,) -> ApprovalCheck: pr = _forgejo_get_json( f"/api/v1/repos/{repo_path}/pulls/{approved_pr}", host=host, token=token, ) if not isinstance(pr, dict): return ApprovalCheck(False, f"PR #{approved_pr} lookup returned non-object payload") if not _is_pr_merged(pr): return ApprovalCheck(False, f"PR #{approved_pr} is not merged", pr_number=approved_pr) sha_values = _pr_sha_values(pr) if approved_sha not in sha_values: return ApprovalCheck( False, f"approved SHA does not match PR #{approved_pr} head/merge SHA", pr_number=approved_pr, head_sha=(pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None, merge_sha=pr.get("merge_commit_sha") or pr.get("merged_commit_sha"), ) # Approval is decided. Files lookup is enrichment; never gate on /files flakiness. # (ralph #162 BLOCKER 2) changed_files: tuple[str, ...] = () try: changed_files = _pull_files(approved_pr, repo_path=repo_path, host=host, token=token) except (RuntimeError, ValueError): pass # /files unavailable — emit approval anyway with empty file list return ApprovalCheck( True, f"approved SHA matches merged PR #{approved_pr}", pr_number=approved_pr, head_sha=(pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None, merge_sha=pr.get("merge_commit_sha") or pr.get("merged_commit_sha"), changed_files=changed_files, )VERIFY:pythondef test_check_specific_pr_succeeds_when_files_endpoint_fails(mocker): """Approval must succeed even if /files returns 500 (ralph BLOCKER 2).""" mocker.patch("...module._forgejo_get_json", return_value={ "merged": True, "head": {"sha": "a" * 40}, }) mocker.patch("...module._pull_files", side_effect=RuntimeError("500 Internal")) result = _check_specific_pr("a" * 40, 42, repo_path="x/y", host="h", token="t") assert result.approved is True assert result.changed_files == ()---## BLOCKER 3 — Sanitize Forgejo error bodies (defense-in-depth)WHAT:_forgejo_get_json(linia 79) embedsbody[:200]z Forgejo error response wRuntimeError. Body flows doApprovalCheck.detail→ result dict → audit logs.WHY: Forgejo error responses mogą zawierać: internal paths, user IDs, w trybie debug nawet tokens, stack traces. Audit logs będą emit'owane do CI logs (potentially public). Defense-in-depth.HOW (drafted patch wapply.pylinie 64-81):pythonimport logging_LOG = logging.getLogger(__name__)def _safe_error_summary(exc: BaseException) -> str: """Strip body details from Forgejo exception flowing into ApprovalCheck.detail.""" msg = str(exc) # Only let known-safe prefix patterns through; never raw response bodies. safe_prefixes = ("Forgejo API returned HTTP ", "Forgejo API request failed", "PR #", "approved SHA") if any(msg.startswith(p) for p in safe_prefixes): return msg return f"{type(exc).__name__}"def _forgejo_get_json(path: str, *, host: str, token: str) -> Any: url = host.rstrip("/") + path request = urllib.request.Request( url, headers={ "Accept": "application/json", "Authorization": f"token {token}", "User-Agent": "platformctl-apply", }, ) try: with urllib.request.urlopen(request, timeout=20) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: # SECURITY (ralph #162 BLOCKER 3): do NOT embed response body — # may leak internal paths, user IDs, debug tokens. Log to debug-only. try: raw_body = exc.read().decode("utf-8", errors="replace")[:1024] _LOG.debug("Forgejo %s → HTTP %s body=%r", path, exc.code, raw_body) except Exception: # noqa: BLE001 pass raise RuntimeError(f"Forgejo API returned HTTP {exc.code} for {path} (response omitted)") from exc except urllib.error.URLError as exc: raise RuntimeError(f"Forgejo API request failed for {path}: {type(exc.reason).__name__}") from excPlus apply_safe_error_summarywverify_approved_shafinal except clause (zamiaststr(exc)na linii 209).VERIFY:pythondef test_forgejo_get_json_omits_response_body(monkeypatch): """RuntimeError must NOT include response body content (ralph BLOCKER 3).""" class FakeHTTPError(urllib.error.HTTPError): def __init__(self): super().__init__("u", 500, "Internal", {}, None) def read(self): return b'{"secret_token": "supersensitive", "internal_path": "/var/forgejo/secrets/x"}' def fake_urlopen(*a, **kw): raise FakeHTTPError() monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) with pytest.raises(RuntimeError) as ei: _forgejo_get_json("/api/test", host="h", token="t") assert "supersensitive" not in str(ei.value) assert "internal_path" not in str(ei.value) assert "response omitted" in str(ei.value)---## Follow-up issues (file as separate)###BREAK-GLASS-HARDEN-01— Require secondary approval for --break-glass>apply_plan(line ~267) accepts--break-glass --reason "<any-string>"jako total bypass całej Forgejo gate. Audit trail = log line + free-text reason. Insider z CLI access bypasses everything.>> Proposed: secondary signed approval. Options: (a) GPG-signed break-glass token, (b) second operator Forgejo comment z magic string na tracking issue, (c) PagerDuty incident ack ID. Track failed break-glass attempts w audit.>> Class:security-sensitive,phase/03,risk/process###PR-BIND-01— Verify approved PR base.ref matches deployment branch>_check_specific_prvalidates merged + SHA, ale nigdy nie sprawdzapr.base.ref. PR merged dorelease/staginglub feature branch satisfymain-line apply approval.>> Proposed: passexpected_base_refparam doverify_approved_sha(defaultmain), reject jeślipr.base.ref != expected_base_ref.>> Class:security-sensitive,phase/03###ACTOR-ID-01— Bind apply actor identity to PR merger/reviewers> Merge blockers state że "wrong actor identity must be rejected", ale no actor check istnieje. Operator runningapplyjest unvalidated against PR's merger / reviewers / CODEOWNERS.>> Proposed: capturemerged_by.loginz PR object, accept optional--actor-identity <login>(or env), reject mismatch chyba że--allow-foreign-actor. Może mieć overlap z #163 plan provenance.>> Class:security-sensitive,phase/03###ERROR-CODE-01— Machine-readable approval failure exit codes>ApprovalCheck(False, str)jest human-friendly ale CI/auditors nie mogą distinguish "Forgejo unreachable" od "SHA mismatch" od "missing token". WszystkoEXIT_APPROVAL_MISMATCH.>> Proposed: addApprovalCheck.reason: enum(NETWORK,SHA_MISMATCH,NOT_MERGED,MISSING_TOKEN,PR_NOT_FOUND). Map do distinct exit codes (EXIT_APPROVAL_NETWORK=12, etc.).>> Class:observability,phase/03---## Action items1. Apply 3 BLOCKER patches →apply.py+ tests2. Force-push docodex/issues/142-apply-approval-binding3. File 4 follow-up issues (titles above)4. Comment "ready for re-review" tutaj5. Operator może odpalić re-ralph dla potwierdzenia że score ≥7/9— ralph batch 2026-05-10, claude-opus-4.7 (Pan Herbata) dispatching via codex identityc39d9099681feb290d12Codex iteration — ralph BLOCKERs addressed
Updated branch
codex/issues/142-apply-approval-bindingat1feb290.Applied:
--approved-pris now required./pulls/<pr>/fileslookup is enrichment only and no longer gates approval.Verification:
PYTHONPATH=control-plane pytest control-plane/platformctl/tests/test_apply_phase3.py -q→ 9 passed../tests/run-verify.shcurrently fails on pre-existingorigin/mainprompt debt:prompts/codex-rs2000-close-2026-05-12.mdexceeds token budget and links missingprompts/codex-rs2000-image-prune-2026-05-12.md. I did not fold that unrelated P2 prompt fix into this security-sensitive #162 scope.Filed follow-up issues from the ralph note:
Ready for re-review / operator review.