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

Security is top tier: enforce pod security everywhere, then red-team the forge from inside #10454

Merged opened this in answer to

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

Merged

scout/forge:scout/10441-security-is-top-tier-enforce-pod-securit → main 2 commits fork

  • Merged. scout/10441-security-is-top-tier-enforce-pod-securit was merged into main as 8ea585e.

Review

Sign in to review this change.
2 files changed +127 −0
docs/ops/RED-TEAM.md added +28
@@ -0,0 +1,28 @@
1 # Red team: the forge from inside
2
3 What issue #10441 asked: can something running inside the forge pod get past
4 a door it should not? Each finding names the code that holds the door and the
5 test that fails if it reopens. This round reads the code paths. It was not a
6 live run in the pod (a crew sandbox can reach neither the pod nor the cluster),
7 so the live run is still open as its own issue.
8
9 ## Findings, 2026-11-09
10
11 | Door | Holds | Kept by |
12 | --- | --- | --- |
13 | An app reads the forge's env (`FORGE_EDGE_TOKEN`, keys, the forge's own token) | `appEnv` builds the app's environment from an allowlist (`src/apps/process.ts`); the forge's environment is never copied in | `test/redteam-doors.test.ts` "app to forge" |
14 | An app reads the forge's data | `FORGE_APP_DATA` is the app's own directory, and the cell confines it (`src/apps/contain.ts`) | `test/redteam-doors.test.ts`, the cell suites (they run on the forge's check, not in a crew sandbox) |
15 | A visitor sends `x-forge-identity` / `x-forge-signature` that the app believes | The proxy deletes both and writes signed ones of its own (`IDENTITY_HEADERS`, `src/apps/proxy.ts`) | `test/apps-identity.test.ts`, `test/redteam-doors.test.ts` |
16 | An app learns the edge secret or a bearer token from a forwarded request | `stripForgeCredentials` drops every `x-plat-*` header, `authorization`, and the grant cookie | `test/redteam-doors.test.ts` "forged identity headers" |
17 | Someone outside claims a person with `x-plat-user` | `edgeIdentity` believes it only with the edge secret, compared as digests | `test/redteam-doors.test.ts`, `test/edge-auth.test.ts` |
18 | A crew run leaves its worktree or its network allowlist | bwrap in `src/crew/sandbox.ts` | `test/crew-sandbox.test.ts`; the escape attempts under `procMount: Unmasked` are still open |
19 | Note or issue text steers a crew brief | `untrusted` marking in briefs | still open: needs a wider test than #10365 |
20
21 ## Not done here
22
23 - **Pod security enforced on all nine namespaces.** The namespace labels live
24 in the cluster's GitOps source, which is not in this repository. That work
25 happens there.
26 - **The live run inside the pod and the sandbox escape attempts**: #10450.
27 - **Wider prompt-injection tests**: #10451.
28 - **A security page under ops** with one line per control: #10452.
test/redteam-doors.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * The red-team findings of #10441 (docs/ops/RED-TEAM.md), each kept as a
3 * test that fails the day its door reopens.
4 */
5
6 import { afterEach, describe, expect, test } from "bun:test";
7 import { appEnv } from "../src/apps/process.ts";
8 import { IDENTITY_HEADERS, stripForgeCredentials } from "../src/apps/proxy.ts";
9 import { edgeIdentity } from "../src/http/edge.ts";
10
11 const FORGE_SECRETS = {
12 FORGE_EDGE_TOKEN: "edge-secret-xyz",
13 FORGE_SECRET_KEY: "secret-key-xyz",
14 FORGE_TOKEN: "forge-own-token-xyz",
15 ANTHROPIC_API_KEY: "sk-ant-xyz",
16 GITHUB_TOKEN: "ghp-xyz",
17 DATABASE_URL: "postgres://forge:pw@db/forge",
18 };
19
20 const saved: Record<string, string | undefined> = {};
21 afterEach(() => {
22 for (const [name, value] of Object.entries(saved)) {
23 if (value === undefined) delete process.env[name];
24 else process.env[name] = value;
25 }
26 });
27
28 function envWithForgeSecretsSet(): Record<string, string> {
29 for (const [name, value] of Object.entries(FORGE_SECRETS)) {
30 saved[name] = process.env[name];
31 process.env[name] = value;
32 }
33 return appEnv({
34 port: 4100,
35 dataDir: "/data/apps/notes/production",
36 env: "production",
37 url: "https://notes.example",
38 commitOid: "a".repeat(40),
39 forgeUrl: "https://forge.example",
40 identityKey: "identity-key",
41 });
42 }
43
44 describe("app to forge: an app process hears none of the forge's secrets", () => {
45 test("no forge secret's value reaches the app's environment", () => {
46 const env = envWithForgeSecretsSet();
47 const values = Object.values(env).join("\n");
48 for (const secret of Object.values(FORGE_SECRETS)) expect(values).not.toContain(secret);
49 expect(env["FORGE_EDGE_TOKEN"]).toBeUndefined();
50 expect(env["FORGE_TOKEN"]).toBeUndefined();
51 });
52
53 test("the app's environment is an allowlist, not the forge's environment minus some", () => {
54 const env = envWithForgeSecretsSet();
55 const allowed = new Set([
56 "PATH", "HOME", "LANG", "TMPDIR", "PORT", "HOST", "NODE_ENV",
57 "FORGE_APP_ENV", "FORGE_APP_DATA", "FORGE_APP_URL", "FORGE_APP_COMMIT",
58 "FORGE_URL", "FORGE_API_URL", "FORGE_APP_IDENTITY_KEY",
59 "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
60 ]);
61 for (const name of Object.keys(env)) expect(allowed.has(name) ? name : `unexpected ${name}`).toBe(name);
62 });
63
64 test("its data directory is its own, never the forge's", () => {
65 const env = envWithForgeSecretsSet();
66 expect(env["FORGE_APP_DATA"]).toBe("/data/apps/notes/production");
67 });
68 });
69
70 describe("forged identity headers: what a visitor writes never reaches an app as truth", () => {
71 test("the forge's identity headers are the ones the proxy replaces", () => {
72 expect(IDENTITY_HEADERS).toEqual(["x-forge-identity", "x-forge-signature"]);
73 });
74
75 test("edge headers, bearer tokens and the grant cookie are stripped before the app", () => {
76 const headers = new Headers({
77 "x-plat-user": "admin",
78 "x-plat-edge-token": "edge-secret-xyz",
79 "X-Plat-Anything": "1",
80 authorization: "Bearer forge-own-token-xyz",
81 cookie: "forge_grant_notes=abc; theme=dark",
82 });
83 stripForgeCredentials(headers, "forge_grant_notes");
84 expect([...headers.keys()].filter((n) => n.startsWith("x-plat-"))).toEqual([]);
85 expect(headers.get("authorization")).toBeNull();
86 expect(headers.get("cookie")).toBe("theme=dark");
87 });
88
89 test("an edge claim without the edge's secret is nobody", () => {
90 const forged = new Request("https://forge.example/", { headers: { "x-plat-user": "trevato" } });
91 expect(edgeIdentity(forged, "edge-secret-xyz")).toBeUndefined();
92 const wrong = new Request("https://forge.example/", {
93 headers: { "x-plat-user": "trevato", "x-plat-edge-token": "guess" },
94 });
95 expect(edgeIdentity(wrong, "edge-secret-xyz")).toBeUndefined();
96 expect(edgeIdentity(wrong, "")).toBeUndefined();
97 expect(edgeIdentity(wrong, undefined)).toBeUndefined();
98 });
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs