@drakon-systems/shieldcortex-realtime 4.49.0 → 4.50.0

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
@@ -2900,6 +2900,16 @@ export default {
2900
2900
  const rec = sessionId ? sessionTaint.get(sessionId) : null;
2901
2901
  return rec ? { reason: rec.reason } : null;
2902
2902
  },
2903
+ // #227: session action lease — the fs-backed shared implementation,
2904
+ // injected through the same runtime seam as evaluateToolCall. Older
2905
+ // installed packages without the export simply leave the option
2906
+ // undefined (no lease plane — the capability-honesty surface says so).
2907
+ checkActionLease: typeof defenceMod.evaluateToolCallLease === 'function'
2908
+ ? (toolName, args, sessionId) => defenceMod.evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
2909
+ : undefined,
2910
+ releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
2911
+ ? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
2912
+ : undefined,
2903
2913
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
2904
2914
  cloudApiKey: scConfig.cloudApiKey ?? '',
2905
2915
  cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
@@ -203,6 +203,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
203
203
  { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
204
204
  // Guard's own approval store (#118): agent-side writes here mint approvals.
205
205
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
206
+ // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
207
+ { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
206
208
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
207
209
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
208
210
  { re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
@@ -680,6 +682,34 @@ export function createInterceptor(config, pipeline, options) {
680
682
  async function runActionGuard(context) {
681
683
  if (!actionGuardCfg.enabled)
682
684
  return;
685
+ // Session action lease (#227) — EARLY, before the guard evaluator (which may
686
+ // be unwired or throw). A freeze is a HARD control and must bind regardless
687
+ // of the guard's own state; running it only after a successful evaluation
688
+ // would let a frozen action through the guard-unavailable path. Unscoped
689
+ // calls return null and cost nothing; a THROW is treated as no-lease (a
690
+ // broken lease layer must not deny everything); state unreadability fails
691
+ // closed to 'unknown' inside the store.
692
+ let leaseGate = null;
693
+ try {
694
+ leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
695
+ if (leaseGate?.ledgerChanged) {
696
+ log.warn(`[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`);
697
+ }
698
+ if (leaseGate && leaseGate.decision.verdict !== 'allow') {
699
+ emitAudit({
700
+ ...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] }, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
701
+ action: 'auto_deny', outcome: 'auto_denied',
702
+ });
703
+ log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
704
+ throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
705
+ }
706
+ }
707
+ catch (err) {
708
+ // A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
709
+ if (err instanceof Error && err.message.startsWith('ShieldCortex:'))
710
+ throw err;
711
+ leaseGate = null;
712
+ }
683
713
  if (typeof evaluateToolCall !== 'function') {
684
714
  handleGuardUnavailable(context, 'evaluateToolCall not wired');
685
715
  return;
@@ -757,6 +787,14 @@ export function createInterceptor(config, pipeline, options) {
757
787
  const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
758
788
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
759
789
  if (v.decision === 'block') {
790
+ // #227: release any lease this call minted early — a blocked action must
791
+ // not leave a hold on that scope (self-heals at TTL if release fails).
792
+ if (leaseGate?.acquired) {
793
+ try {
794
+ options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId);
795
+ }
796
+ catch { /* self-heals */ }
797
+ }
760
798
  emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
761
799
  // Surface the block to the gateway log (journald). Blocks are recorded in
762
800
  // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.49.0",
3
+ "version": "4.50.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/index.ts CHANGED
@@ -3431,6 +3431,18 @@ export default {
3431
3431
  const rec = sessionId ? sessionTaint.get(sessionId) : null;
3432
3432
  return rec ? { reason: rec.reason } : null;
3433
3433
  },
3434
+ // #227: session action lease — the fs-backed shared implementation,
3435
+ // injected through the same runtime seam as evaluateToolCall. Older
3436
+ // installed packages without the export simply leave the option
3437
+ // undefined (no lease plane — the capability-honesty surface says so).
3438
+ checkActionLease: typeof (defenceMod as any).evaluateToolCallLease === 'function'
3439
+ ? (toolName, args, sessionId) =>
3440
+ (defenceMod as any).evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
3441
+ : undefined,
3442
+ releaseActionLease: typeof (defenceMod as any).releaseToolCallLease === 'function'
3443
+ ? (toolName, args, sessionId) =>
3444
+ (defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3445
+ : undefined,
3434
3446
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
3435
3447
  cloudApiKey: (scConfig as any).cloudApiKey ?? '',
3436
3448
  cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
package/interceptor.ts CHANGED
@@ -438,6 +438,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
438
438
  { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
439
439
  // Guard's own approval store (#118): agent-side writes here mint approvals.
440
440
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
441
+ // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
442
+ { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
441
443
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
442
444
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
443
445
  { re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
@@ -770,6 +772,24 @@ interface InterceptorOptions {
770
772
  * (or throwing) means no escalation — a broken scanner must never become a
771
773
  * new source of denials. */
772
774
  sessionTaint?: (sessionId: string | undefined) => { reason: string } | null;
775
+ /** #227: session action lease — injected from `shieldcortex/defence` at
776
+ * runtime (evaluateToolCallLease). Null for unscoped calls (the common
777
+ * case); a non-allow decision for a scoped call is a refusal that must
778
+ * precede every approval affordance. A THROW is treated as no-lease; state
779
+ * unreadability fails closed INSIDE the implementation. */
780
+ checkActionLease?: (
781
+ toolName: string,
782
+ args: Record<string, unknown>,
783
+ sessionId: string | undefined,
784
+ ) => {
785
+ scope: string;
786
+ decision: { verdict: string; reason: string };
787
+ acquired?: boolean;
788
+ ledgerChanged?: { fromHash: string; toHash: string };
789
+ } | null;
790
+ /** #227: release a lease this call minted early, when the guard then blocks
791
+ * the action. Best-effort; a hold self-heals at its TTL if this is absent. */
792
+ releaseActionLease?: (toolName: string, args: Record<string, unknown>, sessionId: string | undefined) => void;
773
793
  /** Approval broker (#143), injected from `shieldcortex/defence` at runtime.
774
794
  * Absent, or present with `config.enabled: false`, means no model is ever
775
795
  * consulted and the guard behaves exactly as it did before #143. */
@@ -983,6 +1003,38 @@ export function createInterceptor(
983
1003
  async function runActionGuard(context: ToolCallContext): Promise<void> {
984
1004
  if (!actionGuardCfg.enabled) return;
985
1005
 
1006
+ // Session action lease (#227) — EARLY, before the guard evaluator (which may
1007
+ // be unwired or throw). A freeze is a HARD control and must bind regardless
1008
+ // of the guard's own state; running it only after a successful evaluation
1009
+ // would let a frozen action through the guard-unavailable path. Unscoped
1010
+ // calls return null and cost nothing; a THROW is treated as no-lease (a
1011
+ // broken lease layer must not deny everything); state unreadability fails
1012
+ // closed to 'unknown' inside the store.
1013
+ let leaseGate: {
1014
+ scope: string; decision: { verdict: string; reason: string };
1015
+ acquired?: boolean; ledgerChanged?: { fromHash: string; toHash: string };
1016
+ } | null = null;
1017
+ try {
1018
+ leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
1019
+ if (leaseGate?.ledgerChanged) {
1020
+ log.warn(
1021
+ `[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`,
1022
+ );
1023
+ }
1024
+ if (leaseGate && leaseGate.decision.verdict !== 'allow') {
1025
+ emitAudit({
1026
+ ...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] } as ToolGuardVerdictLike, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
1027
+ action: 'auto_deny', outcome: 'auto_denied',
1028
+ });
1029
+ log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
1030
+ throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
1031
+ }
1032
+ } catch (err) {
1033
+ // A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
1034
+ if (err instanceof Error && err.message.startsWith('ShieldCortex:')) throw err;
1035
+ leaseGate = null;
1036
+ }
1037
+
986
1038
  if (typeof evaluateToolCall !== 'function') {
987
1039
  handleGuardUnavailable(context, 'evaluateToolCall not wired');
988
1040
  return;
@@ -1065,6 +1117,11 @@ export function createInterceptor(
1065
1117
 
1066
1118
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
1067
1119
  if (v.decision === 'block') {
1120
+ // #227: release any lease this call minted early — a blocked action must
1121
+ // not leave a hold on that scope (self-heals at TTL if release fails).
1122
+ if (leaseGate?.acquired) {
1123
+ try { options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId); } catch { /* self-heals */ }
1124
+ }
1068
1125
  emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
1069
1126
  // Surface the block to the gateway log (journald). Blocks are recorded in
1070
1127
  // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.49.0",
3
+ "version": "4.50.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.49.0",
3
+ "version": "4.50.0",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",