@reefclaw/openclaw-plugin 0.1.14 → 0.1.15

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.
Files changed (38) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  5. package/bridge/gateway/gateway-ws-client.js +41 -11
  6. package/bridge/providers/gateway.d.ts +28 -0
  7. package/bridge/providers/gateway.js +130 -5
  8. package/bridge/providers/onboarding-commands.d.ts +8 -5
  9. package/bridge/providers/onboarding-commands.js +1 -1
  10. package/bridge/providers/risk-calculator.d.ts +61 -2
  11. package/bridge/providers/risk-calculator.js +92 -20
  12. package/bridge/utils/skill-signing.js +8 -3
  13. package/config/plugin-config-io.js +5 -0
  14. package/exchange-adapter.d.ts +13 -0
  15. package/index.js +15 -4
  16. package/ingest/event-loop-monitor.d.ts +11 -0
  17. package/ingest/event-loop-monitor.js +77 -0
  18. package/ingest/readiness-reporter.d.ts +9 -0
  19. package/ingest/readiness-reporter.js +54 -5
  20. package/live/user-data-stream.js +10 -2
  21. package/openclaw.plugin.json +1 -1
  22. package/package.json +5 -4
  23. package/risk/pre-trade-check.js +18 -5
  24. package/skills/reefclaw/SKILL.md +6 -11
  25. package/strategy/condition-registry.js +9 -2
  26. package/strategy/evaluator.d.ts +5 -0
  27. package/tools/cancel-all-orders.js +9 -1
  28. package/tools/create-order.js +18 -1
  29. package/tools/get-bracket-config.d.ts +21 -2
  30. package/tools/get-bracket-config.js +18 -2
  31. package/tools/set-trading-mode.js +6 -3
  32. package/venues/hyperliquid/hl-fill-ingest.js +20 -1
  33. package/venues/hyperliquid/hl-live-adapter.d.ts +4 -0
  34. package/venues/hyperliquid/hl-live-adapter.js +4 -0
  35. package/venues/registry.js +8 -7
  36. package/wave9/paper-admission-guard.d.ts +12 -1
  37. package/wave9/paper-admission-guard.js +12 -1
  38. package/scripts/assemble.mjs +0 -130
@@ -66,11 +66,26 @@ export declare class Bridge {
66
66
  private stopThrottleTimer;
67
67
  private handleRequest;
68
68
  private handleStateChange;
69
- /** Today every authenticated session carries operator.write via the
70
- * relay-level Clerk token. This method exists as the enforcement seam —
71
- * a future PR can drop per-session roles in here without restructuring
72
- * the dispatcher. Returning true keeps current behavior identical. */
73
- private hasOperatorWriteScope;
69
+ /** Whether operator-write methods are allowed for the current session mix.
70
+ *
71
+ * This is NOT a per-frame credential check, and does not claim to be. The
72
+ * real authorization boundary for operator-write methods is layered
73
+ * OUTSIDE this method:
74
+ * 1. Transport: every frame reaching handleRequest arrives over the relay
75
+ * connection, which authenticated the per-user rc_ token against the
76
+ * user-bound relay room before any frame flows (relay/src/auth.ts).
77
+ * There is no other ingress to this dispatcher.
78
+ * 2. State-mutating tools: the plugin independently refuses
79
+ * set_trading_mode / set_exchange_credentials /
80
+ * clear_exchange_credentials / set_bracket_requirement /
81
+ * test_exchange_credentials without the `operator_token` provenance
82
+ * proof (audit F12) — a compromised bridge still cannot flip trading
83
+ * state.
84
+ * While every relay session is an operator session by construction, this
85
+ * returns true; it is the seam where per-session roles attach if a
86
+ * non-operator ingress is ever added, without restructuring the
87
+ * dispatcher. */
88
+ private operatorWriteAllowed;
74
89
  /** Handle a set_trading_mode request. Returns 'responded' if the handler
75
90
  * already wrote the response frame (e.g. on validation failure), otherwise
76
91
  * returns the structured outcome for the caller to wrap in a success
package/bridge/bridge.js CHANGED
@@ -326,15 +326,15 @@ export class Bridge {
326
326
  async handleRequest(frame) {
327
327
  const { id, method, params } = frame;
328
328
  try {
329
- // ---- Operator-write scope gate ----
330
- // Methods in OPERATOR_WRITE_METHODS require an elevated scope. Today,
331
- // all connected sessions inherit operator.write from the relay-level
332
- // authentication (Clerk-issued token), so this check is effectively a
333
- // whitelist it exists so a future PR can attach per-session roles
334
- // without restructuring the dispatcher. Emergency methods are on the
335
- // list for the same reason (they are already covered today, but the
336
- // list is the forward-compatible gate).
337
- if (OPERATOR_WRITE_METHODS.has(method) && !this.hasOperatorWriteScope()) {
329
+ // ---- Operator-write gate ----
330
+ // Methods in OPERATOR_WRITE_METHODS are dashboard-operator actions. The
331
+ // enforcement is layered and documented on operatorWriteAllowed():
332
+ // transport auth at the relay (token↔room binding the only ingress to
333
+ // this dispatcher) + plugin-side operator_token provenance on every
334
+ // state-mutating tool. This branch is the forward-compatible seam for
335
+ // per-session roles; it does not itself verify credentials. Emergency
336
+ // methods are on the list for the same forward-compatibility reason.
337
+ if (OPERATOR_WRITE_METHODS.has(method) && !this.operatorWriteAllowed()) {
338
338
  audit('operator_write.denied', { method, id });
339
339
  this.connector.sendResponse(id, false, undefined, {
340
340
  code: 403,
@@ -578,11 +578,26 @@ export class Bridge {
578
578
  logger.info(TAG, `Connector state: ${state}`);
579
579
  }
580
580
  // ---- Operator onboarding (PR2) ----
581
- /** Today every authenticated session carries operator.write via the
582
- * relay-level Clerk token. This method exists as the enforcement seam —
583
- * a future PR can drop per-session roles in here without restructuring
584
- * the dispatcher. Returning true keeps current behavior identical. */
585
- hasOperatorWriteScope() {
581
+ /** Whether operator-write methods are allowed for the current session mix.
582
+ *
583
+ * This is NOT a per-frame credential check, and does not claim to be. The
584
+ * real authorization boundary for operator-write methods is layered
585
+ * OUTSIDE this method:
586
+ * 1. Transport: every frame reaching handleRequest arrives over the relay
587
+ * connection, which authenticated the per-user rc_ token against the
588
+ * user-bound relay room before any frame flows (relay/src/auth.ts).
589
+ * There is no other ingress to this dispatcher.
590
+ * 2. State-mutating tools: the plugin independently refuses
591
+ * set_trading_mode / set_exchange_credentials /
592
+ * clear_exchange_credentials / set_bracket_requirement /
593
+ * test_exchange_credentials without the `operator_token` provenance
594
+ * proof (audit F12) — a compromised bridge still cannot flip trading
595
+ * state.
596
+ * While every relay session is an operator session by construction, this
597
+ * returns true; it is the seam where per-session roles attach if a
598
+ * non-operator ingress is ever added, without restructuring the
599
+ * dispatcher. */
600
+ operatorWriteAllowed() {
586
601
  return true;
587
602
  }
588
603
  /** Handle a set_trading_mode request. Returns 'responded' if the handler
package/bridge/config.js CHANGED
@@ -11,6 +11,12 @@ const OPENCLAW_DIR = join(homedir(), '.openclaw');
11
11
  const CONFIG_PATH = join(OPENCLAW_DIR, 'openclaw.json');
12
12
  const DEFAULT_RELAY_URL = 'wss://reefclaw.radunlupsa.partykit.dev';
13
13
  // ---- Read/write OpenClaw config ----
14
+ //
15
+ // Scope contract: these helpers touch ONLY the ReefClaw skill entry
16
+ // (skills.entries.reefclaw.config — the token/userId/relayUrl the USER pasted
17
+ // during onboarding) and preserve every other key verbatim. They exist so the
18
+ // connector can pick up a connection the user saved via chat and persist it
19
+ // across restarts — not to manage OpenClaw's own configuration.
14
20
  /** Read the OpenClaw config file and extract ReefClaw skill settings.
15
21
  * Prefers the schema-valid nested shape (entry.config.*), falls back to the
16
22
  * legacy flat shape per-field. */
@@ -71,7 +71,10 @@ export declare class GatewayWsClient {
71
71
  private listeners;
72
72
  private readonly gatewayUrl;
73
73
  private readonly gatewayToken;
74
- constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'>, reconnect?: Partial<ReconnectConfig>);
74
+ private readonly requestAdminScope;
75
+ constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'> & {
76
+ requestAdminScope?: boolean;
77
+ }, reconnect?: Partial<ReconnectConfig>);
75
78
  /** Start the WebSocket connection and handshake */
76
79
  connect(): void;
77
80
  /** Disconnect and clean up. Cannot reconnect after this. */
@@ -5,6 +5,21 @@ import WebSocket from 'ws';
5
5
  import { logger } from '../logger.js';
6
6
  import { computeDelay } from '../utils/reconnect.js';
7
7
  const TAG = 'gateway-ws';
8
+ /** True when a `host[:port][/path]` string points at this machine. Used to
9
+ * decide the default scheme for bare gateway hosts (loopback → ws://, remote
10
+ * → wss://) and to warn on plaintext-to-remote. */
11
+ function isLoopbackHost(hostAndRest) {
12
+ const authority = hostAndRest.split('/')[0].toLowerCase();
13
+ if (authority.startsWith('[')) {
14
+ // Bracketed IPv6, e.g. [::1]:18789
15
+ const end = authority.indexOf(']');
16
+ return (end > 0 ? authority.slice(1, end) : authority.slice(1)) === '::1';
17
+ }
18
+ if (authority === '::1')
19
+ return true; // bare IPv6 loopback (no port possible)
20
+ const host = authority.split(':')[0];
21
+ return host === 'localhost' || host.startsWith('127.');
22
+ }
8
23
  // ---- Protocol constants ----
9
24
  // Protocol range we can speak. The gateway picks its own version if it falls
10
25
  // inside [min, max]. v3 = OpenClaw ≤2026.4.x (prod); v4 = OpenClaw 2026.6+
@@ -45,8 +60,14 @@ export class GatewayWsClient {
45
60
  listeners = new Map();
46
61
  gatewayUrl;
47
62
  gatewayToken;
63
+ requestAdminScope;
48
64
  constructor(config, reconnect) {
49
- // Convert to WebSocket URL: http→ws, https→wss, bare host:port→ws://
65
+ this.requestAdminScope = config.requestAdminScope === true;
66
+ // Convert to WebSocket URL: http→ws, https→wss. A bare host:port defaults
67
+ // by destination: loopback → ws:// (the normal localhost gateway), anything
68
+ // else → wss:// — a remote default must never silently downgrade to
69
+ // plaintext, because the hello frame carries the gateway token. An explicit
70
+ // ws:// to a remote host is honored but warned about below.
50
71
  let url = config.gatewayUrl.replace(/\/$/, '');
51
72
  if (/^https:\/\//.test(url)) {
52
73
  url = url.replace(/^https:\/\//, 'wss://');
@@ -55,7 +76,12 @@ export class GatewayWsClient {
55
76
  url = url.replace(/^http:\/\//, 'ws://');
56
77
  }
57
78
  else if (!/^wss?:\/\//.test(url)) {
58
- url = `ws://${url}`;
79
+ url = `${isLoopbackHost(url) ? 'ws' : 'wss'}://${url}`;
80
+ }
81
+ if (/^ws:\/\//.test(url) && !isLoopbackHost(url.slice('ws://'.length))) {
82
+ logger.warn(TAG, `Gateway URL ${url} is PLAINTEXT ws:// to a non-loopback host — the ` +
83
+ `gateway token will transit unencrypted. Use wss:// (or an SSH tunnel ` +
84
+ `to localhost) unless this network is fully trusted.`);
59
85
  }
60
86
  this.gatewayUrl = url;
61
87
  this.gatewayToken = config.gatewayToken;
@@ -280,15 +306,19 @@ export class GatewayWsClient {
280
306
  mode: 'backend',
281
307
  },
282
308
  role: 'operator',
283
- // operator.admin: cron management (the boot-time heartbeat-cron ensure
284
- // calls cron.list/cron.add) moved behind the admin scope on OpenClaw
285
- // 2026.7.x observed live on the first real npx onboarding
286
- // ("Heartbeat cron: skipped (missing scope: operator.admin)",
287
- // 2026-07-22, openclaw 2026.7.1-2). The gateway is localhost-bound and
288
- // this connection already holds operator.write (orders, chat), so the
289
- // marginal grant is small; without it fresh 2026.7.x installs never
290
- // get a heartbeat cron.
291
- scopes: ['operator.read', 'operator.write', 'operator.admin'],
309
+ // Least privilege: operator.admin is requested ONLY while the
310
+ // once-per-install heartbeat-cron ensure is still owed (cron.list/
311
+ // cron.add moved behind the admin scope on OpenClaw 2026.7.x
312
+ // "Heartbeat cron: skipped (missing scope: operator.admin)", observed
313
+ // 2026-07-22 on openclaw 2026.7.1-2; without it fresh installs never
314
+ // get a heartbeat cron). Once the ensure marker exists, the caller
315
+ // constructs this client with requestAdminScope=false and every later
316
+ // session runs on read/write only.
317
+ scopes: [
318
+ 'operator.read',
319
+ 'operator.write',
320
+ ...(this.requestAdminScope ? ['operator.admin'] : []),
321
+ ],
292
322
  auth: {
293
323
  token: this.gatewayToken,
294
324
  ...(this.deviceToken && { deviceToken: this.deviceToken }),
@@ -100,6 +100,7 @@ export declare class GatewayProvider implements OpenClawProvider {
100
100
  private agentStateInterval;
101
101
  private toolRetryInterval;
102
102
  private regimeInterval;
103
+ private tradingParamsInterval;
103
104
  private signalInterval;
104
105
  private missionInterval;
105
106
  private analyticsInterval;
@@ -114,6 +115,17 @@ export declare class GatewayProvider implements OpenClawProvider {
114
115
  private atrData;
115
116
  private atrSampleCount;
116
117
  private currentRegime;
118
+ /** ★ The tenant's ENFORCED risk limits, mirrored from intel
119
+ * `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
120
+ * rejects orders against. Null until the first fetch lands (then
121
+ * DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
122
+ * banner ran on a hardcoded ladder that no enforcer used, so it could show
123
+ * a red "Gate fail" for a limit nothing blocked on — and, in the other
124
+ * direction, stay green while the plugin rejected every entry. */
125
+ private riskLimits;
126
+ /** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
127
+ * stale/stricter local ladder here can flatten a live book early. */
128
+ private drawdownThresholds;
117
129
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
118
130
  private lastTradeTs;
119
131
  /** Epoch ms of the most recent LIVE fill observed this process (issue #248).
@@ -416,6 +428,22 @@ export declare class GatewayProvider implements OpenClawProvider {
416
428
  /** Log first error per poller, suppress subsequent repeats. */
417
429
  private trackIntelError;
418
430
  private startRegimePoller;
431
+ /**
432
+ * Mirror the tenant's trading params into the risk limits the dashboard
433
+ * banner and the RED-zone auto-flatten run on.
434
+ *
435
+ * Deliberately NOT routed through `startIntelligencePoller`: that helper
436
+ * appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
437
+ * limits are a safety input, not a dashboard read — shedding intel display
438
+ * load must not silently revert the operator's configured limits to the
439
+ * fallback ladder.
440
+ *
441
+ * Fail-open by design: a failed fetch keeps the last good values (or the
442
+ * fallback constants on a cold start). We never tighten limits from a
443
+ * parse failure, and we never widen them past what the plugin enforces —
444
+ * the plugin re-reads the same row for the gate that actually blocks.
445
+ */
446
+ private startTradingParamsPoller;
419
447
  private startSignalPoller;
420
448
  private startMissionPoller;
421
449
  private startAnalyticsPoller;
@@ -1,7 +1,7 @@
1
1
  // GatewayProvider — real OpenClaw gateway integration.
2
2
  // Connects to the OpenClaw gateway via HTTP and WebSocket,
3
3
  // streams live trading data, and emits ProviderEvents.
4
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { logger, formatError } from '../logger.js';
@@ -14,7 +14,7 @@ import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, ex
14
14
  import { Poller } from '../gateway/poller.js';
15
15
  import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
16
16
  import { parseIdentityName } from '../utils/identity-name.js';
17
- import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
17
+ import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
18
18
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
19
19
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
20
20
  const TAG = 'gateway';
@@ -220,6 +220,7 @@ export class GatewayProvider {
220
220
  agentStateInterval = null;
221
221
  toolRetryInterval = null;
222
222
  regimeInterval = null;
223
+ tradingParamsInterval = null;
223
224
  signalInterval = null;
224
225
  missionInterval = null;
225
226
  analyticsInterval = null;
@@ -240,6 +241,17 @@ export class GatewayProvider {
240
241
  atrSampleCount = 0;
241
242
  // ---- Current regime (Phase 9e: regime-conditional risk) ----
242
243
  currentRegime = 'UNKNOWN';
244
+ /** ★ The tenant's ENFORCED risk limits, mirrored from intel
245
+ * `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
246
+ * rejects orders against. Null until the first fetch lands (then
247
+ * DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
248
+ * banner ran on a hardcoded ladder that no enforcer used, so it could show
249
+ * a red "Gate fail" for a limit nothing blocked on — and, in the other
250
+ * direction, stay green while the plugin rejected every entry. */
251
+ riskLimits = null;
252
+ /** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
253
+ * stale/stricter local ladder here can flatten a live book early. */
254
+ drawdownThresholds = null;
243
255
  // ---- Trade tracking ----
244
256
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
245
257
  lastTradeTs = null;
@@ -352,6 +364,10 @@ export class GatewayProvider {
352
364
  clearInterval(this.regimeInterval);
353
365
  this.regimeInterval = null;
354
366
  }
367
+ if (this.tradingParamsInterval) {
368
+ clearInterval(this.tradingParamsInterval);
369
+ this.tradingParamsInterval = null;
370
+ }
355
371
  if (this.signalInterval) {
356
372
  clearInterval(this.signalInterval);
357
373
  this.signalInterval = null;
@@ -671,7 +687,10 @@ export class GatewayProvider {
671
687
  return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
672
688
  }
673
689
  async testExchangeCredentials(apiKey, secret, testnet) {
674
- return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
690
+ return executeTestExchangeCredentials(
691
+ // operatorToken required since the SkillSpector hardening: the plugin
692
+ // refuses the credential-validation call without dashboard provenance.
693
+ { http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
675
694
  }
676
695
  async clearExchangeCredentials() {
677
696
  return executeClearExchangeCredentials({
@@ -860,7 +879,13 @@ export class GatewayProvider {
860
879
  // Step 3: Create WS client and connect FIRST to obtain device token.
861
880
  // OpenClaw's REST API requires a device token from the WS handshake,
862
881
  // not the raw gateway auth token. Tool discovery must wait for this.
863
- this.wsClient = new GatewayWsClient(this.config);
882
+ // Least privilege: operator.admin (cron management) is requested only
883
+ // while the once-per-install heartbeat-cron ensure is still owed — after
884
+ // the marker exists, every session runs on operator.read/write alone.
885
+ this.wsClient = new GatewayWsClient({
886
+ ...this.config,
887
+ requestAdminScope: !existsSync(join(homedir(), '.openclaw', 'workspace', 'heartbeat-cron-ensured.json')),
888
+ });
864
889
  this.wireWsEvents();
865
890
  // Wait for WS handshake to complete (or fail)
866
891
  const deviceToken = await this.waitForDeviceToken();
@@ -925,6 +950,9 @@ export class GatewayProvider {
925
950
  }
926
951
  // Step 6: Start regime poller if intelligence service is configured
927
952
  this.startRegimePoller();
953
+ // Step 6b: Mirror the tenant's ENFORCED risk limits. A safety input, so it
954
+ // runs even when the intel display pollers are shed.
955
+ this.startTradingParamsPoller();
928
956
  // Step 7: Start signal poller if intelligence service is configured
929
957
  this.startSignalPoller();
930
958
  // Step 8: Start mission poller if intelligence service is configured
@@ -1641,7 +1669,9 @@ export class GatewayProvider {
1641
1669
  /** Simple heat score (0–100) matching webapp's HeatGauge logic */
1642
1670
  computeHeatScoreSimple(metrics) {
1643
1671
  const { grossExposure, dailyDrawdown, ordersPerMinute, cancelsPerMinute } = metrics.utilization;
1644
- const limits = DEFAULT_RISK_LIMITS;
1672
+ // Same enforced limits the breach detector uses — otherwise the heat gauge
1673
+ // and the gate disagree about what "hot" means.
1674
+ const limits = this.riskLimits ?? DEFAULT_RISK_LIMITS;
1645
1675
  const scores = [
1646
1676
  limits.position.maxGrossExposure > 0 ? (grossExposure / limits.position.maxGrossExposure) * 100 : 0,
1647
1677
  limits.loss.maxDailyDrawdown < 0 ? (dailyDrawdown / limits.loss.maxDailyDrawdown) * 100 : 0,
@@ -2302,6 +2332,8 @@ export class GatewayProvider {
2302
2332
  atrData: this.atrData ?? undefined,
2303
2333
  regime: this.currentRegime,
2304
2334
  realizedPnlToday: this.realizedPnlToday ?? undefined,
2335
+ baseLimits: this.riskLimits ?? undefined,
2336
+ drawdownThresholds: this.drawdownThresholds ?? undefined,
2305
2337
  });
2306
2338
  // Store pruned timestamps back (non-mutating function returns new arrays)
2307
2339
  this.orderTimestamps = result.prunedOrderTimestamps;
@@ -2536,6 +2568,99 @@ export class GatewayProvider {
2536
2568
  this.fire('regimeUpdate', { ...data, event: 'regime_update', symbol });
2537
2569
  }, 'regimeInterval');
2538
2570
  }
2571
+ /**
2572
+ * Mirror the tenant's trading params into the risk limits the dashboard
2573
+ * banner and the RED-zone auto-flatten run on.
2574
+ *
2575
+ * Deliberately NOT routed through `startIntelligencePoller`: that helper
2576
+ * appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
2577
+ * limits are a safety input, not a dashboard read — shedding intel display
2578
+ * load must not silently revert the operator's configured limits to the
2579
+ * fallback ladder.
2580
+ *
2581
+ * Fail-open by design: a failed fetch keeps the last good values (or the
2582
+ * fallback constants on a cold start). We never tighten limits from a
2583
+ * parse failure, and we never widen them past what the plugin enforces —
2584
+ * the plugin re-reads the same row for the gate that actually blocks.
2585
+ */
2586
+ startTradingParamsPoller() {
2587
+ const url = this.config.intelligenceUrl;
2588
+ const token = this.config.connectionToken;
2589
+ if (!url || !token)
2590
+ return;
2591
+ const fullUrl = `${url}/api/trading-params`;
2592
+ logger.info(TAG, `Starting trading-params poller: ${fullUrl}`);
2593
+ const poll = async () => {
2594
+ if (!this.started)
2595
+ return;
2596
+ try {
2597
+ const res = await fetch(fullUrl, {
2598
+ headers: { Authorization: `Bearer ${token}` },
2599
+ signal: AbortSignal.timeout(10_000),
2600
+ });
2601
+ if (!res.ok) {
2602
+ this.trackIntelError('tradingParams', `HTTP ${res.status}`);
2603
+ return;
2604
+ }
2605
+ const body = (await res.json());
2606
+ const p = body?.params;
2607
+ if (!p)
2608
+ return;
2609
+ // Every field is bounds-checked against hardcoded bands
2610
+ // (TENANT_LIMIT_BOUNDS / validateDrawdownLadder) — CLAUDE.md
2611
+ // load-bearing rule: central config moves knobs only WITHIN hardcoded
2612
+ // bounds; it must never be able to disable a safety mechanism.
2613
+ // Out-of-band → field ignored, previous value kept.
2614
+ const B = TENANT_LIMIT_BOUNDS;
2615
+ const maxPositionSize = boundedNum(p.maxPositionSize, B.maxPositionSize.min, B.maxPositionSize.max);
2616
+ const maxOpenPositions = boundedNum(p.maxOpenPositions, B.maxOpenPositions.min, B.maxOpenPositions.max);
2617
+ const maxGrossExposure = boundedNum(p.maxGrossExposure, B.maxGrossExposure.min, B.maxGrossExposure.max);
2618
+ const maxPerTradeLoss = boundedNum(p.maxPerTradeLoss, B.maxPerTradeLoss.min, B.maxPerTradeLoss.max);
2619
+ // Validate the zone ladder FIRST — the loss limit below reads from the
2620
+ // ACCEPTED ladder, so a rejected row can't leak its RED into
2621
+ // maxDailyDrawdown (which drives the dailyDrawdown AUTO_PAUSE breach).
2622
+ const base = this.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
2623
+ const next = {
2624
+ YELLOW: boundedNum(p.drawdownYellow, -1, 0) ?? base.YELLOW,
2625
+ ORANGE: boundedNum(p.drawdownOrange, -1, 0) ?? base.ORANGE,
2626
+ RED: boundedNum(p.drawdownRed, -1, 0) ?? base.RED,
2627
+ };
2628
+ const ladderError = validateDrawdownLadder(next);
2629
+ if (ladderError === null) {
2630
+ this.drawdownThresholds = next;
2631
+ }
2632
+ else {
2633
+ logger.warn(TAG, `Ignoring drawdown zones from trading params: ${ladderError}`);
2634
+ }
2635
+ const prev = this.riskLimits ?? DEFAULT_RISK_LIMITS;
2636
+ const acceptedRed = this.drawdownThresholds?.RED;
2637
+ this.riskLimits = {
2638
+ position: {
2639
+ maxPositionSize: maxPositionSize ?? prev.position.maxPositionSize,
2640
+ maxOpenPositions: maxOpenPositions ?? prev.position.maxOpenPositions,
2641
+ maxGrossExposure: maxGrossExposure ?? prev.position.maxGrossExposure,
2642
+ // Intel has no separate net-tilt param; the gross cap bounds it
2643
+ // (|net| ≤ gross always, so this can never fire before gross).
2644
+ maxNetExposure: maxGrossExposure ?? prev.position.maxNetExposure,
2645
+ },
2646
+ loss: {
2647
+ // Drawdown limit tracks the operator's RED boundary rather than a
2648
+ // third independent number.
2649
+ maxDailyDrawdown: acceptedRed ?? prev.loss.maxDailyDrawdown,
2650
+ maxPerTradeLoss: maxPerTradeLoss != null ? -Math.abs(maxPerTradeLoss) : prev.loss.maxPerTradeLoss,
2651
+ maxDailyLoss: prev.loss.maxDailyLoss,
2652
+ },
2653
+ rate: prev.rate,
2654
+ };
2655
+ this.intelErrors.tradingParams = 0;
2656
+ }
2657
+ catch (err) {
2658
+ this.trackIntelError('tradingParams', formatError(err));
2659
+ }
2660
+ };
2661
+ void poll();
2662
+ this.tradingParamsInterval = setInterval(() => { void poll(); }, 60_000);
2663
+ }
2539
2664
  startSignalPoller() {
2540
2665
  this.startIntelligencePoller('signals', 'signals', 10_000, (raw) => {
2541
2666
  const data = raw;
@@ -38,12 +38,15 @@ export interface ClearExchangeCredentialsOutcome {
38
38
  export interface OnboardingContext {
39
39
  http: GatewayHttpClient | null;
40
40
  toolMap: ToolMap;
41
- /** rc_* connection token, injected as `operator_token` on the four
42
- * state-MUTATING operator tools (set_trading_mode /
41
+ /** rc_* connection token, injected as `operator_token` on the operator-only
42
+ * tools: the four state-MUTATING ones (set_trading_mode /
43
43
  * set_exchange_credentials / clear_exchange_credentials /
44
- * set_bracket_requirement). The plugin refuses those calls without it
45
- * (audit 2026-07-26 F12) this is the operator-provenance proof that the
46
- * request came through the dashboard path, which the agent cannot forge
44
+ * set_bracket_requirement, audit 2026-07-26 F12) plus
45
+ * test_exchange_credentials (read-only, but a live authenticated exchange
46
+ * call with caller-supplied keys without the gate it would hand a
47
+ * prompt-injected agent a credential-validation oracle). The plugin refuses
48
+ * these calls without it — the operator-provenance proof that the request
49
+ * came through the dashboard path, which the agent cannot forge
47
50
  * conversationally (chat redaction strips rc_* tokens). */
48
51
  operatorToken?: string;
49
52
  }
@@ -57,7 +57,7 @@ export async function executeTestExchangeCredentials(ctx, apiKey, secret, testne
57
57
  };
58
58
  }
59
59
  try {
60
- const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
60
+ const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
61
61
  return result.data;
62
62
  }
63
63
  catch (err) {
@@ -10,11 +10,53 @@ export declare const REGIME_LIMIT_PROFILES: Record<string, Partial<{
10
10
  }>>;
11
11
  /** Apply regime-conditional adjustments to limits. */
12
12
  export declare function applyRegimeAdjustment(baseLimits: RiskLimits, regime: string): RiskLimits;
13
+ /** Fallback zone boundaries, used only until the tenant's trading params load.
14
+ * The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
15
+ * trading params — the same numbers the plugin's pre-trade gate rejects on.
16
+ * Keeping a second hardcoded ladder here is how the skill ended up
17
+ * auto-flattening at -2.5% on an account configured to -4%. */
13
18
  export declare const DRAWDOWN_ZONE_THRESHOLDS: {
14
19
  readonly YELLOW: -0.01;
15
20
  readonly ORANGE: -0.02;
16
21
  readonly RED: -0.025;
17
22
  };
23
+ export interface DrawdownThresholds {
24
+ YELLOW: number;
25
+ ORANGE: number;
26
+ RED: number;
27
+ }
28
+ /** The auto-flatten trigger can never be configured looser than −10%. */
29
+ export declare const AUTO_FLATTEN_RED_FLOOR = -0.1;
30
+ /** Sanity bands for tenant limit fields. Values outside → field ignored,
31
+ * previous value kept. Wide on purpose: these reject nonsense, not policy. */
32
+ export declare const TENANT_LIMIT_BOUNDS: {
33
+ readonly maxPositionSize: {
34
+ readonly min: 1;
35
+ readonly max: 1000000000;
36
+ };
37
+ readonly maxOpenPositions: {
38
+ readonly min: 1;
39
+ readonly max: 100;
40
+ };
41
+ readonly maxGrossExposure: {
42
+ readonly min: 0.01;
43
+ readonly max: 20;
44
+ };
45
+ readonly maxPerTradeLoss: {
46
+ readonly min: 0.01;
47
+ readonly max: 10000000;
48
+ };
49
+ };
50
+ /** Finite number within [min, max], else null. */
51
+ export declare function boundedNum(v: unknown, min: number, max: number): number | null;
52
+ /**
53
+ * Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
54
+ * a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
55
+ * losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
56
+ * put the book straight into RED and auto-flatten it); RED no looser than
57
+ * AUTO_FLATTEN_RED_FLOOR.
58
+ */
59
+ export declare function validateDrawdownLadder(t: DrawdownThresholds): string | null;
18
60
  export type DrawdownZone = 'GREEN' | 'YELLOW' | 'ORANGE' | 'RED';
19
61
  /** State snapshot needed to compute equity. */
20
62
  export interface EquityState {
@@ -36,6 +78,12 @@ export interface RiskState {
36
78
  };
37
79
  regime?: string;
38
80
  realizedPnlToday?: number;
81
+ /** The tenant's configured limits (from intel `/api/trading-params`) — the
82
+ * SAME numbers the plugin's pre-trade gate enforces. Omitted until the
83
+ * first fetch lands, in which case DEFAULT_RISK_LIMITS applies. */
84
+ baseLimits?: RiskLimits;
85
+ /** The tenant's configured drawdown-zone boundaries. Omitted → fallback. */
86
+ drawdownThresholds?: DrawdownThresholds;
39
87
  }
40
88
  /** Result of computeRiskMetrics including pruned timestamp arrays. */
41
89
  export interface RiskMetricsResult {
@@ -51,7 +99,7 @@ export declare function computeVolFactor(state: {
51
99
  /** Apply volatility adjustment to base limits. Tightens position/exposure, leaves loss/rate unchanged. */
52
100
  export declare function applyVolatilityAdjustment(baseLimits: RiskLimits, volFactor: number): RiskLimits;
53
101
  /** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
54
- export declare function getDrawdownZone(drawdownRatio: number): DrawdownZone;
102
+ export declare function getDrawdownZone(drawdownRatio: number, thresholds?: DrawdownThresholds): DrawdownZone;
55
103
  /** Human-readable message for a drawdown zone. */
56
104
  export declare function getDrawdownZoneMessage(zone: DrawdownZone, ratio: number): string;
57
105
  /**
@@ -80,6 +128,17 @@ export declare function countRecentEvents(timestamps: number[]): {
80
128
  };
81
129
  /**
82
130
  * Detect risk limit breaches from current metrics.
131
+ *
132
+ * ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
133
+ * tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
134
+ * orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
135
+ * the dashboard must mean "the agent is being blocked right now".
136
+ *
137
+ * `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
138
+ * live only in this file — no enforcer applies them — so they may raise a
139
+ * WARNING (amber, indication) but must never produce a CRITICAL. Conflating
140
+ * the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
141
+ * live book whose 1.5x enforced limit was never even approached.
83
142
  */
84
143
  export declare function detectBreaches(metrics: {
85
144
  grossExposure: number;
@@ -88,7 +147,7 @@ export declare function detectBreaches(metrics: {
88
147
  dailyLoss?: number;
89
148
  ordersPerMinute: number;
90
149
  cancelsPerMinute: number;
91
- }, limits: RiskLimits): RiskBreach[];
150
+ }, limits: RiskLimits, advisoryLimits?: RiskLimits): RiskBreach[];
92
151
  /**
93
152
  * Compute full risk metrics from a state snapshot.
94
153
  * Returns metrics + pruned timestamp arrays for the caller to store.