@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.
Files changed (62) hide show
  1. package/README.md +21 -8
  2. package/dist/az-tool/service.d.ts.map +1 -1
  3. package/dist/azdo-tool/service.d.ts.map +1 -1
  4. package/dist/credential-guard/index.d.ts +2 -0
  5. package/dist/credential-guard/index.d.ts.map +1 -1
  6. package/dist/db-tool/service.d.ts.map +1 -1
  7. package/dist/gh-tool/gist.d.ts +13 -0
  8. package/dist/gh-tool/gist.d.ts.map +1 -1
  9. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  11. package/dist/gh-tool/release.d.ts +46 -1
  12. package/dist/gh-tool/release.d.ts.map +1 -1
  13. package/dist/gh-tool/text-input.d.ts +5 -3
  14. package/dist/gh-tool/text-input.d.ts.map +1 -1
  15. package/dist/k8s-tool/service.d.ts.map +1 -1
  16. package/dist/logs-tool/main.d.ts +1 -2
  17. package/dist/logs-tool/main.d.ts.map +1 -1
  18. package/dist/observability-tool/shared.d.ts.map +1 -1
  19. package/dist/session-tool/main.d.ts +1 -2
  20. package/dist/session-tool/main.d.ts.map +1 -1
  21. package/dist/shared/audit.d.ts.map +1 -1
  22. package/dist/shared/content-security.d.ts +6 -0
  23. package/dist/shared/content-security.d.ts.map +1 -0
  24. package/dist/shared/credential-patterns.d.ts +11 -0
  25. package/dist/shared/credential-patterns.d.ts.map +1 -0
  26. package/dist/shared/effect-version.d.ts.map +1 -1
  27. package/dist/shared/error-renderer.d.ts.map +1 -1
  28. package/dist/shared/format.d.ts.map +1 -1
  29. package/dist/shared/index.d.ts +1 -0
  30. package/dist/shared/index.d.ts.map +1 -1
  31. package/dist/shared/output-boundary.d.ts +3 -0
  32. package/dist/shared/output-boundary.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/audit-tool/main.ts +3 -1
  35. package/src/az-tool/main.ts +3 -1
  36. package/src/az-tool/service.ts +9 -0
  37. package/src/azdo-tool/main.ts +3 -1
  38. package/src/azdo-tool/service.ts +17 -0
  39. package/src/credential-guard/claude-hook.ts +2 -1
  40. package/src/credential-guard/index.ts +936 -87
  41. package/src/db-tool/main.ts +3 -1
  42. package/src/db-tool/service.ts +25 -2
  43. package/src/gh-tool/gist.ts +63 -64
  44. package/src/gh-tool/main.ts +3 -1
  45. package/src/gh-tool/pr/core.ts +10 -3
  46. package/src/gh-tool/pr/review.ts +199 -12
  47. package/src/gh-tool/release.ts +104 -29
  48. package/src/gh-tool/text-input.ts +19 -38
  49. package/src/k8s-tool/main.ts +3 -1
  50. package/src/k8s-tool/service.ts +25 -0
  51. package/src/logs-tool/main.ts +7 -2
  52. package/src/observability-tool/main.ts +5 -7
  53. package/src/observability-tool/shared.ts +41 -1
  54. package/src/session-tool/main.ts +18 -3
  55. package/src/shared/audit.ts +11 -10
  56. package/src/shared/content-security.ts +91 -0
  57. package/src/shared/credential-patterns.ts +62 -0
  58. package/src/shared/effect-version.ts +2 -1
  59. package/src/shared/error-renderer.ts +15 -2
  60. package/src/shared/format.ts +33 -6
  61. package/src/shared/index.ts +5 -0
  62. package/src/shared/output-boundary.ts +29 -0
@@ -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 createRelease = Effect.fn("release.createRelease")(function* (opts: {
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
- args.push("--title", opts.title);
175
+ yield* validateOutboundText(opts.title, "gh-tool release create");
152
176
  }
153
177
 
154
- if (opts.body !== null) {
155
- args.push("--notes", opts.body);
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 (opts.notesFile !== null) {
159
- args.push("--notes-file", opts.notesFile);
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: opts.title ?? opts.tag,
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("Path to release notes file (passed to gh --notes-file)"),
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* resolveOptionalTextInput({
420
- command: "gh-tool release create",
421
- value: Option.getOrNull(body),
422
- fileValue: Option.getOrNull(bodyFile),
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 { detectSecrets } from "#guard";
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 validateOutboundFile = (filePath: string, command: string) =>
37
+ export const readValidatedOutboundFile = (filePath: string, command: string) =>
62
38
  Effect.tryPromise({
63
- try: () => readTextFile(filePath),
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
- Effect.flatMap((text) => validateOutboundText(text, command)),
73
- Effect.asVoid,
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
- if (isSensitivePath(filePath)) {
78
- return Promise.reject(new Error(`Refusing to read sensitive file: ${filePath}`));
79
- }
53
+ return readAllowedFile(filePath).then(({ text }) => text);
54
+ };
80
55
 
81
- return Bun.file(filePath).text();
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) => {
@@ -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
  );
@@ -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) {
@@ -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
- export const run = Command.runWith(mainCommand, {
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 { Console, Effect, Layer } from "effect";
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 { makeSchemaCommand, VERSION } from "#shared";
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(`${config.url}${path}`, {
253
+ const response = await fetch(url, {
214
254
  ...init,
215
255
  headers,
216
256
  });
@@ -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 { makeSchemaCommand, formatOption, formatOutput, logText, VERSION } from "#shared";
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
- export const run = Command.runWith(mainCommand, {
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(Effect.provide(MainLayer));
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,
@@ -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 ?? null,
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),