@blogic-cz/agent-tools 1.5.7 → 1.5.8

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 +9 -1
  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 +25 -56
  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
@@ -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),
@@ -0,0 +1,91 @@
1
+ import { findSecretMatches } from "./credential-patterns";
2
+
3
+ const SENSITIVE_ENV_NAME =
4
+ /(?:KEY|TOKEN|SECRET|PASS(?:WORD)?|PWD|CREDENTIAL|AUTH|COOKIE|SESSION|PSK)/i;
5
+ const isSensitiveEnvironmentName = (name: string) =>
6
+ name !== "PWD" &&
7
+ name !== "OLDPWD" &&
8
+ name !== "CODEX_SESSION_ID" &&
9
+ SENSITIVE_ENV_NAME.test(name);
10
+
11
+ const sensitiveEnvironmentValues = (environment: Record<string, string | undefined>) =>
12
+ Object.entries(environment)
13
+ .flatMap(([name, value]) =>
14
+ isSensitiveEnvironmentName(name) && value && value.length >= 8 ? [value] : [],
15
+ )
16
+ .toSorted((left, right) => right.length - left.length);
17
+
18
+ export function unsafeOutboundTextReason(
19
+ text: string,
20
+ environment: Record<string, string | undefined> = process.env,
21
+ ): string | null {
22
+ if (findSecretMatches(text).length > 0) return "a credential pattern";
23
+ if (sensitiveEnvironmentValues(environment).some((value) => text.includes(value))) {
24
+ return "a credential from the process environment";
25
+ }
26
+
27
+ const assignments = text.match(/^[A-Za-z_][A-Za-z0-9_]*=.*$/gm) ?? [];
28
+ if (
29
+ assignments.length >= 5 &&
30
+ assignments.some((line) => isSensitiveEnvironmentName(line.split("=", 1)[0] ?? ""))
31
+ ) {
32
+ return "an environment dump";
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Remove recognized secret spans and known sensitive process-environment values. */
38
+ export function redactSensitiveText(
39
+ text: string,
40
+ environment: Record<string, string | undefined> = process.env,
41
+ ): string {
42
+ const spans = findSecretMatches(text).map(({ start, end }) => ({ start, end }));
43
+ for (const value of sensitiveEnvironmentValues(environment)) {
44
+ let start = text.indexOf(value);
45
+ while (start !== -1) {
46
+ spans.push({ start, end: start + value.length });
47
+ start = text.indexOf(value, start + 1);
48
+ }
49
+ }
50
+ if (spans.length === 0) return text;
51
+
52
+ spans.sort((left, right) => left.start - right.start || right.end - left.end);
53
+ let result = "";
54
+ let offset = 0;
55
+ for (const span of spans) {
56
+ if (span.start < offset) {
57
+ offset = Math.max(offset, span.end);
58
+ continue;
59
+ }
60
+ result += text.slice(offset, span.start) + "[REDACTED]";
61
+ offset = span.end;
62
+ }
63
+ return result + text.slice(offset);
64
+ }
65
+
66
+ /** Sanitize values before serialization so JSON and TOON retain their structure. */
67
+ export function redactSensitiveValue<T>(
68
+ value: T,
69
+ environment: Record<string, string | undefined> = process.env,
70
+ ): T {
71
+ if (typeof value === "string") return redactSensitiveText(value, environment) as T;
72
+ if (Array.isArray(value))
73
+ return value.map((item) => redactSensitiveValue(item, environment)) as T;
74
+ if (value === null || typeof value !== "object") return value;
75
+ if (value instanceof Date) return redactSensitiveText(value.toISOString(), environment) as T;
76
+
77
+ return Object.fromEntries(
78
+ Object.entries(value).map(([key, item]) => {
79
+ const serialized = JSON.stringify(item) ?? "undefined";
80
+ const pair = `${JSON.stringify(key)}: ${serialized}`;
81
+ const valueStart = pair.length - serialized.length;
82
+ const contextualSecret =
83
+ typeof item === "string" &&
84
+ findSecretMatches(pair).some((match) => match.start < valueStart && match.end > valueStart);
85
+ return [
86
+ redactSensitiveText(key, environment),
87
+ contextualSecret ? "[REDACTED]" : redactSensitiveValue(item, environment),
88
+ ];
89
+ }),
90
+ ) as T;
91
+ }
@@ -0,0 +1,62 @@
1
+ /** Patterns used by the credential guard and shared output redaction. */
2
+ export const SECRET_PATTERNS = [
3
+ { name: "AWS Access Key", pattern: /(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/ },
4
+ { name: "GitHub Token", pattern: /gh[ps]_[A-Za-z0-9]{36}/ },
5
+ { name: "GitHub PAT", pattern: /github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}/ },
6
+ { name: "OpenAI Key", pattern: /sk-[A-Za-z0-9]{48}/ },
7
+ {
8
+ name: "Generic API Key",
9
+ pattern: /(?:api[_-]?key|apikey)["\s:=]+["']?([A-Za-z0-9_-]{20,})["']?/i,
10
+ },
11
+ {
12
+ name: "Generic Secret",
13
+ pattern:
14
+ /(?:secret|token|password|passwd|pwd)[" \t:=]+["']?(?!\$\{|process\.env|z\.|generate|create|read|get|fetch|import|export|const|function|return|Schema)[^\s"']{32,}["']?/i,
15
+ },
16
+ {
17
+ // eslint-disable-next-line eslint/no-useless-concat -- intentionally split to avoid credential guard self-detection
18
+ name: "Priv" + "ate Key",
19
+ pattern: new RegExp("-----BEGIN.*PRIVATE KEY-----"),
20
+ },
21
+ {
22
+ name: "JWT Token",
23
+ pattern: /(?:["'=:\s]|^)eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
24
+ },
25
+ { name: "Azure SAS Token", pattern: /[?&]sig=[A-Za-z0-9%+/=]{20,}/ },
26
+ { name: "GCP Service Account Key", pattern: /"type"\s*:\s*"service_account"/ },
27
+ {
28
+ name: "Slack Webhook URL",
29
+ pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/,
30
+ },
31
+ {
32
+ name: "Discord Webhook URL",
33
+ pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/,
34
+ },
35
+ {
36
+ name: "Database URL",
37
+ pattern: /(?:postgres(?:ql)?|mysql|mongodb):\/\/(?!\$\{)[^:]+:(?!\$\{)[^@]+@/,
38
+ },
39
+ ];
40
+
41
+ export function findSecretMatches(
42
+ content: string,
43
+ ): Array<{ name: string; start: number; end: number }> {
44
+ const matches: Array<{ name: string; start: number; end: number }> = [];
45
+ for (const { name, pattern } of SECRET_PATTERNS) {
46
+ const scanner = new RegExp(
47
+ pattern.source,
48
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
49
+ );
50
+ for (const match of content.matchAll(scanner)) {
51
+ const start = match.index;
52
+ let end = start + match[0].length;
53
+ if (name === "Private Key") {
54
+ const remainder = content.slice(end);
55
+ const closing = /^\r?\n?[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/.exec(remainder);
56
+ end += closing?.[0].length ?? remainder.length;
57
+ }
58
+ matches.push({ name, start, end });
59
+ }
60
+ }
61
+ return matches;
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { redactSensitiveText } from "./content-security";
2
3
 
3
4
  // eslint-disable-next-line import/no-relative-parent-imports -- package.json lives at project root, outside src/
4
5
  import pkg from "../../package.json" with { type: "json" };
@@ -27,6 +28,6 @@ export const checkEffectVersion = async (): Promise<boolean> => {
27
28
 
28
29
  const error = effectVersionError(version);
29
30
  if (error === undefined) return true;
30
- console.error(error);
31
+ console.error(redactSensitiveText(error));
31
32
  return false;
32
33
  };
@@ -1,6 +1,7 @@
1
1
  import type { Effect } from "effect";
2
2
 
3
3
  import { Cause, Console } from "effect";
4
+ import { redactSensitiveText } from "./content-security";
4
5
 
5
6
  const formatError = (error: unknown): string => {
6
7
  if (
@@ -42,7 +43,19 @@ const formatError = (error: unknown): string => {
42
43
 
43
44
  const formatCause = (cause: Cause.Cause<unknown>): string => {
44
45
  const firstFailure = cause.reasons.find(Cause.isFailReason);
45
- if (firstFailure !== undefined) return formatError(firstFailure.error);
46
+ if (firstFailure !== undefined) {
47
+ if (
48
+ typeof firstFailure.error === "object" &&
49
+ firstFailure.error !== null &&
50
+ Reflect.get(firstFailure.error, "_tag") === "ShowHelp"
51
+ ) {
52
+ const errors = Reflect.get(firstFailure.error, "errors");
53
+ return Array.isArray(errors) && errors.length > 0
54
+ ? errors.map(formatError).join("; ")
55
+ : "Help requested";
56
+ }
57
+ return formatError(firstFailure.error);
58
+ }
46
59
 
47
60
  const firstDefect = cause.reasons.find(Cause.isDieReason);
48
61
  if (firstDefect !== undefined) {
@@ -56,4 +69,4 @@ const formatCause = (cause: Cause.Cause<unknown>): string => {
56
69
  };
57
70
 
58
71
  export const renderCauseToStderr = (cause: Cause.Cause<unknown>): Effect.Effect<void> =>
59
- Console.error(formatCause(cause));
72
+ Console.error(redactSensitiveText(formatCause(cause)));
@@ -1,7 +1,8 @@
1
1
  import { Flag } from "effect/unstable/cli";
2
- import { encode as encodeToon } from "@toon-format/toon";
2
+ import { decode as decodeToon, encode as encodeToon } from "@toon-format/toon";
3
3
  import { Effect } from "effect";
4
4
 
5
+ import { redactSensitiveText, redactSensitiveValue } from "./content-security";
5
6
  import type { BaseResult, OutputFormat } from "./types";
6
7
 
7
8
  export const formatOption = Flag.Literals("format", ["toon", "json"]).pipe(
@@ -11,18 +12,44 @@ export const formatOption = Flag.Literals("format", ["toon", "json"]).pipe(
11
12
 
12
13
  export function formatOutput<T extends BaseResult>(result: T, format: OutputFormat): string {
13
14
  if (format === "toon") {
14
- return encodeToon(result);
15
+ return encodeToon(redactSensitiveValue(result));
15
16
  }
16
- return JSON.stringify(result, null, 2);
17
+ return JSON.stringify(redactSensitiveValue(result), null, 2);
17
18
  }
18
19
 
19
20
  export function formatAny<T>(data: T, format: OutputFormat): string {
20
21
  if (format === "toon") {
21
- return encodeToon(data);
22
+ return encodeToon(redactSensitiveValue(data));
22
23
  }
23
- return JSON.stringify(data, null, 2);
24
+ return JSON.stringify(redactSensitiveValue(data), null, 2);
24
25
  }
25
26
 
27
+ const redactOutputText = (text: string) => {
28
+ let json: unknown;
29
+ let isJson = false;
30
+ try {
31
+ json = JSON.parse(text);
32
+ isJson = true;
33
+ } catch {
34
+ // Try structured TOON below before treating this as plain text.
35
+ }
36
+ if (isJson) {
37
+ return (
38
+ JSON.stringify(redactSensitiveValue(json), null, text.includes("\n") ? 2 : undefined) ??
39
+ "null"
40
+ );
41
+ }
42
+
43
+ let toon: unknown;
44
+ try {
45
+ toon = decodeToon(text);
46
+ } catch {
47
+ // Raw logs and status strings are not structured output.
48
+ }
49
+ if (toon !== null && typeof toon === "object") return encodeToon(redactSensitiveValue(toon));
50
+ return redactSensitiveText(text);
51
+ };
52
+
26
53
  // `Console.log` drops bytes on a non-blocking pipe: a payload over the pipe buffer arrives
27
54
  // truncated at a page boundary, reaching the caller as invalid JSON. Awaiting the write callback
28
55
  // makes the stream retry the short write. Never replace this with `Console.log`.
@@ -30,7 +57,7 @@ export function formatAny<T>(data: T, format: OutputFormat): string {
30
57
  // instead of a silent exit 0 with partial output.
31
58
  export const logText = (text: string) =>
32
59
  Effect.callback<undefined>((resume) => {
33
- process.stdout.write(`${text}\n`, (error) =>
60
+ process.stdout.write(`${redactOutputText(text)}\n`, (error) =>
34
61
  resume(
35
62
  error && (error as NodeJS.ErrnoException).code !== "EPIPE"
36
63
  ? Effect.die(error)
@@ -14,6 +14,11 @@ export { expandPath, runCommand } from "./bun";
14
14
  export { commonArgOptions, parseCommonArgs } from "./cli";
15
15
 
16
16
  export { renderCauseToStderr } from "./error-renderer";
17
+ export {
18
+ redactSensitiveText,
19
+ redactSensitiveValue,
20
+ unsafeOutboundTextReason,
21
+ } from "./content-security";
17
22
 
18
23
  export { dumpCommandSchema, makeSchemaCommand, type CommandSchema } from "./schema-dump";
19
24
 
@@ -0,0 +1,29 @@
1
+ import { Console, Effect, Terminal } from "effect";
2
+
3
+ import { redactSensitiveText, redactSensitiveValue } from "./content-security";
4
+
5
+ const redactArgument = (value: unknown) =>
6
+ typeof value === "string" ? redactSensitiveText(value) : redactSensitiveValue(value);
7
+
8
+ export const withRedactedOutput = <A, E, R>(program: Effect.Effect<A, E, R>) =>
9
+ Effect.gen(function* () {
10
+ const currentConsole = yield* Console.Console;
11
+ const currentTerminal = yield* Terminal.Terminal;
12
+ const redactedConsole = new Proxy(currentConsole, {
13
+ get(target, key) {
14
+ const member = Reflect.get(target, key);
15
+ return typeof member === "function"
16
+ ? (...args: ReadonlyArray<unknown>) => member.apply(target, args.map(redactArgument))
17
+ : member;
18
+ },
19
+ });
20
+ const redactedTerminal = Object.assign(Object.create(currentTerminal), {
21
+ display: (text: string) => currentTerminal.display(redactSensitiveText(text)),
22
+ });
23
+
24
+ return yield* Effect.provideService(
25
+ Effect.provideService(program, Console.Console, redactedConsole),
26
+ Terminal.Terminal,
27
+ redactedTerminal,
28
+ );
29
+ });