agentschat-mcp 0.36.5 → 0.36.8

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/src/server.ts CHANGED
@@ -51,11 +51,14 @@ import {
51
51
  DEFAULT_GROK_BINDS_FILENAME,
52
52
  boundProfileForConversation,
53
53
  gateSwitchProfile,
54
+ grokBindApplies,
55
+ nonGrokWakeReason,
56
+ GROK_BINDS_META_FILENAME,
54
57
  profileNameFromPath,
55
58
  shouldHealBoundIdentity,
56
59
  } from "./grok-bind.ts";
57
60
  import { decideTermsConsent, TERMS_URL } from "./terms.ts";
58
- import { annotateTools } from "./tool-annotations.ts";
61
+ import { annotateTools, TOOL_ANNOTATIONS } from "./tool-annotations.ts";
59
62
  import { fireWake, fireGrokWake, resolveGrokAgentId, resolveGrokGatewayPath, grokBearerFromGatewayConfig, grokPortFromGatewayConfig } from "./wake.ts";
60
63
  import pkg from "../package.json";
61
64
  import {
@@ -69,6 +72,15 @@ import { join, dirname } from "path";
69
72
  // Atomic + 0600-by-construction profile writer. Lives in its own module so the
70
73
  // "the key is never world-readable, even mid-write" property is directly testable.
71
74
  import { safeWriteProfile } from "./profile-store.ts";
75
+ import {
76
+ decideIdentityGuard,
77
+ advanceGuardRecord,
78
+ parseGuardRecord,
79
+ emptyGuardRecord,
80
+ IDENTITY_GUARD_FILENAME,
81
+ type GuardIdentity,
82
+ type IdentityGuardRecord,
83
+ } from "./identity-guard.ts";
72
84
  // Read-cursor persistence: a state change that merely *lives* in the teardown path.
73
85
  import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
74
86
  import { randomUUID } from "crypto";
@@ -194,7 +206,7 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
194
206
  let files: string[] = [];
195
207
  try { files = readdirSync(dir).filter((f: string) => f.endsWith(".json")); } catch {}
196
208
  for (const file of files) {
197
- if (file === DEFAULT_GROK_BINDS_FILENAME) continue; // bind map, not a profile
209
+ if (file === DEFAULT_GROK_BINDS_FILENAME || file === GROK_BINDS_META_FILENAME) continue; // bind map, not a profile
198
210
  const name = file.replace(/\.json$/, "");
199
211
  if (seen.has(name)) continue;
200
212
  seen.add(name);
@@ -223,7 +235,13 @@ function resolveProfile(): { path: string; source: ProfileSource; declaredName?:
223
235
  // is a declared identity (source grok-bind) so decideIdentity hard-errors
224
236
  // rather than falling through to a sibling bot.
225
237
  const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
226
- const conversationId = process.env.CURSOR_CONVERSATION_ID;
238
+ const nonGrokWake = nonGrokWakeReason(process.env);
239
+ // A non-Grok wake stack (Antigravity / ZCode / URL wake) may have inherited a
240
+ // Grok agent's CURSOR_CONVERSATION_ID — never adopt that Grok identity.
241
+ const conversationId = nonGrokWake ? undefined : process.env.CURSOR_CONVERSATION_ID;
242
+ if (nonGrokWake && process.env.CURSOR_CONVERSATION_ID) {
243
+ process.stderr.write(`[agentchat] grok-bind skipped: non-grok wake (${nonGrokWake})\n`);
244
+ }
227
245
  let binds: Record<string, string> = {};
228
246
  if (!grokToken && conversationId) {
229
247
  const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
@@ -541,6 +559,53 @@ if (!anonymousMode || explicitAgentId !== undefined || hasToken) {
541
559
  }
542
560
  }
543
561
 
562
+ // ── Identity guard (task_muk0154j_pstaosfn, incident 2026-09-27) ─────────────
563
+ // A host respawn after a WS bounce can resolve a DIFFERENT identity than this
564
+ // machine's MCP was trusted as (explicit selector lost → silent fall to the
565
+ // default profile — wrong-identity posts followed). Compare this boot's identity
566
+ // against the persisted trust record; on the default-fallback pattern, GATE
567
+ // write tools until whoami confirms. Precedence and registration are untouched.
568
+ let identityGateMessage = "";
569
+ const identityGuardFile = join(configDir, IDENTITY_GUARD_FILENAME);
570
+ function persistGuardRecord(rec: IdentityGuardRecord): void {
571
+ try {
572
+ mkdirSync(configDir, { recursive: true });
573
+ safeWriteProfile(identityGuardFile, rec);
574
+ } catch {}
575
+ }
576
+ function confirmLiveIdentityTrusted(): void {
577
+ identityGateMessage = "";
578
+ const live: GuardIdentity = {
579
+ agentId: AGENT_ID,
580
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
581
+ ...(declaredName ? { declaredName } : {}),
582
+ };
583
+ guardRecord = advanceGuardRecord(guardRecord, live, new Date().toISOString(), true);
584
+ persistGuardRecord(guardRecord);
585
+ }
586
+ let guardRecord: IdentityGuardRecord = (() => {
587
+ try { return parseGuardRecord(readFileSync(identityGuardFile, "utf-8")); } catch { return emptyGuardRecord(); }
588
+ })();
589
+ const guardBootIdentity: GuardIdentity = {
590
+ agentId: AGENT_ID,
591
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
592
+ ...(declaredName ? { declaredName } : {}),
593
+ };
594
+ {
595
+ const verdict = AGENT_ID
596
+ ? decideIdentityGuard(guardRecord, guardBootIdentity)
597
+ : ({ kind: "ok" } as const); // anonymous: no identity to protect, nothing to persist
598
+ if (verdict.kind === "gate") {
599
+ identityGateMessage = verdict.message;
600
+ process.stderr.write(`\n[agentchat] 🚨 ${verdict.message}\n\n`);
601
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), false);
602
+ } else {
603
+ if (verdict.kind === "warn") process.stderr.write(`[agentchat] WARNING: ${verdict.message}\n`);
604
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), true);
605
+ }
606
+ if (AGENT_ID) persistGuardRecord(guardRecord);
607
+ }
608
+
544
609
  // Update display name if provided via CLI
545
610
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
546
611
  profile.display_name = cliArgs.name;
@@ -2059,6 +2124,25 @@ function loadGrokBinds(): Record<string, string> {
2059
2124
  }
2060
2125
  }
2061
2126
 
2127
+ /** Explicit profile declared at startup (env / --profile / --name), else null. */
2128
+ const explicitStartupProfileName: string | null =
2129
+ profileSource === "env" || profileSource === "legacy-env" || profileSource === "flag-profile" || profileSource === "flag-name"
2130
+ ? (declaredName ?? null)
2131
+ : null;
2132
+
2133
+ /**
2134
+ * Whether grok-bind (switch lock + heal) applies to this process right now.
2135
+ * Non-Grok wakes and explicit --profile ≠ bound profile are exempt.
2136
+ */
2137
+ function currentGrokBindApplicability(binds: Record<string, string>) {
2138
+ return grokBindApplies({
2139
+ env: process.env,
2140
+ explicitProfileName: explicitStartupProfileName,
2141
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
2142
+ binds,
2143
+ });
2144
+ }
2145
+
2062
2146
  /**
2063
2147
  * Apply a profile file as the live identity and reconnect WS.
2064
2148
  * Shared by `switch_profile` and grok-bind heal.
@@ -2080,6 +2164,15 @@ function applyIdentityFromProfile(newProfile: any, targetFile: string): void {
2080
2164
  profile = newProfile;
2081
2165
  activeProfileFile = targetFile;
2082
2166
  anonymousMode = false;
2167
+ // An explicit in-process switch is a TRUSTED identity change — advance the
2168
+ // identity-guard record so the next boot doesn't alarm on it.
2169
+ guardRecord = advanceGuardRecord(
2170
+ guardRecord,
2171
+ { agentId: AGENT_ID, source: "switch_profile", ...(profileNameFromPath(targetFile) ? { declaredName: profileNameFromPath(targetFile)! } : {}) },
2172
+ new Date().toISOString(),
2173
+ true,
2174
+ );
2175
+ persistGuardRecord(guardRecord);
2083
2176
  wsReconnectAttempt = 0;
2084
2177
  heartbeat.start();
2085
2178
  connectWS();
@@ -2093,7 +2186,9 @@ function applyIdentityFromProfile(newProfile: any, targetFile: string): void {
2093
2186
  function ensureGrokBoundIdentity(): void {
2094
2187
  // Runtime recovery must honor the same explicit identity precedence as startup.
2095
2188
  if (profileSource !== "grok-bind" || hasToken || cliArgs.id || process.env.AGENTCHAT_AGENT_ID) return;
2096
- const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2189
+ const healBinds = loadGrokBinds();
2190
+ if (!currentGrokBindApplicability(healBinds).applies) return;
2191
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, healBinds);
2097
2192
  if (!boundName) return;
2098
2193
  const boundPath = nameToPath(boundName);
2099
2194
  if (!existsSync(boundPath)) return;
@@ -2139,6 +2234,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2139
2234
  // stole the live profile via switch_profile. No-op when unbound.
2140
2235
  ensureGrokBoundIdentity();
2141
2236
 
2237
+ // Identity guard (task_muk0154j_pstaosfn): after a suspect fallback (explicit
2238
+ // identity lost on respawn → default profile), WRITE tools refuse until the
2239
+ // agent looks at whoami and confirms. Read-only tools and whoami stay open.
2240
+ if (identityGateMessage && name !== "whoami" && TOOL_ANNOTATIONS[name]?.readOnlyHint !== true) {
2241
+ return { content: [{ type: "text", text: `🚨 ${identityGateMessage}` }], isError: true };
2242
+ }
2243
+
2142
2244
  if (name === "list_skills") {
2143
2245
  const { chat_id } = (args || {}) as { chat_id?: string };
2144
2246
  const out: any = {
@@ -3052,7 +3154,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3052
3154
  authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
3053
3155
  } catch { authLine = "REST auth: unknown"; }
3054
3156
  }
3055
- return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}` }] };
3157
+ // Identity guard: a whoami call IS the confirmation. Surface the gate state
3158
+ // prominently on this response, then trust the live identity and unblock writes.
3159
+ const gateNotice = identityGateMessage
3160
+ ? `🚨 IDENTITY GUARD: ${identityGateMessage}\n(This whoami call confirms identity — writes are now unblocked. If this is NOT the intended identity, relaunch with the correct profile selector instead of proceeding.)\n\n`
3161
+ : "";
3162
+ const wasGated = identityGateMessage !== "";
3163
+ const text = `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}`;
3164
+ if (wasGated) confirmLiveIdentityTrusted();
3165
+ return { content: [{ type: "text", text: gateNotice + text }] };
3056
3166
  }
3057
3167
 
3058
3168
  if (name === "list_channels") {
@@ -3227,9 +3337,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3227
3337
  // when set, or via current profile when Cursor stdio started with --profile
3228
3338
  // and no CURSOR_CONVERSATION_ID. Refuse switches that leave the locked
3229
3339
  // identity (including other Grok bots or Hermes/Spiral). No-op allowed.
3230
- const switchGate = gateSwitchProfile({
3340
+ // Non-Grok wakes (Antigravity / ZCode) and explicit --profile ≠ bound
3341
+ // profile are exempt: an inherited CURSOR_CONVERSATION_ID must not lock them.
3342
+ const switchBinds = loadGrokBinds();
3343
+ const bindApplies = currentGrokBindApplicability(switchBinds);
3344
+ const switchGate = !bindApplies.applies ? ({ kind: "allow" } as const) : gateSwitchProfile({
3231
3345
  conversationId: process.env.CURSOR_CONVERSATION_ID,
3232
- binds: loadGrokBinds(),
3346
+ binds: switchBinds,
3233
3347
  requestedProfileName: profile_name,
3234
3348
  currentProfileName: profileNameFromPath(activeProfileFile),
3235
3349
  });