@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.
- package/README.md +9 -1
- 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 +25 -56
- 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/pr/review.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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, {
|
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
|
);
|