@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/dist/index.js +440 -99
- package/dist/interceptor.js +193 -8
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +380 -31
- package/interceptor.ts +196 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
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
|
-
|
|
3678
|
+
catch (err) {
|
|
3679
|
+
api.logger?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
|
|
3300
3680
|
return null;
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
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
|
-
|
|
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.
|