-
Notifications
You must be signed in to change notification settings - Fork 0
feat(core): global incident freeze kill-switch #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8764b24
feat(core): add global incident freeze kill-switch + secure GH_PAT ru…
labgadget015-dotcom d7d8a48
style: auto-fix pre-commit issues [skip ci]
github-actions[bot] d9f3982
ci: retrigger Quality Ratchet against ruff-clean head (pre-commit.ci …
labgadget015-dotcom a6da97e
style: auto-fix pre-commit issues [skip ci]
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| """Global incident freeze — the Phase 0 "stop everything" kill switch. | ||
|
|
||
| Per the 2026-08-04 handover (Workstream B), this is the single control that | ||
| defaults write-capable execution to DENY while preserving ingestion, read-only | ||
| analysis, and audit visibility. It is checked by every write path (Python | ||
| executors, n8n write nodes/callbacks, GitHub Actions, retries, scheduled jobs, | ||
| delayed callbacks, manual dispatches). | ||
|
labgadget015-dotcom marked this conversation as resolved.
|
||
|
|
||
| Design rules (from the handover): | ||
| * Defaults to NOT frozen — normal operation is governed by dry_run / approval. | ||
| The freeze is an explicit operator override that escalates the posture. | ||
| * When frozen, ALL write actions are denied for the affected scope; read-only | ||
| and audit paths remain alive. | ||
| * Enabling/disabling records who, why, when, scope, and (for enable) expiry. | ||
| * Safe under repeated activation: re-enabling while already frozen is | ||
| idempotent (state unchanged, no error); disabling while not frozen is a | ||
| no-op (not an error). | ||
| * Does NOT silently discard work — callers must defer + retain status; this | ||
| module only answers "may I write?". | ||
| * Verifiable with a no-op test (see tests/unit/test_incident_freeze.py). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from dataclasses import dataclass, field | ||
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
|
|
||
| DEFAULT_STATE_PATH = Path(__file__).resolve().parent.parent / "state" / "freeze.json" | ||
|
labgadget015-dotcom marked this conversation as resolved.
|
||
|
|
||
| # Scopes: "global" covers everything; specific scopes can be frozen independently | ||
| # (e.g. "n8n", "github-actions", "agent-core"). "global" implies all scopes. | ||
| GLOBAL_SCOPE = "global" | ||
|
|
||
|
|
||
| @dataclass | ||
| class FreezeState: | ||
| frozen: bool = False | ||
| scope: str = GLOBAL_SCOPE | ||
| enabled_by: str | None = None | ||
| enabled_at: str | None = None | ||
| reason: str | None = None | ||
| expires_at: str | None = None | ||
| last_disabled_by: str | None = None | ||
| last_disabled_at: str | None = None | ||
| audit: list = field(default_factory=list) | ||
|
|
||
| def to_dict(self) -> dict: | ||
| return { | ||
| "frozen": self.frozen, | ||
| "scope": self.scope, | ||
| "enabled_by": self.enabled_by, | ||
| "enabled_at": self.enabled_at, | ||
| "reason": self.reason, | ||
| "expires_at": self.expires_at, | ||
| "last_disabled_by": self.last_disabled_by, | ||
| "last_disabled_at": self.last_disabled_at, | ||
| "audit": self.audit, | ||
| } | ||
|
|
||
|
|
||
| class FreezeControl: | ||
| """Operational wrapper around the persisted FreezeState.""" | ||
|
|
||
| def __init__(self, state_path: Path = DEFAULT_STATE_PATH): | ||
| self.state_path = Path(state_path) | ||
| self._state = self._load() | ||
|
|
||
| # --- persistence --- | ||
| def _load(self) -> FreezeState: | ||
| if self.state_path.exists(): | ||
| try: | ||
| raw = json.loads(self.state_path.read_text()) | ||
| return FreezeState( | ||
| **{k: raw.get(k, v) for k, v in FreezeState().__dict__.items()} | ||
| ) | ||
| except Exception: | ||
| # Corrupt state file -> fail safe to NOT frozen (we never want a | ||
| # broken file to wedge the agent; the watchdog will flag it). | ||
| return FreezeState() | ||
| return FreezeState() | ||
|
|
||
| def _save(self) -> None: | ||
| self.state_path.parent.mkdir(parents=True, exist_ok=True) | ||
| # write atomically | ||
| tmp = self.state_path.with_suffix(".tmp") | ||
| tmp.write_text(json.dumps(self._state.to_dict(), indent=2)) | ||
| tmp.replace(self.state_path) | ||
|
|
||
| # --- queries --- | ||
| def is_frozen(self, scope: str = GLOBAL_SCOPE) -> bool: | ||
| s = self._state | ||
| if not s.frozen: | ||
| return False | ||
| # expired freeze auto-clears on read | ||
| if s.expires_at and self._now_iso() >= s.expires_at: | ||
| self._clear_if_expired() | ||
| return False | ||
|
labgadget015-dotcom marked this conversation as resolved.
|
||
| if s.scope == GLOBAL_SCOPE: | ||
| return True | ||
| return scope == s.scope or scope == GLOBAL_SCOPE | ||
|
labgadget015-dotcom marked this conversation as resolved.
|
||
|
|
||
| def is_write_allowed(self, action: str = "any", scope: str = GLOBAL_SCOPE) -> bool: | ||
| """The contract every write path consults. | ||
|
|
||
| Returns True only if the freeze is NOT active for the scope. Dry-run and | ||
| approval gates are handled separately by agent_config. | ||
| """ | ||
| if self.is_frozen(scope): | ||
| return False | ||
| return True | ||
|
|
||
| def status(self) -> dict: | ||
| return self._state.to_dict() | ||
|
|
||
| # --- mutations (operator-only) --- | ||
| def freeze( | ||
| self, | ||
| by: str, | ||
| reason: str, | ||
| scope: str = GLOBAL_SCOPE, | ||
| expires_at: str | None = None, | ||
| ) -> dict: | ||
| """Enable the freeze. Idempotent if already frozen with same scope.""" | ||
| s = self._state | ||
| # If already frozen for an equal-or-broader scope, just refresh metadata | ||
| # without clobbering the original enable timestamp (preserve first cause). | ||
| if s.frozen and ( | ||
| s.scope == GLOBAL_SCOPE or scope == s.scope or scope == GLOBAL_SCOPE | ||
| ): | ||
| entry = { | ||
| "event": "freeze-refreshed", | ||
| "by": by, | ||
| "at": self._now_iso(), | ||
| "scope": scope, | ||
| "reason": reason, | ||
| } | ||
| s.audit.append(entry) | ||
| if expires_at: | ||
| s.expires_at = expires_at | ||
| self._save() | ||
| return s.to_dict() | ||
| # New freeze (broadening scope or first enable) | ||
| s.frozen = True | ||
| s.scope = scope | ||
| s.enabled_by = by | ||
| s.enabled_at = self._now_iso() | ||
| s.reason = reason | ||
| s.expires_at = expires_at | ||
| s.audit.append( | ||
| { | ||
| "event": "freeze-enabled", | ||
| "by": by, | ||
| "at": s.enabled_at, | ||
| "scope": scope, | ||
| "reason": reason, | ||
| "expires_at": expires_at, | ||
| } | ||
| ) | ||
| self._save() | ||
| return s.to_dict() | ||
|
|
||
| def unfreeze(self, by: str, reason: str = "operator cleared") -> dict: | ||
| """Disable the freeze. Idempotent if not frozen.""" | ||
| s = self._state | ||
| if not s.frozen: | ||
| s.audit.append({"event": "unfreeze-noop", "by": by, "at": self._now_iso()}) | ||
| self._save() | ||
| return s.to_dict() | ||
| s.frozen = False | ||
| s.last_disabled_by = by | ||
| s.last_disabled_at = self._now_iso() | ||
| s.audit.append( | ||
| { | ||
| "event": "freeze-disabled", | ||
| "by": by, | ||
| "at": s.last_disabled_at, | ||
| "reason": reason, | ||
| } | ||
| ) | ||
| self._save() | ||
| return s.to_dict() | ||
|
|
||
| # --- helpers --- | ||
| def _now_iso(self) -> str: | ||
| return datetime.now(timezone.utc).isoformat() | ||
|
|
||
| def _clear_if_expired(self) -> None: | ||
| s = self._state | ||
| if s.frozen and s.expires_at and self._now_iso() >= s.expires_at: | ||
| s.frozen = False | ||
| s.audit.append({"event": "freeze-expired", "at": self._now_iso()}) | ||
| self._save() | ||
|
|
||
|
|
||
| # Module-level singleton — import and use directly: | ||
| # from core.incident_freeze import freeze | ||
| # if not freeze.is_write_allowed(scope="n8n"): defer() | ||
| freeze = FreezeControl() | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| """No-op verification tests for the global incident freeze (Phase 0 Workstream B). | ||
|
|
||
| Run: PYTHONPATH=. /usr/bin/python3 -m pytest tests/unit/test_incident_freeze.py -q --no-cov | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def freeze_ctl(tmp_path, monkeypatch): | ||
| # Point the module at a temp state file and reload a fresh singleton | ||
| import core.incident_freeze as mod | ||
|
|
||
| mod.DEFAULT_STATE_PATH = tmp_path / "freeze.json" | ||
| # rebuild singleton against tmp path | ||
| ctl = mod.FreezeControl(state_path=tmp_path / "freeze.json") | ||
| monkeypatch.setattr(mod, "freeze", ctl) | ||
|
labgadget015-dotcom marked this conversation as resolved.
|
||
| return ctl | ||
|
|
||
|
|
||
| def test_defaults_not_frozen(freeze_ctl): | ||
| assert freeze_ctl.is_frozen() is False | ||
| assert freeze_ctl.is_write_allowed("merge") is True | ||
|
|
||
|
|
||
| def test_freeze_blocks_writes(freeze_ctl): | ||
| freeze_ctl.freeze(by="hermes", reason="drill", scope="n8n") | ||
| assert freeze_ctl.is_frozen("n8n") is True | ||
| assert freeze_ctl.is_write_allowed("merge", scope="n8n") is False | ||
| # read-only scope unaffected by a scoped freeze | ||
| assert freeze_ctl.is_write_allowed("merge", scope="github-actions") is True | ||
|
|
||
|
|
||
| def test_global_freeze_blocks_all_scopes(freeze_ctl): | ||
| freeze_ctl.freeze(by="hermes", reason="incident", scope="global") | ||
| assert freeze_ctl.is_frozen("agent-core") is True | ||
| assert freeze_ctl.is_write_allowed("merge", scope="agent-core") is False | ||
| assert freeze_ctl.is_write_allowed("merge", scope="n8n") is False | ||
|
|
||
|
|
||
| def test_idempotent_refreeze(freeze_ctl): | ||
| r1 = freeze_ctl.freeze(by="hermes", reason="first", scope="global") | ||
| first_at = r1["enabled_at"] | ||
| r2 = freeze_ctl.freeze(by="hermes", reason="dup", scope="global") | ||
| # enabled_at preserved (first cause retained), not overwritten | ||
| assert r2["enabled_at"] == first_at | ||
| assert r2["frozen"] is True | ||
|
|
||
|
|
||
| def test_idempotent_unfreeze_when_not_frozen(freeze_ctl): | ||
| # disabling while not frozen must not raise | ||
| r = freeze_ctl.unfreeze(by="hermes") | ||
| assert r["frozen"] is False | ||
|
|
||
|
|
||
| def test_unfreeze_restores_writes(freeze_ctl): | ||
| freeze_ctl.freeze(by="hermes", reason="x", scope="global") | ||
| assert freeze_ctl.is_write_allowed("merge") is False | ||
| freeze_ctl.unfreeze(by="hermes", reason="cleared") | ||
| assert freeze_ctl.is_write_allowed("merge") is True | ||
|
|
||
|
|
||
| def test_expiry_auto_clears(freeze_ctl): | ||
| past = "2000-01-01T00:00:00+00:00" | ||
| freeze_ctl.freeze(by="hermes", reason="temp", scope="global", expires_at=past) | ||
| assert freeze_ctl.is_frozen() is False # expired on read | ||
|
|
||
|
|
||
| def test_audit_records_events(freeze_ctl): | ||
| freeze_ctl.freeze(by="op1", reason="incident", scope="global") | ||
| freeze_ctl.unfreeze(by="op2", reason="resolved") | ||
| events = [a["event"] for a in freeze_ctl.status()["audit"]] | ||
| assert "freeze-enabled" in events | ||
| assert "freeze-disabled" in events | ||
|
|
||
|
|
||
| def test_state_persists(tmp_path): | ||
| import core.incident_freeze as mod | ||
|
|
||
| p = tmp_path / "freeze.json" | ||
| c1 = mod.FreezeControl(state_path=p) | ||
| c1.freeze(by="hermes", reason="persist", scope="global") | ||
| # new instance reads the persisted file | ||
| c2 = mod.FreezeControl(state_path=p) | ||
| assert c2.is_frozen() is True | ||
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.
Uh oh!
There was an error while loading. Please reload this page.