@agent-native/core 0.201.0-nightly-20261005153421 → 0.201.0

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.
Files changed (118) hide show
  1. package/README.md +22 -22
  2. package/dist/a2a/server.d.ts +2 -2
  3. package/dist/a2a/server.js +13 -2
  4. package/dist/agent/thread-data-builder.d.ts +4 -0
  5. package/dist/agent/thread-data-builder.js +8 -0
  6. package/dist/agent-sql/analysis.d.ts +31 -0
  7. package/dist/agent-sql/analysis.js +169 -0
  8. package/dist/agent-sql/index.d.ts +4 -0
  9. package/dist/agent-sql/index.js +4 -0
  10. package/dist/agent-sql/lexer.d.ts +37 -0
  11. package/dist/agent-sql/lexer.js +357 -0
  12. package/dist/agent-sql/postgres.d.ts +51 -0
  13. package/dist/agent-sql/postgres.js +197 -0
  14. package/dist/agent-sql/query.d.ts +30 -0
  15. package/dist/agent-sql/query.js +514 -0
  16. package/dist/catalog.json +24 -24
  17. package/dist/client/analytics/index.d.ts +1 -1
  18. package/dist/client/analytics/index.js +1 -1
  19. package/dist/client/analytics.d.ts +8 -0
  20. package/dist/client/analytics.js +17 -2
  21. package/dist/client/session-replay.js +3 -2
  22. package/dist/identity/offboard.js +31 -3
  23. package/dist/identity/rekey.d.ts +12 -1
  24. package/dist/identity/rekey.js +77 -6
  25. package/dist/identity/retired-emails.d.ts +23 -0
  26. package/dist/identity/retired-emails.js +55 -0
  27. package/dist/mcp/actions/create-org-service-token.js +20 -8
  28. package/dist/mcp/actions/service-token-access.d.ts +1 -0
  29. package/dist/mcp/actions/service-token-access.js +41 -24
  30. package/dist/mcp/build-server.d.ts +20 -6
  31. package/dist/mcp/build-server.js +131 -35
  32. package/dist/mcp/connect-route.d.ts +4 -3
  33. package/dist/mcp/connect-route.js +140 -82
  34. package/dist/mcp/connect-store.d.ts +17 -11
  35. package/dist/mcp/connect-store.js +88 -60
  36. package/dist/mcp/credential-issuance.d.ts +23 -0
  37. package/dist/mcp/credential-issuance.js +77 -0
  38. package/dist/mcp/credential-membership.d.ts +26 -0
  39. package/dist/mcp/credential-membership.js +67 -0
  40. package/dist/mcp/oauth-route.js +133 -45
  41. package/dist/mcp/oauth-store.d.ts +15 -5
  42. package/dist/mcp/oauth-store.js +119 -34
  43. package/dist/mcp/oauth-token.d.ts +4 -0
  44. package/dist/mcp/oauth-token.js +5 -0
  45. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  46. package/dist/mcp/server.js +14 -0
  47. package/dist/onboarding/default-steps.js +1 -1
  48. package/dist/org/handlers.d.ts +1 -1
  49. package/dist/org/index.d.ts +2 -2
  50. package/dist/org/membership.d.ts +3 -1
  51. package/dist/org/membership.js +5 -2
  52. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  53. package/dist/provider-api/actions/provider-api.d.ts +10 -10
  54. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  55. package/dist/resource-changes/store.d.ts +2 -0
  56. package/dist/resource-changes/store.js +4 -0
  57. package/dist/resources/handlers.d.ts +1 -1
  58. package/dist/scripts/db/exec.js +28 -101
  59. package/dist/scripts/db/patch.js +14 -34
  60. package/dist/scripts/db/postgres-client.d.ts +8 -1
  61. package/dist/scripts/db/postgres-client.js +20 -6
  62. package/dist/scripts/db/query.js +31 -33
  63. package/dist/scripts/db/safety.d.ts +47 -2
  64. package/dist/scripts/db/safety.js +383 -137
  65. package/dist/scripts/db/scoping.d.ts +1 -0
  66. package/dist/scripts/db/scoping.js +3 -2
  67. package/dist/search/index-store.d.ts +21 -0
  68. package/dist/search/index-store.js +47 -0
  69. package/dist/search/indexer.js +35 -32
  70. package/dist/search/registry.d.ts +6 -3
  71. package/dist/search/registry.js +30 -8
  72. package/dist/secrets/routes.d.ts +6 -6
  73. package/dist/server/agent-chat-plugin.js +18 -6
  74. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  75. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  76. package/dist/server/agent-run-context.js +9 -0
  77. package/dist/server/attribution.d.ts +2 -0
  78. package/dist/server/attribution.js +7 -1
  79. package/dist/server/auth.js +9 -0
  80. package/dist/server/credential-membership-unavailable.d.ts +13 -0
  81. package/dist/server/credential-membership-unavailable.js +22 -0
  82. package/dist/server/index.d.ts +1 -0
  83. package/dist/server/index.js +1 -0
  84. package/dist/server/onboarding-html.js +1 -1
  85. package/dist/server/realtime-token.d.ts +1 -1
  86. package/dist/server/recap-image-route.js +4 -0
  87. package/dist/server/release-schema.js +4 -0
  88. package/dist/shared/docs-url.d.ts +3 -1
  89. package/dist/shared/docs-url.js +6 -3
  90. package/dist/shared/environment-lanes.d.ts +8 -0
  91. package/dist/shared/environment-lanes.js +26 -0
  92. package/dist/templates/chat/.agents/skills/security/SKILL.md +9 -2
  93. package/dist/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  94. package/dist/templates/chat/README.md +2 -2
  95. package/dist/templates/default/.agents/skills/security/SKILL.md +9 -2
  96. package/dist/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
  97. package/dist/templates/default/app/routes/home.tsx +5 -1
  98. package/dist/templates/headless/.agents/skills/security/SKILL.md +9 -2
  99. package/dist/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
  100. package/dist/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
  101. package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
  102. package/dist/tracking/providers.js +13 -1
  103. package/docs/content/authentication.mdx +5 -1
  104. package/docs/content/durable-background-runs.mdx +17 -8
  105. package/docs/content/external-agents.mdx +3 -1
  106. package/docs/content/mcp-protocol.mdx +6 -2
  107. package/docs/content/security.mdx +1 -1
  108. package/package.json +5 -4
  109. package/src/templates/chat/.agents/skills/security/SKILL.md +9 -2
  110. package/src/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  111. package/src/templates/chat/README.md +2 -2
  112. package/src/templates/default/.agents/skills/security/SKILL.md +9 -2
  113. package/src/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
  114. package/src/templates/default/app/routes/home.tsx +5 -1
  115. package/src/templates/headless/.agents/skills/security/SKILL.md +9 -2
  116. package/src/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
  117. package/src/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
  118. package/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
@@ -1,97 +1,203 @@
1
+ import { AgentSqlPolicyError, assertAgentPostgresTokenPolicy, readAgentPostgresStatement, verifyAgentPostgresResolution, } from "../../agent-sql/postgres.js";
2
+ import { toPostgresParams } from "../../db/client.js";
1
3
  import { fail } from "../utils.js";
2
- const SENSITIVE_FRAMEWORK_TABLE_RE = /\b(app_secrets|oauth_tokens|user|users|session|sessions|account|accounts|verification|jwks|organization|member|invitation|org_members|org_invitations|pg_catalog|information_schema|pg_class|pg_proc|pg_namespace|pg_user|pg_roles|pg_authid|pg_shadow)\b/i;
3
- function stripSqlNonIdentifiers(sql) {
4
- let out = "";
5
- let state = "normal";
6
- for (let i = 0; i < sql.length; i++) {
7
- const ch = sql[i];
8
- const next = sql[i + 1];
9
- if (state === "line-comment") {
10
- if (ch === "\n") {
11
- out += " ";
12
- state = "normal";
13
- }
14
- continue;
4
+ // Every guard here reads the SQL with the shared agent-SQL lexer, so strings,
5
+ // comments, quoted identifiers and dollar quotes are seen the way Postgres
6
+ // sees them. Text the lexer cannot read is refused rather than skipped.
7
+ function guarded(check, label) {
8
+ try {
9
+ return check();
10
+ }
11
+ catch (error) {
12
+ if (error instanceof AgentSqlPolicyError) {
13
+ fail(prefixed(label, error.message));
15
14
  }
16
- if (state === "block-comment") {
17
- if (ch === "*" && next === "/") {
18
- i++;
19
- out += " ";
20
- state = "normal";
21
- }
22
- continue;
15
+ throw error;
16
+ }
17
+ }
18
+ async function guardedAsync(check, label) {
19
+ try {
20
+ await check();
21
+ }
22
+ catch (error) {
23
+ if (error instanceof AgentSqlPolicyError) {
24
+ fail(prefixed(label, error.message));
23
25
  }
24
- if (state === "single") {
25
- if (ch === "'" && next === "'") {
26
- i++;
27
- }
28
- else if (ch === "'") {
29
- out += " ";
30
- state = "normal";
31
- }
26
+ throw error;
27
+ }
28
+ }
29
+ function prefixed(label, message) {
30
+ return label ? `${label}: ${message}` : message;
31
+ }
32
+ function isName(token) {
33
+ return token?.kind === "word" || token?.kind === "quoted-identifier";
34
+ }
35
+ function isWord(token, value) {
36
+ return token?.kind === "word" && token.value === value;
37
+ }
38
+ function isPunctuation(token, text) {
39
+ return token?.kind === "punctuation" && token.text === text;
40
+ }
41
+ const SENSITIVE_FRAMEWORK_TABLES = new Set([
42
+ "app_secrets",
43
+ "oauth_tokens",
44
+ "mcp_oauth_clients",
45
+ "mcp_oauth_codes",
46
+ "mcp_oauth_refresh_tokens",
47
+ "mcp_connect_tokens",
48
+ "mcp_device_codes",
49
+ "user",
50
+ "users",
51
+ "session",
52
+ "sessions",
53
+ "account",
54
+ "accounts",
55
+ "verification",
56
+ "jwks",
57
+ "organization",
58
+ "member",
59
+ "invitation",
60
+ "org_members",
61
+ "org_invitations",
62
+ "pg_catalog",
63
+ "information_schema",
64
+ "pg_class",
65
+ "pg_proc",
66
+ "pg_namespace",
67
+ "pg_user",
68
+ "pg_roles",
69
+ "pg_authid",
70
+ "pg_shadow",
71
+ ]);
72
+ function sensitiveVerb(operation) {
73
+ return operation === "read"
74
+ ? "readable"
75
+ : operation === "write"
76
+ ? "writable"
77
+ : "patchable";
78
+ }
79
+ function assertNoSensitiveFrameworkTableTokens(tokens, operation) {
80
+ for (const token of tokens) {
81
+ if (!isName(token))
32
82
  continue;
33
- }
34
- if (ch === "-" && next === "-") {
35
- i++;
36
- state = "line-comment";
83
+ const name = token.value.toLowerCase();
84
+ if (!SENSITIVE_FRAMEWORK_TABLES.has(name))
37
85
  continue;
86
+ fail(`Sensitive framework table "${name}" is not ${sensitiveVerb(operation)} through raw DB tools. Use the framework auth, secrets, or OAuth APIs instead.`);
87
+ }
88
+ }
89
+ export function assertNoSensitiveFrameworkTables(sql, operation) {
90
+ const statement = guarded(() => readAgentPostgresStatement(sql));
91
+ assertNoSensitiveFrameworkTableTokens(statement.tokens, operation);
92
+ }
93
+ const READ_KEYWORDS = new Set(["select", "with", "explain"]);
94
+ const WRITE_KEYWORDS = new Set(["insert", "update", "delete"]);
95
+ /**
96
+ * Reads one db-query statement and applies every check that needs no
97
+ * database. Returns the statement as it will run.
98
+ */
99
+ export function readRawDbReadStatement(sql) {
100
+ const statement = guarded(() => readAgentPostgresStatement(sql));
101
+ if (!statement.keyword || !READ_KEYWORDS.has(statement.keyword)) {
102
+ fail("Only SELECT, WITH, and EXPLAIN queries are allowed. Use db-exec for writes.");
103
+ }
104
+ guarded(() => assertAgentPostgresTokenPolicy(statement));
105
+ assertNoSensitiveFrameworkTableTokens(statement.tokens, "read");
106
+ return statement;
107
+ }
108
+ /**
109
+ * Reads one db-exec statement and applies every check that needs no
110
+ * database, including the access-control write rules. `label` prefixes
111
+ * errors, e.g. "Statement 2".
112
+ */
113
+ export function readRawDbWriteStatement(sql, label) {
114
+ const statement = guarded(() => {
115
+ try {
116
+ return readAgentPostgresStatement(sql);
38
117
  }
39
- if (ch === "/" && next === "*") {
40
- i++;
41
- state = "block-comment";
42
- continue;
118
+ catch (error) {
119
+ if (!(error instanceof AgentSqlPolicyError))
120
+ throw error;
121
+ const subject = label ?? "The statement";
122
+ if (error.code === "multiple_statements") {
123
+ fail(`${subject} contains multiple SQL statements. Use --statements for batches so each write can be validated and run transactionally.`);
124
+ }
125
+ if (error.code === "empty")
126
+ fail(`${subject} is empty`);
127
+ throw error;
43
128
  }
44
- if (ch === "'") {
45
- state = "single";
46
- continue;
129
+ }, label);
130
+ assertRawDbWriteKeyword(statement, label);
131
+ guarded(() => assertAgentPostgresTokenPolicy(statement), label);
132
+ assertNoSensitiveFrameworkTableTokens(statement.tokens, "write");
133
+ assertNoRawDbAccessControlWriteTokens(statement, label);
134
+ return statement;
135
+ }
136
+ function assertRawDbWriteKeyword(statement, label) {
137
+ const keyword = statement.keyword ?? "";
138
+ if (READ_KEYWORDS.has(keyword)) {
139
+ fail(prefixed(label, "use db-query for read statements. db-exec is for writes only."));
140
+ }
141
+ if (keyword === "create" || keyword === "alter") {
142
+ fail(prefixed(label, "schema changes are not allowed through db-exec. Additive schema changes must go through reviewed migrations or startup code."));
143
+ }
144
+ if (!WRITE_KEYWORDS.has(keyword)) {
145
+ fail(prefixed(label, "only INSERT, UPDATE, DELETE statements are allowed. Dangerous operations such as DROP, TRUNCATE, GRANT, and REVOKE are blocked."));
146
+ }
147
+ }
148
+ /**
149
+ * Converts `?` placeholders and re-reads the result, so the guards have seen
150
+ * exactly the text that executes. Refuses if converting again would change
151
+ * the text, since the PGlite client converts a second time.
152
+ */
153
+ export function finalRawDbSql(sql, kind, label) {
154
+ const finalSql = toPostgresParams(sql);
155
+ if (toPostgresParams(finalSql) !== finalSql) {
156
+ fail(prefixed(label, "The SQL could not be read safely: its placeholders are ambiguous."));
157
+ }
158
+ const statement = guarded(() => readAgentPostgresStatement(finalSql), label);
159
+ // toPostgresParams converts every "?" it reads as code. One left over means
160
+ // it read that text as a string or comment and Postgres will not, so the
161
+ // two disagree about where the statement's code is.
162
+ if (statement.tokens.some((token) => (token.kind === "parameter" || token.kind === "operator") &&
163
+ token.text.includes("?"))) {
164
+ fail(prefixed(label, "The SQL could not be read safely: its placeholders are ambiguous."));
165
+ }
166
+ if (kind === "read") {
167
+ if (!statement.keyword || !READ_KEYWORDS.has(statement.keyword)) {
168
+ fail("Only SELECT, WITH, and EXPLAIN queries are allowed.");
169
+ }
170
+ }
171
+ else if (kind === "patch") {
172
+ if (statement.keyword !== "update") {
173
+ fail("--where must be a single condition.");
47
174
  }
48
- out += ch;
49
175
  }
50
- return out;
176
+ else {
177
+ assertRawDbWriteKeyword(statement, label);
178
+ }
179
+ guarded(() => assertAgentPostgresTokenPolicy(statement), label);
180
+ assertNoSensitiveFrameworkTableTokens(statement.tokens, kind);
181
+ return { sql: finalSql, statement };
51
182
  }
52
- export function assertNoSensitiveFrameworkTables(sql, operation) {
53
- const cleanSql = stripSqlNonIdentifiers(sql);
54
- const match = cleanSql.match(SENSITIVE_FRAMEWORK_TABLE_RE);
55
- if (!match)
183
+ /**
184
+ * The database-side checks: run inside the transaction, after the per-user
185
+ * views exist and immediately before the statement executes.
186
+ * `columnsByTable` comes from the scoping context; it lets an INSERT without a
187
+ * column list be refused when the table has access-control columns.
188
+ */
189
+ export async function verifyRawDbStatement(db, statement, options = {}) {
190
+ await guardedAsync(() => verifyAgentPostgresResolution(db, statement), options.label);
191
+ if (statement.keyword !== "insert" || !options.columnsByTable)
56
192
  return;
57
- const verb = operation === "read"
58
- ? "readable"
59
- : operation === "write"
60
- ? "writable"
61
- : "patchable";
62
- fail(`Sensitive framework table "${match[1]}" is not ${verb} through raw DB tools. Use the framework auth, secrets, or OAuth APIs instead.`);
63
- }
64
- // Schema/database-qualified table references (e.g. `public.notes`,
65
- // `pg_temp.notes`) BYPASS the per-user/per-org temporary views that scope
66
- // db-query / db-exec, because those views only shadow UNQUALIFIED table names.
67
- // A qualified reference resolves straight to the real base table, defeating the
68
- // owner_email / org_id scoping and exposing (or letting writes touch) every
69
- // tenant's rows. db-patch already rejects dotted identifiers via
70
- // isValidIdentifier; db-query / db-exec must reject them too.
71
- //
72
- // Two complementary detectors run on the comment/string-stripped SQL:
73
- // 1. The schemas that actually HOLD base tables and so defeat scoping when
74
- // named explicitly: `public` (Postgres deployments),
75
- // and the Postgres system catalogs. This fires in ANY position, so it also
76
- // catches comma-joins (`FROM notes, public.other`) and `USING public.x`.
77
- // `temp` / `pg_temp` are intentionally NOT listed — temporary objects (our
78
- // scoping views) live there, so `temp.notes` resolves to the *scoped* view,
79
- // not a bypass, and `temp` is a common table alias we must not reject.
80
- // The schema may be bare or double-quoted (`"public"."notes"`).
81
- // 2. Any dotted reference in table position (FROM/JOIN/INTO/UPDATE, incl.
82
- // ONLY/LATERAL), which also catches non-standard schema names. Column /
83
- // alias references like `f.id` sit in select/where/on position, not table
84
- // position, so they do not match — no false positives on ordinary joins.
85
- const DANGEROUS_SCHEMA_QUALIFIER_RE = /(?:\b|")(?:main|public|pg_catalog|pg_toast|information_schema)"?\s*\.\s*(?:"|`|\[|[A-Za-z_])/i;
86
- const TABLE_POSITION_QUALIFIED_RE = /\b(?:FROM|JOIN|INTO|UPDATE)\s+(?:ONLY\s+|LATERAL\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)/i;
87
- export function assertNoSchemaQualifiedTables(sql, operation) {
88
- const cleanSql = stripSqlNonIdentifiers(sql);
89
- if (!DANGEROUS_SCHEMA_QUALIFIER_RE.test(cleanSql) &&
90
- !TABLE_POSITION_QUALIFIED_RE.test(cleanSql)) {
193
+ const target = writeTarget(statement);
194
+ if (!target || target.insertColumns !== null)
91
195
  return;
196
+ const columns = options.columnsByTable.get(target.table) ?? [];
197
+ const sensitive = columns.find((column) => hasSensitiveToken(column, ACCESS_CONTROL_COLUMN_TOKENS));
198
+ if (sensitive) {
199
+ fail(prefixed(options.label, `INSERT into "${target.table}" must list its columns, because the table has the access-control column "${sensitive}". Name the columns you are setting.`));
92
200
  }
93
- const verb = operation === "read" ? "queried" : "written";
94
- fail(`Schema-qualified table references (e.g. "public.<table>") cannot be ${verb} through raw DB tools — a qualified name bypasses the per-user data scoping that isolates each tenant's rows. Use the bare table name; the current user's scoping is applied automatically.`);
95
201
  }
96
202
  const ACCESS_CONTROL_TABLE_TOKENS = new Set([
97
203
  "acl",
@@ -136,15 +242,8 @@ const ACCESS_CONTROL_COLUMN_TOKENS = new Set([
136
242
  "role",
137
243
  "roles",
138
244
  ]);
139
- function normalizeIdentifier(value) {
140
- return value
141
- .trim()
142
- .replace(/^["'`[]/, "")
143
- .replace(/["'`\]]$/, "")
144
- .toLowerCase();
145
- }
146
245
  function identifierTokens(identifier) {
147
- const normalized = normalizeIdentifier(identifier);
246
+ const normalized = identifier.trim().toLowerCase();
148
247
  const tokens = new Set([normalized]);
149
248
  for (const token of normalized.split(/[^a-z0-9]+/).filter(Boolean)) {
150
249
  tokens.add(token);
@@ -158,68 +257,215 @@ function hasSensitiveToken(identifier, sensitiveTokens) {
158
257
  }
159
258
  return null;
160
259
  }
161
- function tableNameFromWriteSql(sql) {
162
- const match = sql.match(/^\s*(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM)\s+((?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+)(?:\s*\.\s*(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+))?)/i);
163
- if (!match)
260
+ /** Reads `name` or `a.b.name` at `index`; returns the last name and the next index. */
261
+ function readNameChain(tokens, index) {
262
+ if (!isName(tokens[index]))
263
+ return null;
264
+ let cursor = index;
265
+ while (isPunctuation(tokens[cursor + 1], ".") && isName(tokens[cursor + 2])) {
266
+ cursor += 2;
267
+ }
268
+ return { name: tokens[cursor].value, next: cursor + 1 };
269
+ }
270
+ const PARENTHESIZED_QUERY_KEYWORDS = new Set([
271
+ "select",
272
+ "with",
273
+ "values",
274
+ "table",
275
+ ]);
276
+ function isOpening(token) {
277
+ return isPunctuation(token, "(") || isPunctuation(token, "[");
278
+ }
279
+ function isClosing(token) {
280
+ return isPunctuation(token, ")") || isPunctuation(token, "]");
281
+ }
282
+ /**
283
+ * The table an INSERT writes and the columns it lists, or null when the
284
+ * statement is not an INSERT the guards can read. `columns` is null when the
285
+ * INSERT has no column list.
286
+ */
287
+ export function rawDbInsertTarget(statement) {
288
+ if (statement.keyword !== "insert")
289
+ return null;
290
+ const target = writeTarget(statement);
291
+ return target ? { table: target.table, columns: target.insertColumns } : null;
292
+ }
293
+ function writeTarget(statement) {
294
+ const { tokens } = statement;
295
+ let index = 1;
296
+ if (statement.keyword === "insert") {
297
+ if (!isWord(tokens[index], "into"))
298
+ return null;
299
+ index++;
300
+ }
301
+ else if (statement.keyword === "delete") {
302
+ if (!isWord(tokens[index], "from"))
303
+ return null;
304
+ index++;
305
+ }
306
+ else if (statement.keyword !== "update") {
164
307
  return null;
165
- return normalizeIdentifier(match[1].split(".").pop() ?? match[1]);
166
- }
167
- function splitColumnList(columns) {
168
- return columns
169
- .split(",")
170
- .map((column) => normalizeIdentifier(column))
171
- .filter(Boolean);
172
- }
173
- function insertColumnsFromSql(sql) {
174
- const match = sql.match(/^\s*(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO)\s+(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+)(?:\s*\.\s*(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+))?\s*\(([^)]+)\)/i);
175
- return match ? splitColumnList(match[1]) : [];
176
- }
177
- function updateColumnsFromSql(sql) {
178
- const setMatch = /\bSET\b/i.exec(sql);
179
- if (!setMatch)
180
- return [];
181
- const tail = sql.slice(setMatch.index + setMatch[0].length);
182
- const endMatch = /\b(?:WHERE|RETURNING)\b/i.exec(tail);
183
- const setClause = endMatch ? tail.slice(0, endMatch.index) : tail;
308
+ }
309
+ if (isWord(tokens[index], "only"))
310
+ index++;
311
+ const chain = readNameChain(tokens, index);
312
+ if (!chain)
313
+ return null;
314
+ let insertColumns = null;
315
+ if (statement.keyword === "insert") {
316
+ let cursor = chain.next;
317
+ if (isWord(tokens[cursor], "as"))
318
+ cursor += 2;
319
+ const opensQuery = isPunctuation(tokens[cursor + 1], "(") ||
320
+ (tokens[cursor + 1]?.kind === "word" &&
321
+ PARENTHESIZED_QUERY_KEYWORDS.has(tokens[cursor + 1].value));
322
+ // `INSERT INTO t (SELECT ...)` is a parenthesized query, not a column list.
323
+ if (isPunctuation(tokens[cursor], "(") && !opensQuery) {
324
+ insertColumns = [];
325
+ let depth = 0;
326
+ for (; cursor < tokens.length; cursor++) {
327
+ const token = tokens[cursor];
328
+ if (isOpening(token))
329
+ depth++;
330
+ else if (isClosing(token) && --depth === 0)
331
+ break;
332
+ else if (isName(token))
333
+ insertColumns.push(token.value);
334
+ }
335
+ }
336
+ else if (isWord(tokens[cursor], "default")) {
337
+ insertColumns = [];
338
+ }
339
+ }
340
+ return {
341
+ table: chain.name,
342
+ insertColumns,
343
+ setColumns: setClauseColumns(tokens),
344
+ };
345
+ }
346
+ const SET_CLAUSE_END = new Set(["from", "where", "returning"]);
347
+ /**
348
+ * Every assignment target in every top-level SET clause: `a = 1`,
349
+ * `(a, b) = (1, 2)`, `a[1] = 1`, and ON CONFLICT DO UPDATE SET.
350
+ */
351
+ function setClauseColumns(tokens) {
184
352
  const columns = [];
185
- const columnRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s*=/g;
186
- let match;
187
- while ((match = columnRe.exec(setClause)) !== null) {
188
- columns.push(normalizeIdentifier(match[1] ?? match[2] ?? match[3] ?? match[4]));
353
+ let depth = 0;
354
+ for (let index = 0; index < tokens.length; index++) {
355
+ const token = tokens[index];
356
+ if (isOpening(token))
357
+ depth++;
358
+ else if (isClosing(token))
359
+ depth--;
360
+ if (depth !== 0 || !isWord(token, "set"))
361
+ continue;
362
+ let assignmentDepth = 0;
363
+ let inTarget = true;
364
+ for (index++; index < tokens.length; index++) {
365
+ const inner = tokens[index];
366
+ if (isOpening(inner))
367
+ assignmentDepth++;
368
+ else if (isClosing(inner))
369
+ assignmentDepth--;
370
+ if (assignmentDepth === 0) {
371
+ if (inner.kind === "word" &&
372
+ SET_CLAUSE_END.has(inner.value) &&
373
+ !isWord(tokens[index - 1], "distinct")) {
374
+ break;
375
+ }
376
+ if (isPunctuation(inner, ",")) {
377
+ inTarget = true;
378
+ continue;
379
+ }
380
+ if (inner.kind === "operator" && inner.text === "=") {
381
+ inTarget = false;
382
+ continue;
383
+ }
384
+ }
385
+ if (inTarget && isName(inner))
386
+ columns.push(inner.value);
387
+ }
388
+ index--;
189
389
  }
190
390
  return columns;
191
391
  }
192
- function writeColumnsFromSql(sql) {
193
- const upper = sql.trim().toUpperCase();
194
- if (upper.startsWith("UPDATE"))
195
- return updateColumnsFromSql(sql);
196
- if (upper.startsWith("INSERT") || upper.startsWith("REPLACE")) {
197
- return insertColumnsFromSql(sql);
198
- }
199
- return [];
200
- }
201
- export function assertNoRawDbAccessControlWrite(sql) {
202
- const tableName = tableNameFromWriteSql(sql);
203
- if (tableName) {
204
- const tableToken = hasSensitiveToken(tableName, ACCESS_CONTROL_TABLE_TOKENS);
205
- if (tableToken) {
206
- fail(`Sensitive identity/access-control table "${tableName}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`);
207
- }
392
+ function assertNoRawDbAccessControlWriteTokens(statement, label) {
393
+ const target = writeTarget(statement);
394
+ if (!target) {
395
+ fail(prefixed(label, "could not identify the table this statement writes. Write one table by its bare name."));
396
+ }
397
+ if (hasSensitiveToken(target.table, ACCESS_CONTROL_TABLE_TOKENS)) {
398
+ fail(prefixed(label, `Sensitive identity/access-control table "${target.table}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`));
208
399
  }
209
- for (const column of writeColumnsFromSql(sql)) {
210
- const columnToken = hasSensitiveToken(column, ACCESS_CONTROL_COLUMN_TOKENS);
211
- if (!columnToken)
400
+ for (const column of [
401
+ ...(target.insertColumns ?? []),
402
+ ...target.setColumns,
403
+ ]) {
404
+ if (!hasSensitiveToken(column, ACCESS_CONTROL_COLUMN_TOKENS))
212
405
  continue;
213
- fail(`Sensitive identity/access-control column "${column}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`);
406
+ fail(prefixed(label, `Sensitive identity/access-control column "${column}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`));
214
407
  }
215
408
  }
216
409
  export function assertNoRawDbAccessControlPatchTarget(table, column) {
217
- const tableName = normalizeIdentifier(table);
410
+ const tableName = table.trim().toLowerCase();
218
411
  if (hasSensitiveToken(tableName, ACCESS_CONTROL_TABLE_TOKENS)) {
219
412
  fail(`Sensitive identity/access-control table "${tableName}" is not patchable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`);
220
413
  }
221
- const columnName = normalizeIdentifier(column);
414
+ const columnName = column.trim().toLowerCase();
222
415
  if (hasSensitiveToken(columnName, ACCESS_CONTROL_COLUMN_TOKENS)) {
223
416
  fail(`Sensitive identity/access-control column "${columnName}" is not patchable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`);
224
417
  }
225
418
  }
419
+ const BLOCKED_WHERE_KEYWORDS = new Set([
420
+ "insert",
421
+ "update",
422
+ "delete",
423
+ "merge",
424
+ "drop",
425
+ "alter",
426
+ "create",
427
+ "truncate",
428
+ "grant",
429
+ "revoke",
430
+ "copy",
431
+ "call",
432
+ ]);
433
+ /**
434
+ * Validates db-patch's `--where`, which is spliced into a SELECT and an
435
+ * UPDATE. Both full statements are read and verified again before they run.
436
+ */
437
+ export function validateRawDbPatchWhere(where) {
438
+ // Stricter than the lexer needs: a condition never needs a ';' or a comment.
439
+ if (where.includes(";"))
440
+ fail("--where must not contain ';'");
441
+ const statement = guarded(() => readAgentPostgresStatement(where));
442
+ let previousEnd = 0;
443
+ for (const gap of [
444
+ ...statement.tokens.map((token) => {
445
+ const text = where.slice(previousEnd, token.start);
446
+ previousEnd = token.end;
447
+ return text;
448
+ }),
449
+ where.slice(previousEnd),
450
+ ]) {
451
+ if (gap.includes("--"))
452
+ fail('--where must not contain "--"');
453
+ if (gap.includes("/*"))
454
+ fail('--where must not contain "/*"');
455
+ }
456
+ for (const token of statement.tokens) {
457
+ if (token.kind === "word" && BLOCKED_WHERE_KEYWORDS.has(token.value)) {
458
+ fail(`--where must not contain "${token.value.toUpperCase()}"`);
459
+ }
460
+ }
461
+ }
462
+ /** Reads a statement db-patch built from its arguments. */
463
+ export function readRawDbPatchStatement(sql, keyword) {
464
+ const statement = guarded(() => readAgentPostgresStatement(sql));
465
+ if (statement.keyword !== keyword) {
466
+ fail("--where must be a single condition.");
467
+ }
468
+ guarded(() => assertAgentPostgresTokenPolicy(statement));
469
+ assertNoSensitiveFrameworkTableTokens(statement.tokens, "patch");
470
+ return statement;
471
+ }
@@ -7,6 +7,7 @@ export interface ScopingContext {
7
7
  ownerEmailTables: Set<string>;
8
8
  orgIdTables: Set<string>;
9
9
  tablePredicates: Map<string, string>;
10
+ columnsByTable: Map<string, string[]>;
10
11
  }
11
12
  export declare function buildScopingPostgres(client: {
12
13
  unsafe(sql: string, args?: unknown[]): Promise<unknown[]>;
@@ -82,8 +82,8 @@ function buildScopedTables(allColumns, userEmail, orgId) {
82
82
  // prompt-injection exfiltration channel (read own secret → send to
83
83
  // attacker URL) and also hides any legacy plaintext rows that predate
84
84
  // encryption plus the recoverable last4/preview. Schema-qualified attempts
85
- // to reach the base table (public.settings) are
86
- // rejected separately by assertNoSchemaQualifiedTables in safety.ts.
85
+ // to reach the base table (public.settings) are rejected separately
86
+ // by the agent-SQL guards in safety.ts.
87
87
  whereSql =
88
88
  `"${coreScoping.column}" LIKE '${prefix}%' ESCAPE '\\'` +
89
89
  ` AND "${coreScoping.column}" NOT LIKE '${prefix}credential:%' ESCAPE '\\'`;
@@ -149,5 +149,6 @@ export async function buildScopingPostgres(client) {
149
149
  ownerEmailTables,
150
150
  orgIdTables,
151
151
  tablePredicates: new Map(scoped.map((s) => [s.name, s.predicate])),
152
+ columnsByTable,
152
153
  };
153
154
  }
@@ -7,3 +7,24 @@ import type { DbExec } from "../db/client.js";
7
7
  export declare const SEARCH_RESOURCES_TABLE = "search_resources";
8
8
  export declare const SEARCH_INDEX_STATE_TABLE = "search_index_state";
9
9
  export declare function ensureSearchIndexTables(injectedClient?: DbExec): Promise<void>;
10
+ /**
11
+ * Raises an index's target version, leaving the rebuild for the first drain
12
+ * at that version to claim. A process on a lower version reads the index as
13
+ * outdated from then on and serves its previous search. Returns the target
14
+ * now in force, which is higher than `version` when a newer build raised it.
15
+ */
16
+ export declare function raiseSearchIndexTarget(exec: DbExec, target: {
17
+ app: string;
18
+ type: string;
19
+ version: number;
20
+ }): Promise<number>;
21
+ /**
22
+ * Marks the index as needing a rebuild at its current version, including a
23
+ * rebuild still queueing rows: its queue may predate the writes this
24
+ * invalidation is for. Nothing to do before the search tables exist: there
25
+ * is no index yet.
26
+ */
27
+ export declare function invalidateSearchIndex(exec: DbExec, target: {
28
+ app: string;
29
+ type: string;
30
+ }): Promise<void>;