@superatomai/sdk-node 0.0.56-dsp → 0.0.57-dsp

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  DEFAULT_AGENT_CONFIG: () => DEFAULT_AGENT_CONFIG,
37
37
  LLM: () => LLM,
38
38
  MainAgent: () => MainAgent,
39
+ MissingRequestContextError: () => MissingRequestContextError,
39
40
  STORAGE_CONFIG: () => STORAGE_CONFIG,
40
41
  ScriptMatcher: () => ScriptMatcher,
41
42
  ScriptStore: () => ScriptStore,
@@ -47,36 +48,53 @@ __export(index_exports, {
47
48
  UserManager: () => UserManager,
48
49
  anthropicLLM: () => anthropicLLM,
49
50
  assertReadOnlySql: () => assertReadOnlySql,
51
+ bindContext: () => bindContext,
52
+ captureContext: () => captureContext,
53
+ clearContextTokens: () => clearContextTokens,
54
+ contextTokenCount: () => contextTokenCount,
50
55
  createCodexSession: () => createCodexSession,
51
56
  createCodingAgentSession: () => createCodingAgentSession,
52
57
  dashboardConversationHistory: () => dashboardConversationHistory,
53
58
  dataFingerprint: () => dataFingerprint,
59
+ describeContext: () => describeContext,
54
60
  describeTriggers: () => describeTriggers,
55
61
  emitAgentEvent: () => emitAgentEvent,
56
62
  extractNamedExport: () => extractNamedExport,
57
63
  geminiLLM: () => geminiLLM,
58
64
  getCodingAgent: () => getCodingAgent,
59
65
  getCodingAgentsConfig: () => getCodingAgentsConfig,
66
+ getRequestContext: () => getRequestContext,
60
67
  groqLLM: () => groqLLM,
61
68
  hybridRerank: () => hybridRerank,
69
+ isUserContext: () => isUserContext,
62
70
  listCodingAgents: () => listCodingAgents,
63
71
  llmUsageLogger: () => llmUsageLogger,
64
72
  logger: () => logger,
73
+ mintContextToken: () => mintContextToken,
65
74
  normalizeScriptBody: () => normalizeScriptBody,
66
75
  onAgentRun: () => onAgentRun,
67
76
  openaiLLM: () => openaiLLM,
77
+ policiesFromConfig: () => policiesFromConfig,
68
78
  queryCache: () => queryCache,
69
79
  registerCodingAgentCollection: () => registerCodingAgentCollection,
70
80
  registerCodingAgents: () => registerCodingAgents,
71
81
  registerDashboardAgentCollection: () => registerDashboardAgentCollection,
82
+ requireRequestContext: () => requireRequestContext,
72
83
  rerankChromaResults: () => rerankChromaResults,
73
84
  rerankConversationResults: () => rerankConversationResults,
85
+ resolveContextToken: () => resolveContextToken,
74
86
  resolveScriptRecipeStore: () => resolveScriptRecipeStore,
87
+ revokeContextToken: () => revokeContextToken,
88
+ runAsSystem: () => runAsSystem,
89
+ runAsUser: () => runAsUser,
75
90
  runCodingAgent: () => runCodingAgent,
76
91
  runScript: () => runScript,
92
+ runWithContext: () => runWithContext,
93
+ runWithMessageIdentity: () => runWithMessageIdentity,
77
94
  startAgentTriggers: () => startAgentTriggers,
78
95
  stopAgentTriggers: () => stopAgentTriggers,
79
96
  userPromptErrorLogger: () => userPromptErrorLogger,
97
+ withCapturedContext: () => withCapturedContext,
80
98
  writeBridgeShims: () => writeBridgeShims
81
99
  });
82
100
  module.exports = __toCommonJS(index_exports);
@@ -360,11 +378,17 @@ var UserPromptRequestPayloadSchema = import_zod3.z.object({
360
378
  }).optional(),
361
379
  responseMode: import_zod3.z.enum(["component", "text"]).optional()
362
380
  });
381
+ var MessageAuthContextSchema = import_zod3.z.object({
382
+ userId: import_zod3.z.string(),
383
+ orgId: import_zod3.z.string().optional(),
384
+ role: import_zod3.z.string().optional()
385
+ });
363
386
  var UserPromptRequestMessageSchema = import_zod3.z.object({
364
387
  id: import_zod3.z.string(),
365
388
  from: MessageParticipantSchema,
366
389
  type: import_zod3.z.literal("USER_PROMPT_REQ"),
367
- payload: UserPromptRequestPayloadSchema
390
+ payload: UserPromptRequestPayloadSchema,
391
+ authContext: MessageAuthContextSchema.optional()
368
392
  });
369
393
  var UserPromptAbortRequestPayloadSchema = import_zod3.z.object({
370
394
  uiBlockId: import_zod3.z.string(),
@@ -814,7 +838,8 @@ var DashCompRequestMessageSchema = import_zod3.z.object({
814
838
  id: import_zod3.z.string(),
815
839
  from: MessageParticipantSchema,
816
840
  type: import_zod3.z.literal("DASH_COMP_REQ"),
817
- payload: DashCompRequestPayloadSchema
841
+ payload: DashCompRequestPayloadSchema,
842
+ authContext: MessageAuthContextSchema.optional()
818
843
  });
819
844
  var DashboardAgentRequestPayloadSchema = import_zod3.z.object({
820
845
  prompt: import_zod3.z.string(),
@@ -887,7 +912,8 @@ var ReportCompRequestMessageSchema = import_zod3.z.object({
887
912
  id: import_zod3.z.string(),
888
913
  from: MessageParticipantSchema,
889
914
  type: import_zod3.z.literal("REPORT_COMP_REQ"),
890
- payload: ReportCompRequestPayloadSchema
915
+ payload: ReportCompRequestPayloadSchema,
916
+ authContext: MessageAuthContextSchema.optional()
891
917
  });
892
918
  var ReportCompAbortRequestPayloadSchema = import_zod3.z.object({
893
919
  reportId: import_zod3.z.string()
@@ -1681,6 +1707,101 @@ var ThreadManager = class _ThreadManager {
1681
1707
 
1682
1708
  // src/utils/query-cache.ts
1683
1709
  var import_crypto3 = __toESM(require("crypto"));
1710
+
1711
+ // src/utils/request-context.ts
1712
+ var import_async_hooks = require("async_hooks");
1713
+ var STORAGE_KEY = Symbol.for("superatom.requestContext.v1");
1714
+ function getStorage() {
1715
+ const g = globalThis;
1716
+ let store = g[STORAGE_KEY];
1717
+ if (!store) {
1718
+ store = new import_async_hooks.AsyncLocalStorage();
1719
+ g[STORAGE_KEY] = store;
1720
+ }
1721
+ return store;
1722
+ }
1723
+ function enforcementApplies() {
1724
+ return (process.env.SA_SQL_REWRITE_MODE || "").toLowerCase() === "enforce";
1725
+ }
1726
+ function getRequestContext() {
1727
+ return getStorage().getStore();
1728
+ }
1729
+ function requireRequestContext(what = "this operation") {
1730
+ const ctx = getStorage().getStore();
1731
+ if (!ctx) {
1732
+ throw new MissingRequestContextError(
1733
+ `No request context for ${what}. The caller must establish one with runWithContext() or declare itself trusted with runAsSystem().`
1734
+ );
1735
+ }
1736
+ return ctx;
1737
+ }
1738
+ var MissingRequestContextError = class extends Error {
1739
+ constructor(message) {
1740
+ super(message);
1741
+ this.name = "MissingRequestContextError";
1742
+ }
1743
+ };
1744
+ function runWithContext(ctx, fn) {
1745
+ return getStorage().run(ctx, fn);
1746
+ }
1747
+ function runAsUser(user, fn) {
1748
+ const { userId, orgId, role, projectId, requestId, policies } = user;
1749
+ return runWithContext(
1750
+ { principal: { kind: "user", userId, orgId, role }, projectId, requestId, policies },
1751
+ fn
1752
+ );
1753
+ }
1754
+ function runAsSystem(reason, fn, opts) {
1755
+ if (!reason || !reason.trim()) {
1756
+ throw new Error("runAsSystem() requires a non-empty reason \u2014 it identifies the path in the audit log.");
1757
+ }
1758
+ return runWithContext(
1759
+ { principal: { kind: "system", reason: reason.trim() }, projectId: opts?.projectId, requestId: opts?.requestId },
1760
+ fn
1761
+ );
1762
+ }
1763
+ function captureContext() {
1764
+ return getStorage().getStore();
1765
+ }
1766
+ function withCapturedContext(ctx, fn) {
1767
+ return ctx ? runWithContext(ctx, fn) : fn();
1768
+ }
1769
+ function bindContext(fn) {
1770
+ const captured = captureContext();
1771
+ return (...args) => withCapturedContext(captured, () => fn(...args));
1772
+ }
1773
+ function describeContext(ctx = getRequestContext()) {
1774
+ if (!ctx) return "anonymous";
1775
+ return ctx.principal.kind === "user" ? `user:${ctx.principal.userId}` : `system:${ctx.principal.reason}`;
1776
+ }
1777
+ function isUserContext(ctx = getRequestContext()) {
1778
+ return ctx?.principal.kind === "user";
1779
+ }
1780
+
1781
+ // src/utils/query-cache.ts
1782
+ var fingerprints = /* @__PURE__ */ new WeakMap();
1783
+ function canonicalPolicies(policies) {
1784
+ const norm = policies.map((p) => ({
1785
+ s: p.sourceId ?? "*",
1786
+ t: [...p.readableTables ?? []].sort(),
1787
+ f: (p.rowFilters ?? []).map((f) => ({
1788
+ t: f.table ?? "*",
1789
+ c: f.column,
1790
+ o: f.op ?? "in",
1791
+ v: [...f.values ?? []].map((v) => String(v)).sort()
1792
+ })).sort((a, b) => `${a.t}.${a.c}.${a.o}`.localeCompare(`${b.t}.${b.c}.${b.o}`))
1793
+ }));
1794
+ norm.sort((a, b) => a.s.localeCompare(b.s));
1795
+ return JSON.stringify(norm);
1796
+ }
1797
+ function policyFingerprint(policies) {
1798
+ const memo = fingerprints.get(policies);
1799
+ if (memo !== void 0) return memo;
1800
+ const fp = import_crypto3.default.createHash("sha256").update(canonicalPolicies(policies)).digest("hex").slice(0, 16);
1801
+ fingerprints.set(policies, fp);
1802
+ return fp;
1803
+ }
1804
+ var warnedNoContext = false;
1684
1805
  var QueryCache = class {
1685
1806
  constructor() {
1686
1807
  this.cache = /* @__PURE__ */ new Map();
@@ -1712,6 +1833,31 @@ var QueryCache = class {
1712
1833
  getTTL() {
1713
1834
  return this.ttlMs / 60 / 1e3;
1714
1835
  }
1836
+ /**
1837
+ * Which access bucket this call reads and writes.
1838
+ *
1839
+ * `''` means the unrestricted bucket and produces a key identical to the one
1840
+ * used before authorization existed. `null` means do not touch the cache.
1841
+ */
1842
+ scope() {
1843
+ if (!enforcementApplies()) return "";
1844
+ const ctx = getRequestContext();
1845
+ if (!ctx) {
1846
+ if (!warnedNoContext) {
1847
+ warnedNoContext = true;
1848
+ logger.warn("[QueryCache] bypassed \u2014 no request context under SA_SQL_REWRITE_MODE=enforce");
1849
+ }
1850
+ return null;
1851
+ }
1852
+ const policies = ctx.policies ?? [];
1853
+ return policies.length ? policyFingerprint(policies) : "";
1854
+ }
1855
+ /** The stored key: the scope namespace, then the caller's key. NUL cannot occur in SQL. */
1856
+ scopedKey(query) {
1857
+ const s = this.scope();
1858
+ if (s === null) return null;
1859
+ return s ? `${s}\0${query}` : query;
1860
+ }
1715
1861
  /**
1716
1862
  * Store query result in data cache.
1717
1863
  * If the key already exists, it's removed first so the re-insert places it
@@ -1719,12 +1865,14 @@ var QueryCache = class {
1719
1865
  * a genuinely new key past the size limit.
1720
1866
  */
1721
1867
  set(query, data) {
1722
- this.cache.delete(query);
1868
+ const key = this.scopedKey(query);
1869
+ if (key === null) return;
1870
+ this.cache.delete(key);
1723
1871
  if (this.cache.size >= this.maxCacheSize) {
1724
1872
  const oldestKey = this.cache.keys().next().value;
1725
1873
  if (oldestKey) this.cache.delete(oldestKey);
1726
1874
  }
1727
- this.cache.set(query, {
1875
+ this.cache.set(key, {
1728
1876
  query,
1729
1877
  data,
1730
1878
  timestamp: Date.now()
@@ -1737,14 +1885,16 @@ var QueryCache = class {
1737
1885
  * iteration order — turning FIFO eviction into true LRU.
1738
1886
  */
1739
1887
  get(query) {
1740
- const entry = this.cache.get(query);
1888
+ const key = this.scopedKey(query);
1889
+ if (key === null) return null;
1890
+ const entry = this.cache.get(key);
1741
1891
  if (!entry) return null;
1742
1892
  if (Date.now() - entry.timestamp > this.ttlMs) {
1743
- this.cache.delete(query);
1893
+ this.cache.delete(key);
1744
1894
  return null;
1745
1895
  }
1746
- this.cache.delete(query);
1747
- this.cache.set(query, entry);
1896
+ this.cache.delete(key);
1897
+ this.cache.set(key, entry);
1748
1898
  logger.info(`[QueryCache] Cache HIT for query (${query.substring(0, 50)}...)`);
1749
1899
  return entry.data;
1750
1900
  }
@@ -1758,7 +1908,8 @@ var QueryCache = class {
1758
1908
  * Remove a specific query from cache
1759
1909
  */
1760
1910
  delete(query) {
1761
- this.cache.delete(query);
1911
+ const key = this.scopedKey(query);
1912
+ if (key !== null) this.cache.delete(key);
1762
1913
  }
1763
1914
  /**
1764
1915
  * Clear all cached entries
@@ -2261,6 +2412,96 @@ function buildDirectToolCacheKey(toolId, parameters) {
2261
2412
  return `et-direct:${toolId}:${stableStringify(parameters || {})}`;
2262
2413
  }
2263
2414
 
2415
+ // src/handlers/request-identity.ts
2416
+ function policiesFromConfig(config2) {
2417
+ const entries = Array.isArray(config2) ? config2 : [config2];
2418
+ const out = [];
2419
+ for (const entry of entries) {
2420
+ if (!entry || typeof entry !== "object") continue;
2421
+ const e = entry;
2422
+ const rawFilters = Array.isArray(e.rowFilters) ? e.rowFilters : [];
2423
+ const rowFilters = [];
2424
+ for (const f of rawFilters) {
2425
+ if (!f || typeof f !== "object") continue;
2426
+ const rf = f;
2427
+ if (typeof rf.column !== "string" || !rf.column) continue;
2428
+ if (!Array.isArray(rf.values) || rf.values.length === 0) continue;
2429
+ const op = typeof rf.op === "string" ? rf.op.toLowerCase() : "in";
2430
+ if (op !== "in" && op !== "not_in" && op !== "eq" && op !== "ne") continue;
2431
+ rowFilters.push({
2432
+ table: typeof rf.table === "string" && rf.table ? rf.table : void 0,
2433
+ column: rf.column,
2434
+ op,
2435
+ values: rf.values,
2436
+ label: typeof rf.label === "string" && rf.label.trim() ? rf.label.trim() : void 0
2437
+ });
2438
+ }
2439
+ const readableTables = Array.isArray(e.readableTables) ? e.readableTables.filter((t) => typeof t === "string" && !!t) : [];
2440
+ if (rowFilters.length === 0 && readableTables.length === 0) continue;
2441
+ const label = typeof e.label === "string" && e.label.trim() ? e.label.trim() : void 0;
2442
+ if (!label && rowFilters.some((f) => !f.label) && !warnedUnlabelled) {
2443
+ warnedUnlabelled = true;
2444
+ logger.warn(
2445
+ '[RequestIdentity] An access policy has no "label" \u2014 the agent will describe it to the user as a raw SQL predicate. Add a short noun phrase, e.g. "label": "Hyderabad branch".'
2446
+ );
2447
+ }
2448
+ out.push({
2449
+ sourceId: typeof e.sourceId === "string" && e.sourceId ? e.sourceId : null,
2450
+ label,
2451
+ rowFilters,
2452
+ readableTables
2453
+ });
2454
+ }
2455
+ return out;
2456
+ }
2457
+ var warnedUntrusted = false;
2458
+ var warnedAnonymous = false;
2459
+ var warnedUnlabelled = false;
2460
+ var announcedVerified = false;
2461
+ var reportedIdMismatches = /* @__PURE__ */ new Set();
2462
+ function runWithMessageIdentity(message, fn) {
2463
+ const verified = message?.authContext;
2464
+ const requestId = message?.id;
2465
+ const policies = policiesFromConfig(message?.payload?.config);
2466
+ if (verified?.userId) {
2467
+ const claimedId = message?.payload?.userId;
2468
+ if (!announcedVerified) {
2469
+ announcedVerified = true;
2470
+ logger.info(
2471
+ `[RequestIdentity] Using the relay-verified authContext as the principal (userId ${verified.userId})${claimedId ? `; client claimed ${claimedId === verified.userId ? "the same id" : claimedId}` : ""}.`
2472
+ );
2473
+ }
2474
+ if (claimedId && claimedId !== verified.userId) {
2475
+ const pair = `${verified.userId}<-${claimedId}`;
2476
+ if (!reportedIdMismatches.has(pair) && reportedIdMismatches.size < 100) {
2477
+ reportedIdMismatches.add(pair);
2478
+ logger.warn(
2479
+ `[RequestIdentity] IDENTITY MISMATCH: the verified session says userId=${verified.userId} but the client sent payload.userId=${claimedId}. The verified id wins. Anything keyed on the principal (conversation history, threads, bookmarks) will look EMPTY for this user until the two agree.`
2480
+ );
2481
+ }
2482
+ }
2483
+ return runAsUser(
2484
+ { userId: verified.userId, orgId: verified.orgId, role: verified.role, requestId, policies },
2485
+ fn
2486
+ );
2487
+ }
2488
+ const claimed = message?.payload?.userId;
2489
+ if (typeof claimed === "string" && claimed) {
2490
+ if (!warnedUntrusted) {
2491
+ warnedUntrusted = true;
2492
+ logger.warn(
2493
+ "[RequestIdentity] Using the client-supplied payload.userId as the principal \u2014 the relay is not stamping a verified authContext yet, so this identity is NOT trustworthy. Fine for plumbing; do not enable policy enforcement until the relay change lands."
2494
+ );
2495
+ }
2496
+ return runAsUser({ userId: claimed, requestId, policies }, fn);
2497
+ }
2498
+ if (!warnedAnonymous) {
2499
+ warnedAnonymous = true;
2500
+ logger.info("[RequestIdentity] No user identity on this message \u2014 running without a request context.");
2501
+ }
2502
+ return fn();
2503
+ }
2504
+
2264
2505
  // src/handlers/data-request.ts
2265
2506
  function getQueryCacheKey(query) {
2266
2507
  if (typeof query === "string") {
@@ -2300,6 +2541,9 @@ function getCacheKey(collection, op, params, tools) {
2300
2541
  return "";
2301
2542
  }
2302
2543
  async function handleDataRequest(data, collections, sendMessage, tools) {
2544
+ return runWithMessageIdentity(data, () => handleDataRequestInner(data, collections, sendMessage, tools));
2545
+ }
2546
+ async function handleDataRequestInner(data, collections, sendMessage, tools) {
2303
2547
  let requestId;
2304
2548
  let collection;
2305
2549
  let op;
@@ -7157,7 +7401,6 @@ Rules: state ONLY filters you actually applied (do not claim a filter you didn't
7157
7401
  ### Knowledge Base Context
7158
7402
  {{KNOWLEDGE_BASE_CONTEXT}}
7159
7403
 
7160
- ### User Access Scope
7161
7404
  {{USER_ACCESS_CONTEXT}}
7162
7405
 
7163
7406
  ### Conversation History
@@ -7187,7 +7430,6 @@ Your job is to fetch the requested data using the available tool.
7187
7430
 
7188
7431
  ### Critical Rules
7189
7432
  - **CRITICAL: Always apply the filtering rules from the Global Knowledge Base and Knowledge Base Context sections below.** These define how business entities should be filtered in queries. If a knowledge base entry specifies column conditions for an entity, ALWAYS include those conditions as WHERE clauses.
7190
- - **CRITICAL: Always apply the restrictions in the User Access Scope section below \u2014 unlike other defaults in this prompt, this is NOT overridden by an explicit user request.** If it names a value for a dimension (e.g. a location/branch id) and this table has a column representing that dimension, ALWAYS filter on it, inferring the matching column from the schema. If the user explicitly asks for a different value on that dimension, do NOT fetch it \u2014 say they don't have access to it.
7191
7433
  - **CRITICAL: Follow ALL the database-specific SQL rules above.** These prevent syntax errors specific to this database engine. Violating them will cause query failures.
7192
7434
  - **CRITICAL ROW LIMIT: Every query MUST limit results to {{MAX_ROWS}} rows maximum.** {{ROW_LIMIT_SYNTAX}}. Queries without a row limit will be rejected. For UNION queries, apply the limit to the outermost query or to each branch.
7193
7435
 
@@ -7231,7 +7473,6 @@ Your job is to fetch the requested data using the available tool.
7231
7473
  ### Knowledge Base Context
7232
7474
  {{KNOWLEDGE_BASE_CONTEXT}}
7233
7475
 
7234
- ### User Access Scope
7235
7476
  {{USER_ACCESS_CONTEXT}}`,
7236
7477
  user: `Current Date and Time: {{CURRENT_DATETIME}}
7237
7478
 
@@ -9212,9 +9453,9 @@ var PromptLoader = class {
9212
9453
  const rulesPath = import_path3.default.join(this.promptsDir, "database-rules", `${this.databaseType}.md`);
9213
9454
  try {
9214
9455
  if (import_fs4.default.existsSync(rulesPath)) {
9215
- const rules = import_fs4.default.readFileSync(rulesPath, "utf-8");
9216
- this.databaseRulesCache.set(this.databaseType, rules);
9217
- return rules;
9456
+ const rules2 = import_fs4.default.readFileSync(rulesPath, "utf-8");
9457
+ this.databaseRulesCache.set(this.databaseType, rules2);
9458
+ return rules2;
9218
9459
  }
9219
9460
  } catch (error) {
9220
9461
  logger.warn(`Could not load database rules for '${this.databaseType}' from file system: ${error}`);
@@ -9255,9 +9496,9 @@ var PromptLoader = class {
9255
9496
  const rulesPath = import_path3.default.join(this.promptsDir, "database-rules", `${dbType}.md`);
9256
9497
  try {
9257
9498
  if (import_fs4.default.existsSync(rulesPath)) {
9258
- const rules = import_fs4.default.readFileSync(rulesPath, "utf-8");
9259
- this.databaseRulesCache.set(dbType, rules);
9260
- return rules;
9499
+ const rules2 = import_fs4.default.readFileSync(rulesPath, "utf-8");
9500
+ this.databaseRulesCache.set(dbType, rules2);
9501
+ return rules2;
9261
9502
  }
9262
9503
  } catch (error) {
9263
9504
  logger.warn(`Could not load database rules for '${dbType}' from file system: ${error}`);
@@ -9955,6 +10196,57 @@ function validateAndFixSqlQuery(query, dbType) {
9955
10196
  };
9956
10197
  }
9957
10198
 
10199
+ // src/userResponse/agents/access-section.ts
10200
+ var HEADING = "### User Access Scope";
10201
+ var enforced = enforcementApplies;
10202
+ var EMPTY_RESULT_RULE = `**An EMPTY result is not evidence of absence.** Zero rows under a restriction usually means the records are outside the user's access, not that they do not exist. Never report it as "no data found", "there are none", or "no records exist" \u2014 say the data is outside their access.`;
10203
+ function mainRule() {
10204
+ return enforced() ? "**These restrictions are applied automatically to every query before it runs.** Do NOT write them into your SQL, into an INTENT you dispatch to a source, or into a saved script \u2014 a scope you add is redundant, and it goes stale the moment this user's access changes. Dispatch the question as the user asked it; the scope is enforced for you.\n**If the user names a value outside this scope** (by name or via a resolved id), do not query it \u2014 say they lack access. For a question mixing in-scope and out-of-scope items (a comparison, say), answer the part you can and state plainly which part you cannot.\n" + EMPTY_RESULT_RULE : "**CRITICAL: Apply this on every question and every dispatch \u2014 it overrides an explicit user request AND a resolved entity id.** If the user names a value outside this scope (by name or via a resolved id), do not query it \u2014 say they lack access. For a question mixing in-scope and out-of-scope items (a comparison, say), answer the part you can and state plainly which part you cannot.\n" + EMPTY_RESULT_RULE;
10205
+ }
10206
+ function sourceRule() {
10207
+ return (enforced() ? "**These restrictions are already applied to every query before it runs.** Do NOT add them to your SQL \u2014 a filter you write is redundant at best, and stale the moment this user's access changes. Write the query as if unrestricted; the scope is enforced for you.\n" : "**CRITICAL: Apply these restrictions on every query \u2014 they override both an explicit user request AND a RESOLVED ENTITIES id.** If a restriction names a value for a dimension (e.g. a location/branch id) and the table you are querying has a column representing that dimension, ALWAYS filter on it, inferring the matching column from the schema. Never use the user's or a resolved id's value where it conflicts. On conflict, refuse and say the user lacks access \u2014 do not fetch.\n") + EMPTY_RESULT_RULE;
10208
+ }
10209
+ function editRule() {
10210
+ return enforced() ? "**CRITICAL: this overrides the edit instruction.** The scope below is applied automatically on every run. If the script hardcodes any of it \u2014 as a SQL predicate or in its text \u2014 REMOVE it: a baked-in scope returns zero rows the moment this user's access changes. Never add one." : '**CRITICAL: this overrides the edit instruction.** A filter that conflicts with the scope below is not "correct as-is" \u2014 fix it regardless of what the edit instruction named.';
10211
+ }
10212
+ var rules = () => ({
10213
+ main: mainRule(),
10214
+ source: sourceRule(),
10215
+ edit: editRule()
10216
+ });
10217
+ function buildAccessSection(renderedConfig, variant) {
10218
+ const body = (renderedConfig || "").trim();
10219
+ if (!body) return "";
10220
+ return `${HEADING}
10221
+ ${rules()[variant]}
10222
+ ${body}`;
10223
+ }
10224
+ function buildAccessBodyContract(renderedConfig) {
10225
+ if (!(renderedConfig || "").trim()) return "";
10226
+ return `
10227
+
10228
+ ### Access policy \u2014 never write it into the script
10229
+
10230
+ A query result may report that an authorization policy restricted it. That restriction is enforced
10231
+ automatically on every run, including replays. So:
10232
+
10233
+ - **Never put a policy predicate in your SQL** (e.g. a branch/tenant/region id you were told was
10234
+ applied). It is redundant, and it goes stale the moment that user's access changes \u2014 the recipe
10235
+ would keep filtering on the old scope while the live policy applies the new one, giving zero rows.
10236
+ - **Never hardcode the scope in text.** \`getAnalysis\` and \`getComponents\` receive the restrictions
10237
+ that actually applied to THIS run as their 6th argument, \`scope: string[]\`. Use it:
10238
+
10239
+ \`\`\`js
10240
+ export function getAnalysis(data, params, fmt, datasets, entities, scope) {
10241
+ const limitedTo = scope.length ? \` (\${scope.join('; ')})\` : '';
10242
+ return { analysis: \`**\${fmt.num(data[0].Total)} records**\${limitedTo}.\` };
10243
+ }
10244
+ \`\`\`
10245
+
10246
+ \`scope\` is empty when no policy applied, so the same body is correct for restricted and
10247
+ unrestricted users alike.`;
10248
+ }
10249
+
9958
10250
  // src/userResponse/agents/source-agent.ts
9959
10251
  var SourceAgent = class {
9960
10252
  constructor(tool, config2, streamBuffer, options) {
@@ -10141,6 +10433,21 @@ Analyze the error and try again with a corrected query.`;
10141
10433
  name: this.tool.name,
10142
10434
  params: cappedInput,
10143
10435
  result: {
10436
+ // The query was restricted before it ran. The agent cannot see
10437
+ // this from the SQL it wrote — it wrote no such restriction — so
10438
+ // without being told it labels the answer as if unrestricted.
10439
+ // Confirmed live: a branch-scoped count reported as covering
10440
+ // every branch, plus a wasted turn trying to remove a
10441
+ // restriction that is re-applied to every query.
10442
+ //
10443
+ // The WORDING is a fixed, generic frame; the restrictions
10444
+ // themselves are whatever the deployment's policy produced.
10445
+ // Every client shares this code, so nothing here may assume a
10446
+ // policy TYPE (row filter, masked column, restricted table) or
10447
+ // any client's vocabulary.
10448
+ ...result.metadata?.appliedPolicies?.length ? {
10449
+ _ACCESS_POLICY_APPLIED: `An authorization policy restricted this query before it ran: ${result.metadata.appliedPolicies.join("; ")}. These results are ONLY what this user is permitted to see. State the restriction in titles, analysis and method lines; never present the figures as complete, overall, or covering everything. The policy is re-applied to every query, so rewriting without the restriction returns the same result \u2014 do not retry to widen it.`
10450
+ } : {},
10144
10451
  ...hitFetchCap ? {
10145
10452
  _WARNING_TRUNCATED: `This result FILLED the ${this.config.maxRowsFetched}-row fetch cap \u2014 it is a PARTIAL head slice, NOT the full result. There are almost certainly more rows, and (if ordered) often all from just the first few groups (e.g. the first districts alphabetically). Treat the set of districts/categories/entities and any count/total shown here as INCOMPLETE \u2014 never as the full set.`
10146
10453
  } : {},
@@ -10166,7 +10473,9 @@ Analyze the error and try again with a corrected query.`;
10166
10473
  const formatted = typeof formattedResult === "string" ? formattedResult : JSON.stringify(formattedResult);
10167
10474
  const followUpNote = successfulQueries < 2 ? "STOP now and return this data \u2014 it satisfies the intent in the common case. Make a second query ONLY if a column or row the intent explicitly requires is verifiably MISSING from these results (not merely to widen, re-sort, or double-check). A redundant follow-up wastes a full round-trip." : "Maximum queries reached. Use the data you have.";
10168
10475
  const capWarning = hitFetchCap ? ` \u26A0\uFE0F This filled the ${this.config.maxRowsFetched}-row fetch cap \u2014 a partial head slice; the groups/values shown are NOT the complete set.` : "";
10169
- return `\u2705 Query executed successfully. ${resultData.length} rows returned.${capWarning} ${followUpNote}
10476
+ const emptyUnderPolicy = resultData.length === 0;
10477
+ const policyNote = result.metadata?.appliedPolicies?.length ? ` \u{1F512} An authorization policy restricted this query before it ran: ${result.metadata.appliedPolicies.join("; ")}. These rows are ONLY what this user may see. ` + (emptyUnderPolicy ? `It returned ZERO rows UNDER THAT RESTRICTION \u2014 that is NOT evidence the records do not exist; they are most likely outside this user's access. Say that; never report it as "no data found" or "there are none". ` : "") + `Work that restriction into your wording naturally (e.g. "\u2026 for <restriction>"); never paste it in as a raw filter expression, and never call the figures complete or overall. The policy re-applies to every query, so re-running without it returns the same rows. Do NOT copy the restriction into your SQL or into a saved script: it is enforced automatically, and a policy written into a script goes stale the moment this user's access changes.` : "";
10478
+ return `\u2705 Query executed successfully. ${resultData.length} rows returned.${capWarning}${policyNote} ${followUpNote}
10170
10479
 
10171
10480
  ${formatted}`;
10172
10481
  } catch (execError) {
@@ -10385,7 +10694,7 @@ Even if a table appears in the detailed schema above, search_schema returns samp
10385
10694
  logger.warn(`[SourceAgent:${this.tool.name}] Trace capture failed for schemaSearchInstructionsText (non-blocking): ${traceErr}`);
10386
10695
  }
10387
10696
  const displaySourceType = sourceType === "excel" || sourceType === "csv" ? `${sourceType} \u2014 uses DuckDB SQL engine` : sourceType;
10388
- const userAccessContextText = this.config.userAccessContext || "No user-specific access restrictions.";
10697
+ const userAccessContextText = buildAccessSection(this.config.userAccessContext ?? "", "source");
10389
10698
  try {
10390
10699
  if (this.traceSchemaEntry) this.traceSchemaEntry.userAccessContextText = userAccessContextText;
10391
10700
  } catch (traceErr) {
@@ -10574,6 +10883,55 @@ var LineSplitter = class {
10574
10883
  }
10575
10884
  };
10576
10885
 
10886
+ // src/utils/context-token.ts
10887
+ var import_crypto5 = require("crypto");
10888
+ var DEFAULT_TTL_MS = 30 * 6e4;
10889
+ var KEY = Symbol.for("superatom.contextTokens.v1");
10890
+ function registry() {
10891
+ const g = globalThis;
10892
+ let m = g[KEY];
10893
+ if (!m) {
10894
+ m = /* @__PURE__ */ new Map();
10895
+ g[KEY] = m;
10896
+ }
10897
+ return m;
10898
+ }
10899
+ function sweep(now = Date.now()) {
10900
+ const reg = registry();
10901
+ for (const [token, entry] of reg) {
10902
+ if (entry.expiresAt <= now) reg.delete(token);
10903
+ }
10904
+ }
10905
+ function mintContextToken(ttlMs = DEFAULT_TTL_MS) {
10906
+ const ctx = captureContext();
10907
+ if (!ctx) return void 0;
10908
+ sweep();
10909
+ const token = (0, import_crypto5.randomBytes)(24).toString("base64url");
10910
+ registry().set(token, { ctx, expiresAt: Date.now() + ttlMs });
10911
+ return token;
10912
+ }
10913
+ function resolveContextToken(token) {
10914
+ if (!token) return void 0;
10915
+ const entry = registry().get(token);
10916
+ if (!entry) return void 0;
10917
+ if (entry.expiresAt <= Date.now()) {
10918
+ registry().delete(token);
10919
+ logger.warn("[ContextToken] Rejected an expired token \u2014 the script outlived its context grant.");
10920
+ return void 0;
10921
+ }
10922
+ return entry.ctx;
10923
+ }
10924
+ function revokeContextToken(token) {
10925
+ if (token) registry().delete(token);
10926
+ }
10927
+ function contextTokenCount() {
10928
+ sweep();
10929
+ return registry().size;
10930
+ }
10931
+ function clearContextTokens() {
10932
+ registry().clear();
10933
+ }
10934
+
10577
10935
  // src/userResponse/scripts/script-runner.ts
10578
10936
  var fmtConfig;
10579
10937
  function setFmtConfig(cfg) {
@@ -10699,7 +11057,8 @@ FIX: pass every required parameter to execute_script, or give it a default in wr
10699
11057
  // analysisData: getData produces it
10700
11058
  void 0,
10701
11059
  // analysisDatasets: ctx.emit produces them
10702
- options.entities
11060
+ options.entities,
11061
+ options.appliedPolicies
10703
11062
  );
10704
11063
  const totalMs = Date.now() - startedAt;
10705
11064
  if (result.kind === "ok") {
@@ -10747,10 +11106,11 @@ FIX: pass every required parameter to execute_script, or give it a default in wr
10747
11106
  scriptSemaphore.release();
10748
11107
  }
10749
11108
  }
10750
- function executeInSubprocess(scriptPath, params, paramSchema, externalTools, streamBuffer, timeoutMs, signal, mode, analysisData, analysisDatasets, entities) {
11109
+ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, streamBuffer, timeoutMs, signal, mode, analysisData, analysisDatasets, entities, appliedPolicies) {
10751
11110
  return new Promise((resolve2) => {
10752
11111
  const tsxBin = resolveTsxBinary();
10753
11112
  const bootstrap = resolveBootstrapPath();
11113
+ const contextToken = mintContextToken();
10754
11114
  const child = (0, import_child_process.spawn)(
10755
11115
  process.execPath,
10756
11116
  // node — tsx runs via its own cli, we invoke it with node
@@ -10769,7 +11129,12 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10769
11129
  env: {
10770
11130
  ...process.env,
10771
11131
  // Keep the child quiet about dotenv etc.
10772
- NODE_NO_WARNINGS: "1"
11132
+ NODE_NO_WARNINGS: "1",
11133
+ // Identity for the bridge calls the script will make. The async
11134
+ // context cannot cross a process boundary, so it travels as a
11135
+ // token instead. Absent when the run has no context — the bridge
11136
+ // then treats the queries as anonymous, which is the truth.
11137
+ ...contextToken ? { SA_CONTEXT_TOKEN: contextToken } : {}
10773
11138
  }
10774
11139
  }
10775
11140
  );
@@ -10777,6 +11142,7 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10777
11142
  let executedQueries = [];
10778
11143
  let stderrBuffer = "";
10779
11144
  const cleanup = () => {
11145
+ revokeContextToken(contextToken);
10780
11146
  if (child.killed) return;
10781
11147
  const pid = child.pid;
10782
11148
  if (pid && process.platform !== "win32") {
@@ -10881,7 +11247,8 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10881
11247
  ...analysisData ? { data: analysisData } : {},
10882
11248
  ...analysisDatasets && Object.keys(analysisDatasets).length ? { datasets: analysisDatasets } : {},
10883
11249
  ...fmtConfig ? { fmt: fmtConfig } : {},
10884
- ...entities && entities.length ? { entities } : {}
11250
+ ...entities && entities.length ? { entities } : {},
11251
+ ...appliedPolicies && appliedPolicies.length ? { appliedPolicies } : {}
10885
11252
  });
10886
11253
  child.stdin.write(init);
10887
11254
  function handleChildMessage(msg) {
@@ -10966,7 +11333,12 @@ function enrichQueries(queries, externalTools) {
10966
11333
  count: q.count,
10967
11334
  totalCount: q.totalCount,
10968
11335
  executionTimeMs: q.executionTimeMs,
10969
- virtual: q.virtual
11336
+ virtual: q.virtual,
11337
+ // Carried through explicitly: this rebuilds each query field by field,
11338
+ // so anything not listed is silently dropped. Losing it here left
11339
+ // getAnalysis's `scope` empty while the query WAS restricted — the
11340
+ // analysis then described a branch-scoped count as system-wide.
11341
+ appliedPolicies: q.appliedPolicies
10970
11342
  };
10971
11343
  });
10972
11344
  }
@@ -11058,7 +11430,8 @@ async function runComponentsOnly(recipe, scriptPath, params, data, options, data
11058
11430
  "components",
11059
11431
  data,
11060
11432
  datasets,
11061
- options.entities
11433
+ options.entities,
11434
+ options.appliedPolicies
11062
11435
  );
11063
11436
  if (result.kind === "ok") {
11064
11437
  return { components: result.components, componentsError: result.componentsError };
@@ -11091,7 +11464,8 @@ async function runAnalysisOnly(recipe, scriptPath, params, data, options, datase
11091
11464
  "analysis",
11092
11465
  data,
11093
11466
  datasets,
11094
- options.entities
11467
+ options.entities,
11468
+ options.appliedPolicies
11095
11469
  );
11096
11470
  if (result.kind === "ok") {
11097
11471
  return { analysis: result.analysis, analysisError: result.analysisError };
@@ -11288,7 +11662,7 @@ function resolveScriptRecipeStore(collections) {
11288
11662
  // src/userResponse/scripts/script-store.ts
11289
11663
  var fs6 = __toESM(require("fs"));
11290
11664
  var path6 = __toESM(require("path"));
11291
- var import_crypto5 = require("crypto");
11665
+ var import_crypto6 = require("crypto");
11292
11666
  var DEFAULT_STORE_DIR = "scripts-store";
11293
11667
  function stripAnalysisExport(body) {
11294
11668
  return stripNamedExport(body, "getAnalysis");
@@ -11315,7 +11689,7 @@ function stripNamedExport(body, fnName) {
11315
11689
  function dataFingerprint(scriptBody, parameters) {
11316
11690
  const data = extractNamedExport(scriptBody, "getData") ?? scriptBody;
11317
11691
  const params = (parameters ?? []).map((p) => `${p.name}:${p.type}:${JSON.stringify(p.default ?? null)}`).sort().join("|");
11318
- return (0, import_crypto5.createHash)("sha256").update(`${data.trim()}\0${params}`).digest("hex");
11692
+ return (0, import_crypto6.createHash)("sha256").update(`${data.trim()}\0${params}`).digest("hex");
11319
11693
  }
11320
11694
  function extractNamedExport(body, fnName) {
11321
11695
  const start = body.search(new RegExp(`export\\s+(?:async\\s+)?function\\s+${fnName}\\b`));
@@ -11500,7 +11874,7 @@ var ScriptStore = class {
11500
11874
  const now = (/* @__PURE__ */ new Date()).toISOString();
11501
11875
  const existing = input.recipeId ? await this.store.getById(input.recipeId) : null;
11502
11876
  const reuse = existing && existing.status === "draft" && existing.turnId === input.turnId;
11503
- const id = reuse ? existing.id : `script_${Date.now()}_${(0, import_crypto5.randomBytes)(3).toString("hex")}`;
11877
+ const id = reuse ? existing.id : `script_${Date.now()}_${(0, import_crypto6.randomBytes)(3).toString("hex")}`;
11504
11878
  const fileBase = reuse ? existing.fileBase : input.parentId ? `${this.toSlug(input.name)}-${id.slice(-6)}` : await this.computeFileBase(input.name, id);
11505
11879
  let normalized = normalizeScriptBody(input.scriptBody);
11506
11880
  if (reuse) {
@@ -11863,7 +12237,7 @@ ${carried.trim()}
11863
12237
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed-script";
11864
12238
  }
11865
12239
  hash(body) {
11866
- return (0, import_crypto5.createHash)("sha256").update(body).digest("hex");
12240
+ return (0, import_crypto6.createHash)("sha256").update(body).digest("hex");
11867
12241
  }
11868
12242
  /**
11869
12243
  * Attach getComponents to the recipe body. The stored `components` column is
@@ -11871,6 +12245,32 @@ ${carried.trim()}
11871
12245
  * specs, a stale spec array beside a live program is how the wrong one gets
11872
12246
  * read later. See backend/docs/COMPONENTS-AS-PROGRAM-DESIGN.md § 4.4.
11873
12247
  */
12248
+ /**
12249
+ * Body text as it stands now, for reverting an attach that turns out to be
12250
+ * invalid.
12251
+ *
12252
+ * getComponents can only be VALIDATED by running it, and it can only be run
12253
+ * once it is on disk — so attachComponents necessarily writes before anyone
12254
+ * knows whether the body is good. Without a revert, a rejected body stays
12255
+ * attached: promote() then sees the recipe "exports getComponents", clears
12256
+ * the specs the fallback generator produced, and the recipe is verified with
12257
+ * component code that was already rejected. Confirmed live on
12258
+ * "Total Bookings by Branch", which shipped a sourceRef of 'computed:_final'
12259
+ * that validateSpecs had refused.
12260
+ */
12261
+ async snapshotBody(recipeId) {
12262
+ const recipe = await this.get(recipeId);
12263
+ if (!recipe?.fileBase) return null;
12264
+ return this.readBody(recipe.fileBase) || null;
12265
+ }
12266
+ /** Put back a body captured by {@link snapshotBody}. */
12267
+ async restoreBody(recipeId, body) {
12268
+ const recipe = await this.get(recipeId);
12269
+ if (!recipe?.fileBase) return false;
12270
+ this.writeBody(recipe.fileBase, body);
12271
+ logger.info(`[ScriptStore] Reverted body of "${recipe.name}" (${recipeId}) after a rejected attach`);
12272
+ return true;
12273
+ }
11874
12274
  async attachComponents(recipeId, componentsBody) {
11875
12275
  const recipe = await this.get(recipeId);
11876
12276
  if (!recipe) {
@@ -11949,7 +12349,7 @@ ${analysisBody.trim()}
11949
12349
  writeArchive(fileBase, version, body) {
11950
12350
  if (!fs6.existsSync(this.archiveDir)) fs6.mkdirSync(this.archiveDir, { recursive: true });
11951
12351
  const finalPath = path6.join(this.archiveDir, `${fileBase}.v${version}.ts`);
11952
- const tmpPath = `${finalPath}.tmp-${(0, import_crypto5.randomBytes)(4).toString("hex")}`;
12352
+ const tmpPath = `${finalPath}.tmp-${(0, import_crypto6.randomBytes)(4).toString("hex")}`;
11953
12353
  fs6.writeFileSync(tmpPath, body);
11954
12354
  fs6.renameSync(tmpPath, finalPath);
11955
12355
  }
@@ -11976,7 +12376,7 @@ ${analysisBody.trim()}
11976
12376
  writeBody(fileBase, normalizedBody) {
11977
12377
  if (!fs6.existsSync(this.storeDir)) fs6.mkdirSync(this.storeDir, { recursive: true });
11978
12378
  const finalPath = this.bodyPath(fileBase);
11979
- const tmpPath = `${finalPath}.tmp-${(0, import_crypto5.randomBytes)(4).toString("hex")}`;
12379
+ const tmpPath = `${finalPath}.tmp-${(0, import_crypto6.randomBytes)(4).toString("hex")}`;
11980
12380
  fs6.writeFileSync(tmpPath, normalizedBody);
11981
12381
  fs6.renameSync(tmpPath, finalPath);
11982
12382
  }
@@ -12801,7 +13201,7 @@ ${catalog}`,
12801
13201
  return "write_components requires data: run write_script then execute_script successfully first.";
12802
13202
  }
12803
13203
  if (this.renderComponentAttempts >= MAX_RENDER_COMPONENT_ATTEMPTS) {
12804
- return `Reached the max of ${MAX_RENDER_COMPONENT_ATTEMPTS} write_components attempts. Stop calling it and write your answer \u2014 the system will build the dashboard.`;
13204
+ return `Reached the max of ${MAX_RENDER_COMPONENT_ATTEMPTS} write_components attempts. Stop calling it \u2014 the system will build the dashboard. This limit is write_components only; if you have not called write_analysis, call it now.`;
12805
13205
  }
12806
13206
  this.renderComponentAttempts++;
12807
13207
  const hardcodedAggregates = findHardcodedAggregates(body);
@@ -12829,6 +13229,10 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12829
13229
  const recipeId = this.scriptState.recipeId;
12830
13230
  const draft = recipeId ? await this.scriptStore.get(recipeId) : null;
12831
13231
  if (!recipeId || !draft) return "Draft was lost from the store. Call write_script again.";
13232
+ const snapshot = await this.scriptStore.snapshotBody(recipeId);
13233
+ const revert = async () => {
13234
+ if (snapshot) await this.scriptStore.restoreBody(recipeId, snapshot);
13235
+ };
12832
13236
  const attached = await this.scriptStore.attachComponents(recipeId, body);
12833
13237
  if (!attached) return "Could not attach getComponents to the draft. Call render-free: write your answer and the system will build the dashboard.";
12834
13238
  const datasets = {};
@@ -12842,12 +13246,13 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12842
13246
  this.scriptStore.getScriptPath(draft),
12843
13247
  {},
12844
13248
  result.data,
12845
- { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities },
13249
+ { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities, appliedPolicies: this.observedPolicies() },
12846
13250
  datasets
12847
13251
  );
12848
13252
  if (componentsError || !components?.length) {
12849
13253
  const why = componentsError || "it returned no components";
12850
13254
  logger.warn(`[MainAgent] write_components failed: ${why}`);
13255
+ await revert();
12851
13256
  return `getComponents failed: ${why}
12852
13257
 
12853
13258
  Fix the body and call write_components again. It receives (data, params, fmt, datasets) and must RETURN an array of component specs.`;
@@ -12856,6 +13261,14 @@ Fix the body and call write_components again. It receives (data, params, fmt, da
12856
13261
  if (findings.length > 0) {
12857
13262
  logger.warn(`[MainAgent] write_components rejected (attempt ${this.renderComponentAttempts}): ${findings.length} finding(s)`);
12858
13263
  findings.forEach((f) => logger.warn(`[MainAgent] ${f.code} ${f.componentType}: ${f.message}`));
13264
+ await revert();
13265
+ if (this.renderComponentAttempts >= MAX_RENDER_COMPONENT_ATTEMPTS && accepted.length > 0) {
13266
+ this.componentSpecs = accepted;
13267
+ this.componentLayout = { title: input?.layoutTitle || "Dashboard", description: input?.layoutDescription };
13268
+ const dropped = findings.map((f) => `${f.componentType} (${f.code})`).join(", ");
13269
+ logger.warn(`[MainAgent] write_components out of attempts \u2014 keeping ${accepted.length} valid spec(s), dropping ${findings.length}: ${dropped}`);
13270
+ return `Out of write_components attempts. Kept ${accepted.length} valid component(s); dropped ${findings.length} (${dropped}). Stop calling write_components and write your answer.`;
13271
+ }
12859
13272
  return formatFindingsForAgent(findings);
12860
13273
  }
12861
13274
  this.componentSpecs = accepted;
@@ -12871,6 +13284,20 @@ Now write your answer for the user. Do not restate the component list.`;
12871
13284
  // ============================================
12872
13285
  // Script-authoring tool handlers
12873
13286
  // ============================================
13287
+ /**
13288
+ * Restrictions the seam reported while this run's queries executed.
13289
+ *
13290
+ * getData does NOT re-run for write_components/write_analysis, so the child
13291
+ * cannot observe these itself — the parent hands them across, exactly as it
13292
+ * does for `entities`.
13293
+ */
13294
+ observedPolicies() {
13295
+ const seen = /* @__PURE__ */ new Set();
13296
+ for (const q of this.scriptState.lastSuccessfulResult?.executedQueries ?? []) {
13297
+ for (const p of q?.appliedPolicies ?? []) seen.add(p);
13298
+ }
13299
+ return [...seen];
13300
+ }
12874
13301
  /**
12875
13302
  * Author the answer as a program. Runs after execute_script, so the body is
12876
13303
  * written with the result already known and can react to what it found.
@@ -12904,14 +13331,16 @@ Now write your answer for the user. Do not restate the component list.`;
12904
13331
  const lintError = formatLintError(violations);
12905
13332
  if (lintError) {
12906
13333
  const named = violations.map((v) => `"${v.text}"`).join(", ");
13334
+ const budget = `
13335
+ Attempt ${this.scriptState.analysisAttempts} of ${MAX_ANALYSIS_ATTEMPTS}. Fix the value(s) and call write_analysis again \u2014 do not switch to prose while attempts remain.`;
12907
13336
  if (this.scriptState.analysisAttempts < MAX_ANALYSIS_ATTEMPTS) {
12908
13337
  logger.warn(`[MainAgent] write_analysis rejected \u2014 ${violations.length} hardcoded value(s): ${named}`);
12909
13338
  if (repeatedTexts.length > 0) {
12910
13339
  const repeatedList = repeatedTexts.map((t) => `"${t}"`).join(", ");
12911
13340
  return `${lintError}
12912
- STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reason won't help a second time \u2014 this is almost certainly a parameter or an already-resolved value, not something to compute fresh: if it's an id, use \`params.<name>\` directly; if it's a name, read it from \`data\`/\`datasets\` (e.g. \`datasets.summary?.[0]?.<field>\`). Replace the literal itself, not the wording around it.`;
13341
+ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reason won't help a second time \u2014 this is almost certainly a parameter or an already-resolved value, not something to compute fresh: if it's an id, use \`params.<name>\` directly; if it's a name, read it from \`data\`/\`datasets\` (e.g. \`datasets.summary?.[0]?.<field>\`). Replace the literal itself, not the wording around it.` + budget;
12913
13342
  }
12914
- return lintError;
13343
+ return lintError + budget;
12915
13344
  }
12916
13345
  logger.warn(`[MainAgent] write_analysis accepted on the final attempt DESPITE ${violations.length} hardcoded value(s): ${named} \u2014 a computed answer with a frozen figure beats LLM prose. Those value(s) will not track the data on replay.`);
12917
13346
  }
@@ -12931,7 +13360,7 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12931
13360
  this.scriptStore.getScriptPath(draft),
12932
13361
  {},
12933
13362
  verified.data,
12934
- { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities },
13363
+ { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities, appliedPolicies: this.observedPolicies() },
12935
13364
  datasets
12936
13365
  );
12937
13366
  if (analysisError || !analysis?.analysis) {
@@ -13232,11 +13661,26 @@ The verified script is intact. You can still call write_analysis and write_compo
13232
13661
  */
13233
13662
  buildResolvedEntitiesBlock() {
13234
13663
  if (!this.resolvedEntities.length) return "";
13235
- const lines = this.resolvedEntities.map((e) => {
13664
+ const render = (e) => {
13236
13665
  const scopeSql = e.attrs?.scopeSql;
13237
13666
  const scopePart = typeof scopeSql === "string" && scopeSql.trim() ? ` \u2014 READY-MADE SCOPE (use this directly, do not re-derive it): ${scopeSql}` : "";
13238
13667
  return `- "${e.mention}" \u2192 ${e.entityType} id ${e.instanceId} (name in DB: ${e.displayName})${scopePart}`;
13239
- });
13668
+ };
13669
+ const byMention = /* @__PURE__ */ new Map();
13670
+ for (const e of this.resolvedEntities) {
13671
+ const group = byMention.get(e.mention) ?? [];
13672
+ group.push(e);
13673
+ byMention.set(e.mention, group);
13674
+ }
13675
+ const lines = [];
13676
+ for (const [mention, group] of byMention) {
13677
+ if (group.length === 1) {
13678
+ lines.push(render(group[0]));
13679
+ continue;
13680
+ }
13681
+ lines.push(`- "${mention}" matched ${group.length} entity types at the same confidence \u2014 CHOOSE the one the question means and filter on that id ALONE, never on more than one:`);
13682
+ for (const e of group) lines.push(` ${render(e).slice(2)}`);
13683
+ }
13240
13684
  return `
13241
13685
 
13242
13686
  RESOLVED ENTITIES \u2014 these were looked up already. Filter on the ids; do NOT search for them by name. Where a name must appear in output or a comparison, use the "name in DB" spelling EXACTLY as given. Where a READY-MADE SCOPE is given, paste it into the query as-is instead of writing your own join/city-match/lookup for the same thing:
@@ -13556,9 +14000,25 @@ ${r.body}`).join("\n\n");
13556
14000
  logger.info(
13557
14001
  `[MainAgent] resolve_entities: ${res?.resolved?.length ?? 0} resolved, ${res?.unresolved?.length ?? 0} unresolved, ${Object.keys(res?.entityMap || {}).length} concept(s)`
13558
14002
  );
14003
+ const TIE_EPSILON = 0.05;
14004
+ const key = (e) => `${e.entityType}::${e.instanceId}`;
14005
+ const add = (mention, c) => {
14006
+ if (!c?.instanceId) return;
14007
+ if (this.resolvedEntities.some((e) => key(e) === key(c))) return;
14008
+ this.resolvedEntities.push({
14009
+ mention: String(mention ?? ""),
14010
+ entityType: String(c.entityType ?? ""),
14011
+ instanceId: String(c.instanceId),
14012
+ displayName: String(c.displayName ?? mention ?? ""),
14013
+ attrs: c.attrs && typeof c.attrs === "object" ? c.attrs : void 0
14014
+ });
14015
+ };
13559
14016
  for (const r of Array.isArray(res?.resolved) ? res.resolved : []) {
13560
14017
  if (!r?.instanceId) continue;
13561
- const key = (e) => `${e.entityType}::${e.instanceId}`;
14018
+ for (const a of Array.isArray(r.alternatives) ? r.alternatives : []) {
14019
+ const tied = typeof r.score !== "number" || typeof a?.score !== "number" ? false : r.score - a.score <= TIE_EPSILON;
14020
+ if (tied) add(r.mention, a);
14021
+ }
13562
14022
  if (this.resolvedEntities.some((e) => key(e) === key(r))) continue;
13563
14023
  this.resolvedEntities.push({
13564
14024
  mention: String(r.mention ?? ""),
@@ -13817,7 +14277,7 @@ ${propLines}` : ""
13817
14277
  const scriptBodyContract = await promptLoader.loadScriptBodyContract({
13818
14278
  MAX_ROWS_RENDERED: String(MAX_ROWS_RENDERED),
13819
14279
  MAX_ROWS_FETCHED: String(MAX_ROWS_FETCHED)
13820
- });
14280
+ }) + buildAccessBodyContract(this.config.userAccessContext ?? "");
13821
14281
  try {
13822
14282
  if (this.config.trace) this.config.trace.scriptBodyContractText = scriptBodyContract;
13823
14283
  } catch (traceErr) {
@@ -13835,7 +14295,9 @@ ${propLines}` : ""
13835
14295
  GLOBAL_KNOWLEDGE_BASE: this.config.globalKnowledgeBase || "No global knowledge base available.",
13836
14296
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
13837
14297
  KNOWLEDGE_BASE_CONTEXT: this.config.knowledgeBaseContext || "No additional knowledge base context available.",
13838
- USER_ACCESS_CONTEXT: this.config.userAccessContext || "No user-specific access restrictions.",
14298
+ // '' when the user has no policy — the whole section disappears rather
14299
+ // than rendering a heading over "no restrictions". See access-section.ts.
14300
+ USER_ACCESS_CONTEXT: buildAccessSection(this.config.userAccessContext ?? "", this.editMode ? "edit" : "main"),
13839
14301
  ENTITY_CATALOG: entityCatalogText || "No entity catalog available \u2014 resolve_entities may still be called with mentions.",
13840
14302
  CONCEPT_CATALOG: conceptCatalogText || "No concept catalog available \u2014 derive definitions from the schema and say that you did.",
13841
14303
  // Bodies, not names: an invariant is injected precisely because it
@@ -14040,8 +14502,20 @@ Execution time: ${metadata.executionTimeMs}ms
14040
14502
  rows: t?.result?._mainAgentRows ?? t?.result?._sampleData ?? [],
14041
14503
  summary: t?.result?._summary,
14042
14504
  total: t?.result?._totalRecords,
14043
- shown: t?.result?._recordsShown
14505
+ shown: t?.result?._recordsShown,
14506
+ policy: t?.result?._ACCESS_POLICY_APPLIED
14044
14507
  })).filter((q) => Boolean(q.sql));
14508
+ const policyNotes = [...new Set(queries.map((q) => q.policy).filter(Boolean))];
14509
+ if (policyNotes.length) {
14510
+ output += `
14511
+ \u{1F512} ACCESS POLICY \u2014 applies to EVERY figure below:
14512
+ ${policyNotes.map((n) => `- ${n}`).join("\n")}
14513
+ Reflect this in the answer: name the restriction in component titles, the analysis, and the method line \u2014 phrased naturally as part of the sentence, never pasted in as a raw filter expression. Do not describe these numbers as complete, overall, or covering everything.
14514
+ If a query came back EMPTY, that is NOT evidence the records do not exist \u2014 under a restriction it usually means they are outside this user's access. Say so; never write "no data found" or "there are none".
14515
+ Do NOT copy the restriction into the script's SQL: it is enforced automatically on every run, and a value written into a recipe goes stale the moment this user's access changes. getAnalysis and getComponents receive what was actually applied as their 6th argument, \`scope: string[]\` \u2014 use that to name it, in BOTH functions.
14516
+
14517
+ `;
14518
+ }
14045
14519
  if (queries.length === 0) {
14046
14520
  output += "No data returned.";
14047
14521
  return output;
@@ -17480,16 +17954,37 @@ function storeScriptDatasetQuery(q, ctx) {
17480
17954
  return queryCache.storeQuery(q.sql, { data, count });
17481
17955
  }
17482
17956
  function buildUserAccessContext(userConfig) {
17483
- if (!userConfig || typeof userConfig !== "object") return "";
17484
- const { prompt, ...rest } = userConfig;
17485
- const parts = [];
17486
- if (Object.keys(rest).length > 0) {
17487
- parts.push(JSON.stringify(rest));
17488
- }
17489
- if (typeof prompt === "string" && prompt.trim()) {
17490
- parts.push(prompt.trim());
17957
+ const entries = Array.isArray(userConfig) ? userConfig : [userConfig];
17958
+ const restrictions = [];
17959
+ const prompts = [];
17960
+ let anyRestriction = false;
17961
+ for (const e of entries) {
17962
+ if (!e || typeof e !== "object") continue;
17963
+ const rowFilters = Array.isArray(e.rowFilters) ? e.rowFilters : [];
17964
+ const readableTables = Array.isArray(e.readableTables) ? e.readableTables.filter((t) => typeof t === "string" && !!t) : [];
17965
+ const realFilters = rowFilters.filter(
17966
+ (f) => f && typeof f === "object" && f.column && Array.isArray(f.values) && f.values.length > 0
17967
+ );
17968
+ if (realFilters.length || readableTables.length) {
17969
+ anyRestriction = true;
17970
+ if (enforcementApplies()) {
17971
+ const clean = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : "";
17972
+ const labels = [
17973
+ .../* @__PURE__ */ new Set([
17974
+ ...realFilters.map((f) => clean(f.label)).filter(Boolean),
17975
+ ...clean(e.label) ? [clean(e.label)] : []
17976
+ ])
17977
+ ];
17978
+ restrictions.push(labels.length ? labels.join("; ") : "This user's data access is restricted.");
17979
+ } else {
17980
+ const { prompt: _p, ...rest } = e;
17981
+ restrictions.push(JSON.stringify(rest));
17982
+ }
17983
+ }
17984
+ if (typeof e.prompt === "string" && e.prompt.trim()) prompts.push(e.prompt.trim());
17491
17985
  }
17492
- return parts.join("\n\n");
17986
+ if (!anyRestriction && prompts.length === 0) return "";
17987
+ return [...restrictions, ...prompts].join("\n\n");
17493
17988
  }
17494
17989
  var get_agent_user_response = async (prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, conversationHistory, streamCallback, collections, externalTools, userId, mainAgentModel, sourceAgentModel, workflows, signal, activeBindings, userConfig) => {
17495
17990
  const startTime = Date.now();
@@ -19252,7 +19747,7 @@ var get_user_request = async (data, components, sendMessage, anthropicApiKey, gr
19252
19747
  };
19253
19748
  };
19254
19749
  async function handleUserPromptRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows) {
19255
- const response = await get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows);
19750
+ const response = await runWithMessageIdentity(data, () => get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows));
19256
19751
  if (response.data?.component?.props?.config?.components) {
19257
19752
  response.data.component.props.config.components = response.data.component.props.config.components.map((comp) => ({
19258
19753
  ...comp,
@@ -22353,9 +22848,9 @@ async function loadDatabaseRulesForTools(tools) {
22353
22848
  }
22354
22849
  const sections = await Promise.all(
22355
22850
  dbTypes.map(async (dbType) => {
22356
- const rules = await promptLoader.loadDatabaseRulesForType(dbType);
22851
+ const rules2 = await promptLoader.loadDatabaseRulesForType(dbType);
22357
22852
  return `## ${dbType.toUpperCase()} Rules (apply ONLY to ${dbType} tools \u2014 check each tool's ID prefix)
22358
- ${rules}`;
22853
+ ${rules2}`;
22359
22854
  })
22360
22855
  );
22361
22856
  return sections.join("\n\n---\n\n");
@@ -23406,7 +23901,7 @@ var processDashCompRequest = async (data, components, _sendMessage, anthropicApi
23406
23901
  };
23407
23902
  };
23408
23903
  async function handleDashCompRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, dashCompModels) {
23409
- const response = await processDashCompRequest(
23904
+ const response = await runWithMessageIdentity(data, () => processDashCompRequest(
23410
23905
  data,
23411
23906
  components,
23412
23907
  sendMessage,
@@ -23418,7 +23913,7 @@ async function handleDashCompRequest(data, components, sendMessage, anthropicApi
23418
23913
  collections,
23419
23914
  tools,
23420
23915
  dashCompModels
23421
- );
23916
+ ));
23422
23917
  sendDashCompResponse(
23423
23918
  response.id || data.id,
23424
23919
  {
@@ -25400,7 +25895,7 @@ var processReportCompRequest = async (data, components, _sendMessage, anthropicA
25400
25895
  };
25401
25896
  };
25402
25897
  async function handleReportCompRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, modelConfig) {
25403
- const response = await processReportCompRequest(
25898
+ const response = await runWithMessageIdentity(data, () => processReportCompRequest(
25404
25899
  data,
25405
25900
  components,
25406
25901
  sendMessage,
@@ -25412,7 +25907,7 @@ async function handleReportCompRequest(data, components, sendMessage, anthropicA
25412
25907
  collections,
25413
25908
  tools,
25414
25909
  modelConfig
25415
- );
25910
+ ));
25416
25911
  sendReportCompResponse(
25417
25912
  response.id || data.id,
25418
25913
  {
@@ -28065,6 +28560,7 @@ var SuperatomSDK = class {
28065
28560
  DEFAULT_AGENT_CONFIG,
28066
28561
  LLM,
28067
28562
  MainAgent,
28563
+ MissingRequestContextError,
28068
28564
  STORAGE_CONFIG,
28069
28565
  ScriptMatcher,
28070
28566
  ScriptStore,
@@ -28076,36 +28572,53 @@ var SuperatomSDK = class {
28076
28572
  UserManager,
28077
28573
  anthropicLLM,
28078
28574
  assertReadOnlySql,
28575
+ bindContext,
28576
+ captureContext,
28577
+ clearContextTokens,
28578
+ contextTokenCount,
28079
28579
  createCodexSession,
28080
28580
  createCodingAgentSession,
28081
28581
  dashboardConversationHistory,
28082
28582
  dataFingerprint,
28583
+ describeContext,
28083
28584
  describeTriggers,
28084
28585
  emitAgentEvent,
28085
28586
  extractNamedExport,
28086
28587
  geminiLLM,
28087
28588
  getCodingAgent,
28088
28589
  getCodingAgentsConfig,
28590
+ getRequestContext,
28089
28591
  groqLLM,
28090
28592
  hybridRerank,
28593
+ isUserContext,
28091
28594
  listCodingAgents,
28092
28595
  llmUsageLogger,
28093
28596
  logger,
28597
+ mintContextToken,
28094
28598
  normalizeScriptBody,
28095
28599
  onAgentRun,
28096
28600
  openaiLLM,
28601
+ policiesFromConfig,
28097
28602
  queryCache,
28098
28603
  registerCodingAgentCollection,
28099
28604
  registerCodingAgents,
28100
28605
  registerDashboardAgentCollection,
28606
+ requireRequestContext,
28101
28607
  rerankChromaResults,
28102
28608
  rerankConversationResults,
28609
+ resolveContextToken,
28103
28610
  resolveScriptRecipeStore,
28611
+ revokeContextToken,
28612
+ runAsSystem,
28613
+ runAsUser,
28104
28614
  runCodingAgent,
28105
28615
  runScript,
28616
+ runWithContext,
28617
+ runWithMessageIdentity,
28106
28618
  startAgentTriggers,
28107
28619
  stopAgentTriggers,
28108
28620
  userPromptErrorLogger,
28621
+ withCapturedContext,
28109
28622
  writeBridgeShims
28110
28623
  });
28111
28624
  //# sourceMappingURL=index.js.map