@m6d/cortex-cli 0.2.0 → 0.4.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.
@@ -18,7 +18,7 @@ export function validateConfig(config: Record<string, unknown>, label: string) {
18
18
  if (!paths.length) return;
19
19
 
20
20
  report.fail({
21
- what: `${label} has ${paths.length} undefined value${paths.length === 1 ? "" : "s"}`,
21
+ what: `${label} has ${report.plural(paths.length, "undefined value")}`,
22
22
  blocks: [
23
23
  paths.map((entry) => ` ${entry}`).join("\n"),
24
24
  "These keys are present but evaluate to undefined — usually a missing\nenvironment variable. Bun loads .env from the directory you run in; check\nthat ./.env exists and is complete.",
@@ -32,7 +32,7 @@ function findUndefined(value: object, prefix: string, seen: WeakSet<object>): st
32
32
  if (seen.has(value)) return [];
33
33
  seen.add(value);
34
34
 
35
- return (Object.entries(value) as [string, unknown][]).flatMap(function ([key, child]) {
35
+ return (Object.entries(value) as [string, unknown][]).flatMap(([key, child]) => {
36
36
  const path = prefix ? `${prefix}.${key}` : key;
37
37
  if (child === undefined) return [path];
38
38
  if (typeof child !== "object" || child === null) return [];
@@ -155,12 +155,15 @@ function firstViolation(
155
155
  }
156
156
 
157
157
  const URL_FUNCTION = /url\(([^)]*)\)/gi;
158
- /** Every CSS function that names a resource to load; only url() can point at a fragment. */
159
- export const IMAGE_FUNCTION = /\b(url|src|image|image-set|-webkit-image-set|cross-fade|element)\(/i;
160
- /** The same set without url(), whose argument is checked for a fragment instead. */
161
- const OTHER_IMAGE_FUNCTION = /\b(src|image|image-set|-webkit-image-set|cross-fade|element)\(/i;
162
- const OTHER_IMAGE_FUNCTION_GLOBAL =
163
- /\b(src|image|image-set|-webkit-image-set|cross-fade|element)\([^)]*\)/gi;
158
+ /** The CSS functions besides url() that name a resource to load; only url() can point at a fragment, so its argument is checked instead. */
159
+ const OTHER_IMAGE_FUNCTION_NAMES = "src|image|image-set|-webkit-image-set|cross-fade|element";
160
+ /** Every CSS function that names a resource to load. */
161
+ export const IMAGE_FUNCTION = new RegExp(String.raw`\b(url|${OTHER_IMAGE_FUNCTION_NAMES})\(`, "i");
162
+ const OTHER_IMAGE_FUNCTION = new RegExp(String.raw`\b(${OTHER_IMAGE_FUNCTION_NAMES})\(`, "i");
163
+ const OTHER_IMAGE_FUNCTION_GLOBAL = new RegExp(
164
+ String.raw`\b(${OTHER_IMAGE_FUNCTION_NAMES})\([^)]*\)`,
165
+ "gi",
166
+ );
164
167
  const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\(.)/gi;
165
168
 
166
169
  /** Style text with its CSS escapes decoded, so a url() hidden behind one reads as itself. */
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ import { canCarrySecret } from "../egress/url";
3
+
4
+ export { canCarrySecret };
5
+
6
+ /*
7
+ * A credential is a value Cortex presents to an upstream: one it holds
8
+ * (`static`) or one it acquires (`oauth2-client-credentials`, RFC 6749 §4.4).
9
+ * The definition is the same on both sides; the Control Center stores it,
10
+ * a code config declares it, and each side resolves it to a bearer through
11
+ * the helper and cache below with its own egress-guarded fetch.
12
+ */
13
+
14
+ export const CREDENTIAL_KINDS = ["static", "oauth2-client-credentials"] as const;
15
+ export type CredentialKind = (typeof CREDENTIAL_KINDS)[number];
16
+
17
+ export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
18
+ z.object({
19
+ kind: z.literal("static"),
20
+ value: z.string().min(1),
21
+ }),
22
+ z.object({
23
+ kind: z.literal("oauth2-client-credentials"),
24
+ /** The token endpoint; a client secret travels over TLS, to loopback, or to a private host while the egress policy is relaxed. */
25
+ tokenUrl: z
26
+ .url()
27
+ .refine(
28
+ (url) => canCarrySecret(url),
29
+ "token URL must be https, or http to loopback (or a private host while the egress policy is relaxed)",
30
+ ),
31
+ clientId: z.string().min(1),
32
+ clientSecret: z.string().min(1),
33
+ scope: z.string().min(1).optional(),
34
+ }),
35
+ ]);
36
+ export type CredentialDefinition = z.infer<typeof credentialDefinitionSchema>;
37
+ export type OAuth2ClientCredentials = Extract<
38
+ CredentialDefinition,
39
+ { kind: "oauth2-client-credentials" }
40
+ >;
41
+
42
+ /** One POST to the token endpoint through the caller's guarded fetch; `null` when the transport failed. */
43
+ export type TokenPost = (
44
+ url: string,
45
+ init: { headers: Record<string, string>; body: string },
46
+ ) => Promise<{ status: number; body: string } | null>;
47
+
48
+ /** A bearer and when it stops being one; `null` when the issuer did not say. */
49
+ export type AcquiredToken = { token: string; expiresAt: number | null };
50
+
51
+ export type AcquireResult =
52
+ | { ok: true; value: AcquiredToken }
53
+ | {
54
+ ok: false;
55
+ error:
56
+ | "token_url_insecure"
57
+ | "token_request_failed"
58
+ | "token_rejected"
59
+ | "token_response_invalid";
60
+ };
61
+
62
+ const tokenResponseSchema = z.object({
63
+ access_token: z.string().min(1),
64
+ token_type: z.string().optional(),
65
+ // Some issuers (Azure AD v1 among them) send the lifetime as a string.
66
+ expires_in: z
67
+ .union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
68
+ .pipe(z.number().nonnegative())
69
+ .optional(),
70
+ });
71
+
72
+ /**
73
+ * The client credentials grant: a form-encoded POST with the client
74
+ * authenticated by `client_secret_basic` (RFC 6749 §2.3.1, each half
75
+ * form-encoded before base64). The endpoint's body never leaves this
76
+ * function: a failure is one of three literals, since the body may echo
77
+ * the client secret or carry the issuer's own error detail.
78
+ */
79
+ export async function acquireOAuth2ClientCredentials(
80
+ definition: OAuth2ClientCredentials,
81
+ post: TokenPost,
82
+ ) {
83
+ // Judged again here, not only when the definition was saved: the policy
84
+ // may have tightened since, and a secret never travels where it may not.
85
+ if (!canCarrySecret(definition.tokenUrl)) {
86
+ return { ok: false, error: "token_url_insecure" } satisfies AcquireResult;
87
+ }
88
+ const basic = Buffer.from(
89
+ `${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
90
+ ).toString("base64");
91
+ const answer = await post(definition.tokenUrl, {
92
+ headers: {
93
+ Authorization: `Basic ${basic}`,
94
+ "Content-Type": "application/x-www-form-urlencoded",
95
+ Accept: "application/json",
96
+ },
97
+ body: new URLSearchParams({
98
+ grant_type: "client_credentials",
99
+ ...(definition.scope ? { scope: definition.scope } : {}),
100
+ }).toString(),
101
+ });
102
+ if (!answer) return { ok: false, error: "token_request_failed" } satisfies AcquireResult;
103
+ if (answer.status < 200 || answer.status >= 300) {
104
+ return { ok: false, error: "token_rejected" } satisfies AcquireResult;
105
+ }
106
+ let parsed: z.ZodSafeParseResult<z.infer<typeof tokenResponseSchema>>;
107
+ try {
108
+ parsed = tokenResponseSchema.safeParse(JSON.parse(answer.body));
109
+ } catch {
110
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
111
+ }
112
+ if (!parsed.success || (parsed.data.token_type ?? "bearer").toLowerCase() !== "bearer") {
113
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
114
+ }
115
+ const issuedAt = Date.now();
116
+ return {
117
+ ok: true as const,
118
+ value: {
119
+ token: parsed.data.access_token,
120
+ expiresAt:
121
+ parsed.data.expires_in === undefined
122
+ ? null
123
+ : issuedAt + parsed.data.expires_in * 1000,
124
+ },
125
+ } satisfies AcquireResult;
126
+ }
127
+
128
+ /** A token is not reused inside this margin of its expiry: the call it is presented on still has to complete. */
129
+ const EXPIRY_MARGIN_MS = 30_000;
130
+
131
+ /** Equal definitions share one key; a rotated secret is another definition and another token. Kept in memory only, like the definitions themselves. */
132
+ function keyOf(definition: CredentialDefinition) {
133
+ return JSON.stringify(definition);
134
+ }
135
+
136
+ /**
137
+ * Acquired tokens by definition, reused until their expiry margin. Concurrent
138
+ * acquisitions of one definition share a single request; a token the issuer
139
+ * gave no expiry for is answered but not kept, since nothing says when it
140
+ * stops working. `invalidate` is for the caller that saw a 401: the next
141
+ * resolve acquires again. Pure, so the console's client bundle may import
142
+ * the schemas beside it.
143
+ */
144
+ export function createCredentialCache(now: () => number = Date.now) {
145
+ const tokens = new Map<string, AcquiredToken>();
146
+ const inFlight = new Map<string, Promise<AcquireResult>>();
147
+
148
+ return {
149
+ async get(definition: CredentialDefinition, acquire: () => Promise<AcquireResult>) {
150
+ const key = keyOf(definition);
151
+ const cached = tokens.get(key);
152
+ if (
153
+ cached &&
154
+ cached.expiresAt !== null &&
155
+ cached.expiresAt - EXPIRY_MARGIN_MS > now()
156
+ ) {
157
+ return { ok: true, value: cached } satisfies AcquireResult;
158
+ }
159
+ tokens.delete(key);
160
+ const pending = inFlight.get(key);
161
+ if (pending) return pending;
162
+ const acquiring = acquire()
163
+ .then((result) => {
164
+ if (
165
+ result.ok &&
166
+ result.value.expiresAt !== null &&
167
+ result.value.expiresAt - EXPIRY_MARGIN_MS > now()
168
+ ) {
169
+ tokens.set(key, result.value);
170
+ }
171
+ return result;
172
+ })
173
+ .finally(() => inFlight.delete(key));
174
+ inFlight.set(key, acquiring);
175
+ return acquiring;
176
+ },
177
+ invalidate(definition: CredentialDefinition) {
178
+ tokens.delete(keyOf(definition));
179
+ },
180
+ };
181
+ }
182
+
183
+ export type CredentialCache = ReturnType<typeof createCredentialCache>;
@@ -1,7 +1,7 @@
1
1
  import { lookup } from "node:dns/promises";
2
- import { isIP } from "node:net";
3
2
  import type { LookupFunction } from "node:net";
4
3
  import { Agent } from "undici";
4
+ import { isEgressRelaxed, isPrivateAddress } from "./url";
5
5
 
6
6
  /*
7
7
  * The outbound network policy for URLs a tenant controls (tool endpoints,
@@ -13,72 +13,18 @@ import { Agent } from "undici";
13
13
 
14
14
  export { credentialForms, scrubCredentialStrings } from "./scrub";
15
15
 
16
- type Range = { first: number; last: number };
17
-
18
- /** IPv4 /8 and /16 blocks that never denote a public host, as `[a, b]` octet ranges. */
19
- const BLOCKED_V4 = [
20
- { a: 0 },
21
- { a: 10 },
22
- { a: 127 },
23
- { a: 100, b: { first: 64, last: 127 } },
24
- { a: 169, b: { first: 254, last: 254 } },
25
- { a: 172, b: { first: 16, last: 31 } },
26
- { a: 192, b: { first: 168, last: 168 } },
27
- { a: 198, b: { first: 18, last: 19 } },
28
- ] satisfies Array<{ a: number; b?: Range }>;
29
-
30
- /** Multicast and reserved: everything from 224.0.0.0 up. */
31
- const BLOCKED_V4_FROM = 224;
32
-
33
- function isBlockedV4(address: string) {
34
- const [a = 0, b = 0] = address.split(".").map(Number);
35
- if (a >= BLOCKED_V4_FROM) return true;
36
- return BLOCKED_V4.some(
37
- (block) => block.a === a && (!block.b || (b >= block.b.first && b <= block.b.last)),
38
- );
39
- }
40
-
41
- function isBlockedV6(address: string) {
42
- const normalized = address.toLowerCase();
43
- if (normalized.startsWith("::ffff:")) {
44
- const mapped = normalized.slice("::ffff:".length);
45
- return isIP(mapped) === 4 ? isBlockedV4(mapped) : true;
46
- }
47
- return (
48
- normalized === "::" ||
49
- normalized === "::1" ||
50
- normalized.startsWith("fc") ||
51
- normalized.startsWith("fd") ||
52
- /^fe[89ab]/.test(normalized)
53
- );
54
- }
55
-
56
16
  /**
57
- * Whether an IP address may be dialed. `EGRESS_ALLOW_PRIVATE_NETWORK=true` is
17
+ * Whether an IP address must not be dialed. `EGRESS_ALLOW_PRIVATE_NETWORK=true` is
58
18
  * the development opt-out for localhost and private demo backends; it disables
59
- * every range check and must never be set in production.
19
+ * every range check, lets a secret travel over plain http to a private host
20
+ * (`canCarrySecret`), and must never be set in production.
60
21
  */
61
- export function isBlockedAddress(address: string) {
62
- if (process.env.EGRESS_ALLOW_PRIVATE_NETWORK === "true") return false;
63
- return isIP(address) === 6 ? isBlockedV6(address) : isBlockedV4(address);
22
+ function isBlockedAddress(address: string) {
23
+ if (isEgressRelaxed()) return false;
24
+ return isPrivateAddress(address);
64
25
  }
65
26
 
66
- /**
67
- * Whether a credential may travel to this URL: over TLS anywhere, or over
68
- * plain http to loopback alone, for a service on the developer's machine.
69
- */
70
- export function isTlsOrLoopback(url: string) {
71
- try {
72
- const { protocol, hostname } = new URL(url);
73
- if (protocol === "https:") return true;
74
- return (
75
- protocol === "http:" &&
76
- (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
77
- );
78
- } catch {
79
- return false;
80
- }
81
- }
27
+ export { canCarrySecret, isEgressRelaxed } from "./url";
82
28
 
83
29
  export function egressError() {
84
30
  const error: NodeJS.ErrnoException = new Error("egress blocked");
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "../json";
2
+
1
3
  const REDACTION_MARKER = "[REDACTED]";
2
4
 
3
5
  /**
@@ -49,7 +51,7 @@ export function scrubCredentialStrings(value: unknown, credentials: string[]): u
49
51
  if (Array.isArray(value)) {
50
52
  return value.map((item) => scrubCredentialStrings(item, credentials));
51
53
  }
52
- if (typeof value !== "object" || value === null) return value;
54
+ if (!isRecord(value)) return value;
53
55
  return Object.fromEntries(
54
56
  Object.entries(value).map(([name, child]) => [
55
57
  scrub(name),
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Where a secret may be sent, judged from the URL alone. Pure and free of
3
+ * node modules, so a browser bundle judges a URL the same way the server does.
4
+ */
5
+
6
+ type Range = { first: number; last: number };
7
+
8
+ /** IPv4 /8 and /16 blocks that never denote a public host, as `[a, b]` octet ranges. */
9
+ const BLOCKED_V4 = [
10
+ { a: 0 },
11
+ { a: 10 },
12
+ { a: 127 },
13
+ { a: 100, b: { first: 64, last: 127 } },
14
+ { a: 169, b: { first: 254, last: 254 } },
15
+ { a: 172, b: { first: 16, last: 31 } },
16
+ { a: 192, b: { first: 168, last: 168 } },
17
+ { a: 198, b: { first: 18, last: 19 } },
18
+ ] satisfies Array<{ a: number; b?: Range }>;
19
+
20
+ /** Multicast and reserved: everything from 224.0.0.0 up. */
21
+ const BLOCKED_V4_FROM = 224;
22
+
23
+ const V4_LITERAL = /^\d{1,3}(\.\d{1,3}){3}$/;
24
+
25
+ function isBlockedV4(address: string) {
26
+ const [a = 0, b = 0] = address.split(".").map(Number);
27
+ if (a >= BLOCKED_V4_FROM) return true;
28
+ return BLOCKED_V4.some(
29
+ (block) => block.a === a && (!block.b || (b >= block.b.first && b <= block.b.last)),
30
+ );
31
+ }
32
+
33
+ /**
34
+ * The IPv4 address inside an IPv4-mapped IPv6 one, which arrives dotted from a
35
+ * DNS answer (`::ffff:8.8.8.8`) and as two hex groups from a parsed URL
36
+ * (`::ffff:808:808`). `null` when the tail is neither.
37
+ */
38
+ function mappedV4(mapped: string) {
39
+ if (V4_LITERAL.test(mapped)) return mapped;
40
+ const groups = mapped.split(":");
41
+ if (groups.length !== 2 || !groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null;
42
+ const [high = 0, low = 0] = groups.map((group) => parseInt(group, 16));
43
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
44
+ }
45
+
46
+ function isBlockedV6(address: string) {
47
+ const normalized = address.toLowerCase();
48
+ if (normalized.startsWith("::ffff:")) {
49
+ const mapped = mappedV4(normalized.slice("::ffff:".length));
50
+ return mapped === null || isBlockedV4(mapped);
51
+ }
52
+ return (
53
+ normalized === "::" ||
54
+ normalized === "::1" ||
55
+ normalized.startsWith("fc") ||
56
+ normalized.startsWith("fd") ||
57
+ /^fe[89ab]/.test(normalized)
58
+ );
59
+ }
60
+
61
+ /** Whether an IP literal (v4 dotted, or v6 without brackets) lies in a range that never denotes a public host. */
62
+ export function isPrivateAddress(address: string) {
63
+ return address.includes(":") ? isBlockedV6(address) : isBlockedV4(address);
64
+ }
65
+
66
+ function isLoopback(hostname: string) {
67
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
68
+ }
69
+
70
+ /**
71
+ * A host on the developer's own network: a private IP literal, a bare compose
72
+ * service name such as `app`, or a `.internal` / `.local` / `.localhost` name
73
+ * such as `host.docker.internal`. Public names and addresses are not.
74
+ */
75
+ function isPrivateHost(hostname: string) {
76
+ const bare = hostname.replace(/^\[|\]$/g, "");
77
+ if (bare.includes(":") || V4_LITERAL.test(bare)) return isPrivateAddress(bare);
78
+ return !hostname.includes(".") || /\.(internal|local|localhost)$/.test(hostname);
79
+ }
80
+
81
+ /**
82
+ * The development opt-out of the egress policy: `EGRESS_ALLOW_PRIVATE_NETWORK=true`
83
+ * lifts every range check and lets a secret travel over plain http within the
84
+ * private network. Read lazily and through `globalThis`, so a browser bundle,
85
+ * which has no `process`, sees it off.
86
+ */
87
+ export function isEgressRelaxed() {
88
+ return globalThis.process?.env?.EGRESS_ALLOW_PRIVATE_NETWORK === "true";
89
+ }
90
+
91
+ /**
92
+ * Whether a secret may be sent to this URL: over TLS anywhere, or over plain
93
+ * http to loopback, and while the egress policy is relaxed to any private host.
94
+ * Pass `relaxed` explicitly where the policy is known from elsewhere (the
95
+ * browser form reads it from the session) or must stay strict regardless of it
96
+ * (a URL the browser or the CLI dials, not the server).
97
+ */
98
+ export function canCarrySecret(url: string, relaxed = isEgressRelaxed()) {
99
+ try {
100
+ const { protocol, hostname: raw } = new URL(url);
101
+ if (protocol === "https:") return true;
102
+ if (protocol !== "http:") return false;
103
+ // A trailing dot is the DNS root, the same name: `host.docker.internal.` is `host.docker.internal`.
104
+ const hostname = raw.replace(/\.$/, "");
105
+ return isLoopback(hostname) || (relaxed && isPrivateHost(hostname));
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
@@ -30,9 +30,7 @@ export function defineDomain(def: Omit<DomainDef, "__brand">) {
30
30
  }
31
31
 
32
32
  export function defineEndpoint(input: EndpointInput) {
33
- const propertiesDescriptions = input.propertiesDescriptions ?? {};
34
-
35
- const normalized = input.autoGenerated;
33
+ const generated = input.autoGenerated;
36
34
 
37
35
  return {
38
36
  __brand: "endpoint" as const,
@@ -40,13 +38,13 @@ export function defineEndpoint(input: EndpointInput) {
40
38
  description: input.description ?? input.name,
41
39
  path: input.path,
42
40
  method: input.method,
43
- propertiesDescriptions,
44
- params: normalized ? [...normalized.params] : [],
45
- body: normalized ? [...normalized.body] : [],
46
- response: normalized ? [...normalized.response] : [],
47
- successStatus: normalized?.successStatus ?? 200,
48
- errorStatuses: normalized ? [...normalized.errorStatuses] : [],
49
- responseKind: normalized?.responseKind ?? "object",
41
+ propertiesDescriptions: input.propertiesDescriptions ?? {},
42
+ params: generated ? [...generated.params] : [],
43
+ body: generated ? [...generated.body] : [],
44
+ response: generated ? [...generated.response] : [],
45
+ successStatus: generated?.successStatus ?? 200,
46
+ errorStatuses: generated ? [...generated.errorStatuses] : [],
47
+ responseKind: generated?.responseKind ?? "object",
50
48
  queries: input.queries,
51
49
  mutates: input.mutates,
52
50
  returns: input.returns,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * An object that is neither null nor an array: the shape a JSON object parses to, and the
3
+ * guard every walker over an untyped payload (a JSON Schema, an OpenAPI document, a parsed
4
+ * config or cache file) starts from.
5
+ */
6
+ export function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ /**
11
+ * One reference token of a JSON Pointer (RFC 6901): `~` is written `~0` and `/` is written
12
+ * `~1`. Escaping replaces `~` first and unescaping `~1` first, so a literal `~1` survives the
13
+ * round trip.
14
+ */
15
+ export function escapePointerToken(token: string) {
16
+ return token.replaceAll("~", "~0").replaceAll("/", "~1");
17
+ }
18
+
19
+ export function unescapePointerToken(token: string) {
20
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
21
+ }
@@ -4,12 +4,12 @@ import {
4
4
  boundVariablesSchema,
5
5
  catalogVersionSchema,
6
6
  greetingSchema,
7
+ IDENTIFIER_PATTERN,
7
8
  localeSchema,
8
9
  localizedTextSchema,
9
10
  mentionedToolSchema,
10
11
  slugSchema,
11
12
  suggestionsSchema,
12
- TOOL_NAME_PATTERN,
13
13
  toolSurfaceSchema,
14
14
  variableDeclarationSchema,
15
15
  } from "./grammar";
@@ -106,7 +106,7 @@ export type ToolEmbed = z.infer<typeof toolEmbedSchema>;
106
106
 
107
107
  const toolSignatureBaseSchema = z.object({
108
108
  toolId: z.uuid(),
109
- name: z.string().regex(TOOL_NAME_PATTERN),
109
+ name: z.string().regex(IDENTIFIER_PATTERN),
110
110
  version: z.number().int().positive(),
111
111
  readOnly: z.boolean(),
112
112
  /**