@blogic-cz/agent-tools 1.6.19 → 1.7.0
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 +8 -2
- package/dist/api-tool/client.d.ts +8 -0
- package/dist/api-tool/client.d.ts.map +1 -0
- package/dist/api-tool/errors.d.ts +14 -0
- package/dist/api-tool/errors.d.ts.map +1 -0
- package/dist/api-tool/index.d.ts +3 -0
- package/dist/api-tool/index.d.ts.map +1 -0
- package/dist/api-tool/main.d.ts +2 -0
- package/dist/api-tool/main.d.ts.map +1 -0
- package/dist/api-tool/proxy-entry.d.ts +3 -0
- package/dist/api-tool/proxy-entry.d.ts.map +1 -0
- package/dist/api-tool/proxy.d.ts +15 -0
- package/dist/api-tool/proxy.d.ts.map +1 -0
- package/dist/api-tool/retention.d.ts +9 -0
- package/dist/api-tool/retention.d.ts.map +1 -0
- package/dist/api-tool/server.d.ts +6 -0
- package/dist/api-tool/server.d.ts.map +1 -0
- package/dist/api-tool/store.d.ts +5 -0
- package/dist/api-tool/store.d.ts.map +1 -0
- package/dist/api-tool/transport.d.ts +26 -0
- package/dist/api-tool/transport.d.ts.map +1 -0
- package/dist/api-tool/types.d.ts +136 -0
- package/dist/api-tool/types.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/gh-tool/issue/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/docs/rest-api-proxy.md +136 -0
- package/package.json +16 -2
- package/src/api-tool/client.ts +56 -0
- package/src/api-tool/errors.ts +41 -0
- package/src/api-tool/index.ts +4 -0
- package/src/api-tool/main.ts +58 -0
- package/src/api-tool/proxy-entry.ts +19 -0
- package/src/api-tool/proxy.ts +298 -0
- package/src/api-tool/retention.ts +58 -0
- package/src/api-tool/server.ts +114 -0
- package/src/api-tool/store.ts +93 -0
- package/src/api-tool/transport.ts +167 -0
- package/src/api-tool/types.ts +202 -0
- package/src/credential-guard/index.ts +58 -0
- package/src/gh-tool/issue/core.ts +2 -3
- package/src/gh-tool/pr/review.ts +4 -6
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import type { SecureContextOptions } from "node:tls";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { ApiError, apiError } from "./errors";
|
|
7
|
+
|
|
8
|
+
export type TransportRequest = {
|
|
9
|
+
url: URL;
|
|
10
|
+
method: string;
|
|
11
|
+
headers: Readonly<Record<string, string>>;
|
|
12
|
+
body: string;
|
|
13
|
+
maxBytes: number;
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
allowedPrivateAddresses: readonly string[];
|
|
16
|
+
developmentLoopback?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type TransportResult = { status: number; body: string };
|
|
19
|
+
export type Transport = (request: TransportRequest) => Promise<TransportResult>;
|
|
20
|
+
export type Resolver = (host: string) => Promise<readonly { address: string; family: number }[]>;
|
|
21
|
+
|
|
22
|
+
export function beforeAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
void operation.catch(() => undefined);
|
|
25
|
+
return Promise.reject(apiError("DEADLINE_EXCEEDED"));
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const abort = () => reject(apiError("DEADLINE_EXCEEDED"));
|
|
29
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
operation.then(
|
|
31
|
+
(value) => {
|
|
32
|
+
signal.removeEventListener("abort", abort);
|
|
33
|
+
return resolve(value);
|
|
34
|
+
},
|
|
35
|
+
(error) => {
|
|
36
|
+
signal.removeEventListener("abort", abort);
|
|
37
|
+
return reject(error);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function allowedAddress(
|
|
44
|
+
address: string,
|
|
45
|
+
privateAllow: readonly string[],
|
|
46
|
+
developmentLoopback = false,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (developmentLoopback && address === "127.0.0.1") return true;
|
|
49
|
+
const family = isIP(address);
|
|
50
|
+
if (!family) return false;
|
|
51
|
+
if (family === 6) {
|
|
52
|
+
const normalized = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
|
53
|
+
const first = Number.parseInt(normalized.split(":")[0] || "0", 16);
|
|
54
|
+
if (
|
|
55
|
+
normalized.startsWith("::ffff:") ||
|
|
56
|
+
(first & 0xffc0) === 0xfe80 ||
|
|
57
|
+
normalized === "fd00:ec2::254"
|
|
58
|
+
)
|
|
59
|
+
return false;
|
|
60
|
+
if (privateAllow.includes(normalized)) return true;
|
|
61
|
+
const second = Number.parseInt(normalized.split(":")[1] || "0", 16);
|
|
62
|
+
return (
|
|
63
|
+
/^[23][0-9a-f]{3}:/.test(normalized) &&
|
|
64
|
+
!(normalized.startsWith("2001:") && (second <= 0x1ff || second === 0xdb8)) &&
|
|
65
|
+
!normalized.startsWith("2002:") &&
|
|
66
|
+
!(normalized.startsWith("3fff:") && second <= 0xfff)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const parts = address.split(".").map(Number);
|
|
70
|
+
const [a = 0, b = 0] = parts;
|
|
71
|
+
if ((a === 169 && b === 254) || address === "168.63.129.16" || a === 0 || a >= 224) return false;
|
|
72
|
+
if (privateAllow.includes(address)) return true;
|
|
73
|
+
return !(
|
|
74
|
+
a === 10 ||
|
|
75
|
+
a === 127 ||
|
|
76
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
77
|
+
(a === 192 && (b === 168 || b === 0 || b === 2)) ||
|
|
78
|
+
(a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
79
|
+
(a === 203 && b === 0) ||
|
|
80
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const MAX_PENDING_DNS_LOOKUPS = 64;
|
|
85
|
+
export function makePinnedTransport(
|
|
86
|
+
resolve: Resolver = (host) => lookup(host, { all: true, verbatim: true }),
|
|
87
|
+
trust: Pick<SecureContextOptions, "ca"> = {},
|
|
88
|
+
): Transport {
|
|
89
|
+
let pendingLookups = 0;
|
|
90
|
+
return async (input) => {
|
|
91
|
+
const host = input.url.hostname.replace(/^\[|\]$/g, "");
|
|
92
|
+
if (
|
|
93
|
+
input.url.protocol !== "https:" &&
|
|
94
|
+
!(input.developmentLoopback && input.url.protocol === "http:" && host === "127.0.0.1")
|
|
95
|
+
)
|
|
96
|
+
throw apiError("DESTINATION_DENIED");
|
|
97
|
+
if (input.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
98
|
+
let addresses: Awaited<ReturnType<Resolver>>;
|
|
99
|
+
try {
|
|
100
|
+
if (isIP(host)) addresses = [{ address: host, family: isIP(host) }];
|
|
101
|
+
else {
|
|
102
|
+
if (pendingLookups >= MAX_PENDING_DNS_LOOKUPS) throw apiError("BUDGET_EXHAUSTED");
|
|
103
|
+
pendingLookups++;
|
|
104
|
+
const operation = Promise.resolve()
|
|
105
|
+
.then(() => resolve(host))
|
|
106
|
+
.finally(() => {
|
|
107
|
+
pendingLookups--;
|
|
108
|
+
});
|
|
109
|
+
addresses = await beforeAbort(operation, input.signal);
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof ApiError && error.code === "BUDGET_EXHAUSTED") throw error;
|
|
113
|
+
throw apiError(input.signal.aborted ? "DEADLINE_EXCEEDED" : "DESTINATION_DENIED");
|
|
114
|
+
}
|
|
115
|
+
if (input.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
116
|
+
const pinned = addresses[0];
|
|
117
|
+
if (
|
|
118
|
+
!pinned ||
|
|
119
|
+
addresses.some(
|
|
120
|
+
(item) =>
|
|
121
|
+
!allowedAddress(item.address, input.allowedPrivateAddresses, input.developmentLoopback),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
throw apiError("DESTINATION_DENIED");
|
|
125
|
+
return new Promise((resolveResult, reject) => {
|
|
126
|
+
const request = (input.url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
127
|
+
input.url,
|
|
128
|
+
{
|
|
129
|
+
method: input.method,
|
|
130
|
+
headers: input.headers,
|
|
131
|
+
signal: input.signal,
|
|
132
|
+
agent: false,
|
|
133
|
+
family: pinned.family,
|
|
134
|
+
ca: trust.ca,
|
|
135
|
+
rejectUnauthorized: true,
|
|
136
|
+
lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
|
|
137
|
+
},
|
|
138
|
+
(response) => {
|
|
139
|
+
const status = response.statusCode ?? 0;
|
|
140
|
+
if (status >= 300 && status < 400) {
|
|
141
|
+
response.destroy();
|
|
142
|
+
reject(apiError("DESTINATION_DENIED"));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const chunks: Buffer[] = [];
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
response.on("data", (chunk: Buffer) => {
|
|
148
|
+
bytes += chunk.length;
|
|
149
|
+
if (bytes > input.maxBytes) {
|
|
150
|
+
response.destroy();
|
|
151
|
+
reject(apiError("LIMIT_EXCEEDED"));
|
|
152
|
+
} else chunks.push(chunk);
|
|
153
|
+
});
|
|
154
|
+
response.on("error", () => reject(apiError("UPSTREAM_FAILED")));
|
|
155
|
+
response.on("end", () =>
|
|
156
|
+
resolveResult({ status, body: Buffer.concat(chunks).toString("utf8") }),
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
request.on("error", () =>
|
|
161
|
+
reject(apiError(input.signal.aborted ? "DEADLINE_EXCEEDED" : "UPSTREAM_FAILED")),
|
|
162
|
+
);
|
|
163
|
+
request.end(input.body);
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export const pinnedTransport = makePinnedTransport();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { allowedAddress } from "./transport";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { apiError } from "./errors";
|
|
5
|
+
|
|
6
|
+
export const Method = Schema.Literals(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
+
const Auth = Schema.Union([
|
|
8
|
+
Schema.Struct({ kind: Schema.Literal("bearer"), secretRef: Schema.String }),
|
|
9
|
+
Schema.Struct({
|
|
10
|
+
kind: Schema.Literal("apiKey"),
|
|
11
|
+
header: Schema.String,
|
|
12
|
+
secretRef: Schema.String,
|
|
13
|
+
}),
|
|
14
|
+
Schema.Struct({
|
|
15
|
+
kind: Schema.Literal("basic"),
|
|
16
|
+
usernameRef: Schema.String,
|
|
17
|
+
passwordRef: Schema.String,
|
|
18
|
+
}),
|
|
19
|
+
Schema.Struct({
|
|
20
|
+
kind: Schema.Literal("login"),
|
|
21
|
+
path: Schema.String,
|
|
22
|
+
fields: Schema.Record(Schema.String, Schema.String),
|
|
23
|
+
tokenField: Schema.String,
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
export const ProfileSchema = Schema.Struct({
|
|
27
|
+
origin: Schema.String,
|
|
28
|
+
auth: Auth,
|
|
29
|
+
methods: Schema.optionalKey(Schema.Array(Method)),
|
|
30
|
+
pathPrefixes: Schema.Array(Schema.String),
|
|
31
|
+
deniedPathPrefixes: Schema.Array(Schema.String),
|
|
32
|
+
allowedPrivateAddresses: Schema.Array(Schema.String),
|
|
33
|
+
disclosure: Schema.Union([
|
|
34
|
+
Schema.Struct({ kind: Schema.Literal("raw") }),
|
|
35
|
+
Schema.Struct({ kind: Schema.Literal("fields"), fields: Schema.Array(Schema.String) }),
|
|
36
|
+
]),
|
|
37
|
+
maxRequestBytes: Schema.Number,
|
|
38
|
+
maxResponseBytes: Schema.Number,
|
|
39
|
+
timeoutMs: Schema.Number,
|
|
40
|
+
});
|
|
41
|
+
const GrantSchema = Schema.Struct({
|
|
42
|
+
digest: Schema.String,
|
|
43
|
+
expiresAt: Schema.Number,
|
|
44
|
+
revoked: Schema.Boolean,
|
|
45
|
+
profiles: Schema.Array(Schema.String),
|
|
46
|
+
maxRequests: Schema.Number,
|
|
47
|
+
maxBytes: Schema.Number,
|
|
48
|
+
maxConcurrent: Schema.Number,
|
|
49
|
+
lifetimeMs: Schema.Number,
|
|
50
|
+
});
|
|
51
|
+
export const OperatorSchema = Schema.Struct({
|
|
52
|
+
listen: Schema.Union([
|
|
53
|
+
Schema.Struct({
|
|
54
|
+
kind: Schema.Literal("tls"),
|
|
55
|
+
host: Schema.String,
|
|
56
|
+
port: Schema.Number,
|
|
57
|
+
certFile: Schema.String,
|
|
58
|
+
keyFile: Schema.String,
|
|
59
|
+
separateIdentityAcknowledged: Schema.Literal(true),
|
|
60
|
+
}),
|
|
61
|
+
Schema.Struct({
|
|
62
|
+
kind: Schema.Literal("developmentLoopback"),
|
|
63
|
+
host: Schema.Literal("127.0.0.1"),
|
|
64
|
+
port: Schema.Number,
|
|
65
|
+
}),
|
|
66
|
+
]),
|
|
67
|
+
secretsFile: Schema.String,
|
|
68
|
+
grantUseDirectory: Schema.String,
|
|
69
|
+
profiles: Schema.Record(Schema.String, ProfileSchema),
|
|
70
|
+
grants: Schema.Array(GrantSchema),
|
|
71
|
+
maxConcurrent: Schema.Number,
|
|
72
|
+
});
|
|
73
|
+
export const ClientSchema = Schema.Struct({
|
|
74
|
+
proxyOrigin: Schema.String,
|
|
75
|
+
capability: Schema.String,
|
|
76
|
+
developmentLoopback: Schema.optionalKey(Schema.Boolean),
|
|
77
|
+
});
|
|
78
|
+
export const RequestSchema = Schema.Struct({
|
|
79
|
+
profile: Schema.String,
|
|
80
|
+
method: Method,
|
|
81
|
+
path: Schema.String,
|
|
82
|
+
query: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
83
|
+
body: Schema.optionalKey(Schema.Json),
|
|
84
|
+
});
|
|
85
|
+
export const ResultSchema = Schema.Struct({ status: Schema.Number, data: Schema.Json });
|
|
86
|
+
export type OperatorConfig = typeof OperatorSchema.Type;
|
|
87
|
+
export type ClientConfig = typeof ClientSchema.Type;
|
|
88
|
+
export type ApiRequest = typeof RequestSchema.Type;
|
|
89
|
+
export type ApiResult = typeof ResultSchema.Type;
|
|
90
|
+
export type Profile = typeof ProfileSchema.Type;
|
|
91
|
+
export type Grant = typeof GrantSchema.Type;
|
|
92
|
+
|
|
93
|
+
export function decode<S extends Schema.ConstraintDecoder<unknown>>(
|
|
94
|
+
schema: S,
|
|
95
|
+
value: unknown,
|
|
96
|
+
): S["Type"] {
|
|
97
|
+
try {
|
|
98
|
+
return Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })(value);
|
|
99
|
+
} catch {
|
|
100
|
+
throw apiError("CONFIG_INVALID");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export const MAX_PATH_BYTES = 16_384;
|
|
104
|
+
export const MAX_RESULT_ENVELOPE_BYTES = 10_000_000;
|
|
105
|
+
export function relativePath(path: string): string {
|
|
106
|
+
if (
|
|
107
|
+
path.length > MAX_PATH_BYTES ||
|
|
108
|
+
!/^\/[A-Za-z0-9_~!$&'()+,=:@./-]*$/.test(path) ||
|
|
109
|
+
path.includes("//") ||
|
|
110
|
+
path.split("/").some((segment) => segment.endsWith("."))
|
|
111
|
+
)
|
|
112
|
+
throw apiError("POLICY_DENIED");
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
export const matchesPrefix = (path: string, prefix: string): boolean => {
|
|
116
|
+
const normalized = prefix === "/" ? prefix : prefix.replace(/\/$/, "");
|
|
117
|
+
return normalized === "/" || path === normalized || path.startsWith(`${normalized}/`);
|
|
118
|
+
};
|
|
119
|
+
export function originUrl(origin: string, developmentLoopback = false): URL {
|
|
120
|
+
let url: URL;
|
|
121
|
+
try {
|
|
122
|
+
url = new URL(origin);
|
|
123
|
+
} catch {
|
|
124
|
+
throw apiError("CONFIG_INVALID");
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
url.origin !== origin ||
|
|
128
|
+
url.username ||
|
|
129
|
+
url.password ||
|
|
130
|
+
(url.protocol !== "https:" &&
|
|
131
|
+
!(developmentLoopback && url.protocol === "http:" && url.hostname === "127.0.0.1"))
|
|
132
|
+
)
|
|
133
|
+
throw apiError("CONFIG_INVALID");
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
const bounded = (n: number, max: number): boolean => Number.isSafeInteger(n) && n > 0 && n <= max;
|
|
137
|
+
export function validateOperator(value: unknown): OperatorConfig {
|
|
138
|
+
const config = decode(OperatorSchema, value);
|
|
139
|
+
if (
|
|
140
|
+
!(
|
|
141
|
+
bounded(config.listen.port, 65535) ||
|
|
142
|
+
(config.listen.kind === "developmentLoopback" && config.listen.port === 0)
|
|
143
|
+
) ||
|
|
144
|
+
!bounded(config.maxConcurrent, 64) ||
|
|
145
|
+
!config.grants.length ||
|
|
146
|
+
config.grants.length > 100 ||
|
|
147
|
+
Object.keys(config.profiles).length > 100 ||
|
|
148
|
+
!Object.keys(config.profiles).length
|
|
149
|
+
)
|
|
150
|
+
throw apiError("CONFIG_INVALID");
|
|
151
|
+
const digests = new Set<string>();
|
|
152
|
+
for (const grant of config.grants) {
|
|
153
|
+
if (
|
|
154
|
+
!/^[a-f0-9]{64}$/.test(grant.digest) ||
|
|
155
|
+
digests.has(grant.digest) ||
|
|
156
|
+
!bounded(grant.expiresAt, Number.MAX_SAFE_INTEGER) ||
|
|
157
|
+
!bounded(grant.maxRequests, 10000) ||
|
|
158
|
+
!bounded(grant.maxBytes, 1_000_000_000) ||
|
|
159
|
+
!bounded(grant.maxConcurrent, 64) ||
|
|
160
|
+
!bounded(grant.lifetimeMs, 86400000) ||
|
|
161
|
+
!grant.profiles.length ||
|
|
162
|
+
grant.profiles.some((p) => !Object.hasOwn(config.profiles, p))
|
|
163
|
+
)
|
|
164
|
+
throw apiError("CONFIG_INVALID");
|
|
165
|
+
digests.add(grant.digest);
|
|
166
|
+
}
|
|
167
|
+
for (const profile of Object.values(config.profiles)) {
|
|
168
|
+
originUrl(profile.origin);
|
|
169
|
+
for (const address of profile.allowedPrivateAddresses) {
|
|
170
|
+
if (
|
|
171
|
+
!isIP(address) ||
|
|
172
|
+
!allowedAddress(address, [address]) ||
|
|
173
|
+
(isIP(address) === 6 && new URL(`http://[${address}]`).hostname.slice(1, -1) !== address)
|
|
174
|
+
)
|
|
175
|
+
throw apiError("CONFIG_INVALID");
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
!profile.pathPrefixes.length ||
|
|
179
|
+
!bounded(profile.maxRequestBytes, 1_000_000) ||
|
|
180
|
+
!bounded(profile.maxResponseBytes, 10_000_000) ||
|
|
181
|
+
!bounded(profile.timeoutMs, 60000) ||
|
|
182
|
+
profile.methods?.length === 0
|
|
183
|
+
)
|
|
184
|
+
throw apiError("CONFIG_INVALID");
|
|
185
|
+
for (const prefix of [...profile.pathPrefixes, ...profile.deniedPathPrefixes])
|
|
186
|
+
relativePath(prefix);
|
|
187
|
+
if (profile.auth.kind === "apiKey" && !/^(?:x-[a-z0-9-]+|api-key)$/i.test(profile.auth.header))
|
|
188
|
+
throw apiError("CONFIG_INVALID");
|
|
189
|
+
if (profile.auth.kind === "login") {
|
|
190
|
+
relativePath(profile.auth.path);
|
|
191
|
+
const minimumBody = JSON.stringify(
|
|
192
|
+
Object.fromEntries(Object.keys(profile.auth.fields).map((field) => [field, ""])),
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
Buffer.byteLength(profile.auth.path) + Buffer.byteLength(minimumBody) >
|
|
196
|
+
profile.maxRequestBytes
|
|
197
|
+
)
|
|
198
|
+
throw apiError("CONFIG_INVALID");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return config;
|
|
202
|
+
}
|
|
@@ -1602,6 +1602,9 @@ function hasArgumentBraceExpansion(text: string): boolean {
|
|
|
1602
1602
|
}
|
|
1603
1603
|
|
|
1604
1604
|
function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
1605
|
+
const apiData = apiRequestDataIndexes(argv);
|
|
1606
|
+
if (apiData)
|
|
1607
|
+
return argv.some((arg, index) => !apiData.has(index) && hasArgumentBraceExpansion(arg));
|
|
1605
1608
|
// LogQL is data for this wrapper; its quoted label selectors are never shell-evaluated.
|
|
1606
1609
|
if (
|
|
1607
1610
|
argv[0]?.split("/").at(-1) === "bun" &&
|
|
@@ -1621,6 +1624,52 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
1621
1624
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
1622
1625
|
}
|
|
1623
1626
|
|
|
1627
|
+
function apiRequestDataIndexes(argv: string[]): Set<number> | undefined {
|
|
1628
|
+
const start =
|
|
1629
|
+
argv[0] === "bun" && argv[1] === "run" && argv[2] === "api-tool"
|
|
1630
|
+
? 3
|
|
1631
|
+
: ["api-tool", "agent-tools-api"].includes(argv[0] ?? "")
|
|
1632
|
+
? 1
|
|
1633
|
+
: -1;
|
|
1634
|
+
if (start < 0 || argv[start] !== "request" || argv.length > 18) return undefined;
|
|
1635
|
+
const data = new Set<number>();
|
|
1636
|
+
const seen = new Set<string>();
|
|
1637
|
+
for (let index = start + 1; index < argv.length; index++) {
|
|
1638
|
+
const [flag = "", ...attached] = (argv[index] ?? "").split("=");
|
|
1639
|
+
if (
|
|
1640
|
+
!["--config", "--profile", "--path", "--method", "--query", "--body"].includes(flag) ||
|
|
1641
|
+
seen.has(flag)
|
|
1642
|
+
)
|
|
1643
|
+
return undefined;
|
|
1644
|
+
seen.add(flag);
|
|
1645
|
+
const value = attached.length ? attached.join("=") : argv[++index];
|
|
1646
|
+
if (!value || value.startsWith("--")) return undefined;
|
|
1647
|
+
if (flag === "--query" || flag === "--body") {
|
|
1648
|
+
try {
|
|
1649
|
+
JSON.parse(value);
|
|
1650
|
+
} catch {
|
|
1651
|
+
return undefined;
|
|
1652
|
+
}
|
|
1653
|
+
data.add(index);
|
|
1654
|
+
} else if (flag === "--method") {
|
|
1655
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(value)) return undefined;
|
|
1656
|
+
} else if (flag === "--profile") {
|
|
1657
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) return undefined;
|
|
1658
|
+
} else if (flag === "--config") {
|
|
1659
|
+
if (!/^(?:\.?\.?\/)?[A-Za-z0-9_./-]+$/.test(value)) return undefined;
|
|
1660
|
+
} else if (flag === "--path") {
|
|
1661
|
+
if (
|
|
1662
|
+
value.length > 16384 ||
|
|
1663
|
+
!/^\/[A-Za-z0-9_~!$&'()+,=:@./-]*$/.test(value) ||
|
|
1664
|
+
value.includes("//") ||
|
|
1665
|
+
value.split("/").some((part) => part.endsWith("."))
|
|
1666
|
+
)
|
|
1667
|
+
return undefined;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return data;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1624
1673
|
function literalSqlArgumentIndex(argv: string[]): number | undefined {
|
|
1625
1674
|
if (
|
|
1626
1675
|
argv[0]?.split("/").at(-1) !== "bun" ||
|
|
@@ -2551,6 +2600,15 @@ function hasEnvironmentRead(
|
|
|
2551
2600
|
// ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
|
|
2552
2601
|
const parsed = parseStaticShellCommands(command);
|
|
2553
2602
|
if (parsed === "brace-expansion") return true;
|
|
2603
|
+
if (
|
|
2604
|
+
parsed &&
|
|
2605
|
+
typeof parsed !== "string" &&
|
|
2606
|
+
hasEnvironmentMutationPrefix(command) &&
|
|
2607
|
+
parsed.pipelines
|
|
2608
|
+
.flat()
|
|
2609
|
+
.some((words) => apiRequestDataIndexes(unwrapStaticCommand(words)) !== undefined)
|
|
2610
|
+
)
|
|
2611
|
+
return true;
|
|
2554
2612
|
if (!parsed) {
|
|
2555
2613
|
const operands = parseStaticShellCommands(command, true);
|
|
2556
2614
|
if (
|
|
@@ -216,8 +216,7 @@ export const commentOnIssue = Effect.fn("issue.commentOnIssue")(function* (opts:
|
|
|
216
216
|
const gh = yield* GitHubService;
|
|
217
217
|
const repoInfo = yield* gh.getRepoInfo();
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
if (trimmedBody.length === 0) {
|
|
219
|
+
if (opts.body.trim().length === 0) {
|
|
221
220
|
return yield* Effect.fail(
|
|
222
221
|
new GitHubCommandError({
|
|
223
222
|
command: "gh-tool issue comment",
|
|
@@ -234,7 +233,7 @@ export const commentOnIssue = Effect.fn("issue.commentOnIssue")(function* (opts:
|
|
|
234
233
|
"POST",
|
|
235
234
|
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${opts.issue}/comments`,
|
|
236
235
|
"-f",
|
|
237
|
-
`body=${
|
|
236
|
+
`body=${opts.body}`,
|
|
238
237
|
]);
|
|
239
238
|
|
|
240
239
|
const rawComment = yield* Effect.try({
|
package/src/gh-tool/pr/review.ts
CHANGED
|
@@ -716,8 +716,7 @@ export const postIssueComment = Effect.fn("pr.postIssueComment")(function* (
|
|
|
716
716
|
|
|
717
717
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
718
718
|
|
|
719
|
-
|
|
720
|
-
if (trimmedBody.length === 0) {
|
|
719
|
+
if (body.trim().length === 0) {
|
|
721
720
|
return yield* Effect.fail(
|
|
722
721
|
new GitHubCommandError({
|
|
723
722
|
command: "gh-tool pr comment",
|
|
@@ -734,7 +733,7 @@ export const postIssueComment = Effect.fn("pr.postIssueComment")(function* (
|
|
|
734
733
|
"POST",
|
|
735
734
|
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${resolvedPr}/comments`,
|
|
736
735
|
"-f",
|
|
737
|
-
`body=${
|
|
736
|
+
`body=${body}`,
|
|
738
737
|
]);
|
|
739
738
|
|
|
740
739
|
const rawComment = yield* Effect.try({
|
|
@@ -924,8 +923,7 @@ export const replyToComment = Effect.fn("pr.replyToComment")(function* (
|
|
|
924
923
|
|
|
925
924
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
926
925
|
|
|
927
|
-
|
|
928
|
-
if (trimmedBody.length === 0) {
|
|
926
|
+
if (body.trim().length === 0) {
|
|
929
927
|
return yield* Effect.fail(
|
|
930
928
|
new GitHubCommandError({
|
|
931
929
|
command: "gh-tool pr reply",
|
|
@@ -957,7 +955,7 @@ export const replyToComment = Effect.fn("pr.replyToComment")(function* (
|
|
|
957
955
|
"POST",
|
|
958
956
|
`repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/comments/${rootCommentId}/replies`,
|
|
959
957
|
"-f",
|
|
960
|
-
`body=${
|
|
958
|
+
`body=${body}`,
|
|
961
959
|
])
|
|
962
960
|
.pipe(
|
|
963
961
|
Effect.catchTag("GitHubCommandError", (error) => {
|