Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ gscroll export --format md # structured report, ready to share
| **Validation** | `gscroll validate [SESSION] --repair` checks JSONL/assets/parts and patches repairable metadata |
| **Replay** | `gscroll replay` via `scriptreplay` with speed control |
| **TUI** | Interactive Textual dashboard — session sidebar, phase timeline, command table |
| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with session CRUD (create, delete, continue with part tracking, validate) |
| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with browser-based session create/close, uploads, heartbeats, and continue/validate |
| **Session auto-detect** | All sub-commands pick up `GUILD_SCROLL_SESSION` automatically |
| **Self-update** | `gscroll update` checks GitHub and reinstalls |

Expand Down Expand Up @@ -354,7 +354,7 @@ Sessions are stored under `./guild_scroll/sessions/<name>/` (CWD-local, like `.g

Override the base path with `GUILD_SCROLL_DIR`.

Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-localhost addresses (required for Docker/container deployments that use `--host 0.0.0.0`). Localhost-only is the default.
Non-localhost binds (`--host 0.0.0.0`) are allowed but print a safety warning; set `GUILD_SCROLL_ALLOW_REMOTE=1` (or enable TLS) to silence the warning in containerized deployments.

### Web API Endpoints

Expand All @@ -364,13 +364,17 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca
|---|---|---|
| `GET` | `/api/sessions` | List all sessions |
| `GET` | `/api/session/{name}` | Fetch session detail (commands, notes, assets) |
| `POST` | `/api/sessions` | Create a session scaffold (`{"name": "..."}`) → 201/409/422 |
| `DELETE` | `/api/session/{name}` | Delete a session directory → 204/404/400 |
| `POST` | `/api/sessions` | Create a session scaffold (`{"name": "..." , "operator": "...", "target": "...", "platform": "htb|thm"}`) → `{"session": {session_meta...}}` (201/409/422) |
| `DELETE` | `/api/session/{name}` | Delete a session directory → `{"deleted": name}` (200/404/400) |
| `POST` | `/api/session/{name}/continue` | Start a joined session part → `{"session": "...", "part": N, "status": "active"}` (404 if missing, 409 if already active) |
| `POST` | `/api/session/{name}/validate` | Validate (and optionally repair with `?repair=true`) → `{valid, errors, warnings, repaired}` |
| `POST` | `/api/session/{name}/report` | Render a filtered export (body: `{"format": "md\|html", ...}`) |
| `GET` | `/api/session/{name}/download` | Download session export (`?format=md\|html`) |
| `GET` | `/api/session/{name}/discoveries` | Fetch recent notes/assets timeline |
| `GET` / `POST` | `/api/session/{name}/heartbeat` | Track liveness of active sessions; GET returns `{"status": "live\|unknown", "last_beat": ...}` |
| `POST` | `/api/session/{name}/close` | Stop live terminals, clear heartbeat, and delete the session directory |
| `POST` | `/api/session/{name}/upload` | Upload screenshots/evidence (PNG/JPEG/WEBP/GIF/SVG) to `assets/uploads/` |
| `GET` | `/api/session/{name}/asset/{filename}` | Serve uploaded assets (with content-type enforcement) |

### Live Web Terminal

Expand All @@ -383,7 +387,7 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca

| Type | Key Fields |
|---|---|
| `session_meta` | `session_name`, `session_id`, `start_time`, `hostname`, `end_time`, `command_count` |
| `session_meta` | `session_name`, `session_id`, `start_time`, `hostname`, `end_time`, `command_count`, `parts_count`, `operator`, `platform`, `target` |
| `command` | `seq`, `command`, `timestamp_start`, `timestamp_end`, `exit_code`, `working_directory` |
| `asset` | `seq`, `trigger_command`, `asset_type`, `captured_path`, `original_path`, `timestamp` |
| `note` | `text`, `timestamp`, `tags` |
Expand Down
6 changes: 6 additions & 0 deletions src/guild_scroll/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
join | share | import | serve
"""
import sys
import errno
import click

from guild_scroll import __version__
Expand Down Expand Up @@ -461,6 +462,11 @@ def serve(host, port):
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
except OSError as exc:
if getattr(exc, "errno", None) == errno.EADDRINUSE:
click.echo(f"Port {port} already in use", err=True)
sys.exit(1)
raise


@cli.command(
Expand Down
1 change: 1 addition & 0 deletions src/guild_scroll/log_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SessionMeta:
parts_count: int = 1
platform: Optional[str] = None # "htb" | "thm" | None
operator: Optional[str] = None
target: Optional[str] = None
result: Optional[str] = None # "rooted" | "compromised" | "partial" | "failed" | "incomplete"
finalized: bool = False
mode: Optional[str] = None # "ctf" | "assessment" | None (legacy)
Expand Down
61 changes: 61 additions & 0 deletions src/guild_scroll/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,64 @@ def update_parts_count(sess_dir: Path, parts_count: int) -> None:
_patch_session_meta_file(writer._fh, parts_count=parts_count)


def create_session_scaffold(
raw_name: str,
*,
operator: Optional[str] = None,
target: Optional[str] = None,
platform: Optional[str] = None,
mode: Optional[str] = None,
) -> SessionMeta:
"""Create a new session directory tree and initial session_meta record."""
if mode is None:
mode = get_default_mode()
if not isinstance(raw_name, str) or not raw_name.strip():
raise ValueError("Invalid session name: 'name' is required")
if not any(ch.isalnum() for ch in raw_name):
raise ValueError("Invalid session name: must include letters or numbers")
if any(sep in raw_name for sep in ("/", "\\")) or ".." in raw_name:
raise ValueError("Invalid session name: path traversal not allowed")

name = sanitize_session_name(raw_name)
sess_dir = _session_dir(name)
try:
resolved_sessions_dir = get_sessions_dir().resolve()
resolved_candidate = sess_dir.resolve(strict=False)
resolved_candidate.relative_to(resolved_sessions_dir)
except (OSError, ValueError) as exc:
raise ValueError("Invalid session name") from exc

if sess_dir.exists():
raise FileExistsError(f"Session already exists: {name!r}")

logs_dir = sess_dir / "logs"
assets_dir = sess_dir / "assets"
screenshots_dir = sess_dir / "screenshots"
for directory in (logs_dir, assets_dir, screenshots_dir):
directory.mkdir(parents=True, exist_ok=True)

session_id = generate_session_id()
hmac_key = generate_session_key(sess_dir)
generate_encryption_key(sess_dir)
resolved_operator = (operator or "").strip() or _detect_operator()
resolved_target = (target or "").strip() or None
resolved_platform = (platform or "").strip() or _detect_platform_safe()

meta = SessionMeta(
session_name=name,
session_id=session_id,
start_time=iso_timestamp(),
hostname=socket.gethostname(),
operator=resolved_operator,
platform=resolved_platform,
target=resolved_target,
mode=mode,
)
with JSONLWriter(logs_dir / SESSION_LOG_NAME, hmac_key=hmac_key) as writer:
writer.write(meta.to_dict())
return meta


def start_session(raw_name: str, join: bool = False, mode: Optional[str] = None) -> None:
"""Create the session directory tree, inject hooks, launch script, finalize.

Expand Down Expand Up @@ -287,6 +345,9 @@ def finalize_session(
pass
elif etype == "asset_hint":
original_path = Path(evt.get("original_path", ""))
if original_path.is_absolute() or ".." in original_path.parts:
logging.getLogger(__name__).warning("Rejected asset path outside session: %s", original_path)
continue
if original_path.exists():
dest = _capture_asset_for_event(original_path, assets_dir)
if dest:
Expand Down
Loading
Loading