@drakon-systems/shieldcortex-realtime 5.0.4 → 5.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { createHash, randomUUID } from "node:crypto";
28
28
  import fs from "node:fs/promises";
29
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
29
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
30
30
  import path from "node:path";
31
31
  import { homedir, hostname } from "node:os";
32
32
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -60,6 +60,11 @@ type OpenClawRuntime = {
60
60
  // `before_tool_call` interceptor (runDefencePipeline) and realtime scanning
61
61
  // (scanToolResponse) load from the SAME module via getDefenceModule().
62
62
  type DefenceModule = {
63
+ /** #501 policy lock. Optional exactly like every other member: an older
64
+ * installed dist does not have it, and its absence is covered by the inline
65
+ * probe in {@link inlinePolicyLockPresent} rather than by failing open. */
66
+ readPolicyLock?: (options?: { audit?: boolean; warn?: boolean }) => unknown;
67
+ applyPolicyLock?: (raw: Record<string, unknown>, state: unknown) => Record<string, unknown>;
63
68
  runDefencePipeline?: (...args: any[]) => any;
64
69
  scanToolResponse?: (
65
70
  toolName: string,
@@ -374,7 +379,7 @@ export function __setRuntimeForTest(runtime: OpenClawRuntime | null): void {
374
379
  if (runtime) runtimePromise = null;
375
380
  }
376
381
  export function __resetConfigStateForTest(): void {
377
- _config = null;
382
+ _mergedConfig = null;
378
383
  _configOverride = null;
379
384
  _lastShieldConfigRef = null;
380
385
  _provenanceUndeclared = 0;
@@ -1428,7 +1433,12 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
1428
1433
  },
1429
1434
  };
1430
1435
 
1431
- let _config: SCConfig | null = null;
1436
+ /**
1437
+ * The cached MERGE — shield config + openclaw.json entry, with the policy lock
1438
+ * deliberately NOT applied. `loadConfig` re-applies the lock to this on every
1439
+ * call; see the comment there for why the two halves cache differently.
1440
+ */
1441
+ let _mergedConfig: SCConfig | null = null;
1432
1442
  // Identity of the shield config we last merged from. The runtime's
1433
1443
  // loadShieldConfig() returns the same parsed object until the file's mtime
1434
1444
  // advances; using reference equality lets us re-merge precisely when the
@@ -1796,7 +1806,7 @@ function applyPluginConfigOverride(api: PluginApi): void {
1796
1806
  if (Object.keys(pluginConfig).length === 0) return;
1797
1807
  _configOverride = mergeConfigs(_configOverride ?? {}, pluginConfig);
1798
1808
  // Override changed — invalidate so loadConfig() re-merges with new override.
1799
- _config = null;
1809
+ _mergedConfig = null;
1800
1810
  _lastShieldConfigRef = null;
1801
1811
  }
1802
1812
 
@@ -1885,6 +1895,219 @@ function noteL2Degraded(reason: string): void {
1885
1895
  );
1886
1896
  }
1887
1897
 
1898
+ // ==================== POLICY LOCK (#501) ====================
1899
+
1900
+ /**
1901
+ * The canonical protected root, duplicated as a literal — and it has to be.
1902
+ *
1903
+ * This constant backs the INLINE PROBE, whose whole job is to be right when the
1904
+ * `shieldcortex/defence` module cannot be resolved. Importing it from the module
1905
+ * the probe exists to survive the absence of would defeat the probe. Same
1906
+ * duplication, same reason, as the script-source resolver (#160): a real build
1907
+ * boundary, stated at the copy, held in step by the enforcement-surface parity
1908
+ * test rather than by hope.
1909
+ */
1910
+ const INLINE_PROTECTED_ROOT = '/etc/shieldcortex';
1911
+ const INLINE_PROTECTED_ROOT_POINTER = '/etc/shieldcortex.conf';
1912
+ const INLINE_POLICY_LOCK_FILENAME = 'policy.json';
1913
+
1914
+ /**
1915
+ * The posture an unverifiable-or-unreadable lock forces. Mirrors
1916
+ * STRICT_FAILCLOSED_POSTURE, key for key — including `reviewedScripts`
1917
+ * (#522, GPT-6 round-6, item 1), so the two inline copies and the dist
1918
+ * constant can never disagree about which keys a fail-closed posture pins.
1919
+ * On this surface a module that proved unusable for the policy READ is
1920
+ * already distrusted for VERDICTS too (#522 r7 FIND-3), so the pin is parity
1921
+ * rather than a reachable behavioural change today; the enforcement-surface
1922
+ * parity test holds it in step.
1923
+ */
1924
+ const INLINE_STRICT_GUARD_POSTURE = {
1925
+ enabled: true,
1926
+ enforce: true,
1927
+ autoApprove: [] as string[],
1928
+ broker: { enabled: false },
1929
+ reviewedScripts: [] as unknown[],
1930
+ };
1931
+
1932
+ /**
1933
+ * The pointed-to protected root, judged inline — mirrors `resolvePointerRoot`.
1934
+ *
1935
+ * Deliberately WITHOUT the full ancestor walk `verifyProtectedFile` runs: this
1936
+ * probe's only output is "is a lock present", and a `true` can only ever raise
1937
+ * the posture. The file-level rules (root-owned, a real regular file, not
1938
+ * group- or other-writable) are the ones that stop an agent-writable pointer
1939
+ * from being read at all, and those are cheap enough to state here.
1940
+ */
1941
+ function inlinePointerRoot(): string | null {
1942
+ try {
1943
+ const st = lstatSync(INLINE_PROTECTED_ROOT_POINTER);
1944
+ if (st.isSymbolicLink() || !st.isFile()) return null;
1945
+ if (st.uid !== 0 || (st.mode & 0o022) !== 0) return null;
1946
+ for (const rawLine of readFileSync(INLINE_PROTECTED_ROOT_POINTER, 'utf-8').split(/\r?\n/)) {
1947
+ const line = rawLine.trim();
1948
+ if (!line || line.startsWith('#')) continue;
1949
+ const eq = line.indexOf('=');
1950
+ if (eq === -1 || line.slice(0, eq).trim() !== 'root') continue;
1951
+ const value = line.slice(eq + 1).trim();
1952
+ return value && path.isAbsolute(value) ? value : null;
1953
+ }
1954
+ return null;
1955
+ } catch {
1956
+ return null;
1957
+ }
1958
+ }
1959
+
1960
+ /**
1961
+ * Does a policy lock FILE exist, judged without resolving anything from dist?
1962
+ *
1963
+ * Mirrors `resolveProtectedRoot`'s ordering exactly, including the rule that
1964
+ * keeps the test override non-loosening: BOTH production roots — the canonical
1965
+ * one and the root-owned pointer — are resolved first, and the environment
1966
+ * variable is only consulted on a host where neither answers. So the probe can
1967
+ * never be pointed away from a real lock.
1968
+ *
1969
+ * The pointer half is the #501 review's BLOCK-2: without it, a pointer host
1970
+ * with an unresolvable defence module probed `false` and the plugin failed
1971
+ * OPEN — precisely the "break the install" case this probe exists to close.
1972
+ */
1973
+ function inlinePolicyLockPresent(): boolean {
1974
+ if (process.platform === 'win32') return false;
1975
+ try {
1976
+ const canonicalLock = path.join(INLINE_PROTECTED_ROOT, INLINE_POLICY_LOCK_FILENAME);
1977
+ const canonicalOccupied = existsSync(canonicalLock) || existsSync(INLINE_PROTECTED_ROOT);
1978
+ const pointed = inlinePointerRoot();
1979
+ let root = pointed ?? INLINE_PROTECTED_ROOT;
1980
+ if (!canonicalOccupied && pointed === null) {
1981
+ const override = process.env.SHIELDCORTEX_PROTECTED_ROOT?.trim();
1982
+ if (override && path.isAbsolute(override)) root = override;
1983
+ }
1984
+ // Presence is judged the way the reader judges it — `lstat`, so an ENTRY of
1985
+ // any kind counts, a dangling symlink included. `existsSync` follows the
1986
+ // link and reports "no lock" for exactly the entry the reader reports as
1987
+ // present-and-unverifiable (review R3-2). A `true` can only raise the
1988
+ // posture, so present is the safe direction.
1989
+ lstatSync(path.join(root, INLINE_POLICY_LOCK_FILENAME));
1990
+ return true;
1991
+ } catch {
1992
+ return false;
1993
+ }
1994
+ }
1995
+
1996
+ function withGuardPosture(config: SCConfig, guard: Record<string, unknown>): SCConfig {
1997
+ return {
1998
+ ...config,
1999
+ interceptor: {
2000
+ ...(config.interceptor ?? {}),
2001
+ // #522 r7 FIND-2: the interceptor is only the CARRIER of the gate the
2002
+ // lock pins — `initInterceptor` returns null outright on
2003
+ // `enabled:false`, and `before_tool_call` reads that as no gate at all.
2004
+ // Leaving an unsigned same-UID `interceptor.enabled:false` to stand
2005
+ // while the lock says `actionGuard.enabled:true` made the precedence
2006
+ // rule decorative.
2007
+ // #522 G3: `failurePolicy.high` is the same story one layer down. It is
2008
+ // the "cannot obtain a verdict" policy, and a degraded guard is exactly
2009
+ // that — so on a broken install `handleGuardUnavailable` asked the
2010
+ // unsigned `openclaw.json` whether to deny the DANGEROUS tier, and
2011
+ // `failurePolicy.high:"allow"` there let it through while the lock said
2012
+ // the guard was on and enforcing. With the lock's guard enabled, the
2013
+ // lock owns that answer too. `severityActions` and the other severities
2014
+ // are left alone: this is the one key that decides the degraded tier.
2015
+ ...(guard.enabled === true
2016
+ ? {
2017
+ enabled: true,
2018
+ failurePolicy: { ...(config.interceptor?.failurePolicy ?? {}), high: 'deny' as const },
2019
+ }
2020
+ : {}),
2021
+ actionGuard: { ...(config.interceptor?.actionGuard ?? {}), ...guard } as NonNullable<InterceptorUserConfig['actionGuard']>,
2022
+ },
2023
+ };
2024
+ }
2025
+
2026
+ /**
2027
+ * Apply the OS-owned policy lock to the plugin's EFFECTIVE Action Guard config.
2028
+ *
2029
+ * Applied AFTER `mergeConfigs`, not before, and that ordering is the whole
2030
+ * point: the `openclaw.json` plugin entry deep-merges OVER the shield config, so
2031
+ * applying the lock to the shield config alone would leave a plugin entry saying
2032
+ * `actionGuard.enabled: false` as the last word — which is precisely the
2033
+ * unsigned, same-UID file the lock exists to stop being authoritative. The
2034
+ * `src/setup/openclaw-plugin-guard-sync.ts` mirror writes into that same entry,
2035
+ * so it too is now out-ranked by the lock rather than able to out-rank it.
2036
+ *
2037
+ * The precedence rules themselves come from dist (`applyPolicyLock`) so there is
2038
+ * exactly ONE implementation of them across both enforcement surfaces.
2039
+ */
2040
+ async function applyPolicyLockToPluginConfig(config: SCConfig): Promise<SCConfig> {
2041
+ const mod = await getDefenceModule().catch(() => null);
2042
+ const failClosed = () => {
2043
+ // #522 r7 FIND-3: this module just proved unusable for the policy READ
2044
+ // while a lock is on disk — absent, unloadable, throwing, or lying. Its
2045
+ // VERDICTS cannot then be what enforces that policy: a substituted
2046
+ // module whose `readPolicyLock` answers 'absent' and whose
2047
+ // `evaluateToolCall` answers 'allow' reported `enforce` and gated
2048
+ // nothing at all.
2049
+ _defenceModuleDistrusted = true;
2050
+ if (!_policyLockDegradedLogged) {
2051
+ _policyLockDegradedLogged = true;
2052
+ console.warn(
2053
+ '[shieldcortex] ⚠️ a policy lock is present but the ShieldCortex defence module could not be ' +
2054
+ 'loaded to read it — enforcing the strict fail-closed posture (Action Guard on + enforcing, ' +
2055
+ 'no auto-approve, broker off). Run `shieldcortex repair` to restore the install.',
2056
+ );
2057
+ }
2058
+ return withGuardPosture(config, INLINE_STRICT_GUARD_POSTURE);
2059
+ };
2060
+
2061
+ if (!mod || typeof mod.readPolicyLock !== 'function' || typeof mod.applyPolicyLock !== 'function') {
2062
+ if (!inlinePolicyLockPresent()) _defenceModuleDistrusted = false;
2063
+ // A missing reader on a host with NO lock is today's behaviour: the plugin
2064
+ // runs on the config it has. A missing reader on a host WITH a lock would
2065
+ // make "break the install" the bypass, so that one fails closed.
2066
+ return inlinePolicyLockPresent() ? failClosed() : config;
2067
+ }
2068
+ try {
2069
+ // `audit` off: the SQLite audit logger belongs to the src-side reader, not
2070
+ // to a plugin load. Shaped as a raw config view so the ONE precedence
2071
+ // implementation in dist does the work.
2072
+ const view = { actionGuard: { ...(config.interceptor?.actionGuard ?? {}) } } as Record<string, unknown>;
2073
+ const verdict = mod.readPolicyLock({ audit: false }) as { status?: string } | undefined;
2074
+ // #501 review BLOCK-1, mirrored from the hook: a reader that LOADS and lies
2075
+ // is treated exactly like one that could not be loaded at all. The hook's
2076
+ // copy of this closed a real `SHIELDCORTEX_DIST_ROOT` bypass; this copy
2077
+ // exists so the two surfaces answer a substituted reader identically, which
2078
+ // is the #160 lesson applied to the lock.
2079
+ const status = verdict?.status;
2080
+ if ((status === undefined || status === 'absent' || status === 'unsupported') && inlinePolicyLockPresent()) {
2081
+ return failClosed();
2082
+ }
2083
+ const locked = mod.applyPolicyLock(view, verdict);
2084
+ // The reader answered, and it agreed with the inline probe. Trust
2085
+ // restored (a `repair` mid-process must not stay latched into the
2086
+ // degraded path).
2087
+ _defenceModuleDistrusted = false;
2088
+ const guard = locked.actionGuard;
2089
+ if (!guard || typeof guard !== 'object' || Array.isArray(guard)) return config;
2090
+ return withGuardPosture(config, guard as Record<string, unknown>);
2091
+ } catch {
2092
+ // A reader that throws is treated exactly like a lock that cannot be
2093
+ // verified, for the same reason: we cannot tell what the operator pinned.
2094
+ if (!inlinePolicyLockPresent()) {
2095
+ _defenceModuleDistrusted = false;
2096
+ return config;
2097
+ }
2098
+ return failClosed();
2099
+ }
2100
+ }
2101
+
2102
+ /** #522 r7 FIND-3: the defence module disagreed with the on-disk lock (or
2103
+ * could not be loaded to read it) while a lock is present. Re-evaluated on
2104
+ * every config read, so `shieldcortex repair` clears it without a gateway
2105
+ * restart. */
2106
+ let _defenceModuleDistrusted = false;
2107
+
2108
+ /** One warning per plugin load, like every other degrade note in this file. */
2109
+ let _policyLockDegradedLogged = false;
2110
+
1888
2111
  async function loadConfig(): Promise<SCConfig> {
1889
2112
  let shieldConfigRaw: unknown;
1890
2113
  try {
@@ -1901,20 +2124,36 @@ async function loadConfig(): Promise<SCConfig> {
1901
2124
  );
1902
2125
  }
1903
2126
  // A fresh object every time: `_configOverride` is module state and callers
1904
- // must not be handed something they could mutate.
1905
- return mergeConfigs({}, _configOverride ?? {});
2127
+ // must not be handed something they could mutate. The policy lock still
2128
+ // applies — a shield config we could not read is exactly when the plugin
2129
+ // entry is the only thing talking, and the entry is the unsigned file.
2130
+ return applyPolicyLockToPluginConfig(mergeConfigs({}, _configOverride ?? {}));
1906
2131
  }
1907
2132
  // A load that succeeds after a failure re-arms the warning, so a SECOND
1908
2133
  // outage is reported rather than swallowed by the first one's flag. Set
1909
2134
  // before the cache check: a runtime that hands back the same object every
1910
2135
  // call would otherwise take the early return and leave the flag latched.
1911
2136
  _shieldConfigLoadFailureLogged = false;
1912
- if (_config && shieldConfigRaw === _lastShieldConfigRef) return _config;
1913
- _lastShieldConfigRef = shieldConfigRaw;
1914
- // Plugin config (openclaw.json) deep-merges over the shield config file —
1915
- // see mergeConfigs() for the per-key semantics.
1916
- _config = mergeConfigs(normaliseConfig(shieldConfigRaw), _configOverride ?? {});
1917
- return _config;
2137
+ // The MERGE is cached; the LOCK is not, and that split is the #501 review's
2138
+ // SHOULD-FIX-4. `policy-lock.ts` states the rule — "no cache on the lock
2139
+ // read. Four `lstat`s per config read on an unlocked host, in exchange for an
2140
+ // operator who has just run `protect` being obeyed by the already-running
2141
+ // agent rather than at its next restart." That held for the hook (a fresh
2142
+ // process per call) and the CLI, and was quietly false here: the effective
2143
+ // config was memoised on the shield config's object IDENTITY, and writing
2144
+ // the lock does not touch `config.json`, so it was read exactly once per
2145
+ // gateway process and never again. An operator who ran `protect` on a live
2146
+ // box was told by doctor the host was locked while this gate was still off.
2147
+ if (!_mergedConfig || shieldConfigRaw !== _lastShieldConfigRef) {
2148
+ _lastShieldConfigRef = shieldConfigRaw;
2149
+ // Plugin config (openclaw.json) deep-merges over the shield config file —
2150
+ // see mergeConfigs() for the per-key semantics.
2151
+ _mergedConfig = mergeConfigs(normaliseConfig(shieldConfigRaw), _configOverride ?? {});
2152
+ }
2153
+ // Applied over BOTH, on every call, because the plugin entry is an unsigned,
2154
+ // same-UID file and must not be the last word on the Action Guard's own
2155
+ // switches — and because a lock written a second ago is still a lock.
2156
+ return applyPolicyLockToPluginConfig(_mergedConfig);
1918
2157
  }
1919
2158
 
1920
2159
  function isAutoMemoryEnabled(config: SCConfig): boolean {
@@ -3562,7 +3801,10 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
3562
3801
  .split(/\r?\n/u)
3563
3802
  .map((line) => line.trim())
3564
3803
  .filter(Boolean)
3565
- .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
3804
+ // #524: the action-guard card's button row is now `[Allow once] [Deny]`.
3805
+ // The old pattern needed the label to be exactly `Approve`/`Deny`, so the
3806
+ // new row fell through and rendered as the first line of the description.
3807
+ .filter((line) => !/^\[(?:Approve|Allow[^\]]*|Deny)\]/i.test(line));
3566
3808
  const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
3567
3809
  const detailLines = lines.slice(1);
3568
3810
  const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
@@ -3760,6 +4002,33 @@ export default {
3760
4002
  // --- Interceptor (lazy init) ---
3761
4003
  let interceptorReady: ReturnType<typeof createInterceptor> | null = null;
3762
4004
  let interceptorInitAttempted = false;
4005
+ /** #522 r7 FIND-5: the live interceptor is the DEGRADED one (WS2 fallback only). */
4006
+ let interceptorDegraded = false;
4007
+ /**
4008
+ * The build in flight for `interceptorGuardPosture`, if any (#522 r7 FIND-1).
4009
+ * `interceptorReady` is deliberately null for the duration of a rebuild, and
4010
+ * `before_tool_call` reads null as "no gate at all" — so every concurrent
4011
+ * call that took the posture-cache shortcut during that window was
4012
+ * ungated: the same full bypass F1 closed for the serial case, still open
4013
+ * for concurrent ones (23 of 24 in the reviewer's reproduction).
4014
+ */
4015
+ let interceptorBuild: Promise<ReturnType<typeof createInterceptor> | null> | null = null;
4016
+ /**
4017
+ * The Action Guard posture the live interceptor was BUILT with (#501).
4018
+ *
4019
+ * `createInterceptor` captures `config.actionGuard` once, so a lazily
4020
+ * initialised interceptor is a second cache sitting behind `loadConfig`'s.
4021
+ * Fixing only the first one — which is what the review's SHOULD-FIX-4 asked
4022
+ * for — moved `/shieldcortex-status` to the right answer and left the gate
4023
+ * itself on the posture that was live at gateway start. Both have to go.
4024
+ *
4025
+ * Rebuilding on a posture CHANGE (not on every call) is also the right
4026
+ * semantics rather than merely the cheap one: the per-session deny cache
4027
+ * and rate limiter hold decisions taken under the old posture, and an
4028
+ * allow decided while the host was unlocked must not survive the lock
4029
+ * landing.
4030
+ */
4031
+ let interceptorGuardPosture: string | null = null;
3763
4032
 
3764
4033
  // #134 §2: registered UNCONDITIONALLY, before the try block below that can
3765
4034
  // throw. Previously this command lived inside that try, so a plugin crash
@@ -3802,7 +4071,7 @@ export default {
3802
4071
  ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
3803
4072
  : !interceptorOn || !guardCfg.enabled
3804
4073
  ? "off"
3805
- : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
4074
+ : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? (interceptorDegraded ? " — DEGRADED: dependency-free fallback scan only (WS2); run `shieldcortex repair`" : "") : " — not yet initialised this session"}`;
3806
4075
  const hooksLine = _beforeToolCallRegistered
3807
4076
  ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
3808
4077
  // #226: session_end is registered even with the interceptor off —
@@ -3868,11 +4137,38 @@ export default {
3868
4137
  applyPluginConfigOverride(api);
3869
4138
 
3870
4139
  async function initInterceptor(): Promise<ReturnType<typeof createInterceptor> | null> {
3871
- if (interceptorInitAttempted) return interceptorReady;
4140
+ // The lock read happens HERE, on every call, not once per process:
4141
+ // `loadConfig` re-applies it to a cached merge (four `lstat`s, the cost
4142
+ // the design doc already accepted) so an operator who has just run
4143
+ // `protect` is obeyed by the running gateway rather than at its next
4144
+ // restart. Everything downstream is still built once per POSTURE.
4145
+ let scConfig: SCConfig;
4146
+ try {
4147
+ scConfig = await loadConfig();
4148
+ } catch (err) {
4149
+ // `loadConfig` degrades rather than throwing (#226), so this is the
4150
+ // unexpected path. Keep whatever gate we already have — dropping a
4151
+ // working interceptor because a config re-read hiccuped would turn a
4152
+ // transient fault into an unguarded turn.
4153
+ (api.logger as any)?.warn?.(`[shieldcortex] config re-read failed: ${err instanceof Error ? err.message : err}`);
4154
+ return interceptorReady;
4155
+ }
4156
+ const posture = JSON.stringify(scConfig.interceptor?.actionGuard ?? null);
4157
+ if (interceptorInitAttempted && posture === interceptorGuardPosture) {
4158
+ // #522 r7 FIND-1: a build already in flight for THIS posture is
4159
+ // AWAITED, never shortcut past — handing back the null
4160
+ // `interceptorReady` held during a rebuild gave a concurrent call an
4161
+ // unguarded turn.
4162
+ return interceptorBuild ? await interceptorBuild : interceptorReady;
4163
+ }
3872
4164
  interceptorInitAttempted = true;
4165
+ interceptorGuardPosture = posture;
4166
+ // A rebuild starts from nothing: an `enabled:false` posture, or a failed
4167
+ // rebuild, must not leave the previous interceptor answering for it.
4168
+ interceptorReady = null;
3873
4169
 
4170
+ const build = (async () => {
3874
4171
  try {
3875
- const scConfig = await loadConfig();
3876
4172
  // Normalised user config (deep-partial); DEFAULT_INTERCEPTOR_CONFIG
3877
4173
  // fills the gaps below — defaults never override explicit values.
3878
4174
  const rawInterceptorConfig = scConfig.interceptor;
@@ -3893,15 +4189,49 @@ export default {
3893
4189
  // uses — see getDefenceModule). Loaded via a string-concatenated
3894
4190
  // specifier so TypeScript doesn't resolve 'shieldcortex/defence' at
3895
4191
  // compile time; it only exists at runtime once the package is installed.
3896
- const defenceMod = await getDefenceModule();
3897
- if (!defenceMod) {
3898
- (api.logger as any)?.warn?.('[shieldcortex] Cannot load defence module — interceptor disabled');
3899
- return null;
4192
+ const defenceModRaw = await getDefenceModule();
4193
+ // #522 r7 FIND-3: a module whose policy READ was just proven to
4194
+ // disagree with the on-disk lock (absent, unloadable, throwing, or
4195
+ // lying) must not then supply the VERDICTS that lock is supposed to
4196
+ // enforce — `applyPolicyLockToPluginConfig` already fails the
4197
+ // POSTURE closed for this module; distrust it here too.
4198
+ const defenceMod = _defenceModuleDistrusted ? null : defenceModRaw;
4199
+ // #522 review round-6 follow-up (F1): a missing/incomplete dist used to
4200
+ // `return null` HERE, which made `before_tool_call`'s `if (!interceptor)
4201
+ // return;` skip the gate entirely — a full bypass, not even the
4202
+ // dependency-free WS2 fallback scan (`handleGuardUnavailable` in
4203
+ // interceptor.ts, which already exists for a defence call that throws
4204
+ // at RUNTIME). "Delete/break dist" was therefore a complete Action
4205
+ // Guard bypass on a host with a policy lock on disk — the exact class
4206
+ // of bug #501's review found and fixed in `pre-tool-hook.mjs`
4207
+ // (`applyHookPolicyLock` / `handleDegradedGuard`). The fix is the same
4208
+ // shape here: build a DEGRADED interceptor instead of none. `pipeline`
4209
+ // throws — caught by `handleToolCall`'s existing `failurePolicy.high`
4210
+ // path for memory-write tools — and `evaluateToolCall` is left
4211
+ // undefined, which routes every Action Guard call through the tiered
4212
+ // WS2 fallback scan instead of the real evaluator.
4213
+ // `interceptorConfig.actionGuard.enforce` is already lock-aware
4214
+ // (`loadConfig()` above ran `applyPolicyLockToPluginConfig`), so the
4215
+ // WS2 dangerous tier still denies on a locked host even in this
4216
+ // degraded mode.
4217
+ const canRunPipeline = !!defenceMod && typeof defenceMod.runDefencePipeline === 'function';
4218
+ interceptorDegraded = !canRunPipeline;
4219
+ if (_defenceModuleDistrusted && defenceModRaw) {
4220
+ (api.logger as any)?.warn?.('[shieldcortex] the loaded defence module disagreed with the on-disk policy lock — distrusted: dependency-free fallback scan only (WS2). Run `shieldcortex repair`.');
4221
+ } else if (!defenceMod) {
4222
+ (api.logger as any)?.warn?.('[shieldcortex] Cannot load defence module — degraded: dependency-free fallback scan only (WS2), memory-write scanning follows failurePolicy.high');
4223
+ } else if (!canRunPipeline) {
4224
+ (api.logger as any)?.warn?.('[shieldcortex] defence module missing runDefencePipeline — degraded: dependency-free fallback scan only (WS2), memory-write scanning follows failurePolicy.high');
3900
4225
  }
3901
- if (typeof defenceMod.runDefencePipeline !== 'function') return null;
4226
+ const degradedPipeline: Parameters<typeof createInterceptor>[1] = () => {
4227
+ throw new Error('ShieldCortex: defence pipeline unavailable (dist missing or incomplete)');
4228
+ };
3902
4229
 
3903
- interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline as Parameters<typeof createInterceptor>[1], {
3904
- evaluateToolCall: typeof (defenceMod as any).evaluateToolCall === 'function'
4230
+ interceptorReady = createInterceptor(
4231
+ interceptorConfig,
4232
+ canRunPipeline ? (defenceMod!.runDefencePipeline as Parameters<typeof createInterceptor>[1]) : degradedPipeline,
4233
+ {
4234
+ evaluateToolCall: typeof (defenceMod as any)?.evaluateToolCall === 'function'
3905
4235
  ? ((defenceMod as any).evaluateToolCall as Parameters<typeof createInterceptor>[2] extends { evaluateToolCall?: infer E } ? E : never)
3906
4236
  : undefined,
3907
4237
  broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
@@ -3916,22 +4246,22 @@ export default {
3916
4246
  // injected through the same runtime seam as evaluateToolCall. Older
3917
4247
  // installed packages without the export simply leave the option
3918
4248
  // undefined (no lease plane — the capability-honesty surface says so).
3919
- checkActionLease: typeof (defenceMod as any).evaluateToolCallLease === 'function'
4249
+ checkActionLease: typeof (defenceMod as any)?.evaluateToolCallLease === 'function'
3920
4250
  ? (toolName, args, sessionId) =>
3921
4251
  (defenceMod as any).evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
3922
4252
  : undefined,
3923
- releaseActionLease: typeof (defenceMod as any).releaseToolCallLease === 'function'
4253
+ releaseActionLease: typeof (defenceMod as any)?.releaseToolCallLease === 'function'
3924
4254
  ? (toolName, args, sessionId) =>
3925
4255
  (defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3926
4256
  : undefined,
3927
4257
  // #260: the session-guard index. Same formula as the Claude Code
3928
4258
  // hook. Absent on an older dist — then emitAudit still stamps origin
3929
4259
  // but does not write an index nobody would summarise.
3930
- sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
4260
+ sessionGuard: typeof defenceMod?.sessionKeyFor === 'function' && typeof defenceMod?.appendSessionGuardIndex === 'function'
3931
4261
  ? {
3932
- keyFor: (sessionId) => defenceMod.sessionKeyFor!(sessionId),
4262
+ keyFor: (sessionId) => defenceMod!.sessionKeyFor!(sessionId),
3933
4263
  index: (entry) => {
3934
- defenceMod.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
4264
+ defenceMod!.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
3935
4265
  },
3936
4266
  }
3937
4267
  : undefined,
@@ -3940,7 +4270,7 @@ export default {
3940
4270
  cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
3941
4271
  cloudEnabled: (scConfig as any).cloudEnabled ?? false,
3942
4272
  }),
3943
- bindAudit: typeof (defenceMod as any).attachEnforcementBinding === 'function'
4273
+ bindAudit: typeof (defenceMod as any)?.attachEnforcementBinding === 'function'
3944
4274
  ? (entry, args) => (defenceMod as any).attachEnforcementBinding(entry, {
3945
4275
  plane: 'action_guard',
3946
4276
  hookName: 'before_tool_call',
@@ -3950,7 +4280,9 @@ export default {
3950
4280
  }) as typeof entry
3951
4281
  : undefined,
3952
4282
  });
3953
- const guardState = interceptorConfig.actionGuard?.enabled
4283
+ const guardState = !canRunPipeline
4284
+ ? 'Action Guard: DEGRADED (WS2 fallback scan only)'
4285
+ : interceptorConfig.actionGuard?.enabled
3954
4286
  ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
3955
4287
  : 'Action Guard: off';
3956
4288
  api.logger?.info?.(`[shieldcortex] Interceptor active — memory writes + ${guardState} (shell/file/network/git)`);
@@ -3959,6 +4291,13 @@ export default {
3959
4291
  (api.logger as any)?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
3960
4292
  return null;
3961
4293
  }
4294
+ })();
4295
+ interceptorBuild = build;
4296
+ try {
4297
+ return await build;
4298
+ } finally {
4299
+ if (interceptorBuild === build) interceptorBuild = null;
4300
+ }
3962
4301
  }
3963
4302
 
3964
4303
  // #112 follow-up: when the host config (openclaw.json plugin entry)
@@ -3975,7 +4314,17 @@ export default {
3975
4314
  // immediately and never requests approval — covered by regression tests.
3976
4315
  // Note: re-enabling the interceptor from openclaw.json requires a gateway
3977
4316
  // restart, since registration happens once at plugin load.
3978
- const interceptorDisabledInHostConfig = _configOverride?.interceptor?.enabled === false;
4317
+ // #522 r7 FIND-2: `openclaw.json` is an UNSIGNED, same-UID file that the
4318
+ // Action Guard does not itself gate writes to — so `interceptor.enabled:
4319
+ // false` there was a one-key, unprivileged, silent way to take the whole
4320
+ // gate off a host carrying a root-owned policy lock, which is exactly the
4321
+ // defect #501 exists to close. The lock out-ranks the entry for the
4322
+ // guard's own switches (`applyPolicyLockToPluginConfig`); it must out-rank
4323
+ // it for whether the gate is REGISTERED too, or the precedence rule is
4324
+ // decorative. #112's reason for the flag survives intact on an UNLOCKED
4325
+ // host, which is every host the flag was written for.
4326
+ const interceptorDisabledInHostConfig =
4327
+ _configOverride?.interceptor?.enabled === false && !inlinePolicyLockPresent();
3979
4328
 
3980
4329
  if (!interceptorDisabledInHostConfig) {
3981
4330
  // Typed before_tool_call hook: this is the OpenClaw agent-loop gate that