agent-dag 1.33.145 → 1.33.147

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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-CZfE1WyG.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-BaqIyhG1.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-DhGa0tR_.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.145",
3
+ "version": "1.33.147",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,30 @@ export const installSpec = (spec = "latest", platform = process.platform) =>
50
50
  npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
51
51
  "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform);
52
52
 
53
+ // npx is the same kind of shim as npm, and the fallback run needs the same
54
+ // treatment for a second, sharper reason: its argument vector carries a
55
+ // user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
56
+ // /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
57
+ // route is a GET, any page open in the user's browser could aim it at the
58
+ // loopback port with no CORS, no preflight and no need to read the answer.
59
+ // spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
60
+ // the vector untouched everywhere else, so nothing in it is ever parsed as
61
+ // syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
62
+ // .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
63
+ // spawn can launch at all (PATHEXT is a shell's job), so asking for the
64
+ // extensionless name would trade a shell injection for an ENOENT.
65
+ function npxSpec(args, platform = process.platform) {
66
+ return spawnSpec(platform === "win32" ? "npx.cmd" : "npx", args, platform);
67
+ }
68
+
69
+ /**
70
+ * What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
71
+ * Exported for tests: the platform is a parameter so the Windows command line
72
+ * can be checked from any OS.
73
+ */
74
+ export const fallbackSpec = (args = [], platform = process.platform) =>
75
+ npxSpec(["-y", "ccusage@latest", ...args], platform);
76
+
53
77
  let _installing = null; // Promise guard so concurrent calls share one install
54
78
  let _checkedThisRun = false; // only kick the daily check once per process boot
55
79
 
@@ -188,19 +212,20 @@ async function getRunner() {
188
212
  // Run ccusage with the given args, resolve raw stdout.
189
213
  async function runCcusage(args) {
190
214
  const runner = await getRunner();
191
- const [cmd, full] = runner.kind === "node"
192
- ? [process.execPath, [runner.entry, ...args]]
193
- : ["npx", ["-y", "ccusage@latest", ...args]];
215
+ // Neither branch gets a shell. The managed install is `node <entry> …`, which
216
+ // never needed one; the npx fallback is routed through spawnSpec instead —
217
+ // see npxSpec, and note that `args` here ends in whatever /api/ccusage was
218
+ // asked for.
219
+ const { file, args: full, opts } = runner.kind === "node"
220
+ ? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
221
+ : fallbackSpec(args);
194
222
  return new Promise((resolve, reject) => {
195
- const child = spawn(cmd, full, {
196
- // node path needs no shell; npx fallback does (Windows .cmd shim).
197
- shell: runner.kind === "npx",
198
- windowsHide: true,
199
- });
223
+ const child = spawn(file, full, { windowsHide: true, ...opts });
200
224
  let out = "", err = "";
201
225
  const timer = setTimeout(() => {
202
- // The npx fallback runs through a shell, so on Windows `child` is cmd.exe
203
- // and npx is a grandchild that a plain kill would leave downloading.
226
+ // The npx fallback goes through cmd.exe on Windows, so `child` is the
227
+ // wrapper there and npx is a grandchild that a plain kill would leave
228
+ // downloading.
204
229
  killTree(child);
205
230
  reject(tagged("timeout", "ccusage timed out"));
206
231
  }, TIMEOUT_MS);
@@ -8,7 +8,7 @@ import { extname, join, resolve, dirname as pdirname } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { dirname } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
- import { createHash, randomBytes } from "node:crypto";
11
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12
12
  import { claudeConfigDir } from "./claude-dir.mjs";
13
13
  import { PRODUCT } from "./brand.mjs";
14
14
  import { invokedName } from "./invoked-as.mjs";
@@ -1469,14 +1469,40 @@ async function handleCodexQuota(req, res) {
1469
1469
  send(res, 200, quota);
1470
1470
  }
1471
1471
 
1472
+ /**
1473
+ * ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
1474
+ * at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
1475
+ *
1476
+ * Eight digits is a shape, not a calendar, and deliberately so. What this gate
1477
+ * is for is keeping anything that is not a date out of a child process's
1478
+ * argument vector; `99999999` is exactly as inert an argument as `20260101`,
1479
+ * and whether a date outside the logs means an empty chart is ccusage's
1480
+ * question to answer rather than the deck's to guess at.
1481
+ */
1482
+ export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
1483
+
1472
1484
  async function handleCcusage(req, res) {
1473
- const { fetchCcusageDaily } = await import(
1474
- pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
1475
- );
1476
1485
  const url = new URL(req.url, "http://localhost");
1477
1486
  const force = url.searchParams.get("refresh") === "1";
1478
1487
  const since = url.searchParams.get("since") || undefined;
1479
1488
  const until = url.searchParams.get("until") || undefined;
1489
+ // Refused here, at the boundary, because this is where a string chosen by
1490
+ // someone else enters: /api/ccusage is a GET, so it needs no CORS, no
1491
+ // preflight and no ability to read the response — any page the user has open
1492
+ // can fire it at the loopback port — and both values end up in the argv of a
1493
+ // spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
1494
+ // this is the second lock rather than the only one, and it is the one that
1495
+ // keeps the pair honest if a shell is ever reintroduced downstream.
1496
+ if (!isCliDate(since) || !isCliDate(until)) {
1497
+ return send(res, 400, {
1498
+ ok: false,
1499
+ reason: "bad_range",
1500
+ error: "since and until must be YYYYMMDD dates, e.g. 20260101",
1501
+ });
1502
+ }
1503
+ const { fetchCcusageDaily } = await import(
1504
+ pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
1505
+ );
1480
1506
  const data = await fetchCcusageDaily({ since, until, force });
1481
1507
  send(res, 200, data);
1482
1508
  }
@@ -1672,9 +1698,20 @@ export function challengeProof(token, nonce) {
1672
1698
  //
1673
1699
  // The nonce is the caller's, so the answer proves knowledge of the token
1674
1700
  // without disclosing it, and proves it for this exchange only. Answering
1675
- // freely is safe: a caller that can reach this port can already read the
1676
- // discovery file that holds the token, being the same user on the same
1677
- // machine, and the response is a hash the browser cannot read cross-origin.
1701
+ // freely is what the exchange requires: the hook is asking whether the process
1702
+ // on this port is the deck that wrote the discovery file, and it asks precisely
1703
+ // because it does not yet know — a deck that demanded credentials before
1704
+ // answering could not be told apart from a stranger that refuses.
1705
+ //
1706
+ // So this route is an oracle, and it must stay one. That makes its answer
1707
+ // useless as a credential FOR this server, and nothing here may ever accept it
1708
+ // as one: a caller who can GET this can obtain a valid proof for any nonce, so
1709
+ // a gate honouring proofs is a gate honouring anybody. See presentsDeckToken,
1710
+ // which takes the token itself and refuses the hashed form for this reason.
1711
+ //
1712
+ // What the free answer does NOT give away is the token: the response is a
1713
+ // one-way hash of it, and after the read gate above a rebound page cannot see
1714
+ // even that.
1678
1715
  function handleHookChallenge(_req, res, url) {
1679
1716
  const nonce = url.searchParams.get("nonce") ?? "";
1680
1717
  if (!nonce || nonce.length > 256) return send(res, 400, { error: "bad nonce" });
@@ -1726,6 +1763,41 @@ export function requestUrl(rawUrl) {
1726
1763
  catch { return null; }
1727
1764
  }
1728
1765
 
1766
+ // Is this request allowed to be answered at all, whatever it asks for?
1767
+ //
1768
+ // The rebinding gate below was reached only by mutations, on the reasoning that
1769
+ // a cross-site page cannot read a loopback reply. That is true of a genuinely
1770
+ // cross-origin page and false of a rebound one: the browser resolved
1771
+ // attacker.example to 127.0.0.1 itself, so it calls the reply same-origin and
1772
+ // hands the body to the page. Every read was therefore open to exactly the
1773
+ // attack the mutation gate was written to stop — and the reads are where the
1774
+ // secrets are. GET /api/events is the whole ring buffer: prompt text, the Bash
1775
+ // command lines the agent ran, the paths and contents it wrote, the contents of
1776
+ // every file it read back. /api/claude-accounts names the accounts,
1777
+ // /api/claude-accounts/login carries a live OAuth authorize URL, /api/sound-hook
1778
+ // the user's own hook command lines, /api/health the absolute workspace path.
1779
+ //
1780
+ // So the Host check runs for every method now. What it asks is only the
1781
+ // rebinding question — did this request arrive addressed to a name that can
1782
+ // only ever be this machine — and it asks it of browser-shaped requests alone,
1783
+ // meaning anything carrying an Origin or fetch metadata. A client sending
1784
+ // neither is not a page and has no ambient authority to borrow: that is
1785
+ // hook/hook.js, a plain Node http.request from the user's own machine, and it
1786
+ // keeps reaching the deck under whatever name it used before.
1787
+ //
1788
+ // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
1789
+ // applies. `cross-site` on a read is an ordinary top-level navigation — a link
1790
+ // to http://localhost:4317 clicked on any page — and the document it loads is
1791
+ // the deck's own UI on the deck's own origin, which is not an attack and used
1792
+ // to work. Rebinding does not need that test either: a rebound page's requests
1793
+ // report `same-origin`, and it is the Host that gives it away.
1794
+ export function isTrustedRead({ origin, host, secFetchSite } = {}) {
1795
+ const browserShaped = (typeof origin === "string" && origin !== "")
1796
+ || (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
1797
+ if (!browserShaped) return true;
1798
+ return isLoopbackHost(host);
1799
+ }
1800
+
1729
1801
  // Is this mutating request allowed to be acted on?
1730
1802
  //
1731
1803
  // The deck binds 127.0.0.1, which sounds private but is reachable from every
@@ -1769,8 +1841,10 @@ export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
1769
1841
  const hasOrigin = typeof origin === "string" && origin !== "";
1770
1842
  if (!hasOrigin && !site) return true;
1771
1843
  // Either header present means a browser sent this, so the rebinding gate
1772
- // applies even to the shape that carries fetch metadata but no Origin.
1773
- if (!isLoopbackHost(host)) return false;
1844
+ // applies even to the shape that carries fetch metadata but no Origin. Every
1845
+ // request reaching this line is browser-shaped by the test just above, so
1846
+ // isTrustedRead here is exactly its isLoopbackHost half.
1847
+ if (!isTrustedRead({ origin, host, secFetchSite })) return false;
1774
1848
  return hasOrigin ? originMatchesHost(origin, host) : true;
1775
1849
  }
1776
1850
 
@@ -1817,6 +1891,127 @@ function originMatchesHost(origin, host) {
1817
1891
  return fromOrigin !== "" && fromOrigin === fromHost;
1818
1892
  }
1819
1893
 
1894
+ // Mutating routes that ask nothing of the caller beyond the gates above.
1895
+ //
1896
+ // Only the hook's ingest, and only because hook/hook.js is installed OUTSIDE
1897
+ // this package — it lives in the user's ~/.claude and is loaded by whatever
1898
+ // Claude Code session is already running. Requiring a credential here would
1899
+ // stop every event from every session whose hook predates this change, on a
1900
+ // machine where nothing is obviously broken and nothing says why, until each
1901
+ // one is reinstalled. It also carries no credential and destroys nothing: the
1902
+ // worst a caller does with it is draw a session on the canvas that is not
1903
+ // there. See the handshake in hook/hook.js for the authentication that does
1904
+ // run on this path, which is the deck proving itself to the hook.
1905
+ const OPEN_MUTATIONS = new Set(["/api/event"]);
1906
+
1907
+ // Constant-time comparison of two secrets, and a length test that is not.
1908
+ // Lengths differ freely in public — a mismatched one only says "not this
1909
+ // token" — but timingSafeEqual throws rather than answering false when they do.
1910
+ function secretEquals(given, expected) {
1911
+ const a = Buffer.from(String(given ?? ""), "utf8");
1912
+ const b = Buffer.from(String(expected ?? ""), "utf8");
1913
+ if (a.length === 0 || a.length !== b.length) return false;
1914
+ return timingSafeEqual(a, b);
1915
+ }
1916
+
1917
+ // Does this request carry the deck's own token?
1918
+ //
1919
+ // One spelling, `x-ccdeck-token`, carrying the token itself. That is all a
1920
+ // local process needs, because the only way it can hold the token is to have
1921
+ // read it out of a file only it can open.
1922
+ //
1923
+ // The token is HOOK_TOKEN, fresh per deck and written to the discovery file at
1924
+ // mode 0600. That mode is the whole of the access control: the answer to "who
1925
+ // may do this" is "whoever can read that file", which is the user this deck
1926
+ // runs as and nobody else. On Windows the chmod is a best-effort no-op, so
1927
+ // there the file's protection is the per-user ACL on the profile directory it
1928
+ // sits in rather than a mode bit; the token still works identically.
1929
+ //
1930
+ // The hashed challengeProof form is deliberately NOT accepted here, and this
1931
+ // is the paragraph that must be read before anyone adds it. GET
1932
+ // /api/hook-challenge?nonce=… answers challengeProof(HOOK_TOKEN, nonce) to any
1933
+ // caller for any nonce, by design — so accepting a proof as a credential means
1934
+ // the server hands out its own credentials on request, and two unauthenticated
1935
+ // GETs are a complete bypass of everything below. This gate shipped that way
1936
+ // for one commit; the regression test in csrf-origin.test.ts pins it shut.
1937
+ //
1938
+ // The premise that makes the oracle safe is the one this gate rejects, which is
1939
+ // exactly why the two cannot share a secret: the challenge is the deck proving
1940
+ // itself TO the hook, not the hook proving itself to the deck. hook/hook.js
1941
+ // already holds the token — it read the same 0600 file — and challenges the
1942
+ // port to find out whether the process listening there is really the deck that
1943
+ // wrote it, or something else that inherited the port number from a stale
1944
+ // discovery file. Nothing travels in the other direction, so no caller ever
1945
+ // needed the hashed form as a way IN, and a client entitled to mutate holds the
1946
+ // token itself anyway.
1947
+ function presentsDeckToken(headers = {}) {
1948
+ const raw = headers["x-ccdeck-token"];
1949
+ return typeof raw === "string" && secretEquals(raw.trim(), HOOK_TOKEN);
1950
+ }
1951
+
1952
+ // Is this the deck's own page in the user's browser?
1953
+ //
1954
+ // A strict subset of isTrustedMutation, which every request here has already
1955
+ // passed: an Origin must actually be present — a browser sends one on every
1956
+ // POST, including a same-origin one — it must name this very server, the Host
1957
+ // must be a loopback identity, and any fetch metadata must say `same-origin`.
1958
+ // `none` is excluded on purpose: that is a top-level navigation the user typed
1959
+ // or a form submitted from one, never the UI's own fetch.
1960
+ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
1961
+ const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
1962
+ if (site !== "" && site !== "same-origin") return false;
1963
+ if (typeof origin !== "string" || origin === "") return false;
1964
+ if (!isLoopbackHost(host)) return false;
1965
+ return originMatchesHost(origin, host);
1966
+ }
1967
+
1968
+ // May this request change something?
1969
+ //
1970
+ // Everything above this line is about the browser: whether a page chose the
1971
+ // request, and whether it was addressed to this machine. None of it asks who
1972
+ // the caller is, and for a client that is not a browser at all nothing did —
1973
+ // a request carrying no Origin and no fetch metadata was waved through on the
1974
+ // reasoning that a process able to POST here can already run anything as the
1975
+ // user. That holds on a single-user laptop and fails twice elsewhere. Loopback
1976
+ // is not scoped to a UID, so on a shared box or a multi-tenant container every
1977
+ // other account on the machine can reach this port; and a sandboxed subprocess
1978
+ // denied the credential store but allowed loopback egress — the ordinary shape
1979
+ // of an agent's Bash sandbox — reaches the same credentials through the API.
1980
+ // `curl -XPOST localhost:4317/api/claude-accounts/admin -d '{"action":"share"}'`
1981
+ // answered with the account's live OAuth refresh token in the clear, and the
1982
+ // same request reached account remove and import, the live account switch, a
1983
+ // global `npm i -g`, the restart and the event log.
1984
+ //
1985
+ // So a mutation must now be either of two things:
1986
+ //
1987
+ // - the user's own browser tab, recognised by the headers a page cannot
1988
+ // forge and the deck's own loopback address (isDeckUiRequest), or
1989
+ // - a client holding the deck's token, which it can only have read from the
1990
+ // 0600 discovery file (presentsDeckToken).
1991
+ //
1992
+ // What this does NOT do, stated plainly so nobody mistakes it for more: the
1993
+ // browser clause rests on headers that page script cannot set but any local
1994
+ // program can. A local attacker who adds `Origin` and `Sec-Fetch-Site` to the
1995
+ // request is back through. That is not a gap left by laziness — there is no
1996
+ // fix for it here. Authenticating the tab would mean giving the tab a secret,
1997
+ // and every channel from this process to a browser on the same machine is
1998
+ // readable by any other process on that machine: a token in the served
1999
+ // index.html is read by the same `curl http://localhost:4317/` that the gate
2000
+ // is meant to stop, and a token in the URL the deck opens is read out of the
2001
+ // browser's argv by `ps`. What is closed is the free pass — a request that
2002
+ // presents nothing at all no longer changes anything — and what is opened is
2003
+ // the honest door, so a script of the user's own authenticates by reading the
2004
+ // token instead of impersonating a page.
2005
+ function isAuthorizedMutation(req) {
2006
+ const headers = req?.headers ?? {};
2007
+ if (presentsDeckToken(headers)) return true;
2008
+ return isDeckUiRequest({
2009
+ origin: headers.origin,
2010
+ host: headers.host,
2011
+ secFetchSite: headers["sec-fetch-site"],
2012
+ });
2013
+ }
2014
+
1820
2015
  // A request handler rejected. Two audiences, two different amounts of detail:
1821
2016
  // the operator, who needs the whole error — stack included — to find the bug,
1822
2017
  // and the HTTP client, which needs to know only that the request failed.
@@ -1882,9 +2077,23 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1882
2077
  // would be an uncaughtException inside the listener — i.e. the whole deck.
1883
2078
  if (!url) return send(res, 400, { error: "bad request target" });
1884
2079
 
1885
- // Every route below that changes something is a POST, so one gate in front
1886
- // of the whole table covers them all — and covers any route added later
1887
- // without the author having to remember. See isTrustedMutation.
2080
+ // Three gates in front of the whole table, so every route is covered and
2081
+ // any route added later is covered too, without the author having to
2082
+ // remember. They ask three different questions and they run in the order
2083
+ // that answers the cheapest first.
2084
+
2085
+ // Was this addressed to this machine? Every method, because a rebound page
2086
+ // reads a reply as readily as it writes a request. See isTrustedRead.
2087
+ if (!isTrustedRead({
2088
+ origin: req.headers.origin,
2089
+ host: req.headers.host,
2090
+ secFetchSite: req.headers["sec-fetch-site"],
2091
+ })) {
2092
+ return send(res, 403, { error: "cross-site request blocked" });
2093
+ }
2094
+
2095
+ // Did a page choose this? Mutations only — the same-site read this refuses
2096
+ // is an ordinary navigation. See isTrustedMutation.
1888
2097
  if (req.method !== "GET" && req.method !== "HEAD" && !isTrustedMutation({
1889
2098
  origin: req.headers.origin,
1890
2099
  host: req.headers.host,
@@ -1893,6 +2102,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1893
2102
  return send(res, 403, { error: "cross-site request blocked" });
1894
2103
  }
1895
2104
 
2105
+ // And who is asking? Refusing by default is the point: a mutating route
2106
+ // added later is protected until someone deliberately lists it as open,
2107
+ // which is the direction this has to fail in. See isAuthorizedMutation.
2108
+ if (req.method !== "GET" && req.method !== "HEAD"
2109
+ && !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
2110
+ return send(res, 401, { error: "unauthenticated" });
2111
+ }
2112
+
1896
2113
  // `?persist=0` — another deck was elected to write this event to the log
1897
2114
  // the two of them share. Absent, this deck writes it.
1898
2115
  if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);