@lore-co/cli 0.1.19 → 0.1.22

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
+ }
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
+ };
1804
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,66 @@ 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
- if (!result.requiresConfirmation) {
1939
+ // A policy-blocked action (unsupported required rule) must not be cached as
1940
+ // an allow: the row has to be re-checked until an owner repairs the policy.
1941
+ if (!result.requiresConfirmation && !result.policyUnavailable) {
1881
1942
  state.keys[key] = now.toISOString();
1882
1943
  }
1883
- await writeGuardState(agent, sessionId, state, options.home);
1944
+ await writeGuardState(agent, sessionId, state, options.home).catch(() => undefined);
1884
1945
  if (result.mode === "off") {
1885
1946
  return undefined;
1886
1947
  }
1887
- if (result.requiresConfirmation) {
1948
+ // `policyUnavailable` (an unsupported required rule) blocks the action
1949
+ // independently of `requiresConfirmation`: the producer reports it via
1950
+ // `policyIssue` rather than `confirm`, so it never sets `requiresConfirmation`.
1951
+ if (result.requiresConfirmation || result.policyUnavailable) {
1888
1952
  const reason = guardConfirmationReason(result);
1953
+ if (agent === "polytoken")
1954
+ return { outcome: "deny", reason };
1889
1955
  if (agent === "copilot-cli") {
1890
1956
  return {
1891
- permissionDecision: "ask",
1957
+ permissionDecision: result.offline || result.policyUnavailable ? "deny" : "ask",
1892
1958
  permissionDecisionReason: reason,
1893
1959
  };
1894
1960
  }
@@ -1904,7 +1970,8 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1904
1970
  return {
1905
1971
  hookSpecificOutput: {
1906
1972
  hookEventName: "PreToolUse",
1907
- permissionDecision: "ask",
1973
+ // Codex parses `ask` as unsupported and then continues the tool call.
1974
+ permissionDecision: agent === "codex" || result.offline || result.policyUnavailable ? "deny" : "ask",
1908
1975
  permissionDecisionReason: reason,
1909
1976
  },
1910
1977
  };
@@ -1917,6 +1984,8 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1917
1984
  // context was already added by userPromptTransformed.
1918
1985
  return undefined;
1919
1986
  }
1987
+ if (agent === "polytoken")
1988
+ return { outcome: "allow" };
1920
1989
  if (agent === "cursor") {
1921
1990
  // Cursor cannot inject agent context at the shell boundary on allow.
1922
1991
  return undefined;
@@ -1946,6 +2015,20 @@ export async function handleHookEvent(value, agent, options = {}) {
1946
2015
  return undefined;
1947
2016
  }
1948
2017
  const now = (options.now ?? (() => new Date()))();
2018
+ if (agent === "cursor" && eventName === "SessionStart") {
2019
+ const cwd = stringField(input.cwd);
2020
+ if (!cwd)
2021
+ return { additional_context: "Lore needs a single repository workspace to provide shared context." };
2022
+ const store = await runtimeStore(config, options.home);
2023
+ await store.writeState(cursorSessionKey(sessionId), { sessionId, cwd, updatedAt: now.toISOString() });
2024
+ return { additional_context: [
2025
+ "Lore shared context is connected through the lore-native MCP server.",
2026
+ `For this conversation use sessionId ${JSON.stringify(sessionId)}.`,
2027
+ "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.",
2028
+ "Use the returned context when relevant. If retrieval fails, report that limitation; do not invent shared knowledge.",
2029
+ "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.",
2030
+ ].join("\n") };
2031
+ }
1949
2032
  if (eventName === "Stop" || eventName === "AssistantResponse") {
1950
2033
  await savePending(input, agent, sessionId, now, options.home);
1951
2034
  return undefined;
@@ -1961,6 +2044,20 @@ export async function handleHookEvent(value, agent, options = {}) {
1961
2044
  return await handleGuardEvent(input, agent, config, sessionId, options, now);
1962
2045
  }
1963
2046
  catch {
2047
+ const prior = await readGuardState(agent, sessionId, options.home).catch(() => null);
2048
+ const saved = await runtimeStore(config, options.home).then((store) => store.readState("guard-policy-state")).catch(() => null);
2049
+ const scope = await repositoryScope(stringField(input.cwd)).catch(() => undefined);
2050
+ const override = isObject(saved) && Array.isArray(saved.overrides) ? saved.overrides.find((entry) => isObject(entry) && entry.repo === scope?.repo) : undefined;
2051
+ if (prior?.mode === "enforce" || (isObject(override) ? override.mode : isObject(saved) ? saved.mode : undefined) === "enforce") {
2052
+ const reason = "Lore Guard cannot verify its enforcement policy. Restore Lore before retrying.";
2053
+ if (agent === "cursor")
2054
+ return { permission: "deny", userMessage: reason, agentMessage: reason };
2055
+ if (agent === "copilot-cli")
2056
+ return { permissionDecision: "deny", permissionDecisionReason: reason };
2057
+ if (agent === "polytoken")
2058
+ return { outcome: "deny", reason };
2059
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } };
2060
+ }
1964
2061
  const warning = "Lore Guard is unavailable; this action is proceeding without a live policy decision.";
1965
2062
  if (agent === "cursor") {
1966
2063
  return {
@@ -1968,7 +2065,7 @@ export async function handleHookEvent(value, agent, options = {}) {
1968
2065
  agentMessage: warning,
1969
2066
  };
1970
2067
  }
1971
- if (agent === "copilot-cli") {
2068
+ if (agent === "copilot-cli" || agent === "polytoken") {
1972
2069
  return undefined;
1973
2070
  }
1974
2071
  return {
@@ -1979,6 +2076,57 @@ export async function handleHookEvent(value, agent, options = {}) {
1979
2076
  };
1980
2077
  }
1981
2078
  }
2079
+ if (agent === "cursor") {
2080
+ const store = await runtimeStore(config, options.home);
2081
+ const existing = await store.readState(cursorSessionKey(sessionId));
2082
+ const cwd = stringField(input.cwd);
2083
+ if (cwd)
2084
+ await store.writeState(cursorSessionKey(sessionId), {
2085
+ ...(isObject(existing) ? existing : {}), sessionId, cwd,
2086
+ ...(stringField(input.prompt) ? { prompt: redactSecrets(String(input.prompt)) } : {}),
2087
+ ...(stringField(input.prompt_id) ? { promptId: input.prompt_id } : {}), updatedAt: now.toISOString(),
2088
+ });
2089
+ // Cursor's prompt hook supports submission control only. The MCP call
2090
+ // delivers the context; capture here retains native editor event identity.
2091
+ // Retrieve is skipped so the notice cooldown is not primed by a discarded
2092
+ // result: the MCP `lore_context` call surfaces the reliability notice.
2093
+ await handlePromptEvent(input, agent, sessionId, config, options, now, true, false);
2094
+ return { continue: true };
2095
+ }
2096
+ return handlePromptEvent(input, agent, sessionId, config, options, now);
2097
+ }
2098
+ function cursorSessionKey(sessionId) {
2099
+ return `cursor-session-${sha256(sessionId)}`;
2100
+ }
2101
+ /** MCP requests are bound to a session/workspace observed by the native hook. */
2102
+ async function cursorSession(sessionId, options) {
2103
+ const config = await readRuntimeConfig(options.home);
2104
+ if (!config?.agents.includes("cursor"))
2105
+ throw new Error("Reconnect Cursor to Lore before requesting context.");
2106
+ const store = await runtimeStore(config, options.home);
2107
+ const state = await store.readState(cursorSessionKey(sessionId));
2108
+ if (!isObject(state) || state.sessionId !== sessionId || !stringField(state.cwd)) {
2109
+ throw new Error("Start a new Cursor conversation to register this Lore session.");
2110
+ }
2111
+ return { config, store, state, cwd: stringField(state.cwd) };
2112
+ }
2113
+ export async function getCursorContext(sessionId, prompt, options = {}) {
2114
+ const { config, state, cwd } = await cursorSession(sessionId, options);
2115
+ const now = (options.now ?? (() => new Date()))();
2116
+ const result = await handlePromptEvent({ session_id: sessionId, cwd,
2117
+ prompt: stringField(state.prompt) ?? prompt,
2118
+ ...(stringField(state.promptId) ? { prompt_id: state.promptId } : {}),
2119
+ }, "cursor", sessionId, config, options, now, stringField(state.prompt) === undefined);
2120
+ return result?.additional_context || "No relevant shared knowledge was found for this request.";
2121
+ }
2122
+ export async function completeCursorResponse(sessionId, response, options = {}) {
2123
+ const { store, state, cwd } = await cursorSession(sessionId, options);
2124
+ const now = (options.now ?? (() => new Date()))();
2125
+ await savePending({ cwd, last_assistant_message: response }, "cursor", sessionId, now, options.home);
2126
+ const { prompt: _prompt, promptId: _promptId, ...rest } = state;
2127
+ await store.writeState(cursorSessionKey(sessionId), { ...rest, updatedAt: now.toISOString() });
2128
+ }
2129
+ async function handlePromptEvent(input, agent, sessionId, config, options, now, capture = true, retrieve = true) {
1982
2130
  const fetchImplementation = options.fetch ?? globalThis.fetch;
1983
2131
  const prompt = stringField(input.prompt);
1984
2132
  if (prompt === undefined) {
@@ -1999,13 +2147,7 @@ export async function handleHookEvent(value, agent, options = {}) {
1999
2147
  return agent === "polytoken"
2000
2148
  ? { outcome: "accept", additional_context: notice }
2001
2149
  : agent === "cursor"
2002
- ? {
2003
- continue: true,
2004
- hookSpecificOutput: {
2005
- hookEventName: "UserPromptSubmit",
2006
- additionalContext: notice,
2007
- },
2008
- }
2150
+ ? { additional_context: notice }
2009
2151
  : agent === "copilot-cli"
2010
2152
  ? {
2011
2153
  modifiedTransformedPrompt: `${notice}\n\n${stringField(input.transformed_prompt) ?? prompt}`,
@@ -2026,7 +2168,7 @@ export async function handleHookEvent(value, agent, options = {}) {
2026
2168
  catch {
2027
2169
  notices.push("Lore retained queued captures locally, but replay could not run. Run lore doctor.");
2028
2170
  }
2029
- const pending = await readPending(agent, sessionId, options.home);
2171
+ const pending = capture ? await readPending(agent, sessionId, options.home) : null;
2030
2172
  const lineage = await readLineageMetadata(options.home, options.environment ?? process.env);
2031
2173
  let delivery = emptyDelivery();
2032
2174
  if (pending !== null) {
@@ -2063,7 +2205,7 @@ export async function handleHookEvent(value, agent, options = {}) {
2063
2205
  notices.push("Lore saved this capture locally; server upload is pending.");
2064
2206
  }
2065
2207
  }
2066
- if (delivery.context === "") {
2208
+ if (retrieve && delivery.context === "") {
2067
2209
  try {
2068
2210
  delivery = await getPromptContextWithFallback(config, agent, sessionId, request.eventId, prompt, {
2069
2211
  ...(request.scope === undefined ? {} : { scope: request.scope }),
@@ -2100,8 +2242,10 @@ export async function handleHookEvent(value, agent, options = {}) {
2100
2242
  }
2101
2243
  let enqueued = false;
2102
2244
  try {
2103
- await enqueueCapture(store, { kind: "prompt", request: observation });
2104
- enqueued = true;
2245
+ if (capture) {
2246
+ await enqueueCapture(store, { kind: "prompt", request: observation });
2247
+ enqueued = true;
2248
+ }
2105
2249
  }
2106
2250
  catch (error) {
2107
2251
  notices.push(error instanceof ReliabilityStoreError &&
@@ -2122,45 +2266,49 @@ export async function handleHookEvent(value, agent, options = {}) {
2122
2266
  notices.push("Lore saved this capture locally; server upload is pending.");
2123
2267
  }
2124
2268
  }
2125
- try {
2126
- delivery = await getPromptContextWithFallback(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation, store, now);
2127
- }
2128
- catch {
2129
- delivery = {
2130
- ...emptyDelivery(),
2131
- reliability: {
2132
- status: "failed",
2133
- source: "none",
2134
- fallback: "none",
2135
- reasons: ["invalid_response"],
2136
- freshness: {
2137
- state: "unknown",
2138
- asOf: null,
2139
- ageMs: null,
2140
- validUntil: null,
2269
+ if (retrieve) {
2270
+ try {
2271
+ delivery = await getPromptContextWithFallback(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation, store, now);
2272
+ }
2273
+ catch {
2274
+ delivery = {
2275
+ ...emptyDelivery(),
2276
+ reliability: {
2277
+ status: "failed",
2278
+ source: "none",
2279
+ fallback: "none",
2280
+ reasons: ["invalid_response"],
2281
+ freshness: {
2282
+ state: "unknown",
2283
+ asOf: null,
2284
+ ageMs: null,
2285
+ validUntil: null,
2286
+ },
2287
+ policyVersion: null,
2141
2288
  },
2142
- policyVersion: null,
2143
- },
2144
- };
2289
+ };
2290
+ }
2145
2291
  }
2146
2292
  }
2147
- const reliability = delivery.reliability;
2148
- await recordLocalRetrievalMetric(store, {
2149
- outcome: reliability?.fallback === "cached_context"
2150
- ? "cached_fallback"
2151
- : reliability?.fallback === "live_lexical"
2152
- ? "live_lexical_fallback"
2153
- : reliability?.status === "failed" ||
2154
- reliability?.source === "none"
2155
- ? "failed"
2156
- : "live_primary",
2157
- ...(reliability?.freshness.ageMs === undefined
2158
- ? {}
2159
- : { cacheAgeMs: reliability.freshness.ageMs }),
2160
- }, now).catch(() => undefined);
2161
- const contextReliabilityNotice = await retrievalNotice(delivery, sessionId, store, now);
2162
- if (contextReliabilityNotice !== undefined) {
2163
- notices.push(contextReliabilityNotice);
2293
+ if (retrieve) {
2294
+ const reliability = delivery.reliability;
2295
+ await recordLocalRetrievalMetric(store, {
2296
+ outcome: reliability?.fallback === "cached_context"
2297
+ ? "cached_fallback"
2298
+ : reliability?.fallback === "live_lexical"
2299
+ ? "live_lexical_fallback"
2300
+ : reliability?.status === "failed" ||
2301
+ reliability?.source === "none"
2302
+ ? "failed"
2303
+ : "live_primary",
2304
+ ...(reliability?.freshness.ageMs === undefined
2305
+ ? {}
2306
+ : { cacheAgeMs: reliability.freshness.ageMs }),
2307
+ }, now).catch(() => undefined);
2308
+ const contextReliabilityNotice = await retrievalNotice(delivery, sessionId, store, now);
2309
+ if (contextReliabilityNotice !== undefined) {
2310
+ notices.push(contextReliabilityNotice);
2311
+ }
2164
2312
  }
2165
2313
  const uniqueNotices = [...new Set(notices)];
2166
2314
  const reliabilityNotice = uniqueNotices.length === 0 ? undefined : uniqueNotices.join(" ");
@@ -2182,6 +2330,8 @@ export async function handleHookEvent(value, agent, options = {}) {
2182
2330
  additional_context: injectedContext,
2183
2331
  };
2184
2332
  }
2333
+ if (agent === "cursor")
2334
+ return { additional_context: injectedContext };
2185
2335
  if (agent === "copilot-cli") {
2186
2336
  const copilotContext = [delivery.context, receipt, reliabilityNotice]
2187
2337
  .filter((entry) => entry !== undefined && entry !== "")
@@ -2194,7 +2344,6 @@ export async function handleHookEvent(value, agent, options = {}) {
2194
2344
  };
2195
2345
  }
2196
2346
  return {
2197
- ...(agent === "cursor" ? { continue: true } : {}),
2198
2347
  ...(systemMessage === undefined ? {} : { systemMessage }),
2199
2348
  ...(injectedContext === ""
2200
2349
  ? {}