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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`)})`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}]`;
|
||||
}
|
||||
@@ -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}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user