diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7b26e07..5e13896 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: - name: Install build dependencies run: | apt-get update -qq - apt-get install -y gcc libcurl4-openssl-dev apt-transport-https ca-certificates + apt-get install -y gcc curl libcurl4-openssl-dev apt-transport-https ca-certificates echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" \ > /etc/apt/sources.list.d/google-cloud-sdk.list apt-get update -qq && apt-get install -y google-cloud-cli @@ -107,6 +107,17 @@ jobs: strip -s dist/neuron ls -lh dist/neuron + - name: Soul contract gate (HARD BLOCK — no destructive/stale soul publishes) + run: | + # Boots dist/neuron on a throwaway port with a throwaway HOME/engram/cgi + # (never touches ~/.neuron or any live service) and fails the build if any + # app-contract route is unanswered (PRESENCE) or any engram write route + # hard-deletes instead of tombstoning/superseding (IMMUTABILITY). Non-zero + # here blocks Publish -> Artifact Registry -> GKE deploy, so a stale or + # memory-destroying soul can never reach prod. + chmod +x dist/neuron scripts/verify-soul-contract.sh + bash scripts/verify-soul-contract.sh dist/neuron 7796 + - name: Smoke test run: | file dist/neuron diff --git a/scripts/verify-soul-contract.sh b/scripts/verify-soul-contract.sh new file mode 100755 index 0000000..c120920 --- /dev/null +++ b/scripts/verify-soul-contract.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# verify-soul-contract.sh — the soul contract gate. +# +# TERMINOLOGY (canonical): the ENGRAM is the brain — the memory/knowledge-graph +# substrate. The binary this gate exercises is the SOUL — the runtime/reasoning +# engine compiled from dist/soul.c that serves the /api/ surface. The app +# (neuron-ui) bundles the soul binary at resources//neuron. +# +# WHY THIS EXISTS +# For a while the soul binary was hand-dropped, and a stale one shipped: it +# 404'd several capability routes the app calls (knowledge-graph node +# update/delete, live-run narration, safety-contact, ...). This gate makes +# shipping a stale soul IMPOSSIBLE. It has two enforced sections: +# A. PRESENCE — every route the app calls must be ANSWERED (not 404, not +# the el-runtime "no handler"). This is the packaging gate: +# if it fails, do not package. +# B. IMMUTABILITY — engram nodes/memories are immutable by design. To +# "update" is to create a NEW node + a supersede EDGE back to +# the original; the original is KEPT. To "delete" is to +# supersede/tombstone, never hard-remove. A soul that +# hard-deletes an engram node is DEFECTIVE and fails the gate. +# +# SAFETY +# Never touches the live soul (:7770), live engram (:8742), or ~/.neuron. +# Boots on a throwaway port (default 7799) with HOME=$(mktemp -d), a throwaway +# engram snapshot, a non-genesis cgi id, no ENGRAM_URL (so it uses its own +# in-process store, never the live server), NEURON_API_URL pointed at a dead +# port, and no ANTHROPIC_API_KEY (so no probe triggers a real LLM call). +# Connectors proxy to a HARDCODED 127.0.0.1:7771 (no env override): those +# sub-routes are probed with GET, which the soul maps to a read-only +# connectd_get, so this gate never writes to a running connectd bridge. +# +# USAGE +# scripts/verify-soul-contract.sh [port] +# exit 0 = all required routes answered AND no destructive engram mutation; +# non-zero = a route is missing (presence) or a mutation route hard-deletes. +set -uo pipefail + +SOUL="${1:?usage: verify-soul-contract.sh [port]}" +PORT="${2:-7799}" + +if [ "$PORT" = "7770" ] || [ "$PORT" = "8742" ] || [ "$PORT" = "7771" ]; then + echo "REFUSING: port $PORT is a live service port. Use a throwaway port." >&2 + exit 2 +fi +if [ ! -x "$SOUL" ]; then echo "not executable: $SOUL" >&2; exit 2; fi + +BASE="http://127.0.0.1:$PORT" +THROW_HOME="$(mktemp -d "${TMPDIR:-/tmp}/soul-contract-home.XXXXXX")" +SOUL_LOG="$(mktemp "${TMPDIR:-/tmp}/soul-contract-log.XXXXXX")" +SOUL_PID="" +cleanup() { + [ -n "$SOUL_PID" ] && kill "$SOUL_PID" 2>/dev/null + [ -n "$SOUL_PID" ] && { sleep 0.3; kill -9 "$SOUL_PID" 2>/dev/null; } + rm -rf "$THROW_HOME" "$SOUL_LOG" +} +trap cleanup EXIT INT TERM + +# ============================================================================= +# THE CONTRACT — routes the app (neuron-ui/src/main/kotlin/ai/neuron/ui/*.kt) +# calls against the soul ($SOUL). Format: "METHOD PATH". +# +# EXCLUDED and why: +# /api/auth, /api/auth/status, /api/dispatch, /api/tasks +# -> served by the APP's own DispatchServer.kt (localhost:8080), not the +# soul. Not soul routes. +# /api/tags +# -> not handled by any soul .el (app-side/other). Pre-verified excluded. +# /api/neuron/, /api/neuron/node/, /api/connectors/ (bare prefixes) +# -> base-path string constants used to build the concrete routes below. +# +# KNOWN-PENDING (probed + reported, NON-blocking): +# POST /api/engram/import -> the app's "Restore memory" path. No soul handler +# yet, and the app hides Restore from shipped builds (B3, "lands in an +# update"). Reported so we see it; does not block packaging. +# +# Connectors sub-routes are listed as GET (see SAFETY note): handle_connectors is +# monolithic, so a GET reaching it proves the whole connectors surface without +# writing to the live bridge. Binary-strings cross-check confirms each POST +# sub-path literal is compiled in. +# ============================================================================= +REQUIRED=( + "GET /api/graph/nodes" + "GET /api/graph/edges" + "POST /api/chat" + "GET /api/config" + "POST /api/see" + "GET /api/connectors" + "GET /api/connectors/add" + "GET /api/connectors/toggle" + "GET /api/connectors/auto-approve" + "GET /api/connectors/remove" + "GET /api/connectors/secret" + "GET /api/connectors/oauth/start" + "GET /api/connectors/call" + "POST /api/neuron/memory" + "POST /api/neuron/memory/update" + "POST /api/neuron/memory/delete" + "POST /api/neuron/node/create" + "POST /api/neuron/node/update" + "POST /api/neuron/node/delete" + "POST /api/neuron/knowledge/capture" + "POST /api/neuron/knowledge/evolve" + "POST /api/neuron/knowledge/promote" + "POST /api/neuron/processes/define" + "GET /api/run-progress/__contract_probe__" + "GET /api/safety-contact" + "POST /api/safety-contact" + "GET /api/sessions/__contract_probe__" +) +KNOWN_PENDING=( + "POST /api/engram/import" +) + +# --- boot the soul ----------------------------------------------------------- +echo "== booting soul: $SOUL on port $PORT (throwaway HOME=$THROW_HOME) ==" +env -i \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + HOME="$THROW_HOME" \ + NEURON_PORT="$PORT" \ + SOUL_CGI_ID="ntn-contract-$$" \ + SOUL_ENGRAM_PATH="$THROW_HOME/throwaway-snapshot.json" \ + NEURON_API_URL="http://127.0.0.1:9" \ + SOUL_TICK_MS="3600000" SOUL_HEARTBEAT_MS="3600000" SOUL_REFRESH_MS="3600000" \ + "$SOUL" >"$SOUL_LOG" 2>&1 & +SOUL_PID=$! + +UP=0 +for _ in $(seq 1 60); do + if ! kill -0 "$SOUL_PID" 2>/dev/null; then + echo "!! soul exited during boot. log tail:" >&2; tail -20 "$SOUL_LOG" >&2; exit 3 + fi + RSS=$(ps -o rss= -p "$SOUL_PID" 2>/dev/null | tr -d ' ') + if [ -n "$RSS" ] && [ "$RSS" -gt $((3*1024*1024)) ]; then + echo "!! soul RSS >3GB — kill -9" >&2; kill -9 "$SOUL_PID" 2>/dev/null; exit 3 + fi + [ "$(curl -s -o /dev/null -w '%{http_code}' -m 2 "$BASE/health" 2>/dev/null)" = "200" ] && { UP=1; break; } + sleep 0.5 +done +[ "$UP" = 1 ] || { echo "!! soul never healthy on $BASE/health" >&2; tail -20 "$SOUL_LOG" >&2; exit 3; } +echo "== soul healthy =="; echo + +# --- probing helpers --------------------------------------------------------- +# request METHOD PATH [BODY] -> prints response body (single line) +request() { + curl -s -m 12 -X "$1" -H 'Content-Type: application/json' --data "${3:-{}}" "$BASE$2" 2>/dev/null | tr -d '\n' +} +# is_missing BODY -> 0 if the body is a "route not present" signal +is_missing() { + printf '%s' "$1" | grep -qE '"error":"not found"|"code":"not_found"|no http handler registered|"code":"method_not_allowed"' +} +extract_id() { printf '%s' "$1" | grep -oE '"id":"[^"]+"' | head -1 | sed 's/.*"id":"//;s/"//'; } +node_present() { # id -> 0 if id appears in /api/graph/nodes + request GET /api/graph/nodes | grep -qF "\"$1\"" +} + +# --- SECTION A: presence ----------------------------------------------------- +run_presence() { + local -n arr=$1; local fail=0 + printf ' %-8s %-42s %s\n' "METHOD" "ROUTE" "RESULT" + for e in "${arr[@]}"; do + local m p body; m=$(awk '{print $1}' <<<"$e"); p=$(awk '{print $2}' <<<"$e") + body=$(request "$m" "$p") + if is_missing "$body"; then + printf ' %-8s %-42s MISSING %s\n' "$m" "$p" "$(cut -c1-46 <<<"$body")"; fail=$((fail+1)) + else + printf ' %-8s %-42s ANSWERED %s\n' "$m" "$p" "$(cut -c1-46 <<<"$body")" + fi + done + return $fail +} + +echo "== SECTION A: PRESENCE (required, blocking) ==" +run_presence REQUIRED; A_FAIL=$? +echo +echo "== KNOWN-PENDING (non-blocking) ==" +run_presence KNOWN_PENDING; P_FAIL=$? +echo + +# --- SECTION B: immutability (engram write routes must supersede, not destroy) -- +# For each mutation route: create a node, mutate it, then check the ORIGINAL id +# still exists in the graph. KEPT = supersede/tombstone (correct). DESTROYED = +# hard delete (DEFECTIVE -> fail). N/A = mutate route absent (a presence failure). +# For "delete" mutations we additionally require a real tombstone marker +# (label "tombstone:") so a no-op delete cannot false-pass as KEPT. +marker_present() { # id -> 0 if a "tombstone:" marker exists (include_deleted view) + request GET "/api/graph/nodes?include_deleted=1" | grep -qF "tombstone:$1" +} +immut_check() { # label KIND(update|delete) CREATE_PATH MUTATE_PATH + local label="$1" kind="$2" create="$3" mutate="$4" + local cbody id mb mbody + cbody=$(request POST "$create" "{\"content\":\"__immut_${label}__\",\"node_type\":\"Memory\",\"label\":\"contract:immut\"}") + id=$(extract_id "$cbody") + if [ -z "$id" ]; then printf ' %-14s SKELETON-FAIL create returned no id: %s\n' "$label" "$(cut -c1-40 <<<"$cbody")"; return 2; fi + mb="{\"id\":\"$id\"}"; [ "$kind" = update ] && mb="{\"id\":\"$id\",\"content\":\"__immut_${label}_v2__\"}" + mbody=$(request POST "$mutate" "$mb") + if is_missing "$mbody"; then printf ' %-14s N/A mutate route absent (see Section A)\n' "$label"; return 0; fi + if ! node_present "$id"; then + printf ' %-14s DESTROYED original %s hard-removed <== DEFECTIVE\n' "$label" "$id"; return 1 + fi + if [ "$kind" = delete ] && ! marker_present "$id"; then + printf ' %-14s NO-OP original %s kept but no tombstone marker <== DEFECTIVE\n' "$label" "$id"; return 1 + fi + local how="supersede edge"; [ "$kind" = delete ] && how="tombstoned + hidden from default list" + printf ' %-14s KEPT original %s survived (%s)\n' "$label" "$id" "$how"; return 0 +} + +echo "== SECTION B: IMMUTABILITY (engram nodes must be superseded, never destroyed) ==" +B_FAIL=0 +immut_check "memory-update" update /api/neuron/memory /api/neuron/memory/update || B_FAIL=$((B_FAIL+$?)) +immut_check "memory-delete" delete /api/neuron/memory /api/neuron/memory/delete || B_FAIL=$((B_FAIL+$?)) +immut_check "node-update" update /api/neuron/node/create /api/neuron/node/update || B_FAIL=$((B_FAIL+$?)) +immut_check "node-delete" delete /api/neuron/node/create /api/neuron/node/delete || B_FAIL=$((B_FAIL+$?)) +immut_check "memory-forget" delete /api/neuron/memory /api/neuron/memory/forget || B_FAIL=$((B_FAIL+$?)) +echo + +echo "============================================================" +RC=0 +if [ "$A_FAIL" -gt 0 ]; then echo "PRESENCE: FAIL — $A_FAIL required route(s) unanswered. Do NOT package."; RC=1 + else echo "PRESENCE: PASS — all ${#REQUIRED[@]} required routes answered."; fi +if [ "$B_FAIL" -gt 0 ]; then echo "IMMUTABILITY: FAIL — $B_FAIL engram write route(s) hard-delete. DEFECTIVE soul."; RC=1 + else echo "IMMUTABILITY: PASS — no engram write route hard-deletes."; fi +[ "$P_FAIL" -gt 0 ] && echo "note: $P_FAIL known-pending route(s) unanswered (expected; non-blocking)." +echo "============================================================" +[ "$RC" = 0 ] && echo "GATE: PASS" || echo "GATE: FAIL" +exit $RC