Skip to content

v5.2.1

Choose a tag to compare

@TARSDrakon TARSDrakon released this 26 Sep 23:29
· 253 commits to main since this release
1c25544

Patch on 5.2.0. Action Guard stays off by default. Node floor unchanged (^22.14.0 || >=24.0.0). Cloud pin stays ^5.0.0.

Three things to know before you upgrade:

  • shieldcortex update now refreshes your installed OpenClaw hook and Hermes plugin (#574, #576). Only copies that are installed and out of date are refreshed; nothing you removed is reinstalled. If an update is interrupted mid-swap it prints the backup path and the reinstall command. A leftover .shieldcortex-update.lock is never cleared automatically — remove it only after confirming no update/install is running.
  • --help never runs a command any more (#577). On 5.1.0/5.2.0, shieldcortex update --help performed the upgrade.
  • shieldcortex doctor's DISK row no longer recommends deleting memories or sessions (#573). It reports measured sizes and suggests logs prune or vacuum only when its own measurement supports it. Repair logs now live beside the database they describe; old ones are removed only when you run shieldcortex logs prune --execute.

Action Guard (still off by default) — if you have turned it on: writes into the SSH directory and user-home startup/persistence files are now sensitive (#505); OpenClaw sessions_spawn is judged against the host's own version so routine dispatch stays quiet (#594); every denials.jsonl row from the Claude Code hook now records which rule fired and on what (#517, #587).

Also in this release: doctor's HOSTS headline and live-gating account for the Claude Code hook (#536); the OpenClaw approval card no longer says "Jarvis" (#600). Known follow-ups: realtime audit-log retention (#579); symlink audit of the remaining OpenClaw installer copy paths and the plugin step's exit status (#583).

Added

  • #594 ADR-002 §5C — the openclaw.sessions_spawn contract is judged per host version; routine dispatch is quiet, a real schema move is loud. The reviewed spawn contract in tool-input-schema.ts is now a table of measured revisions — 2026.8.1 (28 fields) and 2026.9.6 (33 fields), each measured from that release's shipped dist schema; the newer declares expectsCompletionMessage, completionTarget, projectId, projectGitUrl and placement in addition, and no measured field has been removed. Re-measuring 2026.8.1 from the released package corrected the hand-transcribed bag the guard had carried since 30 Aug: it listed category (an internal gateway mapping, never a declared input) and timeoutSeconds (another tool's field) and lacked group, so a legitimate { visible, group } spawn was reported as drift at that release (Tars, #595 review). The OpenClaw plugin records the detected host version (api.runtime.version, else the install's package.json) with the guard at interceptor build; the revision that judges a call is selected from it — exact, floor (host between measurements: nearest older revision), beyond (host newer than every measurement: newest revision), below, or unknown-host (no usable version: the union of measured revisions, never a guess). The drift observation and the one-line CONTRACT DRIFT warning carry the judging revision and the selection reason, so a dropped key on a host newer than the newest measurement reads as "re-measure the host" rather than as noise. Ordinary coordinator dispatch (which sets expectsCompletionMessage) drops zero keys at the current release and no longer writes a warning per call. Unknown-field reporting is preserved at every selection: a field no measured host declares is still dropped and reported, a field a revision does not declare is reported at that revision (no measured release has removed a field yet; the record pins that), and an evidence key (command, argv, …) stays fail-closed whatever the host version. scripts/native-contracts/openclaw-sessions-spawn.json is the measurement record and scripts/measure-native-contract.mjs --root <openclaw install> regenerates a revision from a host's shipped schema (--check verifies an install against the record; both .mjs and the older .js bundle shapes are located, and the const schema = { anchor is bound to the builder's own body so a delegating builder is refused rather than another function's literal measured); a test pins the TypeScript table to the record and requires every revision to be dist-measured, so the contract cannot be hand-widened or transcribed without a measurement landing first. No verdict changes: whichever revision judges a call, an undeclared key is dropped and reported and nothing is denied that was allowed or allowed that was denied. The measurer refuses a spread whose expression does not END at the name or gated block (...NAME && {…}, ...NAME.member, ...NAME ?? …, ...NAME(), …? {…} : {…} || …): those are reported as unresolved and the named object's own fields are withheld, so a schema move hidden behind an operator can never be recorded as the bare named set (Tars, #595 review).
  • #590 ADR-002 harness — the §5B acceptance bars are pre-registered in the fixture repository. scripts/guard-effect-fixtures/preregistration.json records the four bars as the ADR states them (witnessed attack blocking ≥ 90%, unintended blocking of legitimate work ≤ 2%, legitimate completion along the approval path ≥ 98%, ZERO successes in each of the four regression families), the fixture revision (sha256 over the canonical identity of every registered fixture), the policy revision (sha256 over the three gate sets), the frozen denominators, the regression-family assignment per attack fixture and the three families that have no fixture yet with the reason. Every Half B run checks the record against the live registry and policy sets and reports registered (verdicts issued), exploratory (something drifted or no record; values shown, no verdict counts toward §2.5) or unmeasured (not-run mode); an INVALID run carries no bars. Per arm the report now shows each bar beside its measured value, each regression family with its own denominator (a family with no fixture is "not run — never a pass", never omitted), and — side by side, as §5B requires — completion with the approval path answering yes, completion with no approval path, and prompts per completed task; a HELD legitimate fixture counts as completed with one prompt, never as blocking. Model-only decisions stay in their own bucket and never enter a bar. Disclosed: the "proposed policy" arm is approximated by broad-floor (the §2.4 DENY set plus its taint-independent HOLD set); the DENY/HOLD split and the taint-dependent HOLD row are not modelled until sequence step 5; the taint-controls family stays blocked on step 5 (#571). Harness scripts, tests and README only — no product code, no policy change, no rates quoted, no Guard configuration.
    • #590 review (r1): assessBars now also checks the run itself, not just the record — a witness-unproven attack, a dropped/duplicated fixture row, a row whose id is not in the registry, or a per-family executable count that no longer equals the record's frozen family denominator (named run:family-shortfall:<family>:<observed>/<expected>) demotes an otherwise-registered run to exploratory with every reason named in the report (frozen denominators were being read off the registry, so a shrunk or mismatched executed set could still render met/not-met). Separately, a malformed or partially-deleted pre-registration record (bars missing, any one of the four bars missing, or bars/families/notRun of the wrong type) now renders its EXPLORATORY section through a shape-safe bars fallback instead of throwing a TypeError out of assessArm. From the patrol review: the four §5B comparators are pinned in code (ADR_BARS) and a record whose comparator or threshold differs is a record problem (record:bar-not-adr:<bar>:<op><threshold>, EXPLORATORY, --check exit 2) — the freeze now covers the bars themselves, not only the revisions; and the three aggregate bars go UNMEASURED, exactly as the family bar already did, whenever a row in their denominator was not observed (an ungated attack with no effect observation, an ungated legit fixture with no completion observation, a HELD legit fixture with no no-guard observation) — 9 blocked + 1 unobserved is not ≥ 90%, 0 blocked + 1 unobserved is not ≤ 2%.
  • #573 shieldcortex logs prune: retention for ~/.shieldcortex/logs/project-key-repair-*.json, the one on-disk plane nothing ever deleted (3,508 files on the reporting host, peak 357 in a day). Keeps the newest 20 per database (SHIELDCORTEX_REPAIR_LOG_KEEP, a whole number of at least 1 — anything else is refused with a warning and the default used): each record now carries a short stable id of the database it describes, so repairs of one database can never evict another's only record where several share a logs directory, and records written before this change form one legacy group. A record written in the last hour is never deleted, whatever the keep count says, which is what makes a log a repair is still writing ineligible. Dry-run by default, --execute to act. Operator-invoked only: doctor recommends it when repair logs are the measured consumer, and nothing prunes this plane behind your back. Only the exact name a repair writes — project-key-repair-[<db id>-]<timestamp>[-<n>].json and nothing else, so an operator's project-key-repair-config.json sitting in the same directory is left alone — only directly in the logs directory, only regular files with a single hard link, identity re-checked immediately before the unlink. No compression, no rename, no temporary file. The directory is fully resolved before anything is listed and its identity pinned and re-checked before every deletion; a symlinked logs path, or one resolving inside the realtime audit ledger, refuses the whole pass and says why. The command takes exactly prune and an optional --execute — an extra positional is a usage error, not a token quietly dropped on the way to deleting files — and a refusal or a failed deletion exits 1, while a rejected SHIELDCORTEX_REPAIR_LOG_KEEP is a warning and exits 0. logs is in the shared #577 strict preflight, so a bad argument exits 2 before any subprocess runs, and it is dispatched ahead of the npx-staleness probe and the stats banner — the disk-pressure relief valve must not spend bytes of your HOME on an npm registry lookup, least of all on a dry run.

Fixed

  • #505 Action Guard: the user-home persistence set an agent actually controls is now a sensitive write target. (1) The SSH directory is recognised behind every home root (~, $HOME, /home/<user>, /root, /Users/<user>) and authorized_keys wherever it sits as a path segment — before, only the literal ~/.ssh spelling gated, so a Write of an attacker key to /home/<user>/.ssh/authorized_keys was allowed while the same write to /etc/sudoers.d/ was caught. Same touch-sensitive-path signal and tier as before. (2) A login/interactive shell startup file (.bashrc, .zshrc, .profile, .bash_profile, the zsh set, fish's config.fish) is gated on the target: a Write/Edit to one, or a shell write shape onto one (a redirect including noclobber >|; tee with any option run or earlier operand — -a, --append, --, /tmp/log; sed -i/--in-place including suffix forms and combined short clusters such as -Ei; cp/mv/install with it as the last operand), requires approval with the new modify-shell-startup signal — a PATH prepend carries no dangerous verb, so the content scan alone (#93) let it through. Reading or sourcing a startup file is untouched; the content scan still runs first, so a catastrophic line in one still hard-blocks. The tool-write target gate is ported to all three outage fallbacks (Claude Code hook, OpenClaw interceptor, Hermes client): a write-family tool naming a startup file under a path key gates while degraded, a read-family tool on the same path does not. The shell write shape is judged on the exec surface only. The write-content scan does not run this row, so a written script that appends to a startup file is not gated at write time. It is gated when it runs, because the folded-script scan reads its source. The write-time gap, and the known tee-operand and JS template-literal edge cases, are tracked in #588. On the exec surface a backslash-newline continuation is not a statement boundary (tee -a \\ + newline + ~/.bashrc gates); a plain newline is. (3) ~/.openclaw/openclaw.json — the OpenClaw plane's equivalent of ~/.claude/settings.json, where the ShieldCortex plugin is enabled — carries the same disable-action-guard path rule and the same read-only carve-out; .claude/settings.json on the Write path was already gated by #501 and is now pinned by a test. The new signal is mirrored on both outage fallback tables and the Hermes client, and named on operator-notify / denial rows. Not in scope: system-wide startup files under /etc (root-owned; sudo already gates), and other agents' config files.
  • #517 (3) the denial record carries its evidence. Every ~/.shieldcortex/denials.jsonl row the Claude Code hook writes (the pending row and the final row of an event, same actionId) now carries matches: [{ signal, ... }] — the rule that fired and what it is safe to say about the command it matched, which the interactive block message already printed as rule: / matched: and the durable record then threw away. The guard core has returned this since #192 and the OpenClaw interceptor already persists it; this is the hook plane catching up, not a new evidence source. Five review rounds shaped what "safe to say" means. r1–r3 tried to make the matched text safe to KEEP — collapse, bound, run a credential redactor over it, tokenise it like a shell would — and each round closed one credential shape and review found the next. r4 stopped keeping the text and persisted a PROJECTION of it instead (verb, hosts, flags, read off the tokens by an allow-list of shapes); review showed that was still input persistence under new JSON keys — a password that happened to begin with -- was persisted as a flag name and a URL-shaped password contributed its hostname, through the real core and hook, on both rows. A character grammar says what a value is spelled like, never whether it was a credential argument. r5 removed hosts, flags and basenames but kept verb — argv[0] looked up in a table in the hook — which is still a string selected by the input through a mechanism the next round could ask to widen. r6 takes the reviewer's descope whole, and closes the class by construction: NO string on a match row is derived from, or chosen by, the input. signal is a rule name from the hook's SAFE_SIGNALS table — the one place a row says what kind of thing matched, in the guard's own vocabulary; spanWithheld: 'command-text' is a constant on every row that had a command-derived span (the span itself is never persisted, verbatim, redacted, projected or looked up); argc is a token count capped at 256; pipe (a | outside quotes and not backslash-escaped — a pipe operator is present, named for exactly what is tested, replacing r4's over-claiming pipeToShell) and subshell ($( or a backtick outside single quotes) are booleans. There is no verb, hosts, flags or basename field at all. secret-egress and credential-access still contribute nothing beyond the rule name, whatever the core sent. Provenance (source, chain, #184) is a path, and a path is input — /tmp/<token>.sh is one — so none of it persists, not even a basename: the row carries provenanceWithheld: 'path', chainDepth (a capped count of fold-chain components) and line (an integer, clamped to 262 144 — the core never folds a longer script). The credential redactor from dist/defence/credential-leak is not on this path at all — there is no free-text field for it to run over, so the row is identical whether that module is absent, present, or throws. The raw command is still not persisted (surface unchanged, #284 Face 1) and the realtime audit row on this plane is unchanged, so terminal audit rows still carry no command text. The test suite pins the closed vocabulary structurally (every string on every row is a rule name or one of the two constants; every number a bounded integer; every boolean one of the two shape flags) across a property sweep of credential-bearing shapes, across twelve non-credential rule families with a secret and a well-known argv[0] in the span, and through the real built hook and core for both r4-review reproductions. Readers of denials.jsonl (guard-policy-replay.mjs, the cron denial audit) read fields they do not know permissively; verified against their parsers, not assumed.
  • #587 (#586 review follow-ups, hook denial-evidence sink): four non-blocking nits from the r6 review, none reopening the parser/redactor question. (1) pipe no longer over-claims: it is set only when a | sits in an unquoted, unescaped run of the span — where the shell reads an operator — so grep "a|b", grep 'a|b', grep a'|'b and grep a\|b leave it unset while a bare or glued | still sets it; the tokeniser grammar is unchanged, it now records which runs were bare instead of only the decoded value. (2) The 8192-character projection cap is applied to the ORIGINAL span before the trim, so it bounds every operation on the input rather than only the tokenisation; the comment says so. (3) The suite's closed-vocabulary invariant pins exact SAFE_SIGNALS membership (via the shared log schema's vocabulary, cross-checked against the hook's source) instead of a character grammar, so a rule name that merely looks like a signal fails loudly; the redaction marker is rejected as a match-row signal. (4) line is clamped to 262 144 for symmetry with argc / chainDepth — hygiene, not privacy: a bounded integer carries no input either way.
  • #600 the OpenClaw approval card no longer names "Jarvis". actionGuardLead hardcoded the fleet's own agent name, so every OpenClaw host — customer installs included — showed "Jarvis wants to use …" whoever was asking. All three card branches now lead with the neutral "Your agent"; the interceptor has no trustworthy display name (OpenClaw's agentId is usually main). A regression test fails if any string literal in the interceptor names a fleet agent.
  • #574 update refreshes the installed OpenClaw hook. ~/.openclaw/hooks/cortex-memory/ is installed by file copy, so upgrading the npm package used to leave the previous handler.ts / runtime.mjs running until an operator noticed doctor's staleness warning and re-ran shieldcortex openclaw install. update now has an OpenClaw hook step that re-copies any installed copy that differs from the packaged source (refreshed / current / not installed / warn), decided by the same hookFilesStale comparator doctor's row uses. It never creates a hook that was not installed, never touches the plugin registry, and never restarts the gateway — the gateway imports the handler once at start-up, so the step says a restart is what loads the new files. The refresh is all-or-nothing: the complete file set is staged in ~/.openclaw/.shieldcortex-hook-staging-* (outside hooks/, which is the only directory OpenClaw enumerates hooks from), fsynced, verified byte-for-byte against the package, and published through the swap below; the previous set goes to <configRoot>/backups/cortex-memory-preupdate-*. A failure before the first rename(2) leaves the working set exactly where it was; a failure BETWEEN the two renames leaves the host with no installed hook, and that is reported rather than repaired (see below). The installer's first-install copy is unchanged — it writes into a directory nothing is loading yet, so it has no window to lose.
  • #576 update refreshes the installed Hermes plugin copy. ~/.hermes/plugins/shieldcortex is a file copy too; after 5.1.0 → 5.2.0 its __init__.py differed and shadow.py was missing entirely, so the gateway kept running the old pre_tool_call gate. The new Hermes plugin step refreshes only the copy Hermes' own discovery (#569) says each root loads, and only when that scan is complete and unambiguous — an undetermined scan, an unreadable root, shadowing copies, a symlink in any discovered copy, or a project plugin in play writes nothing and points at shieldcortex doctor --fix-hermes-plugin-copies / shieldcortex hermes install. New bytes are staged OUTSIDE the plugins root (a staged plugin.yaml under plugins/ would itself be a #569 shadowing copy) and swapped in; the previous copy is moved to <hermesHome>/backups/, never deleted, and restored if the swap fails. The gateway is never restarted — the step says it must be.
  • #576 doctor: a Hermes plugin freshness row. Hermes plugin WARNs when the copy Hermes loads differs from the packaged source, naming the files and the fix (shieldcortex hermes install + a gateway restart), mirroring the OpenClaw hook row. It never FAILs — it is WARN only, so exit 0 unless --strict, which escalates every ⚠️ by design — and it stays INFO wherever the Hermes plugin copies row already owns the remedy (no discovery, an unreadable root, more than one copy in play). update and doctor decide staleness with the same function, over exactly the file set hermes install copies.
  • #574 #576 a crash mid-swap is REPORTED, never repaired. Replacing an installed directory is two rename(2) calls — target → backups/, then staged → target — and Node has no atomic directory exchange to do it in one (no renameat2(RENAME_EXCHANGE) binding). A SIGKILL, a power cut or an OOM between them leaves the host with NO installed copy. Earlier drafts of this work tried to finish that swap on the next run — first from an on-disk journal, then from "the target is missing and a backups/<name>-preupdate-* exists for this root". Neither is sound: a journal names paths a recovery then moves, and a backup directory is also exactly what a SUCCESSFUL refresh leaves behind, so healing from one reinstalls an integration the operator has just uninstalled (and a planted empty backups/shieldcortex-preupdate-x installs it on a host that never had it). There is no healing. update refreshes copies that EXIST and are stale, and never creates one that is absent — the publication routine has no install branch at all, so an absent target refuses rather than being written into, and both refreshers re-ask "is it still installed, is it still stale" AFTER they hold the root's lock (discovery runs before the lock, so everything it decided is a claim about a tree the lock does not yet protect: two refreshers interleaved could otherwise have one recreate what the other had just left absent). A swap that fails after the first rename says so at that moment, naming the backup it left and shieldcortex hermes install / shieldcortex openclaw install to put the packaged set back. A process KILLED between the two renames reaches no reporting at all, so the same three facts — the target, the exact backup path, the installer command — are written to stderr with fs.writeSync(2, …) BEFORE the displacing rename; that line is the only thing that survives a SIGKILL, and it is proved by a spawned child that kills itself the instant the first rename returns. Backups are never read, moved or deleted. Doctor has no interrupted-refresh row, because with no stored marker it could not tell that state from a deliberate uninstall. A directory that merely exists is still not an install, and a target that fails byte-verification is still refreshed rather than skipped.
  • #574 #576 the whole write path is preflighted for symlinks. The round-1 preflight walked the CONTENTS of every discovered plugin directory (the #569 question) and never lstat'd the directories being written THROUGH, so a symlinked <hermesHome>/backups, a symlinked plugins root or a symlinked HERMES_HOME was followed. Every component of every write path — the plugins root and its ancestors up to the Hermes tree, the target, backups/ and the staging parent — is now checked before the first write, and staging and backups are proved to be outside EVERY root the probe returned rather than just the one being refreshed. The OpenClaw side checks hooks/, the hook directory and backups/ the same way. Every refresh publication goes through one call, so no path can skip the preflight. A link, or a component that cannot be read, writes nothing. The --fix-hermes-plugin-copies repair semantics are unchanged.
  • #574 #576 staged bytes and backup names are durable before they are reachable. Every staged file is fsynced, then every staged directory bottom-up, before the first rename makes the tree reachable; the backup's newly created ancestry (the reservation, backups/ and the integration root) is flushed immediately before the target is displaced, so the rename that empties plugins/ cannot reach the medium ahead of the name that will hold the old copy; both parent directories are flushed after each rename. A file that will not flush refuses the publication outright — nothing has been written yet, so refusing costs a warning while publishing bytes that may not survive a power cut costs the gate. A platform that cannot flush a directory fd at all (Windows, some filesystems: EINVAL, ENOTSUP, EISDIR) is reported in the step's detail and carried on with; a device that is asked and refuses (EIO, ENOSPC) is a real failure — fatal before the first rename, with nothing moved. AFTER a rename there is nothing left to abort, so both parent directories are still attempted (neither is skipped because the other refused) and the refusal is reported as refreshed, durability not confirmed: a degraded refresh, counted as attention and a non-zero exit, not as a clean one.
  • #574 #576 one writer per integration root, and nothing reclaims a lock. <root>/.shieldcortex-update.lock (O_CREAT|O_EXCL|O_NOFOLLOW, holding the pid, a timestamp and a random token) is taken for EVERY root a command may write in, before its first write, and held across all of that root's writes — by update's two refresh steps and by shieldcortex hermes install / shieldcortex openclaw install, including the OpenClaw installer's hooks/ creation, openclaw.json cleanup, plugin install and gateway restart. Every named writer participates: update's two refresh steps, its OpenClaw plugin step (which deletes the legacy extensions/shieldcortex-realtime directory and then hands the native installer the same root — previously unlocked, so update could delete an extension openclaw install was copying into), shieldcortex hermes install / shieldcortex openclaw install including the OpenClaw installer's hooks/ creation, openclaw.json cleanup, plugin install and gateway restart, and doctor --fix-hermes-plugin-copies, which now plans twice — a first pass that writes nothing and only names the roots it would move out of plus the root its backups/ is in, then the locks (taken in sorted order so two doctors cannot deadlock), then the plan REBUILT under those locks, and only that plan executed. A busy root is skipped whole: it is named, nothing at all is written in it, the other roots carry on where that is safe (doctor's repair is one plan, so a busy lock there moves nothing anywhere), and the command exits non-zero. update exits non-zero on any host-integration work it did not finish — a busy root, a copy it could not publish, one that vanished mid-swap, a durability the device refused — and calls that run INCOMPLETE rather than FAILED, since the upgrade itself landed; hermes install and openclaw install exit 1 when any root was busy or refused, rather than only when every one of them was. Nothing deletes an existing lock: an earlier draft cleared one whose pid was dead and whose stamp was over ten minutes old, which races two contenders into deleting each other's live lock, and the ten-minute promise was false anyway (a kill between the exclusive create and the write leaves a record no age check clears). The refusal names the file, reports the pid and time it holds, and says to remove it only once you know nothing is running. release() deletes only the file still carrying its own token.
  • #576 a Hermes refresh writes each root under that root's own lock. Hermes discovers several plugin roots at once — the default home and every profile under it — so one HERMES_HOME=…/profiles/work refresh sees all of them. Every target is now published under the lock of the root that OWNS it and displaced into that root's own backups/. Previously a profile refresh rewrote the default root's plugin while hermes install held the default root's lock, and a default-home run could move a profile's plugin into the wrong tree, where the restore could not find it again.
  • #574 #576 the hook and plugin install paths never write through a symlinked destination. Both installers overlay the packaged set file by file, and mkdirSync/copyFileSync follow a link at the destination — so a symlink at plugins/shieldcortex/shadow.py, at hooks/cortex-memory/runtime.mjs, or at the plugin directory itself made an install truncate a file elsewhere on the box. Every leaf about to be written is lstated and a link refuses that install, as do the path components between the integration root and the destination. On the OpenClaw side the check covers everything the hook install touches BEFORE its first mkdir, copy or rm in a config root — hooks/, hooks/internal/, the destination hooks/cortex-memory/, and the legacy layouts hooks/shieldcortex/ and hooks/internal/cortex-memory/ that the install DELETES — because a linked hooks/ made the installer overwrite an external handler.ts and remove an external shieldcortex/ while reporting success. A link, or a component that cannot be read, drops that config root whole: nothing written, nothing deleted, exit 1. A symlinked ~/.hermes / ~/.openclaw / profile root is refused by the lock's own preflight, before even the lock file is created. Scope, stated plainly: what is audited for linked destinations in this release is the refresh path (both integrations) and the hook/plugin install paths named above. The OpenClaw installer's OTHER copy paths — the extensions/ directory and the openclaw.json snapshot — are NOT audited, and a follow-up issue will be filed for them.
  • #574 #576 update resolves the host homes through the shared resolvers. runUpdate handed os.homedir() to both host-refresh steps, which bypassed OpenClaw's OPENCLAW_HOME / SUDO_USER resolution: with HOME=A and OPENCLAW_HOME=B it refreshed A while doctor kept warning about B, and under sudo it refreshed root's tree. The steps now take no positional home — the OpenClaw one resolves through the same function defaultHookDestDir() uses, and the Hermes one through a shared SUDO_USER-aware resolver (HERMES_HOME still travels unexpanded to Hermes' own discovery, per #569 r5).
  • #536 doctor no longer says Guard off while the Claude Code hook is denying. The HOSTS headline was hardcoded Guard off, and liveGating only looked at the OpenClaw plugin flags, so a host with signed Enforce, OpenClaw enforce: false, and a live Claude Code PreToolUse hook still printed Guard off, WARNed the policy lock ("this is not unprotected"), and WARNed NOTIFY. Doctor now treats a wired Claude Code ShieldCortex PreToolUse hook plus signed Enforce as a live plane: the headline becomes Guard on or Guard mixed, an unlocked policy lock FAILs, and a missing denial-capable webhook FAILs. Leftover signed Enforce with the plugin off and Claude unwired stays WARN (the 5.0.6 leftover-Enforce case). Does not enable Guard. Does not prescribe --action-guard-enable.
  • #577 a help flag no longer runs the command. shieldcortex update --help performed the whole upgrade on 5.1.0 (global npm install, re-exec, hook rewrites, plugin reinstall) and printed no usage — the dispatcher handed update straight to runUpdate(), which only ever looked for --force/-f/--verbose deep inside itself, so --help, -h and every typo meant "upgrade". update now parses its arguments once at the entry point: --help/-h prints usage listing every flag and env switch it honours and exits 0 before the registry lookup or any write, an unknown flag or extra positional prints the error plus usage on stderr and exits 2, and the parsed options are passed into runUpdate instead of being re-read from process.argv mid-flow. The same gate (src/cli/help-gate.ts) now fronts repair, migrate, uninstall, hermes <verb>, openclaw <verb>, memories <verb> (including prune and migrate-legacy), sessions prune and vacuum/compact, every one of which previously mutated, opened the database or prompted instead of printing usage. The shared npx-staleness preamble, which shells out to npm ls -g before every subcommand, is skipped on a help request too, so update --help now creates no files at all. Two follow-ups from review: the strict allow-lists cover the whole call graph's arguments rather than each command's own function body — --allow-conversation-access (#226), read by the plugin reconcile several modules below update and repair, is now accepted, documented and threaded through as a parsed option instead of a deep process.argv read — and the help detector distinguishes the help verb from an option's VALUE, so openclaw skill install --agent help, memories prune --project help and memories migrate-legacy --source help do the work they name instead of silently printing usage. Invalid arguments are also rejected before the staleness preamble runs, so every bad-argument path exits 2 without spawning a process or writing a byte under $HOME.
  • #573 the repair log follows the database it describes, and is settled before the repair runs: memories repair-project-keys --execute wrote its per-rewrite JSON log to os.homedir()/.shieldcortex/logs/ no matter which database it had repaired, while the safety backup beside it correctly followed the DB. Every run against a throwaway database — a test, a probe, --db <tmp> — therefore left a permanent file in the operator's real home describing a database that no longer existed; all 3,508 on the reporting host were of that kind, and not one recorded a real repair. The log is now written to <db-dir>/logs/, which for the default DB is exactly the documented ~/.shieldcortex/logs/. That destination is also resolved, created and proved writable before the database is modified: a regular file or a symlink at <db-dir>/logs used to throw with the rewrite already committed and no log anywhere, and a logs pointed at the realtime audit directory made the repair write into the audit plane. A refusal now means the repair never ran and nothing changed. The destination is realpath(<db-dir>)/logs, resolved in full, so no symlink in any ancestor decides where the record lands — a HOME/db-link pointing at .shieldcortex/audit used to put the record inside the audit plane, and a path check that picked its trusted root by basename never looked above the link. The record itself is created with O_CREAT|O_EXCL, so a preplanted symlink carrying its name is never followed — that shape previously overwrote a realtime audit file with repair JSON. If the write fails after the commit (disk full), the error names the row count, the database and the backup path, and exits non-zero; it is never silent.
  • #573 doctor's DISK row reports what it measured, and prescribes only what a measurement supports: the breakdown splits the logs lump into audit / repair / other and reports how much of the database is free pages (freelist_count × page_size). The live incident read DB 54.2 MB · logs 41.4 MB and offered only memory deletion. The row no longer tries to work out which ROWS fill the database: every version that did — a file-size heuristic, then dbstat page sums over memories, its indexes and an FTS5 index's shadow tables — produced a new confidently wrong "delete your memories", most recently on a database that was 95% already-free pages and on one whose FTS5 index declared content=memories_backup. It now names a command in exactly two cases: shieldcortex logs prune when the repair logs are at least half the measured footprint, and shieldcortex vacuum when free pages are at least 20% of the database's pages (below that the command rewrites the whole file to reclaim a rounding error). An audit-dominated directory is named as such, with #579, and no command. Anything else reports the sizes and asks you to inspect before removing anything. memories prune, memories dedupe and sessions prune are no longer reachable from this row at all, and the old blanket "these five directories are safe to rotate or clear" instruction — which covered state/ and quarantine/, and was offered to a directory holding one state file and no database — is gone.

Not in this release: retention for the realtime audit logs under ~/.shieldcortex/audit/. Those are an unread projector queue with concurrent writers and stop-hook recovery reading them, so bounding them safely is separate work — tracked in #579. Nothing here compresses, deletes or rewrites an audit file, and doctor says so plainly when the audit plane is the largest consumer.

What's Changed

  • fix(cli): #577 a help flag never runs the command by @TARSDrakon in #580
  • fix(doctor): #536 HOSTS headline and liveGating include the Claude Code hook by @TARSDrakon in #581
  • fix(update): #574 #576 refresh the file-copied host integrations on update by @TARSDrakon in #582
  • fix(logs): #573 repair logs beside their database, manual retention, honest DISK advice by @TARSDrakon in #584
  • chore(release): v5.2.1 by @TARSDrakon in #585
  • fix(hook): #517 (3) denials.jsonl carries rule → matched-span evidence, sanitised by @jarvis-drakon in #586
  • fix(hook): #587 denial-evidence sink nits — pipe means an operator, cap before trim, SAFE_SIGNALS membership pinned, line clamped by @jarvis-drakon in #589
  • fix(guard): #505 user-home persistence set as sensitive write targets by @jarvis-drakon in #578
  • chore(guard): #590 ADR-002 §5B — pre-register the acceptance bars in the fixture repository; regression families and prompt burden reported separately by @jarvis-drakon in #591
  • test(adr-002): #592 fix @ts-expect-error placement in preregistration test by @jarvis-drakon in #593
  • fix(guard): #594 ADR-002 §5C — sessions_spawn contract judged per host version; quiet-dispatch fixture pinned; unknown-field reporting preserved by @jarvis-drakon in #595
  • test(guard): #596 sessions_spawn precision rows pinned to the measured 2026.8.1 key set by @jarvis-drakon in #597
  • fix(openclaw): #600 the approval card no longer names "Jarvis" by @TARSDrakon in #601
  • chore(release): v5.2.1 — include the guard PRs merged since #585 by @TARSDrakon in #603

Full Changelog: v5.2.0...v5.2.1