@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.
- package/README.md +22 -22
- package/dist/a2a/server.d.ts +2 -2
- package/dist/a2a/server.js +13 -2
- package/dist/agent/thread-data-builder.d.ts +4 -0
- package/dist/agent/thread-data-builder.js +8 -0
- package/dist/agent-sql/analysis.d.ts +31 -0
- package/dist/agent-sql/analysis.js +169 -0
- package/dist/agent-sql/index.d.ts +4 -0
- package/dist/agent-sql/index.js +4 -0
- package/dist/agent-sql/lexer.d.ts +37 -0
- package/dist/agent-sql/lexer.js +357 -0
- package/dist/agent-sql/postgres.d.ts +51 -0
- package/dist/agent-sql/postgres.js +197 -0
- package/dist/agent-sql/query.d.ts +30 -0
- package/dist/agent-sql/query.js +514 -0
- package/dist/catalog.json +24 -24
- package/dist/client/analytics/index.d.ts +1 -1
- package/dist/client/analytics/index.js +1 -1
- package/dist/client/analytics.d.ts +8 -0
- package/dist/client/analytics.js +17 -2
- package/dist/client/session-replay.js +3 -2
- package/dist/identity/offboard.js +31 -3
- package/dist/identity/rekey.d.ts +12 -1
- package/dist/identity/rekey.js +77 -6
- package/dist/identity/retired-emails.d.ts +23 -0
- package/dist/identity/retired-emails.js +55 -0
- package/dist/mcp/actions/create-org-service-token.js +20 -8
- package/dist/mcp/actions/service-token-access.d.ts +1 -0
- package/dist/mcp/actions/service-token-access.js +41 -24
- package/dist/mcp/build-server.d.ts +20 -6
- package/dist/mcp/build-server.js +131 -35
- package/dist/mcp/connect-route.d.ts +4 -3
- package/dist/mcp/connect-route.js +140 -82
- package/dist/mcp/connect-store.d.ts +17 -11
- package/dist/mcp/connect-store.js +88 -60
- package/dist/mcp/credential-issuance.d.ts +23 -0
- package/dist/mcp/credential-issuance.js +77 -0
- package/dist/mcp/credential-membership.d.ts +26 -0
- package/dist/mcp/credential-membership.js +67 -0
- package/dist/mcp/oauth-route.js +133 -45
- package/dist/mcp/oauth-store.d.ts +15 -5
- package/dist/mcp/oauth-store.js +119 -34
- package/dist/mcp/oauth-token.d.ts +4 -0
- package/dist/mcp/oauth-token.js +5 -0
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/server.js +14 -0
- package/dist/onboarding/default-steps.js +1 -1
- package/dist/org/handlers.d.ts +1 -1
- package/dist/org/index.d.ts +2 -2
- package/dist/org/membership.d.ts +3 -1
- package/dist/org/membership.js +5 -2
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +10 -10
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resource-changes/store.d.ts +2 -0
- package/dist/resource-changes/store.js +4 -0
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/db/exec.js +28 -101
- package/dist/scripts/db/patch.js +14 -34
- package/dist/scripts/db/postgres-client.d.ts +8 -1
- package/dist/scripts/db/postgres-client.js +20 -6
- package/dist/scripts/db/query.js +31 -33
- package/dist/scripts/db/safety.d.ts +47 -2
- package/dist/scripts/db/safety.js +383 -137
- package/dist/scripts/db/scoping.d.ts +1 -0
- package/dist/scripts/db/scoping.js +3 -2
- package/dist/search/index-store.d.ts +21 -0
- package/dist/search/index-store.js +47 -0
- package/dist/search/indexer.js +35 -32
- package/dist/search/registry.d.ts +6 -3
- package/dist/search/registry.js +30 -8
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-chat-plugin.js +18 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/agent-run-context.js +9 -0
- package/dist/server/attribution.d.ts +2 -0
- package/dist/server/attribution.js +7 -1
- package/dist/server/auth.js +9 -0
- package/dist/server/credential-membership-unavailable.d.ts +13 -0
- package/dist/server/credential-membership-unavailable.js +22 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/onboarding-html.js +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/recap-image-route.js +4 -0
- package/dist/server/release-schema.js +4 -0
- package/dist/shared/docs-url.d.ts +3 -1
- package/dist/shared/docs-url.js +6 -3
- package/dist/shared/environment-lanes.d.ts +8 -0
- package/dist/shared/environment-lanes.js +26 -0
- package/dist/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/chat/README.md +2 -2
- package/dist/templates/default/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/default/app/routes/home.tsx +5 -1
- package/dist/templates/headless/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/tracking/providers.js +13 -1
- package/docs/content/authentication.mdx +5 -1
- package/docs/content/durable-background-runs.mdx +17 -8
- package/docs/content/external-agents.mdx +3 -1
- package/docs/content/mcp-protocol.mdx +6 -2
- package/docs/content/security.mdx +1 -1
- package/package.json +5 -4
- package/src/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/chat/README.md +2 -2
- package/src/templates/default/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/default/app/routes/home.tsx +5 -1
- package/src/templates/headless/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
58
|
-
|
|
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 =
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
if (!
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
let
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
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>;
|