From 73a932d8be381190388647c72b9bf13cc11c9a0e Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sat, 23 May 2026 00:24:31 +0200 Subject: [PATCH] =?UTF-8?q?stage-25:=20response=20actions=20=E2=80=94=20hu?= =?UTF-8?q?man-gated=20enforcement=20+=20the=20disco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop: intel -> decision -> enforcement -> audit. High/critical cases propose response actions (alert SOC, push IOCs to perimeter firewall+DNS). Nothing fires automatically — each sits PROPOSED until a human approves, then it's POSTed to the enforcement sink (PSYC_SOAR_URL, default mock-cert /soar/enforce) and written to the ledger as ACTIONED. - models: ActionType / ActionStatus / ResponseAction - db: response_actions table - lines/respond.py: propose_for_case (idempotent, sev-gated), execute_action (fire + ledger + mark), reject_action; mock SOAR endpoint in mock_cert - cockpit /response page: proposed/enforced/declined tabs, ⚡ Enforce + decline, and the disco — a full-screen strobe + "ENFORCED" + IOC-scatter animation that fires on approval (respects prefers-reduced-motion) - cli: respond / actions / act-approve / act-reject - 8 tests; verified the full loop live (propose -> enforce -> disco -> SOAR receipt -> ledger ACTIONED row) Co-Authored-By: Claude Opus 4.7 --- docker-compose.yml | 1 + src/psyc/cli.py | 59 ++++++- src/psyc/cockpit/app.py | 36 ++++ src/psyc/cockpit/static/cockpit.css | 50 ++++++ src/psyc/cockpit/templates/base.html | 1 + src/psyc/cockpit/templates/response.html | 81 +++++++++ src/psyc/db.py | 18 ++ src/psyc/lines/respond.py | 211 +++++++++++++++++++++++ src/psyc/mock_cert.py | 7 + src/psyc/models.py | 28 +++ tests/test_respond.py | 144 ++++++++++++++++ 11 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/psyc/cockpit/templates/response.html create mode 100644 src/psyc/lines/respond.py create mode 100644 tests/test_respond.py diff --git a/docker-compose.yml b/docker-compose.yml index d034f38..1d89a5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: VIRTUAL_HOST: psyc.neuronetz.ai VIRTUAL_PORT: "8767" PSYC_MOCK_CERT_URL: http://mock-cert:8770 + PSYC_SOAR_URL: http://mock-cert:8770 PSYC_INFERENCE_URL: http://inference:8771 ports: - "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80 diff --git a/src/psyc/cli.py b/src/psyc/cli.py index 0ea83eb..5a180b2 100644 --- a/src/psyc/cli.py +++ b/src/psyc/cli.py @@ -13,7 +13,7 @@ from psyc import db, log load_dotenv() # per-dev .env (API keys) is loaded into os.environ for venv CLI from psyc.cockpit import inference -from psyc.lines import classify, courier, lookup, proof, route, scout, seal, train +from psyc.lines import classify, courier, lookup, proof, respond, route, scout, seal, train from psyc.lines import map as map_line from psyc.models import Outcome from psyc.result import Err, Ok @@ -396,6 +396,63 @@ def export_blocklist( typer.echo(text) +@app.command("respond") +def respond_propose(case_id: str = typer.Argument(..., help="case to propose response actions for")) -> None: + """Propose human-gated response actions for a high-severity case.""" + result = db.get_case(case_id) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + ids = respond.propose_for_case(result.value) + if not ids: + typer.echo(f"{case_id}: no actions proposed (not high-severity, or already has actions)") + return + typer.echo(f"{case_id}: proposed {len(ids)} action(s) → ids {', '.join(map(str, ids))}") + + +@app.command("actions") +def actions_list(status: str = typer.Option("proposed", help="proposed | executed | rejected | failed | all")) -> None: + """List response actions.""" + from psyc.models import ActionStatus + sf = None if status == "all" else ActionStatus(status) + rows = respond.list_actions(status=sf) + if not rows: + typer.echo(f"(no actions with status={status})") + return + for a in rows: + appr = f" by {a.approver}" if a.approver else "" + typer.echo(f" #{a.id} {a.status.value:9s} [{a.action_type.value:9s}] {a.case_id} sev={a.severity or '?'}{appr}") + typer.echo(f" {a.summary}") + + +@app.command("act-approve") +def act_approve( + action_id: int = typer.Argument(..., help="response action id"), + approver: str = typer.Option("operator", "--by", help="approver identity"), +) -> None: + """Approve + fire a response action (pushes to the enforcement sink).""" + result = respond.execute_action(action_id, approver=approver) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + a = result.value + typer.echo(f"⚡ enforced #{action_id} [{a.action_type.value}] → {a.detail}") + + +@app.command("act-reject") +def act_reject( + action_id: int = typer.Argument(..., help="response action id"), + approver: str = typer.Option("operator", "--by", help="reviewer identity"), + reason: str = typer.Option("", "--reason", help="why declined"), +) -> None: + """Decline a proposed response action — nothing fires.""" + result = respond.reject_action(action_id, approver=approver, reason=reason) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + typer.echo(f"declined #{action_id}{(': ' + reason) if reason else ''}") + + @app.command("mock-cert") def mock_cert_serve(host: str = "127.0.0.1", port: int = 8770) -> None: uvicorn.run("psyc.mock_cert:app", host=host, port=port) diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index c6b955f..292a6b6 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -15,6 +15,7 @@ from psyc.cockpit import inference, journey as journey_view from psyc.lines import courier as courier_line from psyc.lines import ledger as ledger_line from psyc.lines import lookup as lookup_line +from psyc.lines import respond as respond_line from psyc.lines import route as route_line from psyc.lines import seal as seal_line from psyc.lines import train as train_line @@ -138,6 +139,41 @@ def export_blocklist(type: str = "ip", min_severity: str = "") -> PlainTextRespo return PlainTextResponse(header + "\n".join(values) + "\n") +@app.get("/response", response_class=HTMLResponse) +def response_view(request: Request, status: str = "proposed", fired: int = 0, kind: str = "") -> HTMLResponse: + from psyc.models import ActionStatus + sf = None if status == "all" else ActionStatus(status) + actions = respond_line.list_actions(status=sf, limit=200) + counts = { + "proposed": respond_line.action_count(ActionStatus.PROPOSED), + "executed": respond_line.action_count(ActionStatus.EXECUTED), + "rejected": respond_line.action_count(ActionStatus.REJECTED), + "failed": respond_line.action_count(ActionStatus.FAILED), + } + return TEMPLATES.TemplateResponse( + request, + "response.html", + {"actions": actions, "counts": counts, "current_status": status, "fired": fired, "fired_kind": kind}, + ) + + +@app.post("/response/approve/{action_id}") +def response_approve(action_id: int, approver: str = Form("operator")) -> RedirectResponse: + result = respond_line.execute_action(action_id, approver=approver) + if isinstance(result, Err): + _log.warning("cockpit.response.approve.error", action_id=action_id, reason=result.reason) + return RedirectResponse("/response", status_code=303) + # Carry the fired action id + type so the page can set off the disco. + kind = result.value.action_type.value + return RedirectResponse(f"/response?fired={action_id}&kind={kind}", status_code=303) + + +@app.post("/response/reject/{action_id}") +def response_reject(action_id: int, approver: str = Form("operator"), reason: str = Form("")) -> RedirectResponse: + respond_line.reject_action(action_id, approver=approver, reason=reason) + return RedirectResponse("/response", status_code=303) + + @app.get("/queue", response_class=HTMLResponse) def queue_view(request: Request, status: str = "pending") -> HTMLResponse: from psyc.models import ApprovalStatus diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index df1e49d..d55779f 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -325,3 +325,53 @@ tr.sev-low .sev-badge { color: var(--muted); } .verdict { padding: 12px 16px; border-radius: 6px; margin: 14px 0; font-size: 14px; } .verdict-bad { background: rgba(248, 113, 113, 0.12); border: 1px solid var(--red); color: var(--red); } .verdict-clean { background: rgba(74, 222, 128, 0.10); border: 1px solid var(--green); color: var(--green); } + +/* ── response actions ───────────────────────────────────────── */ +.act-type { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; text-transform: uppercase; border: 1px solid var(--border); } +.act-alert { color: var(--amber); border-color: var(--amber); } +.act-blocklist { color: var(--red); border-color: var(--red); } +.act-ticket { color: var(--accent); border-color: var(--accent); } +.btn-enforce { color: var(--bg); background: var(--accent); border-color: var(--accent); font-weight: 700; } +.btn-enforce:hover { background: #5ad8ff; box-shadow: 0 0 12px var(--accent-glow); } +.outcome-failed { background: rgba(248,113,113,0.15); color: var(--red); border: 1px solid var(--red); } + +/* ── the disco: enforcement payoff ──────────────────────────── */ +.disco { + position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; + background: rgba(8,10,14,0.72); backdrop-filter: blur(2px); animation: disco-in 0.25s ease-out; +} +.disco.disco-out { animation: disco-fade 0.7s ease forwards; } +.disco-strobe { + position: absolute; inset: 0; mix-blend-mode: screen; animation: strobe 0.42s steps(1) infinite; +} +@keyframes strobe { + 0% { background: radial-gradient(circle at 30% 40%, rgba(30,200,255,0.55), transparent 55%); } + 25% { background: radial-gradient(circle at 70% 30%, rgba(248,113,113,0.55), transparent 55%); } + 50% { background: radial-gradient(circle at 50% 70%, rgba(74,222,128,0.50), transparent 55%); } + 75% { background: radial-gradient(circle at 25% 65%, rgba(251,191,36,0.50), transparent 55%); } + 100% { background: radial-gradient(circle at 75% 50%, rgba(167,139,250,0.55), transparent 55%); } +} +.disco-core { position: relative; text-align: center; animation: disco-pop 0.45s cubic-bezier(.2,1.4,.4,1); } +.disco-bolt { font-size: 84px; line-height: 1; filter: drop-shadow(0 0 18px var(--accent)); animation: bolt-buzz 0.18s steps(2) infinite; } +@keyframes bolt-buzz { 0%{transform:rotate(-6deg) scale(1);} 100%{transform:rotate(6deg) scale(1.08);} } +.disco-headline { + font-family: var(--font-display); font-size: 64px; font-weight: 700; letter-spacing: 0.12em; + color: #fff; text-shadow: 0 0 24px var(--accent), 0 0 48px rgba(30,200,255,0.6); margin-top: 6px; +} +.disco-sub { color: var(--text); font-size: 14px; margin-top: 4px; opacity: 0.9; } +.disco-iocs { margin-top: 18px; height: 28px; position: relative; } +.ioc-fly { + position: absolute; left: 50%; font-size: 22px; opacity: 0; + animation: ioc-scatter 1.1s ease-out var(--d) forwards; +} +@keyframes ioc-scatter { + 0% { opacity: 0; transform: translate(-50%, 0) scale(0.4); } + 30% { opacity: 1; } + 100% { opacity: 0; transform: translate(calc(-50% + var(--x)), -120px) scale(1.1); } +} +@keyframes disco-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes disco-fade { to { opacity: 0; } } +@keyframes disco-pop { from { transform: scale(0.6); opacity: 0; } to { transform: scale(1); opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + .disco-strobe, .disco-bolt, .ioc-fly { animation: none; } +} diff --git a/src/psyc/cockpit/templates/base.html b/src/psyc/cockpit/templates/base.html index 7db71ff..2a23032 100644 --- a/src/psyc/cockpit/templates/base.html +++ b/src/psyc/cockpit/templates/base.html @@ -19,6 +19,7 @@