Skip to content
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/), versions follow

## [Unreleased]

### Added

- Durable, redacted engagement event log with live SSE, correlation IDs, activity timeline/lanes, filters, and JSONL export
- Mission posture and topology panels for methodology coverage, validation, attack chains, agents, assets, hosts, endpoints, findings, and target notes
- Approval-gated Nmap scan profiles, canonical XML ingestion, saved scan history, host/service/route topology, and scan comparison
- Structured project/session memory with FTS5, provenance, trust, confidence, secret redaction, invalidation, failure reflection, and evaluation-gated procedural promotion
- Server-enforced read-only observer role and typed local execution-plane capability inventory
- Version-pinned MCP catalog with manual/optional install states and three additional owner-maintained integrations
- Safe recovery startup with `--safe` and namespaced extension configuration
- Target-selectable source builds and a localhost-only systemd user service template for Kali/Linux deployments

### Changed

- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction

### Fixed

- Release and local-binary installs now deploy the bundled HackBrowser worker to the runtime data directory
- Web update checks now honor disabled auto-updates, avoiding false upgrade prompts for managed source builds

## [1.1.16] — 2026-08-08

### Added
Expand Down
228 changes: 137 additions & 91 deletions README.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions contrib/systemd/cyberstrike-web.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[Unit]
Description=CyberStrike Web UI
Documentation=https://github.com/CyberStrikeus/CyberStrike
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
Environment=BROWSER=none
Environment=CYBERSTRIKE_DISABLE_AUTOUPDATE=true
EnvironmentFile=%h/.config/cyberstrike/web.env
ExecStart=%h/.cyberstrike/bin/cyberstrike web --hostname 127.0.0.1 --port 4096
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
UMask=0077

[Install]
WantedBy=default.target
14 changes: 13 additions & 1 deletion install
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ while [[ $# -gt 0 ]]; do
done

INSTALL_DIR=$HOME/.cyberstrike/bin
mkdir -p "$INSTALL_DIR"
DATA_BIN_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/cyberstrike/bin"
mkdir -p "$INSTALL_DIR" "$DATA_BIN_DIR"

# If --binary is provided, skip all download/detection logic
if [ -n "$binary_path" ]; then
Expand Down Expand Up @@ -354,13 +355,24 @@ download_and_install() {

mv "$tmp_dir/cyberstrike" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/cyberstrike"
install_worker "$tmp_dir/hackbrowser-worker.js"
rm -rf "$tmp_dir"
}

install_worker() {
local worker_path="$1"
if [ ! -f "$worker_path" ]; then
return
fi
cp "$worker_path" "${DATA_BIN_DIR}/hackbrowser-worker.js"
chmod 755 "${DATA_BIN_DIR}/hackbrowser-worker.js"
}

install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}cyberstrike ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/cyberstrike"
chmod 755 "${INSTALL_DIR}/cyberstrike"
install_worker "$(dirname "$binary_path")/hackbrowser-worker.js"
}

if [ -n "$binary_path" ]; then
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSimpleContext } from "@cyberstrike-io/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"

type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
Expand Down Expand Up @@ -97,6 +97,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
active: "" as ServerConnection.Key | "",
healthy: undefined as boolean | undefined,
needsAuth: false,
role: undefined as ServerHealth["role"],
})

const healthy = () => state.healthy
Expand Down Expand Up @@ -161,6 +162,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!alive) return
setState("healthy", result.healthy)
setState("needsAuth", !!result.needsAuth)
setState("role", result.role)
})
.finally(() => {
busy = false
Expand Down Expand Up @@ -246,6 +248,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
ready: isReady,
healthy,
needsAuth,
role: () => state.role,
isLocal,
get key() {
return state.active as ServerConnection.Key
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,14 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
<Show when={server.role() === "observer"}>
<div
role="status"
class="shrink-0 h-7 px-3 flex items-center justify-center border-b border-border-weak-base bg-surface-info-base text-11-medium text-text-info-base"
>
Read-only observer mode · execution, configuration, secrets, and terminal input are disabled
</div>
</Show>
<div class="flex-1 min-h-0 flex">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
Expand Down
68 changes: 46 additions & 22 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { useServer } from "@/context/server"

type HandoffSession = {
prompt: string
Expand Down Expand Up @@ -102,6 +103,8 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const server = useServer()
const observer = createMemo(() => server.role() === "observer")

const permRequest = createMemo(() => {
const sessionID = params.id
Expand Down Expand Up @@ -688,6 +691,7 @@ export default function Page() {
setUi("autoCreated", false)
return
}
if (observer()) return
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
setUi("autoCreated", true)
Expand Down Expand Up @@ -881,7 +885,17 @@ export default function Page() {
}

const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const panelTabSet = new Set(["mcp-panel", "bolt-panel", "vulns-panel", "todo-panel", "skills-panel"])
const panelTabSet = new Set([
"mission-panel",
"mcp-panel",
"bolt-panel",
"vulns-panel",
"web-panel",
"topology-panel",
"memory-panel",
"todo-panel",
"skills-panel",
])
const openedTabs = createMemo(() =>
tabs()
.all()
Expand Down Expand Up @@ -1688,27 +1702,36 @@ export default function Page() {
</Switch>
</div>

<SessionPromptDock
centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
setPromptDockRef={(el) => (promptDock = el)}
/>
<Show
when={!observer()}
fallback={
<div class="mx-auto mb-6 px-3 py-2 rounded-md bg-surface-base text-12-regular text-text-weak">
Read-only observer mode
</div>
}
>
<SessionPromptDock
centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
setPromptDockRef={(el) => (promptDock = el)}
/>
</Show>

<Show when={desktopReviewOpen()}>
<ResizeHandle
Expand Down Expand Up @@ -1770,6 +1793,7 @@ export default function Page() {
resize={layout.terminal.resize}
close={view().terminal.close}
terminal={terminal}
readOnly={observer()}
language={language}
command={command}
handoff={() => handoff.terminal.get(params.dir!) ?? []}
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/pages/session/activity-panel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { isActivity, mergeActivity } from "./activity"

const event = (id: string, time: number, title = id) => ({
id,
projectID: "project",
sessionID: "session",
type: "session.updated",
source: "agent" as const,
data: { title },
time,
})

describe("activity history", () => {
test("preserves live events that arrive before history", () => {
expect(mergeActivity([event("one", 1)], [event("two", 2)])).toEqual([event("one", 1), event("two", 2)])
})

test("keeps the live version of duplicate events", () => {
expect(mergeActivity([event("one", 1, "old")], [event("one", 1, "new")])).toEqual([
event("one", 1, "new"),
])
})

test("ignores SSE heartbeats", () => {
expect(isActivity({})).toBe(false)
expect(isActivity(event("one", 1))).toBe(true)
})
})
Loading