@m6d/cortex-cli 0.3.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.
- package/package.json +1 -1
- package/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +22 -5
- package/src/contracts/egress/index.ts +8 -47
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +101 -9
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +19 -25
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
package/src/config/validate.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
/**
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
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. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { canCarrySecret } from "../egress/url";
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export { canCarrySecret };
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
* A credential is a value Cortex presents to an upstream: one it holds
|
|
@@ -21,8 +21,13 @@ export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
|
|
|
21
21
|
}),
|
|
22
22
|
z.object({
|
|
23
23
|
kind: z.literal("oauth2-client-credentials"),
|
|
24
|
-
/** The token endpoint; a client secret travels
|
|
25
|
-
tokenUrl: z
|
|
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
|
+
),
|
|
26
31
|
clientId: z.string().min(1),
|
|
27
32
|
clientSecret: z.string().min(1),
|
|
28
33
|
scope: z.string().min(1).optional(),
|
|
@@ -45,7 +50,14 @@ export type AcquiredToken = { token: string; expiresAt: number | null };
|
|
|
45
50
|
|
|
46
51
|
export type AcquireResult =
|
|
47
52
|
| { ok: true; value: AcquiredToken }
|
|
48
|
-
| {
|
|
53
|
+
| {
|
|
54
|
+
ok: false;
|
|
55
|
+
error:
|
|
56
|
+
| "token_url_insecure"
|
|
57
|
+
| "token_request_failed"
|
|
58
|
+
| "token_rejected"
|
|
59
|
+
| "token_response_invalid";
|
|
60
|
+
};
|
|
49
61
|
|
|
50
62
|
const tokenResponseSchema = z.object({
|
|
51
63
|
access_token: z.string().min(1),
|
|
@@ -68,6 +80,11 @@ export async function acquireOAuth2ClientCredentials(
|
|
|
68
80
|
definition: OAuth2ClientCredentials,
|
|
69
81
|
post: TokenPost,
|
|
70
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
|
+
}
|
|
71
88
|
const basic = Buffer.from(
|
|
72
89
|
`${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
|
|
73
90
|
).toString("base64");
|
|
@@ -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,57 +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
|
|
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
|
|
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
|
-
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
22
|
+
function isBlockedAddress(address: string) {
|
|
23
|
+
if (isEgressRelaxed()) return false;
|
|
24
|
+
return isPrivateAddress(address);
|
|
64
25
|
}
|
|
65
26
|
|
|
66
|
-
export {
|
|
27
|
+
export { canCarrySecret, isEgressRelaxed } from "./url";
|
|
67
28
|
|
|
68
29
|
export function egressError() {
|
|
69
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 (
|
|
54
|
+
if (!isRecord(value)) return value;
|
|
53
55
|
return Object.fromEntries(
|
|
54
56
|
Object.entries(value).map(([name, child]) => [
|
|
55
57
|
scrub(name),
|
|
@@ -1,16 +1,108 @@
|
|
|
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
|
+
|
|
1
91
|
/**
|
|
2
|
-
* Whether a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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).
|
|
5
97
|
*/
|
|
6
|
-
export function
|
|
98
|
+
export function canCarrySecret(url: string, relaxed = isEgressRelaxed()) {
|
|
7
99
|
try {
|
|
8
|
-
const { protocol, hostname } = new URL(url);
|
|
100
|
+
const { protocol, hostname: raw } = new URL(url);
|
|
9
101
|
if (protocol === "https:") return true;
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
);
|
|
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));
|
|
14
106
|
} catch {
|
|
15
107
|
return false;
|
|
16
108
|
}
|
|
@@ -30,9 +30,7 @@ export function defineDomain(def: Omit<DomainDef, "__brand">) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function defineEndpoint(input: EndpointInput) {
|
|
33
|
-
const
|
|
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:
|
|
45
|
-
body:
|
|
46
|
-
response:
|
|
47
|
-
successStatus:
|
|
48
|
-
errorStatuses:
|
|
49
|
-
responseKind:
|
|
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(
|
|
109
|
+
name: z.string().regex(IDENTIFIER_PATTERN),
|
|
110
110
|
version: z.number().int().positive(),
|
|
111
111
|
readOnly: z.boolean(),
|
|
112
112
|
/**
|