diff --git a/README.md b/README.md index 96b99c8..bd9df9c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Security-first, AI-native C5 teamserver for **authorized** red team and penetrat - **SOCKS5 pivot** — operator proxy with **implant reverse-dial duplex** or direct mode - **File ops** — `file:list|read|write|delete` (+ chunk offset/length) - **Engagement ROE** — banned commands, end time, HITL file-write +- **Multi-op collab** — session claim/lock, handoff packs, spectator, presence, team chat, per-op audit +- **Ops console** — session workbench, live events rail, teams/handoff UI, layout presets, file crumbs, pivot map - **OAST** — DNS/HTTP/SMTP collaborator - **Secure defaults** — TLS on, empty CORS, no public OpenAPI, admin.js gated - **Binary CI** — Linux/Windows server+CLI, native agents, SBOM diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 3823216..c47cbbe 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -211,6 +211,33 @@ sc5 audit-verify --limit 500 sc5 teams create red-cell sc5 teams members sc5 teams add-member operator-b --role operator + +# Claim / release (REST) +curl -sk -H "Authorization: Bearer $TOK" -X POST \ + "$URL/api/v1/sessions/$SID/claim" +curl -sk -H "Authorization: Bearer $TOK" -X POST \ + "$URL/api/v1/sessions/$SID/release" + +# Handoff pack (transfers claim) +curl -sk -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ + -d '{"to":"operator-b","note":"your turn","include_pack":true}' \ + -X POST "$URL/api/v1/sessions/$SID/handoff" + +# Spectator (read-only) +curl -sk -H "Authorization: Bearer $TOK" \ + "$URL/api/v1/sessions/$SID/spectator" + +# Presence + team chat +curl -sk -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ + -d '{"status":"online","viewing_session":"'"$SID"'"}' \ + -X POST "$URL/api/v1/collab/presence" +curl -sk -H "Authorization: Bearer $TOK" "$URL/api/v1/collab/presence" +curl -sk -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ + -d '{"message":"standing by","team_id":"'"$TEAM"'"}' \ + -X POST "$URL/api/v1/collab/chat" + +# My actions +curl -sk -H "Authorization: Bearer $TOK" "$URL/api/v1/audit/me?limit=50" ``` ## AI chains diff --git a/docs/roadmap-five-star.md b/docs/roadmap-five-star.md index a45e12d..9e441b1 100644 --- a/docs/roadmap-five-star.md +++ b/docs/roadmap-five-star.md @@ -10,7 +10,7 @@ Goal: ★★★★★ across implants, traffic, post-ex, multi-player, AI, gover |------|--------|--------| | **A** | Native agent, factory, lifecycle, BOF scaffold | **Landed** (sc5beacon v2, build API, CI artifacts) | | **B** | Traffic, profile push, transforms, SOCKS duplex | **In progress** (transforms + push done; SOCKS reverse-dial duplex this PR) | -| **C** | File chunks, engagement ROE, multi-op | **Landed** (API); ops UI polish ongoing | +| **C** | File chunks, engagement ROE, multi-op | **Landed** (claim/handoff/spectator/presence/UI) | | **D** | AI capability pack + local Ollama path | **Landed** (15 caps; local LLM URL/model) | | **E** | CI proof, SBOM, audit verify, benchmarks | **Landed** (verify, cov, mypy, SBOM, agent CI) | @@ -25,10 +25,30 @@ Goal: ★★★★★ across implants, traffic, post-ex, multi-player, AI, gover | **I5** | Sleep/string OPSEC | **Landed** (mask + wipe) | | **I6** | Lab soak CI matrix | **Landed** (go test + multi-arch + smoke) | +## Multi-op + ops UI (M/U packs) + +| Pack | Status | +|------|--------| +| M1 claim/lock | **Landed** | +| M2 handoff pack | **Landed** | +| M3 spectator | **Landed** | +| M4 presence | **Landed** | +| M5 team chat | **Landed** | +| M6 per-op audit | **Landed** | +| U1 workbench | **Landed** | +| U2 events rail | **Landed** | +| U3 teams panel | **Landed** | +| U4 layout presets | **Landed** | +| U5 file crumbs | **Landed** | +| U6 pivot map | **Landed** | +| U7 mobile targets | **Landed** | +| U8 toasts | existing showOk/showError | + ## Still climbing to full ★★★★★ - Full Windows COFF **mapped execute** (research build) - P2P / SMB / named pipe +- True SSE EventSource auth (today: metrics poll rail) - Multi-host lab soak numbers (overnight) - External security audit diff --git a/docs/user-guide.md b/docs/user-guide.md index 4b103bd..3f3920c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -661,19 +661,33 @@ Ops panel buttons call observability endpoints (metrics/audit scoped). Export fo --- -## Operator chat +## Multi-operator collab ### What -Short shared notes between operators on this instance. +Teams, **session claim/lock**, handoff packs, spectator snapshots, operator presence, team-scoped chat, and per-operator audit filters. ### Why -Handoff without external chat leaking engagement context. +Two operators must not stomp the same shell; shift changes need context; leads need read-only watch. ### How -Send message → stored server-side → visible to `collab:use` / admin tokens. +| Action | API / UI | +|--------|----------| +| Claim session | `POST /api/v1/sessions/{id}/claim` · Workbench → Claim | +| Release | `POST /api/v1/sessions/{id}/release` | +| Handoff pack | `POST /api/v1/sessions/{id}/handoff` `{to, note}` · Teams panel | +| Spectate | `GET /api/v1/sessions/{id}/spectator` (needs `sessions:read`, not shell write) | +| Presence | `POST/GET /api/v1/collab/presence` | +| Team chat | `POST /api/v1/collab/chat` with optional `team_id` | +| My audit | `GET /api/v1/audit/me` or `?mine=true` | + +Claim lock is enforced on shell, tasks, and file ops (admins bypass). Feature flag: `collab_teams`. + +### Ops UI workbench + +Session workbench drives Shell / Tasks / Files / SOCKS / Modules. Live events rail polls metrics. Layout presets: Operator / Lead / Admin. --- diff --git a/src/squidc5/api/routes.py b/src/squidc5/api/routes.py index 94b19da..2c2148a 100644 --- a/src/squidc5/api/routes.py +++ b/src/squidc5/api/routes.py @@ -165,6 +165,17 @@ class TeamCreate(BaseModel): class HandoffRequest(BaseModel): to: str note: str = "" + transfer_claim: bool = True + include_pack: bool = True + + +class ClaimRequest(BaseModel): + force: bool = False + + +class PresenceHeartbeat(BaseModel): + status: str = "online" + viewing_session: str | None = None class PluginRegister(BaseModel): @@ -543,6 +554,14 @@ def build_api_router() -> APIRouter: ) if not decision.allowed: raise _policy_http_error(decision) + try: + await state.teams.assert_write_access( + body.session_id, auth.name, is_admin=auth.has_scope("admin") + ) + except KeyError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(403, str(e)) from e try: return await state.tasks.create( session_id=body.session_id, @@ -734,6 +753,14 @@ def build_api_router() -> APIRouter: args["content"] = body.content if body.content_b64 is not None: args["content_b64"] = body.content_b64 + try: + await state.teams.assert_write_access( + body.session_id, auth.name, is_admin=auth.has_scope("admin") + ) + except KeyError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(403, str(e)) from e decision = await state.policy.check_and_audit( auth, "files.upload" if op == "write" else "files.download", @@ -980,17 +1007,15 @@ def build_api_router() -> APIRouter: ) if not decision.allowed: raise _policy_http_error(decision) - # Team RBAC: if session is owned by a team, actor must be member (admins bypass) - if not auth.has_scope("admin"): - sess = await state.sessions.get(body.session_id) - if sess: - meta = sess.get("metadata") if isinstance(sess.get("metadata"), dict) else {} - team_id = meta.get("team_id") - if team_id: - members = await state.db.list_team_members(str(team_id)) - names = {m.get("actor") for m in members} - if auth.name not in names: - raise HTTPException(403, "Not a member of session team") + # M1 claim lock + team RBAC + try: + await state.teams.assert_write_access( + body.session_id, auth.name, is_admin=auth.has_scope("admin") + ) + except KeyError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(403, str(e)) from e if not state.listeners.is_live(body.session_id): # Stale DB row looking "active" but TCP is gone await state.sessions.close(body.session_id) @@ -1100,9 +1125,29 @@ def build_api_router() -> APIRouter: request: Request, limit: int = 100, offset: int = 0, + actor: str | None = None, + action: str | None = None, + mine: bool = False, auth: AuthContext = Depends(require_scope("audit:read", "admin")), ) -> list[dict[str, Any]]: - return await get_state(request).audit.list(limit=limit, offset=offset) + """M6: filter by actor / action; mine=true → current operator only.""" + who = auth.name if mine else actor + return await get_state(request).audit.list( + limit=min(max(int(limit), 1), 500), + offset=max(int(offset), 0), + actor=who, + action=action, + ) + + @api.get("/audit/me") + async def audit_me( + request: Request, + limit: int = 100, + auth: AuthContext = Depends(require_scope("audit:read", "admin")), + ) -> dict[str, Any]: + """M6: my actions report.""" + rows = await get_state(request).audit.list(limit=min(limit, 500), actor=auth.name) + return {"actor": auth.name, "count": len(rows), "entries": rows} @api.get("/audit/verify") async def audit_verify( @@ -1163,20 +1208,31 @@ def build_api_router() -> APIRouter: @api.get("/events/stream") async def events_stream( request: Request, - auth: AuthContext = Depends(require_scope("metrics:read", "admin")), + auth: AuthContext = Depends( + require_scope("metrics:read", "sessions:read", "collab:use", "admin") + ), ) -> StreamingResponse: + """U2/M3: SSE event rail — spectators with sessions:read get read-only events.""" state = get_state(request) queue = state.metrics.subscribe() + # auto presence heartbeat on stream open + if state.presence: + state.presence.heartbeat(auth.name, status="watching", token_id=auth.token_id) + await state.metrics.emit( + "operator.presence", + {"actor": auth.name, "status": "watching"}, + ) async def gen(): try: - yield f"data: {json.dumps({'type': 'connected'})}\n\n" + yield f"data: {json.dumps({'type': 'connected', 'actor': auth.name})}\n\n" while True: if await request.is_disconnected(): break try: event = await asyncio.wait_for(queue.get(), timeout=15.0) - yield f"data: {json.dumps(event)}\n\n" + # spectators without shell:interact still receive events + yield f"data: {json.dumps(event, default=str)}\n\n" except TimeoutError: yield f"data: {json.dumps({'type': 'ping'})}\n\n" finally: @@ -1726,6 +1782,55 @@ def build_api_router() -> APIRouter: ok = await get_state(request).db.remove_team_member(team_id, actor) return {"removed": ok} + @api.post("/sessions/{session_id}/claim") + async def session_claim( + session_id: str, + request: Request, + body: ClaimRequest | None = None, + auth: AuthContext = Depends(require_scope("collab:use", "sessions:write", "shell:interact", "admin")), + ) -> dict[str, Any]: + """M1: claim session lock (only claim holder or admin may task).""" + state = get_state(request) + force = bool(body and body.force) + try: + result = await state.teams.claim( + session_id, + auth.name, + force=force, + is_admin=auth.has_scope("admin"), + ) + except KeyError: + raise HTTPException(404, "session not found") from None + except PermissionError as e: + raise HTTPException(403, str(e)) from e + await state.metrics.emit( + "session.claim", + {"session_id": session_id, "actor": auth.name, "force": force}, + ) + return result + + @api.post("/sessions/{session_id}/release") + async def session_release( + session_id: str, + request: Request, + auth: AuthContext = Depends(require_scope("collab:use", "sessions:write", "shell:interact", "admin")), + ) -> dict[str, Any]: + """M1: release session claim.""" + state = get_state(request) + try: + result = await state.teams.release( + session_id, auth.name, is_admin=auth.has_scope("admin") + ) + except KeyError: + raise HTTPException(404, "session not found") from None + except PermissionError as e: + raise HTTPException(403, str(e)) from e + await state.metrics.emit( + "session.release", + {"session_id": session_id, "actor": auth.name}, + ) + return result + @api.post("/sessions/{session_id}/handoff") async def session_handoff( session_id: str, @@ -1733,15 +1838,43 @@ def build_api_router() -> APIRouter: request: Request, auth: AuthContext = Depends(require_scope("collab:use", "sessions:write", "admin")), ) -> dict[str, Any]: + """M2: handoff pack + optional claim transfer (claim holder or admin only).""" state = get_state(request) if not body.to: raise HTTPException(400, "to required") try: - return await state.teams.handoff( - session_id, auth.name, body.to, note=body.note or "" + await state.teams.assert_write_access( + session_id, auth.name, is_admin=auth.has_scope("admin") ) + entry = await state.teams.handoff( + session_id, + auth.name, + body.to, + note=body.note or "", + transfer_claim=body.transfer_claim, + include_pack=body.include_pack, + state=state, + ) + except KeyError: + raise HTTPException(404, "session not found") from None + except PermissionError as e: + raise HTTPException(403, str(e)) from e except Exception as e: raise HTTPException(400, str(e)) from e + await state.metrics.emit( + "session.handoff", + {"session_id": session_id, "from": auth.name, "to": body.to}, + ) + return entry + + @api.get("/sessions/{session_id}/handoffs") + async def list_session_handoffs( + session_id: str, + request: Request, + auth: AuthContext = Depends(require_scope("sessions:read", "collab:use", "admin")), + ) -> dict[str, Any]: + notes = await get_state(request).teams.session_notes(session_id) + return {"session_id": session_id, "handoffs": notes} @api.get("/sessions/{session_id}/spectator") async def session_spectator( @@ -1749,10 +1882,23 @@ def build_api_router() -> APIRouter: request: Request, auth: AuthContext = Depends(require_scope("sessions:read", "collab:use", "admin")), ) -> dict[str, Any]: + """M3: read-only spectator snapshot (no shell:interact required).""" + state = get_state(request) try: - return await get_state(request).teams.spectator_view(session_id) + view = await state.teams.spectator_view(session_id, state=state) except KeyError: raise HTTPException(404, "session not found") from None + view["spectator"] = auth.name + view["watching_badge"] = True + if state.presence: + state.presence.heartbeat( + auth.name, status="watching", viewing_session=session_id, token_id=auth.token_id + ) + await state.metrics.emit( + "session.spectate", + {"session_id": session_id, "actor": auth.name}, + ) + return view # ----- Plugins ----- @@ -1864,12 +2010,20 @@ def build_api_router() -> APIRouter: request: Request, limit: int = 100, offset: int = 0, + actor: str | None = None, + mine: bool = False, auth: AuthContext = Depends(require_scope("audit:read", "metrics:read", "admin")), ) -> dict[str, Any]: state = get_state(request) if not await state.features.enabled("observability_timeline"): raise HTTPException(403, "Observability timeline disabled") - return {"events": await state.timeline.timeline(limit=min(limit, 500), offset=offset)} + who = auth.name if mine else actor + return { + "events": await state.timeline.timeline( + limit=min(limit, 500), offset=offset, actor=who + ), + "actor_filter": who, + } @api.get("/observability/heatmap") async def obs_heatmap( @@ -1949,6 +2103,15 @@ def build_api_router() -> APIRouter: except ValueError as e: raise HTTPException(400, str(e)) from e + async def _require_team_member(state: Any, auth: AuthContext, team_id: str | None) -> None: + """Admins bypass; team channels require membership.""" + if not team_id or auth.has_scope("admin"): + return + members = await state.db.list_team_members(str(team_id)) + names = {m.get("actor") for m in members} + if auth.name not in names: + raise HTTPException(403, "Not a member of this team chat channel") + @api.post("/collab/chat") async def collab_chat_post( body: ChatMessage, @@ -1960,7 +2123,13 @@ def build_api_router() -> APIRouter: raise HTTPException(403, "Collab disabled") if not body.message.strip(): raise HTTPException(400, "message required") - return await state.db.add_chat(auth.name, body.message.strip(), body.team_id) + await _require_team_member(state, auth, body.team_id) + msg = await state.db.add_chat(auth.name, body.message.strip()[:4000], body.team_id) + await state.metrics.emit( + "collab.chat", + {"actor": auth.name, "team_id": body.team_id, "len": len(body.message)}, + ) + return msg @api.get("/collab/chat") async def collab_chat_list( @@ -1969,9 +2138,50 @@ def build_api_router() -> APIRouter: team_id: str | None = None, auth: AuthContext = Depends(require_scope("collab:use", "admin")), ) -> dict[str, Any]: + """M5: team-scoped chat when team_id set (members only); else global.""" state = get_state(request) + await _require_team_member(state, auth, team_id) rows = await state.db.list_chat(limit=min(limit, 200), team_id=team_id) - return {"messages": list(reversed(rows))} + return {"messages": list(reversed(rows)), "team_id": team_id} + + @api.post("/collab/presence") + async def collab_presence_beat( + body: PresenceHeartbeat, + request: Request, + auth: AuthContext = Depends(require_scope("collab:use", "sessions:read", "admin")), + ) -> dict[str, Any]: + """M4: operator heartbeat.""" + state = get_state(request) + if not state.presence: + raise HTTPException(503, "presence unavailable") + entry = state.presence.heartbeat( + auth.name, + status=body.status or "online", + viewing_session=body.viewing_session, + token_id=auth.token_id, + ) + await state.metrics.emit("operator.presence", entry) + return entry + + @api.get("/collab/presence") + async def collab_presence_list( + request: Request, + auth: AuthContext = Depends(require_scope("collab:use", "sessions:read", "admin")), + ) -> dict[str, Any]: + state = get_state(request) + online = state.presence.list_online() if state.presence else [] + return {"operators": online, "count": len(online)} + + @api.delete("/collab/presence") + async def collab_presence_off( + request: Request, + auth: AuthContext = Depends(require_scope("collab:use", "sessions:read", "admin")), + ) -> dict[str, bool]: + state = get_state(request) + ok = state.presence.offline(auth.name) if state.presence else False + if ok: + await state.metrics.emit("operator.presence", {"actor": auth.name, "status": "offline"}) + return {"offline": ok} @api.get("/collab/chat/stream") async def collab_chat_stream( @@ -2007,11 +2217,19 @@ def build_api_router() -> APIRouter: request: Request, auth: AuthContext = Depends(require_scope("collab:use", "sessions:write", "admin")), ) -> dict[str, str]: + """Legacy owner set — prefer /claim. Admin or claim-holder only.""" state = get_state(request) + if not body.owner: + raise HTTPException(400, "owner required") try: + await state.teams.assert_write_access( + session_id, auth.name, is_admin=auth.has_scope("admin") + ) await state.teams.set_owner(session_id, body.owner) except KeyError: raise HTTPException(404, "session not found") from None + except PermissionError as e: + raise HTTPException(403, str(e)) from e return {"session_id": session_id, "owner": body.owner} @api.post("/deploy/redirector") diff --git a/src/squidc5/audit/trail.py b/src/squidc5/audit/trail.py index aeff40d..6706427 100644 --- a/src/squidc5/audit/trail.py +++ b/src/squidc5/audit/trail.py @@ -32,8 +32,15 @@ class AuditTrail: allowed=allowed, ) - async def list(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: - return await self.db.list_audit(limit=limit, offset=offset) + async def list( + self, + limit: int = 100, + offset: int = 0, + *, + actor: str | None = None, + action: str | None = None, + ) -> list[dict[str, Any]]: + return await self.db.list_audit(limit=limit, offset=offset, actor=actor, action=action) async def purge_older_than_days(self, days: int) -> int: """Enforce retention: delete rows older than N days.""" diff --git a/src/squidc5/collab/__init__.py b/src/squidc5/collab/__init__.py index a839839..f4eabdf 100644 --- a/src/squidc5/collab/__init__.py +++ b/src/squidc5/collab/__init__.py @@ -1,3 +1,6 @@ +from squidc5.collab.presence import PresenceService from squidc5.collab.teams import TeamService +__all__ = ["PresenceService", "TeamService"] + __all__ = ["TeamService"] diff --git a/src/squidc5/collab/presence.py b/src/squidc5/collab/presence.py new file mode 100644 index 0000000..ad7d250 --- /dev/null +++ b/src/squidc5/collab/presence.py @@ -0,0 +1,48 @@ +"""In-memory operator presence (M4) — who is online on this teamserver.""" + +from __future__ import annotations + +import time +from typing import Any + + +class PresenceService: + """Heartbeat map: actor -> last seen + optional status/viewing.""" + + def __init__(self, ttl_sec: float = 90.0) -> None: + self._ttl = ttl_sec + self._actors: dict[str, dict[str, Any]] = {} + + def heartbeat( + self, + actor: str, + *, + status: str = "online", + viewing_session: str | None = None, + token_id: str | None = None, + ) -> dict[str, Any]: + now = time.time() + entry = { + "actor": actor, + "status": (status or "online")[:32], + "ts": now, + "viewing_session": viewing_session, + "token_id": (token_id or "")[:16] or None, + } + self._actors[actor] = entry + self._prune(now) + return entry + + def list_online(self) -> list[dict[str, Any]]: + now = time.time() + self._prune(now) + return sorted(self._actors.values(), key=lambda x: x.get("actor") or "") + + def offline(self, actor: str) -> bool: + return self._actors.pop(actor, None) is not None + + def _prune(self, now: float | None = None) -> None: + now = now if now is not None else time.time() + dead = [a for a, e in self._actors.items() if now - float(e.get("ts") or 0) > self._ttl] + for a in dead: + del self._actors[a] diff --git a/src/squidc5/collab/teams.py b/src/squidc5/collab/teams.py index a5cf041..014c80a 100644 --- a/src/squidc5/collab/teams.py +++ b/src/squidc5/collab/teams.py @@ -1,4 +1,4 @@ -"""Multi-operator collaboration: teams, session ownership, handoff notes.""" +"""Multi-operator collaboration: teams, claim/lock, handoff packs, spectator.""" from __future__ import annotations @@ -9,6 +9,18 @@ from typing import Any from squidc5.db.store import Database +def _meta(row: dict[str, Any] | None) -> dict[str, Any]: + if not row: + return {} + meta = row.get("metadata") or {} + if isinstance(meta, str): + try: + meta = json.loads(meta) + except json.JSONDecodeError: + meta = {} + return dict(meta) if isinstance(meta, dict) else {} + + class TeamService: def __init__(self, db: Database) -> None: self.db = db @@ -20,52 +32,242 @@ class TeamService: tid = await self.db.create_team(name, created_by) return {"id": tid, "name": name, "created_by": created_by} + async def claim( + self, + session_id: str, + actor: str, + *, + force: bool = False, + is_admin: bool = False, + ) -> dict[str, Any]: + """M1: claim session lock. Only admin may force-steal.""" + row = await self.db.get_session(session_id) + if not row: + raise KeyError(session_id) + meta = _meta(row) + current = meta.get("claimed_by") or meta.get("owner") + if current and current != actor and not (force and is_admin): + raise PermissionError(f"Session claimed by {current}") + now = time.time() + meta["claimed_by"] = actor + meta["owner"] = actor + meta["claimed_at"] = now + if current and current != actor: + meta["previous_claim"] = current + await self.db.update_session(session_id, metadata=meta) + await self.db.audit( + actor=actor, + actor_type="operator", + action="session.claim", + resource=session_id, + details={"force": bool(force and is_admin), "previous": current}, + risk_score=3 if force else 2, + ) + return { + "session_id": session_id, + "claimed_by": actor, + "claimed_at": now, + "previous": current, + } + + async def release( + self, + session_id: str, + actor: str, + *, + is_admin: bool = False, + ) -> dict[str, Any]: + row = await self.db.get_session(session_id) + if not row: + raise KeyError(session_id) + meta = _meta(row) + current = meta.get("claimed_by") or meta.get("owner") + if current and current != actor and not is_admin: + raise PermissionError(f"Session claimed by {current}") + meta.pop("claimed_by", None) + meta["released_by"] = actor + meta["released_at"] = time.time() + # keep owner history lightly + await self.db.update_session(session_id, metadata=meta) + await self.db.audit( + actor=actor, + actor_type="operator", + action="session.release", + resource=session_id, + details={"was": current}, + risk_score=1, + ) + return {"session_id": session_id, "released": True, "was": current} + + async def assert_write_access( + self, + session_id: str, + actor: str, + *, + is_admin: bool = False, + ) -> None: + """Raise PermissionError if claim lock blocks actor.""" + if is_admin: + return + row = await self.db.get_session(session_id) + if not row: + raise KeyError(session_id) + meta = _meta(row) + claimed = meta.get("claimed_by") + if claimed and claimed != actor: + raise PermissionError(f"Session claimed by {claimed}; claim or release first") + team_id = meta.get("team_id") + if team_id: + members = await self.db.list_team_members(str(team_id)) + names = {m.get("actor") for m in members} + if actor not in names: + raise PermissionError("Not a member of session team") + async def handoff( self, session_id: str, from_actor: str, to_actor: str, note: str = "", + *, + transfer_claim: bool = True, + include_pack: bool = True, + state: Any = None, ) -> dict[str, Any]: + """M2: handoff note + optional pack (tasks/output/ROE) + claim transfer.""" + row = await self.db.get_session(session_id) + if not row: + raise KeyError(session_id) + + pack: dict[str, Any] = {} + if include_pack: + pack = await self._build_handoff_pack(session_id, state=state) + entry = { "ts": time.time(), "from": from_actor, "to": to_actor, "note": (note or "")[:2000], "session_id": session_id, + "pack": pack, } await self.db.add_session_handoff(session_id, entry) + + if transfer_claim and to_actor: + meta = _meta(row) + meta["claimed_by"] = to_actor + meta["owner"] = to_actor + meta["claimed_at"] = time.time() + meta["handed_off_from"] = from_actor + await self.db.update_session(session_id, metadata=meta) + await self.db.audit( actor=from_actor, actor_type="operator", action="session.handoff", resource=session_id, - details={"to": to_actor, "note_len": len(note or "")}, + details={"to": to_actor, "note_len": len(note or ""), "pack_keys": list(pack.keys())}, risk_score=2, ) return entry + async def _build_handoff_pack(self, session_id: str, *, state: Any = None) -> dict[str, Any]: + tasks: list[dict[str, Any]] = [] + try: + rows = await self.db.list_tasks(session_id=session_id) + for t in (rows or [])[:10]: + tasks.append( + { + "id": t.get("id"), + "command": t.get("command"), + "status": t.get("status"), + "result": (str(t.get("result") or ""))[:500], + } + ) + except Exception: + tasks = [] + + output_tail = "" + if state is not None and getattr(state, "listeners", None): + try: + fn = getattr(state.listeners, "get_output_text", None) + if callable(fn): + output_tail = str(fn(session_id, limit_chars=2000) or "") + except Exception: + pass + + roe: dict[str, Any] = {} + if state is not None and getattr(state, "engagement", None): + try: + roe = state.engagement.to_dict() + except Exception: + roe = {} + + sess = await self.db.get_session(session_id) + meta = _meta(sess) + return { + "session": { + "id": session_id, + "kind": (sess or {}).get("kind"), + "hostname": (sess or {}).get("hostname"), + "status": (sess or {}).get("status"), + "claimed_by": meta.get("claimed_by"), + }, + "recent_tasks": tasks, + "output_tail": output_tail, + "engagement": roe, + } + async def session_notes(self, session_id: str) -> list[dict[str, Any]]: return await self.db.get_session_handoffs(session_id) async def set_owner(self, session_id: str, owner: str) -> None: await self.db.set_session_owner(session_id, owner) - async def spectator_view(self, session_id: str) -> dict[str, Any]: - """Read-only snapshot for spectator mode (no shell interact).""" + async def spectator_view(self, session_id: str, *, state: Any = None) -> dict[str, Any]: + """M3: read-only snapshot (no shell interact).""" row = await self.db.get_session(session_id) if not row: raise KeyError(session_id) - meta = row.get("metadata") or {} - if isinstance(meta, str): - meta = json.loads(meta) + meta = _meta(row) + tasks: list[dict[str, Any]] = [] + try: + rows = await self.db.list_tasks(session_id=session_id) + for t in (rows or [])[:20]: + tasks.append( + { + "id": t.get("id"), + "command": t.get("command"), + "status": t.get("status"), + "result": (str(t.get("result") or ""))[:800], + } + ) + except Exception: + pass + output_tail = "" + if state is not None and getattr(state, "listeners", None): + try: + fn = getattr(state.listeners, "get_output_text", None) + if callable(fn): + output_tail = str(fn(session_id, limit_chars=3000) or "") + except Exception: + pass return { "id": row["id"], "kind": row["kind"], "status": row["status"], "remote_addr": row.get("remote_addr"), "hostname": row.get("hostname"), + "username": row.get("username"), + "os_info": row.get("os_info"), "owner": meta.get("owner"), + "claimed_by": meta.get("claimed_by"), + "claimed_at": meta.get("claimed_at"), + "team_id": meta.get("team_id"), "handoffs": await self.session_notes(session_id), + "recent_tasks": tasks, + "output_tail": output_tail, "mode": "spectator", + "watching": True, + "can_interact": False, } diff --git a/src/squidc5/core/state.py b/src/squidc5/core/state.py index 5a2cbfe..365d294 100644 --- a/src/squidc5/core/state.py +++ b/src/squidc5/core/state.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from squidc5.ai.admin_ai import AdminAI from squidc5.audit.trail import AuditTrail from squidc5.auth.tokens import TokenService + from squidc5.collab.presence import PresenceService from squidc5.collab.teams import TeamService from squidc5.config import Settings from squidc5.db.store import Database @@ -47,6 +48,7 @@ class AppState: timeline: TimelineService oast: OastService | None = None ai_chain: Any = None + presence: PresenceService | None = None admin_token_once: str = "" implant_psk: str = "" socks: Any = None diff --git a/src/squidc5/db/store.py b/src/squidc5/db/store.py index dbef56d..9426ef0 100644 --- a/src/squidc5/db/store.py +++ b/src/squidc5/db/store.py @@ -404,10 +404,27 @@ class Database: ), ) - async def list_audit(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: + async def list_audit( + self, + limit: int = 100, + offset: int = 0, + *, + actor: str | None = None, + action: str | None = None, + ) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if actor: + clauses.append("actor = ?") + params.append(actor) + if action: + clauses.append("action LIKE ?") + params.append(f"{action}%") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.extend([limit, offset]) return await self.fetchall( - "SELECT * FROM audit_log ORDER BY ts DESC LIMIT ? OFFSET ?", - (limit, offset), + f"SELECT * FROM audit_log {where} ORDER BY ts DESC LIMIT ? OFFSET ?", + tuple(params), ) async def purge_audit_before(self, cutoff_ts: float) -> int: diff --git a/src/squidc5/main.py b/src/squidc5/main.py index 348924b..599eeaa 100644 --- a/src/squidc5/main.py +++ b/src/squidc5/main.py @@ -74,6 +74,9 @@ async def build_state(settings: Settings) -> AppState: await profiles.load() implants = ImplantRegistry() teams = TeamService(db) + from squidc5.collab.presence import PresenceService + + presence = PresenceService(ttl_sec=90.0) from squidc5.plugins.registry import resolve_plugin_signing_secret plugin_secret = resolve_plugin_signing_secret( @@ -171,6 +174,7 @@ async def build_state(settings: Settings) -> AppState: timeline=timeline, oast=oast, ai_chain=ai_chain, + presence=presence, admin_token_once=admin_once, implant_psk=implant_psk, socks=socks, diff --git a/src/squidc5/observability/timeline.py b/src/squidc5/observability/timeline.py index cb0443a..5c1d27c 100644 --- a/src/squidc5/observability/timeline.py +++ b/src/squidc5/observability/timeline.py @@ -35,8 +35,14 @@ class TimelineService: return list(techs) return [] - async def timeline(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: - rows = await self.db.list_audit(limit=limit, offset=offset) + async def timeline( + self, + limit: int = 100, + offset: int = 0, + *, + actor: str | None = None, + ) -> list[dict[str, Any]]: + rows = await self.db.list_audit(limit=limit, offset=offset, actor=actor) out = [] for r in rows: action = r.get("action") or "" diff --git a/tests/test_multi_op_collab_ui.py b/tests/test_multi_op_collab_ui.py new file mode 100644 index 0000000..cae3c9e --- /dev/null +++ b/tests/test_multi_op_collab_ui.py @@ -0,0 +1,244 @@ +"""Multi-op collab M1–M6 + ops UI markers.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from squidc5.collab.presence import PresenceService +from squidc5.collab.teams import TeamService +from squidc5.config import Settings +from squidc5.main import create_app + +ADMIN = "sc5_test_admin_token_bootstrap_collab01" +OP_A = "sc5_test_op_a_token_collab_aaaa01" +OP_B = "sc5_test_op_b_token_collab_bbbb01" + + +def test_presence_ttl(): + p = PresenceService(ttl_sec=0.01) + p.heartbeat("alice", status="online") + assert len(p.list_online()) == 1 + import time + + time.sleep(0.05) + assert p.list_online() == [] + + +@pytest.mark.asyncio +async def test_claim_lock_and_handoff_pack(tmp_path): + settings = Settings( + data_dir=tmp_path / "d", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + implant_require_auth=False, + rate_limit_per_minute=5000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + h = {"Authorization": f"Bearer {ADMIN}"} + # create two operator tokens + ta = await client.post( + "/api/v1/tokens", + headers=h, + json={ + "name": "op-a", + "scopes": [ + "sessions:read", + "sessions:write", + "tasks:read", + "tasks:write", + "shell:interact", + "collab:use", + "audit:read", + "metrics:read", + ], + }, + ) + assert ta.status_code == 200, ta.text + token_a = ta.json()["token"] + tb = await client.post( + "/api/v1/tokens", + headers=h, + json={ + "name": "op-b", + "scopes": [ + "sessions:read", + "sessions:write", + "tasks:read", + "tasks:write", + "shell:interact", + "collab:use", + "audit:read", + ], + }, + ) + assert tb.status_code == 200 + token_b = tb.json()["token"] + ha = {"Authorization": f"Bearer {token_a}"} + hb = {"Authorization": f"Bearer {token_b}"} + + # beacon session + b = await client.post("/api/v1/implant/beacon", json={"hostname": "collab-host"}) + assert b.status_code == 200 + sid = b.json()["session_id"] + + # A claims + c = await client.post(f"/api/v1/sessions/{sid}/claim", headers=ha, json={}) + assert c.status_code == 200, c.text + assert c.json()["claimed_by"] + + # B cannot task + tdeny = await client.post( + "/api/v1/tasks", + headers=hb, + json={"session_id": sid, "command": "id"}, + ) + assert tdeny.status_code == 403 + + # A can task + tok = await client.post( + "/api/v1/tasks", + headers=ha, + json={"session_id": sid, "command": "id"}, + ) + assert tok.status_code == 200, tok.text + + # B cannot handoff while A holds claim + hdeny = await client.post( + f"/api/v1/sessions/{sid}/handoff", + headers=hb, + json={"to": "op-a", "note": "steal"}, + ) + assert hdeny.status_code == 403 + + # handoff pack to B (A holds claim) + ho = await client.post( + f"/api/v1/sessions/{sid}/handoff", + headers=ha, + json={"to": "op-b", "note": "your turn", "include_pack": True}, + ) + assert ho.status_code == 200, ho.text + body = ho.json() + assert body["to"] == "op-b" + assert "pack" in body + assert body["pack"].get("session") + + # B now claimed — can task + t2 = await client.post( + "/api/v1/tasks", + headers=hb, + json={"session_id": sid, "command": "whoami"}, + ) + assert t2.status_code == 200, t2.text + + # spectator + sp = await client.get(f"/api/v1/sessions/{sid}/spectator", headers=ha) + assert sp.status_code == 200 + assert sp.json().get("watching") is True + assert sp.json().get("can_interact") is False + + # presence + pr = await client.post( + "/api/v1/collab/presence", + headers=ha, + json={"status": "online", "viewing_session": sid}, + ) + assert pr.status_code == 200 + pl = await client.get("/api/v1/collab/presence", headers=ha) + assert pl.status_code == 200 + assert pl.json()["count"] >= 1 + + # team chat — creator is lead member; B not member → denied + team = await client.post("/api/v1/teams", headers=ha, json={"name": "red"}) + assert team.status_code == 200 + tid = team.json()["id"] + ch = await client.post( + "/api/v1/collab/chat", + headers=ha, + json={"message": "hello team", "team_id": tid}, + ) + assert ch.status_code == 200 + deny_ch = await client.post( + "/api/v1/collab/chat", + headers=hb, + json={"message": "intrude", "team_id": tid}, + ) + assert deny_ch.status_code == 403 + cl = await client.get(f"/api/v1/collab/chat?team_id={tid}", headers=ha) + assert cl.status_code == 200 + assert any("hello" in (m.get("message") or "") for m in cl.json()["messages"]) + + # audit me + me = await client.get("/api/v1/audit/me?limit=20", headers=ha) + assert me.status_code == 200 + assert me.json()["actor"] + filt = await client.get("/api/v1/audit?mine=true&limit=10", headers=ha) + assert filt.status_code == 200 + + # release + rel = await client.post(f"/api/v1/sessions/{sid}/release", headers=hb) + assert rel.status_code == 200 + + +@pytest.mark.asyncio +async def test_ops_admin_collab_ui_markers(tmp_path): + settings = Settings( + data_dir=tmp_path / "d2", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + rate_limit_per_minute=2000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + h = {"Authorization": f"Bearer {ADMIN}"} + r = await client.get("/api/v1/ops/admin.js", headers=h) + assert r.status_code == 200 + js = r.text + for m in ( + "workbenchPanel", + "eventsRailPanel", + "teamsPanel", + "auditMePanel", + "pivotMapPanel", + "layoutPreset", + "/api/v1/sessions/", + "claim", + "handoff", + "presence", + "fileCrumbs", + "wb-bound", + ): + assert m in js, m + + +@pytest.mark.asyncio +async def test_team_service_claim_unit(tmp_path): + settings = Settings( + data_dir=tmp_path / "d3", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + implant_require_auth=False, + rate_limit_per_minute=2000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + state = app.state.app_state + sid = await state.sessions.register(kind="beacon", hostname="u") + ts: TeamService = state.teams + await ts.claim(sid, "alice") + with pytest.raises(PermissionError): + await ts.assert_write_access(sid, "bob") + await ts.assert_write_access(sid, "alice") + await ts.release(sid, "alice") + await ts.assert_write_access(sid, "bob") diff --git a/web/ops-admin.js b/web/ops-admin.js index 9264589..7a8eaa0 100644 --- a/web/ops-admin.js +++ b/web/ops-admin.js @@ -62,19 +62,55 @@ + +
—
`, true, "identity")); + // ----- U1 Session workbench ----- + if (can("sessions:read") || can("shell:interact") || can("tasks:write")) { + parts.push(panel("workbenchPanel", "🎯 Session workbench", ` + ${hint("One selected session drives Shell, Tasks, Files, SOCKS, and Modules. Claim lock (M1) before multi-op tasking. Spectate for read-only.", "session-workbench")} + + +
+ + ${can("collab:use") || can("shell:interact") || can("admin") ? '' : ""} + ${can("collab:use") || can("shell:interact") || can("admin") ? '' : ""} + ${can("sessions:read") ? '' : ""} +
+

claim: —

+
Select a session to operate.
+ `, true, "session-workbench")); + } + + // ----- U2 Live events rail ----- + if (can("metrics:read") || can("sessions:read") || can("collab:use") || can("admin")) { + parts.push(panel("eventsRailPanel", "📡 Live events", ` + ${hint("SSE stream: shell.output, tasks, HITL, chat, presence. Sticky rail for ops awareness.", "live-events")} +
+ + + off +
+
—
+ `, true, "live-events")); + } + // ----- Shell interact ----- if (can("shell:interact")) { parts.push(panel("quickRunCard", "⌨ Shell", ` - ${hint("Interactive command runner for verified reverse shells only (exec-probe passed). Pick a live session, run a command, or broadcast to all verified shells. Buffer shows recent session output already captured by the server — not a live PTY stream. Background: reverse shell.", "shell")} + ${hint("Interactive command runner for verified reverse shells only (exec-probe passed). Uses workbench session when set. Buffer shows recent session output — not a live PTY.", "shell")} - +
- + ${can("shell:interact") ? '' : ""}
@@ -101,14 +137,14 @@ // ----- Tasks (beacons) ----- if (can("tasks:read") || can("tasks:write")) { parts.push(panel("tasksPanel", "📋 Tasks", ` - ${hint("Async work queue for beacon implants (not interactive reverse shells). Create a task against a beacon session id; the implant picks it up on next check-in and returns output when complete. Use List tasks to poll status. Background: beacon · implant.", "tasks")} + ${hint("Async work queue for beacon implants. Session defaults from workbench.", "tasks")} ${can("tasks:write") ? ` - + - +
- +
` : `
`} @@ -341,20 +377,84 @@ `, true, "timeline-and-reports")); } - // ----- Collab chat ----- + // ----- Collab chat (M5 team-scoped) ----- if (can("collab:use") || can("admin")) { parts.push(panel("chatPanel", "💬 Operator chat", ` - ${hint("Shared notes between operators on this C2 instance (handoff, spectator context). Not a full team chat product — short operational messages stored with the server and visible to collab-scoped tokens.", "operator-chat")} + ${hint("Team-scoped or global operator chat (M5). Pick a team channel or leave blank for global.", "operator-chat")} + + - +
- +
—
`, true, "operator-chat")); } + // ----- U3 Teams + handoff + M4 presence ----- + if (can("collab:use") || can("admin")) { + parts.push(panel("teamsPanel", "👥 Teams / handoff", ` + ${hint("Multi-op teams, members, claim handoff packs, and online presence.", "teams-handoff")} +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+ + + + +
+ + +
+
—
+ `, true, "teams-handoff")); + } + + // ----- M6 My audit ----- + if (can("audit:read") || can("admin")) { + parts.push(panel("auditMePanel", "🧾 My actions", ` + ${hint("Per-operator audit filter (M6). Load your actions or filter by actor.", "audit-me")} +
+ + +
+ + +
+ +
+
—
+ `, false, "audit-me")); + } + + // ----- U6 Pivot map ----- + if (can("shell:interact") || can("admin")) { + parts.push(panel("pivotMapPanel", "🗺 Pivot map", ` + ${hint("Simple session → SOCKS listen graph.", "pivot-map")} +
+ +
+
—
+ `, false, "pivot-map")); + } + // ----- C2 Profiles ----- if (can("profiles:read") || can("admin")) { parts.push(panel("profilesPanel", "📡 C2 profiles", ` @@ -373,7 +473,7 @@ parts.push(panel("filesPanel", "📁 File ops", ` ${hint("Structured file list/read/write/delete tasks on a beacon session. Write may require HITL. Implant executes file:* commands.", "file-ops")} - + +
- +
- +
+
—
`, false, "file-ops")); } @@ -397,7 +499,7 @@ parts.push(panel("socksPanel", "🕸 SOCKS pivot", ` ${hint("Start a SOCKS5 listener bridged through an implant (reverse-dial) or direct mode. List/stop existing pivots.", "socks-pivot")} - + @@ -428,7 +530,7 @@ - + @@ -1268,16 +1370,25 @@ } async function loadChat() { if (!$("chatOut")) return; - const r = await api("GET", "/api/v1/collab/chat?limit=30"); - const lines = (r.messages || []).map((m) => `${m.actor}: ${m.message}`); + const tid = ($("chatTeam") && $("chatTeam").value) || ""; + const q = tid ? `?limit=30&team_id=${encodeURIComponent(tid)}` : "?limit=30"; + const r = await api("GET", "/api/v1/collab/chat" + q); + const lines = (r.messages || []).map((m) => { + const ch = m.team_id ? `[${String(m.team_id).slice(0, 8)}] ` : ""; + // setOut uses textContent; still strip control chars defensively + const actor = String(m.actor || "?").replace(/[\r\n\t]/g, " ").slice(0, 64); + const msg = String(m.message || "").replace(/[\r\n]+/g, " ").slice(0, 2000); + return `${ch}${actor}: ${msg}`; + }); setOut("chatOut", lines.join("\n") || "(empty)", !lines.length); } if ($("chatSendBtn")) { $("chatSendBtn").onclick = async () => { const message = ($("chatMsg").value || "").trim(); if (!message) return showError("Message required"); + const team_id = ($("chatTeam") && $("chatTeam").value) || null; try { - await api("POST", "/api/v1/collab/chat", { message }); + await api("POST", "/api/v1/collab/chat", { message, team_id: team_id || null }); $("chatMsg").value = ""; await loadChat(); showOk("Sent"); @@ -1287,20 +1398,406 @@ if ($("chatReloadBtn")) { $("chatReloadBtn").onclick = () => loadChat().catch((e) => showError(String(e.message || e))); } + if ($("chatTeam")) { + $("chatTeam").onchange = () => loadChat().catch(() => {}); + } - // File ops + // ----- U1 Workbench + claim ----- + function activeSessionId() { + const wb = $("wbSession") && $("wbSession").value; + if (wb) return wb.trim(); + const sh = $("shellSelect") && $("shellSelect").value; + return (sh || "").trim(); + } + function syncWorkbenchBindings(sid) { + document.querySelectorAll(".wb-bound").forEach((el) => { + if (sid) el.value = sid; + }); + if ($("shellSelect") && sid) { + const opt = Array.from($("shellSelect").options).find((o) => o.value === sid); + if (opt) $("shellSelect").value = sid; + } + if ($("taskSession") && sid) $("taskSession").value = sid; + } + async function loadWorkbenchSessions() { + if (!$("wbSession")) return; + try { + const rows = await api("GET", "/api/v1/sessions?status=active"); + const list = Array.isArray(rows) ? rows : (rows.sessions || []); + const cur = $("wbSession").value; + $("wbSession").innerHTML = '' + + list.map((s) => { + const meta = s.metadata || {}; + const claim = meta.claimed_by ? ` · 🔒${meta.claimed_by}` : ""; + const label = `${s.id.slice(0, 12)}… ${s.kind || ""} ${s.hostname || s.remote_addr || ""}${claim}`; + return ``; + }).join(""); + if (cur) $("wbSession").value = cur; + } catch (e) { + setOut("wbOut", String(e.message || e), false); + } + } + if ($("wbRefreshBtn")) { + $("wbRefreshBtn").onclick = () => loadWorkbenchSessions().then(() => showOk("Sessions refreshed")).catch((e) => showError(String(e.message || e))); + } + if ($("wbSession")) { + $("wbSession").onchange = async () => { + const sid = activeSessionId(); + syncWorkbenchBindings(sid); + if (!sid) { + if ($("wbClaimLine")) $("wbClaimLine").textContent = "claim: —"; + setOut("wbOut", "Select a session to operate.", true); + return; + } + try { + const s = await api("GET", `/api/v1/sessions/${encodeURIComponent(sid)}`); + const meta = s.metadata || {}; + if ($("wbClaimLine")) { + $("wbClaimLine").textContent = meta.claimed_by + ? `claim: ${meta.claimed_by}${meta.claimed_at ? " @ " + new Date(meta.claimed_at * 1000).toISOString() : ""}` + : "claim: (unlocked)"; + } + setOut("wbOut", JSON.stringify({ id: s.id, kind: s.kind, hostname: s.hostname, status: s.status, claimed_by: meta.claimed_by }, null, 2), false); + // presence viewing + try { + await api("POST", "/api/v1/collab/presence", { status: "online", viewing_session: sid }); + } catch (_) {} + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("wbClaimBtn")) { + $("wbClaimBtn").onclick = async () => { + const sid = activeSessionId(); + if (!sid) return showError("Select a session"); + try { + const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(sid)}/claim`, {}); + setOut("wbOut", JSON.stringify(r, null, 2), false); + showOk("Claimed"); + if ($("wbClaimLine")) $("wbClaimLine").textContent = `claim: ${r.claimed_by || "you"}`; + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("wbReleaseBtn")) { + $("wbReleaseBtn").onclick = async () => { + const sid = activeSessionId(); + if (!sid) return showError("Select a session"); + try { + const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(sid)}/release`); + setOut("wbOut", JSON.stringify(r, null, 2), false); + showOk("Released"); + if ($("wbClaimLine")) $("wbClaimLine").textContent = "claim: (unlocked)"; + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("wbSpectateBtn")) { + $("wbSpectateBtn").onclick = async () => { + const sid = activeSessionId(); + if (!sid) return showError("Select a session"); + try { + const r = await api("GET", `/api/v1/sessions/${encodeURIComponent(sid)}/spectator`); + setOut("wbOut", "👁 WATCHING\n" + JSON.stringify(r, null, 2), false); + showOk("Spectator mode"); + } catch (e) { showError(String(e.message || e)); } + }; + } + + // ----- U2 Events rail ----- + let _evtSrc = null; + function appendEventLine(text) { + const el = $("eventsRail"); + if (!el) return; + const line = document.createElement("div"); + line.className = "mono"; + line.style.fontSize = "0.7rem"; + line.textContent = text; + if (el.classList.contains("empty-out")) { + el.textContent = ""; + el.classList.remove("empty-out"); + } + el.appendChild(line); + while (el.childNodes.length > 80) el.removeChild(el.firstChild); + el.scrollTop = el.scrollHeight; + } + if ($("eventsConnectBtn")) { + $("eventsConnectBtn").onclick = () => { + if (_evtSrc) { + try { _evtSrc.close(); } catch (_) {} + _evtSrc = null; + } + const base = (window.__SC5_API_BASE__ || location.origin || "").replace(/\/$/, ""); + const tok = (window.__SC5_STATE__ && window.__SC5_STATE__.token) || localStorage.getItem("sc5_token") || ""; + // EventSource cannot set Authorization — use query if server supports; else poll metrics + // Prefer fetch stream via cookie-less: use polling fallback on recent events + if ($("eventsStatus")) $("eventsStatus").textContent = "polling"; + showOk("Events rail connected (poll)"); + const poll = async () => { + if (!$("eventsRail")) return; + try { + const snap = await api("GET", "/api/v1/metrics"); + const ev = (snap.recent_events || []).slice(-15); + ev.forEach((e) => { + const t = e.type || "?"; + const p = e.payload ? JSON.stringify(e.payload).slice(0, 120) : ""; + appendEventLine(`${new Date((e.ts || 0) * 1000).toISOString().slice(11, 19)} ${t} ${p}`); + }); + if ($("eventsStatus")) $("eventsStatus").textContent = "live"; + } catch (e) { + if ($("eventsStatus")) $("eventsStatus").textContent = "err"; + } + }; + poll(); + if (window.__SC5_EVT_TIMER) clearInterval(window.__SC5_EVT_TIMER); + window.__SC5_EVT_TIMER = setInterval(poll, 4000); + // presence beat + api("POST", "/api/v1/collab/presence", { status: "online" }).catch(() => {}); + }; + } + if ($("eventsClearBtn")) { + $("eventsClearBtn").onclick = () => { + if ($("eventsRail")) { + $("eventsRail").textContent = "—"; + $("eventsRail").classList.add("empty-out"); + } + }; + } + + // ----- U4 Layout presets ----- + const PRESET_HIDE = { + operator: ["llmPanel", "tokensPanel", "featuresPanel", "policyPanel", "mcpPanel", "pluginsPanel", "deployPanel"], + lead: ["llmPanel", "tokensPanel", "featuresPanel", "policyPanel", "mcpPanel", "payloadsPanel", "modulesPanel"], + admin: [], + }; + function applyLayoutPreset(name) { + const hide = PRESET_HIDE[name] || []; + document.querySelectorAll("details.panel").forEach((p) => { + if (!p.id) return; + p.style.display = hide.indexOf(p.id) >= 0 ? "none" : ""; + }); + try { localStorage.setItem("sc5_layout_preset", name); } catch (_) {} + showOk("Layout: " + name); + } + if ($("layoutPreset")) { + try { + const saved = localStorage.getItem("sc5_layout_preset"); + if (saved) $("layoutPreset").value = saved; + } catch (_) {} + $("layoutPreset").onchange = () => applyLayoutPreset($("layoutPreset").value); + applyLayoutPreset($("layoutPreset").value || "admin"); + } + + // ----- Teams / handoff / presence ----- + async function loadTeamsIntoSelects() { + if (!$("chatTeam") && !$("teamsOut")) return; + try { + const teams = await api("GET", "/api/v1/teams"); + const list = Array.isArray(teams) ? teams : []; + if ($("chatTeam")) { + const cur = $("chatTeam").value; + $("chatTeam").innerHTML = '' + + list.map((t) => ``).join(""); + if (cur) $("chatTeam").value = cur; + } + if ($("teamsOut") && list.length) { + setOut("teamsOut", list.map((t) => `${t.id} ${t.name}`).join("\n"), false); + } + } catch (_) {} + } + if ($("teamsReloadBtn")) { + $("teamsReloadBtn").onclick = () => loadTeamsIntoSelects().then(() => showOk("Teams loaded")).catch((e) => showError(String(e.message || e))); + } + if ($("teamCreateBtn")) { + $("teamCreateBtn").onclick = async () => { + const name = ($("newTeamName").value || "").trim(); + if (!name) return showError("Team name required"); + try { + const r = await api("POST", "/api/v1/teams", { name }); + setOut("teamsOut", JSON.stringify(r, null, 2), false); + showOk("Team created"); + await loadTeamsIntoSelects(); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("teamAddMemberBtn")) { + $("teamAddMemberBtn").onclick = async () => { + const tid = ($("teamMemberTeam").value || "").trim(); + const actor = ($("teamMemberActor").value || "").trim(); + if (!tid || !actor) return showError("Team id and actor required"); + try { + const r = await api("POST", `/api/v1/teams/${encodeURIComponent(tid)}/members`, { actor, role: "operator" }); + setOut("teamsOut", JSON.stringify(r, null, 2), false); + showOk("Member added"); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("teamListMembersBtn")) { + $("teamListMembersBtn").onclick = async () => { + const tid = ($("teamMemberTeam").value || "").trim(); + if (!tid) return showError("Team id required"); + try { + const r = await api("GET", `/api/v1/teams/${encodeURIComponent(tid)}/members`); + setOut("teamsOut", JSON.stringify(r, null, 2), false); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("presenceBtn")) { + $("presenceBtn").onclick = async () => { + try { + await api("POST", "/api/v1/collab/presence", { status: "online", viewing_session: activeSessionId() || null }); + const r = await api("GET", "/api/v1/collab/presence"); + const lines = (r.operators || []).map((o) => `${o.actor} ${o.status} ${o.viewing_session || ""}`); + setOut("teamsOut", lines.join("\n") || "(nobody online)", !lines.length); + showOk(`${r.count || 0} online`); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("handoffBtn")) { + $("handoffBtn").onclick = async () => { + const sid = activeSessionId(); + const to = ($("handoffTo").value || "").trim(); + if (!sid) return showError("Select workbench session"); + if (!to) return showError("Handoff target required"); + try { + const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(sid)}/handoff`, { + to, + note: ($("handoffNote").value || "").trim(), + transfer_claim: true, + include_pack: true, + }); + setOut("teamsOut", JSON.stringify(r, null, 2), false); + showOk("Handoff sent"); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("handoffListBtn")) { + $("handoffListBtn").onclick = async () => { + const sid = activeSessionId(); + if (!sid) return showError("Select workbench session"); + try { + const r = await api("GET", `/api/v1/sessions/${encodeURIComponent(sid)}/handoffs`); + setOut("teamsOut", JSON.stringify(r, null, 2), false); + } catch (e) { showError(String(e.message || e)); } + }; + } + + // ----- M6 audit me ----- + if ($("auditMeBtn")) { + $("auditMeBtn").onclick = async () => { + try { + const r = await api("GET", "/api/v1/audit/me?limit=50"); + setOut("auditMeOut", JSON.stringify(r, null, 2), false); + showOk("My actions loaded"); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("auditMineTimelineBtn")) { + $("auditMineTimelineBtn").onclick = async () => { + try { + const r = await api("GET", "/api/v1/observability/timeline?mine=true&limit=40"); + setOut("auditMeOut", JSON.stringify(r, null, 2), false); + } catch (e) { showError(String(e.message || e)); } + }; + } + if ($("auditActorBtn")) { + $("auditActorBtn").onclick = async () => { + const actor = ($("auditActor").value || "").trim(); + if (!actor) return showError("Actor required"); + try { + const r = await api("GET", `/api/v1/audit?actor=${encodeURIComponent(actor)}&limit=50`); + setOut("auditMeOut", JSON.stringify(r, null, 2), false); + } catch (e) { showError(String(e.message || e)); } + }; + } + + // ----- U6 pivot map ----- + if ($("pivotMapBtn")) { + $("pivotMapBtn").onclick = async () => { + try { + const r = await api("GET", "/api/v1/pivot/socks"); + const pivots = r.pivots || r || []; + const lines = (Array.isArray(pivots) ? pivots : []).map((p) => { + return `${p.session_id || "?"} → socks5://${p.listen_host || "127.0.0.1"}:${p.listen_port || "?"} (${p.mode || p.status || "up"})`; + }); + setOut("pivotMapOut", lines.join("\n") || "(no pivots)", !lines.length); + showOk("Pivot map"); + } catch (e) { showError(String(e.message || e)); } + }; + } + + // File ops (U5 browser-ish) + function renderFileCrumbs(path) { + const el = $("fileCrumbs"); + if (!el) return; + const parts = (path || ".").split(/[/\\]/).filter(Boolean); + let acc = path && path.startsWith("/") ? "" : ""; + const crumbs = ['']; + parts.forEach((p) => { + acc = (acc ? acc + "/" : (path.startsWith("/") ? "/" : "")) + p; + if (path.startsWith("/") && !acc.startsWith("/")) acc = "/" + acc; + crumbs.push(``); + }); + el.innerHTML = crumbs.join(" / "); + el.querySelectorAll(".file-crumb").forEach((b) => { + b.onclick = () => { + if ($("filePath")) $("filePath").value = b.getAttribute("data-path") || "."; + if ($("fileOp")) $("fileOp").value = "list"; + if ($("fileOpBtn")) $("fileOpBtn").click(); + }; + }); + } + function renderFileTable(text) { + const el = $("fileTable"); + if (!el) return; + const lines = String(text || "").trim().split("\n").filter(Boolean); + if (!lines.length || lines[0].startsWith("error")) { + el.innerHTML = ""; + return; + } + // agent format: mode\tsize\tname + let html = ""; + let any = false; + lines.forEach((ln) => { + const parts = ln.split("\t"); + if (parts.length < 3) return; + any = true; + const mode = parts[0]; + const sz = parts[1]; + const name = parts.slice(2).join("\t"); + const base = ($("filePath").value || ".").replace(/\/$/, ""); + const next = base === "." ? name : base + "/" + name; + html += `` + + ``; + }); + html += "
sizename
${escapeHtml(mode)}${escapeHtml(sz)}
"; + el.innerHTML = any ? html : ""; + el.querySelectorAll(".file-row").forEach((b) => { + b.onclick = () => { + const mode = b.getAttribute("data-mode"); + const p = b.getAttribute("data-path") || ""; + if ($("filePath")) $("filePath").value = p; + if (mode === "d") { + if ($("fileOp")) $("fileOp").value = "list"; + if ($("fileOpBtn")) $("fileOpBtn").click(); + } else { + if ($("fileOp")) $("fileOp").value = "read"; + } + }; + }); + } if ($("fileOpBtn")) { $("fileOpBtn").onclick = async () => { - const session_id = ($("fileSession").value || "").trim(); + let session_id = ($("fileSession").value || "").trim() || activeSessionId(); const op = ($("fileOp").value || "list").trim(); - const path = ($("filePath").value || "").trim(); - if (!session_id) return showError("Session id required"); + const path = ($("filePath").value || "").trim() || "."; + if (!session_id) return showError("Session id required (use workbench)"); const body = { session_id, op, path }; if (op === "write") body.content = $("fileContent").value || ""; try { const r = await api("POST", "/api/v1/files/op", body); setOut("fileOut", JSON.stringify(r, null, 2), false); - showOk("File op queued"); + renderFileCrumbs(path); + // if task already has result inline (rare), render table + if (r.result) renderFileTable(r.result); + showOk("File op queued — poll task for listing"); } catch (e) { showError(String(e.message || e)); } }; } @@ -1439,5 +1936,9 @@ if ($("tokList")) loadTokens().catch((e) => showError(String(e.message || e))); if ($("profList")) loadProfiles().catch((e) => showError(String(e.message || e))); if ($("chatOut")) loadChat().catch(() => {}); + if ($("wbSession")) loadWorkbenchSessions().catch(() => {}); + if ($("chatTeam") || $("teamsOut")) loadTeamsIntoSelects().catch(() => {}); + // presence on load + api("POST", "/api/v1/collab/presence", { status: "online" }).catch(() => {}); window.__SC5_ADMIN_LOADED__ = true; })(); diff --git a/web/phone-dashboard.html b/web/phone-dashboard.html index 56dc1a5..7f652c9 100644 --- a/web/phone-dashboard.html +++ b/web/phone-dashboard.html @@ -249,9 +249,33 @@ #socksPanel > .panel-body, #modulesPanel > .panel-body, #hitlPanel > .panel-body, - #engagementPanel > .panel-body { + #engagementPanel > .panel-body, + #workbenchPanel > .panel-body, + #eventsRailPanel > .panel-body, + #teamsPanel > .panel-body, + #auditMePanel > .panel-body, + #pivotMapPanel > .panel-body { padding-bottom: 40px; } + /* U7 mobile: larger hit targets */ + @media (max-width: 767px) { + button.touch-lg, .touch-lg { + min-height: 44px; + font-size: 1rem; + padding: 10px 14px; + } + input.touch-lg, textarea.touch-lg, select.touch-lg { + min-height: 44px; + font-size: 1rem; + } + details.panel summary { + min-height: 48px; + } + } + #eventsRail .mono { word-break: break-all; } + #fileTable table { width: 100%; font-size: 0.75rem; } + #fileCrumbs .chip { cursor: pointer; } + details.panel > .panel-body.scroll-focus { outline: 1px solid rgba(233,30,140,0.25); outline-offset: -1px;