@m6d/cortex-cli 0.5.1 → 0.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.
- package/package.json +1 -1
- package/src/commands/cc.ts +1 -1
- package/src/contracts/credentials/index.ts +2 -2
- package/src/contracts/egress/index.ts +27 -14
- package/src/contracts/egress/url.ts +46 -26
- package/src/contracts/runtime/sync.ts +14 -1
- package/src/contracts/runtime/triggers.ts +36 -3
- package/src/scaffold/files.ts +1 -1
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MESSAGE_ROLES, type TokenUsage } from "../wire/index";
|
|
3
3
|
import { agentRefSchema, agentSlugSchema, variableDeclarationSchema } from "./grammar";
|
|
4
|
-
import { TRIGGER_OUTCOMES, TRIGGER_SOURCES } from "./triggers";
|
|
4
|
+
import { approvalRequestedEventSchema, TRIGGER_OUTCOMES, TRIGGER_SOURCES } from "./triggers";
|
|
5
5
|
import { DATASET_ERROR_KINDS } from "./datasets";
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -141,6 +141,19 @@ export const syncEventSchema = z.discriminatedUnion("type", [
|
|
|
141
141
|
createdAt: z.iso.datetime(),
|
|
142
142
|
finishedAt: z.iso.datetime().nullable(),
|
|
143
143
|
}),
|
|
144
|
+
/**
|
|
145
|
+
* A run on a cc-backed agent parked on approval. cc delivers it to the
|
|
146
|
+
* published webhooks in the agent's audience; a code agent reacts in
|
|
147
|
+
* code and sends none.
|
|
148
|
+
*/
|
|
149
|
+
z.object({
|
|
150
|
+
type: z.literal("approval.requested"),
|
|
151
|
+
id: z.uuid(),
|
|
152
|
+
agentSlug: agentSlugSchema,
|
|
153
|
+
agentRef: agentRefSchema,
|
|
154
|
+
payload: approvalRequestedEventSchema,
|
|
155
|
+
createdAt: z.iso.datetime(),
|
|
156
|
+
}),
|
|
144
157
|
/** What the server offers console-created agents, sent whole at boot and replacing the previous declaration. */
|
|
145
158
|
z.object({
|
|
146
159
|
type: z.literal("server.declare"),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { datasetIdentifierSchema } from "./datasets";
|
|
3
3
|
import {
|
|
4
|
+
agentRefSchema,
|
|
4
5
|
agentSlugSchema,
|
|
5
6
|
localizedTextSchema,
|
|
6
7
|
mentionedToolSchema,
|
|
@@ -152,6 +153,38 @@ export type TriggerAuthResponse = z.infer<typeof triggerAuthResponseSchema>;
|
|
|
152
153
|
|
|
153
154
|
/* The server answers a caller firing `POST /agents/:agentSlug/triggers/:name`. */
|
|
154
155
|
|
|
156
|
+
/** One server tool call a parked run waits on; `POST .../firings/:id/answer` decides it. */
|
|
157
|
+
const triggerApprovalSchema = z.object({
|
|
158
|
+
toolCallId: z.string(),
|
|
159
|
+
toolName: z.string(),
|
|
160
|
+
input: z.unknown(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** The host-reaction events a run reports. One so far; the shape is generic. */
|
|
164
|
+
export const HOST_EVENTS = ["approval_requested"] as const;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A run parked on server tools that need the user's approval, as every host
|
|
168
|
+
* hears of it: the code hook's argument and a webhook's body are this one
|
|
169
|
+
* object. `agentId` is the slug; `agentRef` is cc's id when cc backs the
|
|
170
|
+
* agent. `firingId` and `triggerName` are set when a trigger started the run,
|
|
171
|
+
* which is what a host answering through the firing route needs.
|
|
172
|
+
*/
|
|
173
|
+
export const approvalRequestedEventSchema = z.object({
|
|
174
|
+
event: z.literal("approval_requested"),
|
|
175
|
+
agentId: z.string(),
|
|
176
|
+
agentRef: agentRefSchema.optional(),
|
|
177
|
+
threadId: z.string(),
|
|
178
|
+
runId: z.string(),
|
|
179
|
+
userId: z.string(),
|
|
180
|
+
firingId: z.uuid().optional(),
|
|
181
|
+
triggerName: z.string().optional(),
|
|
182
|
+
/** The hook always gets each call's `input`; a webhook body leaves it out when the whole would be too large for the wire. */
|
|
183
|
+
approvals: z.array(triggerApprovalSchema.extend({ input: z.unknown().optional() })),
|
|
184
|
+
occurredAt: z.iso.datetime(),
|
|
185
|
+
});
|
|
186
|
+
export type ApprovalRequestedEvent = z.infer<typeof approvalRequestedEventSchema>;
|
|
187
|
+
|
|
155
188
|
export const triggerFiringResponseSchema = z.discriminatedUnion("outcome", [
|
|
156
189
|
z.object({
|
|
157
190
|
firingId: z.uuid(),
|
|
@@ -165,9 +198,7 @@ export const triggerFiringResponseSchema = z.discriminatedUnion("outcome", [
|
|
|
165
198
|
outcome: z.literal("awaiting_approval"),
|
|
166
199
|
threadId: z.uuid(),
|
|
167
200
|
runId: z.string(),
|
|
168
|
-
approvals: z.array(
|
|
169
|
-
z.object({ toolCallId: z.string(), toolName: z.string(), input: z.unknown() }),
|
|
170
|
-
),
|
|
201
|
+
approvals: z.array(triggerApprovalSchema),
|
|
171
202
|
}),
|
|
172
203
|
z.object({
|
|
173
204
|
firingId: z.uuid(),
|
|
@@ -216,6 +247,8 @@ export const triggerFiringRecordSchema = z.object({
|
|
|
216
247
|
reason: z.string().nullable(),
|
|
217
248
|
/** Set once a run with an output schema completed; null otherwise. */
|
|
218
249
|
output: z.unknown(),
|
|
250
|
+
/** The calls an `awaiting_approval` firing waits on; filled by the single-firing read, empty in the list and for every other outcome. */
|
|
251
|
+
approvals: z.array(triggerApprovalSchema),
|
|
219
252
|
createdAt: z.iso.datetime(),
|
|
220
253
|
finishedAt: z.iso.datetime().nullable(),
|
|
221
254
|
});
|
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.7.0",
|
|
31
31
|
"@tanstack/ai": "^0.55.0",
|
|
32
32
|
"@tanstack/ai-openai": "^0.22.8",
|
|
33
33
|
hono: "^4.12.3",
|