mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from squidc5.collab.presence import PresenceService
|
||||
from squidc5.collab.teams import TeamService
|
||||
|
||||
__all__ = ["PresenceService", "TeamService"]
|
||||
|
||||
__all__ = ["TeamService"]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user