Skip to content
Draft
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ 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
- Always-visible live workbench status with resilient activity streaming, meaningful change badges, and event-driven Mission, Topology, and Memory refresh

### Changed

- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction
- Agent methodology now routes Nmap through the approval-gated `nmap_scan` system of record and explicitly records verified discoveries, findings, and reusable memory for workbench projection

### 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
- Hidden workbench panels no longer miss live changes or depend on visible-only polling; repeated session lifecycle events and reconnect gaps are recovered
- Nmap permissions now identify active/elevated execution, privileged profiles use non-interactive sudo when required, and Topology explains why generic shell output is not graphable

## [1.1.16] — 2026-08-08

### Added
Expand Down
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/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { WorkbenchProvider } from "@/context/workbench"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@cyberstrike-io/ui/context/dialog"
Expand Down Expand Up @@ -92,7 +93,9 @@ function SessionProviders(props: ParentProps) {
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
<CommentsProvider>
<WorkbenchProvider>{props.children}</WorkbenchProvider>
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
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
254 changes: 254 additions & 0 deletions packages/app/src/context/workbench.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { createSimpleContext } from "@cyberstrike-io/ui/context"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@/context/sdk"
import {
activityChannels,
activityRefreshChannels,
isActivity,
mergeActivity,
type Activity,
type ActivitySource,
type WorkbenchChannel,
} from "@/pages/session/activity"

const blank = () => ({
activity: 0,
mission: 0,
topology: 0,
memory: 0,
mcp: 0,
bolt: 0,
terminal: 0,
vulns: 0,
web: 0,
})

export const { use: useWorkbench, provider: WorkbenchProvider } = createSimpleContext({
name: "Workbench",
init: () => {
const params = useParams()
const sdk = useSDK()
const [events, setEvents] = createStore<Activity[]>([])
const [state, setState] = createStore({
connected: false,
error: "",
changes: blank(),
revision: blank(),
last: blank(),
})

const bump = (channel: WorkbenchChannel, time = Date.now(), changed = true) => {
if (changed) setState("changes", channel, (value) => value + 1)
setState("revision", channel, (value) => value + 1)
setState("last", channel, time)
}

const marked = new Set<string>()
const mark = (event: Activity) => {
if (marked.has(event.id)) return
marked.delete(event.id)
marked.add(event.id)
while (marked.size > 2_000) marked.delete(marked.values().next().value!)
for (const channel of activityChannels(event)) bump(channel, event.time)
for (const channel of activityRefreshChannels(event)) bump(channel, event.time, false)
}

const add = (event: Activity) => {
const index = events.findIndex((item) => item.id === event.id)
if (index !== -1) {
setEvents(index, reconcile(event))
mark(event)
return
}
setEvents(
produce((draft) => {
draft.push(event)
if (draft.length > 2_000) draft.splice(0, draft.length - 2_000)
}),
)
mark(event)
}

createEffect(() => {
const sessionID = params.id
sdk.directory
marked.clear()
setEvents(reconcile([]))
setState({
connected: false,
error: "",
changes: blank(),
revision: blank(),
last: blank(),
})
if (!sessionID) return

const abort = new AbortController()
const client = sdk.createClient({
directory: sdk.directory,
throwOnError: true,
signal: abort.signal,
})
let connected = false
let hydrated = false
let requested = 0
let completed = 0
let syncing: Promise<void> | undefined
let streamError = ""
let historyError = ""
const render = () => setState({ connected, error: historyError || streamError })
const snapshot = async () => {
const known = new Set(untrack(() => events.map((event) => event.id)))
const pages: Activity[] = []
let before: number | undefined
let beforeID: string | undefined
while (pages.length < 2_000) {
const limit = Math.min(500, 2_000 - pages.length)
const response = await client.eventLog.list({ sessionID, before, beforeID, limit })
const page = (response.data ?? []).filter(isActivity)
if (page.length === 0) break
pages.unshift(...page)
if (hydrated && page.some((event) => known.has(event.id))) break
if (page.length < limit) break
const next = page[0]!.time
const nextID = page[0]!.id
if (before === next && beforeID === nextID) break
before = next
beforeID = nextID
}
return { known, incoming: mergeActivity(pages, [], 2_000) }
}
const sync = () => {
if (syncing) return syncing
syncing = (async () => {
while (!abort.signal.aborted) {
const version = requested
try {
const history = await snapshot()
if (abort.signal.aborted) return
const changed = hydrated
setEvents(reconcile(mergeActivity(history.incoming, [...events])))
if (changed) history.incoming.filter((event) => !history.known.has(event.id)).forEach(mark)
hydrated = true
completed = version
historyError = ""
render()
if (requested <= completed) return
} catch (cause) {
if (abort.signal.aborted) return
historyError = cause instanceof Error ? cause.message : String(cause)
render()
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
})().finally(() => {
syncing = undefined
})
return syncing
}
const requestSync = () => {
requested++
return sync()
}
const unsubs = [
sdk.event.on("memory.updated", (event) => {
if (!event.properties.sessionID) bump("memory")
}),
sdk.event.on("mcp.tools.changed", () => bump("mcp")),
sdk.event.on("pty.created", () => bump("terminal")),
sdk.event.on("pty.updated", () => bump("terminal")),
sdk.event.on("pty.exited", () => bump("terminal")),
sdk.event.on("pty.deleted", () => bump("terminal")),
]

void requestSync()
let timer: ReturnType<typeof setTimeout> | undefined
void (async () => {
while (!abort.signal.aborted) {
try {
const response = await client.eventLog.stream(
{ sessionID },
{
onSseError: (cause) => {
if (abort.signal.aborted) return
connected = false
streamError = cause instanceof Error ? cause.message : String(cause)
render()
},
onSseEvent: () => {
if (timer) clearTimeout(timer)
timer = undefined
const recovered = !connected
connected = true
streamError = ""
render()
if (recovered) void requestSync()
},
},
)
timer = setTimeout(() => {
if (abort.signal.aborted) return
streamError = "Live activity stream did not connect"
render()
}, 5_000)
let next = await response.stream.next()
while (!next.done && !abort.signal.aborted) {
if (isActivity(next.value)) add(next.value)
next = await response.stream.next()
}
if (abort.signal.aborted) return
connected = false
streamError = "Live activity stream disconnected"
render()
} catch (cause) {
if (abort.signal.aborted) return
connected = false
streamError = cause instanceof Error ? cause.message : String(cause)
render()
} finally {
if (timer) clearTimeout(timer)
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
})()

onCleanup(() => {
if (timer) clearTimeout(timer)
abort.abort()
unsubs.forEach((unsub) => unsub())
})
})

const latest = createMemo(() => events.at(-1))

return {
get events() {
return events
},
get connected() {
return state.connected
},
get error() {
return state.error
},
latest,
count(source: ActivitySource) {
return events.filter((event) => event.source === source).length
},
changes(channel: WorkbenchChannel) {
return state.changes[channel]
},
revision(channel: WorkbenchChannel) {
return state.revision[channel]
},
last(channel: WorkbenchChannel) {
return state.last[channel]
},
ack(channel: WorkbenchChannel) {
setState("changes", channel, 0)
},
}
},
})
Loading
Loading