@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
@@ -15,6 +15,7 @@ import type { REVIEW_EVENTS } from "#gh/config";
15
15
 
16
16
  import { GitHubCommandError } from "#gh/errors";
17
17
  import { GitHubService } from "#gh/service";
18
+ import { validateOutboundText } from "#gh/text-input";
18
19
 
19
20
  import { viewPR } from "./core";
20
21
 
@@ -83,16 +84,39 @@ const RESOLVE_THREAD_MUTATION = `
83
84
  `;
84
85
 
85
86
  const PENDING_REVIEWS_QUERY = `
86
- query($owner: String!, $name: String!, $pr: Int!) {
87
+ query($owner: String!, $name: String!, $pr: Int!, $after: String) {
87
88
  viewer { login }
88
89
  repository(owner: $owner, name: $name) {
89
90
  pullRequest(number: $pr) {
90
- reviews(last: 100, states: [PENDING]) {
91
+ reviews(first: 100, after: $after, states: [PENDING]) {
91
92
  nodes {
92
93
  id
93
94
  state
94
95
  author { login }
95
96
  }
97
+ pageInfo { hasNextPage endCursor }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ `;
103
+
104
+ const PENDING_REVIEW_CONTENT_QUERY = `
105
+ query($reviewId: ID!, $after: String) {
106
+ viewer { login }
107
+ node(id: $reviewId) {
108
+ ... on PullRequestReview {
109
+ id
110
+ state
111
+ body
112
+ author { login }
113
+ pullRequest {
114
+ number
115
+ repository { nameWithOwner }
116
+ }
117
+ comments(first: 100, after: $after) {
118
+ nodes { body }
119
+ pageInfo { hasNextPage endCursor }
96
120
  }
97
121
  }
98
122
  }
@@ -188,11 +212,27 @@ type PendingReviewsQueryResult = {
188
212
  state: string;
189
213
  author: { login: string };
190
214
  }>;
215
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
191
216
  };
192
217
  };
193
218
  };
194
219
  };
195
220
 
221
+ type PendingReviewContentQueryResult = {
222
+ viewer: { login: string };
223
+ node: {
224
+ id: string;
225
+ state: string;
226
+ body: string | null;
227
+ author: { login: string };
228
+ pullRequest: { number: number; repository: { nameWithOwner: string } };
229
+ comments: {
230
+ nodes: Array<{ body: string }>;
231
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
232
+ };
233
+ } | null;
234
+ };
235
+
196
236
  type SubmitReviewResult = {
197
237
  submitPullRequestReview: {
198
238
  pullRequestReview: { id: string; state: string };
@@ -1080,18 +1120,76 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
1080
1120
  let targetReviewId = reviewId;
1081
1121
 
1082
1122
  if (targetReviewId === null) {
1083
- const pending = (yield* service.runGraphQL(PENDING_REVIEWS_QUERY, {
1084
- owner: repoInfo.owner,
1085
- name: repoInfo.name,
1086
- pr: resolvedPr,
1087
- })) as PendingReviewsQueryResult;
1123
+ let after: string | null = null;
1124
+ let hasNextPage = true;
1125
+ let viewerLogin: string | null = null;
1126
+ let ownPendingReviewId: string | null = null;
1127
+ const seenPendingCursors = new Set<string>();
1128
+
1129
+ while (hasNextPage) {
1130
+ const pending = (yield* service.runGraphQL(PENDING_REVIEWS_QUERY, {
1131
+ owner: repoInfo.owner,
1132
+ name: repoInfo.name,
1133
+ pr: resolvedPr,
1134
+ after,
1135
+ })) as PendingReviewsQueryResult;
1136
+
1137
+ if (
1138
+ typeof pending.viewer?.login !== "string" ||
1139
+ !Array.isArray(pending.repository?.pullRequest?.reviews?.nodes) ||
1140
+ typeof pending.repository.pullRequest.reviews?.pageInfo?.hasNextPage !== "boolean" ||
1141
+ (pending.repository.pullRequest.reviews.pageInfo.endCursor !== null &&
1142
+ typeof pending.repository.pullRequest.reviews.pageInfo.endCursor !== "string") ||
1143
+ pending.repository.pullRequest.reviews.nodes.some(
1144
+ (review) =>
1145
+ typeof review?.id !== "string" ||
1146
+ typeof review.state !== "string" ||
1147
+ typeof review.author?.login !== "string",
1148
+ )
1149
+ ) {
1150
+ return yield* Effect.fail(
1151
+ new GitHubCommandError({
1152
+ command: "gh-tool pr submit-review",
1153
+ exitCode: 1,
1154
+ message: "Could not verify pending reviews; refusing to submit",
1155
+ stderr: "Pending review lookup returned incomplete data",
1156
+ }),
1157
+ );
1158
+ }
1088
1159
 
1089
- const viewerLogin = pending.viewer.login;
1090
- const pendingReviews = pending.repository.pullRequest.reviews.nodes;
1160
+ viewerLogin ??= pending.viewer.login;
1161
+ const own = pending.repository.pullRequest.reviews.nodes.find(
1162
+ (review) => review.author?.login === viewerLogin && review.state === "PENDING",
1163
+ );
1164
+ ownPendingReviewId ??= own?.id ?? null;
1091
1165
 
1092
- const ownPendingReview = pendingReviews.find((review) => review.author.login === viewerLogin);
1166
+ const pageInfo = pending.repository.pullRequest.reviews.pageInfo;
1167
+ hasNextPage = pageInfo.hasNextPage;
1168
+ after = pageInfo.endCursor;
1169
+ if (hasNextPage && (after === null || after.length === 0)) {
1170
+ return yield* Effect.fail(
1171
+ new GitHubCommandError({
1172
+ command: "gh-tool pr submit-review",
1173
+ exitCode: 1,
1174
+ message: "Could not verify all pending reviews; refusing to submit",
1175
+ stderr: "Pending review lookup returned an incomplete page cursor",
1176
+ }),
1177
+ );
1178
+ }
1179
+ if (hasNextPage && after !== null && seenPendingCursors.has(after)) {
1180
+ return yield* Effect.fail(
1181
+ new GitHubCommandError({
1182
+ command: "gh-tool pr submit-review",
1183
+ exitCode: 1,
1184
+ message: "Could not verify all pending reviews; refusing to submit",
1185
+ stderr: "Pending review lookup repeated a page cursor",
1186
+ }),
1187
+ );
1188
+ }
1189
+ if (hasNextPage && after !== null) seenPendingCursors.add(after);
1190
+ }
1093
1191
 
1094
- if (!ownPendingReview) {
1192
+ if (ownPendingReviewId === null) {
1095
1193
  return yield* Effect.fail(
1096
1194
  new GitHubCommandError({
1097
1195
  command: "gh-tool pr submit-review",
@@ -1102,7 +1200,96 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
1102
1200
  );
1103
1201
  }
1104
1202
 
1105
- targetReviewId = ownPendingReview.id;
1203
+ targetReviewId = ownPendingReviewId;
1204
+ }
1205
+
1206
+ if (targetReviewId === null) {
1207
+ return yield* Effect.fail(
1208
+ new GitHubCommandError({
1209
+ command: "gh-tool pr submit-review",
1210
+ exitCode: 1,
1211
+ message: "Could not identify pending review; refusing to submit",
1212
+ stderr: "Pending review lookup returned no review ID",
1213
+ }),
1214
+ );
1215
+ }
1216
+
1217
+ let afterComment: string | null = null;
1218
+ let commentsHaveNextPage = true;
1219
+ const seenCommentCursors = new Set<string>();
1220
+ while (commentsHaveNextPage) {
1221
+ const pendingReview = (yield* service.runGraphQL(PENDING_REVIEW_CONTENT_QUERY, {
1222
+ reviewId: targetReviewId,
1223
+ after: afterComment,
1224
+ })) as PendingReviewContentQueryResult;
1225
+
1226
+ const review = pendingReview?.node;
1227
+ if (
1228
+ typeof pendingReview?.viewer?.login !== "string" ||
1229
+ review === null ||
1230
+ review === undefined ||
1231
+ review.id !== targetReviewId ||
1232
+ review.state !== "PENDING" ||
1233
+ review.author?.login !== pendingReview.viewer.login ||
1234
+ review.pullRequest?.number !== resolvedPr ||
1235
+ typeof review.pullRequest.repository?.nameWithOwner !== "string" ||
1236
+ review.pullRequest.repository.nameWithOwner.toLowerCase() !==
1237
+ `${repoInfo.owner}/${repoInfo.name}`.toLowerCase() ||
1238
+ typeof review.body !== "string" ||
1239
+ !Array.isArray(review.comments?.nodes) ||
1240
+ typeof review.comments.pageInfo?.hasNextPage !== "boolean" ||
1241
+ (review.comments.pageInfo.endCursor !== null &&
1242
+ typeof review.comments.pageInfo.endCursor !== "string")
1243
+ ) {
1244
+ return yield* Effect.fail(
1245
+ new GitHubCommandError({
1246
+ command: "gh-tool pr submit-review",
1247
+ exitCode: 1,
1248
+ message: "Could not verify pending review contents; refusing to submit",
1249
+ stderr: "Pending review lookup returned incomplete or non-owned review data",
1250
+ }),
1251
+ );
1252
+ }
1253
+
1254
+ yield* validateOutboundText(review.body, "gh-tool pr submit-review");
1255
+ for (const comment of review.comments.nodes) {
1256
+ if (typeof comment?.body !== "string") {
1257
+ return yield* Effect.fail(
1258
+ new GitHubCommandError({
1259
+ command: "gh-tool pr submit-review",
1260
+ exitCode: 1,
1261
+ message: "Could not verify pending review comments; refusing to submit",
1262
+ stderr: "Pending review lookup returned a comment without a body",
1263
+ }),
1264
+ );
1265
+ }
1266
+ yield* validateOutboundText(comment.body, "gh-tool pr submit-review");
1267
+ }
1268
+
1269
+ const pageInfo = review.comments.pageInfo;
1270
+ commentsHaveNextPage = pageInfo.hasNextPage;
1271
+ afterComment = pageInfo.endCursor;
1272
+ if (commentsHaveNextPage && (afterComment === null || afterComment.length === 0)) {
1273
+ return yield* Effect.fail(
1274
+ new GitHubCommandError({
1275
+ command: "gh-tool pr submit-review",
1276
+ exitCode: 1,
1277
+ message: "Could not verify all pending review comments; refusing to submit",
1278
+ stderr: "Pending review lookup returned an incomplete comment cursor",
1279
+ }),
1280
+ );
1281
+ }
1282
+ if (commentsHaveNextPage && afterComment !== null && seenCommentCursors.has(afterComment)) {
1283
+ return yield* Effect.fail(
1284
+ new GitHubCommandError({
1285
+ command: "gh-tool pr submit-review",
1286
+ exitCode: 1,
1287
+ message: "Could not verify all pending review comments; refusing to submit",
1288
+ stderr: "Pending review lookup repeated a comment cursor",
1289
+ }),
1290
+ );
1291
+ }
1292
+ if (commentsHaveNextPage && afterComment !== null) seenCommentCursors.add(afterComment);
1106
1293
  }
1107
1294
 
1108
1295
  const result = (yield* service.runGraphQL(SUBMIT_REVIEW_MUTATION, {
@@ -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
  );