@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1913
|
-
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
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
|
-
|
|
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(
|
|
3904
|
-
|
|
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)
|
|
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)
|
|
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
|
|
4260
|
+
sessionGuard: typeof defenceMod?.sessionKeyFor === 'function' && typeof defenceMod?.appendSessionGuardIndex === 'function'
|
|
3931
4261
|
? {
|
|
3932
|
-
keyFor: (sessionId) => defenceMod
|
|
4262
|
+
keyFor: (sessionId) => defenceMod!.sessionKeyFor!(sessionId),
|
|
3933
4263
|
index: (entry) => {
|
|
3934
|
-
defenceMod
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|