Move secrets between users and computers without the model seeing them

A new personal env scope (paradigm user: one user's secret for one bot)
beats every other scope, reaches only that user's runs of that bot, and
is redacted like any secret. Bots take secrets in with request_secret,
which puts a [secret-request:ID] card in the chat: the user types the
value into a masked input there, never into a message, and the bot is
told only the key. Bots show secrets with capture_secret (a task's
computer tool that saves a file or a command's output straight to a
personal env, then redacts it from the rest of the run) and show_secret
(an env by key): both give a [secret:ID] chip whose Reveal and Copy fetch
the value on click, for its own user (personal) or a super admin
(anything else), logged by key. Values never go into messages, page
props, history, Copy chat or logs. Users manage their own secrets on
Settings > Secrets; the Environment page lists everyone's, remove only.
Deleting a bot or user removes its personal secrets, and SecretsNote
tells bots never to ask for or print secrets in chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 16:51:32 +01:00
co-authored by Claude Opus 5.5
parent 83ff79b9c3
commit 69988e08fc
78 changed files with 2472 additions and 52 deletions
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import checkSecretKey from "./check-secret-key";
describe("checkSecretKey", () => {
test("keeps a shell variable name, trimmed", () => {
expect(checkSecretKey({ key: " GITHUB_TOKEN " })).toBe("GITHUB_TOKEN");
});
test("refuses names a shell can't take", () => {
expect(() => checkSecretKey({ key: "1TOKEN" })).toThrow();
expect(() => checkSecretKey({ key: "MY-TOKEN" })).toThrow();
expect(() => checkSecretKey({ key: "" })).toThrow();
});
test("refuses the app's own settings and process-only keys", () => {
expect(() => checkSecretKey({ key: "ENCRYPTION_KEY" })).toThrow();
expect(() => checkSecretKey({ key: "VAPID_PRIVATE_KEY" })).toThrow();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { EnvDict, EnvKeyRegex, ProcessOnlyEnvKeys } from "@/src/dict/env-dict";
import AppError from "./app-error";
type Params = {
key: string;
};
/**
* The key a personal secret is saved under, trimmed: a shell variable
* name, and never one of the app's own settings or process-only keys.
* Throws why otherwise.
*/
export default function checkSecretKey({ key }: Params) {
const final_key = key.trim();
if (!EnvKeyRegex.test(final_key)) {
throw new AppError(`Use letters, digits and underscores for the key, starting with a letter or underscore.`);
}
if (
(ProcessOnlyEnvKeys as readonly string[]).includes(final_key) ||
EnvDict.some((env) => env.key == final_key)
) {
throw new AppError(`${final_key} is one of the app's own settings. Pick another key.`);
}
return final_key;
}
+6
View File
@@ -14,6 +14,12 @@ describe("exportMessageText", () => {
).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)");
});
test("words secrets and secret requests by number, never a value", () => {
expect(exportMessageText({ text: "Here: [secret:12]. [secret-request:4]", ...names, heading_offset: 0 })).toBe(
"Here: (secret 12, hidden). (secret request 4)",
);
});
test("leaves refs that are already links alone", () => {
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
});
+4 -1
View File
@@ -15,7 +15,8 @@ type Params = {
/**
* A message's markdown as it reads outside the app: its refs in words
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
* "computer 3", a folder as its path) and its headings dropped by
* "computer 3", a folder as its path, a secret by number only, never its
* value) and its headings dropped by
* `heading_offset`. Code blocks are left exactly as they are.
*/
export default function exportMessageText({
@@ -82,6 +83,8 @@ function wordRefs({
.replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)")
.replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1")
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
+8
View File
@@ -5,6 +5,8 @@ import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
import hookProposalRef from "./hook-proposal-ref";
import proposalRef from "./proposal-ref";
import secretRef from "./secret-ref";
import secretRequestRef from "./secret-request-ref";
import takeOverRef from "./take-over-ref";
describe("linkComputerRefs", () => {
@@ -34,6 +36,12 @@ describe("linkComputerRefs", () => {
).toBe("[hook proposal 4](#hook-proposal-4) and [proposal 4](#proposal-4)");
});
test("links secrets and secret requests, never confusing the two", () => {
expect(
linkComputerRefs({ text: `${secretRef({ id: 12 })} and ${secretRequestRef({ id: 4 })}` }),
).toBe("[secret 12](#secret-ref-12) and [secret request 4](#secret-request-4)");
});
test("links take-over requests", () => {
expect(
linkComputerRefs({
+20 -1
View File
@@ -51,12 +51,26 @@ export const ProposalRefHrefPrefix = "#proposal-";
*/
export const HookProposalRefHrefPrefix = "#hook-proposal-";
/**
* The link target a secret ref (`secretRef`) becomes, e.g. `#secret-ref-12`.
* The chat renders it as a masked chip with Reveal and Copy.
*/
export const SecretRefHrefPrefix = "#secret-ref-";
/**
* The link target a secret request ref (`secretRequestRef`) becomes, e.g.
* `#secret-request-4`. The chat renders it as a chip that opens the card
* the user types the secret into.
*/
export const SecretRequestRefHrefPrefix = "#secret-request-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
* proposal ref (`[hook-proposal:4]`) in a message into a markdown link, so it
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
* request ref (`[secret-request:4]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button,
* chip or download.
*
@@ -87,6 +101,11 @@ export default function linkComputerRefs({ text }: { text: string }) {
/\[hook-proposal:(\d+)\](?!\()/g,
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
)
.replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`)
.replace(
/\[secret-request:(\d+)\](?!\()/g,
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
)
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import canRevealEnv from "./can-reveal-env";
describe("canRevealEnv", () => {
const personal = { paradigm: "user", user_id: 4 };
const general = { paradigm: "general", user_id: null };
test("a personal secret only to its own user, while they reach the bot", () => {
expect(canRevealEnv({ env: personal, viewer_id: 4, role: "member", reaches_bot: true })).toBe(true);
expect(canRevealEnv({ env: personal, viewer_id: 4, role: "member", reaches_bot: false })).toBe(false);
expect(canRevealEnv({ env: personal, viewer_id: 1, role: "super_admin", reaches_bot: true })).toBe(false);
});
test("any other env only to a super admin", () => {
expect(canRevealEnv({ env: general, viewer_id: 1, role: "super_admin", reaches_bot: true })).toBe(true);
expect(canRevealEnv({ env: general, viewer_id: 2, role: "admin", reaches_bot: true })).toBe(false);
expect(canRevealEnv({ env: { paradigm: "bot", user_id: null }, viewer_id: 3, role: "member", reaches_bot: true })).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { CrewRole } from "./crew-role";
type Params = {
env: { paradigm: string; user_id: number | null };
viewer_id: number | string;
role: CrewRole;
/**
* Whether the viewer still reaches the env's bot (for a personal one).
*/
reaches_bot: boolean;
};
/**
* Who may see a secret's value in the chat (`[secret:ID]`): a personal
* one only its own user, while they still reach its bot; any other env
* only a super admin, as on the Environment page. A bot never may: the
* value only ever goes to the viewer's browser.
*/
export default function canRevealEnv({ env, viewer_id, role, reaches_bot }: Params) {
if (env.paradigm == "user") return env.user_id == Number(viewer_id) && reaches_bot;
return role == "super_admin";
}
+13
View File
@@ -0,0 +1,13 @@
type Params = {
id: number | string;
};
/**
* How a bot shows the user a secret without ever holding its value:
* `[secret:12]`, the id of the env it's saved as. The chat shows it as a
* masked chip whose Reveal and Copy fetch the value for whoever may see
* it (`canRevealEnv`).
*/
export default function secretRef({ id }: Params) {
return `[secret:${id}]`;
}
+12
View File
@@ -0,0 +1,12 @@
type Params = {
id: number | string;
};
/**
* How a bot names a secret it asked the user for: `[secret-request:4]`.
* The chat shows it as a chip that opens the card the user types it
* into, so it never goes through the chat or the model.
*/
export default function secretRequestRef({ id }: Params) {
return `[secret-request:${id}]`;
}