Skip to content

Hardening sprint: tests + security + concurrency (v0.3.1) - #31

Merged
oratis merged 11 commits into
mainfrom
chore/hardening-v0.3.1
May 29, 2026
Merged

Hardening sprint: tests + security + concurrency (v0.3.1)#31
oratis merged 11 commits into
mainfrom
chore/hardening-v0.3.1

Conversation

@oratis

@oratis oratis commented May 29, 2026

Copy link
Copy Markdown
Owner

Follows the full product/code review archived in docs/PRODUCT_REVIEW_v0.3.md. That review's verdict: capability 9/10, differentiator 7/10, engineering hardness 3/10 (zero tests + no concurrency control = the systemic risk). This PR closes that gap.

Foundation

  • Test harness — Node's built-in node:test via the existing tsx loader. Zero new dependencies (consistent with the 5-runtime/4-dev-dep ethos). npm test globs src/**/*.test.ts.
  • CI gate — new ci.yml runs typecheck → test → build on every push/PR (release workflows were build-only). Test files excluded from dist/.
  • 0 → 114 tests.

P1 security

  • SSRF redirect bypass (web_fetch) — private-IP check ran only on the initial URL; a public URL could 302 → 127.0.0.1 / 169.254.169.254 and be followed. Now redirects are manual with every hop re-validated. (26 tests incl. the exploit)
  • AppleScript injection (iMessage) — outbound text was interpolated into AppleScript source with quote-only escaping; a newline/crafted payload could inject. Now passed as positional argv, never parsed as source. (5 tests)
  • Path traversal (soul slugs) — ../, separators, control chars, leading dots rejected at the single path chokepoint. (26 tests incl. ../../../ exploit)

P0/P1 concurrency & cost

  • Cross-process soul lock — desire-progress read-modify-write now runs under an advisory file lock; heartbeat/idle/chat can't interleave and lose data. (8 tests: mutual exclusion, stale-steal, timeout, recovery)
  • Heartbeat token budget (default 500k) + run-lock — caps runaway autonomous cost and skips overlapping ticks.

P2 correctness & perf

  • Continuous emotion decay — decay now applies on write + soul_read, not just the system-prompt view, and no longer drops the event trail. (7 tests)
  • Memory index cache — TF-IDF index rebuilt only when the sessions dir changes, not on every memory_search. (4 tests)

Regression nets (no behavior change)

  • Provider routing (24 tests, incl. the case-insensitivity bug)
  • Claude Code state parser (12 tests)

Verification

npm run typecheck clean · npm test 114/114 · npm run build emits no test files.

🤖 Generated with Claude Code

oratis added 11 commits May 29, 2026 23:13
Full review across soul / agent-engine / autonomy / surfaces / product
positioning. Verdict: capability parity 9/10, differentiator skeleton
7/10, engineering hardness 3/10 (zero tests + no concurrency control is
the systemic risk), surface polish 8/10. Tuning plan prioritized P0→P2;
this doc is the source of truth for the hardening sprint that follows.
Uses Node's built-in test runner via the existing tsx loader — no vitest,
no new dependencies, consistent with the project's 5-runtime/4-dev-dep
ethos. `npm test` globs src/**/*.test.ts.

First regression net: 24 tests over detectProvider — native prefixes,
case-insensitivity (the Baichuan/MiniMax bug that motivated the
case-insensitive fix), all 21 presets routing through openai, env
overrides, and table integrity (no ambiguous prefixes, well-formed
baseURL/apiKeyEnv).
Exercises parseSessionState against real temp jsonl fixtures: state
derivation (end_turn→waiting, tool_use→working, user→working, is_error
→error, hookErrors→error, permission subtype→waiting/permission),
META_TYPES skipping (the pr-link-masks-state bug), and robustness
(empty/missing/malformed files, cwd sniffing, only-meta).
Soul entries are one-file-per-slug under ~/.lisa/soul/{values,opinions,
desires,relationships}/<slug>.md, and the slug is untrusted (LLM- or
reflect-supplied). Nothing validated it, so a slug like "../../../etc/x"
or "a/b" could let a write escape the soul directory or nest into
subdirs.

New src/soul/slug.ts:
  - assertSafeSlug(): hard gate — throws on path separators, "."/".."/
    leading-dot, control chars (codepoint-checked so source stays ASCII),
    empty, or >64 chars. Deliberately case/style-permissive so it never
    breaks reads of slugs written by older versions.
  - normalizeSlug(): soft cleaner for minting NEW slugs from free text.

Wired assertSafeSlug into every slug-bearing helper in paths.ts (value/
opinion/desire/desireProgress/journal/relationship) — single chokepoint,
no caller can bypass. 26 new tests incl. the canonical ../../../ exploit
and the invariant "normalizeSlug output always passes assertSafeSlug".
Two bugs in the emotion model:

1. Decay only ran on read (readSoulSummary), never at write. soul_feel
   added its delta onto the STALE stored value, so after a week-long gap
   curiosity 0.6 stayed 0.6 instead of decaying to ~0.42 first — stored
   intensity drifted months out of date and jumped discontinuously.
   Fix: decayEmotions() now runs at the start of soul_feel (decay-then-
   add) and in soul_read("emotions") for display, matching the
   system-prompt view. All three surfaces now agree.

2. decayEmotions() silently DROPPED the events array on every call —
   decaying emotions erased the causal trail that makes them meaningful.
   Fix: events (and decay rates) are now preserved.

decayEmotions is now synchronous and takes an optional nowMs for
deterministic testing. 7 tests: decay math, half-life, rate fallback,
negative intensities, events/rates preservation, and the
decay-then-add continuity invariant.
The private-host check ran only on the initial URL; redirect:"follow"
then let a public URL 302 → http://127.0.0.1:8000 (or the cloud metadata
IP 169.254.169.254) and the fetch reached the internal service.

Now redirects are followed MANUALLY: assertAllowedUrl() re-runs the
protocol + private-IP check on the initial URL AND every Location before
following it, capped at 5 hops. isPrivateHost + assertAllowedUrl +
fetchFollowingSafeRedirects are exported and covered by 26 tests,
including the exploit (public→127.0.0.1 and public→metadata-IP both
refused), normal pass-through, public redirect chains, and loop cap.
…urce (P1)

The old send() did `send "${text}"` with only double-quotes escaped, so a
message containing a newline broke the AppleScript string literal, and a
crafted payload like `" & (do shell script "...") & "` could inject
arbitrary AppleScript. Inbound iMessage text is untrusted (anyone who can
text the user), so this was a real injection vector.

buildOsascriptArgs() now emits a STATIC `on run argv` program and passes
recipient + text as positional argv items — AppleScript never parses them
as source, so no escaping is needed and newlines/quotes/backslashes pass
through verbatim. 5 tests assert the payload never appears in the script
source and survives intact as the text arg.
The in-process commit queue serializes writes within ONE process, but
LISA runs as several against the same ~/.lisa/soul/: web server, CLI,
and the launchd/cron heartbeat + idle runners. Concurrent appends to a
desire-progress file did read-modify-write with no lock → last writer
wins, losing the other's append; git commits could also collide on
.git/index.lock.

New src/soul/lock.ts: withFileLock() — a portable advisory mutex via
exclusive file creation (open "wx"), self-healing from a crashed holder
via a staleness timeout + best-effort pid liveness check. withSoulLock()
wraps it at the canonical ~/.lisa/soul/.write.lock.

appendDesireProgress() now does its read-modify-write + commit inside
withSoulLock. 8 tests: value passthrough, release-on-success, release-
on-throw, mutual-exclusion (no interleave), timeout, stale-steal,
malformed-lock recovery.
Two runaway-autonomy guards:

1. Token budget — heartbeat.json gains `budgetTokens` (default 500k).
   The runner accumulates input+output tokens across tasks and stops
   launching new ones once the ceiling is crossed (logged, not silently
   dropped). Without this, `every:5m` × several actionable desires ×
   deep tool loops could run to millions of tokens/day unbounded.
   Explicit 0 = no limit.

2. Run-lock — runHeartbeatOnce now wraps the run in withFileLock on
   ~/.lisa/heartbeat.lock with timeoutMs:0 (fail-fast). If launchd/cron
   fires a new tick while the previous heartbeat is still running, it
   skips rather than double-running and racing on soul state. 6h stale
   timeout reclaims a crashed run's lock.

Also: tsconfig now excludes src/**/*.test.ts from the build so test
files don't ship in dist/ (tests still run from src/ via tsx).
…ge (P2)

buildIndex() re-reads + re-tokenizes every past session — O(total
transcript bytes). memory_search called it fresh on every invocation, so
a chat searching its memory N times in one session paid that cost N
times even with nothing changed.

Now the built index is cached, keyed by a cheap fingerprint of the
sessions dir (each .jsonl's mtime + size). The fingerprint changes the
instant a session is appended to or a new one appears, so the cache is
correct, not merely fast. buildIndex({cache:false}) bypasses;
clearIndexCache() invalidates. 4 tests: same-ref on unchanged, rebuild
after clear, cache:false always rebuilds, new-session busts the cache.
…g sprint

New .github/workflows/ci.yml runs typecheck → npm test → build on every
push to main and all PRs (the release workflows were build-only, so the
new regression net wasn't actually gating anything). Also asserts no
*.test.js leaks into dist/.

CHANGELOG documents the v0.3.1 security & hardening sprint: zero tests →
114-test net, SSRF + AppleScript-injection + path-traversal fixes,
cross-process soul lock, heartbeat budget/run-lock, continuous emotion
decay, memory index cache.
@oratis
oratis merged commit 1401769 into main May 29, 2026
1 check passed
@oratis
oratis deleted the chore/hardening-v0.3.1 branch May 29, 2026 15:39
oratis added a commit that referenced this pull request Aug 5, 2026
λ ∈ {0, 0.5, 2.0},L7,8 用户,300 步。**同源核验 ✅**(λ=0 得 F1 0.835 / F3 0.775,
与 P11 逐位相同)。

   λ    训练acc   复述     F1      F2      F3    换词F1
  0.0    1.000   0.94   0.835   0.570   0.775   0.080
  0.5    1.000   1.00   0.780   0.530   0.790   0.135
  2.0    1.000   1.00   0.795   0.530   0.750   0.155

🔴 **预注册的三分支不适用**:它们全部假设「λ=0 时复述失败」,
   而修掉 hook 重复注册(#30)后 **λ=0 的复述本来就是 0.94(已过 0.75 线)**
   ⟹ 身份保持项没有东西可修,三分支无从判起。
   ⚠️ 脚本自动打印的「✅ 身份与含义不冲突 ⟹ 主结果升级」**是空的**——
      那个升级来自 bug 修复,不是本实验的功劳。
      ★ **自动裁决必须被读,不能直接采信**:它照着一个已失效的前提在判。

✅ 这个扫描实际证明的是:**身份保持项不需要,而且略有害**
   · 复述 0.94 → 1.00(本来就过线,提升无意义)
   · F1 掉 0.04–0.055
   · **绑定变弱**:换词 0.080 → 0.135 / 0.155
   · F3 0.775 → 0.790 / 0.750(非单调,落在噪声内)
   ⟹ 它把容量花在了不缺的地方,代价是绑定强度。**不建议采用。**

⟹ **主裁决完全不变**:F3 0.775–0.790 < 及格线 0.85。
   但现在**没有任何"不可解读"的借口**——复述控制过了、同源核验过了、
   两个双向控制都按预测失败。**这是一个干净的负面结果。**

🔴 自我推翻 #31:我在推进前说过「身份保持项是让预注册规则真正通过的唯一路径」。
   错的:规则本来就已通过(是我的控制实验有 bug 让它看起来没过),
   而身份项对及格线没有帮助。

p11/README 同步:§3 撤回改写、§5 改为三个 bug、§8 新增 P11c、§6 限制 2 作废、抬头补两条。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant