@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.mjs CHANGED
@@ -284,11 +284,17 @@ var UserPromptRequestPayloadSchema = z3.object({
284
284
  }).optional(),
285
285
  responseMode: z3.enum(["component", "text"]).optional()
286
286
  });
287
+ var MessageAuthContextSchema = z3.object({
288
+ userId: z3.string(),
289
+ orgId: z3.string().optional(),
290
+ role: z3.string().optional()
291
+ });
287
292
  var UserPromptRequestMessageSchema = z3.object({
288
293
  id: z3.string(),
289
294
  from: MessageParticipantSchema,
290
295
  type: z3.literal("USER_PROMPT_REQ"),
291
- payload: UserPromptRequestPayloadSchema
296
+ payload: UserPromptRequestPayloadSchema,
297
+ authContext: MessageAuthContextSchema.optional()
292
298
  });
293
299
  var UserPromptAbortRequestPayloadSchema = z3.object({
294
300
  uiBlockId: z3.string(),
@@ -738,7 +744,8 @@ var DashCompRequestMessageSchema = z3.object({
738
744
  id: z3.string(),
739
745
  from: MessageParticipantSchema,
740
746
  type: z3.literal("DASH_COMP_REQ"),
741
- payload: DashCompRequestPayloadSchema
747
+ payload: DashCompRequestPayloadSchema,
748
+ authContext: MessageAuthContextSchema.optional()
742
749
  });
743
750
  var DashboardAgentRequestPayloadSchema = z3.object({
744
751
  prompt: z3.string(),
@@ -811,7 +818,8 @@ var ReportCompRequestMessageSchema = z3.object({
811
818
  id: z3.string(),
812
819
  from: MessageParticipantSchema,
813
820
  type: z3.literal("REPORT_COMP_REQ"),
814
- payload: ReportCompRequestPayloadSchema
821
+ payload: ReportCompRequestPayloadSchema,
822
+ authContext: MessageAuthContextSchema.optional()
815
823
  });
816
824
  var ReportCompAbortRequestPayloadSchema = z3.object({
817
825
  reportId: z3.string()
@@ -1605,6 +1613,101 @@ var ThreadManager = class _ThreadManager {
1605
1613
 
1606
1614
  // src/utils/query-cache.ts
1607
1615
  import crypto from "crypto";
1616
+
1617
+ // src/utils/request-context.ts
1618
+ import { AsyncLocalStorage } from "async_hooks";
1619
+ var STORAGE_KEY = Symbol.for("superatom.requestContext.v1");
1620
+ function getStorage() {
1621
+ const g = globalThis;
1622
+ let store = g[STORAGE_KEY];
1623
+ if (!store) {
1624
+ store = new AsyncLocalStorage();
1625
+ g[STORAGE_KEY] = store;
1626
+ }
1627
+ return store;
1628
+ }
1629
+ function enforcementApplies() {
1630
+ return (process.env.SA_SQL_REWRITE_MODE || "").toLowerCase() === "enforce";
1631
+ }
1632
+ function getRequestContext() {
1633
+ return getStorage().getStore();
1634
+ }
1635
+ function requireRequestContext(what = "this operation") {
1636
+ const ctx = getStorage().getStore();
1637
+ if (!ctx) {
1638
+ throw new MissingRequestContextError(
1639
+ `No request context for ${what}. The caller must establish one with runWithContext() or declare itself trusted with runAsSystem().`
1640
+ );
1641
+ }
1642
+ return ctx;
1643
+ }
1644
+ var MissingRequestContextError = class extends Error {
1645
+ constructor(message) {
1646
+ super(message);
1647
+ this.name = "MissingRequestContextError";
1648
+ }
1649
+ };
1650
+ function runWithContext(ctx, fn) {
1651
+ return getStorage().run(ctx, fn);
1652
+ }
1653
+ function runAsUser(user, fn) {
1654
+ const { userId, orgId, role, projectId, requestId, policies } = user;
1655
+ return runWithContext(
1656
+ { principal: { kind: "user", userId, orgId, role }, projectId, requestId, policies },
1657
+ fn
1658
+ );
1659
+ }
1660
+ function runAsSystem(reason, fn, opts) {
1661
+ if (!reason || !reason.trim()) {
1662
+ throw new Error("runAsSystem() requires a non-empty reason \u2014 it identifies the path in the audit log.");
1663
+ }
1664
+ return runWithContext(
1665
+ { principal: { kind: "system", reason: reason.trim() }, projectId: opts?.projectId, requestId: opts?.requestId },
1666
+ fn
1667
+ );
1668
+ }
1669
+ function captureContext() {
1670
+ return getStorage().getStore();
1671
+ }
1672
+ function withCapturedContext(ctx, fn) {
1673
+ return ctx ? runWithContext(ctx, fn) : fn();
1674
+ }
1675
+ function bindContext(fn) {
1676
+ const captured = captureContext();
1677
+ return (...args) => withCapturedContext(captured, () => fn(...args));
1678
+ }
1679
+ function describeContext(ctx = getRequestContext()) {
1680
+ if (!ctx) return "anonymous";
1681
+ return ctx.principal.kind === "user" ? `user:${ctx.principal.userId}` : `system:${ctx.principal.reason}`;
1682
+ }
1683
+ function isUserContext(ctx = getRequestContext()) {
1684
+ return ctx?.principal.kind === "user";
1685
+ }
1686
+
1687
+ // src/utils/query-cache.ts
1688
+ var fingerprints = /* @__PURE__ */ new WeakMap();
1689
+ function canonicalPolicies(policies) {
1690
+ const norm = policies.map((p) => ({
1691
+ s: p.sourceId ?? "*",
1692
+ t: [...p.readableTables ?? []].sort(),
1693
+ f: (p.rowFilters ?? []).map((f) => ({
1694
+ t: f.table ?? "*",
1695
+ c: f.column,
1696
+ o: f.op ?? "in",
1697
+ v: [...f.values ?? []].map((v) => String(v)).sort()
1698
+ })).sort((a, b) => `${a.t}.${a.c}.${a.o}`.localeCompare(`${b.t}.${b.c}.${b.o}`))
1699
+ }));
1700
+ norm.sort((a, b) => a.s.localeCompare(b.s));
1701
+ return JSON.stringify(norm);
1702
+ }
1703
+ function policyFingerprint(policies) {
1704
+ const memo = fingerprints.get(policies);
1705
+ if (memo !== void 0) return memo;
1706
+ const fp = crypto.createHash("sha256").update(canonicalPolicies(policies)).digest("hex").slice(0, 16);
1707
+ fingerprints.set(policies, fp);
1708
+ return fp;
1709
+ }
1710
+ var warnedNoContext = false;
1608
1711
  var QueryCache = class {
1609
1712
  constructor() {
1610
1713
  this.cache = /* @__PURE__ */ new Map();
@@ -1636,6 +1739,31 @@ var QueryCache = class {
1636
1739
  getTTL() {
1637
1740
  return this.ttlMs / 60 / 1e3;
1638
1741
  }
1742
+ /**
1743
+ * Which access bucket this call reads and writes.
1744
+ *
1745
+ * `''` means the unrestricted bucket and produces a key identical to the one
1746
+ * used before authorization existed. `null` means do not touch the cache.
1747
+ */
1748
+ scope() {
1749
+ if (!enforcementApplies()) return "";
1750
+ const ctx = getRequestContext();
1751
+ if (!ctx) {
1752
+ if (!warnedNoContext) {
1753
+ warnedNoContext = true;
1754
+ logger.warn("[QueryCache] bypassed \u2014 no request context under SA_SQL_REWRITE_MODE=enforce");
1755
+ }
1756
+ return null;
1757
+ }
1758
+ const policies = ctx.policies ?? [];
1759
+ return policies.length ? policyFingerprint(policies) : "";
1760
+ }
1761
+ /** The stored key: the scope namespace, then the caller's key. NUL cannot occur in SQL. */
1762
+ scopedKey(query) {
1763
+ const s = this.scope();
1764
+ if (s === null) return null;
1765
+ return s ? `${s}\0${query}` : query;
1766
+ }
1639
1767
  /**
1640
1768
  * Store query result in data cache.
1641
1769
  * If the key already exists, it's removed first so the re-insert places it
@@ -1643,12 +1771,14 @@ var QueryCache = class {
1643
1771
  * a genuinely new key past the size limit.
1644
1772
  */
1645
1773
  set(query, data) {
1646
- this.cache.delete(query);
1774
+ const key = this.scopedKey(query);
1775
+ if (key === null) return;
1776
+ this.cache.delete(key);
1647
1777
  if (this.cache.size >= this.maxCacheSize) {
1648
1778
  const oldestKey = this.cache.keys().next().value;
1649
1779
  if (oldestKey) this.cache.delete(oldestKey);
1650
1780
  }
1651
- this.cache.set(query, {
1781
+ this.cache.set(key, {
1652
1782
  query,
1653
1783
  data,
1654
1784
  timestamp: Date.now()
@@ -1661,14 +1791,16 @@ var QueryCache = class {
1661
1791
  * iteration order — turning FIFO eviction into true LRU.
1662
1792
  */
1663
1793
  get(query) {
1664
- const entry = this.cache.get(query);
1794
+ const key = this.scopedKey(query);
1795
+ if (key === null) return null;
1796
+ const entry = this.cache.get(key);
1665
1797
  if (!entry) return null;
1666
1798
  if (Date.now() - entry.timestamp > this.ttlMs) {
1667
- this.cache.delete(query);
1799
+ this.cache.delete(key);
1668
1800
  return null;
1669
1801
  }
1670
- this.cache.delete(query);
1671
- this.cache.set(query, entry);
1802
+ this.cache.delete(key);
1803
+ this.cache.set(key, entry);
1672
1804
  logger.info(`[QueryCache] Cache HIT for query (${query.substring(0, 50)}...)`);
1673
1805
  return entry.data;
1674
1806
  }
@@ -1682,7 +1814,8 @@ var QueryCache = class {
1682
1814
  * Remove a specific query from cache
1683
1815
  */
1684
1816
  delete(query) {
1685
- this.cache.delete(query);
1817
+ const key = this.scopedKey(query);
1818
+ if (key !== null) this.cache.delete(key);
1686
1819
  }
1687
1820
  /**
1688
1821
  * Clear all cached entries
@@ -2185,6 +2318,96 @@ function buildDirectToolCacheKey(toolId, parameters) {
2185
2318
  return `et-direct:${toolId}:${stableStringify(parameters || {})}`;
2186
2319
  }
2187
2320
 
2321
+ // src/handlers/request-identity.ts
2322
+ function policiesFromConfig(config2) {
2323
+ const entries = Array.isArray(config2) ? config2 : [config2];
2324
+ const out = [];
2325
+ for (const entry of entries) {
2326
+ if (!entry || typeof entry !== "object") continue;
2327
+ const e = entry;
2328
+ const rawFilters = Array.isArray(e.rowFilters) ? e.rowFilters : [];
2329
+ const rowFilters = [];
2330
+ for (const f of rawFilters) {
2331
+ if (!f || typeof f !== "object") continue;
2332
+ const rf = f;
2333
+ if (typeof rf.column !== "string" || !rf.column) continue;
2334
+ if (!Array.isArray(rf.values) || rf.values.length === 0) continue;
2335
+ const op = typeof rf.op === "string" ? rf.op.toLowerCase() : "in";
2336
+ if (op !== "in" && op !== "not_in" && op !== "eq" && op !== "ne") continue;
2337
+ rowFilters.push({
2338
+ table: typeof rf.table === "string" && rf.table ? rf.table : void 0,
2339
+ column: rf.column,
2340
+ op,
2341
+ values: rf.values,
2342
+ label: typeof rf.label === "string" && rf.label.trim() ? rf.label.trim() : void 0
2343
+ });
2344
+ }
2345
+ const readableTables = Array.isArray(e.readableTables) ? e.readableTables.filter((t) => typeof t === "string" && !!t) : [];
2346
+ if (rowFilters.length === 0 && readableTables.length === 0) continue;
2347
+ const label = typeof e.label === "string" && e.label.trim() ? e.label.trim() : void 0;
2348
+ if (!label && rowFilters.some((f) => !f.label) && !warnedUnlabelled) {
2349
+ warnedUnlabelled = true;
2350
+ logger.warn(
2351
+ '[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".'
2352
+ );
2353
+ }
2354
+ out.push({
2355
+ sourceId: typeof e.sourceId === "string" && e.sourceId ? e.sourceId : null,
2356
+ label,
2357
+ rowFilters,
2358
+ readableTables
2359
+ });
2360
+ }
2361
+ return out;
2362
+ }
2363
+ var warnedUntrusted = false;
2364
+ var warnedAnonymous = false;
2365
+ var warnedUnlabelled = false;
2366
+ var announcedVerified = false;
2367
+ var reportedIdMismatches = /* @__PURE__ */ new Set();
2368
+ function runWithMessageIdentity(message, fn) {
2369
+ const verified = message?.authContext;
2370
+ const requestId = message?.id;
2371
+ const policies = policiesFromConfig(message?.payload?.config);
2372
+ if (verified?.userId) {
2373
+ const claimedId = message?.payload?.userId;
2374
+ if (!announcedVerified) {
2375
+ announcedVerified = true;
2376
+ logger.info(
2377
+ `[RequestIdentity] Using the relay-verified authContext as the principal (userId ${verified.userId})${claimedId ? `; client claimed ${claimedId === verified.userId ? "the same id" : claimedId}` : ""}.`
2378
+ );
2379
+ }
2380
+ if (claimedId && claimedId !== verified.userId) {
2381
+ const pair = `${verified.userId}<-${claimedId}`;
2382
+ if (!reportedIdMismatches.has(pair) && reportedIdMismatches.size < 100) {
2383
+ reportedIdMismatches.add(pair);
2384
+ logger.warn(
2385
+ `[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.`
2386
+ );
2387
+ }
2388
+ }
2389
+ return runAsUser(
2390
+ { userId: verified.userId, orgId: verified.orgId, role: verified.role, requestId, policies },
2391
+ fn
2392
+ );
2393
+ }
2394
+ const claimed = message?.payload?.userId;
2395
+ if (typeof claimed === "string" && claimed) {
2396
+ if (!warnedUntrusted) {
2397
+ warnedUntrusted = true;
2398
+ logger.warn(
2399
+ "[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."
2400
+ );
2401
+ }
2402
+ return runAsUser({ userId: claimed, requestId, policies }, fn);
2403
+ }
2404
+ if (!warnedAnonymous) {
2405
+ warnedAnonymous = true;
2406
+ logger.info("[RequestIdentity] No user identity on this message \u2014 running without a request context.");
2407
+ }
2408
+ return fn();
2409
+ }
2410
+
2188
2411
  // src/handlers/data-request.ts
2189
2412
  function getQueryCacheKey(query) {
2190
2413
  if (typeof query === "string") {
@@ -2224,6 +2447,9 @@ function getCacheKey(collection, op, params, tools) {
2224
2447
  return "";
2225
2448
  }
2226
2449
  async function handleDataRequest(data, collections, sendMessage, tools) {
2450
+ return runWithMessageIdentity(data, () => handleDataRequestInner(data, collections, sendMessage, tools));
2451
+ }
2452
+ async function handleDataRequestInner(data, collections, sendMessage, tools) {
2227
2453
  let requestId;
2228
2454
  let collection;
2229
2455
  let op;
@@ -7081,7 +7307,6 @@ Rules: state ONLY filters you actually applied (do not claim a filter you didn't
7081
7307
  ### Knowledge Base Context
7082
7308
  {{KNOWLEDGE_BASE_CONTEXT}}
7083
7309
 
7084
- ### User Access Scope
7085
7310
  {{USER_ACCESS_CONTEXT}}
7086
7311
 
7087
7312
  ### Conversation History
@@ -7111,7 +7336,6 @@ Your job is to fetch the requested data using the available tool.
7111
7336
 
7112
7337
  ### Critical Rules
7113
7338
  - **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.
7114
- - **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.
7115
7339
  - **CRITICAL: Follow ALL the database-specific SQL rules above.** These prevent syntax errors specific to this database engine. Violating them will cause query failures.
7116
7340
  - **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.
7117
7341
 
@@ -7155,7 +7379,6 @@ Your job is to fetch the requested data using the available tool.
7155
7379
  ### Knowledge Base Context
7156
7380
  {{KNOWLEDGE_BASE_CONTEXT}}
7157
7381
 
7158
- ### User Access Scope
7159
7382
  {{USER_ACCESS_CONTEXT}}`,
7160
7383
  user: `Current Date and Time: {{CURRENT_DATETIME}}
7161
7384
 
@@ -9136,9 +9359,9 @@ var PromptLoader = class {
9136
9359
  const rulesPath = path4.join(this.promptsDir, "database-rules", `${this.databaseType}.md`);
9137
9360
  try {
9138
9361
  if (fs5.existsSync(rulesPath)) {
9139
- const rules = fs5.readFileSync(rulesPath, "utf-8");
9140
- this.databaseRulesCache.set(this.databaseType, rules);
9141
- return rules;
9362
+ const rules2 = fs5.readFileSync(rulesPath, "utf-8");
9363
+ this.databaseRulesCache.set(this.databaseType, rules2);
9364
+ return rules2;
9142
9365
  }
9143
9366
  } catch (error) {
9144
9367
  logger.warn(`Could not load database rules for '${this.databaseType}' from file system: ${error}`);
@@ -9179,9 +9402,9 @@ var PromptLoader = class {
9179
9402
  const rulesPath = path4.join(this.promptsDir, "database-rules", `${dbType}.md`);
9180
9403
  try {
9181
9404
  if (fs5.existsSync(rulesPath)) {
9182
- const rules = fs5.readFileSync(rulesPath, "utf-8");
9183
- this.databaseRulesCache.set(dbType, rules);
9184
- return rules;
9405
+ const rules2 = fs5.readFileSync(rulesPath, "utf-8");
9406
+ this.databaseRulesCache.set(dbType, rules2);
9407
+ return rules2;
9185
9408
  }
9186
9409
  } catch (error) {
9187
9410
  logger.warn(`Could not load database rules for '${dbType}' from file system: ${error}`);
@@ -9879,6 +10102,57 @@ function validateAndFixSqlQuery(query, dbType) {
9879
10102
  };
9880
10103
  }
9881
10104
 
10105
+ // src/userResponse/agents/access-section.ts
10106
+ var HEADING = "### User Access Scope";
10107
+ var enforced = enforcementApplies;
10108
+ 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.`;
10109
+ function mainRule() {
10110
+ 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;
10111
+ }
10112
+ function sourceRule() {
10113
+ 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;
10114
+ }
10115
+ function editRule() {
10116
+ 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.';
10117
+ }
10118
+ var rules = () => ({
10119
+ main: mainRule(),
10120
+ source: sourceRule(),
10121
+ edit: editRule()
10122
+ });
10123
+ function buildAccessSection(renderedConfig, variant) {
10124
+ const body = (renderedConfig || "").trim();
10125
+ if (!body) return "";
10126
+ return `${HEADING}
10127
+ ${rules()[variant]}
10128
+ ${body}`;
10129
+ }
10130
+ function buildAccessBodyContract(renderedConfig) {
10131
+ if (!(renderedConfig || "").trim()) return "";
10132
+ return `
10133
+
10134
+ ### Access policy \u2014 never write it into the script
10135
+
10136
+ A query result may report that an authorization policy restricted it. That restriction is enforced
10137
+ automatically on every run, including replays. So:
10138
+
10139
+ - **Never put a policy predicate in your SQL** (e.g. a branch/tenant/region id you were told was
10140
+ applied). It is redundant, and it goes stale the moment that user's access changes \u2014 the recipe
10141
+ would keep filtering on the old scope while the live policy applies the new one, giving zero rows.
10142
+ - **Never hardcode the scope in text.** \`getAnalysis\` and \`getComponents\` receive the restrictions
10143
+ that actually applied to THIS run as their 6th argument, \`scope: string[]\`. Use it:
10144
+
10145
+ \`\`\`js
10146
+ export function getAnalysis(data, params, fmt, datasets, entities, scope) {
10147
+ const limitedTo = scope.length ? \` (\${scope.join('; ')})\` : '';
10148
+ return { analysis: \`**\${fmt.num(data[0].Total)} records**\${limitedTo}.\` };
10149
+ }
10150
+ \`\`\`
10151
+
10152
+ \`scope\` is empty when no policy applied, so the same body is correct for restricted and
10153
+ unrestricted users alike.`;
10154
+ }
10155
+
9882
10156
  // src/userResponse/agents/source-agent.ts
9883
10157
  var SourceAgent = class {
9884
10158
  constructor(tool, config2, streamBuffer, options) {
@@ -10065,6 +10339,21 @@ Analyze the error and try again with a corrected query.`;
10065
10339
  name: this.tool.name,
10066
10340
  params: cappedInput,
10067
10341
  result: {
10342
+ // The query was restricted before it ran. The agent cannot see
10343
+ // this from the SQL it wrote — it wrote no such restriction — so
10344
+ // without being told it labels the answer as if unrestricted.
10345
+ // Confirmed live: a branch-scoped count reported as covering
10346
+ // every branch, plus a wasted turn trying to remove a
10347
+ // restriction that is re-applied to every query.
10348
+ //
10349
+ // The WORDING is a fixed, generic frame; the restrictions
10350
+ // themselves are whatever the deployment's policy produced.
10351
+ // Every client shares this code, so nothing here may assume a
10352
+ // policy TYPE (row filter, masked column, restricted table) or
10353
+ // any client's vocabulary.
10354
+ ...result.metadata?.appliedPolicies?.length ? {
10355
+ _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.`
10356
+ } : {},
10068
10357
  ...hitFetchCap ? {
10069
10358
  _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.`
10070
10359
  } : {},
@@ -10090,7 +10379,9 @@ Analyze the error and try again with a corrected query.`;
10090
10379
  const formatted = typeof formattedResult === "string" ? formattedResult : JSON.stringify(formattedResult);
10091
10380
  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.";
10092
10381
  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.` : "";
10093
- return `\u2705 Query executed successfully. ${resultData.length} rows returned.${capWarning} ${followUpNote}
10382
+ const emptyUnderPolicy = resultData.length === 0;
10383
+ 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.` : "";
10384
+ return `\u2705 Query executed successfully. ${resultData.length} rows returned.${capWarning}${policyNote} ${followUpNote}
10094
10385
 
10095
10386
  ${formatted}`;
10096
10387
  } catch (execError) {
@@ -10309,7 +10600,7 @@ Even if a table appears in the detailed schema above, search_schema returns samp
10309
10600
  logger.warn(`[SourceAgent:${this.tool.name}] Trace capture failed for schemaSearchInstructionsText (non-blocking): ${traceErr}`);
10310
10601
  }
10311
10602
  const displaySourceType = sourceType === "excel" || sourceType === "csv" ? `${sourceType} \u2014 uses DuckDB SQL engine` : sourceType;
10312
- const userAccessContextText = this.config.userAccessContext || "No user-specific access restrictions.";
10603
+ const userAccessContextText = buildAccessSection(this.config.userAccessContext ?? "", "source");
10313
10604
  try {
10314
10605
  if (this.traceSchemaEntry) this.traceSchemaEntry.userAccessContextText = userAccessContextText;
10315
10606
  } catch (traceErr) {
@@ -10498,6 +10789,55 @@ var LineSplitter = class {
10498
10789
  }
10499
10790
  };
10500
10791
 
10792
+ // src/utils/context-token.ts
10793
+ import { randomBytes } from "crypto";
10794
+ var DEFAULT_TTL_MS = 30 * 6e4;
10795
+ var KEY = Symbol.for("superatom.contextTokens.v1");
10796
+ function registry() {
10797
+ const g = globalThis;
10798
+ let m = g[KEY];
10799
+ if (!m) {
10800
+ m = /* @__PURE__ */ new Map();
10801
+ g[KEY] = m;
10802
+ }
10803
+ return m;
10804
+ }
10805
+ function sweep(now = Date.now()) {
10806
+ const reg = registry();
10807
+ for (const [token, entry] of reg) {
10808
+ if (entry.expiresAt <= now) reg.delete(token);
10809
+ }
10810
+ }
10811
+ function mintContextToken(ttlMs = DEFAULT_TTL_MS) {
10812
+ const ctx = captureContext();
10813
+ if (!ctx) return void 0;
10814
+ sweep();
10815
+ const token = randomBytes(24).toString("base64url");
10816
+ registry().set(token, { ctx, expiresAt: Date.now() + ttlMs });
10817
+ return token;
10818
+ }
10819
+ function resolveContextToken(token) {
10820
+ if (!token) return void 0;
10821
+ const entry = registry().get(token);
10822
+ if (!entry) return void 0;
10823
+ if (entry.expiresAt <= Date.now()) {
10824
+ registry().delete(token);
10825
+ logger.warn("[ContextToken] Rejected an expired token \u2014 the script outlived its context grant.");
10826
+ return void 0;
10827
+ }
10828
+ return entry.ctx;
10829
+ }
10830
+ function revokeContextToken(token) {
10831
+ if (token) registry().delete(token);
10832
+ }
10833
+ function contextTokenCount() {
10834
+ sweep();
10835
+ return registry().size;
10836
+ }
10837
+ function clearContextTokens() {
10838
+ registry().clear();
10839
+ }
10840
+
10501
10841
  // src/userResponse/scripts/script-runner.ts
10502
10842
  var fmtConfig;
10503
10843
  function setFmtConfig(cfg) {
@@ -10623,7 +10963,8 @@ FIX: pass every required parameter to execute_script, or give it a default in wr
10623
10963
  // analysisData: getData produces it
10624
10964
  void 0,
10625
10965
  // analysisDatasets: ctx.emit produces them
10626
- options.entities
10966
+ options.entities,
10967
+ options.appliedPolicies
10627
10968
  );
10628
10969
  const totalMs = Date.now() - startedAt;
10629
10970
  if (result.kind === "ok") {
@@ -10671,10 +11012,11 @@ FIX: pass every required parameter to execute_script, or give it a default in wr
10671
11012
  scriptSemaphore.release();
10672
11013
  }
10673
11014
  }
10674
- function executeInSubprocess(scriptPath, params, paramSchema, externalTools, streamBuffer, timeoutMs, signal, mode, analysisData, analysisDatasets, entities) {
11015
+ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, streamBuffer, timeoutMs, signal, mode, analysisData, analysisDatasets, entities, appliedPolicies) {
10675
11016
  return new Promise((resolve2) => {
10676
11017
  const tsxBin = resolveTsxBinary();
10677
11018
  const bootstrap = resolveBootstrapPath();
11019
+ const contextToken = mintContextToken();
10678
11020
  const child = spawn(
10679
11021
  process.execPath,
10680
11022
  // node — tsx runs via its own cli, we invoke it with node
@@ -10693,7 +11035,12 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10693
11035
  env: {
10694
11036
  ...process.env,
10695
11037
  // Keep the child quiet about dotenv etc.
10696
- NODE_NO_WARNINGS: "1"
11038
+ NODE_NO_WARNINGS: "1",
11039
+ // Identity for the bridge calls the script will make. The async
11040
+ // context cannot cross a process boundary, so it travels as a
11041
+ // token instead. Absent when the run has no context — the bridge
11042
+ // then treats the queries as anonymous, which is the truth.
11043
+ ...contextToken ? { SA_CONTEXT_TOKEN: contextToken } : {}
10697
11044
  }
10698
11045
  }
10699
11046
  );
@@ -10701,6 +11048,7 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10701
11048
  let executedQueries = [];
10702
11049
  let stderrBuffer = "";
10703
11050
  const cleanup = () => {
11051
+ revokeContextToken(contextToken);
10704
11052
  if (child.killed) return;
10705
11053
  const pid = child.pid;
10706
11054
  if (pid && process.platform !== "win32") {
@@ -10805,7 +11153,8 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
10805
11153
  ...analysisData ? { data: analysisData } : {},
10806
11154
  ...analysisDatasets && Object.keys(analysisDatasets).length ? { datasets: analysisDatasets } : {},
10807
11155
  ...fmtConfig ? { fmt: fmtConfig } : {},
10808
- ...entities && entities.length ? { entities } : {}
11156
+ ...entities && entities.length ? { entities } : {},
11157
+ ...appliedPolicies && appliedPolicies.length ? { appliedPolicies } : {}
10809
11158
  });
10810
11159
  child.stdin.write(init);
10811
11160
  function handleChildMessage(msg) {
@@ -10890,7 +11239,12 @@ function enrichQueries(queries, externalTools) {
10890
11239
  count: q.count,
10891
11240
  totalCount: q.totalCount,
10892
11241
  executionTimeMs: q.executionTimeMs,
10893
- virtual: q.virtual
11242
+ virtual: q.virtual,
11243
+ // Carried through explicitly: this rebuilds each query field by field,
11244
+ // so anything not listed is silently dropped. Losing it here left
11245
+ // getAnalysis's `scope` empty while the query WAS restricted — the
11246
+ // analysis then described a branch-scoped count as system-wide.
11247
+ appliedPolicies: q.appliedPolicies
10894
11248
  };
10895
11249
  });
10896
11250
  }
@@ -10982,7 +11336,8 @@ async function runComponentsOnly(recipe, scriptPath, params, data, options, data
10982
11336
  "components",
10983
11337
  data,
10984
11338
  datasets,
10985
- options.entities
11339
+ options.entities,
11340
+ options.appliedPolicies
10986
11341
  );
10987
11342
  if (result.kind === "ok") {
10988
11343
  return { components: result.components, componentsError: result.componentsError };
@@ -11015,7 +11370,8 @@ async function runAnalysisOnly(recipe, scriptPath, params, data, options, datase
11015
11370
  "analysis",
11016
11371
  data,
11017
11372
  datasets,
11018
- options.entities
11373
+ options.entities,
11374
+ options.appliedPolicies
11019
11375
  );
11020
11376
  if (result.kind === "ok") {
11021
11377
  return { analysis: result.analysis, analysisError: result.analysisError };
@@ -11212,7 +11568,7 @@ function resolveScriptRecipeStore(collections) {
11212
11568
  // src/userResponse/scripts/script-store.ts
11213
11569
  import * as fs6 from "fs";
11214
11570
  import * as path6 from "path";
11215
- import { createHash, randomBytes } from "crypto";
11571
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
11216
11572
  var DEFAULT_STORE_DIR = "scripts-store";
11217
11573
  function stripAnalysisExport(body) {
11218
11574
  return stripNamedExport(body, "getAnalysis");
@@ -11424,7 +11780,7 @@ var ScriptStore = class {
11424
11780
  const now = (/* @__PURE__ */ new Date()).toISOString();
11425
11781
  const existing = input.recipeId ? await this.store.getById(input.recipeId) : null;
11426
11782
  const reuse = existing && existing.status === "draft" && existing.turnId === input.turnId;
11427
- const id = reuse ? existing.id : `script_${Date.now()}_${randomBytes(3).toString("hex")}`;
11783
+ const id = reuse ? existing.id : `script_${Date.now()}_${randomBytes2(3).toString("hex")}`;
11428
11784
  const fileBase = reuse ? existing.fileBase : input.parentId ? `${this.toSlug(input.name)}-${id.slice(-6)}` : await this.computeFileBase(input.name, id);
11429
11785
  let normalized = normalizeScriptBody(input.scriptBody);
11430
11786
  if (reuse) {
@@ -11795,6 +12151,32 @@ ${carried.trim()}
11795
12151
  * specs, a stale spec array beside a live program is how the wrong one gets
11796
12152
  * read later. See backend/docs/COMPONENTS-AS-PROGRAM-DESIGN.md § 4.4.
11797
12153
  */
12154
+ /**
12155
+ * Body text as it stands now, for reverting an attach that turns out to be
12156
+ * invalid.
12157
+ *
12158
+ * getComponents can only be VALIDATED by running it, and it can only be run
12159
+ * once it is on disk — so attachComponents necessarily writes before anyone
12160
+ * knows whether the body is good. Without a revert, a rejected body stays
12161
+ * attached: promote() then sees the recipe "exports getComponents", clears
12162
+ * the specs the fallback generator produced, and the recipe is verified with
12163
+ * component code that was already rejected. Confirmed live on
12164
+ * "Total Bookings by Branch", which shipped a sourceRef of 'computed:_final'
12165
+ * that validateSpecs had refused.
12166
+ */
12167
+ async snapshotBody(recipeId) {
12168
+ const recipe = await this.get(recipeId);
12169
+ if (!recipe?.fileBase) return null;
12170
+ return this.readBody(recipe.fileBase) || null;
12171
+ }
12172
+ /** Put back a body captured by {@link snapshotBody}. */
12173
+ async restoreBody(recipeId, body) {
12174
+ const recipe = await this.get(recipeId);
12175
+ if (!recipe?.fileBase) return false;
12176
+ this.writeBody(recipe.fileBase, body);
12177
+ logger.info(`[ScriptStore] Reverted body of "${recipe.name}" (${recipeId}) after a rejected attach`);
12178
+ return true;
12179
+ }
11798
12180
  async attachComponents(recipeId, componentsBody) {
11799
12181
  const recipe = await this.get(recipeId);
11800
12182
  if (!recipe) {
@@ -11873,7 +12255,7 @@ ${analysisBody.trim()}
11873
12255
  writeArchive(fileBase, version, body) {
11874
12256
  if (!fs6.existsSync(this.archiveDir)) fs6.mkdirSync(this.archiveDir, { recursive: true });
11875
12257
  const finalPath = path6.join(this.archiveDir, `${fileBase}.v${version}.ts`);
11876
- const tmpPath = `${finalPath}.tmp-${randomBytes(4).toString("hex")}`;
12258
+ const tmpPath = `${finalPath}.tmp-${randomBytes2(4).toString("hex")}`;
11877
12259
  fs6.writeFileSync(tmpPath, body);
11878
12260
  fs6.renameSync(tmpPath, finalPath);
11879
12261
  }
@@ -11900,7 +12282,7 @@ ${analysisBody.trim()}
11900
12282
  writeBody(fileBase, normalizedBody) {
11901
12283
  if (!fs6.existsSync(this.storeDir)) fs6.mkdirSync(this.storeDir, { recursive: true });
11902
12284
  const finalPath = this.bodyPath(fileBase);
11903
- const tmpPath = `${finalPath}.tmp-${randomBytes(4).toString("hex")}`;
12285
+ const tmpPath = `${finalPath}.tmp-${randomBytes2(4).toString("hex")}`;
11904
12286
  fs6.writeFileSync(tmpPath, normalizedBody);
11905
12287
  fs6.renameSync(tmpPath, finalPath);
11906
12288
  }
@@ -12725,7 +13107,7 @@ ${catalog}`,
12725
13107
  return "write_components requires data: run write_script then execute_script successfully first.";
12726
13108
  }
12727
13109
  if (this.renderComponentAttempts >= MAX_RENDER_COMPONENT_ATTEMPTS) {
12728
- 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.`;
13110
+ 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.`;
12729
13111
  }
12730
13112
  this.renderComponentAttempts++;
12731
13113
  const hardcodedAggregates = findHardcodedAggregates(body);
@@ -12753,6 +13135,10 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12753
13135
  const recipeId = this.scriptState.recipeId;
12754
13136
  const draft = recipeId ? await this.scriptStore.get(recipeId) : null;
12755
13137
  if (!recipeId || !draft) return "Draft was lost from the store. Call write_script again.";
13138
+ const snapshot = await this.scriptStore.snapshotBody(recipeId);
13139
+ const revert = async () => {
13140
+ if (snapshot) await this.scriptStore.restoreBody(recipeId, snapshot);
13141
+ };
12756
13142
  const attached = await this.scriptStore.attachComponents(recipeId, body);
12757
13143
  if (!attached) return "Could not attach getComponents to the draft. Call render-free: write your answer and the system will build the dashboard.";
12758
13144
  const datasets = {};
@@ -12766,12 +13152,13 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12766
13152
  this.scriptStore.getScriptPath(draft),
12767
13153
  {},
12768
13154
  result.data,
12769
- { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities },
13155
+ { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities, appliedPolicies: this.observedPolicies() },
12770
13156
  datasets
12771
13157
  );
12772
13158
  if (componentsError || !components?.length) {
12773
13159
  const why = componentsError || "it returned no components";
12774
13160
  logger.warn(`[MainAgent] write_components failed: ${why}`);
13161
+ await revert();
12775
13162
  return `getComponents failed: ${why}
12776
13163
 
12777
13164
  Fix the body and call write_components again. It receives (data, params, fmt, datasets) and must RETURN an array of component specs.`;
@@ -12780,6 +13167,14 @@ Fix the body and call write_components again. It receives (data, params, fmt, da
12780
13167
  if (findings.length > 0) {
12781
13168
  logger.warn(`[MainAgent] write_components rejected (attempt ${this.renderComponentAttempts}): ${findings.length} finding(s)`);
12782
13169
  findings.forEach((f) => logger.warn(`[MainAgent] ${f.code} ${f.componentType}: ${f.message}`));
13170
+ await revert();
13171
+ if (this.renderComponentAttempts >= MAX_RENDER_COMPONENT_ATTEMPTS && accepted.length > 0) {
13172
+ this.componentSpecs = accepted;
13173
+ this.componentLayout = { title: input?.layoutTitle || "Dashboard", description: input?.layoutDescription };
13174
+ const dropped = findings.map((f) => `${f.componentType} (${f.code})`).join(", ");
13175
+ logger.warn(`[MainAgent] write_components out of attempts \u2014 keeping ${accepted.length} valid spec(s), dropping ${findings.length}: ${dropped}`);
13176
+ return `Out of write_components attempts. Kept ${accepted.length} valid component(s); dropped ${findings.length} (${dropped}). Stop calling write_components and write your answer.`;
13177
+ }
12783
13178
  return formatFindingsForAgent(findings);
12784
13179
  }
12785
13180
  this.componentSpecs = accepted;
@@ -12795,6 +13190,20 @@ Now write your answer for the user. Do not restate the component list.`;
12795
13190
  // ============================================
12796
13191
  // Script-authoring tool handlers
12797
13192
  // ============================================
13193
+ /**
13194
+ * Restrictions the seam reported while this run's queries executed.
13195
+ *
13196
+ * getData does NOT re-run for write_components/write_analysis, so the child
13197
+ * cannot observe these itself — the parent hands them across, exactly as it
13198
+ * does for `entities`.
13199
+ */
13200
+ observedPolicies() {
13201
+ const seen = /* @__PURE__ */ new Set();
13202
+ for (const q of this.scriptState.lastSuccessfulResult?.executedQueries ?? []) {
13203
+ for (const p of q?.appliedPolicies ?? []) seen.add(p);
13204
+ }
13205
+ return [...seen];
13206
+ }
12798
13207
  /**
12799
13208
  * Author the answer as a program. Runs after execute_script, so the body is
12800
13209
  * written with the result already known and can react to what it found.
@@ -12828,14 +13237,16 @@ Now write your answer for the user. Do not restate the component list.`;
12828
13237
  const lintError = formatLintError(violations);
12829
13238
  if (lintError) {
12830
13239
  const named = violations.map((v) => `"${v.text}"`).join(", ");
13240
+ const budget = `
13241
+ 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.`;
12831
13242
  if (this.scriptState.analysisAttempts < MAX_ANALYSIS_ATTEMPTS) {
12832
13243
  logger.warn(`[MainAgent] write_analysis rejected \u2014 ${violations.length} hardcoded value(s): ${named}`);
12833
13244
  if (repeatedTexts.length > 0) {
12834
13245
  const repeatedList = repeatedTexts.map((t) => `"${t}"`).join(", ");
12835
13246
  return `${lintError}
12836
- 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.`;
13247
+ 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;
12837
13248
  }
12838
- return lintError;
13249
+ return lintError + budget;
12839
13250
  }
12840
13251
  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.`);
12841
13252
  }
@@ -12855,7 +13266,7 @@ STILL UNCHANGED FROM THE LAST ATTEMPT: ${repeatedList}. Restating the same reaso
12855
13266
  this.scriptStore.getScriptPath(draft),
12856
13267
  {},
12857
13268
  verified.data,
12858
- { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities },
13269
+ { externalTools: this.externalTools, signal: this.abortSignal, entities: this.resolvedEntities, appliedPolicies: this.observedPolicies() },
12859
13270
  datasets
12860
13271
  );
12861
13272
  if (analysisError || !analysis?.analysis) {
@@ -13156,11 +13567,26 @@ The verified script is intact. You can still call write_analysis and write_compo
13156
13567
  */
13157
13568
  buildResolvedEntitiesBlock() {
13158
13569
  if (!this.resolvedEntities.length) return "";
13159
- const lines = this.resolvedEntities.map((e) => {
13570
+ const render = (e) => {
13160
13571
  const scopeSql = e.attrs?.scopeSql;
13161
13572
  const scopePart = typeof scopeSql === "string" && scopeSql.trim() ? ` \u2014 READY-MADE SCOPE (use this directly, do not re-derive it): ${scopeSql}` : "";
13162
13573
  return `- "${e.mention}" \u2192 ${e.entityType} id ${e.instanceId} (name in DB: ${e.displayName})${scopePart}`;
13163
- });
13574
+ };
13575
+ const byMention = /* @__PURE__ */ new Map();
13576
+ for (const e of this.resolvedEntities) {
13577
+ const group = byMention.get(e.mention) ?? [];
13578
+ group.push(e);
13579
+ byMention.set(e.mention, group);
13580
+ }
13581
+ const lines = [];
13582
+ for (const [mention, group] of byMention) {
13583
+ if (group.length === 1) {
13584
+ lines.push(render(group[0]));
13585
+ continue;
13586
+ }
13587
+ 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:`);
13588
+ for (const e of group) lines.push(` ${render(e).slice(2)}`);
13589
+ }
13164
13590
  return `
13165
13591
 
13166
13592
  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:
@@ -13480,9 +13906,25 @@ ${r.body}`).join("\n\n");
13480
13906
  logger.info(
13481
13907
  `[MainAgent] resolve_entities: ${res?.resolved?.length ?? 0} resolved, ${res?.unresolved?.length ?? 0} unresolved, ${Object.keys(res?.entityMap || {}).length} concept(s)`
13482
13908
  );
13909
+ const TIE_EPSILON = 0.05;
13910
+ const key = (e) => `${e.entityType}::${e.instanceId}`;
13911
+ const add = (mention, c) => {
13912
+ if (!c?.instanceId) return;
13913
+ if (this.resolvedEntities.some((e) => key(e) === key(c))) return;
13914
+ this.resolvedEntities.push({
13915
+ mention: String(mention ?? ""),
13916
+ entityType: String(c.entityType ?? ""),
13917
+ instanceId: String(c.instanceId),
13918
+ displayName: String(c.displayName ?? mention ?? ""),
13919
+ attrs: c.attrs && typeof c.attrs === "object" ? c.attrs : void 0
13920
+ });
13921
+ };
13483
13922
  for (const r of Array.isArray(res?.resolved) ? res.resolved : []) {
13484
13923
  if (!r?.instanceId) continue;
13485
- const key = (e) => `${e.entityType}::${e.instanceId}`;
13924
+ for (const a of Array.isArray(r.alternatives) ? r.alternatives : []) {
13925
+ const tied = typeof r.score !== "number" || typeof a?.score !== "number" ? false : r.score - a.score <= TIE_EPSILON;
13926
+ if (tied) add(r.mention, a);
13927
+ }
13486
13928
  if (this.resolvedEntities.some((e) => key(e) === key(r))) continue;
13487
13929
  this.resolvedEntities.push({
13488
13930
  mention: String(r.mention ?? ""),
@@ -13741,7 +14183,7 @@ ${propLines}` : ""
13741
14183
  const scriptBodyContract = await promptLoader.loadScriptBodyContract({
13742
14184
  MAX_ROWS_RENDERED: String(MAX_ROWS_RENDERED),
13743
14185
  MAX_ROWS_FETCHED: String(MAX_ROWS_FETCHED)
13744
- });
14186
+ }) + buildAccessBodyContract(this.config.userAccessContext ?? "");
13745
14187
  try {
13746
14188
  if (this.config.trace) this.config.trace.scriptBodyContractText = scriptBodyContract;
13747
14189
  } catch (traceErr) {
@@ -13759,7 +14201,9 @@ ${propLines}` : ""
13759
14201
  GLOBAL_KNOWLEDGE_BASE: this.config.globalKnowledgeBase || "No global knowledge base available.",
13760
14202
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
13761
14203
  KNOWLEDGE_BASE_CONTEXT: this.config.knowledgeBaseContext || "No additional knowledge base context available.",
13762
- USER_ACCESS_CONTEXT: this.config.userAccessContext || "No user-specific access restrictions.",
14204
+ // '' when the user has no policy — the whole section disappears rather
14205
+ // than rendering a heading over "no restrictions". See access-section.ts.
14206
+ USER_ACCESS_CONTEXT: buildAccessSection(this.config.userAccessContext ?? "", this.editMode ? "edit" : "main"),
13763
14207
  ENTITY_CATALOG: entityCatalogText || "No entity catalog available \u2014 resolve_entities may still be called with mentions.",
13764
14208
  CONCEPT_CATALOG: conceptCatalogText || "No concept catalog available \u2014 derive definitions from the schema and say that you did.",
13765
14209
  // Bodies, not names: an invariant is injected precisely because it
@@ -13964,8 +14408,20 @@ Execution time: ${metadata.executionTimeMs}ms
13964
14408
  rows: t?.result?._mainAgentRows ?? t?.result?._sampleData ?? [],
13965
14409
  summary: t?.result?._summary,
13966
14410
  total: t?.result?._totalRecords,
13967
- shown: t?.result?._recordsShown
14411
+ shown: t?.result?._recordsShown,
14412
+ policy: t?.result?._ACCESS_POLICY_APPLIED
13968
14413
  })).filter((q) => Boolean(q.sql));
14414
+ const policyNotes = [...new Set(queries.map((q) => q.policy).filter(Boolean))];
14415
+ if (policyNotes.length) {
14416
+ output += `
14417
+ \u{1F512} ACCESS POLICY \u2014 applies to EVERY figure below:
14418
+ ${policyNotes.map((n) => `- ${n}`).join("\n")}
14419
+ 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.
14420
+ 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".
14421
+ 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.
14422
+
14423
+ `;
14424
+ }
13969
14425
  if (queries.length === 0) {
13970
14426
  output += "No data returned.";
13971
14427
  return output;
@@ -17404,16 +17860,37 @@ function storeScriptDatasetQuery(q, ctx) {
17404
17860
  return queryCache.storeQuery(q.sql, { data, count });
17405
17861
  }
17406
17862
  function buildUserAccessContext(userConfig) {
17407
- if (!userConfig || typeof userConfig !== "object") return "";
17408
- const { prompt, ...rest } = userConfig;
17409
- const parts = [];
17410
- if (Object.keys(rest).length > 0) {
17411
- parts.push(JSON.stringify(rest));
17412
- }
17413
- if (typeof prompt === "string" && prompt.trim()) {
17414
- parts.push(prompt.trim());
17863
+ const entries = Array.isArray(userConfig) ? userConfig : [userConfig];
17864
+ const restrictions = [];
17865
+ const prompts = [];
17866
+ let anyRestriction = false;
17867
+ for (const e of entries) {
17868
+ if (!e || typeof e !== "object") continue;
17869
+ const rowFilters = Array.isArray(e.rowFilters) ? e.rowFilters : [];
17870
+ const readableTables = Array.isArray(e.readableTables) ? e.readableTables.filter((t) => typeof t === "string" && !!t) : [];
17871
+ const realFilters = rowFilters.filter(
17872
+ (f) => f && typeof f === "object" && f.column && Array.isArray(f.values) && f.values.length > 0
17873
+ );
17874
+ if (realFilters.length || readableTables.length) {
17875
+ anyRestriction = true;
17876
+ if (enforcementApplies()) {
17877
+ const clean = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : "";
17878
+ const labels = [
17879
+ .../* @__PURE__ */ new Set([
17880
+ ...realFilters.map((f) => clean(f.label)).filter(Boolean),
17881
+ ...clean(e.label) ? [clean(e.label)] : []
17882
+ ])
17883
+ ];
17884
+ restrictions.push(labels.length ? labels.join("; ") : "This user's data access is restricted.");
17885
+ } else {
17886
+ const { prompt: _p, ...rest } = e;
17887
+ restrictions.push(JSON.stringify(rest));
17888
+ }
17889
+ }
17890
+ if (typeof e.prompt === "string" && e.prompt.trim()) prompts.push(e.prompt.trim());
17415
17891
  }
17416
- return parts.join("\n\n");
17892
+ if (!anyRestriction && prompts.length === 0) return "";
17893
+ return [...restrictions, ...prompts].join("\n\n");
17417
17894
  }
17418
17895
  var get_agent_user_response = async (prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, conversationHistory, streamCallback, collections, externalTools, userId, mainAgentModel, sourceAgentModel, workflows, signal, activeBindings, userConfig) => {
17419
17896
  const startTime = Date.now();
@@ -19176,7 +19653,7 @@ var get_user_request = async (data, components, sendMessage, anthropicApiKey, gr
19176
19653
  };
19177
19654
  };
19178
19655
  async function handleUserPromptRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows) {
19179
- const response = await get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows);
19656
+ const response = await runWithMessageIdentity(data, () => get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows));
19180
19657
  if (response.data?.component?.props?.config?.components) {
19181
19658
  response.data.component.props.config.components = response.data.component.props.config.components.map((comp) => ({
19182
19659
  ...comp,
@@ -22277,9 +22754,9 @@ async function loadDatabaseRulesForTools(tools) {
22277
22754
  }
22278
22755
  const sections = await Promise.all(
22279
22756
  dbTypes.map(async (dbType) => {
22280
- const rules = await promptLoader.loadDatabaseRulesForType(dbType);
22757
+ const rules2 = await promptLoader.loadDatabaseRulesForType(dbType);
22281
22758
  return `## ${dbType.toUpperCase()} Rules (apply ONLY to ${dbType} tools \u2014 check each tool's ID prefix)
22282
- ${rules}`;
22759
+ ${rules2}`;
22283
22760
  })
22284
22761
  );
22285
22762
  return sections.join("\n\n---\n\n");
@@ -23330,7 +23807,7 @@ var processDashCompRequest = async (data, components, _sendMessage, anthropicApi
23330
23807
  };
23331
23808
  };
23332
23809
  async function handleDashCompRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, dashCompModels) {
23333
- const response = await processDashCompRequest(
23810
+ const response = await runWithMessageIdentity(data, () => processDashCompRequest(
23334
23811
  data,
23335
23812
  components,
23336
23813
  sendMessage,
@@ -23342,7 +23819,7 @@ async function handleDashCompRequest(data, components, sendMessage, anthropicApi
23342
23819
  collections,
23343
23820
  tools,
23344
23821
  dashCompModels
23345
- );
23822
+ ));
23346
23823
  sendDashCompResponse(
23347
23824
  response.id || data.id,
23348
23825
  {
@@ -25324,7 +25801,7 @@ var processReportCompRequest = async (data, components, _sendMessage, anthropicA
25324
25801
  };
25325
25802
  };
25326
25803
  async function handleReportCompRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, modelConfig) {
25327
- const response = await processReportCompRequest(
25804
+ const response = await runWithMessageIdentity(data, () => processReportCompRequest(
25328
25805
  data,
25329
25806
  components,
25330
25807
  sendMessage,
@@ -25336,7 +25813,7 @@ async function handleReportCompRequest(data, components, sendMessage, anthropicA
25336
25813
  collections,
25337
25814
  tools,
25338
25815
  modelConfig
25339
- );
25816
+ ));
25340
25817
  sendReportCompResponse(
25341
25818
  response.id || data.id,
25342
25819
  {
@@ -27988,6 +28465,7 @@ export {
27988
28465
  DEFAULT_AGENT_CONFIG,
27989
28466
  LLM,
27990
28467
  MainAgent,
28468
+ MissingRequestContextError,
27991
28469
  STORAGE_CONFIG,
27992
28470
  ScriptMatcher,
27993
28471
  ScriptStore,
@@ -27999,36 +28477,53 @@ export {
27999
28477
  UserManager,
28000
28478
  anthropicLLM,
28001
28479
  assertReadOnlySql,
28480
+ bindContext,
28481
+ captureContext,
28482
+ clearContextTokens,
28483
+ contextTokenCount,
28002
28484
  createCodexSession,
28003
28485
  createCodingAgentSession,
28004
28486
  dashboardConversationHistory,
28005
28487
  dataFingerprint,
28488
+ describeContext,
28006
28489
  describeTriggers,
28007
28490
  emitAgentEvent,
28008
28491
  extractNamedExport,
28009
28492
  geminiLLM,
28010
28493
  getCodingAgent,
28011
28494
  getCodingAgentsConfig,
28495
+ getRequestContext,
28012
28496
  groqLLM,
28013
28497
  hybridRerank,
28498
+ isUserContext,
28014
28499
  listCodingAgents,
28015
28500
  llmUsageLogger,
28016
28501
  logger,
28502
+ mintContextToken,
28017
28503
  normalizeScriptBody,
28018
28504
  onAgentRun,
28019
28505
  openaiLLM,
28506
+ policiesFromConfig,
28020
28507
  queryCache,
28021
28508
  registerCodingAgentCollection,
28022
28509
  registerCodingAgents,
28023
28510
  registerDashboardAgentCollection,
28511
+ requireRequestContext,
28024
28512
  rerankChromaResults,
28025
28513
  rerankConversationResults,
28514
+ resolveContextToken,
28026
28515
  resolveScriptRecipeStore,
28516
+ revokeContextToken,
28517
+ runAsSystem,
28518
+ runAsUser,
28027
28519
  runCodingAgent,
28028
28520
  runScript,
28521
+ runWithContext,
28522
+ runWithMessageIdentity,
28029
28523
  startAgentTriggers,
28030
28524
  stopAgentTriggers,
28031
28525
  userPromptErrorLogger,
28526
+ withCapturedContext,
28032
28527
  writeBridgeShims
28033
28528
  };
28034
28529
  //# sourceMappingURL=index.mjs.map