Public: anyone can read this; the crew can change it.

The owner reads what outside agents tried: a compact, redacted request log, landed from pull #1383 on today's main #1761

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1458-the-owner-reads-what-outside-agents-trie → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/1458-the-owner-reads-what-outside-agents-trie was merged into main as b289316.

Review

Sign in to review this change.
11 files changed +847 −15
docs/APPS.md +13
@@ -1147,6 +1147,19 @@ sync cadence in seconds, 10–86400). `src/apps/service.ts` validates it; the
11471147 manifest carries it as `service`, `null` for a web app. Running it, exposing
11481148 the ports and syncing volumes are not built yet (issue #391).
11491149
1150 ## The access log
1151
1152 The forge keeps one compact JSON line per request that is not a static asset
1153 (or `/healthz`) under `<data>/access/access-YYYY-MM-DD.log`: time, host,
1154 method, path without its query string (secret-looking segments blanked),
1155 status, duration, user agent (200 characters), whether a credential came with
1156 it, and a hash of the client address keyed with the data directory's secret
1157 and the day. Lines are queued in memory and appended in one write a second (or per 200 lines), so a request pays for no disk write. No token, cookie or query string is ever written. Files go after
1158 14 days, a day's file stops at 4 MB and the directory at 32 MB, oldest first.
1159 Nothing serves the files. A forge admin reads the last day's non-browser
1160 requests to public paths at Settings → Access, or with the `access` MCP tool
1161 (`since`, `ua`, `path`; write-scope token).
1162
11501163 ## The forge as an OpenID Connect provider
11511164
11521165 Discovery is at `/.well-known/openid-configuration`. Register a client at
scripts/gate.ts +1
@@ -110,6 +110,7 @@ export const GATE: readonly string[] = [
110110 "test/decide.test.ts",
111111 "test/ledger.test.ts",
112112 "test/capacity.test.ts",
113 "test/access-log.test.ts",
113114 "test/flow-pipeline.test.ts",
114115 "test/limits.test.ts",
115116 "test/guard-new-accounts.test.ts",
src/actions/access_log.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * The access log, read by the forge's owner (`http/access_log.ts`): a forge
3 * admin only, never an agent, and everyone else is told `not_found`.
4 */
5
6 import type { App } from "../app.ts";
7 import {
8 readAccess,
9 type AccessLine,
10 type AccessQuery,
11 } from "../http/access_log.ts";
12 import type { User } from "../core/users.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14
15 export function listAccess(
16 app: App,
17 actor: User | undefined,
18 query: AccessQuery,
19 ): Result<AccessLine[], "not_found"> {
20 if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found");
21 return ok(readAccess(app.config.dataDir, query));
22 }
src/http/access_log.ts added +325
@@ -0,0 +1,325 @@
1 /**
2 * What outside callers tried: one compact line per request that is not a
3 * static asset, kept under `<data>/access/` as one file a day.
4 *
5 * A line is JSON, built only from parts that are safe to keep: the time, the
6 * host, the method, the path without its query string (and with any
7 * secret-looking segment blanked), the status, the time to headers (a streamed body is not counted), the first 200
8 * characters of the user agent, whether the caller presented a credential
9 * (never which), and a hash of the client address keyed with the data
10 * directory's secret and the day — so one caller is one hash for a day and
11 * no hash can be walked back to an address or followed across days. Headers
12 * other than the user agent, cookies, bodies and queries are never read.
13 *
14 * Files older than 14 days go, and the directory is capped in total, oldest
15 * first; one day's file stops growing at its own cap. Nothing here is served
16 * by any route; the owner reads it through `readAccess` (a settings page and
17 * the `access` MCP tool).
18 */
19
20 import {
21 appendFileSync,
22 mkdirSync,
23 readdirSync,
24 closeSync,
25 openSync,
26 readSync,
27 statSync,
28 unlinkSync,
29 } from "node:fs";
30 import { createHmac } from "node:crypto";
31 import { join } from "node:path";
32 import { loadKey } from "../kernel/secret.ts";
33
34 export const KEEP_DAYS = 14;
35 export const DAY_BYTES = 4 * 1024 * 1024;
36 export const TOTAL_BYTES = 32 * 1024 * 1024;
37 const UA_MAX = 200;
38 const PATH_MAX = 200;
39 const HOST_MAX = 100;
40 const READ_MAX = 500;
41 /** The most one read looks at, newest first: it never stalls the loop for long. */
42 const READ_BYTES = 4 * 1024 * 1024;
43 const DAY_MS = 86_400_000;
44
45 /** Paths a caller can reach without signing in, for the owner's view. */
46 export const PUBLIC_PREFIXES = [
47 "/issues",
48 "/api/",
49 "/mcp",
50 "/.well-known/",
51 "/llms.txt",
52 "/signin",
53 "/signup",
54 ] as const;
55
56 export interface AccessLine {
57 /** ISO time the request finished. */
58 readonly t: string;
59 readonly host: string;
60 readonly method: string;
61 readonly path: string;
62 readonly status: number;
63 readonly ms: number;
64 readonly ua: string;
65 /** Whether a session cookie or bearer token came with it. */
66 readonly auth: boolean;
67 /** Keyed daily hash of the client address. */
68 readonly who: string;
69 }
70
71 const STATIC = /\.(?:css|js|map|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf)$/i;
72
73 /** A request the log leaves out: assets, and the platform's own health probe. */
74 export function isQuiet(path: string): boolean {
75 return (
76 path.startsWith("/assets/") || path === "/healthz" || STATIC.test(path)
77 );
78 }
79
80 /** Control characters and DEL removed, then cut: nothing here breaks a line. */
81 const clean = (text: string, max: number): string =>
82 // eslint-disable-next-line no-control-regex
83 text.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
84
85 /**
86 * A path as it may be kept: no query, and a segment that could be a secret
87 * (a long run of token characters, or a forge token's own prefix) blanked.
88 */
89 export function keptPath(pathname: string): string {
90 const segments = pathname.split("/").map((s) => {
91 let seg = s;
92 try {
93 seg = decodeURIComponent(s);
94 } catch {
95 // keep the raw text; it is judged the same way
96 }
97 return /^[A-Za-z0-9_~.+=-]{24,}$/.test(seg) || /^(?:frg|forge|fgt)_/i.test(seg)
98 ? ":redacted"
99 : s;
100 });
101 return clean(segments.join("/"), PATH_MAX);
102 }
103
104 const dayOf = (at: Date): string => at.toISOString().slice(0, 10);
105 const fileOf = (dir: string, day: string): string =>
106 join(dir, `access-${day}.log`);
107 export const accessDir = (dataDir: string): string => join(dataDir, "access");
108
109 /** The client, as one hash for one day. */
110 export function clientHash(dataDir: string, address: string, at: Date): string {
111 return createHmac("sha256", loadKey(dataDir))
112 .update(`access:${dayOf(at)}:${address}`)
113 .digest("hex")
114 .slice(0, 16);
115 }
116
117 export interface Seen {
118 readonly request: Request;
119 readonly status: number;
120 readonly ms: number;
121 readonly client: string;
122 }
123
124 /** The line for a request, or undefined when it is not logged. */
125 export function lineFor(
126 dataDir: string,
127 seen: Seen,
128 at: Date,
129 ): AccessLine | undefined {
130 let url: URL;
131 try {
132 url = new URL(seen.request.url);
133 } catch {
134 return undefined;
135 }
136 if (isQuiet(url.pathname)) return undefined;
137 const h = seen.request.headers;
138 return {
139 t: at.toISOString(),
140 host: clean((h.get("host") ?? url.host).toLowerCase(), HOST_MAX),
141 method: clean(seen.request.method, 16),
142 path: keptPath(url.pathname),
143 status: seen.status,
144 ms: Math.round(seen.ms),
145 ua: clean(h.get("user-agent") ?? "", UA_MAX),
146 auth: /(?:^|;\s*)forge_session=/.test(h.get("cookie") ?? "") ||
147 (h.get("authorization") ?? "") !== "",
148 who: clientHash(dataDir, seen.client, at),
149 };
150 }
151
152 let pruned = "";
153
154 /** Drop files past their days, then the oldest until the directory fits. */
155 export function prune(dataDir: string, at: Date): void {
156 const dir = accessDir(dataDir);
157 const oldest = dayOf(new Date(at.getTime() - (KEEP_DAYS - 1) * DAY_MS));
158 const files = readdirSync(dir)
159 .filter((f) => /^access-\d{4}-\d{2}-\d{2}\.log$/.test(f))
160 .sort();
161 const kept: string[] = [];
162 for (const f of files) {
163 if (f.slice(7, 17) < oldest) unlinkSync(join(dir, f));
164 else kept.push(f);
165 }
166 let total = kept.reduce((n, f) => n + statSync(join(dir, f)).size, 0);
167 for (const f of kept) {
168 if (total <= TOTAL_BYTES || f === kept[kept.length - 1]) break;
169 total -= statSync(join(dir, f)).size;
170 unlinkSync(join(dir, f));
171 }
172 }
173
174 /**
175 * Lines waiting for the next flush, by the day's file they belong to, with
176 * the data directory that owns the file: a hosted child's lines wait beside
177 * the parent's and never go into its directory.
178 */
179 const pending = new Map<string, { dataDir: string; lines: string[] }>();
180 let timer: ReturnType<typeof setTimeout> | undefined;
181 const FLUSH_MS = 1000;
182 const FLUSH_LINES = 200;
183
184 /** Write everything that waits, one append per file. Never throws. */
185 export function flushAccess(at: Date = new Date()): void {
186 clearTimeout(timer);
187 timer = undefined;
188 const batches = [...pending];
189 pending.clear();
190 for (const [file, { dataDir, lines }] of batches) {
191 try {
192 const dir = accessDir(dataDir);
193 mkdirSync(dir, { recursive: true, mode: 0o700 });
194 const day = dayOf(at);
195 if (pruned !== `${dir}:${day}`) {
196 pruned = `${dir}:${day}`;
197 prune(dataDir, at);
198 }
199 let size = 0;
200 try {
201 size = statSync(file).size;
202 } catch {
203 // no file yet
204 }
205 if (size >= DAY_BYTES) continue;
206 appendFileSync(file, lines.join(""), { mode: 0o600 });
207 } catch {
208 // the log is a convenience; the request is the work
209 }
210 }
211 }
212
213 /**
214 * Keep a request: a line in memory now, on disk within a second or 200 lines,
215 * so a request pays for a string and not for a write. Never throws.
216 */
217 export function recordAccess(
218 dataDir: string,
219 seen: Seen,
220 at: Date = new Date(),
221 ): void {
222 try {
223 const line = lineFor(dataDir, seen, at);
224 if (line === undefined) return;
225 const file = fileOf(accessDir(dataDir), dayOf(at));
226 const queued = pending.get(file) ?? { dataDir, lines: [] };
227 queued.lines.push(`${JSON.stringify(line)}\n`);
228 pending.set(file, queued);
229 let count = 0;
230 for (const q of pending.values()) count += q.lines.length;
231 if (count >= FLUSH_LINES) flushAccess(at);
232 else if (timer === undefined) {
233 timer = setTimeout(() => flushAccess(), FLUSH_MS);
234 timer.unref();
235 }
236 } catch {
237 // the log is a convenience; the request is the work
238 }
239 }
240
241 /** The last `max` bytes of a file as whole lines. */
242 function tailOf(file: string, max: number): string[] {
243 const fd = openSync(file, "r");
244 try {
245 const size = statSync(file).size;
246 const length = Math.min(size, max);
247 const buffer = Buffer.alloc(length);
248 readSync(fd, buffer, 0, length, size - length);
249 const lines = buffer.toString("utf8").split("\n");
250 // A cut file begins part-way through a line: drop that line.
251 if (length < size) lines.shift();
252 return lines;
253 } finally {
254 closeSync(fd);
255 }
256 }
257
258 export interface AccessQuery {
259 /** ISO time to read from; the last day when absent or unreadable. */
260 readonly since?: string;
261 /** Case-insensitive part of the user agent. */
262 readonly ua?: string;
263 /** Start of the path. */
264 readonly path?: string;
265 }
266
267 /** A browser says so in its user agent; an agent, a script or a probe does not. */
268 export const isBrowser = (ua: string): boolean => ua.includes("Mozilla/");
269
270 const isPublic = (path: string): boolean =>
271 PUBLIC_PREFIXES.some((p) => path.startsWith(p));
272
273 /**
274 * The last day's (or `since`'s) non-browser requests to public paths, newest
275 * first, at most 500.
276 */
277 export function readAccess(
278 dataDir: string,
279 query: AccessQuery,
280 now: Date = new Date(),
281 ): AccessLine[] {
282 flushAccess(now);
283 const floor = now.getTime() - KEEP_DAYS * DAY_MS;
284 const parsed = query.since === undefined ? NaN : Date.parse(query.since);
285 const from = Math.max(
286 Number.isNaN(parsed) ? now.getTime() - DAY_MS : parsed,
287 floor,
288 );
289 const ua = query.ua?.toLowerCase();
290 const dir = accessDir(dataDir);
291 let files: string[];
292 try {
293 files = readdirSync(dir)
294 .filter((f) => /^access-\d{4}-\d{2}-\d{2}\.log$/.test(f))
295 .filter((f) => f.slice(7, 17) >= dayOf(new Date(from)))
296 .sort()
297 .reverse();
298 } catch {
299 return [];
300 }
301 let budget = READ_BYTES;
302 const out: AccessLine[] = [];
303 for (const f of files) {
304 if (budget <= 0) break;
305 const lines = tailOf(join(dir, f), budget).reverse();
306 budget -= lines.reduce((n, l) => n + l.length + 1, 0);
307 for (const raw of lines) {
308 if (raw === "") continue;
309 let line: AccessLine;
310 try {
311 line = JSON.parse(raw) as AccessLine;
312 } catch {
313 continue;
314 }
315 if (Date.parse(line.t) < from) continue;
316 if (isBrowser(line.ua) || !isPublic(line.path)) continue;
317 if (ua !== undefined && !line.ua.toLowerCase().includes(ua)) continue;
318 if (query.path !== undefined && !line.path.startsWith(query.path))
319 continue;
320 out.push(line);
321 if (out.length >= READ_MAX) return out;
322 }
323 }
324 return out;
325 }
src/http/mcp.ts +29
@@ -19,6 +19,7 @@
1919
2020 import { voteVariant } from "../actions/variants.ts";
2121 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
22 import { listAccess } from "../actions/access_log.ts";
2223 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2324 import { readPipeline, verdict } from "../flow/pipeline.ts";
2425 import { changeLimit, listLimits } from "../actions/limits.ts";
@@ -1880,6 +1881,34 @@ const TOOLS: readonly Tool[] = [
18801881 });
18811882 },
18821883 },
1884 {
1885 name: "access",
1886 description:
1887 "What outside agents tried: the forge's own request log, redacted, for a forge admin. Non-browser requests to public paths (/issues, /api/, /mcp, /.well-known/, /llms.txt, sign-in, sign-up), newest first, at most 500: time, host, method, path without query, status, duration in ms, user agent, whether a credential came with it, and a daily hash of the caller (never an address). `since` is an ISO time (the last day when absent; at most 14 days back), `ua` a part of the user agent, `path` the start of the path. Needs a write-scope token. The same as Settings → Access.",
1888 inputSchema: {
1889 type: "object",
1890 properties: {
1891 since: { type: "string", maxLength: 40 },
1892 ua: { type: "string", maxLength: 100 },
1893 path: { type: "string", maxLength: 200 },
1894 },
1895 },
1896 writes: true,
1897 run(ctx, user, args) {
1898 const text = (k: string): string | undefined =>
1899 typeof args[k] === "string" && args[k] !== "" ? (args[k] as string) : undefined;
1900 const since = text("since");
1901 const ua = text("ua");
1902 const path = text("path");
1903 const listed = listAccess(ctx.app, user, {
1904 ...(since === undefined ? {} : { since: since.slice(0, 40) }),
1905 ...(ua === undefined ? {} : { ua: ua.slice(0, 100) }),
1906 ...(path === undefined ? {} : { path: path.slice(0, 200) }),
1907 });
1908 if (!listed.ok) return bad("not_found", "no such tool here");
1909 return good({ requests: listed.value });
1910 },
1911 },
18831912 {
18841913 name: "capacity",
18851914 description:
src/http/server.ts +44 −14
@@ -7,6 +7,7 @@
77
88 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
99 import { unsealReleased } from "../actions/sealed.ts";
10 import { accessPage } from "../web/pages/access.ts";
1011 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1112 import { flowPage } from "../web/pages/flow.ts";
1213 import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts";
@@ -390,6 +391,7 @@ import { apiRoutes } from "./api.ts";
390391 import { feedRoutes } from "./feed.ts";
391392 import { mcpRoutes } from "./mcp.ts";
392393 import { oauthRoutes } from "./oauth.ts";
394 import { flushAccess, recordAccess } from "./access_log.ts";
393395 import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts";
394396 import { parseCidrs } from "../kernel/cidr.ts";
395397 import {
@@ -430,6 +432,7 @@ import { getUserById } from "../core/users.ts";
430432 import { appsRuntime } from "../apps/runtime.ts";
431433 import {
432434 appWebSocket,
435 type AppSocket,
433436 proxyApp,
434437 resolveAppHost,
435438 } from "../apps/proxy.ts";
@@ -696,6 +699,7 @@ export function buildRouter(): Router<Handler> {
696699 router.add("POST", "/settings/cloud/write", cloudWrite);
697700 router.add("POST", "/settings/cloud/audit", cloudAudit);
698701 router.add("POST", "/settings/cloud/remove", cloudRemove);
702 router.add("GET", "/settings/access", accessPage);
699703 router.add("GET", "/settings/capacity", capacityPage);
700704 router.add("POST", "/settings/capacity", capacitySet);
701705 router.add("GET", "/boards/data", boardsPage);
@@ -1732,19 +1736,28 @@ export async function serve(app: App): Promise<Servers> {
17321736 : { header: app.config.clientIpHeader }),
17331737 };
17341738
1735 const web = Bun.serve({
1736 port: app.config.port ?? 3000,
1737 // A release swaps in beside this process on the same port (#1663).
1738 ...(app.config.selfSwap === true ? { reusePort: true } : {}),
1739 idleTimeout: 120,
1740 // The largest thing anyone may send — a git push. Every reader below it
1741 // keeps its own, smaller cap (\`http/limits.ts\`); the runtime enforces
1742 // this one only on a declared length.
1743 maxRequestBodySize: REQUEST_MAX,
1744 // The only sockets the forge holds are an app's, carried through the
1745 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
1746 websocket: appWebSocket,
1747 async fetch(request, server) {
1739 // What the forge itself answered, for the owner's access log. Called only
1740 // after `notePeer`, so the caller is known; an app's own traffic (the
1741 // `proxyApp` branch) is the app's and never comes through here. `ms` is the
1742 // time to headers: a streamed body is still being sent.
1743 const logged = (
1744 dataDir: string,
1745 request: Request,
1746 response: Response,
1747 started: number,
1748 ) =>
1749 recordAccess(dataDir, {
1750 request,
1751 status: response.status,
1752 ms: performance.now() - started,
1753 client: clientAddress(request),
1754 });
1755
1756 const handle = async (
1757 request: Request,
1758 server: Bun.Server<AppSocket>,
1759 ): Promise<Response | undefined> => {
1760 const started = performance.now();
17481761 // An app's hostname is answered before any route: `hello.<domain>` is
17491762 // the app, whatever its path, and the forge's own routes never apply.
17501763 const runtime = appsRuntime(app);
@@ -1775,6 +1788,7 @@ export async function serve(app: App): Promise<Servers> {
17751788 notePeer(request, server.requestIP(request)?.address, trust);
17761789 armClose(request, () => server.timeout(request, 1));
17771790 const response = securityFloor(await http.handle(request));
1791 logged(key, request, response, started);
17781792 await settleBody(request);
17791793 if (!mustClose(request)) return response;
17801794 server.timeout(request, 1);
@@ -1790,12 +1804,27 @@ export async function serve(app: App): Promise<Servers> {
17901804 const response = securityFloor(
17911805 await answer(app, router, apiRouter, limiter, request),
17921806 );
1807 logged(app.config.dataDir, request, response, started);
17931808 await settleBody(request);
17941809 if (!mustClose(request)) return response;
17951810 // Said, so no client or proxy reuses it; then done, so none can.
17961811 server.timeout(request, 1);
17971812 return withHeader(response, "connection", "close");
1798 },
1813 };
1814
1815 const web = Bun.serve({
1816 port: app.config.port ?? 3000,
1817 // A release swaps in beside this process on the same port (#1663).
1818 ...(app.config.selfSwap === true ? { reusePort: true } : {}),
1819 idleTimeout: 120,
1820 // The largest thing anyone may send — a git push. Every reader below it
1821 // keeps its own, smaller cap (\`http/limits.ts\`); the runtime enforces
1822 // this one only on a declared length.
1823 maxRequestBodySize: REQUEST_MAX,
1824 // The only sockets the forge holds are an app's, carried through the
1825 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
1826 websocket: appWebSocket,
1827 fetch: handle,
17991828 error(error) {
18001829 const refused = refusedByDatabase(error);
18011830 if (refused !== undefined) {
@@ -1841,6 +1870,7 @@ export async function serve(app: App): Promise<Servers> {
18411870 for (const http of childHttps.values()) http.close();
18421871 childHttps.clear();
18431872 closeHostedChildren(app);
1873 flushAccess();
18441874 web.stop(true);
18451875 internal.stop(true);
18461876 // Our socket goes with us; a corpse is only ever an unclean exit's.
src/web/pages/access.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * What outside agents tried, for a forge admin: the last day's non-browser
3 * requests to public paths (`http/access_log.ts`), filterable by user agent
4 * and path. The log holds no token, cookie or query string, and a hash —
5 * never an address — stands for the caller.
6 */
7
8 import { listAccess } from "../../actions/access_log.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
11 import { html } from "../html.ts";
12 import { page } from "../layout.ts";
13 import { btn, card, icon, row } from "../ui.ts";
14 import { accountSections } from "./origins.ts";
15
16 // GET /settings/access
17 export function accessPage(ctx: Ctx): Response {
18 if (!ctx.auth.user) return signInFirst(ctx);
19 const ua = ctx.url.searchParams.get("ua")?.slice(0, 100) ?? "";
20 const path = ctx.url.searchParams.get("path")?.slice(0, 200) ?? "";
21 const listed = listAccess(ctx.app, ctx.auth.user, {
22 ...(ua === "" ? {} : { ua }),
23 ...(path === "" ? {} : { path }),
24 });
25 if (!listed.ok) return notFoundPage(ctx);
26 const rows = listed.value.map((l) =>
27 row({
28 lead: icon("activity"),
29 title: html`<span class="mono">${l.method} ${l.path}</span>
30 <span class="mono">${String(l.status)}</span>`,
31 meta: html`${l.t} · ${l.host} · ${String(l.ms)} ms ·
32 ${l.auth ? "signed in" : "anonymous"} ·
33 <span class="mono">${l.who}</span> ·
34 ${l.ua === "" ? "no user agent" : l.ua}`,
35 }),
36 );
37 const body = html`${card(
38 html`<div class="card-body">
39 <p class="muted small">
40 The last day's requests from anything that is not a browser, to
41 public paths (issues, the API, MCP, discovery files, sign-in and
42 sign-up). Tokens, cookies and query strings are never kept; the
43 hash stands for one caller for one day.
44 </p>
45 <form class="row-actions" method="get" action="/settings/access">
46 <input
47 class="input"
48 name="ua"
49 value="${ua}"
50 placeholder="User agent contains"
51 aria-label="User agent contains"
52 />
53 <input
54 class="input mono"
55 name="path"
56 value="${path}"
57 placeholder="/api/"
58 aria-label="Path starts with"
59 />
60 ${btn("Filter", { size: "sm", variant: "primary" })}
61 </form>
62 </div>
63 ${rows.length === 0
64 ? html`<div class="card-body">
65 <p class="muted small" id="access-none">Nothing in the last day.</p>
66 </div>`
67 : html`<ul class="list" id="access-list">
68 ${rows}
69 </ul>`}`,
70 {
71 head: html`${icon("activity")}
72 <h2>Outside requests</h2>`,
73 pad: false,
74 },
75 )}`;
76 return page({
77 ctx,
78 title: "Access",
79 body: html`<div class="page-head"><h1>Access</h1></div>
80 ${settingsLayout(
81 accountSections("access", { admin: true }),
82 body,
83 "Account settings",
84 )}`,
85 });
86 }
src/web/pages/origins.ts +7
@@ -81,6 +81,7 @@ export type AccountSection =
8181 | "backups"
8282 | "secrets"
8383 | "capacity"
84 | "access"
8485 | "limits"
8586 | "cloud";
8687
@@ -133,6 +134,12 @@ export function accountSections(
133134 icon: "activity" as const,
134135 current: current === "capacity",
135136 },
137 {
138 label: "Access",
139 href: "/settings/access",
140 icon: "activity" as const,
141 current: current === "access",
142 },
136143 {
137144 label: "Limits",
138145 href: "/admin/limits",
test/access-log.test.ts added +294
@@ -0,0 +1,294 @@
1 /**
2 * What outside agents tried (#1061): one compact line per request that is
3 * not a static asset, with no token, cookie, query string or address in it,
4 * kept 14 days and size-capped, and readable only by a forge admin through
5 * Settings → Access and the `access` MCP tool (write scope).
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import {
17 accessDir,
18 clientHash,
19 flushAccess,
20 keptPath,
21 prune,
22 readAccess,
23 recordAccess,
24 TOTAL_BYTES,
25 } from "../src/http/access_log.ts";
26 import { AppsRuntime } from "../src/apps/runtime.ts";
27 import { newApp } from "../src/actions/apps.ts";
28 import { serve, type Servers } from "../src/http/server.ts";
29
30 const logText = (dir: string): string => {
31 flushAccess();
32 return readdirSync(accessDir(dir))
33 .map((f) => readFileSync(join(accessDir(dir), f), "utf8"))
34 .join("");
35 };
36
37 describe("the access log", () => {
38 let dataDir: string;
39 let app: App;
40 let servers: Servers;
41 let adminRw: string;
42 let adminRo: string;
43 let bobRw: string;
44
45 const mcp = async (token: string, args: Record<string, unknown>) => {
46 const res = await fetch(`${servers.url}/mcp`, {
47 method: "POST",
48 headers: {
49 authorization: `Bearer ${token}`,
50 "content-type": "application/json",
51 accept: "application/json, text/event-stream",
52 },
53 body: JSON.stringify({
54 jsonrpc: "2.0",
55 id: 1,
56 method: "tools/call",
57 params: { name: "access", arguments: args },
58 }),
59 });
60 const body = (await res.json()) as {
61 result: { isError?: boolean; content: { text: string }[] };
62 };
63 return {
64 error: body.result.isError === true,
65 text: body.result.content[0]!.text,
66 };
67 };
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-access-"));
71 app = createApp({ dataDir, port: 0 });
72 servers = await serve(app);
73 const a = await createUser(app.db, {
74 trusted: true,
75 username: "alice",
76 email: "[email protected]",
77 password: "a-good-passphrase",
78 adminIfFirst: true,
79 });
80 const b = await createUser(app.db, {
81 trusted: true,
82 username: "bob",
83 email: "[email protected]",
84 password: "a-good-passphrase",
85 });
86 if (!a.ok || !b.ok) throw new Error("users");
87 adminRw = createToken(app.db, a.value.id, "t", "rw").token;
88 adminRo = createToken(app.db, a.value.id, "t", "ro").token;
89 bobRw = createToken(app.db, b.value.id, "t", "rw").token;
90 });
91
92 afterAll(async () => {
93 servers.stop();
94 app.close();
95 await rm(dataDir, { recursive: true, force: true });
96 });
97
98 test("a replayed agent request is kept, redacted, and read back", async () => {
99 const secret = "forge_SECRETTOKENVALUE0123456789abcdef";
100 await fetch(`${servers.url}/api/v1/nothing-here?token=${secret}&q=hunter2`, {
101 headers: {
102 "user-agent": "ExampleAgent/1.0 (+https://example.test)",
103 authorization: `Bearer ${secret}`,
104 cookie: "forge_session=COOKIEVALUE123; other=zzz",
105 },
106 });
107 await fetch(`${servers.url}/mcp`, {
108 method: "POST",
109 headers: { "user-agent": "ExampleAgent/1.0" },
110 body: "{}",
111 });
112 // A browser and a static asset are not what this reads.
113 await fetch(`${servers.url}/issues`, {
114 headers: { "user-agent": "Mozilla/5.0 (X11) Firefox/130" },
115 });
116 await fetch(`${servers.url}/assets/missing.css`);
117
118 const raw = logText(dataDir);
119 for (const leak of [secret, "SECRETTOKENVALUE", "hunter2", "COOKIEVALUE", "zzz", "q=", "127.0.0.1"])
120 expect(raw).not.toContain(leak);
121 expect(raw).not.toContain("assets");
122 const lines = raw.trim().split("\n").map((l) => JSON.parse(l));
123 const hit = lines.find((l) => l.path === "/api/v1/nothing-here");
124 expect(hit).toMatchObject({
125 method: "GET",
126 status: 404,
127 auth: true,
128 ua: "ExampleAgent/1.0 (+https://example.test)",
129 });
130 expect(hit.who).toMatch(/^[0-9a-f]{16}$/);
131 expect(typeof hit.ms).toBe("number");
132
133 const read = await mcp(adminRw, { ua: "exampleagent" });
134 expect(read.error).toBe(false);
135 const rows = JSON.parse(read.text).requests as { path: string; ua: string }[];
136 expect(rows.map((r) => r.path)).toContain("/api/v1/nothing-here");
137 expect(rows.every((r) => !r.ua.includes("Mozilla"))).toBe(true);
138 const narrow = JSON.parse((await mcp(adminRw, { path: "/mcp" })).text).requests;
139 expect(narrow.every((r: { path: string }) => r.path.startsWith("/mcp"))).toBe(true);
140 expect(narrow.length).toBeGreaterThan(0);
141 });
142
143 test("a path never carries a secret-looking segment or a long line", () => {
144 expect(keptPath("/claim/abcdefghijklmnopqrstuvwxyz0123")).toBe("/claim/:redacted");
145 expect(keptPath("/x/forge_abc")).toBe("/x/:redacted");
146 expect(keptPath("/issues/12")).toBe("/issues/12");
147 expect(keptPath("/a\n/b" + "c".repeat(500)).length).toBeLessThanOrEqual(200);
148 expect(keptPath("/a\nb")).not.toContain("\n");
149 });
150
151 test("only an admin with write scope reads it, on the page and by MCP", async () => {
152 expect((await mcp(adminRo, {})).error).toBe(true);
153 expect((await mcp(adminRo, {})).text).toContain("forbidden");
154 const bob = await mcp(bobRw, {});
155 expect(bob.error).toBe(true);
156 expect(bob.text).toContain("not_found");
157 const anon = await fetch(`${servers.url}/settings/access`, { redirect: "manual" });
158 expect(anon.status).not.toBe(200);
159 });
160
161 test("the settings page lists what was tried, escaped", async () => {
162 const res = await fetch(`${servers.url}/signin`, {
163 method: "POST",
164 redirect: "manual",
165 headers: {
166 "content-type": "application/x-www-form-urlencoded",
167 origin: servers.url,
168 },
169 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
170 });
171 const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
172 await fetch(`${servers.url}/api/v1/x`, {
173 headers: { "user-agent": "<script>alert(1)</script>-bot" },
174 });
175 const page = await fetch(`${servers.url}/settings/access`, { headers: { cookie } });
176 expect(page.status).toBe(200);
177 const html = await page.text();
178 expect(html).toContain("Outside requests");
179 expect(html).toContain("/api/v1/nothing-here");
180 expect(html).not.toContain("<script>alert(1)</script>-bot");
181 expect(html).toContain("&lt;script&gt;");
182 });
183 });
184
185 describe("the access log's keeping", () => {
186 test("files older than 14 days go, and the directory stays under its cap", async () => {
187 const dir = await mkdtemp(join(tmpdir(), "forge-access-rot-"));
188 try {
189 const keep = accessDir(dir);
190 mkdirSync(keep, { recursive: true });
191 writeFileSync(join(keep, "access-2026-09-01.log"), "old\n");
192 writeFileSync(join(keep, "access-2026-09-30.log"), "recent\n");
193 const now = new Date("2026-10-13T12:00:00Z");
194 prune(dir, now);
195 expect(readdirSync(keep)).toEqual(["access-2026-09-30.log"]);
196 // Over the cap: the oldest goes first, today's file always stays.
197 const big = "x".repeat(TOTAL_BYTES / 2 + 1);
198 writeFileSync(join(keep, "access-2026-10-10.log"), big);
199 writeFileSync(join(keep, "access-2026-10-11.log"), big);
200 writeFileSync(join(keep, "access-2026-10-13.log"), "today\n");
201 prune(dir, now);
202 expect(readdirSync(keep).sort()).toEqual([
203 "access-2026-10-11.log",
204 "access-2026-10-13.log",
205 ]);
206 } finally {
207 await rm(dir, { recursive: true, force: true });
208 }
209 });
210
211 test("recording prunes on a new day, and the hash changes with the day", async () => {
212 const dir = await mkdtemp(join(tmpdir(), "forge-access-day-"));
213 try {
214 const seen = (path: string) => ({
215 request: new Request(`http://forge.test${path}?k=v`, {
216 headers: { "user-agent": "bot" },
217 }),
218 status: 200,
219 ms: 3.4,
220 client: "203.0.113.9",
221 });
222 mkdirSync(accessDir(dir), { recursive: true });
223 writeFileSync(join(accessDir(dir), "access-2026-09-01.log"), "old\n");
224 recordAccess(dir, seen("/llms.txt"), new Date("2026-10-13T01:00:00Z"));
225 recordAccess(dir, seen("/llms.txt"), new Date("2026-10-14T01:00:00Z"));
226 // Batched: a request has only queued its line.
227 expect(readdirSync(accessDir(dir))).toEqual(["access-2026-09-01.log"]);
228 flushAccess(new Date("2026-10-14T01:00:00Z"));
229 expect(readdirSync(accessDir(dir)).sort()).toEqual([
230 "access-2026-10-13.log",
231 "access-2026-10-14.log",
232 ]);
233 const [a, b] = readdirSync(accessDir(dir))
234 .sort()
235 .map((f) => JSON.parse(readFileSync(join(accessDir(dir), f), "utf8")));
236 expect(a.who).not.toBe(b.who);
237 expect(logText(dir)).not.toContain("203.0.113.9");
238 const rows = readAccess(dir, { since: "2026-10-13T00:00:00Z" }, new Date("2026-10-14T02:00:00Z"));
239 expect(rows.map((r) => r.path)).toEqual(["/llms.txt", "/llms.txt"]);
240 } finally {
241 await rm(dir, { recursive: true, force: true });
242 }
243 });
244 });
245
246 describe("the access log and app hostnames", () => {
247 test("an app's own traffic, and a name nobody holds, are not the forge's access", async () => {
248 const dataDir = await mkdtemp(join(tmpdir(), "forge-access-apps-"));
249 const app = createApp({ dataDir, port: 0, host: "forge.test" });
250 const servers = await serve(app);
251 const runtime = new AppsRuntime(app, {
252 domain: "apps.test",
253 scheme: "http",
254 publicPort: servers.port,
255 forgeUrl: servers.url,
256 concurrency: 1,
257 });
258 try {
259 await runtime.start();
260 const u = await createUser(app.db, {
261 trusted: true,
262 username: "alice",
263 email: "[email protected]",
264 password: "a-good-passphrase",
265 adminIfFirst: true,
266 });
267 if (!u.ok) throw new Error(u.error);
268 const made = await newApp(app, u.value, {
269 name: "notes",
270 description: "",
271 visibility: "public",
272 });
273 if (!made.ok) throw new Error(String(made.error));
274 const ask = (host: string, path: string) =>
275 fetch(`${servers.url}${path}`, {
276 headers: { host, "user-agent": "VisitorBot/1.0" },
277 });
278 await ask("notes.apps.test", "/api/visitor-of-the-app");
279 await ask("nosuch.apps.test", "/api/nobody-home");
280 await ask("forge.test", "/api/the-forge-itself");
281 const lines = logText(dataDir)
282 .trim()
283 .split("\n")
284 .map((l) => JSON.parse(l));
285 expect(lines.map((l) => l.path)).toEqual(["/api/the-forge-itself"]);
286 expect(lines[0].who).not.toBe(clientHash(dataDir, "unknown", new Date()));
287 } finally {
288 await runtime.stop();
289 servers.stop();
290 app.close();
291 await rm(dataDir, { recursive: true, force: true });
292 }
293 });
294 });
test/mcp.test.ts +1
@@ -202,6 +202,7 @@ describe("the forge over MCP", () => {
202202 "attach_look",
203203 "hold",
204204 "plan",
205 "access",
205206 "capacity",
206207 "flow",
207208 "limits",
test/spawn-host.test.ts +25 −1
@@ -6,9 +6,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
9 import { spawnForge } from "../src/actions/spawn.ts";
9 import { readdirSync, readFileSync } from "node:fs";
10 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1011 import { createApp, type App } from "../src/app.ts";
1112 import { createUser } from "../src/core/users.ts";
13 import { flushAccess } from "../src/http/access_log.ts";
1214 import { serve, type Servers } from "../src/http/server.ts";
1315 import { hostedPlacement } from "../src/spawn/placement.ts";
1416 import { quietLogs } from "./helpers/globals.ts";
@@ -72,4 +74,26 @@ describe("hosted child Host dispatch", () => {
7274 });
7375 expect(unknown.status).toBe(404);
7476 });
77
78 test("a child's requests are logged in the child's data, never the parent's", async () => {
79 const ask = (host: string, path: string) =>
80 fetch(servers.url + path, {
81 headers: { host, "user-agent": "VisitorBot/1.0" },
82 });
83 await ask("homie--forge.test", "/api/in-the-childs-house");
84 await ask("forge.test", "/api/in-the-parents-house");
85 flushAccess();
86 const { bodyId } = JSON.parse(
87 readFileSync(join(dir, "spawn", "homie.json"), "utf8"),
88 ) as { bodyId: string };
89 const paths = (dataDir: string): string[] =>
90 readdirSync(join(dataDir, "access")).flatMap((f) =>
91 readFileSync(join(dataDir, "access", f), "utf8")
92 .split("\n")
93 .filter((l) => l !== "")
94 .map((l) => (JSON.parse(l) as { path: string }).path),
95 );
96 expect(paths(childDirOf(parent, bodyId))).toEqual(["/api/in-the-childs-house"]);
97 expect(paths(dir)).toEqual(["/api/in-the-parents-house"]);
98 });
7599 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs