mecord-connect 1.0.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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { evidence } from '../core/evidence.js';
|
|
7
|
+
import { OperatorError } from '../core/errors.js';
|
|
8
|
+
import { resolveTrustedExecutable } from '../core/trusted-executable.js';
|
|
9
|
+
import { readDurableStateText } from '../core/durable-state.js';
|
|
10
|
+
import { PathScope } from './path-scope.js';
|
|
11
|
+
|
|
12
|
+
const SCORE = {
|
|
13
|
+
reliability: 0.98,
|
|
14
|
+
latency: 0.9,
|
|
15
|
+
determinism: 0.99,
|
|
16
|
+
security: 0.98,
|
|
17
|
+
reversibility: 1,
|
|
18
|
+
informationQuality: 0.99,
|
|
19
|
+
interactionCost: 0.01
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const MAX_REGISTRY_BYTES = 256 * 1024;
|
|
23
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
const MAX_PROFILES = 50;
|
|
25
|
+
const MAX_ROWS = 500;
|
|
26
|
+
const MAX_COLUMNS = 50;
|
|
27
|
+
const MAX_FILTERS = 20;
|
|
28
|
+
const POSTGRES_REGISTRY_OPTIONS = {
|
|
29
|
+
maxBytes: MAX_REGISTRY_BYTES,
|
|
30
|
+
errorCode: 'POSTGRES_REGISTRY_INVALID',
|
|
31
|
+
invalidMessage: 'PostgreSQL profile registry is invalid.'
|
|
32
|
+
} ;
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
export class PostgresProvider {
|
|
58
|
+
name = 'postgres.psql.structured';
|
|
59
|
+
#scope ;
|
|
60
|
+
#allowedRoots ;
|
|
61
|
+
#registryPath ;
|
|
62
|
+
#psqlExecutable ;
|
|
63
|
+
#psqlArgsPrefix ;
|
|
64
|
+
|
|
65
|
+
constructor(options ) {
|
|
66
|
+
this.#scope = new PathScope(options.allowedRoots);
|
|
67
|
+
this.#allowedRoots = options.allowedRoots.map((root) => path.resolve(root));
|
|
68
|
+
this.#registryPath = path.resolve(options.registryPath ?? path.join(os.homedir(), '.operator', 'postgres-profiles.json'));
|
|
69
|
+
this.#psqlExecutable = options.psqlExecutable ?? 'psql';
|
|
70
|
+
this.#psqlArgsPrefix = options.psqlArgsPrefix ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
supports(action ) {
|
|
74
|
+
return action.capability === 'postgres.inspect' || action.capability === 'postgres.select';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
score() { return SCORE; }
|
|
78
|
+
|
|
79
|
+
async execute(action , context = {}) {
|
|
80
|
+
const started = performance.now();
|
|
81
|
+
try {
|
|
82
|
+
const root = await this.#scope.resolveExisting(String(action.input.path ?? ''));
|
|
83
|
+
const registry = await this.#readRegistry();
|
|
84
|
+
const authorized = await this.#profilesForRoot(registry, root);
|
|
85
|
+
|
|
86
|
+
if (action.capability === 'postgres.inspect') {
|
|
87
|
+
const operation = String(action.input.operation ?? 'profiles');
|
|
88
|
+
if (operation === 'profiles') {
|
|
89
|
+
return success(action, started, {
|
|
90
|
+
root,
|
|
91
|
+
profiles: authorized.map((profile) => ({
|
|
92
|
+
id: profile.id,
|
|
93
|
+
title: profile.title,
|
|
94
|
+
database: profile.database,
|
|
95
|
+
user: profile.user,
|
|
96
|
+
endpoint: endpointClass(profile.host),
|
|
97
|
+
port: profile.port,
|
|
98
|
+
sslMode: profile.sslMode
|
|
99
|
+
}))
|
|
100
|
+
}, [evidence('postgres_profiles', 'pass', 'Returned only trusted PostgreSQL profiles bound to this authorized project root; credentials were not returned.', {
|
|
101
|
+
profileCount: authorized.length
|
|
102
|
+
})]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const profile = this.#chooseProfile(authorized, String(action.input.profileId ?? ''));
|
|
106
|
+
const timeoutMs = boundedTimeout(action.input.timeoutMs);
|
|
107
|
+
if (operation === 'server') {
|
|
108
|
+
const rows = await this.#query(profile, root,
|
|
109
|
+
"SELECT current_database() AS database, current_user AS user_name, current_setting('server_version') AS server_version, current_setting('transaction_read_only') AS transaction_read_only",
|
|
110
|
+
{}, timeoutMs, context.signal);
|
|
111
|
+
return success(action, started, { profileId: profile.id, operation, rows }, [readOnlyEvidence(profile, operation)]);
|
|
112
|
+
}
|
|
113
|
+
if (operation === 'schemas') {
|
|
114
|
+
const rows = await this.#query(profile, root,
|
|
115
|
+
"SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_toast%' ORDER BY schema_name LIMIT 200",
|
|
116
|
+
{}, timeoutMs, context.signal);
|
|
117
|
+
return success(action, started, { profileId: profile.id, operation, rows }, [readOnlyEvidence(profile, operation)]);
|
|
118
|
+
}
|
|
119
|
+
if (operation === 'tables') {
|
|
120
|
+
const schema = identifier(String(action.input.schema ?? 'public'), 'schema');
|
|
121
|
+
const rows = await this.#query(profile, root,
|
|
122
|
+
"SELECT table_schema, table_name, table_type FROM information_schema.tables WHERE table_schema = :'op_schema' ORDER BY table_name LIMIT 500",
|
|
123
|
+
{ op_schema: schema }, timeoutMs, context.signal);
|
|
124
|
+
return success(action, started, { profileId: profile.id, operation, schema, rows }, [readOnlyEvidence(profile, operation)]);
|
|
125
|
+
}
|
|
126
|
+
if (operation === 'columns') {
|
|
127
|
+
const schema = identifier(String(action.input.schema ?? 'public'), 'schema');
|
|
128
|
+
const table = identifier(String(action.input.table ?? ''), 'table');
|
|
129
|
+
const rows = await this.#query(profile, root,
|
|
130
|
+
"SELECT column_name, data_type, is_nullable, ordinal_position FROM information_schema.columns WHERE table_schema = :'op_schema' AND table_name = :'op_table' ORDER BY ordinal_position LIMIT 500",
|
|
131
|
+
{ op_schema: schema, op_table: table }, timeoutMs, context.signal);
|
|
132
|
+
return success(action, started, { profileId: profile.id, operation, schema, table, rows }, [readOnlyEvidence(profile, operation)]);
|
|
133
|
+
}
|
|
134
|
+
throw new OperatorError('INVALID_POSTGRES_INSPECT_OPERATION', 'operation must be profiles, server, schemas, tables, or columns.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const profile = this.#chooseProfile(authorized, String(action.input.profileId ?? ''));
|
|
138
|
+
const schema = identifier(String(action.input.schema ?? 'public'), 'schema');
|
|
139
|
+
const table = identifier(String(action.input.table ?? ''), 'table');
|
|
140
|
+
const columns = validateColumns(action.input.columns);
|
|
141
|
+
const filters = validateFilters(action.input.filters);
|
|
142
|
+
const orderBy = validateOrder(action.input.orderBy);
|
|
143
|
+
const limit = boundedInteger(action.input.limit, 100, 1, MAX_ROWS);
|
|
144
|
+
const offset = boundedInteger(action.input.offset, 0, 0, 10_000);
|
|
145
|
+
const timeoutMs = boundedTimeout(action.input.timeoutMs);
|
|
146
|
+
const built = buildSelect(schema, table, columns, filters, orderBy, limit, offset);
|
|
147
|
+
const rows = await this.#query(profile, root, built.sql, built.variables, timeoutMs, context.signal);
|
|
148
|
+
return success(action, started, {
|
|
149
|
+
profileId: profile.id,
|
|
150
|
+
schema,
|
|
151
|
+
table,
|
|
152
|
+
columns: columns.length === 0 ? ['*'] : columns,
|
|
153
|
+
rowCount: rows.length,
|
|
154
|
+
limit,
|
|
155
|
+
offset,
|
|
156
|
+
rows
|
|
157
|
+
}, [
|
|
158
|
+
evidence('postgres_read_only', 'pass', 'Executed an Operator-constructed structured SELECT under a server-enforced read-only session with statement/lock timeouts.', {
|
|
159
|
+
profileId: profile.id,
|
|
160
|
+
rowCount: rows.length,
|
|
161
|
+
filterCount: filters.length,
|
|
162
|
+
orderCount: orderBy.length
|
|
163
|
+
}),
|
|
164
|
+
evidence('postgres_parameters', 'pass', 'Identifiers and filter values were passed through psql quoted-variable interpolation rather than concatenated into SQL text.', {
|
|
165
|
+
variableCount: Object.keys(built.variables).length
|
|
166
|
+
})
|
|
167
|
+
]);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const op = error instanceof OperatorError
|
|
170
|
+
? error
|
|
171
|
+
: new OperatorError('POSTGRES_ERROR', error instanceof Error ? error.message : String(error), { retryable: true });
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
capability: action.capability,
|
|
175
|
+
provider: this.name,
|
|
176
|
+
evidence: [evidence('postgres', 'fail', op.message, { code: op.code })],
|
|
177
|
+
error: { code: op.code, message: op.message, retryable: op.retryable },
|
|
178
|
+
durationMs: Math.round(performance.now() - started)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async #readRegistry() {
|
|
184
|
+
let realRegistry ;
|
|
185
|
+
try {
|
|
186
|
+
realRegistry = await fs.realpath(this.#registryPath);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if ((error ).code === 'ENOENT') return null;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const root of this.#allowedRoots) {
|
|
193
|
+
let realRoot = root;
|
|
194
|
+
try { realRoot = await fs.realpath(root); } catch { /* retain lexical root */ }
|
|
195
|
+
if (isWithin(this.#registryPath, root) || isWithin(realRegistry, realRoot)) {
|
|
196
|
+
throw new OperatorError('POSTGRES_REGISTRY_INSIDE_PROJECT_DENIED', 'Trusted PostgreSQL profile registry must live outside all authorized project roots.');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let raw ;
|
|
201
|
+
try {
|
|
202
|
+
raw = await readDurableStateText(this.#registryPath, POSTGRES_REGISTRY_OPTIONS);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof OperatorError) throw error;
|
|
205
|
+
throw new OperatorError('POSTGRES_REGISTRY_INVALID', 'PostgreSQL profile registry could not be read.', {
|
|
206
|
+
details: { cause: String(error) }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let parsed ;
|
|
211
|
+
try { parsed = JSON.parse(raw); } catch {
|
|
212
|
+
throw new OperatorError('POSTGRES_REGISTRY_INVALID', 'PostgreSQL profile registry is not valid JSON.');
|
|
213
|
+
}
|
|
214
|
+
return validateRegistry(parsed);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #profilesForRoot(registry , root ) {
|
|
218
|
+
if (!registry) return [];
|
|
219
|
+
const matched = [];
|
|
220
|
+
for (const profile of registry.profiles) {
|
|
221
|
+
for (const configuredRoot of profile.roots) {
|
|
222
|
+
try {
|
|
223
|
+
const canonical = await this.#scope.resolveExisting(configuredRoot);
|
|
224
|
+
if (canonical === root) { matched.push(profile); break; }
|
|
225
|
+
} catch { /* profile belongs to a root not authorized in this agent */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return matched;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#chooseProfile(profiles , profileId ) {
|
|
232
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profileId)) {
|
|
233
|
+
throw new OperatorError('POSTGRES_PROFILE_ID_REQUIRED', 'A profileId from postgres.inspect profiles is required.');
|
|
234
|
+
}
|
|
235
|
+
const profile = profiles.find((candidate) => candidate.id === profileId);
|
|
236
|
+
if (!profile) throw new OperatorError('POSTGRES_PROFILE_NOT_AUTHORIZED', 'PostgreSQL profile is not registered for this authorized project root.');
|
|
237
|
+
if (!isLocalPostgresHost(profile.host)) {
|
|
238
|
+
throw new OperatorError('POSTGRES_REMOTE_HOST_DENIED', 'This certified PostgreSQL adapter permits only loopback TCP or local Unix-socket profiles.', {
|
|
239
|
+
details: { profileId: profile.id, endpoint: endpointClass(profile.host) }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return profile;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async #query(profile , cwd , sql , variables , timeoutMs , signal ) {
|
|
246
|
+
const args = [
|
|
247
|
+
...this.#psqlArgsPrefix,
|
|
248
|
+
'-X',
|
|
249
|
+
'--csv',
|
|
250
|
+
'--quiet',
|
|
251
|
+
'--no-password',
|
|
252
|
+
'--set=ON_ERROR_STOP=1',
|
|
253
|
+
`--host=${profile.host}`,
|
|
254
|
+
`--port=${profile.port}`,
|
|
255
|
+
`--dbname=${profile.database}`,
|
|
256
|
+
`--username=${profile.user}`
|
|
257
|
+
];
|
|
258
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
259
|
+
if (value.includes('\0')) throw new OperatorError('POSTGRES_PARAMETER_INVALID', 'PostgreSQL query parameters cannot contain NUL bytes.');
|
|
260
|
+
args.push(`--set=${name}=${value}`);
|
|
261
|
+
}
|
|
262
|
+
args.push('--command', sql);
|
|
263
|
+
const output = await runPsql(this.#psqlExecutable, args, cwd, profile, timeoutMs, signal);
|
|
264
|
+
if (output.truncated) throw new OperatorError('POSTGRES_OUTPUT_TOO_LARGE', 'PostgreSQL output exceeded the bounded response size.');
|
|
265
|
+
return parseCsvObjects(output.stdout, MAX_ROWS);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateRegistry(input ) {
|
|
270
|
+
if (!input || typeof input !== 'object') throw new OperatorError('POSTGRES_REGISTRY_INVALID', 'PostgreSQL profile registry must be an object.');
|
|
271
|
+
const raw = input ;
|
|
272
|
+
if (raw.version !== 1 || !Array.isArray(raw.profiles) || raw.profiles.length > MAX_PROFILES) {
|
|
273
|
+
throw new OperatorError('POSTGRES_REGISTRY_INVALID', `PostgreSQL registry requires version=1 and at most ${MAX_PROFILES} profiles.`);
|
|
274
|
+
}
|
|
275
|
+
const seen = new Set ();
|
|
276
|
+
const profiles = raw.profiles.map((entry, index) => {
|
|
277
|
+
if (!entry || typeof entry !== 'object') throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${index} is invalid.`);
|
|
278
|
+
const value = entry ;
|
|
279
|
+
const id = String(value.id ?? '');
|
|
280
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id) || seen.has(id)) throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${index} id is invalid or duplicated.`);
|
|
281
|
+
seen.add(id);
|
|
282
|
+
const roots = Array.isArray(value.roots) ? value.roots.map(String) : [];
|
|
283
|
+
if (roots.length === 0 || roots.length > 20 || roots.some((root) => !path.isAbsolute(root))) {
|
|
284
|
+
throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${id} requires 1-20 absolute project roots.`);
|
|
285
|
+
}
|
|
286
|
+
const host = boundedText(value.host, 512, `Profile ${id} host`);
|
|
287
|
+
const port = Number(value.port ?? 5432);
|
|
288
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${id} port is invalid.`);
|
|
289
|
+
const database = boundedText(value.database, 128, `Profile ${id} database`);
|
|
290
|
+
const user = boundedText(value.user, 128, `Profile ${id} user`);
|
|
291
|
+
const passwordEnv = value.passwordEnv === undefined ? undefined : String(value.passwordEnv);
|
|
292
|
+
if (passwordEnv !== undefined && !/^[A-Z_][A-Z0-9_]{0,127}$/.test(passwordEnv)) throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${id} passwordEnv is invalid.`);
|
|
293
|
+
const sslMode = String(value.sslMode ?? 'prefer');
|
|
294
|
+
if (!['disable', 'prefer', 'require'].includes(sslMode)) throw new OperatorError('POSTGRES_REGISTRY_INVALID', `Profile ${id} sslMode is invalid.`);
|
|
295
|
+
const title = typeof value.title === 'string' ? value.title.trim().slice(0, 160) || undefined : undefined;
|
|
296
|
+
return { id, title, roots: roots.map((root) => path.resolve(root)), host, port, database, user, passwordEnv, sslMode: sslMode };
|
|
297
|
+
});
|
|
298
|
+
return { version: 1, profiles };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateColumns(input ) {
|
|
302
|
+
if (input === undefined) return [];
|
|
303
|
+
if (!Array.isArray(input) || input.length > MAX_COLUMNS) throw new OperatorError('POSTGRES_COLUMNS_INVALID', `columns must contain at most ${MAX_COLUMNS} identifiers.`);
|
|
304
|
+
const columns = input.map((value) => identifier(String(value), 'column'));
|
|
305
|
+
if (new Set(columns).size !== columns.length) throw new OperatorError('POSTGRES_COLUMNS_INVALID', 'Duplicate selected columns are not allowed.');
|
|
306
|
+
return columns;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateFilters(input ) {
|
|
310
|
+
if (input === undefined) return [];
|
|
311
|
+
if (!Array.isArray(input) || input.length > MAX_FILTERS) throw new OperatorError('POSTGRES_FILTERS_INVALID', `filters must contain at most ${MAX_FILTERS} entries.`);
|
|
312
|
+
return input.map((entry, index) => {
|
|
313
|
+
if (!entry || typeof entry !== 'object') throw new OperatorError('POSTGRES_FILTERS_INVALID', `Filter ${index} is invalid.`);
|
|
314
|
+
const raw = entry ;
|
|
315
|
+
const column = identifier(String(raw.column ?? ''), `filter ${index} column`);
|
|
316
|
+
const op = String(raw.op ?? 'eq') ;
|
|
317
|
+
if (!['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'like', 'ilike', 'is_null', 'not_null'].includes(op)) throw new OperatorError('POSTGRES_FILTERS_INVALID', `Filter ${index} operator is invalid.`);
|
|
318
|
+
if (op === 'is_null' || op === 'not_null') return { column, op };
|
|
319
|
+
const value = String(raw.value ?? '');
|
|
320
|
+
if (value.includes('\0') || value.length > 100_000) throw new OperatorError('POSTGRES_PARAMETER_INVALID', `Filter ${index} value exceeds bounds.`);
|
|
321
|
+
return { column, op, value };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function validateOrder(input ) {
|
|
326
|
+
if (input === undefined) return [];
|
|
327
|
+
if (!Array.isArray(input) || input.length > 5) throw new OperatorError('POSTGRES_ORDER_INVALID', 'orderBy must contain at most 5 entries.');
|
|
328
|
+
return input.map((entry, index) => {
|
|
329
|
+
if (!entry || typeof entry !== 'object') throw new OperatorError('POSTGRES_ORDER_INVALID', `Order ${index} is invalid.`);
|
|
330
|
+
const raw = entry ;
|
|
331
|
+
const column = identifier(String(raw.column ?? ''), `order ${index} column`);
|
|
332
|
+
const direction = String(raw.direction ?? 'asc').toLowerCase();
|
|
333
|
+
if (direction !== 'asc' && direction !== 'desc') throw new OperatorError('POSTGRES_ORDER_INVALID', `Order ${index} direction is invalid.`);
|
|
334
|
+
return { column, direction } ;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildSelect(schema , table , columns , filters , orderBy , limit , offset ) {
|
|
339
|
+
const variables = { op_schema: schema, op_table: table };
|
|
340
|
+
const selectList = columns.length === 0 ? '*' : columns.map((column, index) => {
|
|
341
|
+
const name = `op_col_${index}`;
|
|
342
|
+
variables[name] = column;
|
|
343
|
+
return `:"${name}"`;
|
|
344
|
+
}).join(', ');
|
|
345
|
+
const clauses = filters.map((filter, index) => {
|
|
346
|
+
const columnName = `op_filter_col_${index}`;
|
|
347
|
+
variables[columnName] = filter.column;
|
|
348
|
+
if (filter.op === 'is_null') return `:"${columnName}" IS NULL`;
|
|
349
|
+
if (filter.op === 'not_null') return `:"${columnName}" IS NOT NULL`;
|
|
350
|
+
const valueName = `op_filter_val_${index}`;
|
|
351
|
+
variables[valueName] = filter.value ?? '';
|
|
352
|
+
const operators = {
|
|
353
|
+
eq: '=', ne: '<>', lt: '<', lte: '<=', gt: '>', gte: '>=', like: 'LIKE', ilike: 'ILIKE'
|
|
354
|
+
};
|
|
355
|
+
return `:"${columnName}" ${operators[filter.op]} :'${valueName}'`;
|
|
356
|
+
});
|
|
357
|
+
const ordering = orderBy.map((order, index) => {
|
|
358
|
+
const name = `op_order_${index}`;
|
|
359
|
+
variables[name] = order.column;
|
|
360
|
+
return `:"${name}" ${order.direction.toUpperCase()}`;
|
|
361
|
+
});
|
|
362
|
+
let sql = `SELECT ${selectList} FROM :"op_schema".:"op_table"`;
|
|
363
|
+
if (clauses.length > 0) sql += ` WHERE ${clauses.join(' AND ')}`;
|
|
364
|
+
if (ordering.length > 0) sql += ` ORDER BY ${ordering.join(', ')}`;
|
|
365
|
+
sql += ` LIMIT ${limit} OFFSET ${offset}`;
|
|
366
|
+
return { sql, variables };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function boundedText(value , max , label ) {
|
|
370
|
+
const text = String(value ?? '');
|
|
371
|
+
if (!text || text.length > max || /[\0\r\n]/.test(text)) throw new OperatorError('POSTGRES_REGISTRY_INVALID', `${label} is invalid.`);
|
|
372
|
+
return text;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function identifier(value , label ) {
|
|
376
|
+
if (!value || value.length > 128 || /[\0\r\n]/.test(value)) throw new OperatorError('POSTGRES_IDENTIFIER_INVALID', `${label} identifier is invalid.`);
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function boundedTimeout(value ) {
|
|
381
|
+
return boundedInteger(value, 5_000, 250, 10_000);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function boundedInteger(value , fallback , min , max ) {
|
|
385
|
+
const parsed = Number(value ?? fallback);
|
|
386
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
387
|
+
return Math.min(Math.max(Math.trunc(parsed), min), max);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isLocalPostgresHost(host ) {
|
|
391
|
+
if (path.isAbsolute(host)) return true;
|
|
392
|
+
const normalized = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
393
|
+
if (normalized === 'localhost' || normalized === '::1') return true;
|
|
394
|
+
return /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function endpointClass(host ) {
|
|
398
|
+
if (path.isAbsolute(host)) return 'unix_socket';
|
|
399
|
+
return isLocalPostgresHost(host) ? 'loopback' : 'remote';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readOnlyEvidence(profile , operation ) {
|
|
403
|
+
return evidence('postgres_read_only', 'pass', 'Executed fixed PostgreSQL metadata inspection under a server-enforced read-only session.', {
|
|
404
|
+
profileId: profile.id,
|
|
405
|
+
operation,
|
|
406
|
+
endpoint: endpointClass(profile.host)
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isWithin(candidate , root ) {
|
|
411
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
412
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function psqlEnvironment(profile , timeoutMs ) {
|
|
416
|
+
const env = {
|
|
417
|
+
PGPASSFILE: os.devNull,
|
|
418
|
+
PGSSLMODE: profile.sslMode,
|
|
419
|
+
PGOPTIONS: `-c default_transaction_read_only=on -c statement_timeout=${timeoutMs} -c lock_timeout=${Math.min(timeoutMs, 2000)}`
|
|
420
|
+
};
|
|
421
|
+
for (const key of ['PATH', 'Path', 'SYSTEMROOT', 'WINDIR', 'TMP', 'TEMP']) {
|
|
422
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
423
|
+
}
|
|
424
|
+
if (profile.passwordEnv) {
|
|
425
|
+
const password = process.env[profile.passwordEnv];
|
|
426
|
+
if (password === undefined) throw new OperatorError('POSTGRES_PASSWORD_ENV_MISSING', `Password environment variable configured for profile ${profile.id} is not set.`);
|
|
427
|
+
env.PGPASSWORD = password;
|
|
428
|
+
}
|
|
429
|
+
return env;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function runPsql(executable , args , cwd , profile , timeoutMs , signal ) {
|
|
433
|
+
return await new Promise((resolve, reject) => {
|
|
434
|
+
if (signal?.aborted) {
|
|
435
|
+
reject(new OperatorError('EXECUTION_ABORTED', 'PostgreSQL execution was cancelled.', { retryable: false }));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const environment = psqlEnvironment(profile, timeoutMs);
|
|
439
|
+
const psqlExecutable = resolveTrustedExecutable(executable, environment);
|
|
440
|
+
const child = spawn(psqlExecutable, args, {
|
|
441
|
+
cwd,
|
|
442
|
+
shell: false,
|
|
443
|
+
windowsHide: true,
|
|
444
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
445
|
+
env: environment
|
|
446
|
+
});
|
|
447
|
+
const stdout = [];
|
|
448
|
+
const stderr = [];
|
|
449
|
+
let bytes = 0;
|
|
450
|
+
let truncated = false;
|
|
451
|
+
let timedOut = false;
|
|
452
|
+
let aborted = false;
|
|
453
|
+
let forceKillTimer ;
|
|
454
|
+
const capture = (bucket ) => (chunk ) => {
|
|
455
|
+
if (bytes >= MAX_OUTPUT_BYTES) { truncated = true; return; }
|
|
456
|
+
const sliced = chunk.subarray(0, MAX_OUTPUT_BYTES - bytes);
|
|
457
|
+
bucket.push(sliced);
|
|
458
|
+
bytes += sliced.byteLength;
|
|
459
|
+
if (sliced.byteLength < chunk.byteLength) truncated = true;
|
|
460
|
+
};
|
|
461
|
+
child.stdout.on('data', capture(stdout));
|
|
462
|
+
child.stderr.on('data', capture(stderr));
|
|
463
|
+
child.once('error', reject);
|
|
464
|
+
const terminate = () => {
|
|
465
|
+
try { child.kill('SIGTERM'); } catch { /* child may already be gone */ }
|
|
466
|
+
if (!forceKillTimer) {
|
|
467
|
+
forceKillTimer = setTimeout(() => {
|
|
468
|
+
try { child.kill('SIGKILL'); } catch { /* child may already be gone */ }
|
|
469
|
+
}, 1000);
|
|
470
|
+
forceKillTimer.unref();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const onAbort = () => { aborted = true; terminate(); };
|
|
474
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
475
|
+
if (signal?.aborted) onAbort();
|
|
476
|
+
const timer = setTimeout(() => {
|
|
477
|
+
timedOut = true;
|
|
478
|
+
terminate();
|
|
479
|
+
}, timeoutMs + 1500);
|
|
480
|
+
timer.unref();
|
|
481
|
+
child.once('close', (code) => {
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
484
|
+
signal?.removeEventListener('abort', onAbort);
|
|
485
|
+
if (aborted) {
|
|
486
|
+
reject(new OperatorError('EXECUTION_ABORTED', 'PostgreSQL execution was cancelled.', { retryable: false }));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (timedOut) {
|
|
490
|
+
reject(new OperatorError('POSTGRES_TIMEOUT', `PostgreSQL command exceeded ${timeoutMs}ms statement window.`, { retryable: true }));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const output = {
|
|
494
|
+
code: code ?? -1,
|
|
495
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
496
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
497
|
+
truncated
|
|
498
|
+
};
|
|
499
|
+
if (output.code !== 0) {
|
|
500
|
+
reject(new OperatorError('POSTGRES_QUERY_FAILED', output.stderr.trim().slice(0, 1600) || `psql exited with code ${output.code}.`));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
resolve(output);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function parseCsvObjects(csv , maxRows ) {
|
|
509
|
+
if (!csv.trim()) return [];
|
|
510
|
+
const records = [];
|
|
511
|
+
let row = [];
|
|
512
|
+
let field = '';
|
|
513
|
+
let quoted = false;
|
|
514
|
+
let inQuotes = false;
|
|
515
|
+
for (let i = 0; i < csv.length; i += 1) {
|
|
516
|
+
const ch = csv[i];
|
|
517
|
+
if (inQuotes) {
|
|
518
|
+
if (ch === '"') {
|
|
519
|
+
if (csv[i + 1] === '"') { field += '"'; i += 1; }
|
|
520
|
+
else inQuotes = false;
|
|
521
|
+
} else field += ch;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (ch === '"' && field.length === 0) { inQuotes = true; quoted = true; continue; }
|
|
525
|
+
if (ch === ',') { row.push({ value: field, quoted }); field = ''; quoted = false; continue; }
|
|
526
|
+
if (ch === '\n' || ch === '\r') {
|
|
527
|
+
if (ch === '\r' && csv[i + 1] === '\n') i += 1;
|
|
528
|
+
row.push({ value: field, quoted });
|
|
529
|
+
records.push(row);
|
|
530
|
+
row = [];
|
|
531
|
+
field = '';
|
|
532
|
+
quoted = false;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
field += ch;
|
|
536
|
+
}
|
|
537
|
+
if (inQuotes) throw new OperatorError('POSTGRES_CSV_INVALID', 'PostgreSQL CSV output ended inside a quoted field.');
|
|
538
|
+
if (field.length > 0 || quoted || row.length > 0) { row.push({ value: field, quoted }); records.push(row); }
|
|
539
|
+
if (records.length === 0) return [];
|
|
540
|
+
const headers = records[0].map((cell) => cell.value);
|
|
541
|
+
if (headers.some((header) => !header || header.length > 256) || new Set(headers).size !== headers.length) {
|
|
542
|
+
throw new OperatorError('POSTGRES_CSV_INVALID', 'PostgreSQL CSV headers are invalid or duplicated.');
|
|
543
|
+
}
|
|
544
|
+
const data = records.slice(1).filter((record) => !(record.length === 1 && record[0].value === ''));
|
|
545
|
+
if (data.length > maxRows) throw new OperatorError('POSTGRES_ROW_LIMIT_EXCEEDED', 'PostgreSQL returned more rows than the configured bound.');
|
|
546
|
+
return data.map((record) => {
|
|
547
|
+
if (record.length !== headers.length) throw new OperatorError('POSTGRES_CSV_INVALID', 'PostgreSQL CSV row width does not match headers.');
|
|
548
|
+
const object = {};
|
|
549
|
+
for (let index = 0; index < headers.length; index += 1) {
|
|
550
|
+
const cell = record[index];
|
|
551
|
+
object[headers[index]] = cell.value === '' && !cell.quoted ? null : cell.value;
|
|
552
|
+
}
|
|
553
|
+
return object;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function success(action , started , output , extraEvidence ) {
|
|
558
|
+
return {
|
|
559
|
+
ok: true,
|
|
560
|
+
capability: action.capability,
|
|
561
|
+
provider: 'postgres.psql.structured',
|
|
562
|
+
output,
|
|
563
|
+
evidence: extraEvidence,
|
|
564
|
+
durationMs: Math.round(performance.now() - started)
|
|
565
|
+
};
|
|
566
|
+
}
|