@blogic-cz/agent-tools 1.6.20 → 1.7.1

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 (38) hide show
  1. package/README.md +8 -2
  2. package/dist/api-tool/client.d.ts +8 -0
  3. package/dist/api-tool/client.d.ts.map +1 -0
  4. package/dist/api-tool/errors.d.ts +14 -0
  5. package/dist/api-tool/errors.d.ts.map +1 -0
  6. package/dist/api-tool/index.d.ts +3 -0
  7. package/dist/api-tool/index.d.ts.map +1 -0
  8. package/dist/api-tool/main.d.ts +2 -0
  9. package/dist/api-tool/main.d.ts.map +1 -0
  10. package/dist/api-tool/proxy-entry.d.ts +3 -0
  11. package/dist/api-tool/proxy-entry.d.ts.map +1 -0
  12. package/dist/api-tool/proxy.d.ts +15 -0
  13. package/dist/api-tool/proxy.d.ts.map +1 -0
  14. package/dist/api-tool/retention.d.ts +9 -0
  15. package/dist/api-tool/retention.d.ts.map +1 -0
  16. package/dist/api-tool/server.d.ts +6 -0
  17. package/dist/api-tool/server.d.ts.map +1 -0
  18. package/dist/api-tool/store.d.ts +5 -0
  19. package/dist/api-tool/store.d.ts.map +1 -0
  20. package/dist/api-tool/transport.d.ts +26 -0
  21. package/dist/api-tool/transport.d.ts.map +1 -0
  22. package/dist/api-tool/types.d.ts +136 -0
  23. package/dist/api-tool/types.d.ts.map +1 -0
  24. package/dist/credential-guard/index.d.ts.map +1 -1
  25. package/docs/rest-api-proxy.md +136 -0
  26. package/package.json +16 -2
  27. package/src/api-tool/client.ts +56 -0
  28. package/src/api-tool/errors.ts +41 -0
  29. package/src/api-tool/index.ts +4 -0
  30. package/src/api-tool/main.ts +58 -0
  31. package/src/api-tool/proxy-entry.ts +19 -0
  32. package/src/api-tool/proxy.ts +298 -0
  33. package/src/api-tool/retention.ts +58 -0
  34. package/src/api-tool/server.ts +114 -0
  35. package/src/api-tool/store.ts +93 -0
  36. package/src/api-tool/transport.ts +167 -0
  37. package/src/api-tool/types.ts +202 -0
  38. package/src/credential-guard/index.ts +91 -3
@@ -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" ||
@@ -1647,9 +1696,11 @@ function literalSqlArgumentIndex(argv: string[]): number | undefined {
1647
1696
  return sql;
1648
1697
  }
1649
1698
 
1650
- /** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
1651
1699
  function isLiteralTextProducer(argv: string[]): boolean {
1652
- return isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head";
1700
+ return (
1701
+ (argv[0]?.split("/").at(-1) === "git" && argv[1] === "commit") ||
1702
+ (isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head")
1703
+ );
1653
1704
  }
1654
1705
 
1655
1706
  function awkProgramIndex(argv: string[]): number | undefined {
@@ -2412,6 +2463,22 @@ function isReadonlySedPrint(argv: string[]): boolean {
2412
2463
  );
2413
2464
  }
2414
2465
 
2466
+ function gitCommitExecutionArguments(argv: string[]): string[] {
2467
+ if (argv[0]?.split("/").at(-1) !== "git" || argv[1] !== "commit") return argv;
2468
+ const executionArguments = [...argv];
2469
+ for (let index = 2; index < argv.length && argv[index] !== "--"; index++) {
2470
+ const argument = argv[index] ?? "";
2471
+ if (argument === "-m" || argument === "--message") {
2472
+ if (index + 1 < argv.length) executionArguments[++index] = "";
2473
+ } else if (argument.startsWith("--message=")) {
2474
+ executionArguments[index] = "--message=";
2475
+ } else if (argument.startsWith("-m") && argument.length > 2) {
2476
+ executionArguments[index] = "-m";
2477
+ }
2478
+ }
2479
+ return executionArguments;
2480
+ }
2481
+
2415
2482
  function hasStaticEnvironmentRead(
2416
2483
  argv: string[],
2417
2484
  allowedNames: Set<string>,
@@ -2468,7 +2535,7 @@ function hasStaticEnvironmentRead(
2468
2535
  "jq",
2469
2536
  "docker",
2470
2537
  ].includes(name ?? "") &&
2471
- mentionsEnvironmentRead(argv.join(" ")))
2538
+ mentionsEnvironmentRead(gitCommitExecutionArguments(argv).join(" ")))
2472
2539
  );
2473
2540
  }
2474
2541
 
@@ -2551,6 +2618,15 @@ function hasEnvironmentRead(
2551
2618
  // ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
2552
2619
  const parsed = parseStaticShellCommands(command);
2553
2620
  if (parsed === "brace-expansion") return true;
2621
+ if (
2622
+ parsed &&
2623
+ typeof parsed !== "string" &&
2624
+ hasEnvironmentMutationPrefix(command) &&
2625
+ parsed.pipelines
2626
+ .flat()
2627
+ .some((words) => apiRequestDataIndexes(unwrapStaticCommand(words)) !== undefined)
2628
+ )
2629
+ return true;
2554
2630
  if (!parsed) {
2555
2631
  const operands = parseStaticShellCommands(command, true);
2556
2632
  if (
@@ -2603,6 +2679,18 @@ function hasEnvironmentRead(
2603
2679
  ) {
2604
2680
  return true;
2605
2681
  }
2682
+ if (
2683
+ parsed.redirects.length > 0 &&
2684
+ unwrapped.some(
2685
+ (producer, index) =>
2686
+ producer[0]?.split("/").at(-1) === "git" &&
2687
+ producer[1] === "commit" &&
2688
+ mentionsEnvironmentRead(producer.join(" ")) &&
2689
+ unwrapped.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
2690
+ )
2691
+ ) {
2692
+ return true;
2693
+ }
2606
2694
  if (
2607
2695
  parsed.redirects.length > 0 &&
2608
2696
  pipelines.some((pipeline) =>