@blogic-cz/agent-tools 1.6.20 → 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.
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 +58 -0
@@ -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 (