mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
feat: shell stabilize toggle (default off) + one-click Stabilize
- shell_auto_stabilize default false (config, features, env, compose)
- Runtime auto controlled by feature flag; Admin feature checkboxes
- POST /sessions/{id}/stabilize: OS auto-detect Linux/Windows stage-2
- Context rail Stabilize shell button
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ SQUIDC5_SECURITY_HEADERS=true
|
||||
# SQUIDC5_CORS_ORIGINS=["https://ops.example.mil"]
|
||||
# Public callback host/IP for stage-2 implants (lab/prod). Example: 203.0.113.10
|
||||
SQUIDC5_PUBLIC_HOST=
|
||||
SQUIDC5_SHELL_AUTO_STABILIZE=true
|
||||
SQUIDC5_SHELL_AUTO_STABILIZE=false
|
||||
# Optional: set a known bootstrap admin token (otherwise auto-generated)
|
||||
# SQUIDC5_ADMIN_TOKEN_BOOTSTRAP=sc5_your_secure_token_here
|
||||
# Plugin HMAC signing secret (or auto-generated under data/plugin_signing.secret)
|
||||
|
||||
@@ -41,7 +41,7 @@ Ops UI: `/ops` (admin UI loaded only after server-side admin token check)
|
||||
| MCP external tools | **OFF** until settings/feature enable |
|
||||
| Shell exec probe | **ON** |
|
||||
| False-shell filter | **ON** |
|
||||
| Auto stage-2 stabilize | **ON** |
|
||||
| Auto stage-2 stabilize | **OFF** (manual Stabilize or feature flag) |
|
||||
| Health details | **minimal** (`{"status":"ok"}`) |
|
||||
| Security headers | **ON** (nosniff, DENY frame, CSP, no-store) |
|
||||
| Admin ops UI | **server-gated** by admin scope |
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ services:
|
||||
# Secure-by-default: MCP off until explicitly needed
|
||||
SQUIDC5_MCP_ENABLED: ${SQUIDC5_MCP_ENABLED:-false}
|
||||
SQUIDC5_AI_ENABLED: ${SQUIDC5_AI_ENABLED:-true}
|
||||
SQUIDC5_SHELL_AUTO_STABILIZE: ${SQUIDC5_SHELL_AUTO_STABILIZE:-true}
|
||||
SQUIDC5_SHELL_AUTO_STABILIZE: ${SQUIDC5_SHELL_AUTO_STABILIZE:-false}
|
||||
SQUIDC5_EXPOSE_HEALTH_DETAILS: ${SQUIDC5_EXPOSE_HEALTH_DETAILS:-false}
|
||||
SQUIDC5_SECURITY_HEADERS: ${SQUIDC5_SECURITY_HEADERS:-true}
|
||||
# Stage-2 reconnect callback host - set in .env (never commit real IPs)
|
||||
|
||||
+9
-1
@@ -453,7 +453,15 @@ sc5 shell <session_id> "whoami"
|
||||
|
||||
### Why stage-2 stabilize
|
||||
|
||||
Raw reverse shells die on network blips and often lack a clean line protocol. Auto-stabilize injects a reconnecting agent (Linux Python / Windows PowerShell) that re-checks in to `SQUIDC5_PUBLIC_HOST:<port>` and supports reliable command execution. Stage-2 reconnects skip re-staging. Exec probe must pass or the session is dropped.
|
||||
Raw reverse shells die on network blips and often lack a clean line protocol. Stage-2 injects a reconnecting agent (Linux Python / Windows PowerShell) that re-checks in to `SQUIDC5_PUBLIC_HOST:<port>` and supports reliable command execution. Stage-2 reconnects skip re-staging. Exec probe must pass or the session is dropped.
|
||||
|
||||
**Default: auto-stabilize is OFF.** Prefer one-shot **Stabilize shell** on the session context rail (detects Linux vs Windows). Enable auto via Admin → Features → *Auto stage-2 on reverse-shell capture*, or `SQUIDC5_SHELL_AUTO_STABILIZE=true`.
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Manual stabilize | Ops → session → **Stabilize shell** → `POST /api/v1/sessions/{id}/stabilize` `{os:"auto"}` |
|
||||
| Force OS | `{os:"linux"}` or `{os:"windows"}` |
|
||||
| Auto on capture | Feature `shell_auto_stabilize` (default false) |
|
||||
|
||||
### Pitfalls
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Environment=SQUIDC5_DATA_DIR=/opt/squidc5/data
|
||||
Environment=SQUIDC5_PUBLIC_HOST=
|
||||
Environment=SQUIDC5_MCP_ENABLED=false
|
||||
Environment=SQUIDC5_AI_ENABLED=true
|
||||
Environment=SQUIDC5_SHELL_AUTO_STABILIZE=true
|
||||
Environment=SQUIDC5_SHELL_AUTO_STABILIZE=false
|
||||
Environment=SQUIDC5_EXPOSE_HEALTH_DETAILS=false
|
||||
Environment=SQUIDC5_SECURITY_HEADERS=true
|
||||
Environment=SQUIDC5_TLS_ENABLED=true
|
||||
|
||||
@@ -279,6 +279,12 @@ class ClaimRequest(BaseModel):
|
||||
ttl_sec: int | None = None # override default claim TTL; 0 = no expiry
|
||||
|
||||
|
||||
class StabilizeRequest(BaseModel):
|
||||
"""One-shot stage-2. os: auto|linux|windows (default auto = probe)."""
|
||||
|
||||
os: str | None = "auto"
|
||||
|
||||
|
||||
class PresenceHeartbeat(BaseModel):
|
||||
status: str = "online"
|
||||
viewing_session: str | None = None
|
||||
@@ -1723,6 +1729,54 @@ def build_api_router() -> APIRouter:
|
||||
raise HTTPException(404, result.get("error") or "No live reverse shell for session")
|
||||
return result
|
||||
|
||||
@api.post("/sessions/{session_id}/stabilize")
|
||||
async def stabilize_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
body: StabilizeRequest | None = None,
|
||||
auth: AuthContext = Depends(require_scope("shell:interact", "sessions:write", "admin")),
|
||||
) -> dict[str, Any]:
|
||||
"""One-shot stage-2: detect Linux/Windows and inject reconnect agent."""
|
||||
state = get_state(request)
|
||||
decision = await state.policy.check_and_audit(
|
||||
auth,
|
||||
"shell.stabilize",
|
||||
resource=session_id,
|
||||
extra={"os": (body.os if body else None) or "auto"},
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise _policy_http_error(decision)
|
||||
try:
|
||||
await state.teams.assert_write_access(
|
||||
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(session_id):
|
||||
raise HTTPException(
|
||||
404,
|
||||
"No live TCP channel — stabilize needs an open reverse shell",
|
||||
)
|
||||
os_hint = (body.os if body else None) or "auto"
|
||||
if os_hint.strip().lower() in ("", "auto", "detect"):
|
||||
os_hint = None
|
||||
try:
|
||||
result = await state.listeners.stabilize_session(
|
||||
session_id,
|
||||
os_hint=os_hint,
|
||||
actor=auth.name,
|
||||
delay=False,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
await state.metrics.emit(
|
||||
"shell.stabilize.manual",
|
||||
{"session_id": session_id, "actor": auth.name, "result": result.get("status")},
|
||||
)
|
||||
return result
|
||||
|
||||
@api.post("/shell/broadcast")
|
||||
async def shell_broadcast(
|
||||
request: Request,
|
||||
|
||||
@@ -48,8 +48,9 @@ class Settings(BaseSettings):
|
||||
# Implant beacon AEAD (ChaCha20-Poly1305). PSK auto-generated under data/implant_psk.txt
|
||||
implant_psk: str | None = None
|
||||
implant_require_auth: bool = True
|
||||
# Reverse-shell auto-stabilization (stage-2 reconnect agents)
|
||||
shell_auto_stabilize: bool = True
|
||||
# Reverse-shell auto-stabilization (stage-2 reconnect agents). Default OFF —
|
||||
# operators enable via feature flag or one-shot Stabilize on a session.
|
||||
shell_auto_stabilize: bool = False
|
||||
# Host/IP implants should call back to (defaults to request/local bind if empty)
|
||||
public_host: str = ""
|
||||
public_ip: str = "" # A-record for OAST DNS answers (SQUIDC5_PUBLIC_IP)
|
||||
|
||||
@@ -11,7 +11,7 @@ from squidc5.db.store import Database
|
||||
DEFAULT_FEATURES: dict[str, bool] = {
|
||||
"ai_enabled": True,
|
||||
"mcp_enabled": False, # external AI off until explicitly enabled
|
||||
"shell_auto_stabilize": True,
|
||||
"shell_auto_stabilize": False, # manual Stabilize preferred; enable for auto stage-2
|
||||
"shell_exec_probe": True,
|
||||
"shell_broadcast": True,
|
||||
"false_shell_filter": True,
|
||||
@@ -33,7 +33,7 @@ DEFAULT_FEATURES: dict[str, bool] = {
|
||||
FEATURE_LABELS: dict[str, str] = {
|
||||
"ai_enabled": "Admin AI (LLM capabilities)",
|
||||
"mcp_enabled": "External MCP tools",
|
||||
"shell_auto_stabilize": "Reverse-shell auto stage-2",
|
||||
"shell_auto_stabilize": "Auto stage-2 on reverse-shell capture (default OFF)",
|
||||
"shell_exec_probe": "Shell exec verification / zombie drop",
|
||||
"shell_broadcast": "Shell broadcast to all verified",
|
||||
"false_shell_filter": "TLS/HTTP false-shell filter",
|
||||
|
||||
@@ -465,20 +465,20 @@ class ListenerManager:
|
||||
|
||||
out_task = asyncio.create_task(pump_out())
|
||||
stabilize_task: asyncio.Task[None] | None = None
|
||||
# Feature flags (optional; default on when unset)
|
||||
do_stabilize = self.auto_stabilize
|
||||
# Auto-stabilize: feature flag is the runtime switch (seeded from settings default OFF)
|
||||
do_stabilize = bool(self.auto_stabilize)
|
||||
do_probe = True
|
||||
do_filter = True
|
||||
if self.feature_check is not None:
|
||||
try:
|
||||
do_stabilize = do_stabilize and await self.feature_check("shell_auto_stabilize")
|
||||
do_stabilize = bool(await self.feature_check("shell_auto_stabilize"))
|
||||
do_probe = await self.feature_check("shell_exec_probe")
|
||||
do_filter = await self.feature_check("false_shell_filter")
|
||||
except Exception:
|
||||
pass
|
||||
if kind == "reverse_shell" and do_stabilize:
|
||||
stabilize_task = asyncio.create_task(
|
||||
self._auto_stabilize(sid, cb_host, cb_port),
|
||||
self._stabilize_session(sid, cb_host, cb_port, delay=True),
|
||||
name=f"stabilize-{sid}",
|
||||
)
|
||||
# Verify channel can execute - drop echo-only zombies
|
||||
@@ -586,63 +586,149 @@ class ListenerManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _auto_stabilize(self, session_id: str, host: str, port: int) -> None:
|
||||
"""Probe OS then inject platform stage-2 reconnect agent."""
|
||||
async def resolve_callback(self, session_id: str) -> tuple[str, int]:
|
||||
"""Public host + listener port for stage-2 reconnect."""
|
||||
writer = self._writers.get(session_id)
|
||||
if writer is None:
|
||||
raise RuntimeError("session has no live TCP channel")
|
||||
srow = await self.db.get_session(session_id)
|
||||
listener_port = 0
|
||||
if srow and srow.get("listener_id"):
|
||||
lrow = await self.db.get_listener(str(srow["listener_id"]))
|
||||
if lrow:
|
||||
listener_port = int(lrow["port"] or 0)
|
||||
return self._callback_host_port(writer, listener_port)
|
||||
|
||||
async def stabilize_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
os_hint: str | None = None,
|
||||
actor: str = "operator",
|
||||
delay: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Operator one-shot: probe OS (or use hint) and inject Win/Linux stage-2."""
|
||||
if not self.is_live(session_id):
|
||||
raise RuntimeError("session is not a live reverse shell")
|
||||
host, port = await self.resolve_callback(session_id)
|
||||
return await self._stabilize_session(
|
||||
session_id,
|
||||
host,
|
||||
port,
|
||||
os_hint=os_hint,
|
||||
actor=actor,
|
||||
delay=delay,
|
||||
reject_on_noise=False,
|
||||
)
|
||||
|
||||
async def _stabilize_session(
|
||||
self,
|
||||
session_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
os_hint: str | None = None,
|
||||
actor: str = "system",
|
||||
delay: bool = True,
|
||||
reject_on_noise: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe OS then inject platform stage-2 reconnect agent (Linux/Windows)."""
|
||||
try:
|
||||
await asyncio.sleep(self.stabilize_delay_sec)
|
||||
if delay:
|
||||
await asyncio.sleep(self.stabilize_delay_sec)
|
||||
if session_id in self._rejected:
|
||||
return
|
||||
return {"status": "rejected", "session_id": session_id}
|
||||
|
||||
early = "".join(self._shell_buffers.get(session_id, [])[-10:])
|
||||
if early:
|
||||
v = classify_inbound(early)
|
||||
if not v.is_shell and v.confidence >= 0.8:
|
||||
await self._reject_session(session_id, v.reason, "stabilize-early")
|
||||
return
|
||||
if reject_on_noise:
|
||||
await self._reject_session(session_id, v.reason, "stabilize-early")
|
||||
return {"status": "rejected", "reason": v.reason, "session_id": session_id}
|
||||
return {"status": "error", "reason": v.reason, "session_id": session_id}
|
||||
|
||||
if "SC5_STABLE" in early:
|
||||
log.info("Session %s already stable (banner) - skip re-stage", session_id)
|
||||
await self.db.update_session(
|
||||
session_id,
|
||||
metadata={"stabilized": True, "stage2": True, "stable_banner": True},
|
||||
)
|
||||
row = await self.db.get_session(session_id)
|
||||
existing: dict[str, Any] = {}
|
||||
if row and row.get("metadata"):
|
||||
try:
|
||||
existing = (
|
||||
json.loads(row["metadata"])
|
||||
if isinstance(row["metadata"], str)
|
||||
else dict(row["metadata"])
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
existing = {}
|
||||
existing.update({"stabilized": True, "stage2": True, "stable_banner": True})
|
||||
await self.db.update_session(session_id, metadata=existing)
|
||||
await self.metrics.emit(
|
||||
"shell.stabilize.skip",
|
||||
{"session_id": session_id, "reason": "already_stable"},
|
||||
)
|
||||
return
|
||||
return {
|
||||
"status": "already_stable",
|
||||
"session_id": session_id,
|
||||
"callback": f"{host}:{port}",
|
||||
}
|
||||
|
||||
stabilizer = ShellStabilizer(host, port)
|
||||
await asyncio.sleep(0.3)
|
||||
if delay:
|
||||
await asyncio.sleep(0.3)
|
||||
if session_id in self._rejected:
|
||||
return
|
||||
probe = stabilizer.probe_command()
|
||||
await self.send_shell(session_id, probe)
|
||||
await asyncio.sleep(self.probe_wait_sec)
|
||||
if session_id in self._rejected:
|
||||
return
|
||||
return {"status": "rejected", "session_id": session_id}
|
||||
|
||||
blob = "".join(self._shell_buffers.get(session_id, [])[-20:])
|
||||
if blob:
|
||||
v = classify_inbound(blob)
|
||||
if not v.is_shell and v.confidence >= 0.8:
|
||||
await self._reject_session(session_id, v.reason, "stabilize-probe")
|
||||
return
|
||||
hint = (os_hint or "").strip().lower()
|
||||
family = "unknown"
|
||||
if hint in ("linux", "unix", "posix"):
|
||||
family = "linux"
|
||||
elif hint in ("windows", "win", "win32"):
|
||||
family = "windows"
|
||||
else:
|
||||
probe = stabilizer.probe_command()
|
||||
await self.send_shell(session_id, probe)
|
||||
await asyncio.sleep(self.probe_wait_sec)
|
||||
if session_id in self._rejected:
|
||||
return {"status": "rejected", "session_id": session_id}
|
||||
|
||||
if "SC5_STABLE" in blob:
|
||||
log.info("Session %s became stable during probe - skip", session_id)
|
||||
return
|
||||
blob = "".join(self._shell_buffers.get(session_id, [])[-20:])
|
||||
if blob:
|
||||
v = classify_inbound(blob)
|
||||
if not v.is_shell and v.confidence >= 0.8:
|
||||
if reject_on_noise:
|
||||
await self._reject_session(session_id, v.reason, "stabilize-probe")
|
||||
return {"status": "rejected", "reason": v.reason, "session_id": session_id}
|
||||
return {"status": "error", "reason": v.reason, "session_id": session_id}
|
||||
|
||||
if "SC5_STABLE" in blob:
|
||||
log.info("Session %s became stable during probe - skip", session_id)
|
||||
return {
|
||||
"status": "already_stable",
|
||||
"session_id": session_id,
|
||||
"callback": f"{host}:{port}",
|
||||
}
|
||||
|
||||
family = detect_os(blob)
|
||||
# Prefer session os_info when probe is ambiguous
|
||||
if family == "unknown":
|
||||
srow = await self.db.get_session(session_id)
|
||||
oi = ((srow or {}).get("os_info") or "").lower()
|
||||
if "win" in oi:
|
||||
family = "windows"
|
||||
elif "linux" in oi or "unix" in oi or "darwin" in oi:
|
||||
family = "linux"
|
||||
|
||||
family = detect_os(blob)
|
||||
plan = stabilizer.plan(family)
|
||||
|
||||
log.info(
|
||||
"Stabilizing session %s as %s via %s (callback %s:%s)",
|
||||
"Stabilizing session %s as %s via %s (callback %s:%s) actor=%s",
|
||||
session_id,
|
||||
plan.os_family,
|
||||
plan.method,
|
||||
host,
|
||||
port,
|
||||
actor,
|
||||
)
|
||||
await self.metrics.emit(
|
||||
"shell.stabilize.start",
|
||||
@@ -651,12 +737,13 @@ class ListenerManager:
|
||||
"os": plan.os_family,
|
||||
"method": plan.method,
|
||||
"callback": f"{host}:{port}",
|
||||
"actor": actor,
|
||||
},
|
||||
)
|
||||
|
||||
for cmd in plan.commands:
|
||||
if session_id in self._rejected:
|
||||
return
|
||||
return {"status": "rejected", "session_id": session_id}
|
||||
ok = await self.send_shell(session_id, cmd)
|
||||
if not ok:
|
||||
break
|
||||
@@ -664,15 +751,17 @@ class ListenerManager:
|
||||
|
||||
meta = {
|
||||
"stabilized": True,
|
||||
"stage2": True,
|
||||
"stabilize_os": plan.os_family,
|
||||
"stabilize_method": plan.method,
|
||||
"stabilize_callback": f"{host}:{port}",
|
||||
"stabilize_notes": plan.notes,
|
||||
"stabilize_actor": actor,
|
||||
}
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
return
|
||||
existing: dict[str, Any] = {}
|
||||
return {"status": "error", "reason": "session_gone", "session_id": session_id}
|
||||
existing = {}
|
||||
if row.get("metadata"):
|
||||
try:
|
||||
existing = (
|
||||
@@ -693,18 +782,27 @@ class ListenerManager:
|
||||
{"session_id": session_id, "os": plan.os_family, "method": plan.method},
|
||||
)
|
||||
await self.db.audit(
|
||||
actor="system",
|
||||
actor_type="system",
|
||||
actor=actor,
|
||||
actor_type="operator" if actor != "system" else "system",
|
||||
action="shell.stabilize",
|
||||
resource=session_id,
|
||||
details=meta,
|
||||
risk_score=4,
|
||||
)
|
||||
return {
|
||||
"status": "stabilized",
|
||||
"session_id": session_id,
|
||||
"os": plan.os_family,
|
||||
"method": plan.method,
|
||||
"callback": f"{host}:{port}",
|
||||
"notes": plan.notes,
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("Auto-stabilize failed for %s", session_id)
|
||||
except Exception as e:
|
||||
log.exception("Stabilize failed for %s", session_id)
|
||||
await self.metrics.emit("shell.stabilize.error", {"session_id": session_id})
|
||||
raise RuntimeError(str(e) or "stabilize failed") from e
|
||||
|
||||
def is_live(self, session_id: str) -> bool:
|
||||
"""True only while a TCP reverse-shell channel is attached in this process."""
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Manual shell stabilize API + auto-stabilize default OFF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from squidc5.config import Settings
|
||||
from squidc5.features import DEFAULT_FEATURES
|
||||
from squidc5.main import create_app
|
||||
from squidc5.shells.stabilize import ShellStabilizer, detect_os
|
||||
|
||||
ADMIN = "sc5_test_admin_token_bootstrap_stab01"
|
||||
|
||||
|
||||
def test_defaults_auto_stabilize_off(tmp_path):
|
||||
assert DEFAULT_FEATURES.get("shell_auto_stabilize") is False
|
||||
s = Settings(
|
||||
data_dir=tmp_path / "sc5-stab-cfg",
|
||||
debug=True,
|
||||
mcp_enabled=False,
|
||||
plugin_signing_secret="x" * 32,
|
||||
)
|
||||
assert s.shell_auto_stabilize is False
|
||||
|
||||
|
||||
def test_detect_os_and_plans():
|
||||
assert detect_os("Linux ubuntu 5.15") == "linux"
|
||||
assert detect_os("Microsoft Windows [Version 10.0]") == "windows"
|
||||
st = ShellStabilizer("10.0.0.1", 443)
|
||||
assert "python" in st.plan("linux").method.lower() or "stage2" in st.plan("linux").method.lower()
|
||||
assert "power" in st.plan("windows").method.lower() or "stage2" in st.plan("windows").method.lower()
|
||||
assert st.plan("linux").commands
|
||||
assert st.plan("windows").commands
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stabilize_requires_live_channel(tmp_path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "stab1",
|
||||
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,
|
||||
shell_auto_stabilize=False,
|
||||
)
|
||||
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}"}
|
||||
# default feature flag off
|
||||
feat = await client.get("/api/v1/features", headers=h)
|
||||
assert feat.status_code == 200
|
||||
assert feat.json()["features"]["shell_auto_stabilize"] is False
|
||||
|
||||
sid = await app.state.app_state.sessions.register(
|
||||
kind="reverse_shell",
|
||||
remote_addr="10.1.2.3:4444",
|
||||
)
|
||||
r = await client.post(
|
||||
f"/api/v1/sessions/{sid}/stabilize",
|
||||
headers=h,
|
||||
json={"os": "auto"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert "live" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_ui_stabilize_marker(tmp_path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "stab2",
|
||||
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}"}
|
||||
js = (await client.get("/api/v1/ops/admin.js", headers=h)).text
|
||||
assert "ctxStabilize" in js
|
||||
assert "/stabilize" in js
|
||||
assert "adFeatSave" in js
|
||||
+72
-3
@@ -483,6 +483,24 @@
|
||||
showOk("Spectator snapshot");
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("ctxStabilize")) el("ctxStabilize").onclick = async () => {
|
||||
if (!selectedId) return;
|
||||
if (!confirm("Inject stage-2 stabilize agent? Detects Linux vs Windows and reconnects a durable channel.")) return;
|
||||
try {
|
||||
const r = await api(
|
||||
"POST",
|
||||
`/api/v1/sessions/${encodeURIComponent(selectedId)}/stabilize`,
|
||||
{ os: "auto" },
|
||||
);
|
||||
showOk(r.status === "already_stable" ? "Already stable" : ("Stabilized as " + (r.os || "?")));
|
||||
if (el("ctxOut")) {
|
||||
el("ctxOut").textContent = JSON.stringify(r, null, 2);
|
||||
el("ctxOut").classList.remove("empty");
|
||||
}
|
||||
if (window.__SC5_refresh) await window.__SC5_refresh();
|
||||
renderContext(true);
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("ctxRun")) el("ctxRun").onclick = async () => {
|
||||
const command = (el("ctxCmd").value || "").trim();
|
||||
if (!command) return showError("Command required");
|
||||
@@ -563,6 +581,10 @@
|
||||
${can("sessions:read") ? '<button type="button" class="ghost" id="ctxSpectate">Spectate</button>' : ""}
|
||||
</div>
|
||||
${shellOk ? `
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button type="button" class="primary" id="ctxStabilize" title="Detect Linux/Windows and inject stage-2 reconnect agent">Stabilize shell</button>
|
||||
</div>
|
||||
<p class="muted" style="font-size:0.68rem;margin:4px 0 8px">Injects OS-aware stage-2 (Python/PowerShell). Auto-stabilize is OFF by default — Admin → Features.</p>
|
||||
<label for="ctxCmd">Shell command</label>
|
||||
<textarea id="ctxCmd" rows="2" placeholder="whoami"></textarea>
|
||||
<div class="row"><button type="button" class="primary" id="ctxRun">Run</button></div>
|
||||
@@ -1996,9 +2018,12 @@
|
||||
<div class="wp-body">
|
||||
<div class="row">
|
||||
<button type="button" id="adPolGet">Get policy</button>
|
||||
<button type="button" id="adFeat">Features</button>
|
||||
<button type="button" id="adFeat">Reload features</button>
|
||||
<button type="button" class="primary" id="adFeatSave">Save features</button>
|
||||
</div>
|
||||
<div class="outbox empty" id="adOut" style="max-height:min(420px,50vh);flex:1">-</div>
|
||||
<p class="muted" style="font-size:0.72rem;margin:8px 0">Runtime switches. <strong>Auto stage-2</strong> defaults OFF — use session <em>Stabilize shell</em> for one-shot.</p>
|
||||
<div id="adFeatGrid" class="scope-grid" style="max-height:min(360px,45vh)"></div>
|
||||
<div class="outbox empty" id="adOut" style="max-height:min(280px,35vh);flex:1">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2436,7 +2461,51 @@
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("adPolGet")) el("adPolGet").onclick = () => dump("/api/v1/policy");
|
||||
if (el("adFeat")) el("adFeat").onclick = () => dump("/api/v1/features");
|
||||
async function loadFeatureToggles() {
|
||||
const grid = el("adFeatGrid");
|
||||
if (!grid) return;
|
||||
try {
|
||||
const r = await api("GET", "/api/v1/features");
|
||||
const feats = r.features || r || {};
|
||||
const labelBy = {};
|
||||
(r.catalog || []).forEach((c) => {
|
||||
if (c && c.key) labelBy[c.key] = c.label || c.key;
|
||||
});
|
||||
const keys = Object.keys(feats).sort();
|
||||
grid.innerHTML = keys.map((k) => {
|
||||
const lab = labelBy[k] || k;
|
||||
const on = !!feats[k];
|
||||
const locked = k === "public_docs";
|
||||
return `<label title="${esc(k)}">
|
||||
<input type="checkbox" class="ad-feat" data-feat="${esc(k)}" ${on ? "checked" : ""} ${locked ? "disabled" : ""} />
|
||||
<span>${esc(lab)}</span>
|
||||
</label>`;
|
||||
}).join("") || '<span class="muted">No features</span>';
|
||||
if (el("adOut")) {
|
||||
el("adOut").classList.remove("empty");
|
||||
el("adOut").textContent = JSON.stringify({ features: feats }, null, 2);
|
||||
}
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
}
|
||||
if (el("adFeat")) el("adFeat").onclick = () => loadFeatureToggles();
|
||||
if (el("adFeatSave")) el("adFeatSave").onclick = async () => {
|
||||
const features = {};
|
||||
document.querySelectorAll("input.ad-feat").forEach((inp) => {
|
||||
const k = inp.getAttribute("data-feat");
|
||||
if (k && !inp.disabled) features[k] = !!inp.checked;
|
||||
});
|
||||
try {
|
||||
const r = await api("PUT", "/api/v1/features", { features });
|
||||
showOk("Features saved");
|
||||
if (el("adOut")) {
|
||||
el("adOut").classList.remove("empty");
|
||||
el("adOut").textContent = JSON.stringify(r, null, 2);
|
||||
}
|
||||
await loadFeatureToggles();
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
// Load feature grid when admin view builds
|
||||
loadFeatureToggles();
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user