OpenCode 2.0.10 replaced the lifecycle events the generated plugin listened
for (session.idle and friends) with session.execution.{succeeded,failed,
interrupted}, and it runs one long-lived service behind every CLI: closing a
terminal is not a session end, so sessions captured through the old binding
rarely produced a summary page or a baton. The plugin also re-ran two `git`
processes per event, lost startup context after the first model request,
dropped content-only tool results, and shared queue and spool state across
the per-location instances OpenCode 2 loads.
Plugin (install_hooks.rs, render_shared.rs, the node host fixture):
- binds session.execution.*, session.text.ended, session.moved,
session.deleted, session.compaction.started and the context/prompt/tool
hooks; every name was checked against the OpenCode 2.0.14 binary;
- claims startup context once per root session and re-injects it on every
model request; child sessions never claim it or publish a baton;
- keeps queue, spool and cleanup state per location instance; spool names
can no longer collide within a millisecond, and a torn-down host cancels
what is still in flight only after its final session-ends had the drain
budget (all generated TypeScript integrations share this runtime now);
- opt-in assistant capture (--capture-assistant) hands the last completed
text to the native hook, which sanitizes and caps it before the spool or
the wire, and falls back to the plain stop hook if that binary cannot run.
Server (router.rs, ops.rs, reader.rs, writer.rs):
- a completed root turn is a turn checkpoint: sessions/<id>.md and the
automatic baton are refreshed deterministically (no LLM) in the session
row's own scope, keeping one open baton per live session, refreshed in
place and audited; a checkpoint that lost the race with the session's end
touches nothing;
- an explicit, keyed session.moved rebinds the live session row to its new
directory on first delivery (compare-and-set on the cwd it left), so the
session's end and checkpoints follow it;
- a SessionEnd whose resolved scope drifted under the same cwd (a
.ai-memory.toml appeared mid-session) ends the session instead of
stranding it open; scope-drifted ordinary events are recorded as upstream
records any other drifted event;
- the latest captured assistant excerpt rides in the automatic baton; it is
not rendered into the git-tracked session page.
Docs: install.md, support-matrix.md, auto-scope.md, SECURITY.md and
DATA_HANDLING.md describe the checkpoint, routing and capture behavior.
Verified: cargo fmt --all -- --check; cargo clippy --workspace --all-targets
-- -D warnings; cargo test --workspace --all-targets (3569 passed, on release/2.5); the node
host fixture runs under cargo test (node >= 22.6) and fails if unload aborts
deliveries before its session-ends. Live on Windows 11 with OpenCode 2.0.14:
after a 64 -> 66 store migration, the running OpenCode service loaded the
regenerated plugin and one real turn through it (a Code Mode call to memory_status)
recorded its prompt, tool events and stop, logged "turn checkpoint written;
native session remains open", left the session open, wrote
sessions/<id>.md and exactly one open baton carrying the captured
assistant excerpt, which the session page does not contain.
Consolidates facts already documented in SECURITY.md, docs/install.md, and
docs/local-embeddings.md into the shape a security/legal reviewer typically
needs before approving a dev tool: a single data-flow page, an SSO/OIDC
summary, and an offline-install path. No behavior change. Adds a fallback
security-contact path for reporters who can't use GitHub's private-advisory
flow.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The v1.27.0 bind guard reads a non-loopback bind as evidence of network
exposure and refuses without a token. That inference holds on a host but
not inside a container: publishing a port with `-p` requires binding
0.0.0.0 in the namespace, and whether that port reaches the network is
decided by the host-side publish spec, which the process cannot observe.
So the guard refused every container started from the documented Quick
start — which publishes to loopback and was therefore safe — and with the
documented `--restart unless-stopped` that became a restart loop. Verified
against the published image: 1.28.0 exits(1) on the README command, and
starts when only an auth token is added.
Containers now warn, naming the publish spec as the thing to check. The
host rule is unchanged: `validate_http_exposure` still refuses an
unauthenticated non-loopback bind outside a container, and a test asserts
that identical inputs refuse on a host and warn in a container.
The Host allowlist deliberately does not back this up. It defends against
DNS rebinding, where a browser sets the header; a client that can route to
the port sets `Host` freely, confirmed by reaching a container's IP
directly with a forged `Host: localhost`.
Refs #407
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit-driven hardening bundle:
- Hook session UUIDs reject cross-owner reuse atomically inside the writer
transaction before ingest-key claim, observation, summary, handoff, or
end-state mutation; guarded end operations revalidate the persisted
tuple+owner; explicit root `finalize-session --all-owners` recovery is
SessionEnd-only and Admin-gated; keyed replay serialization holds the
ingest gate through downstream completion.
- Unauthenticated non-loopback HTTP binds now fail closed before serving;
`--allow-insecure-no-auth` is the explicit dangerous override. Loopback
default unchanged.
- New data dirs (0700) and config/SQLite/segment/backup files (0600) are
created owner-only regardless of umask; existing installs untouched.
- `[auth].secure_cookie` marks the /web browser cookie Secure for HTTPS
reverse-proxy deployments; SameSite is now Strict; proxy headers are
never trusted to infer HTTPS.
- /api/v1 internal errors return a stable generic 500 body while the
detailed cause is logged server-side; AuthSettings Debug redacts bearer,
pepper, and proxy secrets.
- Multi-user per-actor autoscope contract aligned with globally unique
durable SessionIds: distinct run IDs per user; cross-owner same-ID reuse
is dropped before pointer publication.
Verified: 2378 workspace tests, clippy -D warnings, gitleaks, cargo
audit/deny, plus live-server auth/permission checks.
Builds on the default-off strip (1.17.2) to add an explicit, sanitized,
double opt-in that persists a Claude Code Stop event's final assistant
turn as the Stop body. Off by default.
Double opt-in (both required): the server sets `capture_assistant`
(config / `AI_MEMORY_CAPTURE_ASSISTANT`), and the client is installed
with `install-hooks --agent claude-code --capture-assistant`. Any gate
failure degrades to an empty Stop with the same 202 "queued".
- Protocol: `AssistantCaptureProtocol { version, excerpt }` carried in the
body under `_ai_memory_assistant`, `deny_unknown_fields` + version gate so
a malformed/future marker is rejected. `capture_assistant=1` query flag.
- Client (`transform_for_client`): reads the assistant message, scrubs with
the built-in `Sanitizer` BEFORE truncating to 2 KB (UTF-8-safe), splices the
protocol, and appends the flag to the event URL — all before spool/wire.
- Server (`apply_assistant_backstop`): consumes the marker unconditionally,
and only when server-enabled + client-requested + supported Stop + valid
non-empty protocol sets `body_excerpt`. Re-scrubs via `Sanitized::new` AND
re-enforces the 2 KB cap at the persistence boundary — never trusting the
client's length. Applied identically in `handle_hook`/`handle_hook_batch`.
- Install: `--capture-assistant` baked onto the native `stop` command only, in
all three render paths (Posix/Windows string forms + the primary Windows
exec form). `capture_assistant_allowed` gates it to Claude Code on a native
platform; any other agent/platform bails (no silent no-op). Re-running
without the flag removes it.
- Config/state: `Config.capture_assistant`, `HookState.capture_assistant_enabled`
wired through `serve`, `HookQuery.capture_assistant` →
`HookEnvelope.capture_assistant_requested`. `truncate_excerpt` refactored to a
shared `truncate_utf8_bytes`, one UTF-8-safe truncation, two named caps.
- Script fallback cannot sanitize: `stop.sh` (POSIX `case`, no bash-isms) and
the shared `ai-memory-hook.ps1` (scoped to claude-code + stop) drop a Stop
still carrying the raw field rather than POST it verbatim.
- Tests: protocol/transform/backstop table + gates + forged/versioned markers,
scrub-before-truncate, multibyte cap, server-side oversized-excerpt cap,
server round-trip (both opt-ins on → body persisted; server off → empty)
with whole-store byte scan, spool splice via subprocess, spool→drain→batch
flag round-trip, exec/string render, and the install agent/platform gate.
- Docs: install guide (server→client order), README matrix, marker-file,
ARCHITECTURE invariant #6, SECURITY.md (sanitize seams, LLM/cloud flow,
global scope), config template, CHANGELOG.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>