@m6d/cortex-cli 0.5.0 → 0.6.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
CHANGED
package/src/commands/cc.ts
CHANGED
|
@@ -167,7 +167,7 @@ function withProfileFile<T>(operation: () => T) {
|
|
|
167
167
|
|
|
168
168
|
/** The key rides on every call, so the URL it goes to must be https (or loopback). */
|
|
169
169
|
function requireTls(url: string) {
|
|
170
|
-
if (canCarrySecret(url,
|
|
170
|
+
if (canCarrySecret(url, [])) return url;
|
|
171
171
|
return report.fail({
|
|
172
172
|
what: `${url} is not https`,
|
|
173
173
|
blocks: [
|
|
@@ -21,12 +21,12 @@ 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 over TLS, to loopback, or to a
|
|
24
|
+
/** The token endpoint; a client secret travels over TLS, to loopback, or to a host the egress policy admits. */
|
|
25
25
|
tokenUrl: z
|
|
26
26
|
.url()
|
|
27
27
|
.refine(
|
|
28
28
|
(url) => canCarrySecret(url),
|
|
29
|
-
"token URL must be https, or http to loopback
|
|
29
|
+
"token URL must be https, or http to loopback or to a host the egress policy admits",
|
|
30
30
|
),
|
|
31
31
|
clientId: z.string().min(1),
|
|
32
32
|
clientSecret: z.string().min(1),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import type { LookupFunction } from "node:net";
|
|
3
3
|
import { Agent } from "undici";
|
|
4
|
-
import {
|
|
4
|
+
import { isEgressAllowedHost, isPrivateAddress, normalizeHost } from "./url";
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
* The outbound network policy for URLs a tenant controls (tool endpoints,
|
|
@@ -14,17 +14,14 @@ import { isEgressRelaxed, isPrivateAddress } from "./url";
|
|
|
14
14
|
export { credentialForms, scrubCredentialStrings } from "./scrub";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Whether
|
|
18
|
-
*
|
|
19
|
-
* every range check, lets a secret travel over plain http to a private host
|
|
20
|
-
* (`canCarrySecret`), and must never be set in production.
|
|
17
|
+
* Whether a host may be dialed at these addresses: any address when the
|
|
18
|
+
* policy admits the host (`EGRESS_ALLOWED_HOSTS`), else only public ones.
|
|
21
19
|
*/
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
return isPrivateAddress(address);
|
|
20
|
+
function isDialable(hostname: string, addresses: string[]) {
|
|
21
|
+
return isEgressAllowedHost(hostname) || !addresses.some(isPrivateAddress);
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
export { canCarrySecret,
|
|
24
|
+
export { canCarrySecret, egressAllowedHosts } from "./url";
|
|
28
25
|
|
|
29
26
|
export function egressError() {
|
|
30
27
|
const error: NodeJS.ErrnoException = new Error("egress blocked");
|
|
@@ -51,7 +48,13 @@ const pinnedLookup: LookupFunction = (hostname, options, callback) => {
|
|
|
51
48
|
lookup(hostname, { all: true, verbatim: true }).then(
|
|
52
49
|
(addresses) => {
|
|
53
50
|
const first = addresses[0];
|
|
54
|
-
if (
|
|
51
|
+
if (
|
|
52
|
+
!first ||
|
|
53
|
+
!isDialable(
|
|
54
|
+
hostname,
|
|
55
|
+
addresses.map((address) => address.address),
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
55
58
|
callback(egressError(), "", 0);
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
@@ -74,23 +77,33 @@ const pinnedLookup: LookupFunction = (hostname, options, callback) => {
|
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* Checks a URL before any socket opens: http(s) only, no embedded credentials,
|
|
77
|
-
* an optional
|
|
80
|
+
* an optional per-call restriction to `allowedHostnames`, and the host dialable
|
|
81
|
+
* (every resolved address public, or the host admitted by the policy). Returns
|
|
78
82
|
* the bare hostname on success. `pinnedLookup` re-checks at connect time; this
|
|
79
83
|
* is the cheap, clean refusal for a statically bad target.
|
|
80
84
|
*/
|
|
81
85
|
export async function checkEgressTarget(target: URL, allowedHostnames?: string[]) {
|
|
86
|
+
// Resolved as written, root dot included, so an absolute name is never search-suffixed; normalized only to compare.
|
|
82
87
|
const hostname = target.hostname.replace(/^\[(.*)]$/, "$1");
|
|
83
|
-
const normalize = (host: string) => host.toLowerCase().replace(/\.$/, "");
|
|
84
88
|
|
|
85
89
|
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) {
|
|
86
90
|
return false;
|
|
87
91
|
}
|
|
88
|
-
if (
|
|
92
|
+
if (
|
|
93
|
+
allowedHostnames &&
|
|
94
|
+
!allowedHostnames.map(normalizeHost).includes(normalizeHost(hostname))
|
|
95
|
+
) {
|
|
89
96
|
return false;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
const preflight = await lookup(hostname, { all: true, verbatim: true });
|
|
93
|
-
return
|
|
100
|
+
return (
|
|
101
|
+
preflight.length > 0 &&
|
|
102
|
+
isDialable(
|
|
103
|
+
hostname,
|
|
104
|
+
preflight.map((address) => address.address),
|
|
105
|
+
)
|
|
106
|
+
);
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The egress policy as far as a URL alone can tell: which addresses are
|
|
3
|
+
* private, which hosts the deployment admits, and where a secret may be
|
|
4
|
+
* sent. Pure and free of node modules, so a browser bundle judges a URL the
|
|
5
|
+
* same way the server does.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
type Range = { first: number; last: number };
|
|
@@ -63,46 +65,64 @@ export function isPrivateAddress(address: string) {
|
|
|
63
65
|
return address.includes(":") ? isBlockedV6(address) : isBlockedV4(address);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** Loopback by its normalized spelling: `[::1]` arrives from a URL without its brackets. */
|
|
66
69
|
function isLoopback(hostname: string) {
|
|
67
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "
|
|
70
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
+
* One spelling for a host: lower case, no DNS root dot, no IPv6 brackets, and
|
|
75
|
+
* an IPv6 literal compressed the way `URL` spells it, so `fd00:0::1` in the
|
|
76
|
+
* policy matches the `fd00::1` a parsed URL reports.
|
|
74
77
|
*/
|
|
75
|
-
function
|
|
76
|
-
const bare = hostname
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
export function normalizeHost(hostname: string) {
|
|
79
|
+
const bare = hostname
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/\.$/, "")
|
|
82
|
+
.replace(/^\[(.*)]$/, "$1");
|
|
83
|
+
if (!bare.includes(":")) return bare;
|
|
84
|
+
try {
|
|
85
|
+
return new URL(`http://[${bare}]`).hostname.replace(/^\[(.*)]$/, "$1");
|
|
86
|
+
} catch {
|
|
87
|
+
return bare;
|
|
88
|
+
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/**
|
|
82
|
-
* The
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
92
|
+
* The hosts the egress policy admits beyond its strict rules, from
|
|
93
|
+
* `EGRESS_ALLOWED_HOSTS`: hostnames separated by commas, or `*` for every
|
|
94
|
+
* host. A listed host may be dialed even when it resolves to a private
|
|
95
|
+
* address, and a secret may travel to it over plain http; everything else
|
|
96
|
+
* keeps the strict rules. Empty, the default, admits nothing. Read lazily and
|
|
97
|
+
* through `globalThis`, so a browser bundle, which has no `process`, sees
|
|
98
|
+
* nothing admitted.
|
|
86
99
|
*/
|
|
87
|
-
export function
|
|
88
|
-
return globalThis.process?.env?.
|
|
100
|
+
export function egressAllowedHosts() {
|
|
101
|
+
return (globalThis.process?.env?.EGRESS_ALLOWED_HOSTS ?? "")
|
|
102
|
+
.split(",")
|
|
103
|
+
.map((host) => host.trim())
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether the policy admits this host: it is listed, or the list is `*`. */
|
|
108
|
+
export function isEgressAllowedHost(hostname: string, allowed = egressAllowedHosts()) {
|
|
109
|
+
const wanted = normalizeHost(hostname);
|
|
110
|
+
return allowed.some((host) => host === "*" || normalizeHost(host) === wanted);
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
/**
|
|
92
|
-
* Whether a secret may be sent to this URL: over TLS anywhere,
|
|
93
|
-
* http to loopback,
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
114
|
+
* Whether a secret may be sent to this URL: over TLS anywhere, over plain
|
|
115
|
+
* http to loopback, or over plain http to a host the policy admits. Pass
|
|
116
|
+
* `allowed` explicitly where the policy is known from elsewhere (the browser
|
|
117
|
+
* form reads it from the session) or must stay strict regardless of it (a
|
|
118
|
+
* URL the browser or the CLI dials, not the server): `[]` admits nothing.
|
|
97
119
|
*/
|
|
98
|
-
export function canCarrySecret(url: string,
|
|
120
|
+
export function canCarrySecret(url: string, allowed = egressAllowedHosts()) {
|
|
99
121
|
try {
|
|
100
|
-
const { protocol, hostname
|
|
122
|
+
const { protocol, hostname } = new URL(url);
|
|
101
123
|
if (protocol === "https:") return true;
|
|
102
124
|
if (protocol !== "http:") return false;
|
|
103
|
-
|
|
104
|
-
const hostname = raw.replace(/\.$/, "");
|
|
105
|
-
return isLoopback(hostname) || (relaxed && isPrivateHost(hostname));
|
|
125
|
+
return isLoopback(normalizeHost(hostname)) || isEgressAllowedHost(hostname, allowed);
|
|
106
126
|
} catch {
|
|
107
127
|
return false;
|
|
108
128
|
}
|
|
@@ -152,6 +152,13 @@ export type TriggerAuthResponse = z.infer<typeof triggerAuthResponseSchema>;
|
|
|
152
152
|
|
|
153
153
|
/* The server answers a caller firing `POST /agents/:agentSlug/triggers/:name`. */
|
|
154
154
|
|
|
155
|
+
/** One server tool call a parked run waits on; `POST .../firings/:id/answer` decides it. */
|
|
156
|
+
const triggerApprovalSchema = z.object({
|
|
157
|
+
toolCallId: z.string(),
|
|
158
|
+
toolName: z.string(),
|
|
159
|
+
input: z.unknown(),
|
|
160
|
+
});
|
|
161
|
+
|
|
155
162
|
export const triggerFiringResponseSchema = z.discriminatedUnion("outcome", [
|
|
156
163
|
z.object({
|
|
157
164
|
firingId: z.uuid(),
|
|
@@ -165,9 +172,7 @@ export const triggerFiringResponseSchema = z.discriminatedUnion("outcome", [
|
|
|
165
172
|
outcome: z.literal("awaiting_approval"),
|
|
166
173
|
threadId: z.uuid(),
|
|
167
174
|
runId: z.string(),
|
|
168
|
-
approvals: z.array(
|
|
169
|
-
z.object({ toolCallId: z.string(), toolName: z.string(), input: z.unknown() }),
|
|
170
|
-
),
|
|
175
|
+
approvals: z.array(triggerApprovalSchema),
|
|
171
176
|
}),
|
|
172
177
|
z.object({
|
|
173
178
|
firingId: z.uuid(),
|
|
@@ -216,6 +221,8 @@ export const triggerFiringRecordSchema = z.object({
|
|
|
216
221
|
reason: z.string().nullable(),
|
|
217
222
|
/** Set once a run with an output schema completed; null otherwise. */
|
|
218
223
|
output: z.unknown(),
|
|
224
|
+
/** The calls an `awaiting_approval` firing waits on; filled by the single-firing read, empty in the list and for every other outcome. */
|
|
225
|
+
approvals: z.array(triggerApprovalSchema),
|
|
219
226
|
createdAt: z.iso.datetime(),
|
|
220
227
|
finishedAt: z.iso.datetime().nullable(),
|
|
221
228
|
});
|
package/src/scaffold/files.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
|
|
|
27
27
|
// sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
|
|
28
28
|
// them to that.
|
|
29
29
|
export const DEPENDENCIES = {
|
|
30
|
-
"@m6d/cortex-server": "~0.
|
|
30
|
+
"@m6d/cortex-server": "~0.6.0",
|
|
31
31
|
"@tanstack/ai": "^0.55.0",
|
|
32
32
|
"@tanstack/ai-openai": "^0.22.8",
|
|
33
33
|
hono: "^4.12.3",
|