feat(collab): multi-op M1–M6 + ops UI U1–U8

Session claim/lock, handoff packs, spectator, presence, team chat,
per-op audit; workbench, events rail, teams panel, presets, file crumbs,
pivot map, mobile hit targets. Docs + tests.
This commit is contained in:
Mr. The Plague
2026-08-01 18:37:27 -04:00
parent 1801cf5905
commit 86c985eb89
16 changed files with 1371 additions and 65 deletions
+2
View File
@@ -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
+27
View File
@@ -211,6 +211,33 @@ sc5 audit-verify --limit 500
sc5 teams create red-cell
sc5 teams members <team_id>
sc5 teams add-member <team_id> 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
+21 -1
View File
@@ -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
+18 -4
View File
@@ -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.
---
+223 -21
View File
@@ -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,38 @@ 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."""
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 ""
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 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 +1877,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 +2005,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(
@@ -1960,7 +2109,12 @@ 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)
msg = await state.db.add_chat(auth.name, body.message.strip(), 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 +2123,49 @@ 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; else global."""
state = get_state(request)
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 +2201,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")
+9 -2
View File
@@ -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."""
+3
View File
@@ -1,3 +1,6 @@
from squidc5.collab.presence import PresenceService
from squidc5.collab.teams import TeamService
__all__ = ["PresenceService", "TeamService"]
__all__ = ["TeamService"]
+48
View File
@@ -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]
+209 -7
View File
@@ -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,
}
+2
View File
@@ -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
+20 -3
View File
@@ -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:
+4
View File
@@ -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,
+8 -2
View File
@@ -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 ""
+230
View File
@@ -0,0 +1,230 @@
"""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
# handoff pack to B
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
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
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")
+522 -24
View File
@@ -62,19 +62,55 @@
<button type="button" id="whoamiBtn">Whoami</button>
<button type="button" id="healthBtn">Health</button>
</div>
<label for="layoutPreset">Layout preset (U4)</label>
<select id="layoutPreset">
<option value="admin">Admin (all panels)</option>
<option value="operator">Operator</option>
<option value="lead">Lead / spectator</option>
</select>
<div class="outbox empty-out" id="identOut" style="margin-top:8px">—</div>
`, 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")}
<label for="wbSession">Active session</label>
<select id="wbSession"><option value="">(none)</option></select>
<div class="row" style="margin-top:6px">
<button type="button" id="wbRefreshBtn">Refresh list</button>
${can("collab:use") || can("shell:interact") || can("admin") ? '<button type="button" class="primary" id="wbClaimBtn">Claim</button>' : ""}
${can("collab:use") || can("shell:interact") || can("admin") ? '<button type="button" id="wbReleaseBtn">Release</button>' : ""}
${can("sessions:read") ? '<button type="button" id="wbSpectateBtn">Spectate</button>' : ""}
</div>
<p class="muted mono" id="wbClaimLine" style="margin-top:8px">claim: —</p>
<div class="outbox empty-out" id="wbOut" style="margin-top:8px">Select a session to operate.</div>
`, 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")}
<div class="row">
<button type="button" class="primary" id="eventsConnectBtn">Connect</button>
<button type="button" id="eventsClearBtn">Clear</button>
<span class="chip" id="eventsStatus">off</span>
</div>
<div class="outbox empty-out" id="eventsRail" style="margin-top:8px;min-height:120px;max-height:220px;overflow:auto">—</div>
`, true, "live-events"));
}
// ----- Shell interact -----
if (can("shell:interact")) {
parts.push(panel("quickRunCard", "⌨ Shell", `
${hint("Interactive command runner for <strong>verified reverse shells</strong> 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: <a class=\"doc-link\" href=\"https://grok.com/pedia/reverse-shell\" target=\"_blank\" rel=\"noopener noreferrer\">reverse shell</a>.", "shell")}
${hint("Interactive command runner for <strong>verified reverse shells</strong> only (exec-probe passed). Uses workbench session when set. Buffer shows recent session output — not a live PTY.", "shell")}
<label for="shellSelect">Target shell</label>
<select id="shellSelect"><option value="">(none)</option></select>
<label for="shellCmd">Command</label>
<textarea id="shellCmd" placeholder="whoami"></textarea>
<textarea id="shellCmd" placeholder="whoami" class="touch-lg"></textarea>
<div class="row">
<button type="button" class="primary" id="runShellBtn">Run</button>
<button type="button" class="primary touch-lg" id="runShellBtn">Run</button>
${can("shell:interact") ? '<button type="button" id="runAllBtn">All verified</button>' : ""}
<button type="button" id="dumpOutBtn">Buffer</button>
</div>
@@ -101,14 +137,14 @@
// ----- Tasks (beacons) -----
if (can("tasks:read") || can("tasks:write")) {
parts.push(panel("tasksPanel", "📋 Tasks", `
${hint("Async work queue for <strong>beacon</strong> 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: <a class=\"doc-link\" href=\"https://grok.com/pedia/beacon\" target=\"_blank\" rel=\"noopener noreferrer\">beacon</a> · <a class=\"doc-link\" href=\"https://grok.com/pedia/implant\" target=\"_blank\" rel=\"noopener noreferrer\">implant</a>.", "tasks")}
${hint("Async work queue for <strong>beacon</strong> implants. Session defaults from workbench.", "tasks")}
${can("tasks:write") ? `
<label for="taskSession">Session id</label>
<input id="taskSession" placeholder="beacon session id" autocomplete="off" />
<input id="taskSession" class="wb-bound" placeholder="from workbench" autocomplete="off" />
<label for="taskCmd">Command</label>
<input id="taskCmd" placeholder="id / whoami" autocomplete="off" />
<input id="taskCmd" placeholder="id / whoami" autocomplete="off" class="touch-lg" />
<div class="row">
<button type="button" class="primary" id="createTaskBtn">Create task</button>
<button type="button" class="primary touch-lg" id="createTaskBtn">Create task</button>
<button type="button" id="listTasksBtn">List tasks</button>
</div>
` : `<div class="row"><button type="button" id="listTasksBtn">List tasks</button></div>`}
@@ -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")}
<label for="chatTeam">Team channel</label>
<select id="chatTeam"><option value="">(global)</option></select>
<label for="chatMsg">Message</label>
<input id="chatMsg" placeholder="handoff note…" autocomplete="off" />
<input id="chatMsg" placeholder="handoff note…" autocomplete="off" class="touch-lg" />
<div class="row">
<button type="button" class="primary" id="chatSendBtn">Send</button>
<button type="button" class="primary touch-lg" id="chatSendBtn">Send</button>
<button type="button" id="chatReloadBtn">Reload</button>
</div>
<div class="outbox empty-out" id="chatOut" style="margin-top:8px">—</div>
`, 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")}
<div class="row">
<button type="button" id="teamsReloadBtn">List teams</button>
<button type="button" id="presenceBtn">Who's online</button>
</div>
<label for="newTeamName">Create team</label>
<div class="row">
<input id="newTeamName" placeholder="red-cell" autocomplete="off" />
<button type="button" class="primary" id="teamCreateBtn">Create</button>
</div>
<label for="teamMemberTeam">Add member — team id</label>
<input id="teamMemberTeam" placeholder="team_…" autocomplete="off" />
<label for="teamMemberActor">Actor name</label>
<input id="teamMemberActor" placeholder="alice" autocomplete="off" />
<div class="row">
<button type="button" id="teamAddMemberBtn">Add member</button>
<button type="button" id="teamListMembersBtn">List members</button>
</div>
<hr style="border-color:rgba(255,255,255,0.08);margin:10px 0" />
<label for="handoffTo">Handoff to (actor)</label>
<input id="handoffTo" placeholder="bob" autocomplete="off" />
<label for="handoffNote">Note</label>
<textarea id="handoffNote" placeholder="status + next steps" style="min-height:50px"></textarea>
<div class="row">
<button type="button" class="primary" id="handoffBtn">Handoff active session</button>
<button type="button" id="handoffListBtn">List handoffs</button>
</div>
<div class="outbox empty-out" id="teamsOut" style="margin-top:8px">—</div>
`, 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")}
<div class="row">
<button type="button" class="primary" id="auditMeBtn">My actions</button>
<button type="button" id="auditMineTimelineBtn">My timeline</button>
</div>
<label for="auditActor">Filter actor</label>
<input id="auditActor" placeholder="operator name" autocomplete="off" />
<div class="row">
<button type="button" id="auditActorBtn">Load actor audit</button>
</div>
<div class="outbox empty-out" id="auditMeOut" style="margin-top:8px">—</div>
`, 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")}
<div class="row">
<button type="button" class="primary" id="pivotMapBtn">Refresh map</button>
</div>
<div class="outbox empty-out" id="pivotMapOut" style="margin-top:8px">—</div>
`, 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 <code>file:*</code> commands.", "file-ops")}
<label for="fileSession">Session id</label>
<input id="fileSession" placeholder="beacon session id" autocomplete="off" />
<input id="fileSession" class="wb-bound" placeholder="from workbench" autocomplete="off" />
<label for="fileOp">Op</label>
<select id="fileOp">
<option value="list">list</option>
@@ -381,13 +481,15 @@
<option value="write">write</option>
<option value="delete">delete</option>
</select>
<div class="row" id="fileCrumbs" style="flex-wrap:wrap;gap:4px;margin:6px 0"></div>
<label for="filePath">Path</label>
<input id="filePath" placeholder="/tmp or C:\\\\temp" autocomplete="off" />
<input id="filePath" placeholder="/tmp or ." autocomplete="off" class="touch-lg" />
<label for="fileContent">Content (write)</label>
<textarea id="fileContent" placeholder="optional text" style="min-height:60px"></textarea>
<div class="row">
<button type="button" class="primary" id="fileOpBtn">Queue file op</button>
<button type="button" class="primary touch-lg" id="fileOpBtn">Queue / browse</button>
</div>
<div id="fileTable" style="margin-top:8px;overflow:auto"></div>
<div class="outbox empty-out" id="fileOut" style="margin-top:8px">—</div>
`, 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")}
<label for="socksSession">Session id</label>
<input id="socksSession" placeholder="beacon session id" autocomplete="off" />
<input id="socksSession" class="wb-bound" placeholder="from workbench" autocomplete="off" />
<label for="socksHost">Listen host</label>
<input id="socksHost" value="127.0.0.1" autocomplete="off" />
<label for="socksPort">Listen port (0=ephemeral)</label>
@@ -428,7 +530,7 @@
<button type="button" id="modCatalogBtn">Load catalog</button>
</div>
<label for="modSession">Session id</label>
<input id="modSession" placeholder="beacon session id" autocomplete="off" />
<input id="modSession" class="wb-bound" placeholder="from workbench" autocomplete="off" />
<label for="modTech">Inject technique</label>
<input id="modTech" placeholder="create_remote_thread" autocomplete="off" />
<label for="modPid">PID</label>
@@ -1268,16 +1370,22 @@
}
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 ? `[${m.team_id.slice(0, 8)}] ` : "";
return `${ch}${m.actor}: ${m.message}`;
});
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 +1395,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 = '<option value="">(none)</option>' +
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 `<option value="${escapeHtml(s.id)}">${escapeHtml(label)}</option>`;
}).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 = '<option value="">(global)</option>' +
list.map((t) => `<option value="${escapeHtml(t.id)}">${escapeHtml(t.name || t.id)}</option>`).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 = ['<button type="button" class="chip file-crumb" data-path=".">root</button>'];
parts.forEach((p) => {
acc = (acc ? acc + "/" : (path.startsWith("/") ? "/" : "")) + p;
if (path.startsWith("/") && !acc.startsWith("/")) acc = "/" + acc;
crumbs.push(`<button type="button" class="chip file-crumb" data-path="${escapeHtml(acc)}">${escapeHtml(p)}</button>`);
});
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 = "<table><thead><tr><th></th><th>size</th><th>name</th></tr></thead><tbody>";
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 += `<tr><td>${escapeHtml(mode)}</td><td class="muted">${escapeHtml(sz)}</td>` +
`<td><button type="button" class="file-row" data-path="${escapeHtml(next)}" data-mode="${escapeHtml(mode)}" style="background:none;border:0;color:inherit;cursor:pointer;text-align:left">${escapeHtml(name)}</button></td></tr>`;
});
html += "</tbody></table>";
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 +1933,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;
})();
+25 -1
View File
@@ -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;