@sweny-ai/core 0.1.111 → 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.
- package/dist/cli/config.d.ts +9 -0
- package/dist/cli/config.js +17 -2
- package/dist/cli/main.js +4 -0
- package/dist/executor.d.ts +9 -0
- package/dist/executor.js +5 -1
- package/dist/skills/betterstack.d.ts +19 -0
- package/dist/skills/betterstack.js +59 -15
- package/dist/skills/github.d.ts +15 -0
- package/dist/skills/github.js +28 -2
- package/dist/skills/supabase.d.ts +11 -0
- package/dist/skills/supabase.js +99 -23
- package/dist/source-resolver.js +36 -7
- package/dist/sources.d.ts +9 -6
- package/dist/templates.d.ts +7 -0
- package/dist/templates.js +4 -1
- package/package.json +1 -1
package/dist/cli/config.d.ts
CHANGED
|
@@ -74,6 +74,15 @@ export interface CliConfig {
|
|
|
74
74
|
context: string[];
|
|
75
75
|
offline: boolean;
|
|
76
76
|
fetchAuth: Record<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* When set, sandbox `file:` Sources to this directory tree: a `file:` source
|
|
79
|
+
* that escapes it (via `..`, an absolute path, or a symlink pointing out) is
|
|
80
|
+
* rejected. The sandbox is opt-in: empty string means no root configured, so
|
|
81
|
+
* `file:` Sources read anywhere on disk (default). Set via `--file-root [dir]`
|
|
82
|
+
* (the working directory when the flag is given without a value) or a
|
|
83
|
+
* `.sweny.yml` `file-root: <path>`.
|
|
84
|
+
*/
|
|
85
|
+
fileRoot: string;
|
|
77
86
|
}
|
|
78
87
|
export declare function registerTriageCommand(program: Command): Command;
|
|
79
88
|
export declare function parseCliInputs(options: Record<string, unknown>, fileConfig?: FileConfig): CliConfig;
|
package/dist/cli/config.js
CHANGED
|
@@ -63,7 +63,8 @@ export function registerTriageCommand(program) {
|
|
|
63
63
|
.option("--no-cache", "Disable step cache")
|
|
64
64
|
.option("--output-dir <path>", "Output directory for file providers (default: .sweny/output)")
|
|
65
65
|
.option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
|
|
66
|
-
.option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
|
|
66
|
+
.option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
|
|
67
|
+
.option("--file-root [dir]", "Sandbox file: Sources to this directory tree (defaults to the working directory when the flag is given without a value)");
|
|
67
68
|
}
|
|
68
69
|
export function parseCliInputs(options, fileConfig = {}) {
|
|
69
70
|
const env = process.env;
|
|
@@ -183,8 +184,21 @@ export function parseCliInputs(options, fileConfig = {}) {
|
|
|
183
184
|
context: fa("context"),
|
|
184
185
|
offline: Boolean(options.offline),
|
|
185
186
|
fetchAuth: parseFetchAuth(fileConfig),
|
|
187
|
+
// Opt-in file: sandbox. `--file-root <dir>` sets the root; `--file-root`
|
|
188
|
+
// with no value defaults to cwd; otherwise honor a `.sweny.yml`
|
|
189
|
+
// `file-root: <path>`. Empty string = no root = read-anywhere (default).
|
|
190
|
+
fileRoot: typeof options.fileRoot === "string"
|
|
191
|
+
? options.fileRoot
|
|
192
|
+
: options.fileRoot === true
|
|
193
|
+
? process.cwd()
|
|
194
|
+
: fileStr(fileConfig, "file-root"),
|
|
186
195
|
};
|
|
187
196
|
}
|
|
197
|
+
/** Read a string key from file config, returning "" when absent or non-string. */
|
|
198
|
+
function fileStr(fileConfig, key) {
|
|
199
|
+
const v = fileConfig[key];
|
|
200
|
+
return typeof v === "string" ? v : "";
|
|
201
|
+
}
|
|
188
202
|
function parseFetchAuth(fileConfig) {
|
|
189
203
|
const raw = fileConfig["fetch.auth"];
|
|
190
204
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
@@ -659,7 +673,8 @@ export function registerImplementCommand(program) {
|
|
|
659
673
|
.option("--additional-instructions <text>", "Extra instructions for the coding agent")
|
|
660
674
|
.option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false)
|
|
661
675
|
.option("--verbose", "Print each tool call's input and output inline (human-readable, truncated). Use --stream for full untruncated NDJSON.", false)
|
|
662
|
-
.option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
|
|
676
|
+
.option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
|
|
677
|
+
.option("--file-root [dir]", "Sandbox file: Sources to this directory tree (defaults to the working directory when the flag is given without a value)");
|
|
663
678
|
}
|
|
664
679
|
/**
|
|
665
680
|
* Parse a JSON string into MCP server configs.
|
package/dist/cli/main.js
CHANGED
|
@@ -145,6 +145,7 @@ async function resolveRulesAndContext(config) {
|
|
|
145
145
|
offline: config.offline,
|
|
146
146
|
fetchAuth: config.fetchAuth,
|
|
147
147
|
env: process.env,
|
|
148
|
+
fileRoot: config.fileRoot || undefined,
|
|
148
149
|
};
|
|
149
150
|
const [rulesResult, contextResult] = await Promise.all([
|
|
150
151
|
loadAdditionalContext(config.rules, loadOptions),
|
|
@@ -366,6 +367,7 @@ triageCmd.action(async (options) => {
|
|
|
366
367
|
env: process.env,
|
|
367
368
|
fetchAuth: config.fetchAuth,
|
|
368
369
|
offline: config.offline,
|
|
370
|
+
fileRoot: config.fileRoot || undefined,
|
|
369
371
|
});
|
|
370
372
|
const durationMs = Date.now() - runStart;
|
|
371
373
|
// Output
|
|
@@ -552,6 +554,7 @@ implementCmd.action(async (issueId, options) => {
|
|
|
552
554
|
env: process.env,
|
|
553
555
|
fetchAuth: config.fetchAuth,
|
|
554
556
|
offline: config.offline,
|
|
557
|
+
fileRoot: config.fileRoot || undefined,
|
|
555
558
|
});
|
|
556
559
|
const hasFailed = [...results.values()].some((r) => r.status === "failed");
|
|
557
560
|
const implDurationMs = Date.now() - implRunStart;
|
|
@@ -833,6 +836,7 @@ export async function workflowRunAction(file, options) {
|
|
|
833
836
|
env: process.env,
|
|
834
837
|
fetchAuth: config.fetchAuth,
|
|
835
838
|
offline: config.offline,
|
|
839
|
+
fileRoot: config.fileRoot || undefined,
|
|
836
840
|
});
|
|
837
841
|
const wfDurationMs = Date.now() - runStart;
|
|
838
842
|
const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
|
package/dist/executor.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ export interface ExecuteOptions {
|
|
|
32
32
|
fetchAuth?: Record<string, string>;
|
|
33
33
|
/** When true, URL Sources throw instead of fetching */
|
|
34
34
|
offline?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* When set, `file:` Sources are sandboxed to this directory tree: a `file:`
|
|
37
|
+
* source that resolves outside it (via `..` traversal, an absolute path, or a
|
|
38
|
+
* symlink pointing out) is rejected with `SOURCE_FILE_OUTSIDE_ROOT`. The
|
|
39
|
+
* sandbox is opt-in: when unset, `file:` sources may read anywhere on disk
|
|
40
|
+
* (default), preserving the read-anywhere behavior workflow authors rely on
|
|
41
|
+
* for files elsewhere in their own repo / machine.
|
|
42
|
+
*/
|
|
43
|
+
fileRoot?: string;
|
|
35
44
|
/**
|
|
36
45
|
* Workflow-level hard cap on total node executions (the number of times the
|
|
37
46
|
* main loop visits a node). Guards against unbounded cycles, e.g. a back-edge
|
package/dist/executor.js
CHANGED
|
@@ -93,11 +93,15 @@ export async function execute(workflow, input, options) {
|
|
|
93
93
|
nodeRules.forEach((s, i) => (sourceMap[`nodes.${nodeId}.rules.${i}`] = s));
|
|
94
94
|
nodeContext.forEach((s, i) => (sourceMap[`nodes.${nodeId}.context.${i}`] = s));
|
|
95
95
|
}
|
|
96
|
+
const resolveCwd = options.cwd ?? process.cwd();
|
|
96
97
|
const resolvedSources = await resolveSources(sourceMap, {
|
|
97
|
-
cwd:
|
|
98
|
+
cwd: resolveCwd,
|
|
98
99
|
env: options.env ?? process.env,
|
|
99
100
|
authConfig: options.fetchAuth ?? {},
|
|
100
101
|
offline: options.offline ?? false,
|
|
102
|
+
// Opt-in `file:` sandbox: only engaged when the caller sets `fileRoot`.
|
|
103
|
+
// When unset, `file:` sources read anywhere on disk (default).
|
|
104
|
+
fileRoot: options.fileRoot,
|
|
101
105
|
logger,
|
|
102
106
|
});
|
|
103
107
|
trace.sources = resolvedSources;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
],
|
package/dist/skills/github.d.ts
CHANGED
|
@@ -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;
|
package/dist/skills/github.js
CHANGED
|
@@ -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;
|
package/dist/skills/supabase.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(`/${
|
|
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
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
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
|
|
231
|
-
|
|
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/dist/source-resolver.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* or `node:crypto` into bundlers that target the browser.
|
|
8
8
|
*/
|
|
9
9
|
import * as dns from "node:dns/promises";
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
10
11
|
import * as fs from "node:fs/promises";
|
|
11
12
|
import * as path from "node:path";
|
|
12
13
|
import { normalizeSource } from "./sources.js";
|
|
@@ -385,22 +386,50 @@ function embeddedIpv4(ip) {
|
|
|
385
386
|
}
|
|
386
387
|
/**
|
|
387
388
|
* Enforce the optional repo-root file sandbox. The sandbox is opt-in: it only
|
|
388
|
-
* applies when `fileRoot` is set
|
|
389
|
-
*
|
|
390
|
-
* resolve to a path inside
|
|
391
|
-
* escape the root are rejected
|
|
389
|
+
* applies when `fileRoot` is set. When unset, legacy "read anywhere" behavior is
|
|
390
|
+
* preserved (a `file:` source may resolve to any path on disk). When a root is
|
|
391
|
+
* configured, a `file:` source must resolve to a path inside it; `..` traversal
|
|
392
|
+
* and absolute paths that escape the root are rejected with
|
|
393
|
+
* `SOURCE_FILE_OUTSIDE_ROOT`. Set `allowFileOutsideRoot` to opt back out even
|
|
392
394
|
* when a root is configured.
|
|
395
|
+
*
|
|
396
|
+
* Symlinks are resolved with `fs.realpathSync` before the containment check so
|
|
397
|
+
* a symlink inside the root pointing at an external target cannot smuggle
|
|
398
|
+
* out-of-root content. `realpath` throws `ENOENT` on a not-yet-existing file; in
|
|
399
|
+
* that case we resolve the nearest existing ancestor (so a symlinked parent dir
|
|
400
|
+
* is still caught) and re-append the missing tail, preserving the downstream
|
|
401
|
+
* `SOURCE_FILE_NOT_FOUND` error for genuinely missing files.
|
|
393
402
|
*/
|
|
394
403
|
function assertFileWithinRoot(absolute, ctx, fieldPath) {
|
|
395
404
|
if (ctx.allowFileOutsideRoot)
|
|
396
405
|
return;
|
|
397
406
|
if (!ctx.fileRoot)
|
|
398
|
-
return; //
|
|
399
|
-
const root = path.resolve(ctx.fileRoot);
|
|
400
|
-
const normalized = path.resolve(absolute);
|
|
407
|
+
return; // no root configured — sandbox not engaged
|
|
408
|
+
const root = realpathOrLexical(path.resolve(ctx.fileRoot));
|
|
409
|
+
const normalized = realpathOrLexical(path.resolve(absolute));
|
|
401
410
|
const rel = path.relative(root, normalized);
|
|
402
411
|
const escapes = rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
|
|
403
412
|
if (escapes) {
|
|
404
413
|
throw new Error(`SOURCE_FILE_OUTSIDE_ROOT: ${absolute} is outside the allowed root ${root} (referenced by ${fieldPath}). Set allowFileOutsideRoot to permit reads outside the repo.`);
|
|
405
414
|
}
|
|
406
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Canonicalize `target` via `fs.realpathSync` (resolving symlinks). If the path
|
|
418
|
+
* does not yet exist, walk up to the nearest existing ancestor, canonicalize
|
|
419
|
+
* that, and re-append the non-existent tail. This keeps symlink resolution
|
|
420
|
+
* meaningful for the directory portion (catching a symlinked parent that
|
|
421
|
+
* escapes the root) while not throwing on a file that simply isn't there — that
|
|
422
|
+
* case is surfaced later as `SOURCE_FILE_NOT_FOUND` by the read.
|
|
423
|
+
*/
|
|
424
|
+
function realpathOrLexical(target) {
|
|
425
|
+
try {
|
|
426
|
+
return realpathSync(target);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
const parent = path.dirname(target);
|
|
430
|
+
if (parent === target)
|
|
431
|
+
return target; // reached filesystem root
|
|
432
|
+
const base = path.basename(target);
|
|
433
|
+
return path.join(realpathOrLexical(parent), base);
|
|
434
|
+
}
|
|
435
|
+
}
|
package/dist/sources.d.ts
CHANGED
|
@@ -86,15 +86,18 @@ export type SourceResolutionContext = {
|
|
|
86
86
|
*/
|
|
87
87
|
fetchTokenHosts?: string[];
|
|
88
88
|
/**
|
|
89
|
-
* Restrict `file:` source resolution to this directory tree
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
89
|
+
* Restrict `file:` source resolution to this directory tree. Relative and
|
|
90
|
+
* absolute paths that escape it via `..` or via a symlink pointing outside the
|
|
91
|
+
* root are rejected (the resolver canonicalizes with `realpath` before the
|
|
92
|
+
* containment check). The sandbox is opt-in: when unset, it is not engaged
|
|
93
|
+
* (default) and `file:` sources may read anywhere on disk. Set
|
|
94
|
+
* {@link allowFileOutsideRoot} to opt back out even when a root is configured.
|
|
93
95
|
*/
|
|
94
96
|
fileRoot?: string;
|
|
95
97
|
/**
|
|
96
|
-
*
|
|
97
|
-
* anywhere on disk (legacy behavior).
|
|
98
|
+
* When a {@link fileRoot} IS configured, opt out of the sandbox to permit
|
|
99
|
+
* `file:` sources to read anywhere on disk (legacy read-anywhere behavior).
|
|
100
|
+
* No effect when `fileRoot` is unset. Defaults to `false`.
|
|
98
101
|
*/
|
|
99
102
|
allowFileOutsideRoot?: boolean;
|
|
100
103
|
/**
|
package/dist/templates.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface SourceLoadOptions {
|
|
|
31
31
|
fetchAuth?: Record<string, string>;
|
|
32
32
|
/** Environment used to resolve `fetchAuth` env-var names. Defaults to process.env. */
|
|
33
33
|
env?: NodeJS.ProcessEnv;
|
|
34
|
+
/**
|
|
35
|
+
* When set, `file:` template/context Sources are sandboxed to this directory
|
|
36
|
+
* tree: a path that escapes it via `..`, an absolute path, or a symlink
|
|
37
|
+
* pointing out is rejected. The sandbox is opt-in: when unset, `file:` sources
|
|
38
|
+
* may read anywhere on disk (default).
|
|
39
|
+
*/
|
|
40
|
+
fileRoot?: string;
|
|
34
41
|
}
|
|
35
42
|
/**
|
|
36
43
|
* Load a template from a file path or URL.
|
package/dist/templates.js
CHANGED
|
@@ -53,11 +53,14 @@ export const DEFAULT_PR_TEMPLATE = `## Summary
|
|
|
53
53
|
Fixes #
|
|
54
54
|
`;
|
|
55
55
|
function buildCtx(options) {
|
|
56
|
+
const cwd = options.cwd ?? process.cwd();
|
|
56
57
|
return {
|
|
57
|
-
cwd
|
|
58
|
+
cwd,
|
|
58
59
|
env: options.env ?? process.env,
|
|
59
60
|
authConfig: options.fetchAuth ?? {},
|
|
60
61
|
offline: options.offline ?? false,
|
|
62
|
+
// Opt-in `file:` sandbox: only engaged when the caller sets `fileRoot`.
|
|
63
|
+
fileRoot: options.fileRoot,
|
|
61
64
|
logger: consoleLogger,
|
|
62
65
|
};
|
|
63
66
|
}
|