@agentprojectcontext/apx 1.53.6 → 1.53.7

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": "@agentprojectcontext/apx",
3
- "version": "1.53.6",
3
+ "version": "1.53.7",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -188,6 +188,7 @@ export async function runResumedTurn(self, ctx) {
188
188
  previousMessages,
189
189
  target,
190
190
  author,
191
+ authorId,
191
192
  relationshipBlock,
192
193
  allowedTools,
193
194
  onEvent,
@@ -56,12 +56,17 @@ export async function handleUpdate(self, u) {
56
56
  // in-memory globalConfig in place so later messages in this daemon session
57
57
  // see the update. The resulting block is injected into whichever agent
58
58
  // answers (super-agent OR a routed project agent).
59
- const { sender } = registerSender({
59
+ const { sender, claimedOwner } = registerSender({
60
60
  cfg: self.globalConfig,
61
61
  channelName: self.channel.name,
62
62
  from: msg.from,
63
63
  chatType: msg.chat?.type,
64
64
  });
65
+ if (claimedOwner) {
66
+ // Trust-on-first-use: this sender just became owner of a previously
67
+ // ownerless private channel. Log it so an unexpected claim is visible.
68
+ self.log(`telegram[${self.channel.name}] owner claimed by user_id=${msg.from?.id} (${author}) — verify this is you`);
69
+ }
65
70
  const relationshipBlock = buildRelationshipBlock(sender);
66
71
  // Role-based tool gating for the super-agent path (guests → no tools).
67
72
  const allowedTools = resolveAllowedTools(self.globalConfig, sender);
@@ -287,6 +292,7 @@ export async function handleUpdate(self, u) {
287
292
  previousMessages,
288
293
  target,
289
294
  author,
295
+ authorId: msg.from?.id,
290
296
  relationshipBlock,
291
297
  allowedTools,
292
298
  contextNote: slashed.handled ? slashed.contextNote : "",
@@ -99,13 +99,15 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
99
99
  * inherit it. Throws on failure (caller decides abort-vs-error handling).
100
100
  */
101
101
  export function runTelegramSuperAgent(self, {
102
- chat_id, prompt, previousMessages, target, author, relationshipBlock,
102
+ chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
103
103
  allowedTools, contextNote, signal, onEvent,
104
104
  }) {
105
105
  const confirmAdapter = createTelegramConfirmAdapter({
106
106
  token: resolveBotToken(self.channel),
107
107
  chatId: chat_id,
108
108
  pendingStore: getConfirmStore(),
109
+ // Only the user who triggered this turn may answer its confirmations.
110
+ guardActorId: authorId ?? null,
109
111
  });
110
112
  return runSuperAgent({
111
113
  globalConfig: self.globalConfig,
@@ -218,6 +218,10 @@ function backupConfigBeforeLoss() {
218
218
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
219
219
  const backup = `${CONFIG_PATH}.${ts}.bak`;
220
220
  fs.copyFileSync(CONFIG_PATH, backup);
221
+ // config.json holds API keys + bot tokens — the backup must not be more
222
+ // permissive than the original. copyFileSync inherits the source mode on
223
+ // some platforms but not reliably, so pin it.
224
+ try { fs.chmodSync(backup, 0o600); } catch {}
221
225
  return backup;
222
226
  } catch {
223
227
  return null;
@@ -280,8 +284,14 @@ export function writeConfig(cfg) {
280
284
  if (cfg?._allowClear) delete cfg._allowClear;
281
285
 
282
286
  const tmp = `${CONFIG_PATH}.tmp`;
283
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
287
+ // 0o600: config.json stores provider API keys and the Telegram bot token.
288
+ // Without an explicit mode it lands at 0o644 (world-readable) — any local
289
+ // user could read the secrets. Match daemon.token / clients.json.
290
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
284
291
  fs.renameSync(tmp, CONFIG_PATH);
292
+ // writeFileSync's mode only applies when the tmp file is created; if it
293
+ // pre-existed with looser perms the bits stick. Pin it after the rename.
294
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch {}
285
295
  }
286
296
 
287
297
  // Normalise `model_fallback` to the new format (`models` as an ordered array
@@ -31,12 +31,15 @@ import { sendMessage, answerCallbackQuery as apiAnswerCallbackQuery, editMessage
31
31
  const TIMEOUT_MS = 60_000; // 60 s — long enough for a human, short enough to not block forever
32
32
 
33
33
  /**
34
- * @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore }} opts
34
+ * @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore, guardActorId?: string|number|null }} opts
35
35
  * @returns {{ requestConfirmation, handleCallbackQuery }}
36
36
  */
37
- export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
37
+ export function createTelegramConfirmAdapter({ token, chatId, pendingStore, guardActorId = null }) {
38
38
  async function requestConfirmation(tool, _args, description) {
39
- const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS });
39
+ // Bind this confirmation to the user who triggered the turn. In a group
40
+ // chat the keyboard is visible to everyone, so without this any member
41
+ // could tap "Yes" and approve the initiator's destructive action.
42
+ const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS, guardActorId });
40
43
 
41
44
  await sendConfirmKeyboard(token, chatId, description, correlationId, TIMEOUT_MS);
42
45
 
@@ -52,12 +55,21 @@ export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
52
55
 
53
56
  const [, correlationId, answer] = match;
54
57
  const confirmed = answer === "yes";
58
+ const presserId = callbackQuery.from?.id ?? null;
59
+
60
+ // Reject a bystander's press without consuming the pending entry or wiping
61
+ // the keyboard — the authorized initiator can still answer. We still return
62
+ // true (the callback matched our namespace and is handled).
63
+ if (!pendingStore.isActorAllowed(correlationId, presserId)) {
64
+ await answerCallbackQuery(token, callbackQuery.id, "⛔ Not your confirmation");
65
+ return true;
66
+ }
55
67
 
56
68
  // ACK the callback immediately to clear the loading spinner on the button.
57
69
  // Fire-and-forget — a slow ACK is annoying but not fatal.
58
70
  await answerCallbackQuery(token, callbackQuery.id, confirmed ? "✅ Confirmed" : "❌ Cancelled");
59
71
 
60
- const resolved = pendingStore.resolve(correlationId, confirmed);
72
+ const resolved = pendingStore.resolve(correlationId, confirmed, presserId);
61
73
 
62
74
  // If not resolved, the entry timed out or the process restarted — show "Expired"
63
75
  // so the user knows the button is no longer actionable.
@@ -28,30 +28,56 @@ export class ConfirmationPendingStore {
28
28
  * - correlationId: embed in the reply (button callback_data, SSE event…)
29
29
  * - promise: resolves to true (confirmed) or false (denied / timeout)
30
30
  *
31
+ * `guardActorId` (optional): when set, only resolve() calls that supply a
32
+ * matching actorId may answer this confirmation. Channels where the reply is
33
+ * visible to more than the initiator (a Telegram group's inline keyboard)
34
+ * pass the initiator's id so a bystander can't approve someone else's action.
35
+ * Channels whose transport is already 1:1/authenticated (web SSE + token,
36
+ * terminal) leave it null and behave exactly as before.
37
+ *
31
38
  * After timeoutMs with no response the promise auto-resolves to false.
32
39
  */
33
- create({ timeoutMs = 30_000 } = {}) {
40
+ create({ timeoutMs = 30_000, guardActorId = null } = {}) {
34
41
  const correlationId = generateId();
42
+ const guard = guardActorId == null ? null : String(guardActorId);
35
43
 
36
44
  const promise = new Promise((resolve) => {
37
45
  const timer = setTimeout(() => {
38
46
  this._pending.delete(correlationId);
39
47
  resolve(false);
40
48
  }, timeoutMs);
41
- this._pending.set(correlationId, { resolve, timer });
49
+ this._pending.set(correlationId, { resolve, timer, guardActorId: guard });
42
50
  });
43
51
 
44
52
  return { correlationId, promise };
45
53
  }
46
54
 
55
+ /**
56
+ * Is `actorId` allowed to answer this confirmation? True when the entry is
57
+ * unknown (let resolve() report "expired"), unguarded, or the actor matches
58
+ * the guard. Lets an adapter reject a bystander's press WITHOUT consuming the
59
+ * pending entry, so the real initiator can still respond.
60
+ */
61
+ isActorAllowed(correlationId, actorId) {
62
+ const entry = this._pending.get(correlationId);
63
+ if (!entry) return true;
64
+ if (entry.guardActorId == null) return true;
65
+ return actorId != null && String(actorId) === entry.guardActorId;
66
+ }
67
+
47
68
  /**
48
69
  * Resolve a pending confirmation.
49
70
  * Returns true if found and resolved, false if not found (timed out, already
50
- * resolved, or stale button after a process restart).
71
+ * resolved, stale button after a restart) OR if the entry is guarded and
72
+ * `actorId` doesn't match — in the mismatch case the entry is preserved so
73
+ * the authorized initiator can still answer.
51
74
  */
52
- resolve(correlationId, value) {
75
+ resolve(correlationId, value, actorId) {
53
76
  const entry = this._pending.get(correlationId);
54
77
  if (!entry) return false;
78
+ if (entry.guardActorId != null && (actorId == null || String(actorId) !== entry.guardActorId)) {
79
+ return false;
80
+ }
55
81
  clearTimeout(entry.timer);
56
82
  this._pending.delete(correlationId);
57
83
  entry.resolve(value);
@@ -15,6 +15,9 @@
15
15
  // Fetch resolver
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
+ import dns from "node:dns/promises";
19
+ import net from "node:net";
20
+
18
21
  let _fetch = null;
19
22
 
20
23
  async function getFetch() {
@@ -32,18 +35,65 @@ async function getFetch() {
32
35
  const DEFAULT_TIMEOUT = 30000;
33
36
  const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
34
37
 
35
- // Block private/link-local ranges and cloud metadata endpoints to prevent SSRF.
36
- const BLOCKED_HOST_RE = /^(localhost|metadata\.google\.internal\.?)$/i;
37
- const PRIVATE_IP_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|fd[0-9a-f]{2}:)/i;
38
+ // Block cloud metadata endpoints by name (they may resolve to public-looking
39
+ // IPs on some providers). IP-range blocking is done on the RESOLVED address
40
+ // below, not on the literal host string.
41
+ const BLOCKED_HOST_RE = /^(metadata\.google\.internal\.?|metadata\.goog\.?)$/i;
42
+
43
+ // True for an IPv4 string in a loopback / private / link-local / CGNAT range.
44
+ // Malformed input returns true (fail closed) — validateUrl only feeds this
45
+ // addresses from dns.lookup / net.isIP, so a non-parse means "don't trust it".
46
+ function isPrivateIpv4(ip) {
47
+ const parts = ip.split(".").map((n) => Number(n));
48
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
49
+ const [a, b] = parts;
50
+ if (a === 0 || a === 127) return true; // this-host / loopback
51
+ if (a === 10) return true; // private
52
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
53
+ if (a === 192 && b === 168) return true; // private
54
+ if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254)
55
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
56
+ return false;
57
+ }
58
+
59
+ function isBlockedAddress(addr) {
60
+ if (!addr) return true;
61
+ const ip = String(addr).toLowerCase();
62
+ const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); // IPv4-mapped IPv6
63
+ if (mapped) return isPrivateIpv4(mapped[1]);
64
+ const fam = net.isIP(ip);
65
+ if (fam === 4) return isPrivateIpv4(ip);
66
+ if (fam === 6) {
67
+ if (ip === "::1" || ip === "::") return true; // loopback / unspecified
68
+ if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // ULA fc00::/7
69
+ if (/^fe[89ab]/.test(ip)) return true; // link-local fe80::/10
70
+ return false;
71
+ }
72
+ return true; // not a recognizable IP → fail closed
73
+ }
38
74
 
39
- function validateUrl(rawUrl) {
75
+ // SSRF guard. Resolving the host to concrete IPs (rather than pattern-matching
76
+ // the literal string) defeats DNS names that point at internal ranges AND
77
+ // numeric encodings like http://2130706433 (= 127.0.0.1) that a regex misses.
78
+ // Residual: a rebind between this lookup and the actual connect is still
79
+ // theoretically possible; pinning the resolved IP would need a custom agent.
80
+ async function validateUrl(rawUrl) {
40
81
  let parsed;
41
82
  try { parsed = new URL(rawUrl); } catch { throw new Error("Invalid URL"); }
42
83
  if (!["http:", "https:"].includes(parsed.protocol)) {
43
84
  throw new Error(`Protocol "${parsed.protocol}" is not allowed; use http or https`);
44
85
  }
45
86
  const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
46
- if (BLOCKED_HOST_RE.test(host) || PRIVATE_IP_RE.test(host)) {
87
+ if (BLOCKED_HOST_RE.test(host)) {
88
+ throw new Error(`Requests to private or link-local addresses are blocked`);
89
+ }
90
+ let addresses;
91
+ try {
92
+ addresses = await dns.lookup(host, { all: true });
93
+ } catch {
94
+ throw new Error("Could not resolve host");
95
+ }
96
+ if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) {
47
97
  throw new Error(`Requests to private or link-local addresses are blocked`);
48
98
  }
49
99
  }
@@ -72,7 +122,7 @@ async function readBody(response, jsonHint) {
72
122
 
73
123
  async function doRequest({ url, method = "GET", headers = {}, body = null, timeout_ms = DEFAULT_TIMEOUT, json = false } = {}) {
74
124
  if (!url) throw new Error("url required");
75
- validateUrl(url);
125
+ await validateUrl(url);
76
126
  const fetch = await getFetch();
77
127
 
78
128
  const controller = new AbortController();
@@ -55,16 +55,18 @@ export function resolveSender({ cfg, channelName, from, chatType }) {
55
55
  * - owner → "*" (all tools)
56
56
  * - guest → [] (no tools; text only)
57
57
  * - a role defined in telegram.roles → its `tools` ("*" or an array)
58
- * - any other named role with no definition → "*" (an admin assigned it
59
- * deliberately; default permissive rather than silently muting it)
58
+ * - any other named role with no definition → [] (fail closed: a typo'd or
59
+ * removed role must NOT silently grant every tool. Define the role in
60
+ * telegram.roles to grant access.)
60
61
  * Returns "*" or an array of tool names.
61
62
  */
62
63
  export function resolveAllowedTools(cfg, sender) {
63
64
  if (sender?.isOwner) return "*";
64
65
  const def = cfg?.telegram?.roles?.[sender?.role];
65
66
  if (def && def.tools !== undefined) return def.tools;
66
- if (sender?.role === SENDER_ROLES.GUEST) return [];
67
- return "*";
67
+ // Fail closed: guests and any sender whose role isn't explicitly defined get
68
+ // no tools. Only the owner and configured roles receive capabilities.
69
+ return [];
68
70
  }
69
71
 
70
72
  /**
@@ -108,7 +110,7 @@ export function registerSender({ cfg, channelName, from, chatType }) {
108
110
  else if (!existing) kind = "guest";
109
111
  else if (existing.last_seen?.slice(0, 10) !== now.slice(0, 10)) kind = "touch";
110
112
 
111
- if (!kind) return { sender: base(), mutated: false };
113
+ if (!kind) return { sender: base(), mutated: false, claimedOwner: false };
112
114
 
113
115
  if (kind === "claim") {
114
116
  upsertTelegramChannel(disk, channelName, { owner_user_id: userId });
@@ -123,5 +125,8 @@ export function registerSender({ cfg, channelName, from, chatType }) {
123
125
  upsertContact(disk, userId, { last_seen: now });
124
126
  }
125
127
 
126
- return { sender: base(), mutated: true };
128
+ // Signal an owner-claim (trust-on-first-use) so the caller can surface it —
129
+ // whoever messages a fresh private channel first becomes owner, and that
130
+ // event must be auditable rather than silent.
131
+ return { sender: base(), mutated: true, claimedOwner: kind === "claim" };
127
132
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Both are auth-gated (the global middleware applies).
6
6
  import { readConfig } from "#core/config/index.js";
7
- import { exec } from "node:child_process";
7
+ import { execFile } from "node:child_process";
8
8
  import fs from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
@@ -57,22 +57,52 @@ export function register(app, { scheduler, plugins, config, registries }) {
57
57
  // platform lacks a usable picker — or none is installed — the endpoint
58
58
  // returns 501 so the frontend can fall back to the inline directory list.
59
59
  app.get("/admin/fs/pick-dir", (req, res) => {
60
- const prompt = String(req.query.prompt || "Select a folder").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
60
+ // The prompt is attacker-controllable (query string). NEVER interpolate it
61
+ // into a shell string — use execFile (no shell) with an argv array, and
62
+ // pass the prompt out-of-band via an env var so it can't break out of any
63
+ // quoting context in the picker's own scripting language.
64
+ const prompt = String(req.query.prompt || "Select a folder");
61
65
  const platform = process.platform;
62
- let cmd;
66
+ const env = { ...process.env, APX_PICK_PROMPT: prompt };
67
+ let file;
68
+ let args;
63
69
  if (platform === "darwin") {
64
70
  // try/end-try makes cancel exit with code 0 + empty stdout so we can
65
- // distinguish "cancelled" from "no picker available".
66
- cmd = `osascript -e 'try' -e 'POSIX path of (choose folder with prompt "${prompt}")' -e 'on error' -e 'return ""' -e 'end try'`;
71
+ // distinguish "cancelled" from "no picker available". `system attribute`
72
+ // reads the env var, so the prompt never touches the AppleScript source.
73
+ file = "osascript";
74
+ args = [
75
+ "-e", "try",
76
+ "-e", 'POSIX path of (choose folder with prompt (system attribute "APX_PICK_PROMPT"))',
77
+ "-e", "on error",
78
+ "-e", 'return ""',
79
+ "-e", "end try",
80
+ ];
67
81
  } else if (platform === "linux") {
68
- cmd = `command -v zenity >/dev/null && zenity --file-selection --directory --title="${prompt}" 2>/dev/null || true`;
82
+ // zenity takes the title as a plain argv value no shell, no escaping.
83
+ file = "zenity";
84
+ args = ["--file-selection", "--directory", `--title=${prompt}`];
69
85
  } else if (platform === "win32") {
70
- cmd = `powershell -NoProfile -Command "$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, '${prompt.replace(/'/g, "''")}', 0, 0); if ($f) { $f.Self.Path }"`;
86
+ // Reference the prompt via $env: inside PowerShell rather than splicing it
87
+ // into the -Command string.
88
+ file = "powershell";
89
+ args = [
90
+ "-NoProfile",
91
+ "-Command",
92
+ "$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, $env:APX_PICK_PROMPT, 0, 0); if ($f) { $f.Self.Path }",
93
+ ];
71
94
  } else {
72
95
  return res.status(501).json({ error: "Native folder picker not supported on this platform" });
73
96
  }
74
- exec(cmd, { timeout: 5 * 60 * 1000 }, (err, stdout) => {
75
- if (err) return res.status(500).json({ error: err.message });
97
+ execFile(file, args, { timeout: 5 * 60 * 1000, env }, (err, stdout) => {
98
+ if (err) {
99
+ // Picker binary absent (e.g. no zenity) → let the frontend fall back to
100
+ // the inline directory list instead of surfacing a hard 500.
101
+ if (err.code === "ENOENT") {
102
+ return res.status(501).json({ error: "Native folder picker not available" });
103
+ }
104
+ return res.status(500).json({ error: err.message });
105
+ }
76
106
  const picked = (stdout || "").trim().replace(/[\r\n]+$/g, "");
77
107
  if (!picked) return res.json({ cancelled: true });
78
108
  res.json({ path: picked.replace(/\/+$/, "") });
@@ -10,6 +10,7 @@ import { readAgents } from "#core/apc/parser.js";
10
10
  import { agentMemoryPath } from "#core/agent/memory.js";
11
11
  import { apcMemoryFile } from "#core/apc/paths.js";
12
12
  import { CHANNELS } from "#core/constants/channels.js";
13
+ import { isKnownSpaRoute } from "./web.js";
13
14
 
14
15
  export const nowIso = () =>
15
16
  new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -51,34 +52,29 @@ export function traceIdMiddleware(req, res, next) {
51
52
  // /pair/init and /admin/web-token both enforce localhost-only checks of
52
53
  // their own — the auth middleware just gets out of their way.
53
54
  const UNAUTHENTICATED_PREFIXES = ["/health", "/pair/", "/admin/web-token"];
54
- // API prefixes that MUST stay authenticated. Anything not in this list,
55
- // requested with GET, is treated as a static SPA asset (HTML/CSS/JS/font)
56
- // or a client-router path and served without auth — the bundle itself
57
- // fetches /admin/web-token to obtain a bearer for the subsequent API
58
- // calls. This is safe because all data-bearing routes start with one of
59
- // the API prefixes below.
60
- const API_PREFIXES = [
61
- "/health", "/admin", "/projects", "/telegram", "/engines", "/runtimes",
62
- "/messages", "/sessions", "/tools", "/mcp", "/voice", "/tts", "/desktop", "/overlay",
63
- "/transcribe", "/run", "/files", "/memory", "/env", "/pair", "/deck",
64
- "/super-agent", "/identity", "/agents", "/tasks",
65
- ];
66
- function isApiPath(p) {
67
- for (const prefix of API_PREFIXES) {
68
- if (p === prefix || p.startsWith(prefix + "/")) return true;
69
- }
70
- return false;
55
+
56
+ // Does this path look like a static asset (has a file extension)? Vite emits
57
+ // hashed, extension-bearing filenames (index-abc123.js, logo.svg, font.woff2),
58
+ // so an extension is a reliable "this is a bundle asset, not a data route"
59
+ // signal. Data routes (/skills, /projects, /p/0/tasks) have no extension.
60
+ function isStaticAssetPath(p) {
61
+ return path.extname(p) !== "";
71
62
  }
63
+
72
64
  function isUnauthenticatedPath(p, method = "GET") {
73
65
  if (p === "/health") return true;
74
66
  if (p === "/admin/web-token") return true;
75
67
  for (const prefix of UNAUTHENTICATED_PREFIXES) {
76
68
  if (p === prefix.replace(/\/$/, "") || p.startsWith(prefix)) return true;
77
69
  }
78
- // GET requests that don't hit an API prefix are SPA assets / client-router
79
- // paths; let them through so the admin bundle can load before it has a
80
- // bearer.
81
- if (method === "GET" && !isApiPath(p)) return true;
70
+ // SPA bootstrap: the admin bundle loads before it holds a bearer, so a GET
71
+ // for a static asset or a known client-router route is served without auth
72
+ // the bundle then fetches /admin/web-token. Everything else, including every
73
+ // data GET (/skills, /plugins, /embeddings, ), REQUIRES a token. This is an
74
+ // allowlist by construction: a new data route can never silently become
75
+ // public just because someone forgot to register its prefix (the old
76
+ // denylist failure mode).
77
+ if (method === "GET" && (isStaticAssetPath(p) || isKnownSpaRoute(p))) return true;
82
78
  return false;
83
79
  }
84
80
 
@@ -62,8 +62,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
62
62
  return res.status(500).json({ error: e.message });
63
63
  }
64
64
  }
65
+ const root = path.resolve(p.path);
65
66
  const abs = path.resolve(p.path, rel);
66
- if (!abs.startsWith(path.resolve(p.path)))
67
+ if (abs !== root && !abs.startsWith(root + path.sep))
67
68
  return res.status(403).json({ error: "path escapes project root" });
68
69
  if (!fs.existsSync(abs))
69
70
  return res.status(404).json({ error: "not found" });
@@ -96,8 +97,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
96
97
  if (!rel) return res.status(400).json({ error: "path required" });
97
98
  if (typeof content !== "string")
98
99
  return res.status(400).json({ error: "content must be string" });
100
+ const root = path.resolve(p.path);
99
101
  const abs = path.resolve(p.path, rel);
100
- if (!abs.startsWith(path.resolve(p.path)))
102
+ if (abs !== root && !abs.startsWith(root + path.sep))
101
103
  return res.status(403).json({ error: "path escapes project root" });
102
104
  fs.mkdirSync(path.dirname(abs), { recursive: true });
103
105
  fs.writeFileSync(abs, content);
@@ -16,8 +16,9 @@ export function setDesktopMessageHandler(fn) {
16
16
  // routes do: a bearer token (master or paired client) carried on the upgrade
17
17
  // request. The legitimate desktop window sends `Authorization: Bearer <token>`
18
18
  // (src/interfaces/desktop/main.js); browser clients can pass `?token=`. Without
19
- // this the daemon (which binds 0.0.0.0 by default) would let any LAN client open
20
- // the channel and drive the super-agent. See QA BUG-WS-AUTH.
19
+ // this, any client that can reach the daemon (loopback by default, but the LAN
20
+ // when host is set to 0.0.0.0) could open the channel and drive the
21
+ // super-agent. See QA BUG-WS-AUTH.
21
22
 
22
23
  /** Path-gate: is this upgrade for the desktop (or legacy overlay) WS channel? */
23
24
  export function isDesktopUpgradePath(url) {
@@ -1259,9 +1259,9 @@
1259
1259
  }
1260
1260
  },
1261
1261
  "node_modules/@reduxjs/toolkit/node_modules/immer": {
1262
- "version": "11.1.8",
1263
- "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
1264
- "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
1262
+ "version": "11.1.9",
1263
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
1264
+ "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
1265
1265
  "license": "MIT",
1266
1266
  "funding": {
1267
1267
  "type": "opencollective",
@@ -2059,9 +2059,9 @@
2059
2059
  "license": "MIT"
2060
2060
  },
2061
2061
  "node_modules/@types/node": {
2062
- "version": "26.0.1",
2063
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
2064
- "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
2062
+ "version": "26.1.0",
2063
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
2064
+ "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
2065
2065
  "devOptional": true,
2066
2066
  "license": "MIT",
2067
2067
  "dependencies": {
@@ -2162,9 +2162,9 @@
2162
2162
  }
2163
2163
  },
2164
2164
  "node_modules/baseline-browser-mapping": {
2165
- "version": "2.10.40",
2166
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
2167
- "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
2165
+ "version": "2.10.41",
2166
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
2167
+ "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
2168
2168
  "dev": true,
2169
2169
  "license": "Apache-2.0",
2170
2170
  "bin": {
@@ -2580,9 +2580,9 @@
2580
2580
  "license": "MIT"
2581
2581
  },
2582
2582
  "node_modules/electron-to-chromium": {
2583
- "version": "1.5.383",
2584
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz",
2585
- "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==",
2583
+ "version": "1.5.385",
2584
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
2585
+ "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
2586
2586
  "dev": true,
2587
2587
  "license": "ISC"
2588
2588
  },
@@ -3256,9 +3256,9 @@
3256
3256
  "license": "ISC"
3257
3257
  },
3258
3258
  "node_modules/picomatch": {
3259
- "version": "4.0.4",
3260
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
3261
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
3259
+ "version": "4.0.5",
3260
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
3261
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
3262
3262
  "license": "MIT",
3263
3263
  "engines": {
3264
3264
  "node": ">=12"