@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold and operate Cortex servers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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, false)) return 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 private host while the egress policy is relaxed. */
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 (or a private host while the egress policy is relaxed)",
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 { isEgressRelaxed, isPrivateAddress } from "./url";
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 an IP address must not be dialed. `EGRESS_ALLOW_PRIVATE_NETWORK=true` is
18
- * the development opt-out for localhost and private demo backends; it disables
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 isBlockedAddress(address: string) {
23
- if (isEgressRelaxed()) return false;
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, isEgressRelaxed } from "./url";
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 (!first || addresses.some((address) => isBlockedAddress(address.address))) {
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 exact-host allowlist, and every resolved address public. Returns
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 (allowedHostnames && !allowedHostnames.map(normalize).includes(normalize(hostname))) {
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 preflight.length > 0 && !preflight.some((address) => isBlockedAddress(address.address));
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
- * 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.
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 === "[::1]";
70
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
68
71
  }
69
72
 
70
73
  /**
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
+ * 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 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);
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 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.
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 isEgressRelaxed() {
88
- return globalThis.process?.env?.EGRESS_ALLOW_PRIVATE_NETWORK === "true";
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, 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).
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, relaxed = isEgressRelaxed()) {
120
+ export function canCarrySecret(url: string, allowed = egressAllowedHosts()) {
99
121
  try {
100
- const { protocol, hostname: raw } = new URL(url);
122
+ const { protocol, hostname } = new URL(url);
101
123
  if (protocol === "https:") return true;
102
124
  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));
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
  });
@@ -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.5.1",
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",