@blogic-cz/agent-tools 1.5.7 → 1.5.9
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 -8
- package/dist/az-tool/service.d.ts.map +1 -1
- package/dist/azdo-tool/service.d.ts.map +1 -1
- package/dist/credential-guard/index.d.ts +2 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/gist.d.ts +13 -0
- package/dist/gh-tool/gist.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/release.d.ts +46 -1
- package/dist/gh-tool/release.d.ts.map +1 -1
- package/dist/gh-tool/text-input.d.ts +5 -3
- package/dist/gh-tool/text-input.d.ts.map +1 -1
- package/dist/k8s-tool/service.d.ts.map +1 -1
- package/dist/logs-tool/main.d.ts +1 -2
- package/dist/logs-tool/main.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/session-tool/main.d.ts +1 -2
- package/dist/session-tool/main.d.ts.map +1 -1
- package/dist/shared/audit.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +6 -0
- package/dist/shared/content-security.d.ts.map +1 -0
- package/dist/shared/credential-patterns.d.ts +11 -0
- package/dist/shared/credential-patterns.d.ts.map +1 -0
- package/dist/shared/effect-version.d.ts.map +1 -1
- package/dist/shared/error-renderer.d.ts.map +1 -1
- package/dist/shared/format.d.ts.map +1 -1
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/output-boundary.d.ts +3 -0
- package/dist/shared/output-boundary.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/audit-tool/main.ts +3 -1
- package/src/az-tool/main.ts +3 -1
- package/src/az-tool/service.ts +9 -0
- package/src/azdo-tool/main.ts +3 -1
- package/src/azdo-tool/service.ts +17 -0
- package/src/credential-guard/claude-hook.ts +2 -1
- package/src/credential-guard/index.ts +936 -87
- package/src/db-tool/main.ts +3 -1
- package/src/db-tool/service.ts +25 -2
- package/src/gh-tool/gist.ts +63 -64
- package/src/gh-tool/main.ts +3 -1
- package/src/gh-tool/pr/core.ts +10 -3
- package/src/gh-tool/pr/review.ts +199 -12
- package/src/gh-tool/release.ts +104 -29
- package/src/gh-tool/text-input.ts +19 -38
- package/src/k8s-tool/main.ts +3 -1
- package/src/k8s-tool/service.ts +25 -0
- package/src/logs-tool/main.ts +7 -2
- package/src/observability-tool/main.ts +5 -7
- package/src/observability-tool/shared.ts +41 -1
- package/src/session-tool/main.ts +18 -3
- package/src/shared/audit.ts +11 -10
- package/src/shared/content-security.ts +91 -0
- package/src/shared/credential-patterns.ts +62 -0
- package/src/shared/effect-version.ts +2 -1
- package/src/shared/error-renderer.ts +15 -2
- package/src/shared/format.ts +33 -6
- package/src/shared/index.ts +5 -0
- package/src/shared/output-boundary.ts +29 -0
package/src/gh-tool/release.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { Command, Flag } from "effect/unstable/cli";
|
|
|
2
2
|
import { Effect, Option } from "effect";
|
|
3
3
|
|
|
4
4
|
import { formatOption, logFormatted } from "#shared";
|
|
5
|
-
import { resolveOptionalTextInput } from "#gh/text-input";
|
|
5
|
+
import { resolveOptionalTextInput, validateOutboundText } from "#gh/text-input";
|
|
6
|
+
import { GitHubCommandError } from "#gh/errors";
|
|
6
7
|
import { GitHubService } from "./service";
|
|
7
8
|
|
|
8
9
|
type ReleaseListItem = {
|
|
@@ -129,11 +130,36 @@ const viewRelease = Effect.fn("release.viewRelease")(function* (opts: {
|
|
|
129
130
|
return yield* gh.runGhJson<ReleaseDetail>(args);
|
|
130
131
|
});
|
|
131
132
|
|
|
132
|
-
const
|
|
133
|
+
export const resolveCreateNotes = (opts: {
|
|
134
|
+
body: string | null;
|
|
135
|
+
bodyFile: string | null;
|
|
136
|
+
notesFile: string | null;
|
|
137
|
+
}) => {
|
|
138
|
+
if (opts.bodyFile !== null && opts.notesFile !== null) {
|
|
139
|
+
return Effect.fail(
|
|
140
|
+
new GitHubCommandError({
|
|
141
|
+
command: "gh-tool release create",
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
message: "Provide only one of --body-file or --notes-file",
|
|
144
|
+
stderr: "Provide only one of --body-file or --notes-file",
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return resolveOptionalTextInput({
|
|
150
|
+
command: "gh-tool release create",
|
|
151
|
+
value: opts.body,
|
|
152
|
+
fileValue: opts.bodyFile ?? opts.notesFile,
|
|
153
|
+
valueFlag: "--body",
|
|
154
|
+
fileFlag: opts.notesFile === null ? "--body-file" : "--notes-file",
|
|
155
|
+
label: "body",
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const createRelease = Effect.fn("release.createRelease")(function* (opts: {
|
|
133
160
|
tag: string;
|
|
134
161
|
title: string | null;
|
|
135
162
|
body: string | null;
|
|
136
|
-
notesFile: string | null;
|
|
137
163
|
draft: boolean;
|
|
138
164
|
prerelease: boolean;
|
|
139
165
|
generateNotes: boolean;
|
|
@@ -145,18 +171,59 @@ const createRelease = Effect.fn("release.createRelease")(function* (opts: {
|
|
|
145
171
|
}) {
|
|
146
172
|
const gh = yield* GitHubService;
|
|
147
173
|
|
|
148
|
-
const args = ["release", "create", opts.tag];
|
|
149
|
-
|
|
150
174
|
if (opts.title !== null) {
|
|
151
|
-
|
|
175
|
+
yield* validateOutboundText(opts.title, "gh-tool release create");
|
|
152
176
|
}
|
|
153
177
|
|
|
154
|
-
|
|
155
|
-
|
|
178
|
+
let body = opts.body;
|
|
179
|
+
let title = opts.title;
|
|
180
|
+
if (opts.generateNotes) {
|
|
181
|
+
const generated = yield* gh.withRepoTarget(
|
|
182
|
+
opts.repo,
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const repo = yield* gh.getRepoInfo();
|
|
185
|
+
const args = [
|
|
186
|
+
"api",
|
|
187
|
+
"--hostname",
|
|
188
|
+
new URL(repo.url).hostname,
|
|
189
|
+
"--method",
|
|
190
|
+
"POST",
|
|
191
|
+
`repos/${repo.owner}/${repo.name}/releases/generate-notes`,
|
|
192
|
+
"-f",
|
|
193
|
+
`tag_name=${opts.tag}`,
|
|
194
|
+
];
|
|
195
|
+
if (opts.target !== null) args.push("-f", `target_commitish=${opts.target}`);
|
|
196
|
+
if (opts.notesStartTag !== null) args.push("-f", `previous_tag_name=${opts.notesStartTag}`);
|
|
197
|
+
return yield* gh.runGhJson<{ name: string; body: string }>(args);
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
if (typeof generated?.body !== "string" || typeof generated.name !== "string") {
|
|
201
|
+
return yield* Effect.fail(
|
|
202
|
+
new GitHubCommandError({
|
|
203
|
+
command: "gh-tool release create",
|
|
204
|
+
exitCode: 1,
|
|
205
|
+
message: "Could not inspect generated release notes; refusing to create release",
|
|
206
|
+
stderr: "GitHub returned incomplete generated release notes",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
yield* validateOutboundText(generated.body, "gh-tool release create");
|
|
211
|
+
if (title === null || title === "") {
|
|
212
|
+
yield* validateOutboundText(generated.name, "gh-tool release create");
|
|
213
|
+
title = generated.name;
|
|
214
|
+
}
|
|
215
|
+
body = opts.body ? `${opts.body}\n${generated.body}` : generated.body;
|
|
216
|
+
yield* validateOutboundText(body, "gh-tool release create");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const args = ["release", "create", opts.tag];
|
|
220
|
+
|
|
221
|
+
if (title !== null) {
|
|
222
|
+
args.push("--title", title);
|
|
156
223
|
}
|
|
157
224
|
|
|
158
|
-
if (
|
|
159
|
-
args.push("--notes
|
|
225
|
+
if (body !== null) {
|
|
226
|
+
args.push("--notes", body);
|
|
160
227
|
}
|
|
161
228
|
|
|
162
229
|
if (opts.draft) {
|
|
@@ -167,14 +234,6 @@ const createRelease = Effect.fn("release.createRelease")(function* (opts: {
|
|
|
167
234
|
args.push("--prerelease");
|
|
168
235
|
}
|
|
169
236
|
|
|
170
|
-
if (opts.generateNotes) {
|
|
171
|
-
args.push("--generate-notes");
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (opts.notesStartTag !== null) {
|
|
175
|
-
args.push("--notes-start-tag", opts.notesStartTag);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
237
|
if (opts.target !== null) {
|
|
179
238
|
args.push("--target", opts.target);
|
|
180
239
|
}
|
|
@@ -203,7 +262,7 @@ const createRelease = Effect.fn("release.createRelease")(function* (opts: {
|
|
|
203
262
|
const created: ReleaseCreateResult = {
|
|
204
263
|
created: true,
|
|
205
264
|
tagName: opts.tag,
|
|
206
|
-
name:
|
|
265
|
+
name: title ?? opts.tag,
|
|
207
266
|
url,
|
|
208
267
|
isDraft: opts.draft,
|
|
209
268
|
isPrerelease: opts.prerelease,
|
|
@@ -212,7 +271,7 @@ const createRelease = Effect.fn("release.createRelease")(function* (opts: {
|
|
|
212
271
|
return created;
|
|
213
272
|
});
|
|
214
273
|
|
|
215
|
-
const editRelease = Effect.fn("release.editRelease")(function* (opts: {
|
|
274
|
+
export const editRelease = Effect.fn("release.editRelease")(function* (opts: {
|
|
216
275
|
tag: string;
|
|
217
276
|
title: string | null;
|
|
218
277
|
body: string | null;
|
|
@@ -223,6 +282,26 @@ const editRelease = Effect.fn("release.editRelease")(function* (opts: {
|
|
|
223
282
|
}) {
|
|
224
283
|
const gh = yield* GitHubService;
|
|
225
284
|
|
|
285
|
+
if (opts.title !== null) {
|
|
286
|
+
yield* validateOutboundText(opts.title, "gh-tool release edit");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (opts.draft === false) {
|
|
290
|
+
const current = yield* viewRelease({ tag: opts.tag, repo: opts.repo });
|
|
291
|
+
if (typeof current.name !== "string" || typeof current.body !== "string") {
|
|
292
|
+
return yield* Effect.fail(
|
|
293
|
+
new GitHubCommandError({
|
|
294
|
+
command: "gh-tool release edit",
|
|
295
|
+
exitCode: 1,
|
|
296
|
+
message: "Could not inspect release content; refusing to publish",
|
|
297
|
+
stderr: "GitHub returned incomplete release content",
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
yield* validateOutboundText(opts.title ?? current.name, "gh-tool release edit");
|
|
302
|
+
yield* validateOutboundText(opts.body ?? current.body, "gh-tool release edit");
|
|
303
|
+
}
|
|
304
|
+
|
|
226
305
|
const args = ["release", "edit", opts.tag];
|
|
227
306
|
|
|
228
307
|
if (opts.title !== null) {
|
|
@@ -370,7 +449,7 @@ export const releaseCreateCommand = Command.make(
|
|
|
370
449
|
Flag.optional,
|
|
371
450
|
),
|
|
372
451
|
notesFile: Flag.String("notes-file").pipe(
|
|
373
|
-
Flag.withDescription("
|
|
452
|
+
Flag.withDescription("Read release notes from a file path or '-' for stdin"),
|
|
374
453
|
Flag.optional,
|
|
375
454
|
),
|
|
376
455
|
notesStartTag: Flag.String("notes-start-tag").pipe(
|
|
@@ -416,20 +495,16 @@ export const releaseCreateCommand = Command.make(
|
|
|
416
495
|
verifyTag,
|
|
417
496
|
}) =>
|
|
418
497
|
Effect.gen(function* () {
|
|
419
|
-
const resolvedBody = yield*
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
valueFlag: "--body",
|
|
424
|
-
fileFlag: "--body-file",
|
|
425
|
-
label: "body",
|
|
498
|
+
const resolvedBody = yield* resolveCreateNotes({
|
|
499
|
+
body: Option.getOrNull(body),
|
|
500
|
+
bodyFile: Option.getOrNull(bodyFile),
|
|
501
|
+
notesFile: Option.getOrNull(notesFile),
|
|
426
502
|
});
|
|
427
503
|
|
|
428
504
|
const result = yield* createRelease({
|
|
429
505
|
tag,
|
|
430
506
|
title: Option.getOrNull(title),
|
|
431
507
|
body: resolvedBody,
|
|
432
|
-
notesFile: Option.getOrNull(notesFile),
|
|
433
508
|
draft,
|
|
434
509
|
prerelease,
|
|
435
510
|
generateNotes,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
import { GitHubCommandError } from "#gh/errors";
|
|
4
|
-
import {
|
|
5
|
+
import { unsafeOutboundTextReason } from "#shared/content-security";
|
|
6
|
+
|
|
7
|
+
export { unsafeOutboundTextReason } from "#shared/content-security";
|
|
5
8
|
|
|
6
9
|
const STDIN_SENTINEL = "-";
|
|
7
10
|
const SENSITIVE_PATH_PATTERNS = [
|
|
@@ -17,33 +20,6 @@ const readTextFromStdin = () => Bun.stdin.text();
|
|
|
17
20
|
export const isSensitivePath = (filePath: string) =>
|
|
18
21
|
SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
19
22
|
|
|
20
|
-
/** Inspect the final text, after shell expansion and after reading files or stdin. */
|
|
21
|
-
export function unsafeOutboundTextReason(
|
|
22
|
-
text: string,
|
|
23
|
-
environment: Record<string, string | undefined> = process.env,
|
|
24
|
-
): string | null {
|
|
25
|
-
if (detectSecrets(text)) return "a credential pattern";
|
|
26
|
-
|
|
27
|
-
const sensitiveName =
|
|
28
|
-
/(?:KEY|TOKEN|SECRET|PASS(?:WORD)?|PWD|CREDENTIAL|AUTH|COOKIE|SESSION|PSK)/i;
|
|
29
|
-
const isSensitiveName = (name: string) =>
|
|
30
|
-
name !== "PWD" && name !== "OLDPWD" && sensitiveName.test(name);
|
|
31
|
-
for (const [name, value] of Object.entries(environment)) {
|
|
32
|
-
if (isSensitiveName(name) && value && value.length >= 8 && text.includes(value)) {
|
|
33
|
-
return "a credential from the process environment";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const assignments = text.match(/^[A-Za-z_][A-Za-z0-9_]*=.*$/gm) ?? [];
|
|
38
|
-
if (
|
|
39
|
-
assignments.length >= 5 &&
|
|
40
|
-
assignments.some((line) => isSensitiveName(line.split("=", 1)[0] ?? ""))
|
|
41
|
-
) {
|
|
42
|
-
return "an environment dump";
|
|
43
|
-
}
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
23
|
export const validateOutboundText = (text: string, command: string) => {
|
|
48
24
|
const reason = unsafeOutboundTextReason(text);
|
|
49
25
|
return reason === null
|
|
@@ -58,9 +34,9 @@ export const validateOutboundText = (text: string, command: string) => {
|
|
|
58
34
|
);
|
|
59
35
|
};
|
|
60
36
|
|
|
61
|
-
export const
|
|
37
|
+
export const readValidatedOutboundFile = (filePath: string, command: string) =>
|
|
62
38
|
Effect.tryPromise({
|
|
63
|
-
try: () =>
|
|
39
|
+
try: () => readAllowedFile(filePath),
|
|
64
40
|
catch: () =>
|
|
65
41
|
new GitHubCommandError({
|
|
66
42
|
command,
|
|
@@ -68,17 +44,22 @@ export const validateOutboundFile = (filePath: string, command: string) =>
|
|
|
68
44
|
stderr: `Refusing to publish unreadable or sensitive file: ${filePath}`,
|
|
69
45
|
message: `Refusing to publish unreadable or sensitive file: ${filePath}`,
|
|
70
46
|
}),
|
|
71
|
-
}).pipe(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
);
|
|
47
|
+
}).pipe(Effect.tap(({ text }) => validateOutboundText(text, command)));
|
|
48
|
+
|
|
49
|
+
export const validateOutboundFile = (filePath: string, command: string) =>
|
|
50
|
+
readValidatedOutboundFile(filePath, command).pipe(Effect.asVoid);
|
|
75
51
|
|
|
76
52
|
const readTextFile = (filePath: string) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
53
|
+
return readAllowedFile(filePath).then(({ text }) => text);
|
|
54
|
+
};
|
|
80
55
|
|
|
81
|
-
|
|
56
|
+
const readAllowedFile = async (filePath: string) => {
|
|
57
|
+
if (isSensitivePath(filePath)) throw new Error(`Refusing to read sensitive file: ${filePath}`);
|
|
58
|
+
const resolvedPath = await realpath(filePath);
|
|
59
|
+
if (isSensitivePath(resolvedPath))
|
|
60
|
+
throw new Error(`Refusing to read sensitive file: ${resolvedPath}`);
|
|
61
|
+
const bytes = await Bun.file(resolvedPath).bytes();
|
|
62
|
+
return { bytes, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
82
63
|
};
|
|
83
64
|
|
|
84
65
|
const ensureResolvedText = (resolvedValue: string | null, context: string) => {
|
package/src/k8s-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 { withRedactedOutput } from "#shared/output-boundary";
|
|
17
18
|
import { K8sService, K8sServiceLayer } from "./service";
|
|
18
19
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
|
|
19
20
|
import type { K8sConfig } from "#config";
|
|
@@ -471,6 +472,7 @@ const mainCommand = Command.make("k8s-tool", {}).pipe(
|
|
|
471
472
|
|
|
472
473
|
const cli = Command.run(mainCommand, {
|
|
473
474
|
version: VERSION,
|
|
475
|
+
renderErrors: false,
|
|
474
476
|
});
|
|
475
477
|
|
|
476
478
|
const MainLayer = K8sServiceLayer.pipe(
|
|
@@ -479,7 +481,7 @@ const MainLayer = K8sServiceLayer.pipe(
|
|
|
479
481
|
Layer.provideMerge(AuditServiceLayer),
|
|
480
482
|
);
|
|
481
483
|
|
|
482
|
-
const program = withAudit("k8s", cli).pipe(
|
|
484
|
+
const program = withRedactedOutput(withAudit("k8s", cli)).pipe(
|
|
483
485
|
Effect.provide(MainLayer),
|
|
484
486
|
Effect.tapCause(renderCauseToStderr),
|
|
485
487
|
);
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
|
22
22
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
23
23
|
import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
|
|
24
24
|
import { parseKubeConfigView, selectKubeContext } from "./context";
|
|
25
|
+
import { unsafeOutboundTextReason } from "#shared/content-security";
|
|
25
26
|
|
|
26
27
|
export class K8sService extends Context.Service<
|
|
27
28
|
K8sService,
|
|
@@ -248,6 +249,14 @@ export class K8sService extends Context.Service<
|
|
|
248
249
|
argv: readonly string[],
|
|
249
250
|
profile?: string,
|
|
250
251
|
) {
|
|
252
|
+
const unsafeReason = unsafeOutboundTextReason(argv.join(" "));
|
|
253
|
+
if (unsafeReason !== null) {
|
|
254
|
+
return yield* new K8sDangerousCommandError({
|
|
255
|
+
message: `Refusing to send command containing ${unsafeReason}.`,
|
|
256
|
+
command: "[redacted]",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
252
261
|
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
253
262
|
const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
|
|
@@ -338,6 +347,14 @@ export class K8sService extends Context.Service<
|
|
|
338
347
|
_env: Environment,
|
|
339
348
|
profile?: string,
|
|
340
349
|
) {
|
|
350
|
+
const unsafeReason = unsafeOutboundTextReason(cmd);
|
|
351
|
+
if (unsafeReason !== null) {
|
|
352
|
+
return yield* new K8sDangerousCommandError({
|
|
353
|
+
message: `Refusing to send command containing ${unsafeReason}.`,
|
|
354
|
+
command: "[redacted]",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
341
358
|
// Security: block dangerous commands before execution
|
|
342
359
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
343
360
|
if (!securityCheck.allowed || !securityCheck.argv) {
|
|
@@ -369,6 +386,14 @@ export class K8sService extends Context.Service<
|
|
|
369
386
|
dryRun: boolean,
|
|
370
387
|
profile?: string,
|
|
371
388
|
) {
|
|
389
|
+
const unsafeReason = unsafeOutboundTextReason(cmd);
|
|
390
|
+
if (unsafeReason !== null) {
|
|
391
|
+
return yield* new K8sDangerousCommandError({
|
|
392
|
+
message: `Refusing to send command containing ${unsafeReason}.`,
|
|
393
|
+
command: "[redacted]",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
372
397
|
// Security: block dangerous commands before execution (even dry-run)
|
|
373
398
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
374
399
|
if (!securityCheck.allowed || !securityCheck.argv) {
|
package/src/logs-tool/main.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
VERSION,
|
|
30
30
|
} from "#shared";
|
|
31
31
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
32
|
+
import { withRedactedOutput } from "#shared/output-boundary";
|
|
32
33
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
33
34
|
import { LogsConfigError, LogsNotFoundError, LogsReadError, LogsTimeoutError } from "./errors";
|
|
34
35
|
import { LogsService, LogsServiceLayer } from "./service";
|
|
@@ -227,11 +228,15 @@ const mainCommand = Command.make("logs-tool", {}).pipe(
|
|
|
227
228
|
|
|
228
229
|
const cli = Command.run(mainCommand, {
|
|
229
230
|
version: VERSION,
|
|
231
|
+
renderErrors: false,
|
|
230
232
|
});
|
|
231
233
|
|
|
232
|
-
|
|
234
|
+
const runWithErrorsDisabled = Command.runWith(mainCommand, {
|
|
233
235
|
version: VERSION,
|
|
236
|
+
renderErrors: false,
|
|
234
237
|
});
|
|
238
|
+
export const run = (args: ReadonlyArray<string>) =>
|
|
239
|
+
withRedactedOutput(runWithErrorsDisabled(args)).pipe(Effect.tapCause(renderCauseToStderr));
|
|
235
240
|
|
|
236
241
|
const MainLayer = LogsServiceLayer.pipe(
|
|
237
242
|
Layer.provideMerge(ConfigServiceLayer),
|
|
@@ -239,7 +244,7 @@ const MainLayer = LogsServiceLayer.pipe(
|
|
|
239
244
|
Layer.provideMerge(AuditServiceLayer),
|
|
240
245
|
);
|
|
241
246
|
|
|
242
|
-
const program = withAudit("logs", cli).pipe(
|
|
247
|
+
const program = withRedactedOutput(withAudit("logs", cli)).pipe(
|
|
243
248
|
Effect.provide(MainLayer),
|
|
244
249
|
Effect.tapCause(renderCauseToStderr),
|
|
245
250
|
);
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
4
|
-
import {
|
|
5
|
-
import type { Cause } from "effect";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
6
5
|
import { Command } from "effect/unstable/cli";
|
|
7
6
|
|
|
8
7
|
import { ConfigServiceLayer } from "#config";
|
|
9
8
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
10
|
-
import {
|
|
9
|
+
import { withRedactedOutput } from "#shared/output-boundary";
|
|
10
|
+
import { makeSchemaCommand, renderCauseToStderr, VERSION } from "#shared";
|
|
11
11
|
|
|
12
12
|
import { metricsCommand } from "./metrics";
|
|
13
13
|
import { logsCommand } from "./logs";
|
|
14
14
|
import { traceCommand } from "./trace";
|
|
15
15
|
|
|
16
|
-
const renderCauseToStderr = (cause: Cause.Cause<unknown>) => Console.error(cause.toString());
|
|
17
|
-
|
|
18
16
|
const commandsCommand = makeSchemaCommand(() => mainCommand);
|
|
19
17
|
|
|
20
18
|
const mainCommand = Command.make("observability-tool", {}).pipe(
|
|
@@ -24,11 +22,11 @@ const mainCommand = Command.make("observability-tool", {}).pipe(
|
|
|
24
22
|
Command.withSubcommands([traceCommand, metricsCommand, logsCommand, commandsCommand]),
|
|
25
23
|
);
|
|
26
24
|
|
|
27
|
-
const cli = Command.run(mainCommand, { version: VERSION });
|
|
25
|
+
const cli = Command.run(mainCommand, { version: VERSION, renderErrors: false });
|
|
28
26
|
|
|
29
27
|
const MainLayer = Layer.mergeAll(BunServices.layer, ConfigServiceLayer, AuditServiceLayer);
|
|
30
28
|
|
|
31
|
-
const program = withAudit("observability", cli).pipe(
|
|
29
|
+
const program = withRedactedOutput(withAudit("observability", cli)).pipe(
|
|
32
30
|
Effect.provide(MainLayer),
|
|
33
31
|
Effect.tapCause(renderCauseToStderr),
|
|
34
32
|
);
|
|
@@ -5,6 +5,7 @@ import { ConfigService, getToolConfig } from "#config";
|
|
|
5
5
|
import type { ObservabilityConfig } from "#config";
|
|
6
6
|
|
|
7
7
|
import { ObservabilityToolError } from "./errors";
|
|
8
|
+
import { unsafeOutboundTextReason } from "#shared/content-security";
|
|
8
9
|
import type {
|
|
9
10
|
DsQueryOpts,
|
|
10
11
|
DsQueryResponse,
|
|
@@ -200,6 +201,45 @@ export function observabilityFetch<T>(
|
|
|
200
201
|
path: string,
|
|
201
202
|
init?: RequestInit,
|
|
202
203
|
): Effect.Effect<T, ObservabilityToolError> {
|
|
204
|
+
const rawBody = typeof init?.body === "string" ? init.body : "";
|
|
205
|
+
const url = `${config.url}${path}`;
|
|
206
|
+
let decodedUrl: string;
|
|
207
|
+
try {
|
|
208
|
+
decodedUrl = decodeURIComponent(url);
|
|
209
|
+
} catch {
|
|
210
|
+
return Effect.fail(
|
|
211
|
+
new ObservabilityToolError({
|
|
212
|
+
cause: new Error("Refusing malformed observability URL encoding."),
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const bodyText = [rawBody];
|
|
217
|
+
const appendJsonText = (value: unknown): void => {
|
|
218
|
+
if (typeof value === "string") {
|
|
219
|
+
bodyText.push(value);
|
|
220
|
+
} else if (Array.isArray(value)) {
|
|
221
|
+
value.forEach(appendJsonText);
|
|
222
|
+
} else if (typeof value === "object" && value !== null) {
|
|
223
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
224
|
+
bodyText.push(key);
|
|
225
|
+
appendJsonText(nested);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
appendJsonText(JSON.parse(rawBody) as unknown);
|
|
231
|
+
} catch {
|
|
232
|
+
// Non-JSON request bodies are still checked as supplied above.
|
|
233
|
+
}
|
|
234
|
+
const unsafeReason = unsafeOutboundTextReason([url, decodedUrl, ...bodyText].join("\n"));
|
|
235
|
+
if (unsafeReason !== null) {
|
|
236
|
+
return Effect.fail(
|
|
237
|
+
new ObservabilityToolError({
|
|
238
|
+
cause: new Error(`Refusing to send observability request containing ${unsafeReason}.`),
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
203
243
|
return Effect.tryPromise({
|
|
204
244
|
try: async () => {
|
|
205
245
|
const headers = buildHeaders(config.token);
|
|
@@ -210,7 +250,7 @@ export function observabilityFetch<T>(
|
|
|
210
250
|
});
|
|
211
251
|
}
|
|
212
252
|
|
|
213
|
-
const response = await fetch(
|
|
253
|
+
const response = await fetch(url, {
|
|
214
254
|
...init,
|
|
215
255
|
headers,
|
|
216
256
|
});
|
package/src/session-tool/main.ts
CHANGED
|
@@ -15,8 +15,16 @@ import type { MessageSummary, SessionResult, SessionSource } from "./types";
|
|
|
15
15
|
|
|
16
16
|
import { ALL_SESSION_SOURCES } from "./types";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
makeSchemaCommand,
|
|
20
|
+
formatOption,
|
|
21
|
+
formatOutput,
|
|
22
|
+
logText,
|
|
23
|
+
renderCauseToStderr,
|
|
24
|
+
VERSION,
|
|
25
|
+
} from "#shared";
|
|
19
26
|
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
27
|
+
import { withRedactedOutput } from "#shared/output-boundary";
|
|
20
28
|
import { ResolvedPaths, ResolvedPathsLayer } from "./config";
|
|
21
29
|
import { SessionStorageNotFoundError } from "./errors";
|
|
22
30
|
import { formatDate, SessionService, SessionServiceLayer } from "./service";
|
|
@@ -321,18 +329,25 @@ const mainCommand = Command.make("session-tool", {}).pipe(
|
|
|
321
329
|
|
|
322
330
|
const cli = Command.run(mainCommand, {
|
|
323
331
|
version: VERSION,
|
|
332
|
+
renderErrors: false,
|
|
324
333
|
});
|
|
325
334
|
|
|
326
|
-
|
|
335
|
+
const runWithErrorsDisabled = Command.runWith(mainCommand, {
|
|
327
336
|
version: VERSION,
|
|
337
|
+
renderErrors: false,
|
|
328
338
|
});
|
|
339
|
+
export const run = (args: ReadonlyArray<string>) =>
|
|
340
|
+
withRedactedOutput(runWithErrorsDisabled(args)).pipe(Effect.tapCause(renderCauseToStderr));
|
|
329
341
|
|
|
330
342
|
const MainLayer = AppLayer.pipe(
|
|
331
343
|
Layer.provideMerge(BunServices.layer),
|
|
332
344
|
Layer.provideMerge(AuditServiceLayer),
|
|
333
345
|
);
|
|
334
346
|
|
|
335
|
-
const program = withAudit("session", cli).pipe(
|
|
347
|
+
const program = withRedactedOutput(withAudit("session", cli)).pipe(
|
|
348
|
+
Effect.provide(MainLayer),
|
|
349
|
+
Effect.tapCause(renderCauseToStderr),
|
|
350
|
+
);
|
|
336
351
|
|
|
337
352
|
BunRuntime.runMain(program, {
|
|
338
353
|
disableErrorReporting: true,
|
package/src/shared/audit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
6
6
|
import { Cause, Context, Effect, Layer } from "effect";
|
|
7
7
|
|
|
8
8
|
import { loadConfig } from "#config";
|
|
9
|
+
import { redactSensitiveText } from "./content-security";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
|
11
12
|
const IN_MEMORY_DB_PATH = ":memory:";
|
|
@@ -80,12 +81,12 @@ const isInMemoryDatabase = (dbPath: string): boolean =>
|
|
|
80
81
|
const toAuditEntry = (row: AuditRow): AuditLogEntry => ({
|
|
81
82
|
id: row.id,
|
|
82
83
|
ts: row.ts,
|
|
83
|
-
tool: row.tool,
|
|
84
|
-
project: row.project,
|
|
85
|
-
args: row.args,
|
|
84
|
+
tool: redactSensitiveText(row.tool),
|
|
85
|
+
project: redactSensitiveText(row.project),
|
|
86
|
+
args: redactSensitiveText(row.args),
|
|
86
87
|
duration: row.duration,
|
|
87
88
|
success: row.success === 1,
|
|
88
|
-
error: row.error,
|
|
89
|
+
error: row.error === null ? null : redactSensitiveText(row.error),
|
|
89
90
|
exitCode: row.exit_code,
|
|
90
91
|
});
|
|
91
92
|
|
|
@@ -246,12 +247,12 @@ const createAuditService = (dbPath: string, db: Database | null): AuditServiceSh
|
|
|
246
247
|
db.run(
|
|
247
248
|
"INSERT INTO audit_log (tool, project, args, duration, success, error, exit_code) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
248
249
|
[
|
|
249
|
-
safeToolName(entry.tool),
|
|
250
|
-
entry.project,
|
|
251
|
-
entry.args,
|
|
250
|
+
redactSensitiveText(safeToolName(entry.tool)),
|
|
251
|
+
redactSensitiveText(entry.project),
|
|
252
|
+
redactSensitiveText(entry.args),
|
|
252
253
|
entry.duration,
|
|
253
254
|
entry.success ? 1 : 0,
|
|
254
|
-
entry.error
|
|
255
|
+
entry.error === undefined ? null : redactSensitiveText(entry.error),
|
|
255
256
|
entry.exitCode ?? null,
|
|
256
257
|
],
|
|
257
258
|
);
|
|
@@ -317,7 +318,7 @@ export const withAudit = <A, E, R>(
|
|
|
317
318
|
Effect.suspend(() => {
|
|
318
319
|
const startedAt = Date.now();
|
|
319
320
|
const project = process.cwd();
|
|
320
|
-
const args = process.argv.slice(2).join(" ");
|
|
321
|
+
const args = redactSensitiveText(process.argv.slice(2).join(" "));
|
|
321
322
|
const tool = safeToolName(toolName || deriveToolNameFromArgv());
|
|
322
323
|
|
|
323
324
|
return Effect.matchCauseEffect(program, {
|
|
@@ -332,7 +333,7 @@ export const withAudit = <A, E, R>(
|
|
|
332
333
|
args,
|
|
333
334
|
duration: Date.now() - startedAt,
|
|
334
335
|
success: pureHelp,
|
|
335
|
-
error: pureHelp ? undefined : formatCause(cause),
|
|
336
|
+
error: pureHelp ? undefined : redactSensitiveText(formatCause(cause)),
|
|
336
337
|
exitCode: pureHelp ? 0 : extractExitCode(cause),
|
|
337
338
|
}),
|
|
338
339
|
() => Effect.failCause(cause),
|