@lore-co/cli 0.1.19 → 0.1.21

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/runtime.js CHANGED
@@ -12,6 +12,7 @@ import { writeInvocationAttemptBounded, writeInvocationCompletionBounded, } from
12
12
  import { SignedSnapshotError, verifySignedSnapshot, } from "./signed-snapshot.js";
13
13
  import { CONTEXT_CACHE_MAX_STALE_MS, isContextTransportFailure, selectCachedContext, } from "./context-fallback.js";
14
14
  import { RUNTIME_VERSION } from "./runtime-version.js";
15
+ import { guardRuleConflict, guardToolTrigger, supportedGuardRule, guardEmergencyCommand, emergencyOverrideApplies } from "@lore-co/core/guard-evaluation";
15
16
  const IS_STANDALONE_RUNTIME = typeof __LORE_STANDALONE__ === "boolean" && __LORE_STANDALONE__;
16
17
  export const COMMAND_HOOK_AGENT_NAMES = [
17
18
  "codex",
@@ -143,6 +144,14 @@ function normalizeHookInput(input, agent, environment) {
143
144
  };
144
145
  }
145
146
  if (agent === "copilot-cli") {
147
+ // CLI command hooks encode toolArgs as JSON; SDK-style inputs are objects.
148
+ let toolArgs = input.toolArgs;
149
+ if (typeof toolArgs === "string") {
150
+ try {
151
+ toolArgs = JSON.parse(toolArgs);
152
+ }
153
+ catch { /* Invalid input stays invalid. */ }
154
+ }
146
155
  const eventName = rawEventName === "userPromptTransformed"
147
156
  ? "UserPromptSubmit"
148
157
  : rawEventName === "preToolUse"
@@ -164,7 +173,7 @@ function normalizeHookInput(input, agent, environment) {
164
173
  ...(eventName === "PreToolUse"
165
174
  ? {
166
175
  tool_name: input.toolName,
167
- tool_input: input.toolArgs,
176
+ tool_input: toolArgs,
168
177
  }
169
178
  : {}),
170
179
  ...(eventName === "Stop"
@@ -180,9 +189,11 @@ function normalizeHookInput(input, agent, environment) {
180
189
  ? "UserPromptSubmit"
181
190
  : rawEventName === "post_model_turn"
182
191
  ? "AssistantResponse"
183
- : rawEventName === "session_start"
184
- ? "SessionStart"
185
- : rawEventName;
192
+ : rawEventName === "pre_tool_use"
193
+ ? "PreToolUse"
194
+ : rawEventName === "session_start"
195
+ ? "SessionStart"
196
+ : rawEventName;
186
197
  const sessionId = stringField(input.session_id) ??
187
198
  stringField(environment.POLYTOKEN_SESSION_ID);
188
199
  const cwd = stringField(input.cwd) ??
@@ -205,6 +216,7 @@ function normalizeHookInput(input, agent, environment) {
205
216
  ...input,
206
217
  ...(sessionId === undefined ? {} : { session_id: sessionId }),
207
218
  ...(cwd === undefined ? {} : { cwd }),
219
+ ...(eventName === "PreToolUse" ? { tool_input: input.input ?? input.tool_input } : {}),
208
220
  ...(prompt === undefined ? {} : { prompt }),
209
221
  ...(assistantMessage === undefined
210
222
  ? {}
@@ -223,15 +235,17 @@ function normalizeHookInput(input, agent, environment) {
223
235
  : [];
224
236
  const cwd = stringField(input.cwd) ??
225
237
  (workspaceRoots.length === 1 ? workspaceRoots[0] : undefined);
226
- const eventName = rawEventName === "beforeSubmitPrompt"
227
- ? "UserPromptSubmit"
228
- : rawEventName === "afterAgentResponse"
229
- ? "AssistantResponse"
230
- : rawEventName === "sessionEnd"
231
- ? "SessionEnd"
232
- : rawEventName === "beforeShellExecution"
233
- ? "PreToolUse"
234
- : rawEventName;
238
+ const eventName = rawEventName === "sessionStart"
239
+ ? "SessionStart"
240
+ : rawEventName === "beforeSubmitPrompt"
241
+ ? "UserPromptSubmit"
242
+ : rawEventName === "afterAgentResponse"
243
+ ? "AssistantResponse"
244
+ : rawEventName === "sessionEnd"
245
+ ? "SessionEnd"
246
+ : rawEventName === "beforeShellExecution"
247
+ ? "PreToolUse"
248
+ : rawEventName;
235
249
  return {
236
250
  input: {
237
251
  ...input,
@@ -265,9 +279,10 @@ class InvalidHookInputError extends Error {
265
279
  }
266
280
  function validHookInput(input, agent, eventName) {
267
281
  const recognized = agent === "polytoken"
268
- ? eventName === "UserPromptSubmit" || eventName === "AssistantResponse"
282
+ ? eventName === "UserPromptSubmit" || eventName === "AssistantResponse" || eventName === "PreToolUse"
269
283
  : agent === "cursor"
270
- ? eventName === "UserPromptSubmit" ||
284
+ ? eventName === "SessionStart" ||
285
+ eventName === "UserPromptSubmit" ||
271
286
  eventName === "AssistantResponse" ||
272
287
  eventName === "PreToolUse" ||
273
288
  eventName === "SessionEnd"
@@ -282,6 +297,10 @@ function validHookInput(input, agent, eventName) {
282
297
  return stringField(input.prompt) !== undefined;
283
298
  }
284
299
  if (eventName === "Stop" || eventName === "AssistantResponse") {
300
+ // Copilot agentStop may carry only transcript metadata. Never parse that
301
+ // transcript or invent assistant text; standalone prompt capture still works.
302
+ if (agent === "copilot-cli" && eventName === "Stop")
303
+ return true;
285
304
  return stringField(input.last_assistant_message) !== undefined;
286
305
  }
287
306
  if (eventName === "PreToolUse") {
@@ -289,11 +308,8 @@ function validHookInput(input, agent, eventName) {
289
308
  if (toolName === undefined || !isObject(input.tool_input)) {
290
309
  return false;
291
310
  }
292
- if (GUARD_EDIT_TOOLS.has(toolName)) {
293
- return true;
294
- }
295
- if (GUARD_SHELL_TOOLS.has(toolName)) {
296
- return stringField(input.tool_input.command) !== undefined;
311
+ if (GUARD_SHELL_TOOLS.has(toolName.split(".").at(-1))) {
312
+ return stringField(input.tool_input.command ?? input.tool_input.cmd) !== undefined;
297
313
  }
298
314
  return true;
299
315
  }
@@ -627,7 +643,7 @@ function createPromptObservationRequest(input, agent, sessionId, now, gitContext
627
643
  connector: "lore-cli",
628
644
  agent,
629
645
  sessionId,
630
- eventId: deterministicUuid(`lore-prompt\0${agent}\0${sessionId}\0${promptId ?? "first"}`),
646
+ eventId: deterministicUuid(`lore-prompt\0${agent}\0${sessionId}\0${promptId ?? idempotencyKey}`),
631
647
  prompt: redactSecrets(prompt),
632
648
  ...(promptId === undefined ? {} : { promptId }),
633
649
  timestamp: now.toISOString(),
@@ -947,12 +963,12 @@ function runtimeAccept(config) {
947
963
  }
948
964
  async function runtimeStore(config, home) {
949
965
  const store = new ReliabilityStore(reliabilityWorkspaceKey(config), {
950
- ...(home === undefined ? {} : { home }),
966
+ home: homeDirectory(home),
951
967
  });
952
968
  await store.initialize();
953
969
  await store.migrateLegacyQueue(queueDirectory(home));
954
970
  if (config.workspaceId !== undefined) {
955
- const credentialStore = new ReliabilityStore(credentialReliabilityWorkspaceKey(config), { ...(home === undefined ? {} : { home }) });
971
+ const credentialStore = new ReliabilityStore(credentialReliabilityWorkspaceKey(config), { home: homeDirectory(home) });
956
972
  await credentialStore.transferPendingTo(store).catch(() => undefined);
957
973
  }
958
974
  return store;
@@ -1465,12 +1481,25 @@ function queuedRequest(value) {
1465
1481
  async function flushOne(config, fetchImplementation, store) {
1466
1482
  const claimed = await store.claimNext({
1467
1483
  workerId: `native-hook:${process.pid}`,
1468
- kinds: ["turn", "prompt"],
1484
+ kinds: ["turn", "prompt", "guard_override"],
1469
1485
  });
1470
1486
  if (claimed === null || claimed.claim === undefined) {
1471
1487
  return null;
1472
1488
  }
1473
1489
  try {
1490
+ if (claimed.kind === "guard_override") {
1491
+ const response = await fetchImplementation(`${config.apiUrl.replace(/\/+$/u, "")}/v1/guard/overrides`, {
1492
+ method: "POST", headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
1493
+ body: JSON.stringify(claimed.payload), signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
1494
+ });
1495
+ if (!response.ok)
1496
+ throw new RuntimeHttpError("Override audit upload failed", response.status);
1497
+ const acknowledgement = await protocolResponseJson(response);
1498
+ if (!isObject(acknowledgement) || acknowledgement.overrideId !== claimed.idempotencyKey)
1499
+ throw new IncompatibleReliabilityError("Override acknowledgement is invalid");
1500
+ await store.acknowledgeClaim(claimed.claim.id);
1501
+ return { idempotencyKey: claimed.idempotencyKey, state: "acknowledged" };
1502
+ }
1474
1503
  const queued = queuedRequest(claimed.payload);
1475
1504
  if (queued === null) {
1476
1505
  const failed = await store.failClaim({
@@ -1555,8 +1584,8 @@ function receiptMessage(config, agent, delivery) {
1555
1584
  }
1556
1585
  // ---------------------------------------------------------------------------
1557
1586
  // Context Guard: proactive checks at tool boundaries (PreToolUse and Cursor
1558
- // beforeShellExecution). Fail-open by design a slow or unreachable hub must
1559
- // never stall an agent's tool call.
1587
+ // beforeShellExecution). Assist degrades on outages; known Enforce uses a
1588
+ // verified saved policy or denies actions whose policy cannot be verified.
1560
1589
  // ---------------------------------------------------------------------------
1561
1590
  /** How long a locally cached "guard is off" verdict suppresses network calls. */
1562
1591
  const GUARD_MODE_TTL_MS = 5 * 60_000;
@@ -1564,51 +1593,14 @@ const GUARD_MODE_TTL_MS = 5 * 60_000;
1564
1593
  const GUARD_KEY_COOLDOWN_MS = 15 * 60_000;
1565
1594
  const GUARD_MAX_CACHED_KEYS = 200;
1566
1595
  const GUARD_TIMEOUT_MS = 4_000;
1567
- const GUARD_EDIT_TOOLS = new Set([
1568
- "Edit",
1569
- "Write",
1570
- "MultiEdit",
1571
- "NotebookEdit",
1572
- "ApplyPatch",
1573
- "apply_patch",
1574
- "edit",
1575
- "write",
1576
- "patch",
1577
- "str_replace_editor",
1578
- ]);
1579
- const GUARD_SHELL_TOOLS = new Set(["Bash", "bash", "Shell", "shell"]);
1580
- const GUARD_COMMIT_COMMAND = /\bgit\s+(?:commit|push)\b/iu;
1581
- const GUARD_DEPLOY_COMMAND = /\b(?:terraform\s+(?:apply|destroy)|pulumi\s+up|kubectl\s+(?:apply|delete|rollout)|helm\s+(?:install|upgrade|uninstall)|docker\s+push|fly(?:ctl)?\s+deploy|vercel(?:\s+deploy|\s+--prod)|railway\s+up|serverless\s+deploy|sls\s+deploy|cdk\s+deploy|eb\s+deploy|gcloud\s+(?:app|run|functions)\s+deploy|az\s+webapp\s+(?:up|deploy)|(?:npm|pnpm|yarn)\s+publish|prisma\s+migrate\s+deploy|drizzle-kit\s+(?:push|migrate))\b/iu;
1582
- const GUARD_DESTRUCTIVE_COMMAND = /\b(?:rm\s+(?:-[a-z]*\s+)*-[a-z]*r[a-z]*f|drop\s+(?:table|database)|truncate\s+table)\b/iu;
1596
+ const GUARD_SHELL_TOOLS = new Set(["Bash", "bash", "Shell", "shell", "exec_command", "shell_command", "run_terminal_cmd", "shell_exec", "shell_monitor"]);
1583
1597
  /**
1584
1598
  * Decides whether a tool call is a meaningful action boundary. File edits
1585
1599
  * always qualify; shell commands qualify only when they look high-impact
1586
1600
  * (commit, push, deploy, publish, destructive) to keep the guard quiet.
1587
1601
  */
1588
1602
  export function guardTrigger(toolName, toolInput) {
1589
- if (GUARD_EDIT_TOOLS.has(toolName)) {
1590
- const files = [toolInput.file_path, toolInput.notebook_path, toolInput.path]
1591
- .map((value) => stringField(value))
1592
- .filter((value) => value !== undefined);
1593
- return { action: "edit", ...(files.length === 0 ? {} : { files }) };
1594
- }
1595
- if (GUARD_SHELL_TOOLS.has(toolName)) {
1596
- const command = stringField(toolInput.command);
1597
- if (command === undefined) {
1598
- return null;
1599
- }
1600
- if (GUARD_COMMIT_COMMAND.test(command)) {
1601
- return { action: "commit", command };
1602
- }
1603
- if (GUARD_DEPLOY_COMMAND.test(command)) {
1604
- return { action: "deploy", command };
1605
- }
1606
- if (GUARD_DESTRUCTIVE_COMMAND.test(command)) {
1607
- return { action: "command", command };
1608
- }
1609
- return null;
1610
- }
1611
- return null;
1603
+ return guardToolTrigger(toolName, toolInput);
1612
1604
  }
1613
1605
  function guardStatePath(agent, sessionId, home) {
1614
1606
  return resolve(loreDirectory(home), "state", "guard", `${sha256(`${agent}\0${sessionId}`)}.json`);
@@ -1689,6 +1681,7 @@ function guardResultFromResponse(value) {
1689
1681
  return {
1690
1682
  ...(typeof value.checkId === "string" ? { checkId: value.checkId } : {}),
1691
1683
  mode: value.mode,
1684
+ ...(value.policyIssue === "unsupported_required_rules" ? { policyUnavailable: true } : {}),
1692
1685
  requiresConfirmation: value.requiresConfirmation === true,
1693
1686
  ...(typeof confirm.command === "string"
1694
1687
  ? { confirmCommand: confirm.command }
@@ -1741,7 +1734,7 @@ function guardAssistContext(result) {
1741
1734
  lines.push(` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`);
1742
1735
  }
1743
1736
  if (result.conflictSummaries.length > 0) {
1744
- lines.push("", "Conflicting Lore context (do not silently pick a winner):");
1737
+ lines.push("", "Lore policy notes:");
1745
1738
  for (const summary of result.conflictSummaries) {
1746
1739
  lines.push(`- ${summary}`);
1747
1740
  }
@@ -1749,18 +1742,23 @@ function guardAssistContext(result) {
1749
1742
  return boundedContext(lines.join("\n"));
1750
1743
  }
1751
1744
  function guardConfirmationReason(result) {
1745
+ if (result.policyUnavailable)
1746
+ return "Lore Guard cannot evaluate an existing required rule. An owner or admin must rewrite it using a supported prohibition or change it to advisory before retrying.";
1752
1747
  const required = result.items.filter((item) => item.enforcement === "required");
1753
1748
  const shown = required.length > 0 ? required : result.items;
1754
1749
  const lines = [
1755
- "Lore Guard: this action conflicts with a required rule.",
1750
+ result.items.length === 0 ? "Lore Guard cannot verify the enforcement policy." : "Lore Guard: this action conflicts with a required rule.",
1751
+ ...result.conflictSummaries,
1756
1752
  ...shown.flatMap((item) => [
1757
1753
  `- ${item.content}`,
1758
1754
  ` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`,
1759
1755
  ]),
1760
1756
  ];
1761
- lines.push(result.confirmCommand === undefined
1762
- ? "Ask the user to confirm before proceeding."
1763
- : `Approve here to proceed, or the user can run: ${result.confirmCommand}`);
1757
+ lines.push(result.offline && result.confirmCommand !== undefined
1758
+ ? `Restore Lore or run: ${result.confirmCommand}`
1759
+ : result.confirmCommand === undefined
1760
+ ? "Ask the user to confirm before proceeding."
1761
+ : `Approve here to proceed, or the user can run: ${result.confirmCommand}`);
1764
1762
  return boundedContext(lines.join("\n"));
1765
1763
  }
1766
1764
  async function postGuardCheck(config, request, fetchImplementation, store) {
@@ -1795,13 +1793,65 @@ async function postGuardCheck(config, request, fetchImplementation, store) {
1795
1793
  throw new IncompatibleReliabilityError("Lore Guard snapshot does not bind the returned policy");
1796
1794
  }
1797
1795
  await store.writePolicySnapshot("current", value.policySnapshot);
1796
+ await store.writeState("guard-policy-state", { mode: payload.mode, overrides: payload.overrides, policyVersion: payload.policyVersion });
1798
1797
  return guardResultFromResponse(value.check);
1799
1798
  }
1800
- async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1801
- if (agent === "polytoken") {
1802
- // Polytoken has no tool boundary; its guard runs at prompt injection.
1803
- return undefined;
1799
+ async function offlineGuard(config, store, request, now, previouslyEnforced, allowCachedPolicy) {
1800
+ const remembered = await store.readState("guard-policy-state");
1801
+ const emergency = await store.readState("guard-emergency-override");
1802
+ if (emergencyOverrideApplies(emergency, String(request.sessionId), typeof request.repo === "string" ? request.repo : undefined, now)) {
1803
+ return { offline: true, mode: "enforce", requiresConfirmation: false, items: [], conflictSummaries: ["An audited, time-limited emergency override is active for this session and repository."] };
1804
1804
  }
1805
+ const overrides = isObject(remembered) && Array.isArray(remembered.overrides) ? remembered.overrides : [];
1806
+ const override = overrides.find((item) => isObject(item) && item.repo === request.repo);
1807
+ const knownEnforce = previouslyEnforced || (isObject(override) ? override.mode : isObject(remembered) ? remembered.mode : undefined) === "enforce";
1808
+ try {
1809
+ if (!allowCachedPolicy)
1810
+ throw new Error("A live policy could not be authenticated");
1811
+ const compactJws = await store.readPolicySnapshot("current");
1812
+ const keys = await cachedSnapshotKeys(config, store, now);
1813
+ if (compactJws === null || keys === null)
1814
+ throw new Error("Policy unavailable");
1815
+ const payload = verifySignedSnapshot(compactJws, {
1816
+ workspaceId: store.workspaceId, keys, kind: "guard_policy", now,
1817
+ }).payload;
1818
+ const cachedOverride = Array.isArray(payload.overrides) ? payload.overrides.find((item) => isObject(item) && item.repo === request.repo) : undefined;
1819
+ const mode = isObject(cachedOverride) ? cachedOverride.mode : payload.mode;
1820
+ if (isObject(remembered) && remembered.policyVersion !== payload.policyVersion)
1821
+ throw new Error("Policy has changed");
1822
+ if (knownEnforce && mode !== "enforce")
1823
+ throw new Error("Cached policy cannot loosen known enforcement");
1824
+ if (mode !== "enforce")
1825
+ return null;
1826
+ if (!Array.isArray(payload.requiredRules) || payload.requiredRules.some((rule) => !isObject(rule) || !isObject(rule.scope) || typeof rule.content !== "string" || !supportedGuardRule(rule))) {
1827
+ throw new Error("Policy contains unsupported required rules");
1828
+ }
1829
+ const rules = Array.isArray(payload.requiredRules) ? payload.requiredRules.filter((rule) => isObject(rule) && isObject(rule.scope) && typeof rule.content === "string" &&
1830
+ guardRuleConflict(rule, {
1831
+ action: String(request.action),
1832
+ ...(typeof request.repo === "string" ? { repo: request.repo } : {}),
1833
+ ...(typeof request.path === "string" ? { path: request.path } : {}),
1834
+ ...(typeof request.command === "string" ? { command: request.command } : {}),
1835
+ ...(Array.isArray(request.files) ? { files: request.files.filter((file) => typeof file === "string") } : {}),
1836
+ }) !== null) : [];
1837
+ return {
1838
+ offline: true, mode: "enforce", requiresConfirmation: rules.length > 0,
1839
+ items: rules.map((rule) => ({ content: String(rule.content), enforcement: "required" })),
1840
+ conflictSummaries: ["Lore is offline; this action was checked against its verified, unexpired saved policy."],
1841
+ confirmCommand: guardEmergencyCommand(String(request.sessionId), String(request.repo ?? "")),
1842
+ };
1843
+ }
1844
+ catch {
1845
+ if (!knownEnforce)
1846
+ return null;
1847
+ return {
1848
+ offline: true, mode: "enforce", requiresConfirmation: true, items: [],
1849
+ conflictSummaries: ["Lore cannot verify the current enforcement policy. Restore the connection or use an audited emergency override."],
1850
+ confirmCommand: guardEmergencyCommand(String(request.sessionId), String(request.repo ?? "")),
1851
+ };
1852
+ }
1853
+ }
1854
+ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1805
1855
  const toolName = stringField(input.tool_name);
1806
1856
  if (toolName === undefined) {
1807
1857
  return undefined;
@@ -1812,7 +1862,11 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1812
1862
  }
1813
1863
  const store = await runtimeStore(config, options.home);
1814
1864
  const state = await readGuardState(agent, sessionId, options.home);
1815
- if (state.mode === "off" &&
1865
+ const rememberedPolicy = await store.readState("guard-policy-state");
1866
+ const enforcementMayHaveChanged = isObject(rememberedPolicy) && (rememberedPolicy.mode === "enforce" ||
1867
+ (Array.isArray(rememberedPolicy.overrides) && rememberedPolicy.overrides.some((entry) => isObject(entry) && entry.mode === "enforce")));
1868
+ if (!enforcementMayHaveChanged &&
1869
+ state.mode === "off" &&
1816
1870
  state.modeCheckedAt !== undefined &&
1817
1871
  now.getTime() - Date.parse(state.modeCheckedAt) < GUARD_MODE_TTL_MS) {
1818
1872
  await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
@@ -1832,8 +1886,7 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1832
1886
  ? normalizedRepositoryPath(file)
1833
1887
  : normalizedRepositoryPath(relativePath);
1834
1888
  })
1835
- .filter((file) => file !== "")
1836
- .slice(0, 100);
1889
+ .filter((file) => file !== "");
1837
1890
  const key = sha256([
1838
1891
  "guard",
1839
1892
  trigger.action,
@@ -1842,53 +1895,61 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1842
1895
  ].join("\0"));
1843
1896
  const cachedAt = state.keys[key];
1844
1897
  if (cachedAt !== undefined &&
1898
+ !enforcementMayHaveChanged &&
1899
+ state.mode !== "enforce" &&
1845
1900
  now.getTime() - Date.parse(cachedAt) < GUARD_KEY_COOLDOWN_MS) {
1846
1901
  await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
1847
1902
  return undefined;
1848
1903
  }
1849
1904
  const startedAt = Date.now();
1850
1905
  let result;
1906
+ const guardRequest = {
1907
+ connector: "lore-cli",
1908
+ agent,
1909
+ sessionId,
1910
+ action: trigger.action,
1911
+ tool: toolName,
1912
+ ...(scope?.repo === undefined ? {} : { repo: scope.repo }),
1913
+ ...(scope?.path === undefined ? {} : { path: scope.path }),
1914
+ ...(files.length === 0 ? {} : { files }),
1915
+ ...(trigger.command === undefined
1916
+ ? {}
1917
+ : { command: redactSecrets(trigger.command) }),
1918
+ };
1851
1919
  try {
1852
- result = await postGuardCheck(config, {
1853
- connector: "lore-cli",
1854
- agent,
1855
- sessionId,
1856
- action: trigger.action,
1857
- tool: toolName,
1858
- ...(scope?.repo === undefined ? {} : { repo: scope.repo }),
1859
- ...(scope?.path === undefined ? {} : { path: scope.path }),
1860
- ...(files.length === 0 ? {} : { files }),
1861
- ...(trigger.command === undefined
1862
- ? {}
1863
- : { command: redactSecrets(trigger.command).slice(0, 10_000) }),
1864
- }, options.fetch ?? globalThis.fetch, store);
1920
+ result = await postGuardCheck(config, guardRequest, options.fetch ?? globalThis.fetch, store);
1865
1921
  }
1866
1922
  catch (error) {
1867
1923
  await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1868
- throw error;
1924
+ result = await offlineGuard(config, store, guardRequest, now, state.mode === "enforce", isContextTransportFailure(error));
1925
+ if (result === null)
1926
+ throw error;
1869
1927
  }
1870
1928
  if (result === null) {
1871
1929
  await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1872
1930
  throw new IncompatibleReliabilityError("Lore Guard response is invalid");
1873
1931
  }
1874
- await recordLocalGuardMetric(store, {
1875
- outcome: "live_check",
1876
- durationMs: Math.max(0, Date.now() - startedAt),
1877
- }, now).catch(() => undefined);
1932
+ if (!result.offline)
1933
+ await recordLocalGuardMetric(store, {
1934
+ outcome: "live_check",
1935
+ durationMs: Math.max(0, Date.now() - startedAt),
1936
+ }, now).catch(() => undefined);
1878
1937
  state.mode = result.mode;
1879
1938
  state.modeCheckedAt = now.toISOString();
1880
1939
  if (!result.requiresConfirmation) {
1881
1940
  state.keys[key] = now.toISOString();
1882
1941
  }
1883
- await writeGuardState(agent, sessionId, state, options.home);
1942
+ await writeGuardState(agent, sessionId, state, options.home).catch(() => undefined);
1884
1943
  if (result.mode === "off") {
1885
1944
  return undefined;
1886
1945
  }
1887
1946
  if (result.requiresConfirmation) {
1888
1947
  const reason = guardConfirmationReason(result);
1948
+ if (agent === "polytoken")
1949
+ return { outcome: "deny", reason };
1889
1950
  if (agent === "copilot-cli") {
1890
1951
  return {
1891
- permissionDecision: "ask",
1952
+ permissionDecision: result.offline || result.policyUnavailable ? "deny" : "ask",
1892
1953
  permissionDecisionReason: reason,
1893
1954
  };
1894
1955
  }
@@ -1904,7 +1965,8 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1904
1965
  return {
1905
1966
  hookSpecificOutput: {
1906
1967
  hookEventName: "PreToolUse",
1907
- permissionDecision: "ask",
1968
+ // Codex parses `ask` as unsupported and then continues the tool call.
1969
+ permissionDecision: agent === "codex" || result.offline || result.policyUnavailable ? "deny" : "ask",
1908
1970
  permissionDecisionReason: reason,
1909
1971
  },
1910
1972
  };
@@ -1917,6 +1979,8 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1917
1979
  // context was already added by userPromptTransformed.
1918
1980
  return undefined;
1919
1981
  }
1982
+ if (agent === "polytoken")
1983
+ return { outcome: "allow" };
1920
1984
  if (agent === "cursor") {
1921
1985
  // Cursor cannot inject agent context at the shell boundary on allow.
1922
1986
  return undefined;
@@ -1946,6 +2010,20 @@ export async function handleHookEvent(value, agent, options = {}) {
1946
2010
  return undefined;
1947
2011
  }
1948
2012
  const now = (options.now ?? (() => new Date()))();
2013
+ if (agent === "cursor" && eventName === "SessionStart") {
2014
+ const cwd = stringField(input.cwd);
2015
+ if (!cwd)
2016
+ return { additional_context: "Lore needs a single repository workspace to provide shared context." };
2017
+ const store = await runtimeStore(config, options.home);
2018
+ await store.writeState(cursorSessionKey(sessionId), { sessionId, cwd, updatedAt: now.toISOString() });
2019
+ return { additional_context: [
2020
+ "Lore shared context is connected through the lore-native MCP server.",
2021
+ `For this conversation use sessionId ${JSON.stringify(sessionId)}.`,
2022
+ "Before answering each user request or using other tools, call lore_context with that sessionId and the full user message as prompt. This retrieves relevant shared knowledge and records durable teachings through Lore's normal capture pipeline.",
2023
+ "Use the returned context when relevant. If retrieval fails, report that limitation; do not invent shared knowledge.",
2024
+ "After completing each response, call lore_complete with the same sessionId and a concise response summary so the next user correction retains the prior attempt. These Lore calls are available through MCP and require no shell commands.",
2025
+ ].join("\n") };
2026
+ }
1949
2027
  if (eventName === "Stop" || eventName === "AssistantResponse") {
1950
2028
  await savePending(input, agent, sessionId, now, options.home);
1951
2029
  return undefined;
@@ -1961,6 +2039,20 @@ export async function handleHookEvent(value, agent, options = {}) {
1961
2039
  return await handleGuardEvent(input, agent, config, sessionId, options, now);
1962
2040
  }
1963
2041
  catch {
2042
+ const prior = await readGuardState(agent, sessionId, options.home).catch(() => null);
2043
+ const saved = await runtimeStore(config, options.home).then((store) => store.readState("guard-policy-state")).catch(() => null);
2044
+ const scope = await repositoryScope(stringField(input.cwd)).catch(() => undefined);
2045
+ const override = isObject(saved) && Array.isArray(saved.overrides) ? saved.overrides.find((entry) => isObject(entry) && entry.repo === scope?.repo) : undefined;
2046
+ if (prior?.mode === "enforce" || (isObject(override) ? override.mode : isObject(saved) ? saved.mode : undefined) === "enforce") {
2047
+ const reason = "Lore Guard cannot verify its enforcement policy. Restore Lore before retrying.";
2048
+ if (agent === "cursor")
2049
+ return { permission: "deny", userMessage: reason, agentMessage: reason };
2050
+ if (agent === "copilot-cli")
2051
+ return { permissionDecision: "deny", permissionDecisionReason: reason };
2052
+ if (agent === "polytoken")
2053
+ return { outcome: "deny", reason };
2054
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } };
2055
+ }
1964
2056
  const warning = "Lore Guard is unavailable; this action is proceeding without a live policy decision.";
1965
2057
  if (agent === "cursor") {
1966
2058
  return {
@@ -1968,7 +2060,7 @@ export async function handleHookEvent(value, agent, options = {}) {
1968
2060
  agentMessage: warning,
1969
2061
  };
1970
2062
  }
1971
- if (agent === "copilot-cli") {
2063
+ if (agent === "copilot-cli" || agent === "polytoken") {
1972
2064
  return undefined;
1973
2065
  }
1974
2066
  return {
@@ -1979,6 +2071,55 @@ export async function handleHookEvent(value, agent, options = {}) {
1979
2071
  };
1980
2072
  }
1981
2073
  }
2074
+ if (agent === "cursor") {
2075
+ const store = await runtimeStore(config, options.home);
2076
+ const existing = await store.readState(cursorSessionKey(sessionId));
2077
+ const cwd = stringField(input.cwd);
2078
+ if (cwd)
2079
+ await store.writeState(cursorSessionKey(sessionId), {
2080
+ ...(isObject(existing) ? existing : {}), sessionId, cwd,
2081
+ ...(stringField(input.prompt) ? { prompt: redactSecrets(String(input.prompt)) } : {}),
2082
+ ...(stringField(input.prompt_id) ? { promptId: input.prompt_id } : {}), updatedAt: now.toISOString(),
2083
+ });
2084
+ // Cursor's prompt hook supports submission control only. The MCP call
2085
+ // delivers the context; capture here retains native editor event identity.
2086
+ await handlePromptEvent(input, agent, sessionId, config, options, now);
2087
+ return { continue: true };
2088
+ }
2089
+ return handlePromptEvent(input, agent, sessionId, config, options, now);
2090
+ }
2091
+ function cursorSessionKey(sessionId) {
2092
+ return `cursor-session-${sha256(sessionId)}`;
2093
+ }
2094
+ /** MCP requests are bound to a session/workspace observed by the native hook. */
2095
+ async function cursorSession(sessionId, options) {
2096
+ const config = await readRuntimeConfig(options.home);
2097
+ if (!config?.agents.includes("cursor"))
2098
+ throw new Error("Reconnect Cursor to Lore before requesting context.");
2099
+ const store = await runtimeStore(config, options.home);
2100
+ const state = await store.readState(cursorSessionKey(sessionId));
2101
+ if (!isObject(state) || state.sessionId !== sessionId || !stringField(state.cwd)) {
2102
+ throw new Error("Start a new Cursor conversation to register this Lore session.");
2103
+ }
2104
+ return { config, store, state, cwd: stringField(state.cwd) };
2105
+ }
2106
+ export async function getCursorContext(sessionId, prompt, options = {}) {
2107
+ const { config, state, cwd } = await cursorSession(sessionId, options);
2108
+ const now = (options.now ?? (() => new Date()))();
2109
+ const result = await handlePromptEvent({ session_id: sessionId, cwd,
2110
+ prompt: stringField(state.prompt) ?? prompt,
2111
+ ...(stringField(state.promptId) ? { prompt_id: state.promptId } : {}),
2112
+ }, "cursor", sessionId, config, options, now, stringField(state.prompt) === undefined);
2113
+ return result?.additional_context || "No relevant shared knowledge was found for this request.";
2114
+ }
2115
+ export async function completeCursorResponse(sessionId, response, options = {}) {
2116
+ const { store, state, cwd } = await cursorSession(sessionId, options);
2117
+ const now = (options.now ?? (() => new Date()))();
2118
+ await savePending({ cwd, last_assistant_message: response }, "cursor", sessionId, now, options.home);
2119
+ const { prompt: _prompt, promptId: _promptId, ...rest } = state;
2120
+ await store.writeState(cursorSessionKey(sessionId), { ...rest, updatedAt: now.toISOString() });
2121
+ }
2122
+ async function handlePromptEvent(input, agent, sessionId, config, options, now, capture = true) {
1982
2123
  const fetchImplementation = options.fetch ?? globalThis.fetch;
1983
2124
  const prompt = stringField(input.prompt);
1984
2125
  if (prompt === undefined) {
@@ -1999,13 +2140,7 @@ export async function handleHookEvent(value, agent, options = {}) {
1999
2140
  return agent === "polytoken"
2000
2141
  ? { outcome: "accept", additional_context: notice }
2001
2142
  : agent === "cursor"
2002
- ? {
2003
- continue: true,
2004
- hookSpecificOutput: {
2005
- hookEventName: "UserPromptSubmit",
2006
- additionalContext: notice,
2007
- },
2008
- }
2143
+ ? { additional_context: notice }
2009
2144
  : agent === "copilot-cli"
2010
2145
  ? {
2011
2146
  modifiedTransformedPrompt: `${notice}\n\n${stringField(input.transformed_prompt) ?? prompt}`,
@@ -2026,7 +2161,7 @@ export async function handleHookEvent(value, agent, options = {}) {
2026
2161
  catch {
2027
2162
  notices.push("Lore retained queued captures locally, but replay could not run. Run lore doctor.");
2028
2163
  }
2029
- const pending = await readPending(agent, sessionId, options.home);
2164
+ const pending = capture ? await readPending(agent, sessionId, options.home) : null;
2030
2165
  const lineage = await readLineageMetadata(options.home, options.environment ?? process.env);
2031
2166
  let delivery = emptyDelivery();
2032
2167
  if (pending !== null) {
@@ -2100,8 +2235,10 @@ export async function handleHookEvent(value, agent, options = {}) {
2100
2235
  }
2101
2236
  let enqueued = false;
2102
2237
  try {
2103
- await enqueueCapture(store, { kind: "prompt", request: observation });
2104
- enqueued = true;
2238
+ if (capture) {
2239
+ await enqueueCapture(store, { kind: "prompt", request: observation });
2240
+ enqueued = true;
2241
+ }
2105
2242
  }
2106
2243
  catch (error) {
2107
2244
  notices.push(error instanceof ReliabilityStoreError &&
@@ -2182,6 +2319,8 @@ export async function handleHookEvent(value, agent, options = {}) {
2182
2319
  additional_context: injectedContext,
2183
2320
  };
2184
2321
  }
2322
+ if (agent === "cursor")
2323
+ return { additional_context: injectedContext };
2185
2324
  if (agent === "copilot-cli") {
2186
2325
  const copilotContext = [delivery.context, receipt, reliabilityNotice]
2187
2326
  .filter((entry) => entry !== undefined && entry !== "")
@@ -2194,7 +2333,6 @@ export async function handleHookEvent(value, agent, options = {}) {
2194
2333
  };
2195
2334
  }
2196
2335
  return {
2197
- ...(agent === "cursor" ? { continue: true } : {}),
2198
2336
  ...(systemMessage === undefined ? {} : { systemMessage }),
2199
2337
  ...(injectedContext === ""
2200
2338
  ? {}