@blogic-cz/agent-tools 1.10.1 → 1.12.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 +21 -44
- package/dist/credential-guard/bash.d.ts +3 -0
- package/dist/credential-guard/bash.d.ts.map +1 -0
- package/dist/credential-guard/claude-hook.d.ts.map +1 -1
- package/dist/credential-guard/engine.test.d.ts +5 -0
- package/dist/credential-guard/engine.test.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts +8 -35
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/credential-guard/ir.d.ts +150 -0
- package/dist/credential-guard/ir.d.ts.map +1 -0
- package/dist/credential-guard/literal-output.d.ts +2 -0
- package/dist/credential-guard/literal-output.d.ts.map +1 -0
- package/dist/credential-guard/passive-awk.d.ts +2 -0
- package/dist/credential-guard/passive-awk.d.ts.map +1 -0
- package/dist/credential-guard/passive-jq.d.ts.map +1 -1
- package/dist/credential-guard/passive-sed.d.ts +2 -0
- package/dist/credential-guard/passive-sed.d.ts.map +1 -0
- package/dist/credential-guard/passive.test.d.ts +2 -0
- package/dist/credential-guard/passive.test.d.ts.map +1 -0
- package/dist/credential-guard/paths.d.ts +14 -0
- package/dist/credential-guard/paths.d.ts.map +1 -0
- package/dist/credential-guard/policy.d.ts +47 -0
- package/dist/credential-guard/policy.d.ts.map +1 -0
- package/dist/credential-guard/powershell.d.ts +3 -0
- package/dist/credential-guard/powershell.d.ts.map +1 -0
- package/dist/credential-guard/registry.d.ts +89 -0
- package/dist/credential-guard/registry.d.ts.map +1 -0
- package/dist/credential-guard/review4.test.d.ts +2 -0
- package/dist/credential-guard/review4.test.d.ts.map +1 -0
- package/dist/credential-guard/review7.test.d.ts +2 -0
- package/dist/credential-guard/review7.test.d.ts.map +1 -0
- package/dist/credential-guard/shell-oracle.d.ts +2 -0
- package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
- package/dist/credential-guard/xargs.d.ts +7 -0
- package/dist/credential-guard/xargs.d.ts.map +1 -0
- package/dist/db-tool/service.d.ts +2 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/db-tool/sql-client.d.ts +1 -0
- package/dist/db-tool/sql-client.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts +8 -0
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/cache.d.ts.map +1 -1
- package/dist/gh-tool/read/commands.d.ts +5 -0
- package/dist/gh-tool/read/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/reads.d.ts +38 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -1
- package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
- package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
- package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry.d.ts +59 -0
- package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
- package/dist/gh-tool/read/transport.d.ts.map +1 -1
- package/dist/observability-tool/logs.d.ts +1 -1
- package/dist/observability-tool/logs.d.ts.map +1 -1
- package/dist/observability-tool/metrics.d.ts +1 -1
- package/dist/observability-tool/metrics.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts +2 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +1 -1
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +3 -1
- package/dist/shared/content-security.d.ts.map +1 -1
- package/dist/shared/secret-env.d.ts +22 -0
- package/dist/shared/secret-env.d.ts.map +1 -0
- package/package.json +2 -1
- package/schemas/agent-tools.schema.json +1 -1
- package/src/credential-guard/bash.ts +769 -0
- package/src/credential-guard/claude-hook.ts +14 -14
- package/src/credential-guard/engine.test.ts +2050 -0
- package/src/credential-guard/index.ts +285 -4268
- package/src/credential-guard/ir.ts +139 -0
- package/src/credential-guard/literal-output.ts +8 -0
- package/src/credential-guard/passive-awk.ts +239 -0
- package/src/credential-guard/passive-jq.ts +183 -24
- package/src/credential-guard/passive-sed.ts +78 -0
- package/src/credential-guard/passive.test.ts +141 -0
- package/src/credential-guard/paths.ts +108 -0
- package/src/credential-guard/policy.ts +1576 -0
- package/src/credential-guard/powershell.ts +220 -0
- package/src/credential-guard/registry.ts +4720 -0
- package/src/credential-guard/review4.test.ts +83 -0
- package/src/credential-guard/review7.test.ts +51 -0
- package/src/credential-guard/rg-types.json +389 -0
- package/src/credential-guard/shell-oracle.ts +187 -0
- package/src/credential-guard/xargs.ts +65 -0
- package/src/db-tool/main.ts +2 -0
- package/src/db-tool/service.ts +122 -140
- package/src/db-tool/sql-client.ts +7 -4
- package/src/gh-tool/main.ts +30 -6
- package/src/gh-tool/pr/commands.ts +9 -2
- package/src/gh-tool/pr/core.ts +2 -2
- package/src/gh-tool/read/api.ts +73 -6
- package/src/gh-tool/read/cache.ts +13 -4
- package/src/gh-tool/read/commands.ts +17 -1
- package/src/gh-tool/read/reads.ts +84 -1
- package/src/gh-tool/read/telemetry-command.ts +45 -0
- package/src/gh-tool/read/telemetry-store.ts +85 -0
- package/src/gh-tool/read/telemetry.ts +191 -0
- package/src/gh-tool/read/transport.ts +10 -5
- package/src/gh-tool/workflow.ts +2 -2
- package/src/observability-tool/main.ts +7 -1
- package/src/observability-tool/shared.ts +20 -14
- package/src/shared/content-security.ts +7 -2
- package/src/shared/secret-env.ts +230 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { formatLiteralPrintf } from "./literal-output";
|
|
2
|
+
import { parseBash } from "./bash";
|
|
3
|
+
import { judge } from "./policy";
|
|
4
|
+
import { splitXargsInput, type XargsInput } from "./xargs";
|
|
5
|
+
|
|
6
|
+
const quote = (text: string): string => "'" + text.replaceAll("'", "'\\''") + "'";
|
|
7
|
+
const receipt: Record<string, { checked: number; refused: number; mismatches: unknown[] }> = {};
|
|
8
|
+
async function compare(
|
|
9
|
+
family: string,
|
|
10
|
+
shell: string,
|
|
11
|
+
source: string,
|
|
12
|
+
modeled: readonly string[] | null,
|
|
13
|
+
) {
|
|
14
|
+
const count = (receipt[family] ??= { checked: 0, refused: 0, mismatches: [] });
|
|
15
|
+
const child = Bun.spawn([shell, "-c", source], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
|
|
19
|
+
});
|
|
20
|
+
const actual = await new Response(child.stdout).text();
|
|
21
|
+
await new Response(child.stderr).text();
|
|
22
|
+
await child.exited;
|
|
23
|
+
count.checked++;
|
|
24
|
+
if (modeled === null) count.refused++;
|
|
25
|
+
else if (family === "termination") {
|
|
26
|
+
const reached = actual.split("\n").includes("[reached]");
|
|
27
|
+
if (reached && !modeled.some((output) => output.includes("[reached]")))
|
|
28
|
+
count.mismatches.push({ shell, source, actual, modeled });
|
|
29
|
+
} else if (!modeled.includes(actual)) count.mismatches.push({ shell, source, actual, modeled });
|
|
30
|
+
}
|
|
31
|
+
function modeledOutput(source: string): readonly string[] | null {
|
|
32
|
+
const outputs: string[] = [];
|
|
33
|
+
let unresolved = false;
|
|
34
|
+
const decision = judge(parseBash(source), {
|
|
35
|
+
home: "/home/dev",
|
|
36
|
+
cwd: "/tmp",
|
|
37
|
+
onInvocation(executable, argv) {
|
|
38
|
+
if (executable === "/usr/bin/printf" && argv[1] === "[%s]\\n") {
|
|
39
|
+
if (argv.some((word) => word === undefined)) unresolved = true;
|
|
40
|
+
outputs.push(
|
|
41
|
+
(argv.length > 2 ? argv.slice(2) : [""]).map((word) => `[${word}]\n`).join(""),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return decision.kind === "pass" && !unresolved
|
|
47
|
+
? outputs.length
|
|
48
|
+
? [...outputs, outputs.join("")]
|
|
49
|
+
: [""]
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
const jobs: Array<() => Promise<void>> = [];
|
|
53
|
+
const shells = ["/bin/bash", "/bin/sh", "/bin/zsh", "/bin/dash"];
|
|
54
|
+
const available = await Promise.all(
|
|
55
|
+
shells.map(async (shell) => ({ shell, exists: await Bun.file(shell).exists() })),
|
|
56
|
+
);
|
|
57
|
+
for (const { shell, exists } of available) {
|
|
58
|
+
if (!exists) {
|
|
59
|
+
console.log(`SKIP missing shell ${shell}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const braces = [
|
|
63
|
+
"{a,}",
|
|
64
|
+
"{,}",
|
|
65
|
+
"{,b}",
|
|
66
|
+
"x{,b}",
|
|
67
|
+
"{a,b}{,c}",
|
|
68
|
+
"'{a,b}'",
|
|
69
|
+
'"{a,b}"',
|
|
70
|
+
"{a,{b,c}}",
|
|
71
|
+
"{a,}''",
|
|
72
|
+
"''{,b}",
|
|
73
|
+
];
|
|
74
|
+
for (const left of ["a", "", "b"])
|
|
75
|
+
for (const right of ["", "c", "d"]) braces.push(`{${left},${right}}`, `p{${left},${right}}q`);
|
|
76
|
+
for (const words of braces) {
|
|
77
|
+
const source = `/usr/bin/printf '[%s]\\n' seed ${words}`;
|
|
78
|
+
const model =
|
|
79
|
+
shell.endsWith("/sh") || shell.endsWith("/dash")
|
|
80
|
+
? `sh -c ${quote(source)}`
|
|
81
|
+
: shell.endsWith("/bash")
|
|
82
|
+
? `bash -c ${quote(source)}`
|
|
83
|
+
: source;
|
|
84
|
+
jobs.push(() => compare("brace", shell, source, modeledOutput(model)));
|
|
85
|
+
}
|
|
86
|
+
for (const format of ["%s\\n", "%s ", "%s", "%s:%s\\n"])
|
|
87
|
+
for (const words of [[], ["alpha"], ["alpha", "beta"], ["", "beta"], ["a b", "c"]]) {
|
|
88
|
+
const source = `printf ${quote(format)} ${words.map(quote).join(" ")}`;
|
|
89
|
+
const modeled = formatLiteralPrintf([format, ...words]);
|
|
90
|
+
jobs.push(() => compare("printf", shell, source, modeled === null ? null : [modeled]));
|
|
91
|
+
}
|
|
92
|
+
for (const compound of [
|
|
93
|
+
"{ exit 0; }",
|
|
94
|
+
"(exit 0)",
|
|
95
|
+
"if true; then exit 0; fi",
|
|
96
|
+
"case x in x) exit 0;; esac",
|
|
97
|
+
"for x in a; do exit 0; done",
|
|
98
|
+
"while true; do exit 0; done",
|
|
99
|
+
"exit 0 && true",
|
|
100
|
+
"exit 0 || true",
|
|
101
|
+
"exit 0 && false || true",
|
|
102
|
+
"exit 0 || false && true",
|
|
103
|
+
"{ exit 0; } && true",
|
|
104
|
+
"! { exit 0; } || true",
|
|
105
|
+
])
|
|
106
|
+
for (const redirect of ["&>/dev/null", "&>>/dev/null"]) {
|
|
107
|
+
const source = `${compound} ${redirect}; /usr/bin/printf '[%s]\\n' reached`;
|
|
108
|
+
jobs.push(() =>
|
|
109
|
+
compare(
|
|
110
|
+
"termination",
|
|
111
|
+
shell,
|
|
112
|
+
source,
|
|
113
|
+
modeledOutput(
|
|
114
|
+
`${shell.endsWith("/bash") || shell.endsWith("/zsh") ? "bash" : "sh"} -c ${quote(source)}`,
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
for (const command of ["exit", "return"])
|
|
120
|
+
for (const redirect of [
|
|
121
|
+
"",
|
|
122
|
+
" 2>/dev/null",
|
|
123
|
+
" &>/dev/null",
|
|
124
|
+
" &>>/dev/null",
|
|
125
|
+
" </tmp/guard-oracle-missing/input",
|
|
126
|
+
" >/dev/null/x",
|
|
127
|
+
" 2>&1",
|
|
128
|
+
" 2>&99",
|
|
129
|
+
" 1>&- 2>&1",
|
|
130
|
+
])
|
|
131
|
+
for (const op of [";", "||"]) {
|
|
132
|
+
const source = `${command} 0${redirect} ${op} /usr/bin/printf '[%s]\\n' reached`;
|
|
133
|
+
jobs.push(() =>
|
|
134
|
+
compare(
|
|
135
|
+
"termination",
|
|
136
|
+
shell,
|
|
137
|
+
source,
|
|
138
|
+
modeledOutput(
|
|
139
|
+
`${shell.endsWith("/bash") || shell.endsWith("/zsh") ? "bash" : "sh"} -c ${quote(source)}`,
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const modes: XargsInput[] = [
|
|
146
|
+
{ replacement: null, maxArgs: null, maxLines: null },
|
|
147
|
+
{ replacement: "{}", maxArgs: null, maxLines: null },
|
|
148
|
+
{ replacement: null, maxArgs: 1, maxLines: null },
|
|
149
|
+
{ replacement: null, maxArgs: null, maxLines: 1 },
|
|
150
|
+
];
|
|
151
|
+
const inputs = [
|
|
152
|
+
"a\nb\n",
|
|
153
|
+
"a b\n",
|
|
154
|
+
"'' word\n",
|
|
155
|
+
'"" word\n',
|
|
156
|
+
"a b\n",
|
|
157
|
+
"a\tb\n",
|
|
158
|
+
" a\n",
|
|
159
|
+
"a\n\nb\n",
|
|
160
|
+
"'a b'\n",
|
|
161
|
+
"a\\ b\n",
|
|
162
|
+
];
|
|
163
|
+
for (const a of ["a", "''", '""', " "])
|
|
164
|
+
for (const b of [" ", " ", "\t", "\n"]) inputs.push(a + b + "b\n");
|
|
165
|
+
for (const mode of modes)
|
|
166
|
+
for (const input of inputs) {
|
|
167
|
+
const groups = splitXargsInput(input, mode);
|
|
168
|
+
const output =
|
|
169
|
+
groups === null
|
|
170
|
+
? null
|
|
171
|
+
: groups
|
|
172
|
+
.flatMap((words) => (words.length ? words : [""]).map((word) => `[${word}]\n`))
|
|
173
|
+
.join("");
|
|
174
|
+
const flags = mode.replacement ? "-I{}" : mode.maxArgs ? "-n1" : mode.maxLines ? "-L1" : "";
|
|
175
|
+
const source = `printf '%s' ${quote(input)} | /usr/bin/xargs ${flags} /usr/bin/printf '[%s]\\n' ${mode.replacement ? "'{}'" : ""}`;
|
|
176
|
+
jobs.push(() =>
|
|
177
|
+
compare(
|
|
178
|
+
`xargs-${flags || "default"}`,
|
|
179
|
+
"/bin/bash",
|
|
180
|
+
source,
|
|
181
|
+
output === null ? null : [output],
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await jobs.reduce((previous, run) => previous.then(run), Promise.resolve());
|
|
186
|
+
console.log(JSON.stringify(receipt, null, 2));
|
|
187
|
+
if (Object.values(receipt).some((family) => family.mismatches.length)) process.exitCode = 1;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type XargsInput = {
|
|
2
|
+
readonly replacement: string | null;
|
|
3
|
+
readonly maxArgs: number | null;
|
|
4
|
+
readonly maxLines: number | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function splitXargsInput(text: string, mode: XargsInput): readonly string[][] | null {
|
|
8
|
+
if (/[^\t\n\x20-\x7e]/.test(text)) return null;
|
|
9
|
+
if (mode.replacement !== null && /['"\t]| {2}|^ |\n |\\/.test(text)) return null;
|
|
10
|
+
const lines: string[][] = [];
|
|
11
|
+
let words: string[] = [];
|
|
12
|
+
let word = "";
|
|
13
|
+
let started = false;
|
|
14
|
+
let quote = "";
|
|
15
|
+
const finishWord = () => {
|
|
16
|
+
if (started) words.push(word);
|
|
17
|
+
word = "";
|
|
18
|
+
started = false;
|
|
19
|
+
};
|
|
20
|
+
for (let index = 0; index < text.length; index++) {
|
|
21
|
+
const ch = text[index] ?? "";
|
|
22
|
+
if (ch === "\n") {
|
|
23
|
+
if (
|
|
24
|
+
quote ||
|
|
25
|
+
((mode.maxLines !== null || mode.replacement !== null) &&
|
|
26
|
+
/[ \t]/.test(text[index - 1] ?? ""))
|
|
27
|
+
)
|
|
28
|
+
return null;
|
|
29
|
+
finishWord();
|
|
30
|
+
if (words.length) lines.push(words);
|
|
31
|
+
words = [];
|
|
32
|
+
} else if (ch === "\\" && !quote) {
|
|
33
|
+
const next = text[++index];
|
|
34
|
+
if (next === undefined || next === "\n") return null;
|
|
35
|
+
word += next;
|
|
36
|
+
started = true;
|
|
37
|
+
} else if ((ch === "'" || ch === '"') && (!quote || ch === quote)) {
|
|
38
|
+
quote = quote ? "" : ch;
|
|
39
|
+
started = true;
|
|
40
|
+
} else if (!quote && /[ \t]/.test(ch) && mode.replacement === null) {
|
|
41
|
+
finishWord();
|
|
42
|
+
} else if (mode.replacement !== null && !started && /[ \t]/.test(ch)) {
|
|
43
|
+
continue;
|
|
44
|
+
} else {
|
|
45
|
+
word += ch;
|
|
46
|
+
started = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (quote) return null;
|
|
50
|
+
finishWord();
|
|
51
|
+
if (words.length) lines.push(words);
|
|
52
|
+
const result: string[][] = [];
|
|
53
|
+
if (mode.replacement !== null) result.push(...lines);
|
|
54
|
+
else if (mode.maxLines !== null) {
|
|
55
|
+
for (let index = 0; index < lines.length; index += mode.maxLines)
|
|
56
|
+
result.push(lines.slice(index, index + mode.maxLines).flat());
|
|
57
|
+
} else {
|
|
58
|
+
const all = lines.flat();
|
|
59
|
+
const size = mode.maxArgs ?? Math.max(all.length, 1);
|
|
60
|
+
for (let index = 0; index < all.length; index += size)
|
|
61
|
+
result.push(all.slice(index, index + size));
|
|
62
|
+
}
|
|
63
|
+
if (result.length > 64 || result.some((args) => args.length > 64)) return null;
|
|
64
|
+
return result.length ? result : null;
|
|
65
|
+
}
|
package/src/db-tool/main.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VERSION,
|
|
15
15
|
} from "#shared";
|
|
16
16
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
17
|
+
import { SecretEnvResolver } from "#shared/secret-env";
|
|
17
18
|
import { withRedactedOutput } from "#shared/output-boundary";
|
|
18
19
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
19
20
|
import { DbConfigService, makeDbConfigLayer } from "./config-service";
|
|
@@ -248,6 +249,7 @@ const MainLayer = DbService.layer.pipe(
|
|
|
248
249
|
// provideMerge (not provide) so DbConfigService stays in the program context for the `envs` command,
|
|
249
250
|
// which reads it directly rather than going through DbService.
|
|
250
251
|
Layer.provideMerge(DbSqlClient.layer),
|
|
252
|
+
Layer.provideMerge(SecretEnvResolver.liveLayer),
|
|
251
253
|
Layer.provideMerge(dbConfigLayer),
|
|
252
254
|
Layer.provideMerge(ConfigServiceLayer),
|
|
253
255
|
Layer.provideMerge(BunServices.layer),
|
package/src/db-tool/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
2
|
-
import { Clock, Context, Duration, Effect, Layer,
|
|
2
|
+
import { Clock, Context, Duration, Effect, Layer, Result, Stream } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { DbConfig, DbMutationOperation, QueryResult, SchemaMode } from "./types";
|
|
5
5
|
|
|
@@ -7,6 +7,7 @@ import { ConfigService } from "#config";
|
|
|
7
7
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
8
8
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
9
9
|
import { registerResolvedSecret, unsafeOutboundTextReason } from "#shared/content-security";
|
|
10
|
+
import { SecretEnvResolver } from "#shared/secret-env";
|
|
10
11
|
import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
|
|
11
12
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
12
13
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
@@ -43,12 +44,6 @@ const MIN_TCP_PORT = 1;
|
|
|
43
44
|
const MAX_TCP_PORT = 65535;
|
|
44
45
|
const INTEGER_ONLY = /^\d+$/;
|
|
45
46
|
|
|
46
|
-
// Bun.env is the primary source: it carries the values Bun loads from a .env file, which is where
|
|
47
|
-
// a per-worktree host/port lives. It aliases process.env, which is also the only one that exists
|
|
48
|
-
// when this module runs outside Bun (the vitest runner).
|
|
49
|
-
const readProcessEnv = (envVar: string): string | undefined =>
|
|
50
|
-
(typeof Bun === "undefined" ? process.env : Bun.env)[envVar];
|
|
51
|
-
|
|
52
47
|
export type ResolvedPortOverride =
|
|
53
48
|
| { readonly ok: true; readonly port: number }
|
|
54
49
|
| { readonly ok: false; readonly message: string };
|
|
@@ -159,6 +154,7 @@ export class DbService extends Context.Service<
|
|
|
159
154
|
Effect.gen(function* () {
|
|
160
155
|
const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
161
156
|
const sqlClient = yield* DbSqlClient;
|
|
157
|
+
const secretEnvResolver = yield* SecretEnvResolver;
|
|
162
158
|
const agentToolsConfig = yield* ConfigService;
|
|
163
159
|
const dbConfig = yield* DbConfigService;
|
|
164
160
|
|
|
@@ -185,152 +181,138 @@ export class DbService extends Context.Service<
|
|
|
185
181
|
const apiProbeTimeoutMs = dbConfig.apiProbeTimeoutMs ?? 2000;
|
|
186
182
|
const remotePort = dbConfig.remotePort ?? 5432;
|
|
187
183
|
|
|
188
|
-
const zshrcEnvCache = yield* Ref.make<Record<string, string> | null>(null);
|
|
189
184
|
const envTemplateRegex = /^\$\{([A-Za-z0-9_]+)\}$/;
|
|
185
|
+
const lookupConfigEnv = (name: string) =>
|
|
186
|
+
Effect.tryPromise({
|
|
187
|
+
try: () => secretEnvResolver.lookupConfigEnv(name),
|
|
188
|
+
catch: () => new Error(`${name} lookup failed`),
|
|
189
|
+
});
|
|
190
190
|
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const home = process.env.HOME;
|
|
198
|
-
if (!home || home.trim() === "") {
|
|
199
|
-
yield* Ref.set(zshrcEnvCache, {});
|
|
200
|
-
return {};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const zshrcPath = `${home}/.zshrc`;
|
|
204
|
-
const content = yield* Effect.tryPromise(async () => {
|
|
205
|
-
const file = Bun.file(zshrcPath);
|
|
206
|
-
if (!(await file.exists())) {
|
|
207
|
-
return "";
|
|
191
|
+
const resolvePassword = (config: DbConfig, env: string) =>
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
if (config.password) {
|
|
194
|
+
registerResolvedSecret(config.password);
|
|
195
|
+
return config.password;
|
|
208
196
|
}
|
|
209
|
-
return await file.text();
|
|
210
|
-
}).pipe(Effect.orElseSucceed(() => ""));
|
|
211
197
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
const passwordEnvVar = config.passwordEnvVar;
|
|
199
|
+
if (passwordEnvVar) {
|
|
200
|
+
return yield* Effect.tryPromise({
|
|
201
|
+
try: () => secretEnvResolver.resolveSecretEnv(passwordEnvVar),
|
|
202
|
+
catch: () =>
|
|
203
|
+
new Error(
|
|
204
|
+
`${passwordEnvVar} is not in the environment or in the interactive zsh`,
|
|
205
|
+
),
|
|
206
|
+
}).pipe(
|
|
207
|
+
Effect.mapError(
|
|
208
|
+
() =>
|
|
209
|
+
new DbConnectionError({
|
|
210
|
+
message: `${passwordEnvVar} is not in the environment or in the interactive zsh`,
|
|
211
|
+
environment: env,
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
224
216
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
) {
|
|
229
|
-
if (config.password) {
|
|
230
|
-
registerResolvedSecret(config.password);
|
|
231
|
-
return config.password;
|
|
232
|
-
}
|
|
217
|
+
// Local databases typically don't need a password
|
|
218
|
+
return "";
|
|
219
|
+
});
|
|
233
220
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
221
|
+
const resolveConfigString = (value: string, env: string, label: string) =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const match = value.match(envTemplateRegex);
|
|
224
|
+
if (!match) return value;
|
|
225
|
+
|
|
226
|
+
const envVar = match[1];
|
|
227
|
+
const result = yield* lookupConfigEnv(envVar).pipe(
|
|
228
|
+
Effect.mapError(
|
|
229
|
+
() =>
|
|
230
|
+
new DbConnectionError({
|
|
231
|
+
message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
|
|
232
|
+
environment: env,
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
if (result._tag === "failed") {
|
|
237
|
+
return yield* new DbConnectionError({
|
|
238
|
+
message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
|
|
239
|
+
environment: env,
|
|
240
|
+
});
|
|
239
241
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return fromZsh;
|
|
242
|
+
if (result._tag === "absent") {
|
|
243
|
+
return yield* new DbConnectionError({
|
|
244
|
+
message: `${envVar} is not in the environment or in the interactive zsh (required for '${label}' config field) in environment ${env}.`,
|
|
245
|
+
environment: env,
|
|
246
|
+
});
|
|
246
247
|
}
|
|
247
|
-
|
|
248
|
-
return yield* new DbConnectionError({
|
|
249
|
-
message: `Environment variable ${config.passwordEnvVar} is not set.`,
|
|
250
|
-
environment: env,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Local databases typically don't need a password
|
|
255
|
-
return "";
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const resolveConfigString = Effect.fn("DbService.resolveConfigString")(function* (
|
|
259
|
-
value: string,
|
|
260
|
-
env: string,
|
|
261
|
-
label: string,
|
|
262
|
-
zshrcEnv: Record<string, string>,
|
|
263
|
-
) {
|
|
264
|
-
const match = value.match(envTemplateRegex);
|
|
265
|
-
if (!match) return value;
|
|
266
|
-
|
|
267
|
-
const envVar = match[1];
|
|
268
|
-
const fromEnv = Bun.env[envVar];
|
|
269
|
-
if (fromEnv !== undefined) return fromEnv;
|
|
270
|
-
|
|
271
|
-
const fromZsh = zshrcEnv[envVar];
|
|
272
|
-
if (fromZsh !== undefined) return fromZsh;
|
|
273
|
-
|
|
274
|
-
return yield* new DbConnectionError({
|
|
275
|
-
message: `Environment variable ${envVar} (required for '${label}' config field) is not set in environment ${env}.`,
|
|
276
|
-
environment: env,
|
|
248
|
+
return result.value;
|
|
277
249
|
});
|
|
278
|
-
});
|
|
279
250
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
251
|
+
const resolveDbConfig = (config: DbConfig, env: string) =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const resolveOverride = (name: string | undefined) => {
|
|
254
|
+
if (!name) return Effect.succeed(undefined);
|
|
255
|
+
return lookupConfigEnv(name).pipe(
|
|
256
|
+
Effect.flatMap((result) => {
|
|
257
|
+
if (result._tag === "absent") return Effect.succeed(undefined);
|
|
258
|
+
if (result._tag === "failed") {
|
|
259
|
+
return Effect.fail(
|
|
260
|
+
new DbConnectionError({
|
|
261
|
+
message: `${name} lookup failed in environment ${env}.`,
|
|
262
|
+
environment: env,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return Effect.succeed(result.value);
|
|
267
|
+
}),
|
|
268
|
+
Effect.mapError(
|
|
269
|
+
() =>
|
|
270
|
+
new DbConnectionError({
|
|
271
|
+
message: `${name} lookup failed in environment ${env}.`,
|
|
272
|
+
environment: env,
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
const hostValue = yield* resolveOverride(config.hostEnvVar);
|
|
278
|
+
const portValue = yield* resolveOverride(config.portEnvVar);
|
|
279
|
+
|
|
280
|
+
const host = resolveHostOverride(config.host, config.hostEnvVar, hostValue);
|
|
281
|
+
const portOverride = resolvePortOverride(
|
|
282
|
+
env,
|
|
283
|
+
config.port,
|
|
284
|
+
config.portEnvVar,
|
|
285
|
+
portValue,
|
|
286
|
+
);
|
|
305
287
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
288
|
+
if (!portOverride.ok) {
|
|
289
|
+
return yield* new DbConnectionError({
|
|
290
|
+
message: portOverride.message,
|
|
291
|
+
environment: env,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
312
294
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
295
|
+
// A host override can flip the tunnel decision (a forwarded localhost port vs a directly
|
|
296
|
+
// reachable host), so re-derive the access mode whenever the override changed the host.
|
|
297
|
+
const needsTunnel =
|
|
298
|
+
host === config.host
|
|
299
|
+
? config.needsTunnel
|
|
300
|
+
: resolveDbAccessMode(
|
|
301
|
+
env,
|
|
302
|
+
host,
|
|
303
|
+
dbConfig.kubectl !== undefined,
|
|
304
|
+
config.allowedMutations,
|
|
305
|
+
).needsTunnel;
|
|
324
306
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
307
|
+
return {
|
|
308
|
+
...config,
|
|
309
|
+
host,
|
|
310
|
+
port: portOverride.port,
|
|
311
|
+
needsTunnel,
|
|
312
|
+
user: yield* resolveConfigString(config.user, env, "user"),
|
|
313
|
+
database: yield* resolveConfigString(config.database, env, "database"),
|
|
314
|
+
};
|
|
315
|
+
});
|
|
334
316
|
|
|
335
317
|
const executeShellCommand = (command: ChildProcess.Command) =>
|
|
336
318
|
Effect.scoped(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
2
|
|
|
3
3
|
import { DbQueryError } from "./errors";
|
|
4
|
+
import { redactSensitiveText } from "#shared/content-security";
|
|
4
5
|
|
|
5
6
|
export type DbConnection = {
|
|
6
7
|
readonly host: string;
|
|
@@ -22,6 +23,11 @@ type BunQueryResult = unknown[] & { count?: number; command?: string };
|
|
|
22
23
|
export const toErrorMessage = (cause: unknown): string =>
|
|
23
24
|
cause instanceof Error ? cause.message : String(cause);
|
|
24
25
|
|
|
26
|
+
export const toDbQueryError = (cause: unknown, sql: string): DbQueryError => {
|
|
27
|
+
const message = redactSensitiveText(toErrorMessage(cause));
|
|
28
|
+
return new DbQueryError({ message, sql, stderr: message });
|
|
29
|
+
};
|
|
30
|
+
|
|
25
31
|
const runWithBunSql = (connection: DbConnection, sql: string) =>
|
|
26
32
|
Effect.tryPromise({
|
|
27
33
|
try: async (): Promise<DbQueryOutcome> => {
|
|
@@ -61,10 +67,7 @@ const runWithBunSql = (connection: DbConnection, sql: string) =>
|
|
|
61
67
|
await client.close().catch(() => undefined);
|
|
62
68
|
}
|
|
63
69
|
},
|
|
64
|
-
catch: (cause) =>
|
|
65
|
-
const message = toErrorMessage(cause);
|
|
66
|
-
return new DbQueryError({ message, sql, stderr: message });
|
|
67
|
-
},
|
|
70
|
+
catch: (cause) => toDbQueryError(cause, sql),
|
|
68
71
|
});
|
|
69
72
|
|
|
70
73
|
export class DbSqlClient extends Context.Service<
|