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

Before publishing: decide who reads a public repository's crew run logs, and seal the security work from before sealing existed #1393

Merged opened this

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

Merged

scout/forge:scout/1075-before-publishing-decide-who-reads-a-pub → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/1075-before-publishing-decide-who-reads-a-pub was merged into main as f416d3c.

Review

Sign in to review this change.
10 files changed +283 −24
docs/APPS.md +6
@@ -124,6 +124,12 @@ started`; a deploy no push asked for — a redeploy someone pressed, a
124124 follows the same rule: its environments' state and errors (`envsFor`),
125125 its runs and their logs (`mayReadRuns`) and its run and serving events
126126 reach only those who may enter it.
127 On any repository, public too, a run's **log** (the agent's
128 transcript, a deploy's output) is read only by its writers and crew
129 (`mayReadRunLog`); a stranger reads the run and its summary, not the

This says "writers and crew", and the code lets in writers only. Make the two agree. If check-run output is meant to be hidden from a pull's outside author too, say so here.

130 log, on the page, the API and the live tails (#1075). Check runs share
131 the rule: on a public repository the author of a pull from outside it
132 reads a failed check's summary, not its output.
127133 6. **A run's credential dies with the run.** An agent run gets a token
128134 scoped `rw` (no settings, no tokens, no administration) with an expiry,
129135 and the token is deleted when the run ends however it ends. It never
scripts/gate.ts +1
@@ -38,6 +38,7 @@ export const GATE: readonly string[] = [
3838 "test/contents-api.test.ts",
3939 "test/crew-agents-only.test.ts",
4040 "test/crew-brief-audience.test.ts",
41 "test/run-log-audience.test.ts",
4142 "test/crew.test.ts",
4243 "test/commit-identity.test.ts",
4344 "test/enterable-apps.test.ts",
src/actions/runs.ts +25 −2
@@ -15,7 +15,7 @@ import { steerRun } from "../crew/runner.ts";
1515 import { getIssueById } from "../core/issues.ts";
1616 import { addComment } from "./issues.ts";
1717 import { getAppByRepo } from "../core/apps.ts";
18 import { canEnterApp, type Repo } from "../core/repos.ts";
18 import { canEnterApp, canSeeOwnerFacts, onCrew, type Repo } from "../core/repos.ts";
1919 import {
2020 cancelRun as markCancelled,
2121 getRun,
@@ -82,6 +82,23 @@ export function mayReadRuns(
8282 );
8383 }
8484
85 /**
86 * Whether `actor` may read a run's log — the agent's transcript, or a
87 * deploy's output. A summary is public with the run; the log is for the
88 * repository's writers and crew only, on a public repository too: it says
89 * what the agent ran, read and concluded, in the detail it wrote for itself.
90 */
91 export function mayReadRunLog(
92 app: App,
93 actor: User | undefined,
94 repo: Repo,
95 ): boolean {
96 return (
97 mayReadRuns(app, actor, repo) &&
outdated

High: canSeeOwnerFacts is canWrite, which has no crew grant (see ownerFactRepoIds). The doc comment above promises "writers and crew". Add || onCrew(app.db, repo, actor) as canEnterApp does, and add a test with a crew agent that has no write grant.

98 (canSeeOwnerFacts(app.db, repo, actor) || onCrew(app.db, repo, actor))
99 );
100 }
101
85102 export function listRepoRuns(
86103 app: App,
87104 actor: User | undefined,
@@ -118,6 +135,8 @@ export interface RunLogView {
118135 readonly lines: string[];
119136 readonly truncated: boolean;
120137 readonly path: string;
138 /** The reader may see the run but not its log: `lines` is empty. */
139 readonly logHidden: boolean;
121140 }
122141
123142 export function runLog(
@@ -128,12 +147,16 @@ export function runLog(
128147 ): Result<RunLogView, "not_found"> {
129148 const found = getRepoRun(app, actor, repo, runId);
130149 if (!found.ok) return found;
131 const { lines, truncated } = readRunLog(app.config.dataDir, runId);
150 const logHidden = !mayReadRunLog(app, actor, repo);
151 const { lines, truncated } = logHidden
152 ? { lines: [], truncated: false }
153 : readRunLog(app.config.dataDir, runId);
132154 return ok({
133155 run: found.value,
134156 lines,
135157 truncated,
136158 path: runLogPath(app.config.dataDir, runId),
159 logHidden,
137160 });
138161 }
139162
src/http/api.ts +1 −1
@@ -4449,7 +4449,7 @@ export function apiRoutes(router: Router<Handler>): void {
44494449 const repo = loadRepo(ctx);
44504450 if (!repo) return notFound();
44514451 const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
4452 if (!found.ok) return notFound();
4452 if (!found.ok || found.value.logHidden) return notFound();
44534453 const follow =
44544454 ctx.url.searchParams.get("follow") === "1" ||
44554455 (ctx.request.headers.get("accept") ?? "").includes("text/event-stream");
src/web/pages/runs.ts +2 −2
@@ -298,8 +298,8 @@ export async function runPage(ctx: Ctx): Promise<Response> {
298298 if (!repo) return notFoundPage(ctx);
299299 const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
300300 if (!found.ok) return notFoundPage(ctx);
301 const { run, lines, truncated } = found.value;
302 return await runView(ctx, repo, run, lines, truncated);
301 const { run, lines, truncated, logHidden } = found.value;
302 return await runView(ctx, repo, run, lines, truncated, logHidden);
303303 }
304304
305305 // POST /:owner/:name/runs/:id/steer { text }
src/web/studio/now.ts +2 −2
@@ -20,7 +20,7 @@ import { getUsersByIds } from "../../core/users.ts";
2020 import { repoDir } from "../../git/repos.ts";
2121 import type { Ctx } from "../../http/context.ts";
2222 import { readRunLogTail } from "../../runs/log.ts";
23 import { mayReadRuns } from "../../actions/runs.ts";
23 import { mayReadRunLog } from "../../actions/runs.ts";
2424 import { loadRepo, repoBase } from "../chrome.ts";
2525 import { html, type Html } from "../html.ts";
2626 import { fragment } from "../layout.ts";
@@ -169,7 +169,7 @@ export async function nowCard(
169169 (r) => r.kind === "agent" && r.state === "running",
170170 );
171171 const activeTail =
172 activeRun && mayReadRuns(ctx.app, ctx.auth.user, repo)
172 activeRun && mayReadRunLog(ctx.app, ctx.auth.user, repo)

High: no test covers this gate. If it is reverted to mayReadRuns, the suite stays green and a stranger gets the running agent's transcript server-rendered on a public app's page. Add a stranger/anonymous case to run-log-audience.test.ts that asserts app-run-tail and the marker line are absent, plus an owner case that asserts they are present.

173173 ? logBox({
174174 id: "app-run-tail",
175175 who: voiceFor(runActor(activeRun), ownerFacts),
src/web/studio/previews.ts +3 −2
@@ -194,7 +194,8 @@ function previewsPanel(
194194 * Once the deploy has swapped in, the lines are the app's own — never the
195195 * deploy's, whose word (`envPhrase`) has already moved on to "live" — and
196196 * an app's stdout can carry anything the owner never meant for a stranger,
197 * so only a viewer who can see the owner's facts is shown it at all.
197 * so only a viewer who can see the owner's facts is shown it at all —
198 * a deploy's too, as it is a log (`mayReadRunLog`).
198199 */
199200 function productionTail(
200201 ctx: Ctx,
@@ -206,7 +207,7 @@ function productionTail(
206207 if (production === undefined || production.runId === undefined) return none;
207208 const deploying = production.state === "deploying";
208209 if (!deploying && production.state !== "running") return none;
209 if (!deploying && !writer) return none;
210 if (!writer) return none;

High: this is a behaviour change (a stranger no longer sees a deploy's output while it is deploying), and no test would fail without it. Pin it: give a public app's production env deploying state with a logged marker line; the anonymous reader and mallory should not see the marker, and alice should.

210211 const agentBusy = listRuns(ctx.app.db, repo.id, {
211212 kind: "agent",
212213 limit: 8,
src/web/studio/run.ts +11 −3
@@ -66,6 +66,7 @@ export async function runView(
6666 run: Run,
6767 lines: readonly string[],
6868 truncated: boolean,
69 logHidden = false,
6970 ): Promise<Response> {
7071 const db = ctx.app.db;
7172 const base = repoBase(repo);
@@ -203,6 +204,13 @@ export async function runView(
203204 </div>`,
204205 )}
205206 ${when(pull, (p) => previewCard(ctx, repo, p))}
207 ${when(
208 logHidden,
209 () =>
210 html`<p class="muted small" id="run-log-hidden">
211 The log is for this repository's writers and crew.
212 </p>`,
213 )}
206214 ${when(
207215 truncated,
208216 () =>
@@ -211,14 +219,14 @@ export async function runView(
211219 </p>`,
212220 )}
213221 ${when(
214 lines.length === 0,
222 lines.length === 0 && !logHidden,
215223 () =>
216224 html`<p class="muted small" id="run-empty">
217225 Nothing has been logged yet.
218226 </p>`,
219227 )}
220228 <div class="run-live" id="run-live" data-state="${run.state}">
221 ${logBox({
229 ${when(!logHidden, () => logBox({
222230 id: "run-log",
223231 who,
224232 cap: all
@@ -232,7 +240,7 @@ export async function runView(
232240 extra: html`<button type="button" class="run-jump" id="run-jump" hidden>
233241 newer below
234242 </button>`,
235 })}
243 }))}
236244 ${when(
237245 run.kind === "agent",
238246 () => html`<p class="muted small crew-note">${directionNote(repo)}</p>`,
test/run-log-audience.test.ts added +221
@@ -0,0 +1,221 @@
1 /**
2 * A public repository's run is public in its summary and private in its log
3 * (#1075): the log is the agent's transcript — what it ran, read and
4 * concluded — so only the repository's writers and crew read it, on the run
5 * page, the API, and the live tails beside it.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import type { AppRecord } from "../src/core/apps.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { enableApp } from "../src/actions/apps.ts";
outdated

Nit: fold this into the claimRun, createRun import above, since it comes from the same module.

15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
17 import { AppsRuntime } from "../src/apps/runtime.ts";
18 import { getAppByRepo } from "../src/core/apps.ts";
19 import { createAgent } from "../src/actions/crew.ts";
20 import { addCrew } from "../src/core/agents.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser, type User } from "../src/core/users.ts";
23 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
24 import { appendRunLog } from "../src/runs/log.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 const LINE = "R7LOGLINE cat /sandbox/notes.txt";
30 const SUMMARY = "R7SUMMARY fixed the thing";
31
32 let dataDir: string;
33 let app: App;
34 let http: ForgeHttp;
35 let runId: string;
36 let runtime: AppsRuntime;
37 let appRecord: AppRecord;
38 let repoRow: Repo;
39 let aliceId: string;
40 const token: Record<string, string> = {};
41
42 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
43 if (!r.ok) throw new Error(JSON.stringify(r.error));
44 return r.value;
45 };
46
47 async function get(path: string, who?: string) {
48 const res = await http.handle(new Request(`http://forge.test${path}`, {
49 headers: {
50 cookie: "forge_mode=studio",
51 ...(who === undefined ? {} : { authorization: `Bearer ${token[who]}` }),
52 },
53 }));
54 return { status: res.status, text: await res.text() };
55 }
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-runlog-"));
59 app = createApp({ dataDir, port: 0 });
60 http = forgeHttp(app);
61 const alice: User = ok(
62 await createUser(app.db, {
63 trusted: true,
64 username: "alice",
65 email: "[email protected]",
66 password: "a-good-passphrase",
67 adminIfFirst: true,
68 }),
69 );
70 const mallory = ok(
71 await createUser(app.db, {
72 trusted: true,
73 username: "mallory",
74 email: "[email protected]",
75 password: "a-good-passphrase",
76 }),
77 );
78 const repo = ok(
79 await createRepo(app, alice, { name: "square", visibility: "public" }),
80 );
81 ok(
82 await commitToBranch(app, alice, repo, {
83 message: "Start",
84 files: [{ path: "README.md", content: "# square\n" }],
85 }),
86 );
87 repoRow = repo;
88 aliceId = alice.id;
89 runtime = new AppsRuntime(app, {
90 domain: "audience.test",
91 scheme: "http",
92 publicPort: 0,
93 forgeUrl: "http://forge.test",
94 concurrency: 1,
95 });
96 ok(await enableApp(app, alice, repo, { slug: "square" }));
97 appRecord = getAppByRepo(app.db, repo.id)!;
98 token.alice = createToken(app.db, alice.id, "t", "rw").token;
99 token.mallory = createToken(app.db, mallory.id, "t", "rw").token;
100 const bot = ok(await createAgent(app, alice, { username: "logbot" }));
101 addCrew(app.db, repo.id, bot.user.id, alice.id);
102 token.logbot = createToken(app.db, bot.user.id, "t", "rw").token;
103 const run = createRun(app.db, {
104 repoId: repo.id,
105 kind: "agent",
106 actorId: alice.id,
107 reason: "assigned",
108 });
109 claimRun(app.db, run.id);
110 runId = run.id;
111 appendRunLog(dataDir, runId, LINE);
112 finishRun(app.db, runId, { state: "ok", summary: SUMMARY });
113 }, 60_000);
114
115 afterAll(async () => {
116 await runtime?.stop?.();
117 app?.close();
118 await rm(dataDir, { recursive: true, force: true });
119 });
120
121 describe("run log audience", () => {
122 test("a stranger reads a public repository's run summary, not its log", async () => {
123 for (const who of [undefined, "mallory"]) {
124 const api = await get(`/api/v1/repos/alice/square/runs/${runId}`, who);
125 expect(api.status).toBe(200);
126 expect(api.text).toContain("R7SUMMARY");
127
128 const page = await get(`/alice/square/runs/${runId}`, who);
129 expect(page.status).toBe(200);
130 expect(page.text).toContain("R7SUMMARY");
131 expect(page.text).not.toContain("R7LOGLINE");
132 expect(page.text).not.toContain(`/runs/${runId}/log`);
133
134 for (const path of [
135 `/api/v1/repos/alice/square/runs/${runId}/log`,
136 `/api/v1/repos/alice/square/runs/${runId}/log?follow=1`,
137 ]) {
138 const log = await get(path, who);
139 expect(log.status).toBe(404);
140 expect(log.text).not.toContain("R7LOGLINE");
141 }
142 }
143 });
144
145 test("a writer still reads the log, on the API and the page", async () => {
146 const log = await get(`/api/v1/repos/alice/square/runs/${runId}/log`, "alice");
147 expect(log.status).toBe(200);
148 expect(log.text).toContain("R7LOGLINE");
149 const page = await get(`/alice/square/runs/${runId}`, "alice");
150 expect(page.text).toContain("R7LOGLINE");
151 });
152
153 test("an agent on the crew reads the log without a write grant", async () => {
154 for (const path of [
155 `/api/v1/repos/alice/square/runs/${runId}/log`,
156 `/api/v1/repos/alice/square/runs/${runId}/log?follow=1`,
157 ]) {
158 const log = await get(path, "logbot");
159 expect(log.status).toBe(200);
160 expect(log.text).toContain("R7LOGLINE");
161 }
162 const page = await get(`/alice/square/runs/${runId}`, "logbot");
163 expect(page.text).toContain("R7LOGLINE");
164 });
165
166 const putEnv = (slot: Record<string, unknown>): void => {
167 (runtime as unknown as { envs: Map<string, unknown> }).envs.set(
168 `${appRecord.id}/production`,
169 { app: appRecord, env: "production", chain: Promise.resolve(), restarts: [], ...slot },
170 );
171 };
172
173 test("a deploying production's output is a writer's, on the app page", async () => {
174 const deploy = createRun(app.db, {
175 repoId: repoRow.id,
176 kind: "deploy",
177 env: "production",
178 commitOid: "f00dcafe",
179 reason: "boot",
180 });
181 claimRun(app.db, deploy.id);
182 appendRunLog(dataDir, deploy.id, "R7DEPLOYLINE install deps");
183 putEnv({
184 deploying: {
185 run: { id: deploy.id, commitOid: "f00dcafe" },
186 abort: new AbortController(),
187 },
188 });
189 for (const who of [undefined, "mallory"]) {
190 const page = await get("/alice/square", who);
191 expect(page.status).toBe(200);
192 expect(page.text).not.toContain('id="app-run-tail"');
193 expect(page.text).not.toContain("R7DEPLOYLINE");
194 }
195 const mine = await get("/alice/square", "alice");
196 expect(mine.text).toContain('id="app-run-tail"');
197 expect(mine.text).toContain("R7DEPLOYLINE");
198 finishRun(app.db, deploy.id, { state: "ok", summary: "deploy" });
199 });
200
201 test("a running agent's tail is a writer's, on the app page", async () => {
202 const run = createRun(app.db, {
203 repoId: repoRow.id,
204 kind: "agent",
205 actorId: aliceId,
206 reason: "assigned",
207 });
208 claimRun(app.db, run.id);
209 appendRunLog(dataDir, run.id, "R7AGENTLINE reading secrets.txt");
210 for (const who of [undefined, "mallory"]) {
211 const page = await get("/alice/square", who);
212 expect(page.status).toBe(200);
213 expect(page.text).not.toContain('id="app-run-tail"');
214 expect(page.text).not.toContain("R7AGENTLINE");
215 }
216 const mine = await get("/alice/square", "alice");
217 expect(mine.text).toContain('id="app-run-tail"');
218 expect(mine.text).toContain("R7AGENTLINE");
219 finishRun(app.db, run.id, { state: "ok", summary: "done" });
220 });
221 });
test/studio.test.ts +11 −12
@@ -6051,7 +6051,9 @@ describe("a stranger sees the app, not the owner's books", () => {
60516051 expect(page).not.toContain("$42.50");
60526052 expect(page).not.toContain("7 turns");
60536053 expect(page).not.toContain("keeper");
6054 expect(page).toContain('data-who="the crew"');
6054 // The log box, which carried data-who, is not drawn for them (#1075).
6055 expect(page).toContain("<title>the crew on #");
6056 expect(page).not.toContain('id="run-log"');
60556057 }
60566058 const ownerPage = await (
60576059 await get(`/alice/beacon/runs/${run.id}`, ownerCookie)
@@ -6076,17 +6078,14 @@ describe("a stranger sees the app, not the owner's books", () => {
60766078 ).json()) as { items: { costUsd: number }[] };
60776079 expect(listAsStranger.items.every((r) => r.costUsd === 0)).toBe(true);
60786080
6079 const tailAsStranger = await (
6080 await get(
6081 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
6082 strangerCookie,
6083 )
6084 ).text();
6085 expect(tailAsStranger).toContain("event: done");
6086 // The cost by its key, not its digits: a timestamp's seconds can read
6087 // "42.5" too, and once did.
6088 expect(tailAsStranger).not.toContain('"costUsd":42.5');
6089 expect(tailAsStranger).toContain('"costUsd":0');
6081 // The log is the writers' and crew's (#1075): a stranger's tail is
6082 // refused, and carries neither the cost nor the transcript.
6083 const tailAsStranger = await get(
6084 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
6085 strangerCookie,
6086 );
6087 expect(tailAsStranger.status).toBe(404);
6088 expect(await tailAsStranger.text()).not.toContain("42.5");
60906089 const tailAsOwner = await (
60916090 await get(
60926091 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs