@drakon-systems/shieldcortex-realtime 5.0.5 → 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/dist/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { createHash, randomUUID } from "node:crypto";
27
27
  import fs from "node:fs/promises";
28
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
28
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
29
29
  import path from "node:path";
30
30
  import { homedir, hostname } from "node:os";
31
31
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -265,7 +265,7 @@ export function __setRuntimeForTest(runtime) {
265
265
  runtimePromise = null;
266
266
  }
267
267
  export function __resetConfigStateForTest() {
268
- _config = null;
268
+ _mergedConfig = null;
269
269
  _configOverride = null;
270
270
  _lastShieldConfigRef = null;
271
271
  _provenanceUndeclared = 0;
@@ -1058,7 +1058,12 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
1058
1058
  conversationTrust: CONVERSATION_TRUST_JSON_SCHEMA,
1059
1059
  },
1060
1060
  };
1061
- let _config = null;
1061
+ /**
1062
+ * The cached MERGE — shield config + openclaw.json entry, with the policy lock
1063
+ * deliberately NOT applied. `loadConfig` re-applies the lock to this on every
1064
+ * call; see the comment there for why the two halves cache differently.
1065
+ */
1066
+ let _mergedConfig = null;
1062
1067
  // Identity of the shield config we last merged from. The runtime's
1063
1068
  // loadShieldConfig() returns the same parsed object until the file's mtime
1064
1069
  // advances; using reference equality lets us re-merge precisely when the
@@ -1417,7 +1422,7 @@ function applyPluginConfigOverride(api) {
1417
1422
  return;
1418
1423
  _configOverride = mergeConfigs(_configOverride ?? {}, pluginConfig);
1419
1424
  // Override changed — invalidate so loadConfig() re-merges with new override.
1420
- _config = null;
1425
+ _mergedConfig = null;
1421
1426
  _lastShieldConfigRef = null;
1422
1427
  }
1423
1428
  /**
@@ -1498,6 +1503,218 @@ function noteL2Degraded(reason) {
1498
1503
  'origin was judged by the base scanner alone; L1 is unaffected and nothing is blocked by ' +
1499
1504
  'this. (Logged once per plugin load; the count is in shieldcortex-status.)');
1500
1505
  }
1506
+ // ==================== POLICY LOCK (#501) ====================
1507
+ /**
1508
+ * The canonical protected root, duplicated as a literal — and it has to be.
1509
+ *
1510
+ * This constant backs the INLINE PROBE, whose whole job is to be right when the
1511
+ * `shieldcortex/defence` module cannot be resolved. Importing it from the module
1512
+ * the probe exists to survive the absence of would defeat the probe. Same
1513
+ * duplication, same reason, as the script-source resolver (#160): a real build
1514
+ * boundary, stated at the copy, held in step by the enforcement-surface parity
1515
+ * test rather than by hope.
1516
+ */
1517
+ const INLINE_PROTECTED_ROOT = '/etc/shieldcortex';
1518
+ const INLINE_PROTECTED_ROOT_POINTER = '/etc/shieldcortex.conf';
1519
+ const INLINE_POLICY_LOCK_FILENAME = 'policy.json';
1520
+ /**
1521
+ * The posture an unverifiable-or-unreadable lock forces. Mirrors
1522
+ * STRICT_FAILCLOSED_POSTURE, key for key — including `reviewedScripts`
1523
+ * (#522, GPT-6 round-6, item 1), so the two inline copies and the dist
1524
+ * constant can never disagree about which keys a fail-closed posture pins.
1525
+ * On this surface a module that proved unusable for the policy READ is
1526
+ * already distrusted for VERDICTS too (#522 r7 FIND-3), so the pin is parity
1527
+ * rather than a reachable behavioural change today; the enforcement-surface
1528
+ * parity test holds it in step.
1529
+ */
1530
+ const INLINE_STRICT_GUARD_POSTURE = {
1531
+ enabled: true,
1532
+ enforce: true,
1533
+ autoApprove: [],
1534
+ broker: { enabled: false },
1535
+ reviewedScripts: [],
1536
+ };
1537
+ /**
1538
+ * The pointed-to protected root, judged inline — mirrors `resolvePointerRoot`.
1539
+ *
1540
+ * Deliberately WITHOUT the full ancestor walk `verifyProtectedFile` runs: this
1541
+ * probe's only output is "is a lock present", and a `true` can only ever raise
1542
+ * the posture. The file-level rules (root-owned, a real regular file, not
1543
+ * group- or other-writable) are the ones that stop an agent-writable pointer
1544
+ * from being read at all, and those are cheap enough to state here.
1545
+ */
1546
+ function inlinePointerRoot() {
1547
+ try {
1548
+ const st = lstatSync(INLINE_PROTECTED_ROOT_POINTER);
1549
+ if (st.isSymbolicLink() || !st.isFile())
1550
+ return null;
1551
+ if (st.uid !== 0 || (st.mode & 0o022) !== 0)
1552
+ return null;
1553
+ for (const rawLine of readFileSync(INLINE_PROTECTED_ROOT_POINTER, 'utf-8').split(/\r?\n/)) {
1554
+ const line = rawLine.trim();
1555
+ if (!line || line.startsWith('#'))
1556
+ continue;
1557
+ const eq = line.indexOf('=');
1558
+ if (eq === -1 || line.slice(0, eq).trim() !== 'root')
1559
+ continue;
1560
+ const value = line.slice(eq + 1).trim();
1561
+ return value && path.isAbsolute(value) ? value : null;
1562
+ }
1563
+ return null;
1564
+ }
1565
+ catch {
1566
+ return null;
1567
+ }
1568
+ }
1569
+ /**
1570
+ * Does a policy lock FILE exist, judged without resolving anything from dist?
1571
+ *
1572
+ * Mirrors `resolveProtectedRoot`'s ordering exactly, including the rule that
1573
+ * keeps the test override non-loosening: BOTH production roots — the canonical
1574
+ * one and the root-owned pointer — are resolved first, and the environment
1575
+ * variable is only consulted on a host where neither answers. So the probe can
1576
+ * never be pointed away from a real lock.
1577
+ *
1578
+ * The pointer half is the #501 review's BLOCK-2: without it, a pointer host
1579
+ * with an unresolvable defence module probed `false` and the plugin failed
1580
+ * OPEN — precisely the "break the install" case this probe exists to close.
1581
+ */
1582
+ function inlinePolicyLockPresent() {
1583
+ if (process.platform === 'win32')
1584
+ return false;
1585
+ try {
1586
+ const canonicalLock = path.join(INLINE_PROTECTED_ROOT, INLINE_POLICY_LOCK_FILENAME);
1587
+ const canonicalOccupied = existsSync(canonicalLock) || existsSync(INLINE_PROTECTED_ROOT);
1588
+ const pointed = inlinePointerRoot();
1589
+ let root = pointed ?? INLINE_PROTECTED_ROOT;
1590
+ if (!canonicalOccupied && pointed === null) {
1591
+ const override = process.env.SHIELDCORTEX_PROTECTED_ROOT?.trim();
1592
+ if (override && path.isAbsolute(override))
1593
+ root = override;
1594
+ }
1595
+ // Presence is judged the way the reader judges it — `lstat`, so an ENTRY of
1596
+ // any kind counts, a dangling symlink included. `existsSync` follows the
1597
+ // link and reports "no lock" for exactly the entry the reader reports as
1598
+ // present-and-unverifiable (review R3-2). A `true` can only raise the
1599
+ // posture, so present is the safe direction.
1600
+ lstatSync(path.join(root, INLINE_POLICY_LOCK_FILENAME));
1601
+ return true;
1602
+ }
1603
+ catch {
1604
+ return false;
1605
+ }
1606
+ }
1607
+ function withGuardPosture(config, guard) {
1608
+ return {
1609
+ ...config,
1610
+ interceptor: {
1611
+ ...(config.interceptor ?? {}),
1612
+ // #522 r7 FIND-2: the interceptor is only the CARRIER of the gate the
1613
+ // lock pins — `initInterceptor` returns null outright on
1614
+ // `enabled:false`, and `before_tool_call` reads that as no gate at all.
1615
+ // Leaving an unsigned same-UID `interceptor.enabled:false` to stand
1616
+ // while the lock says `actionGuard.enabled:true` made the precedence
1617
+ // rule decorative.
1618
+ // #522 G3: `failurePolicy.high` is the same story one layer down. It is
1619
+ // the "cannot obtain a verdict" policy, and a degraded guard is exactly
1620
+ // that — so on a broken install `handleGuardUnavailable` asked the
1621
+ // unsigned `openclaw.json` whether to deny the DANGEROUS tier, and
1622
+ // `failurePolicy.high:"allow"` there let it through while the lock said
1623
+ // the guard was on and enforcing. With the lock's guard enabled, the
1624
+ // lock owns that answer too. `severityActions` and the other severities
1625
+ // are left alone: this is the one key that decides the degraded tier.
1626
+ ...(guard.enabled === true
1627
+ ? {
1628
+ enabled: true,
1629
+ failurePolicy: { ...(config.interceptor?.failurePolicy ?? {}), high: 'deny' },
1630
+ }
1631
+ : {}),
1632
+ actionGuard: { ...(config.interceptor?.actionGuard ?? {}), ...guard },
1633
+ },
1634
+ };
1635
+ }
1636
+ /**
1637
+ * Apply the OS-owned policy lock to the plugin's EFFECTIVE Action Guard config.
1638
+ *
1639
+ * Applied AFTER `mergeConfigs`, not before, and that ordering is the whole
1640
+ * point: the `openclaw.json` plugin entry deep-merges OVER the shield config, so
1641
+ * applying the lock to the shield config alone would leave a plugin entry saying
1642
+ * `actionGuard.enabled: false` as the last word — which is precisely the
1643
+ * unsigned, same-UID file the lock exists to stop being authoritative. The
1644
+ * `src/setup/openclaw-plugin-guard-sync.ts` mirror writes into that same entry,
1645
+ * so it too is now out-ranked by the lock rather than able to out-rank it.
1646
+ *
1647
+ * The precedence rules themselves come from dist (`applyPolicyLock`) so there is
1648
+ * exactly ONE implementation of them across both enforcement surfaces.
1649
+ */
1650
+ async function applyPolicyLockToPluginConfig(config) {
1651
+ const mod = await getDefenceModule().catch(() => null);
1652
+ const failClosed = () => {
1653
+ // #522 r7 FIND-3: this module just proved unusable for the policy READ
1654
+ // while a lock is on disk — absent, unloadable, throwing, or lying. Its
1655
+ // VERDICTS cannot then be what enforces that policy: a substituted
1656
+ // module whose `readPolicyLock` answers 'absent' and whose
1657
+ // `evaluateToolCall` answers 'allow' reported `enforce` and gated
1658
+ // nothing at all.
1659
+ _defenceModuleDistrusted = true;
1660
+ if (!_policyLockDegradedLogged) {
1661
+ _policyLockDegradedLogged = true;
1662
+ console.warn('[shieldcortex] ⚠️ a policy lock is present but the ShieldCortex defence module could not be ' +
1663
+ 'loaded to read it — enforcing the strict fail-closed posture (Action Guard on + enforcing, ' +
1664
+ 'no auto-approve, broker off). Run `shieldcortex repair` to restore the install.');
1665
+ }
1666
+ return withGuardPosture(config, INLINE_STRICT_GUARD_POSTURE);
1667
+ };
1668
+ if (!mod || typeof mod.readPolicyLock !== 'function' || typeof mod.applyPolicyLock !== 'function') {
1669
+ if (!inlinePolicyLockPresent())
1670
+ _defenceModuleDistrusted = false;
1671
+ // A missing reader on a host with NO lock is today's behaviour: the plugin
1672
+ // runs on the config it has. A missing reader on a host WITH a lock would
1673
+ // make "break the install" the bypass, so that one fails closed.
1674
+ return inlinePolicyLockPresent() ? failClosed() : config;
1675
+ }
1676
+ try {
1677
+ // `audit` off: the SQLite audit logger belongs to the src-side reader, not
1678
+ // to a plugin load. Shaped as a raw config view so the ONE precedence
1679
+ // implementation in dist does the work.
1680
+ const view = { actionGuard: { ...(config.interceptor?.actionGuard ?? {}) } };
1681
+ const verdict = mod.readPolicyLock({ audit: false });
1682
+ // #501 review BLOCK-1, mirrored from the hook: a reader that LOADS and lies
1683
+ // is treated exactly like one that could not be loaded at all. The hook's
1684
+ // copy of this closed a real `SHIELDCORTEX_DIST_ROOT` bypass; this copy
1685
+ // exists so the two surfaces answer a substituted reader identically, which
1686
+ // is the #160 lesson applied to the lock.
1687
+ const status = verdict?.status;
1688
+ if ((status === undefined || status === 'absent' || status === 'unsupported') && inlinePolicyLockPresent()) {
1689
+ return failClosed();
1690
+ }
1691
+ const locked = mod.applyPolicyLock(view, verdict);
1692
+ // The reader answered, and it agreed with the inline probe. Trust
1693
+ // restored (a `repair` mid-process must not stay latched into the
1694
+ // degraded path).
1695
+ _defenceModuleDistrusted = false;
1696
+ const guard = locked.actionGuard;
1697
+ if (!guard || typeof guard !== 'object' || Array.isArray(guard))
1698
+ return config;
1699
+ return withGuardPosture(config, guard);
1700
+ }
1701
+ catch {
1702
+ // A reader that throws is treated exactly like a lock that cannot be
1703
+ // verified, for the same reason: we cannot tell what the operator pinned.
1704
+ if (!inlinePolicyLockPresent()) {
1705
+ _defenceModuleDistrusted = false;
1706
+ return config;
1707
+ }
1708
+ return failClosed();
1709
+ }
1710
+ }
1711
+ /** #522 r7 FIND-3: the defence module disagreed with the on-disk lock (or
1712
+ * could not be loaded to read it) while a lock is present. Re-evaluated on
1713
+ * every config read, so `shieldcortex repair` clears it without a gateway
1714
+ * restart. */
1715
+ let _defenceModuleDistrusted = false;
1716
+ /** One warning per plugin load, like every other degrade note in this file. */
1717
+ let _policyLockDegradedLogged = false;
1501
1718
  async function loadConfig() {
1502
1719
  let shieldConfigRaw;
1503
1720
  try {
@@ -1513,21 +1730,36 @@ async function loadConfig() {
1513
1730
  'conversation scanning will report UNAVAILABLE until this is fixed. (Logged once per plugin load.)');
1514
1731
  }
1515
1732
  // A fresh object every time: `_configOverride` is module state and callers
1516
- // must not be handed something they could mutate.
1517
- return mergeConfigs({}, _configOverride ?? {});
1733
+ // must not be handed something they could mutate. The policy lock still
1734
+ // applies — a shield config we could not read is exactly when the plugin
1735
+ // entry is the only thing talking, and the entry is the unsigned file.
1736
+ return applyPolicyLockToPluginConfig(mergeConfigs({}, _configOverride ?? {}));
1518
1737
  }
1519
1738
  // A load that succeeds after a failure re-arms the warning, so a SECOND
1520
1739
  // outage is reported rather than swallowed by the first one's flag. Set
1521
1740
  // before the cache check: a runtime that hands back the same object every
1522
1741
  // call would otherwise take the early return and leave the flag latched.
1523
1742
  _shieldConfigLoadFailureLogged = false;
1524
- if (_config && shieldConfigRaw === _lastShieldConfigRef)
1525
- return _config;
1526
- _lastShieldConfigRef = shieldConfigRaw;
1527
- // Plugin config (openclaw.json) deep-merges over the shield config file —
1528
- // see mergeConfigs() for the per-key semantics.
1529
- _config = mergeConfigs(normaliseConfig(shieldConfigRaw), _configOverride ?? {});
1530
- return _config;
1743
+ // The MERGE is cached; the LOCK is not, and that split is the #501 review's
1744
+ // SHOULD-FIX-4. `policy-lock.ts` states the rule — "no cache on the lock
1745
+ // read. Four `lstat`s per config read on an unlocked host, in exchange for an
1746
+ // operator who has just run `protect` being obeyed by the already-running
1747
+ // agent rather than at its next restart." That held for the hook (a fresh
1748
+ // process per call) and the CLI, and was quietly false here: the effective
1749
+ // config was memoised on the shield config's object IDENTITY, and writing
1750
+ // the lock does not touch `config.json`, so it was read exactly once per
1751
+ // gateway process and never again. An operator who ran `protect` on a live
1752
+ // box was told by doctor the host was locked while this gate was still off.
1753
+ if (!_mergedConfig || shieldConfigRaw !== _lastShieldConfigRef) {
1754
+ _lastShieldConfigRef = shieldConfigRaw;
1755
+ // Plugin config (openclaw.json) deep-merges over the shield config file —
1756
+ // see mergeConfigs() for the per-key semantics.
1757
+ _mergedConfig = mergeConfigs(normaliseConfig(shieldConfigRaw), _configOverride ?? {});
1758
+ }
1759
+ // Applied over BOTH, on every call, because the plugin entry is an unsigned,
1760
+ // same-UID file and must not be the last word on the Action Guard's own
1761
+ // switches — and because a lock written a second ago is still a lock.
1762
+ return applyPolicyLockToPluginConfig(_mergedConfig);
1531
1763
  }
1532
1764
  function isAutoMemoryEnabled(config) {
1533
1765
  return config.openclawAutoMemory === true;
@@ -2990,7 +3222,10 @@ function buildTypedApprovalRequest(message) {
2990
3222
  .split(/\r?\n/u)
2991
3223
  .map((line) => line.trim())
2992
3224
  .filter(Boolean)
2993
- .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
3225
+ // #524: the action-guard card's button row is now `[Allow once] [Deny]`.
3226
+ // The old pattern needed the label to be exactly `Approve`/`Deny`, so the
3227
+ // new row fell through and rendered as the first line of the description.
3228
+ .filter((line) => !/^\[(?:Approve|Allow[^\]]*|Deny)\]/i.test(line));
2994
3229
  const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
2995
3230
  const detailLines = lines.slice(1);
2996
3231
  const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
@@ -3163,6 +3398,33 @@ export default {
3163
3398
  // --- Interceptor (lazy init) ---
3164
3399
  let interceptorReady = null;
3165
3400
  let interceptorInitAttempted = false;
3401
+ /** #522 r7 FIND-5: the live interceptor is the DEGRADED one (WS2 fallback only). */
3402
+ let interceptorDegraded = false;
3403
+ /**
3404
+ * The build in flight for `interceptorGuardPosture`, if any (#522 r7 FIND-1).
3405
+ * `interceptorReady` is deliberately null for the duration of a rebuild, and
3406
+ * `before_tool_call` reads null as "no gate at all" — so every concurrent
3407
+ * call that took the posture-cache shortcut during that window was
3408
+ * ungated: the same full bypass F1 closed for the serial case, still open
3409
+ * for concurrent ones (23 of 24 in the reviewer's reproduction).
3410
+ */
3411
+ let interceptorBuild = null;
3412
+ /**
3413
+ * The Action Guard posture the live interceptor was BUILT with (#501).
3414
+ *
3415
+ * `createInterceptor` captures `config.actionGuard` once, so a lazily
3416
+ * initialised interceptor is a second cache sitting behind `loadConfig`'s.
3417
+ * Fixing only the first one — which is what the review's SHOULD-FIX-4 asked
3418
+ * for — moved `/shieldcortex-status` to the right answer and left the gate
3419
+ * itself on the posture that was live at gateway start. Both have to go.
3420
+ *
3421
+ * Rebuilding on a posture CHANGE (not on every call) is also the right
3422
+ * semantics rather than merely the cheap one: the per-session deny cache
3423
+ * and rate limiter hold decisions taken under the old posture, and an
3424
+ * allow decided while the host was unlocked must not survive the lock
3425
+ * landing.
3426
+ */
3427
+ let interceptorGuardPosture = null;
3166
3428
  // #134 §2: registered UNCONDITIONALLY, before the try block below that can
3167
3429
  // throw. Previously this command lived inside that try, so a plugin crash
3168
3430
  // meant the operator had no /shieldcortex-status to run at all — the one
@@ -3203,7 +3465,7 @@ export default {
3203
3465
  ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
3204
3466
  : !interceptorOn || !guardCfg.enabled
3205
3467
  ? "off"
3206
- : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
3468
+ : `${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"}`;
3207
3469
  const hooksLine = _beforeToolCallRegistered
3208
3470
  ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
3209
3471
  // #226: session_end is registered even with the interceptor off —
@@ -3267,94 +3529,164 @@ export default {
3267
3529
  try {
3268
3530
  applyPluginConfigOverride(api);
3269
3531
  async function initInterceptor() {
3270
- if (interceptorInitAttempted)
3532
+ // The lock read happens HERE, on every call, not once per process:
3533
+ // `loadConfig` re-applies it to a cached merge (four `lstat`s, the cost
3534
+ // the design doc already accepted) so an operator who has just run
3535
+ // `protect` is obeyed by the running gateway rather than at its next
3536
+ // restart. Everything downstream is still built once per POSTURE.
3537
+ let scConfig;
3538
+ try {
3539
+ scConfig = await loadConfig();
3540
+ }
3541
+ catch (err) {
3542
+ // `loadConfig` degrades rather than throwing (#226), so this is the
3543
+ // unexpected path. Keep whatever gate we already have — dropping a
3544
+ // working interceptor because a config re-read hiccuped would turn a
3545
+ // transient fault into an unguarded turn.
3546
+ api.logger?.warn?.(`[shieldcortex] config re-read failed: ${err instanceof Error ? err.message : err}`);
3271
3547
  return interceptorReady;
3548
+ }
3549
+ const posture = JSON.stringify(scConfig.interceptor?.actionGuard ?? null);
3550
+ if (interceptorInitAttempted && posture === interceptorGuardPosture) {
3551
+ // #522 r7 FIND-1: a build already in flight for THIS posture is
3552
+ // AWAITED, never shortcut past — handing back the null
3553
+ // `interceptorReady` held during a rebuild gave a concurrent call an
3554
+ // unguarded turn.
3555
+ return interceptorBuild ? await interceptorBuild : interceptorReady;
3556
+ }
3272
3557
  interceptorInitAttempted = true;
3273
- try {
3274
- const scConfig = await loadConfig();
3275
- // Normalised user config (deep-partial); DEFAULT_INTERCEPTOR_CONFIG
3276
- // fills the gaps below — defaults never override explicit values.
3277
- const rawInterceptorConfig = scConfig.interceptor;
3278
- const interceptorConfig = {
3279
- ...DEFAULT_INTERCEPTOR_CONFIG,
3280
- ...(rawInterceptorConfig && typeof rawInterceptorConfig === 'object' ? {
3281
- enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
3282
- severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
3283
- failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
3284
- actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3285
- } : {}),
3286
- logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
3287
- };
3288
- if (!interceptorConfig.enabled)
3289
- return null;
3290
- // Shared in-process defence module (same instance realtime scanning
3291
- // uses — see getDefenceModule). Loaded via a string-concatenated
3292
- // specifier so TypeScript doesn't resolve 'shieldcortex/defence' at
3293
- // compile time; it only exists at runtime once the package is installed.
3294
- const defenceMod = await getDefenceModule();
3295
- if (!defenceMod) {
3296
- api.logger?.warn?.('[shieldcortex] Cannot load defence module — interceptor disabled');
3297
- return null;
3558
+ interceptorGuardPosture = posture;
3559
+ // A rebuild starts from nothing: an `enabled:false` posture, or a failed
3560
+ // rebuild, must not leave the previous interceptor answering for it.
3561
+ interceptorReady = null;
3562
+ const build = (async () => {
3563
+ try {
3564
+ // Normalised user config (deep-partial); DEFAULT_INTERCEPTOR_CONFIG
3565
+ // fills the gaps below — defaults never override explicit values.
3566
+ const rawInterceptorConfig = scConfig.interceptor;
3567
+ const interceptorConfig = {
3568
+ ...DEFAULT_INTERCEPTOR_CONFIG,
3569
+ ...(rawInterceptorConfig && typeof rawInterceptorConfig === 'object' ? {
3570
+ enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
3571
+ severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
3572
+ failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
3573
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3574
+ } : {}),
3575
+ logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
3576
+ };
3577
+ if (!interceptorConfig.enabled)
3578
+ return null;
3579
+ // Shared in-process defence module (same instance realtime scanning
3580
+ // uses — see getDefenceModule). Loaded via a string-concatenated
3581
+ // specifier so TypeScript doesn't resolve 'shieldcortex/defence' at
3582
+ // compile time; it only exists at runtime once the package is installed.
3583
+ const defenceModRaw = await getDefenceModule();
3584
+ // #522 r7 FIND-3: a module whose policy READ was just proven to
3585
+ // disagree with the on-disk lock (absent, unloadable, throwing, or
3586
+ // lying) must not then supply the VERDICTS that lock is supposed to
3587
+ // enforce — `applyPolicyLockToPluginConfig` already fails the
3588
+ // POSTURE closed for this module; distrust it here too.
3589
+ const defenceMod = _defenceModuleDistrusted ? null : defenceModRaw;
3590
+ // #522 review round-6 follow-up (F1): a missing/incomplete dist used to
3591
+ // `return null` HERE, which made `before_tool_call`'s `if (!interceptor)
3592
+ // return;` skip the gate entirely — a full bypass, not even the
3593
+ // dependency-free WS2 fallback scan (`handleGuardUnavailable` in
3594
+ // interceptor.ts, which already exists for a defence call that throws
3595
+ // at RUNTIME). "Delete/break dist" was therefore a complete Action
3596
+ // Guard bypass on a host with a policy lock on disk — the exact class
3597
+ // of bug #501's review found and fixed in `pre-tool-hook.mjs`
3598
+ // (`applyHookPolicyLock` / `handleDegradedGuard`). The fix is the same
3599
+ // shape here: build a DEGRADED interceptor instead of none. `pipeline`
3600
+ // throws — caught by `handleToolCall`'s existing `failurePolicy.high`
3601
+ // path for memory-write tools — and `evaluateToolCall` is left
3602
+ // undefined, which routes every Action Guard call through the tiered
3603
+ // WS2 fallback scan instead of the real evaluator.
3604
+ // `interceptorConfig.actionGuard.enforce` is already lock-aware
3605
+ // (`loadConfig()` above ran `applyPolicyLockToPluginConfig`), so the
3606
+ // WS2 dangerous tier still denies on a locked host even in this
3607
+ // degraded mode.
3608
+ const canRunPipeline = !!defenceMod && typeof defenceMod.runDefencePipeline === 'function';
3609
+ interceptorDegraded = !canRunPipeline;
3610
+ if (_defenceModuleDistrusted && defenceModRaw) {
3611
+ api.logger?.warn?.('[shieldcortex] the loaded defence module disagreed with the on-disk policy lock — distrusted: dependency-free fallback scan only (WS2). Run `shieldcortex repair`.');
3612
+ }
3613
+ else if (!defenceMod) {
3614
+ api.logger?.warn?.('[shieldcortex] Cannot load defence module — degraded: dependency-free fallback scan only (WS2), memory-write scanning follows failurePolicy.high');
3615
+ }
3616
+ else if (!canRunPipeline) {
3617
+ api.logger?.warn?.('[shieldcortex] defence module missing runDefencePipeline — degraded: dependency-free fallback scan only (WS2), memory-write scanning follows failurePolicy.high');
3618
+ }
3619
+ const degradedPipeline = () => {
3620
+ throw new Error('ShieldCortex: defence pipeline unavailable (dist missing or incomplete)');
3621
+ };
3622
+ interceptorReady = createInterceptor(interceptorConfig, canRunPipeline ? defenceMod.runDefencePipeline : degradedPipeline, {
3623
+ evaluateToolCall: typeof defenceMod?.evaluateToolCall === 'function'
3624
+ ? defenceMod.evaluateToolCall
3625
+ : undefined,
3626
+ broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
3627
+ // #233: the read side of conversation taint. Returns null for a clean
3628
+ // or unknown session, so the guard behaves exactly as before unless a
3629
+ // conversation detection actually happened in THIS session.
3630
+ sessionTaint: (sessionId) => {
3631
+ const rec = sessionId ? sessionTaint.get(sessionId) : null;
3632
+ return rec ? { reason: rec.reason } : null;
3633
+ },
3634
+ // #227: session action lease — the fs-backed shared implementation,
3635
+ // injected through the same runtime seam as evaluateToolCall. Older
3636
+ // installed packages without the export simply leave the option
3637
+ // undefined (no lease plane — the capability-honesty surface says so).
3638
+ checkActionLease: typeof defenceMod?.evaluateToolCallLease === 'function'
3639
+ ? (toolName, args, sessionId) => defenceMod.evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
3640
+ : undefined,
3641
+ releaseActionLease: typeof defenceMod?.releaseToolCallLease === 'function'
3642
+ ? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3643
+ : undefined,
3644
+ // #260: the session-guard index. Same formula as the Claude Code
3645
+ // hook. Absent on an older dist — then emitAudit still stamps origin
3646
+ // but does not write an index nobody would summarise.
3647
+ sessionGuard: typeof defenceMod?.sessionKeyFor === 'function' && typeof defenceMod?.appendSessionGuardIndex === 'function'
3648
+ ? {
3649
+ keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
3650
+ index: (entry) => {
3651
+ defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
3652
+ },
3653
+ }
3654
+ : undefined,
3655
+ onAuditEntry: (entry) => syncInterceptEvent(entry, {
3656
+ cloudApiKey: scConfig.cloudApiKey ?? '',
3657
+ cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
3658
+ cloudEnabled: scConfig.cloudEnabled ?? false,
3659
+ }),
3660
+ bindAudit: typeof defenceMod?.attachEnforcementBinding === 'function'
3661
+ ? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
3662
+ plane: 'action_guard',
3663
+ hookName: 'before_tool_call',
3664
+ pluginId: 'shieldcortex-realtime',
3665
+ tool: entry.tool,
3666
+ args: args ?? {},
3667
+ })
3668
+ : undefined,
3669
+ });
3670
+ const guardState = !canRunPipeline
3671
+ ? 'Action Guard: DEGRADED (WS2 fallback scan only)'
3672
+ : interceptorConfig.actionGuard?.enabled
3673
+ ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
3674
+ : 'Action Guard: off';
3675
+ api.logger?.info?.(`[shieldcortex] Interceptor active — memory writes + ${guardState} (shell/file/network/git)`);
3676
+ return interceptorReady;
3298
3677
  }
3299
- if (typeof defenceMod.runDefencePipeline !== 'function')
3678
+ catch (err) {
3679
+ api.logger?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
3300
3680
  return null;
3301
- interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline, {
3302
- evaluateToolCall: typeof defenceMod.evaluateToolCall === 'function'
3303
- ? defenceMod.evaluateToolCall
3304
- : undefined,
3305
- broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
3306
- // #233: the read side of conversation taint. Returns null for a clean
3307
- // or unknown session, so the guard behaves exactly as before unless a
3308
- // conversation detection actually happened in THIS session.
3309
- sessionTaint: (sessionId) => {
3310
- const rec = sessionId ? sessionTaint.get(sessionId) : null;
3311
- return rec ? { reason: rec.reason } : null;
3312
- },
3313
- // #227: session action lease — the fs-backed shared implementation,
3314
- // injected through the same runtime seam as evaluateToolCall. Older
3315
- // installed packages without the export simply leave the option
3316
- // undefined (no lease plane — the capability-honesty surface says so).
3317
- checkActionLease: typeof defenceMod.evaluateToolCallLease === 'function'
3318
- ? (toolName, args, sessionId) => defenceMod.evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
3319
- : undefined,
3320
- releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
3321
- ? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3322
- : undefined,
3323
- // #260: the session-guard index. Same formula as the Claude Code
3324
- // hook. Absent on an older dist — then emitAudit still stamps origin
3325
- // but does not write an index nobody would summarise.
3326
- sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
3327
- ? {
3328
- keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
3329
- index: (entry) => {
3330
- defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
3331
- },
3332
- }
3333
- : undefined,
3334
- onAuditEntry: (entry) => syncInterceptEvent(entry, {
3335
- cloudApiKey: scConfig.cloudApiKey ?? '',
3336
- cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
3337
- cloudEnabled: scConfig.cloudEnabled ?? false,
3338
- }),
3339
- bindAudit: typeof defenceMod.attachEnforcementBinding === 'function'
3340
- ? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
3341
- plane: 'action_guard',
3342
- hookName: 'before_tool_call',
3343
- pluginId: 'shieldcortex-realtime',
3344
- tool: entry.tool,
3345
- args: args ?? {},
3346
- })
3347
- : undefined,
3348
- });
3349
- const guardState = interceptorConfig.actionGuard?.enabled
3350
- ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
3351
- : 'Action Guard: off';
3352
- api.logger?.info?.(`[shieldcortex] Interceptor active — memory writes + ${guardState} (shell/file/network/git)`);
3353
- return interceptorReady;
3681
+ }
3682
+ })();
3683
+ interceptorBuild = build;
3684
+ try {
3685
+ return await build;
3354
3686
  }
3355
- catch (err) {
3356
- api.logger?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
3357
- return null;
3687
+ finally {
3688
+ if (interceptorBuild === build)
3689
+ interceptorBuild = null;
3358
3690
  }
3359
3691
  }
3360
3692
  // #112 follow-up: when the host config (openclaw.json plugin entry)
@@ -3371,7 +3703,16 @@ export default {
3371
3703
  // immediately and never requests approval — covered by regression tests.
3372
3704
  // Note: re-enabling the interceptor from openclaw.json requires a gateway
3373
3705
  // restart, since registration happens once at plugin load.
3374
- const interceptorDisabledInHostConfig = _configOverride?.interceptor?.enabled === false;
3706
+ // #522 r7 FIND-2: `openclaw.json` is an UNSIGNED, same-UID file that the
3707
+ // Action Guard does not itself gate writes to — so `interceptor.enabled:
3708
+ // false` there was a one-key, unprivileged, silent way to take the whole
3709
+ // gate off a host carrying a root-owned policy lock, which is exactly the
3710
+ // defect #501 exists to close. The lock out-ranks the entry for the
3711
+ // guard's own switches (`applyPolicyLockToPluginConfig`); it must out-rank
3712
+ // it for whether the gate is REGISTERED too, or the precedence rule is
3713
+ // decorative. #112's reason for the flag survives intact on an UNLOCKED
3714
+ // host, which is every host the flag was written for.
3715
+ const interceptorDisabledInHostConfig = _configOverride?.interceptor?.enabled === false && !inlinePolicyLockPresent();
3375
3716
  if (!interceptorDisabledInHostConfig) {
3376
3717
  // Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
3377
3718
  // can block or require approval before the selected tool executes.