@sweny-ai/core 0.1.112 → 0.1.113

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.
@@ -5,4 +5,23 @@
5
5
  * CI-native alternative to the BetterStack MCP server.
6
6
  */
7
7
  import type { Skill } from "../types.js";
8
+ /**
9
+ * Validate an LLM-chosen table name as a bare ClickHouse identifier.
10
+ * Anything outside [A-Za-z0-9_] is rejected — table names from
11
+ * BetterStack are always of the form t<id>_<name>.
12
+ */
13
+ export declare function assertClickHouseIdentifier(name: string): string;
14
+ /**
15
+ * Best-effort read-only guard for `betterstack_query`.
16
+ *
17
+ * Accepts SELECT / WITH / DESCRIBE statements, rejects everything else,
18
+ * rejects multi-statement input, and appends a LIMIT cap when the query
19
+ * has none. Keyword checks ignore string literal contents so a log
20
+ * message containing "LIMIT" doesn't skip the cap.
21
+ *
22
+ * This is UX, not a security boundary: a determined query can still do
23
+ * anything the ClickHouse connection's role allows. Use a read-only
24
+ * role for the configured credentials.
25
+ */
26
+ export declare function prepareReadOnlyQuery(query: string): string;
8
27
  export declare const betterstack: Skill;
@@ -37,6 +37,58 @@ async function bsQuery(sql, ctx) {
37
37
  .split("\n")
38
38
  .map((line) => JSON.parse(line));
39
39
  }
40
+ // ─── Input hardening (issue #226) ──────────────────────────────
41
+ //
42
+ // Threat model: tool arguments are LLM-controlled at runtime and can be
43
+ // steered by prompt-injected content (issue bodies, fetched logs). The
44
+ // `table` argument reaches a ClickHouse query string, so it is strictly
45
+ // validated as an identifier. The read-only query guard below is
46
+ // best-effort UX — the real security boundary must be a read-only
47
+ // ClickHouse role on the BETTERSTACK_QUERY_USERNAME connection.
48
+ /**
49
+ * Validate an LLM-chosen table name as a bare ClickHouse identifier.
50
+ * Anything outside [A-Za-z0-9_] is rejected — table names from
51
+ * BetterStack are always of the form t<id>_<name>.
52
+ */
53
+ export function assertClickHouseIdentifier(name) {
54
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
55
+ throw new Error(`[BetterStack] Invalid table identifier "${name}" — only letters, digits, and _ are allowed`);
56
+ }
57
+ return name;
58
+ }
59
+ /** Blank out string literal contents so keyword checks don't match inside them. */
60
+ function stripStringLiterals(sql) {
61
+ // Handles both '' (SQL) and \' (ClickHouse) quote escaping.
62
+ return sql.replace(/'(?:[^'\\]|\\.|'')*'/g, "''");
63
+ }
64
+ /**
65
+ * Best-effort read-only guard for `betterstack_query`.
66
+ *
67
+ * Accepts SELECT / WITH / DESCRIBE statements, rejects everything else,
68
+ * rejects multi-statement input, and appends a LIMIT cap when the query
69
+ * has none. Keyword checks ignore string literal contents so a log
70
+ * message containing "LIMIT" doesn't skip the cap.
71
+ *
72
+ * This is UX, not a security boundary: a determined query can still do
73
+ * anything the ClickHouse connection's role allows. Use a read-only
74
+ * role for the configured credentials.
75
+ */
76
+ export function prepareReadOnlyQuery(query) {
77
+ let sql = query.trim().replace(/;\s*$/, "").trim();
78
+ const stripped = stripStringLiterals(sql);
79
+ if (stripped.includes(";")) {
80
+ throw new Error("[BetterStack] Multi-statement queries are not allowed — send a single statement");
81
+ }
82
+ if (!/^(SELECT|WITH|DESCRIBE)\b/i.test(stripped)) {
83
+ throw new Error("[BetterStack] Only read-only queries are allowed (SELECT, WITH, or DESCRIBE)");
84
+ }
85
+ // Append LIMIT if none present to prevent unbounded result sets.
86
+ // DESCRIBE output is already bounded by the column count.
87
+ if (!/^DESCRIBE\b/i.test(stripped) && !/\bLIMIT\b/i.test(stripped)) {
88
+ sql = `${sql} LIMIT 500`;
89
+ }
90
+ return sql;
91
+ }
40
92
  // ─── Skill definition ──────────────────────────────────────────
41
93
  export const betterstack = {
42
94
  id: "betterstack",
@@ -55,7 +107,8 @@ export const betterstack = {
55
107
  env: "BETTERSTACK_QUERY_ENDPOINT",
56
108
  },
57
109
  BETTERSTACK_QUERY_USERNAME: {
58
- description: "ClickHouse connection username",
110
+ description: "ClickHouse connection username. Use a read-only role — the in-skill query guard is best-effort, " +
111
+ "not a security boundary",
59
112
  required: true,
60
113
  env: "BETTERSTACK_QUERY_USERNAME",
61
114
  },
@@ -114,7 +167,7 @@ export const betterstack = {
114
167
  required: ["table"],
115
168
  },
116
169
  handler: async (input, ctx) => {
117
- return bsQuery(`DESCRIBE TABLE remote(${input.table}_logs)`, ctx);
170
+ return bsQuery(`DESCRIBE TABLE remote(${assertClickHouseIdentifier(input.table)}_logs)`, ctx);
118
171
  },
119
172
  },
120
173
  {
@@ -123,28 +176,19 @@ export const betterstack = {
123
176
  Tables: remote(TABLE_logs) for recent logs, s3Cluster(primary, TABLE_s3) for historical (add WHERE _row_type = 1).
124
177
  Key fields: dt (timestamp), raw (JSON blob with all log fields).
125
178
  Extract nested fields: JSONExtract(raw, 'field_name', 'Nullable(String)').
126
- Use betterstack_get_source_fields to discover available columns.`,
179
+ Use betterstack_get_source_fields to discover available columns.
180
+ Only single SELECT / WITH / DESCRIBE statements are accepted.`,
127
181
  input_schema: {
128
182
  type: "object",
129
183
  properties: {
130
- query: { type: "string", description: "ClickHouse SQL query (SELECT only)" },
184
+ query: { type: "string", description: "ClickHouse SQL query (single SELECT, WITH, or DESCRIBE statement)" },
131
185
  source_id: { type: "number", description: "Source ID (for context)" },
132
186
  table: { type: "string", description: "Table name (e.g. t273774_offload_ecs_production)" },
133
187
  },
134
188
  required: ["query", "source_id", "table"],
135
189
  },
136
190
  handler: async (input, ctx) => {
137
- const trimmed = input.query.trim();
138
- const upper = trimmed.toUpperCase();
139
- if (!upper.startsWith("SELECT") && !upper.startsWith("DESCRIBE")) {
140
- throw new Error("[BetterStack] Only SELECT and DESCRIBE queries are allowed");
141
- }
142
- // Append LIMIT if none present to prevent unbounded result sets
143
- let sql = trimmed;
144
- if (!upper.includes("LIMIT")) {
145
- sql = `${sql} LIMIT 500`;
146
- }
147
- return bsQuery(sql, ctx);
191
+ return bsQuery(prepareReadOnlyQuery(input.query), ctx);
148
192
  },
149
193
  },
150
194
  ],
@@ -5,4 +5,19 @@
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
7
  import type { Skill } from "../types.js";
8
+ /**
9
+ * Encode an LLM-chosen repo file path for safe interpolation into a
10
+ * GitHub API request path.
11
+ *
12
+ * Trust model (issue #226): tool arguments are runtime-controlled by the
13
+ * model and can be steered by prompt-injected content. Without encoding,
14
+ * a `path` containing `?` or `#` rewrites the request (injects query
15
+ * params / truncates via fragment), and `..` segments walk out of the
16
+ * `/contents/` endpoint entirely.
17
+ *
18
+ * Each `/`-separated segment is percent-encoded (so `/` between segments
19
+ * is preserved); `.` and `..` segments are rejected outright because
20
+ * URL normalization resolves them server-side even when encoded.
21
+ */
22
+ export declare function encodeRepoFilePath(path: string): string;
8
23
  export declare const github: Skill;
@@ -34,6 +34,32 @@ function isAlreadyExistsError(err) {
34
34
  return false;
35
35
  return /pull request already exists/i.test(err.body);
36
36
  }
37
+ /**
38
+ * Encode an LLM-chosen repo file path for safe interpolation into a
39
+ * GitHub API request path.
40
+ *
41
+ * Trust model (issue #226): tool arguments are runtime-controlled by the
42
+ * model and can be steered by prompt-injected content. Without encoding,
43
+ * a `path` containing `?` or `#` rewrites the request (injects query
44
+ * params / truncates via fragment), and `..` segments walk out of the
45
+ * `/contents/` endpoint entirely.
46
+ *
47
+ * Each `/`-separated segment is percent-encoded (so `/` between segments
48
+ * is preserved); `.` and `..` segments are rejected outright because
49
+ * URL normalization resolves them server-side even when encoded.
50
+ */
51
+ export function encodeRepoFilePath(path) {
52
+ return path
53
+ .split("/")
54
+ .filter((segment) => segment !== "")
55
+ .map((segment) => {
56
+ if (segment === "." || segment === "..") {
57
+ throw new Error(`[GitHub] Invalid path segment "${segment}" — relative path segments are not allowed`);
58
+ }
59
+ return encodeURIComponent(segment);
60
+ })
61
+ .join("/");
62
+ }
37
63
  export const github = {
38
64
  id: "github",
39
65
  name: "GitHub",
@@ -224,8 +250,8 @@ export const github = {
224
250
  required: ["repo", "path"],
225
251
  },
226
252
  handler: async (input, ctx) => {
227
- const ref = input.ref ? `?ref=${input.ref}` : "";
228
- const data = await gh(`/repos/${input.repo}/contents/${input.path}${ref}`, ctx);
253
+ const ref = input.ref ? `?ref=${encodeURIComponent(input.ref)}` : "";
254
+ const data = await gh(`/repos/${input.repo}/contents/${encodeRepoFilePath(input.path)}${ref}`, ctx);
229
255
  if (data.content && data.encoding === "base64") {
230
256
  return { ...data, decoded_content: Buffer.from(data.content, "base64").toString("utf-8") };
231
257
  }
@@ -6,4 +6,15 @@
6
6
  * and the Management API for project-level operations.
7
7
  */
8
8
  import type { Skill } from "../types.js";
9
+ /** Validate an LLM-chosen table/column name as a bare PostgREST identifier. */
10
+ export declare function assertSupabaseIdentifier(name: string, label?: string): string;
11
+ /** Validate an LLM-chosen RPC / edge function name (hyphens allowed for edge functions). */
12
+ export declare function assertSupabaseFunctionName(name: string): string;
13
+ /**
14
+ * Append PostgREST filter strings ("column=op.value") to query params.
15
+ * Each filter is split at its first `=`; the value side is appended as a
16
+ * single parameter value so `&`/`=` inside it are percent-encoded rather
17
+ * than interpreted as additional parameters.
18
+ */
19
+ export declare function appendPostgrestFilters(params: URLSearchParams, filters: string[]): URLSearchParams;
9
20
  export declare const supabase: Skill;
@@ -5,6 +5,73 @@
5
5
  * Uses the Supabase REST API (PostgREST) for data operations
6
6
  * and the Management API for project-level operations.
7
7
  */
8
+ // ─── Input hardening (issue #226) ──────────────────────────────
9
+ //
10
+ // Threat model: this skill runs with SUPABASE_SERVICE_ROLE_KEY, which
11
+ // bypasses RLS entirely, and `table`, `filters[]`, and function names are
12
+ // LLM-controlled at runtime (steerable by prompt-injected content).
13
+ // Identifiers are validated before they reach the request path, filter
14
+ // values are URL-encoded so one filter string cannot smuggle extra query
15
+ // parameters, and the optional SUPABASE_ALLOWED_TABLES /
16
+ // SUPABASE_ALLOWED_FUNCTIONS allowlists bound what the agent can touch.
17
+ /** Validate an LLM-chosen table/column name as a bare PostgREST identifier. */
18
+ export function assertSupabaseIdentifier(name, label = "table") {
19
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
20
+ throw new Error(`[Supabase] Invalid ${label} identifier "${name}" — only letters, digits, and _ are allowed`);
21
+ }
22
+ return name;
23
+ }
24
+ /** Validate an LLM-chosen RPC / edge function name (hyphens allowed for edge functions). */
25
+ export function assertSupabaseFunctionName(name) {
26
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
27
+ throw new Error(`[Supabase] Invalid function name "${name}" — only letters, digits, _ and - are allowed`);
28
+ }
29
+ return name;
30
+ }
31
+ /**
32
+ * Append PostgREST filter strings ("column=op.value") to query params.
33
+ * Each filter is split at its first `=`; the value side is appended as a
34
+ * single parameter value so `&`/`=` inside it are percent-encoded rather
35
+ * than interpreted as additional parameters.
36
+ */
37
+ export function appendPostgrestFilters(params, filters) {
38
+ for (const filter of filters) {
39
+ const eq = filter.indexOf("=");
40
+ if (eq <= 0) {
41
+ throw new Error(`[Supabase] Invalid filter "${filter}" — expected "column=operator.value" (e.g. "id=eq.1")`);
42
+ }
43
+ params.append(filter.slice(0, eq), filter.slice(eq + 1));
44
+ }
45
+ return params;
46
+ }
47
+ /** Parse an optional comma-separated allowlist config value. Returns null when unset (no restriction). */
48
+ function resolveAllowlist(config, key) {
49
+ const raw = config[key];
50
+ if (!raw || !raw.trim())
51
+ return null;
52
+ return new Set(raw
53
+ .split(",")
54
+ .map((entry) => entry.trim())
55
+ .filter(Boolean));
56
+ }
57
+ /** Validate a table name and check it against SUPABASE_ALLOWED_TABLES (when configured). */
58
+ function checkTable(table, ctx) {
59
+ assertSupabaseIdentifier(table);
60
+ const allowlist = resolveAllowlist(ctx.config, "SUPABASE_ALLOWED_TABLES");
61
+ if (allowlist && !allowlist.has(table)) {
62
+ throw new Error(`[Supabase] Table "${table}" is not on the SUPABASE_ALLOWED_TABLES allowlist (${[...allowlist].join(", ")})`);
63
+ }
64
+ return table;
65
+ }
66
+ /** Validate a function name and check it against SUPABASE_ALLOWED_FUNCTIONS (when configured). */
67
+ function checkFunction(name, ctx) {
68
+ assertSupabaseFunctionName(name);
69
+ const allowlist = resolveAllowlist(ctx.config, "SUPABASE_ALLOWED_FUNCTIONS");
70
+ if (allowlist && !allowlist.has(name)) {
71
+ throw new Error(`[Supabase] Function "${name}" is not on the SUPABASE_ALLOWED_FUNCTIONS allowlist (${[...allowlist].join(", ")})`);
72
+ }
73
+ return name;
74
+ }
8
75
  async function supabaseRest(path, ctx, init) {
9
76
  const url = ctx.config.SUPABASE_URL;
10
77
  const key = ctx.config.SUPABASE_SERVICE_ROLE_KEY;
@@ -54,10 +121,24 @@ export const supabase = {
54
121
  env: "SUPABASE_URL",
55
122
  },
56
123
  SUPABASE_SERVICE_ROLE_KEY: {
57
- description: "Supabase service role key (full access, server-side only)",
124
+ description: "Supabase service role key (full access, bypasses RLS — high blast radius). " +
125
+ "Prefer a scoped key where possible, and bound exposure with SUPABASE_ALLOWED_TABLES / " +
126
+ "SUPABASE_ALLOWED_FUNCTIONS",
58
127
  required: true,
59
128
  env: "SUPABASE_SERVICE_ROLE_KEY",
60
129
  },
130
+ SUPABASE_ALLOWED_TABLES: {
131
+ description: "Optional comma-separated table allowlist. When set, data tools (query/count/insert/update/delete) " +
132
+ "are restricted to these tables",
133
+ required: false,
134
+ env: "SUPABASE_ALLOWED_TABLES",
135
+ },
136
+ SUPABASE_ALLOWED_FUNCTIONS: {
137
+ description: "Optional comma-separated function allowlist. When set, supabase_rpc and supabase_invoke_function " +
138
+ "are restricted to these RPC / edge function names",
139
+ required: false,
140
+ env: "SUPABASE_ALLOWED_FUNCTIONS",
141
+ },
61
142
  },
62
143
  tools: [
63
144
  // ─── Read ────────────────────────────────────────────────
@@ -89,18 +170,14 @@ export const supabase = {
89
170
  required: ["table"],
90
171
  },
91
172
  handler: async (input, ctx) => {
173
+ const table = checkTable(input.table, ctx);
92
174
  const params = new URLSearchParams();
93
175
  params.set("select", input.select || "*");
94
176
  if (input.order)
95
177
  params.set("order", input.order);
96
178
  params.set("limit", String(input.limit || 100));
97
- let path = `/${input.table}?${params}`;
98
- if (input.filters) {
99
- for (const f of input.filters) {
100
- path += `&${f}`;
101
- }
102
- }
103
- return supabaseRest(path, ctx);
179
+ appendPostgrestFilters(params, input.filters ?? []);
180
+ return supabaseRest(`/${table}?${params}`, ctx);
104
181
  },
105
182
  },
106
183
  {
@@ -121,12 +198,10 @@ export const supabase = {
121
198
  handler: async (input, ctx) => {
122
199
  const url = ctx.config.SUPABASE_URL;
123
200
  const key = ctx.config.SUPABASE_SERVICE_ROLE_KEY;
124
- let path = `${url}/rest/v1/${input.table}?select=count`;
125
- if (input.filters) {
126
- for (const f of input.filters) {
127
- path += `&${f}`;
128
- }
129
- }
201
+ const table = checkTable(input.table, ctx);
202
+ const params = new URLSearchParams({ select: "count" });
203
+ appendPostgrestFilters(params, input.filters ?? []);
204
+ const path = `${url}/rest/v1/${table}?${params}`;
130
205
  const res = await fetch(path, {
131
206
  headers: {
132
207
  apikey: key,
@@ -165,11 +240,12 @@ export const supabase = {
165
240
  required: ["table", "rows"],
166
241
  },
167
242
  handler: async (input, ctx) => {
243
+ const table = checkTable(input.table, ctx);
168
244
  const headers = {};
169
245
  if (input.upsert) {
170
246
  headers["Prefer"] = "resolution=merge-duplicates,return=representation";
171
247
  }
172
- return supabaseRest(`/${input.table}`, ctx, {
248
+ return supabaseRest(`/${table}`, ctx, {
173
249
  method: "POST",
174
250
  headers,
175
251
  body: JSON.stringify(input.rows),
@@ -199,10 +275,9 @@ export const supabase = {
199
275
  if (!input.filters || input.filters.length === 0) {
200
276
  throw new Error("Filters are required for update operations to prevent accidental full-table updates");
201
277
  }
202
- let path = `/${input.table}`;
203
- const filterParts = input.filters.map((f) => f);
204
- path += `?${filterParts.join("&")}`;
205
- return supabaseRest(path, ctx, {
278
+ const table = checkTable(input.table, ctx);
279
+ const params = appendPostgrestFilters(new URLSearchParams(), input.filters);
280
+ return supabaseRest(`/${table}?${params}`, ctx, {
206
281
  method: "PATCH",
207
282
  body: JSON.stringify(input.data),
208
283
  });
@@ -227,8 +302,9 @@ export const supabase = {
227
302
  if (!input.filters || input.filters.length === 0) {
228
303
  throw new Error("Filters are required for delete operations");
229
304
  }
230
- const path = `/${input.table}?${input.filters.join("&")}`;
231
- return supabaseRest(path, ctx, { method: "DELETE" });
305
+ const table = checkTable(input.table, ctx);
306
+ const params = appendPostgrestFilters(new URLSearchParams(), input.filters);
307
+ return supabaseRest(`/${table}?${params}`, ctx, { method: "DELETE" });
232
308
  },
233
309
  },
234
310
  // ─── RPC ─────────────────────────────────────────────────
@@ -247,7 +323,7 @@ export const supabase = {
247
323
  required: ["function_name"],
248
324
  },
249
325
  handler: async (input, ctx) => {
250
- return supabaseRest(`/rpc/${input.function_name}`, ctx, {
326
+ return supabaseRest(`/rpc/${checkFunction(input.function_name, ctx)}`, ctx, {
251
327
  method: "POST",
252
328
  body: JSON.stringify(input.params || {}),
253
329
  });
@@ -272,7 +348,7 @@ export const supabase = {
272
348
  required: ["function_name", "body"],
273
349
  },
274
350
  handler: async (input, ctx) => {
275
- return supabaseEdgeFunction(input.function_name, input.body, ctx);
351
+ return supabaseEdgeFunction(checkFunction(input.function_name, ctx), input.body, ctx);
276
352
  },
277
353
  },
278
354
  // ─── Auth Admin ──────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"