Repository navigation
v5.2.1
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 updatenow 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.lockis never cleared automatically — remove it only after confirming no update/install is running.--helpnever runs a command any more (#577). On 5.1.0/5.2.0,shieldcortex update --helpperformed the upgrade.shieldcortex doctor's DISK row no longer recommends deleting memories or sessions (#573). It reports measured sizes and suggestslogs pruneorvacuumonly when its own measurement supports it. Repair logs now live beside the database they describe; old ones are removed only when you runshieldcortex 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_spawncontract is judged per host version; routine dispatch is quiet, a real schema move is loud. The reviewed spawn contract intool-input-schema.tsis now a table of measured revisions — 2026.8.1 (28 fields) and 2026.9.6 (33 fields), each measured from that release's shippeddistschema; the newer declaresexpectsCompletionMessage,completionTarget,projectId,projectGitUrlandplacementin 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 listedcategory(an internal gateway mapping, never a declared input) andtimeoutSeconds(another tool's field) and lackedgroup, 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'spackage.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, orunknown-host(no usable version: the union of measured revisions, never a guess). The drift observation and the one-lineCONTRACT DRIFTwarning 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 setsexpectsCompletionMessage) 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.jsonis the measurement record andscripts/measure-native-contract.mjs --root <openclaw install>regenerates a revision from a host's shipped schema (--checkverifies an install against the record; both.mjsand the older.jsbundle shapes are located, and theconst 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.jsonrecords 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 reportsregistered(verdicts issued),exploratory(something drifted or no record; values shown, no verdict counts toward §2.5) orunmeasured(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 bybroad-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):
assessBarsnow 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 (namedrun:family-shortfall:<family>:<observed>/<expected>) demotes an otherwise-registeredrun toexploratorywith every reason named in the report (frozen denominators were being read off the registry, so a shrunk or mismatched executed set could still rendermet/not-met). Separately, a malformed or partially-deleted pre-registration record (barsmissing, any one of the four bars missing, orbars/families/notRunof the wrong type) now renders its EXPLORATORY section through a shape-safe bars fallback instead of throwing aTypeErrorout ofassessArm. 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,--checkexit 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%.
- #590 review (r1):
- #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 onelegacygroup. 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,--executeto 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>].jsonand nothing else, so an operator'sproject-key-repair-config.jsonsitting 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 theunlink. 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 exactlypruneand 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 rejectedSHIELDCORTEX_REPAIR_LOG_KEEPis a warning and exits 0.logsis 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>) andauthorized_keyswherever it sits as a path segment — before, only the literal~/.sshspelling gated, so a Write of an attacker key to/home/<user>/.ssh/authorized_keyswas allowed while the same write to/etc/sudoers.d/was caught. Sametouch-sensitive-pathsignal and tier as before. (2) A login/interactive shell startup file (.bashrc,.zshrc,.profile,.bash_profile, the zsh set, fish'sconfig.fish) is gated on the target: a Write/Edit to one, or a shell write shape onto one (a redirect including noclobber>|;teewith any option run or earlier operand —-a,--append,--,/tmp/log;sed -i/--in-placeincluding suffix forms and combined short clusters such as-Ei;cp/mv/installwith it as the last operand), requires approval with the newmodify-shell-startupsignal — 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 knowntee-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 +~/.bashrcgates); 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 samedisable-action-guardpath rule and the same read-only carve-out;.claude/settings.jsonon 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;sudoalready gates), and other agents' config files. - #517 (3) the denial record carries its evidence. Every
~/.shieldcortex/denials.jsonlrow the Claude Code hook writes (the pending row and the final row of an event, sameactionId) now carriesmatches: [{ signal, ... }]— the rule that fired and what it is safe to say about the command it matched, which the interactive block message already printed asrule:/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 keptverb— 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.signalis a rule name from the hook'sSAFE_SIGNALStable — 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);argcis 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-claimingpipeToShell) andsubshell($(or a backtick outside single quotes) are booleans. There is noverb,hosts,flagsor basename field at all.secret-egressandcredential-accessstill contribute nothing beyond the rule name, whatever the core sent. Provenance (source,chain, #184) is a path, and a path is input —/tmp/<token>.shis one — so none of it persists, not even a basename: the row carriesprovenanceWithheld: 'path',chainDepth(a capped count of fold-chain components) andline(an integer, clamped to 262 144 — the core never folds a longer script). The credential redactor fromdist/defence/credential-leakis 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 (surfaceunchanged, #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 ofdenials.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)
pipeno longer over-claims: it is set only when a|sits in an unquoted, unescaped run of the span — where the shell reads an operator — sogrep "a|b",grep 'a|b',grep a'|'bandgrep a\|bleave 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 exactSAFE_SIGNALSmembership (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)lineis clamped to 262 144 for symmetry withargc/chainDepth— hygiene, not privacy: a bounded integer carries no input either way. - #600 the OpenClaw approval card no longer names "Jarvis".
actionGuardLeadhardcoded 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'sagentIdis usuallymain). A regression test fails if any string literal in the interceptor names a fleet agent. - #574
updaterefreshes the installed OpenClaw hook.~/.openclaw/hooks/cortex-memory/is installed by file copy, so upgrading the npm package used to leave the previoushandler.ts/runtime.mjsrunning until an operator noticed doctor's staleness warning and re-ranshieldcortex openclaw install.updatenow 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 samehookFilesStalecomparator 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-*(outsidehooks/, 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 firstrename(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
updaterefreshes the installed Hermes plugin copy.~/.hermes/plugins/shieldcortexis a file copy too; after 5.1.0 → 5.2.0 its__init__.pydiffered andshadow.pywas missing entirely, so the gateway kept running the oldpre_tool_callgate. 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 atshieldcortex doctor --fix-hermes-plugin-copies/shieldcortex hermes install. New bytes are staged OUTSIDE the plugins root (a stagedplugin.yamlunderplugins/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 pluginWARNs when the copy Hermes loads differs from the packaged source, naming the files and the fix (shieldcortex hermes install+ a gateway restart), mirroring theOpenClaw hookrow. It never FAILs — it is WARN only, so exit 0 unless--strict, which escalates every⚠️ by design — and it stays INFO wherever theHermes plugin copiesrow already owns the remedy (no discovery, an unreadable root, more than one copy in play).updateanddoctordecide staleness with the same function, over exactly the file sethermes installcopies. - #574 #576 a crash mid-swap is REPORTED, never repaired. Replacing an installed directory is two
rename(2)calls —target → backups/, thenstaged → target— and Node has no atomic directory exchange to do it in one (norenameat2(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 abackups/<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 emptybackups/shieldcortex-preupdate-xinstalls it on a host that never had it). There is no healing.updaterefreshes 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 andshieldcortex hermes install/shieldcortex openclaw installto 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 withfs.writeSync(2, …)BEFORE the displacing rename; that line is the only thing that survives aSIGKILL, 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 symlinkedHERMES_HOMEwas 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 checkshooks/, the hook directory andbackups/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-copiesrepair 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 emptiesplugins/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 — byupdate's two refresh steps and byshieldcortex hermes install/shieldcortex openclaw install, including the OpenClaw installer'shooks/creation,openclaw.jsoncleanup, plugin install and gateway restart. Every named writer participates:update's two refresh steps, its OpenClaw plugin step (which deletes the legacyextensions/shieldcortex-realtimedirectory and then hands the native installer the same root — previously unlocked, soupdatecould delete an extensionopenclaw installwas copying into),shieldcortex hermes install/shieldcortex openclaw installincluding the OpenClaw installer'shooks/creation,openclaw.jsoncleanup, plugin install and gateway restart, anddoctor --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 itsbackups/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.updateexits 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 installandopenclaw installexit 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/workrefresh sees all of them. Every target is now published under the lock of the root that OWNS it and displaced into that root's ownbackups/. Previously a profile refresh rewrote the default root's plugin whilehermes installheld 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/copyFileSyncfollow a link at the destination — so a symlink atplugins/shieldcortex/shadow.py, athooks/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 islstated 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 firstmkdir, copy orrmin a config root —hooks/,hooks/internal/, the destinationhooks/cortex-memory/, and the legacy layoutshooks/shieldcortex/andhooks/internal/cortex-memory/that the install DELETES — because a linkedhooks/made the installer overwrite an externalhandler.tsand remove an externalshieldcortex/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 — theextensions/directory and theopenclaw.jsonsnapshot — are NOT audited, and a follow-up issue will be filed for them. - #574 #576
updateresolves the host homes through the shared resolvers.runUpdatehandedos.homedir()to both host-refresh steps, which bypassed OpenClaw'sOPENCLAW_HOME/SUDO_USERresolution: withHOME=AandOPENCLAW_HOME=Bit refreshed A while doctor kept warning about B, and undersudoit refreshed root's tree. The steps now take no positional home — the OpenClaw one resolves through the same functiondefaultHookDestDir()uses, and the Hermes one through a sharedSUDO_USER-aware resolver (HERMES_HOMEstill 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, andliveGatingonly looked at the OpenClaw plugin flags, so a host with signed Enforce, OpenClawenforce: 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 becomesGuard onorGuard 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 --helpperformed the whole upgrade on 5.1.0 (global npm install, re-exec, hook rewrites, plugin reinstall) and printed no usage — the dispatcher handedupdatestraight torunUpdate(), which only ever looked for--force/-f/--verbosedeep inside itself, so--help,-hand every typo meant "upgrade".updatenow parses its arguments once at the entry point:--help/-hprints 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 intorunUpdateinstead of being re-read fromprocess.argvmid-flow. The same gate (src/cli/help-gate.ts) now frontsrepair,migrate,uninstall,hermes <verb>,openclaw <verb>,memories <verb>(includingpruneandmigrate-legacy),sessions pruneandvacuum/compact, every one of which previously mutated, opened the database or prompted instead of printing usage. The shared npx-staleness preamble, which shells out tonpm ls -gbefore every subcommand, is skipped on a help request too, soupdate --helpnow 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 belowupdateandrepair, is now accepted, documented and threaded through as a parsed option instead of a deepprocess.argvread — and the help detector distinguishes the help verb from an option's VALUE, soopenclaw skill install --agent help,memories prune --project helpandmemories migrate-legacy --source helpdo 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 --executewrote its per-rewrite JSON log toos.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>/logsused to throw with the rewrite already committed and no log anywhere, and alogspointed 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 isrealpath(<db-dir>)/logs, resolved in full, so no symlink in any ancestor decides where the record lands — aHOME/db-linkpointing at.shieldcortex/auditused 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 withO_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
logslump intoaudit/repair/otherand reports how much of the database is free pages (freelist_count × page_size). The live incident readDB 54.2 MB · logs 41.4 MBand 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, thendbstatpage sums overmemories, 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 declaredcontent=memories_backup. It now names a command in exactly two cases:shieldcortex logs prunewhen the repair logs are at least half the measured footprint, andshieldcortex vacuumwhen 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 dedupeandsessions pruneare no longer reachable from this row at all, and the old blanket "these five directories are safe to rotate or clear" instruction — which coveredstate/andquarantine/, 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