diff --git a/lang/el-compiler/runtime/el_runtime.c b/lang/el-compiler/runtime/el_runtime.c index 1d4d9ac..e64cc73 100644 --- a/lang/el-compiler/runtime/el_runtime.c +++ b/lang/el-compiler/runtime/el_runtime.c @@ -1553,8 +1553,13 @@ static void* http_worker(void* arg) { int fd = a->fd; #endif free(a); - char *method = NULL, *path = NULL, *body = NULL; - if (http_read_request(fd, &method, &path, &body, NULL) == 0) { + char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL; + if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0 + && !el_http_request_authorized(method, path, hdr_block)) { + /* Loopback hardening: EL_HTTP_AUTH_KEY is set and this request lacks the + * matching X-Neuron-Auth header — refuse before it reaches any handler. */ + el_http_send_401(fd); + } else if (method != NULL) { http_handler_fn h = http_lookup_active(); char* response = NULL; /* HEAD: dispatch as GET so existing handlers respond with the same @@ -1582,7 +1587,7 @@ static void* http_worker(void* arg) { _tl_http_head_only = 0; free(response); } - free(method); free(path); free(body); + free(method); free(path); free(body); free(hdr_block); el_closesocket(fd); /* release a slot */ pthread_mutex_lock(&_http_conn_mu); @@ -1592,6 +1597,108 @@ static void* http_worker(void* arg) { return NULL; } +/* ── loopback lock + local API-key auth (shipped desktop hardening) ──────── + * Both controls are OFF by default (their env vars unset), so dev, self-host, + * and server builds behave exactly as before. The shipped macOS launcher + * neuron-daemons.sh sets them so a customer's soul is neither reachable from + * other machines on the LAN nor callable by other local users/processes + * without the per-install key held in the login Keychain: + * + * EL_HTTP_BIND_HOST=127.0.0.1 -> bind loopback only (el_http_apply_bind_addr) + * EL_HTTP_AUTH_KEY= -> require "X-Neuron-Auth: " per request + */ + +/* Set the listen address on the dual-stack (AF_INET6, V6ONLY=0) socket. Default + * is in6addr_any (all interfaces) — unchanged. When EL_HTTP_BIND_HOST names a + * loopback ("127.0.0.1", "localhost", "loopback", or "::1") we bind the IPv4- + * mapped IPv6 loopback ::ffff:127.0.0.1: on a V6ONLY=0 socket this accepts IPv4 + * 127.0.0.1 clients (the desktop app connects there) while refusing every + * off-machine address. */ +static void el_http_apply_bind_addr(struct sockaddr_in6* addr) { + const char* h = getenv("EL_HTTP_BIND_HOST"); + int loopback = h && *h && (strcmp(h, "127.0.0.1") == 0 + || strcmp(h, "localhost") == 0 + || strcmp(h, "loopback") == 0 + || strcmp(h, "::1") == 0); + if (loopback) { + memset(&addr->sin6_addr, 0, sizeof(addr->sin6_addr)); + addr->sin6_addr.s6_addr[10] = 0xff; /* ::ffff:127.0.0.1 */ + addr->sin6_addr.s6_addr[11] = 0xff; + addr->sin6_addr.s6_addr[12] = 127; + addr->sin6_addr.s6_addr[15] = 1; + } else { + addr->sin6_addr = in6addr_any; + } +} + +/* Human-readable description of the active bind host, for the listen log line. */ +static const char* el_http_bind_desc(void) { + const char* h = getenv("EL_HTTP_BIND_HOST"); + if (h && *h && (strcmp(h, "127.0.0.1") == 0 || strcmp(h, "localhost") == 0 + || strcmp(h, "loopback") == 0 || strcmp(h, "::1") == 0)) { + return "127.0.0.1 (loopback)"; + } + return "[::] (dual-stack)"; +} + +/* Case-insensitive compare of the first n bytes of a and b. */ +static int el_ci_eq_n(const char* a, const char* b, size_t n) { + for (size_t i = 0; i < n; i++) { + unsigned char ca = (unsigned char)a[i], cb = (unsigned char)b[i]; + if (tolower(ca) != tolower(cb)) return 0; + } + return 1; +} + +/* Return 1 iff the raw header block carries a header named `name` (case- + * insensitive) whose trimmed value equals `want` exactly. */ +static int el_http_header_equals(const char* hdr_block, const char* name, + const char* want) { + if (!hdr_block || !name || !want) return 0; + size_t nlen = strlen(name), wlen = strlen(want); + const char* p = hdr_block; + while (*p) { + const char* line_end = strstr(p, "\r\n"); + const char* end = line_end ? line_end : p + strlen(p); + const char* colon = memchr(p, ':', (size_t)(end - p)); + if (colon && (size_t)(colon - p) == nlen && el_ci_eq_n(p, name, nlen)) { + const char* v = colon + 1; + while (v < end && (*v == ' ' || *v == '\t')) v++; + size_t vlen = (size_t)(end - v); + while (vlen > 0 && (v[vlen - 1] == ' ' || v[vlen - 1] == '\t')) vlen--; + if (vlen == wlen && memcmp(v, want, wlen) == 0) return 1; + } + if (!line_end) break; + p = line_end + 2; + } + return 0; +} + +/* Authorize an inbound request. Enforcement is active only when EL_HTTP_AUTH_KEY + * is set; otherwise every request is allowed (dev default). GET/HEAD /health* + * are always allowed so launch-agent liveness probes work without the key. */ +static int el_http_request_authorized(const char* method, const char* path, + const char* hdr_block) { + const char* key = getenv("EL_HTTP_AUTH_KEY"); + if (!key || !*key) return 1; + if (method && (strcmp(method, "GET") == 0 || strcmp(method, "HEAD") == 0) + && path && strncmp(path, "/health", 7) == 0) return 1; + return el_http_header_equals(hdr_block, "x-neuron-auth", key); +} + +/* Minimal 401 for unauthorized requests — never reaches an EL handler. */ +static void el_http_send_401(int fd) { + static const char* body = "{\"error\":\"unauthorized\",\"code\":\"auth_required\"}"; + char resp[256]; + int n = snprintf(resp, sizeof(resp), + "HTTP/1.1 401 Unauthorized\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n\r\n%s", + strlen(body), body); + if (n > 0) http_send_all(fd, resp, (size_t)n); +} + el_val_t http_serve(el_val_t port, el_val_t handler) { /* If `handler` looks like a string name, register it as the active handler. */ const char* hname = EL_CSTR(handler); @@ -1610,13 +1717,13 @@ el_val_t http_serve(el_val_t port, el_val_t handler) { struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr)); addr.sin6_family = AF_INET6; - addr.sin6_addr = in6addr_any; + el_http_apply_bind_addr(&addr); addr.sin6_port = htons((uint16_t)p); if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); el_closesocket(sock); return 0; } if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return 0; } - fprintf(stderr, "[http] listening on [::]:%d (dual-stack)\n", p); + fprintf(stderr, "[http] listening on %s port %d\n", el_http_bind_desc(), p); while (1) { struct sockaddr_in6 cli; socklen_t clen = sizeof(cli); @@ -1866,13 +1973,13 @@ el_val_t http_serve_v2(el_val_t port, el_val_t handler) { struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr)); addr.sin6_family = AF_INET6; - addr.sin6_addr = in6addr_any; + el_http_apply_bind_addr(&addr); addr.sin6_port = htons((uint16_t)p); if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); el_closesocket(sock); return 0; } if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return 0; } - fprintf(stderr, "[http v2] listening on [::]:%d (dual-stack)\n", p); + fprintf(stderr, "[http v2] listening on %s port %d\n", el_http_bind_desc(), p); while (1) { struct sockaddr_in6 cli; socklen_t clen = sizeof(cli); @@ -1968,13 +2075,13 @@ void http_serve_async(el_val_t port, el_val_t handler) { struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr)); addr.sin6_family = AF_INET6; - addr.sin6_addr = in6addr_any; + el_http_apply_bind_addr(&addr); addr.sin6_port = htons((uint16_t)p); if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); close(sock); return; } if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; } - fprintf(stderr, "[http] async listening on [::]:%d (dual-stack)\n", p); + fprintf(stderr, "[http] async listening on %s port %d\n", el_http_bind_desc(), p); HttpServeAsyncArg* a = malloc(sizeof(HttpServeAsyncArg)); if (!a) { close(sock); return; } a->sock = sock; @@ -3139,10 +3246,72 @@ static char* jp_parse_string_raw(JsonParser* jp) { case 'r': c = '\r'; break; case 't': c = '\t'; break; case 'u': { - /* Skip 4 hex digits; emit '?' as a placeholder */ - for (int i = 0; i < 4 && jp->p < jp->end; i++) jp->p++; - c = '?'; - break; + /* Decode \uXXXX (with surrogate pairs) to UTF-8. + * Ported from lang/releases/v1.0.0-20260501 (2026-08-08 + * self-review). This copy carried the identical defect: + * the escape was skipped and a literal '?' emitted, which + * silently destroyed every non-ASCII character in any JSON + * string entering the runtime. Two copies of one parser + * bug is exactly how this class of fault survives, so the + * fix lands in both. See the release copy for the full + * measurement and rationale. */ + unsigned cp = 0; + int ok = 1; + for (int i = 0; i < 4; i++) { + if (jp->p >= jp->end) { ok = 0; break; } + char h = *jp->p++; + unsigned d; + if (h >= '0' && h <= '9') d = (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') d = (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') d = (unsigned)(h - 'A' + 10); + else { ok = 0; break; } + cp = (cp << 4) | d; + } + if (!ok) { c = '?'; break; } + if (cp >= 0xD800 && cp <= 0xDBFF && + (size_t)(jp->end - jp->p) >= 6 && + jp->p[0] == '\\' && jp->p[1] == 'u') { + const char* save = jp->p; + unsigned lo = 0; int ok2 = 1; + jp->p += 2; + for (int i = 0; i < 4; i++) { + char h = *jp->p++; + unsigned d; + if (h >= '0' && h <= '9') d = (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') d = (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') d = (unsigned)(h - 'A' + 10); + else { ok2 = 0; break; } + lo = (lo << 4) | d; + } + if (ok2 && lo >= 0xDC00 && lo <= 0xDFFF) + cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u); + else jp->p = save; + } + if (cp >= 0xD800 && cp <= 0xDFFF) cp = 0xFFFD; + + char ub[4]; int un; + if (cp < 0x80) { + ub[0] = (char)cp; un = 1; + } else if (cp < 0x800) { + ub[0] = (char)(0xC0 | (cp >> 6)); + ub[1] = (char)(0x80 | (cp & 0x3F)); un = 2; + } else if (cp < 0x10000) { + ub[0] = (char)(0xE0 | (cp >> 12)); + ub[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + ub[2] = (char)(0x80 | (cp & 0x3F)); un = 3; + } else { + ub[0] = (char)(0xF0 | (cp >> 18)); + ub[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + ub[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + ub[3] = (char)(0x80 | (cp & 0x3F)); un = 4; + } + while (len + (size_t)un >= cap) { + cap *= 2; + out = realloc(out, cap); + if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); } + } + for (int i = 0; i < un; i++) out[len++] = ub[i]; + continue; /* bytes already appended */ } default: c = esc; break; } @@ -6048,6 +6217,13 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal, #define ENGRAM_SUPPRESSION_BREAKTHROUGH 5 #define ENGRAM_BREAKTHROUGH_WEIGHT 0.25 #define ENGRAM_INHIBITION_FACTOR 0.1 +/* ENGRAM_WM_CAP: hard global ceiling on nodes holding working_memory_weight + * > 0 at any time. Cowan (2001) puts human WM capacity at ~4 chunks; 24 gives + * the daemon generous headroom while preventing the unbounded growth observed + * in production (wm_active 288-778 per heartbeat — "working memory" that is + * really the whole recently-touched graph). Ported from release runtime + * v1.0.0-20260501 Pass 5 on 2026-07-15 self-review. */ +#define ENGRAM_WM_CAP 24 /* ── Layered consciousness architecture ────────────────────────────────────── * @@ -7252,6 +7428,48 @@ static double engram_goal_bias(const EngramNode* n, const char* query) { return bias; } +/* eg_cmp_double_desc — qsort comparator, descending doubles. */ +static int eg_cmp_double_desc(const void* a, const void* b) { + double da = *(const double*)a, db = *(const double*)b; + if (da < db) return 1; + if (da > db) return -1; + return 0; +} + +/* eg_enforce_wm_cap_global — clamp the store-wide working-memory population + * to ENGRAM_WM_CAP, keeping the top-K by current weight. Runs at every point + * that materializes WM: post-activation persist and snapshot load/merge. + * (Ported from release runtime v1.0.0-20260501 Pass 5, 2026-07-15.) */ +static void eg_enforce_wm_cap_global(EngramStore* g) { + int64_t wm_count = 0; + for (int64_t i = 0; i < g->node_count; i++) { + if (g->nodes[i].working_memory_weight > 0.0) wm_count++; + } + if (wm_count <= ENGRAM_WM_CAP) return; + double* vals = malloc((size_t)wm_count * sizeof(double)); + if (!vals) return; /* OOM: over cap this call, no corruption */ + int64_t vi = 0; + for (int64_t i = 0; i < g->node_count; i++) { + if (g->nodes[i].working_memory_weight > 0.0) + vals[vi++] = g->nodes[i].working_memory_weight; + } + qsort(vals, (size_t)wm_count, sizeof(double), eg_cmp_double_desc); + double cutoff = vals[ENGRAM_WM_CAP - 1]; + free(vals); + int64_t above = 0; + for (int64_t i = 0; i < g->node_count; i++) { + if (g->nodes[i].working_memory_weight > cutoff) above++; + } + int64_t slots_at_cutoff = ENGRAM_WM_CAP - above; + for (int64_t i = 0; i < g->node_count; i++) { + EngramNode* n = &g->nodes[i]; + if (n->working_memory_weight <= 0.0) continue; + if (n->working_memory_weight > cutoff) continue; + if (slots_at_cutoff > 0) { slots_at_cutoff--; continue; } + n->working_memory_weight = 0.0; /* evict: over global cap */ + } +} + el_val_t engram_activate(el_val_t query, el_val_t depth) { EngramStore* g = engram_get(); const char* q = EL_CSTR(query); @@ -7457,6 +7675,12 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { g->nodes[i].working_memory_weight = wm_weights[i]; } + /* Global WM cap: keep only the top ENGRAM_WM_CAP by weight across the + * whole store (see eg_enforce_wm_cap_global). Without this, repeated + * activation calls accumulate hundreds of "promoted" nodes and WM stops + * meaning anything (production heartbeats showed wm_active up to 778). */ + eg_enforce_wm_cap_global(g); + /* ── Collect all background-activated nodes for the return value ──── * Callers see both layers. Context compilation uses only promoted nodes * (working_memory_weight > 0). Sort: promoted first by wm_weight desc, @@ -7834,6 +8058,9 @@ el_val_t engram_load(el_val_t path) { } } } + /* WM cap discipline applies to every entry point that materializes WM, + * including snapshot restore (see eg_enforce_wm_cap_global). */ + eg_enforce_wm_cap_global(g); free(data); return 1; } @@ -7854,6 +8081,33 @@ el_val_t engram_get_node_json(el_val_t id) { return el_wrap_str(jb_finish(&b)); } +/* engram_get_node_by_label — find the first node whose label field exactly + * matches the given string. Returns the node as a JSON object string, or "{}" + * if no match is found. + * + * Exact match (strcmp, not substring) because labels like "conv:history" + * must not collide with nodes whose content contains that substring. + * + * Ported from the release runtime 2026-07-16 self-review: chat.el has called + * this since 2026-07-01 but the function only existed in + * releases/v1.0.0-20260501/el_runtime.c — the soul daemon (which builds + * against THIS runtime) failed to compile once clang made implicit + * declarations an error. */ +el_val_t engram_get_node_by_label(el_val_t label) { + const char* lbl = EL_CSTR(label); + if (!lbl || !*lbl) return el_wrap_str(el_strdup("{}")); + EngramStore* g = engram_get(); + for (int64_t i = 0; i < g->node_count; i++) { + EngramNode* n = &g->nodes[i]; + if (n->label && strcmp(n->label, lbl) == 0) { + JsonBuf b; jb_init(&b); + engram_emit_node_json(&b, n); + return el_wrap_str(jb_finish(&b)); + } + } + return el_wrap_str(el_strdup("{}")); +} + el_val_t engram_search_json(el_val_t query, el_val_t limit) { EngramStore* g = engram_get(); const char* q = EL_CSTR(query); @@ -8411,6 +8665,9 @@ el_val_t engram_load_merge(el_val_t path) { } } + /* Merged nodes can carry snapshot WM weights too — hold the cap here as + * well (see eg_enforce_wm_cap_global). */ + eg_enforce_wm_cap_global(g); free(data); return (el_val_t)added_nodes; } diff --git a/lang/el-compiler/runtime/el_runtime.h b/lang/el-compiler/runtime/el_runtime.h index f64bf63..87348f5 100644 --- a/lang/el-compiler/runtime/el_runtime.h +++ b/lang/el-compiler/runtime/el_runtime.h @@ -632,6 +632,7 @@ el_val_t engram_load(el_val_t path); * can pass results straight through without round-tripping ElList/ElMap * through json_stringify. */ el_val_t engram_get_node_json(el_val_t id); +el_val_t engram_get_node_by_label(el_val_t label); el_val_t engram_search_json(el_val_t query, el_val_t limit); el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset); el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);