@makerstufferupper/flypi 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +303 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +35 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- package/src/shared/views.ts +31 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault's crypto and its key seam (docs/secrets.md Phase 5.5).
|
|
3
|
+
*
|
|
4
|
+
* AES-256-GCM through `node:crypto` — no native module, so `npm install` still works everywhere flypi
|
|
5
|
+
* runs. Every row gets a fresh 96-bit nonce and its own auth tag, which is what makes a tampered or
|
|
6
|
+
* swapped row fail loudly instead of decrypting to garbage. That is the whole reason this file is a choke
|
|
7
|
+
* point: a seal that silently returns the wrong plaintext would put a wrong secret into a config file, and
|
|
8
|
+
* nothing downstream could tell.
|
|
9
|
+
*
|
|
10
|
+
* The key is behind `KeyProvider` so a macOS Keychain or a pinentry prompt can appear later without
|
|
11
|
+
* touching the table. The default reads a key file — see `keyfileProvider`.
|
|
12
|
+
*/
|
|
13
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
import { parse } from "yaml";
|
|
17
|
+
|
|
18
|
+
export const KEY_BYTES = 32;
|
|
19
|
+
export const NONCE_BYTES = 12;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A secret kind is a label with consequences: it decides what a list line may show (see `mask`) and what a
|
|
23
|
+
* value has to be before it is stored (see `validate`).
|
|
24
|
+
*
|
|
25
|
+
* One `password` kind covers an api key, a token and a password: they are the same shape to the code — one line
|
|
26
|
+
* of characters, injected as-is — and which of the three it was was never information the vault could use. A
|
|
27
|
+
* person reading the list learns it from the name and the notes, and the agent from those and from whether the
|
|
28
|
+
* config wants `secret_env` or `secret_file`. (They used to be three kinds. Nothing read the difference, and the
|
|
29
|
+
* dropdown made people choose wrong.) v8 collapses any stored `api_key`/`token` row to `password`.
|
|
30
|
+
*/
|
|
31
|
+
export const SECRET_KINDS = ["password", "ssh_private_key", "ssh_public_key", "yaml", "generic"] as const;
|
|
32
|
+
export type SecretKind = (typeof SECRET_KINDS)[number];
|
|
33
|
+
|
|
34
|
+
/** One sealed row: the three pieces GCM produces, kept apart because SQLite stores each as its own BLOB. */
|
|
35
|
+
export interface Sealed {
|
|
36
|
+
valueCipher: Uint8Array;
|
|
37
|
+
valueNonce: Uint8Array;
|
|
38
|
+
valueTag: Uint8Array;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The key is 32 bytes and the plaintext is UTF-8; nothing else is encoded, so a row round-trips exactly. */
|
|
42
|
+
export function seal(plaintext: string, key: Uint8Array): Sealed {
|
|
43
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
44
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
45
|
+
const valueCipher = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
46
|
+
return { valueCipher, valueNonce: nonce, valueTag: cipher.getAuthTag() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Throws when the key is not the one that sealed the row, or when any byte of the row moved. The caller
|
|
51
|
+
* adds the name — that is the only way a user finds out which secret a key rotation left behind.
|
|
52
|
+
*/
|
|
53
|
+
export function open(sealed: Sealed, key: Uint8Array): string {
|
|
54
|
+
const decipher = createDecipheriv("aes-256-gcm", key, asBuffer(sealed.valueNonce));
|
|
55
|
+
decipher.setAuthTag(asBuffer(sealed.valueTag));
|
|
56
|
+
try {
|
|
57
|
+
return Buffer.concat([decipher.update(asBuffer(sealed.valueCipher)), decipher.final()]).toString("utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error("the vault could not open this row: the key version is gone, or the ciphertext changed");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** sha256 hex. `put` checks this before storing anything, so a repeat write does not need to decrypt. */
|
|
64
|
+
export function fingerprint(plaintext: string): string {
|
|
65
|
+
return createHash("sha256").update(plaintext, "utf8").digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a list, a tool result or the UI shows instead of the value.
|
|
70
|
+
*
|
|
71
|
+
* A key's last four characters are how a user recognises which one they mean — the reason the plan keeps a
|
|
72
|
+
* little of it — and a fingerprint is how two SSH keys are told apart (what `ssh-keygen -lf` prints, so it
|
|
73
|
+
* reads the same). A short value gets no characters at all: showing four of a six-character password would
|
|
74
|
+
* show most of it. Note this fingerprints the bytes stored, not the public half of a private key, which is
|
|
75
|
+
* enough to tell two stored keys apart and is not the string to compare against `ssh-keygen`.
|
|
76
|
+
*/
|
|
77
|
+
export function mask(value: string, kind: SecretKind): string {
|
|
78
|
+
if (kind === "ssh_private_key" || kind === "ssh_public_key") {
|
|
79
|
+
return `SHA256:${createHash("sha256").update(value, "utf8").digest("base64").replace(/=+$/, "")}`;
|
|
80
|
+
}
|
|
81
|
+
// A mask is one line in a list, whatever was stored — a yaml blob or a key pasted with a trailing newline.
|
|
82
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
83
|
+
if (flat.length <= 8) return "•••••";
|
|
84
|
+
return `${flat.slice(0, 3)}…${flat.slice(-4)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Does this value match the kind it is being stored as? Answers with the sentence to show, or null.
|
|
89
|
+
*
|
|
90
|
+
* Only `yaml` has anything to say. A kubeconfig or an MCP config blob that does not parse is worthless the
|
|
91
|
+
* moment `secret_file` hands it to a command, and the vault is the last place that can name the line that
|
|
92
|
+
* broke. Any other kind is a string: rejecting a typo in a password would be a guess about what the user
|
|
93
|
+
* meant, which is not this function's call to make.
|
|
94
|
+
*/
|
|
95
|
+
export function validate(value: string, kind: SecretKind): string | null {
|
|
96
|
+
if (kind !== "yaml") return null;
|
|
97
|
+
try {
|
|
98
|
+
parse(value);
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
// A YAMLParseError's first line names the line and column; the rest is a code frame the panel has no room for.
|
|
101
|
+
return `is not valid YAML: ${(cause as Error).message.split("\n")[0]}`;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A key provider holds versioned keys. `currentVersion` seals new rows; `key(version)` returns an older key
|
|
108
|
+
* while rows still need it and `undefined` once it is gone, which is how "rotate, then re-encrypt, then
|
|
109
|
+
* forget the old key" reaches a real error rather than a wrong plaintext.
|
|
110
|
+
*/
|
|
111
|
+
export interface KeyProvider {
|
|
112
|
+
readonly name: string;
|
|
113
|
+
currentVersion(): number;
|
|
114
|
+
key(version: number): Uint8Array | undefined;
|
|
115
|
+
/** Generate a new key, make it current, and return its version. */
|
|
116
|
+
rotate(): number;
|
|
117
|
+
/** Drop the versions no row needs any more. */
|
|
118
|
+
retain(versions: readonly number[]): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The default provider: a key file, mode 0600, created on first use. It is a JSON map of versions because
|
|
123
|
+
* rotation must keep the old key readable until every row has been re-sealed under the new one.
|
|
124
|
+
*
|
|
125
|
+
* Written by hand rather than with a temp file and a rename: the file already exists by then (created 0600
|
|
126
|
+
* on first use), and the only thing that can happen mid-write is a crash with an unreadable vault, which
|
|
127
|
+
* every other flypi JSON file behaves the same way about.
|
|
128
|
+
*/
|
|
129
|
+
export function keyfileProvider(file: string): KeyProvider {
|
|
130
|
+
const read = (): { current: number; keys: Record<string, string> } => {
|
|
131
|
+
if (!existsSync(file)) return write({ current: 1, keys: { 1: randomBytes(KEY_BYTES).toString("base64") } });
|
|
132
|
+
try {
|
|
133
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
134
|
+
if (isObject(raw) && typeof raw.current === "number" && isObject(raw.keys)) return raw as { current: number; keys: Record<string, string> };
|
|
135
|
+
} catch {
|
|
136
|
+
// Fall through: a vault that cannot be read must not be replaced by a fresh key, because that
|
|
137
|
+
// would strand every row in the database. Say so instead.
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`${file} is not a key file flypi can read (it holds the keys every encrypted row needs)`);
|
|
140
|
+
};
|
|
141
|
+
const write = (state: { current: number; keys: Record<string, string> }) => {
|
|
142
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
143
|
+
writeFileSync(file, `${JSON.stringify(state, null, "\t")}\n`, { mode: 0o600 });
|
|
144
|
+
// `mode` only applies when the file is created; a umask, or a file that already existed, can leave it
|
|
145
|
+
// wider than that. Ask for it explicitly.
|
|
146
|
+
chmodSync(file, 0o600);
|
|
147
|
+
return state;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
name: "keyfile",
|
|
152
|
+
currentVersion: () => read().current,
|
|
153
|
+
key: (version) => {
|
|
154
|
+
const encoded = read().keys[String(version)];
|
|
155
|
+
return encoded === undefined ? undefined : Buffer.from(encoded, "base64");
|
|
156
|
+
},
|
|
157
|
+
rotate: () => {
|
|
158
|
+
const state = read();
|
|
159
|
+
const current = state.current + 1;
|
|
160
|
+
write({ current, keys: { ...state.keys, [current]: randomBytes(KEY_BYTES).toString("base64") } });
|
|
161
|
+
return current;
|
|
162
|
+
},
|
|
163
|
+
retain: (versions) => {
|
|
164
|
+
const state = read();
|
|
165
|
+
const keep = new Set([state.current, ...versions]);
|
|
166
|
+
const keys: Record<string, string> = {};
|
|
167
|
+
for (const [version, encoded] of Object.entries(state.keys)) if (keep.has(Number(version))) keys[version] = encoded;
|
|
168
|
+
write({ current: state.current, keys });
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
174
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function asBuffer(value: Uint8Array): Buffer {
|
|
178
|
+
// node:sqlite hands back a plain Uint8Array for a BLOB; the crypto API wants a Buffer.
|
|
179
|
+
return Buffer.from(value);
|
|
180
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault's agent surface (docs/secrets.md Phase 5.5): the `secret_*` tools and the two redaction hooks,
|
|
3
|
+
* registered as one extension like the memory one is.
|
|
4
|
+
*
|
|
5
|
+
* Two rules shape this file:
|
|
6
|
+
*
|
|
7
|
+
* - A tool result is where a credential is most likely to appear, so every result passes through the
|
|
8
|
+
* redactor — the model sees `[secret:github_token]` instead of the token — except `secret_get`, which is
|
|
9
|
+
* the one call whose whole point is to hand the plaintext over, and which the permission gate holds until
|
|
10
|
+
* the user agrees.
|
|
11
|
+
* - The value must not become a durable artifact. So the assistant message is redacted at `message_end`,
|
|
12
|
+
* before it is written to the session file: the value is in the model's context for the turn that asked
|
|
13
|
+
* for it, and nothing after that keeps it.
|
|
14
|
+
*
|
|
15
|
+
* The vault itself is opened by the process that owns the memory database (the daemon, `npm run memory`, the
|
|
16
|
+
* CLI). A process with no vault registers nothing, which is how `npm run serve` stays out of it.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import type { FlypiPaths } from "../agent/paths.ts";
|
|
23
|
+
import { blockedToolCall } from "../agent/permission-gate.ts";
|
|
24
|
+
import { secretFilePath, secretsConfig } from "./config.ts";
|
|
25
|
+
import { placeholder, redactSecrets } from "./redact.ts";
|
|
26
|
+
import { SECRET_KINDS, type SecretKind, type SecretStore } from "./vault.ts";
|
|
27
|
+
|
|
28
|
+
/** The one tool whose result is plaintext, and the name the permission gate holds by default. */
|
|
29
|
+
export const SECRET_GET = "secret_get";
|
|
30
|
+
|
|
31
|
+
/** A command may print a lot; a credential does not need the last megabyte of it. */
|
|
32
|
+
const COMMAND_BYTES = 64_000;
|
|
33
|
+
const COMMAND_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
const NameParam = Type.String({ description: "The secret's name, as it appears in the vault" });
|
|
36
|
+
const ValueParam = Type.String({ description: "The secret's value. It is encrypted before it is stored." });
|
|
37
|
+
const KindParam = Type.Optional(
|
|
38
|
+
Type.String({
|
|
39
|
+
description: `What kind of secret: ${SECRET_KINDS.join(", ")}. "password" is any credential a service hands you — an api key, a token or a password, stored the same way; "yaml" is parsed before it is stored.`,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export interface SecretsExtensionOptions {
|
|
44
|
+
/** Whose doing every audit row records. */
|
|
45
|
+
actor?: string;
|
|
46
|
+
paths: FlypiPaths;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createSecretsExtension(vault: SecretStore, options: SecretsExtensionOptions): ExtensionFactory {
|
|
50
|
+
const actor = options.actor ?? "agent";
|
|
51
|
+
return (pi) => {
|
|
52
|
+
/**
|
|
53
|
+
* The gate Phase 5 built, applied to the one tool here whose result is a credential. Read from the
|
|
54
|
+
* settings on every call like `createPermissionGateExtension` does, so the user turning it off takes
|
|
55
|
+
* effect on the next call and not at the next restart. Being asked is the default: a model that wants a
|
|
56
|
+
* value has to ask a person for it.
|
|
57
|
+
*/
|
|
58
|
+
pi.on("tool_call", (event) => {
|
|
59
|
+
if (event.toolName !== SECRET_GET) return;
|
|
60
|
+
if (!secretsConfig(options.paths).askBeforeUse) return;
|
|
61
|
+
return blockedToolCall(
|
|
62
|
+
`flypi did not run "${SECRET_GET}": reading a secret back as plaintext is the user's decision. ` +
|
|
63
|
+
`Ask them whether this call may run; to stop being asked, they set "secrets": { "askBeforeUse": false } ` +
|
|
64
|
+
`in .flypi/agent/settings.json. Until then use secret_env or secret_file, which hand the value to a command instead of to you.`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: "secret_list",
|
|
70
|
+
label: "List secrets",
|
|
71
|
+
description: "List the flypi secret vault: names, kinds, masks and metadata. Never the values.",
|
|
72
|
+
parameters: Type.Object({ query: Type.Optional(Type.String({ description: "Filter by name or metadata" })) }),
|
|
73
|
+
execute: async (_id, { query }) => json(vault.list(typeof query === "string" ? query : "")),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: "secret_env",
|
|
78
|
+
label: "Run a command with a secret",
|
|
79
|
+
description:
|
|
80
|
+
"Run a shell command with the secret in FLYPI_SECRET and FLYPI_SECRET_<NAME>, and return its output with the secret replaced. Use this rather than putting the value in the command: it never reaches the transcript, the session file or the model's arguments.",
|
|
81
|
+
parameters: Type.Object({ name: NameParam, command: Type.String({ description: "The command to run" }) }),
|
|
82
|
+
execute: async (_id, { name, command }) => {
|
|
83
|
+
const value = vault.value(name, { actor, action: "used" });
|
|
84
|
+
if (value === undefined) return error(`no secret named "${name}" in the vault`);
|
|
85
|
+
const ran = await run(command, { FLYPI_SECRET: value, [`FLYPI_SECRET_${name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`]: value });
|
|
86
|
+
// Both guards: `redactSecrets` for any *other* secret the command printed, and this one for the
|
|
87
|
+
// value just handed over, which is redacted whatever its length. A failed command's output is
|
|
88
|
+
// redacted on the way out too — a stack trace quotes the header it was trying to send.
|
|
89
|
+
const text = redactSecrets(ran.text).replaceAll(value, placeholder(name));
|
|
90
|
+
if (!ran.ok) return error(text);
|
|
91
|
+
return ran.text.trim() === "" ? json({ ran: true, output: "", note: "the command printed nothing" }) : { content: [{ type: "text" as const, text }], details: { name, ran: true } };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "secret_file",
|
|
97
|
+
label: "Write a secret to a file",
|
|
98
|
+
description:
|
|
99
|
+
"Write the secret to a mode-0400 file under .flypi/secrets/ and return its path. For the commands that only take a path — ssh, gpg. It is not the agent's environment, so the value is not inherited by every child process.",
|
|
100
|
+
parameters: Type.Object({ name: NameParam }),
|
|
101
|
+
execute: async (_id, { name }) => {
|
|
102
|
+
const value = vault.value(name, { actor, action: "used" });
|
|
103
|
+
if (value === undefined) return error(`no secret named "${name}" in the vault`);
|
|
104
|
+
mkdirSync(options.paths.secretsDir, { recursive: true });
|
|
105
|
+
// `secret_file_path` refuses a name that would not be a safe file name.
|
|
106
|
+
const file = secretFilePath(options.paths, name);
|
|
107
|
+
writeFileSync(file, value, { mode: 0o400 });
|
|
108
|
+
// `mode` only applies on create; ask for it, so re-writing a secret never leaves it world-readable.
|
|
109
|
+
chmodSync(file, 0o400);
|
|
110
|
+
return json({ path: file, mode: "0400", note: "the file holds the plaintext — delete it when the command is done" });
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerTool({
|
|
115
|
+
name: SECRET_GET,
|
|
116
|
+
label: "Read a secret's value",
|
|
117
|
+
description:
|
|
118
|
+
"The plaintext value of one secret. It is held until the user approves each call: this is the one tool whose result is a credential, and the value will not be kept in the conversation afterwards.",
|
|
119
|
+
parameters: Type.Object({ name: NameParam }),
|
|
120
|
+
execute: async (_id, { name }) => {
|
|
121
|
+
const value = vault.value(name, { actor, action: "revealed" });
|
|
122
|
+
// No redaction on this result — that is the whole exception. If the value turns up again in a
|
|
123
|
+
// later tool result or in the answer, it is redacted there.
|
|
124
|
+
return value === undefined ? error(`no secret named "${name}" in the vault`) : { content: [{ type: "text" as const, text: value }], details: { name } };
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.registerTool({
|
|
129
|
+
name: "secret_put",
|
|
130
|
+
label: "Store a secret",
|
|
131
|
+
description: "Store a value in the vault. Give it a name and a kind; the value is encrypted and only a mask is ever listed back.",
|
|
132
|
+
parameters: Type.Object({ name: NameParam, value: ValueParam, kind: KindParam, meta: Type.Optional(Type.Record(Type.String(), Type.String())) }),
|
|
133
|
+
execute: async (_id, params) => json(vault.put({ name: params.name, value: params.value, kind: params.kind as SecretKind, meta: params.meta, actor })),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.registerTool({
|
|
137
|
+
name: "secret_rotate",
|
|
138
|
+
label: "Replace a secret's value",
|
|
139
|
+
description: "Replace the value of an existing secret. The value it replaced is kept as a mask in the audit history; nothing auto-expires a secret.",
|
|
140
|
+
parameters: Type.Object({ name: NameParam, value: ValueParam, kind: KindParam }),
|
|
141
|
+
execute: async (_id, params) => json(vault.rotate({ name: params.name, value: params.value, kind: params.kind as SecretKind, actor })),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.registerTool({
|
|
145
|
+
name: "secret_delete",
|
|
146
|
+
label: "Delete a secret",
|
|
147
|
+
description: "Delete a secret from the vault. It leaves a tombstone and its history, like a forgotten memory.",
|
|
148
|
+
parameters: Type.Object({ name: NameParam }),
|
|
149
|
+
execute: async (_id, { name }) => json(vault.remove(name, { actor })),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every tool result, redacted — including `structuredContent`, which a handler that replaces only
|
|
154
|
+
* `content` silently drops (docs/extensions.md). An MCP server's structured result is exactly where a
|
|
155
|
+
* token tends to be echoed back.
|
|
156
|
+
*/
|
|
157
|
+
pi.on("tool_result", (event) => {
|
|
158
|
+
if (event.toolName === SECRET_GET) return;
|
|
159
|
+
let changed = false;
|
|
160
|
+
const content = event.content.map((block) => {
|
|
161
|
+
if (block.type !== "text") return block;
|
|
162
|
+
const text = redactSecrets(block.text);
|
|
163
|
+
if (text === block.text) return block;
|
|
164
|
+
changed = true;
|
|
165
|
+
return { ...block, text };
|
|
166
|
+
}) as typeof event.content;
|
|
167
|
+
const structured = event.structuredContent === undefined ? undefined : redactValue(event.structuredContent as Json);
|
|
168
|
+
if (!changed && structured === undefined) return;
|
|
169
|
+
return {
|
|
170
|
+
content,
|
|
171
|
+
...(structured === undefined ? {} : { structuredContent: structured }),
|
|
172
|
+
...(event.isError ? { isError: true } : {}),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The assistant's own words, on the way into the session file. A model that repeats a token it was
|
|
178
|
+
* shown writes it into the transcript, the context of every later turn, the HTML export and any memory
|
|
179
|
+
* captured from it — this is the seam that stops that.
|
|
180
|
+
*/
|
|
181
|
+
pi.on("message_end", (event) => {
|
|
182
|
+
const message = event.message as { role?: string; content?: unknown };
|
|
183
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
184
|
+
let changed = false;
|
|
185
|
+
const content = message.content.map((block) => {
|
|
186
|
+
if (typeof block !== "object" || block === null) return block;
|
|
187
|
+
const part = block as { type?: string; text?: string };
|
|
188
|
+
if (part.type !== "text" || typeof part.text !== "string") return block;
|
|
189
|
+
const text = redactSecrets(part.text);
|
|
190
|
+
if (text === part.text) return block;
|
|
191
|
+
changed = true;
|
|
192
|
+
return { ...part, text };
|
|
193
|
+
});
|
|
194
|
+
if (!changed) return;
|
|
195
|
+
return { message: { ...(event.message as object), content } as never };
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function json(value: unknown): { content: { type: "text"; text: string }[]; details: unknown } {
|
|
201
|
+
const text = JSON.stringify(value, null, 2);
|
|
202
|
+
return { content: [{ type: "text", text }], details: value };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function error(text: string): { content: { type: "text"; text: string }[]; details: unknown; isError: true } {
|
|
206
|
+
return { content: [{ type: "text", text }], details: { error: text }, isError: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Recursive, because `structuredContent` is JSON: a token can be nested in it. Declared here rather than
|
|
211
|
+
* imported — pi's own `JsonValue` lives in `@earendil-works/pi-ai`, which is a dev dependency here.
|
|
212
|
+
*/
|
|
213
|
+
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
214
|
+
|
|
215
|
+
function redactValue(value: Json): Json {
|
|
216
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
217
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item));
|
|
218
|
+
if (typeof value === "object" && value !== null) {
|
|
219
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactValue(item)]));
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The command's own environment plus the secret — never the other way round, so nothing can name a variable
|
|
226
|
+
* over an existing one. Output is capped and the command is killed if it hangs; a command that fails is
|
|
227
|
+
* reported with its output, because "ssh refused it" is the answer the model needs.
|
|
228
|
+
*/
|
|
229
|
+
/** `{ ok: false }` covers both a non-zero exit and a command that never started; the text says which. */
|
|
230
|
+
function run(command: string, env: Record<string, string>): Promise<{ text: string; ok: boolean }> {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const child = spawn(command, { shell: true, env: { ...process.env, ...env } });
|
|
233
|
+
const chunks: Buffer[] = [];
|
|
234
|
+
let bytes = 0;
|
|
235
|
+
const collect = (buffer: Buffer) => {
|
|
236
|
+
if (bytes >= COMMAND_BYTES) return;
|
|
237
|
+
bytes += buffer.length;
|
|
238
|
+
chunks.push(buffer);
|
|
239
|
+
};
|
|
240
|
+
child.stdout.on("data", collect);
|
|
241
|
+
child.stderr.on("data", collect);
|
|
242
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), COMMAND_MS);
|
|
243
|
+
child.on("close", (code) => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
const output = Buffer.concat(chunks).toString("utf8");
|
|
246
|
+
resolve(code === 0 ? { text: output, ok: true } : { text: `the command exited with ${code}\n${output}`, ok: false });
|
|
247
|
+
});
|
|
248
|
+
child.on("error", (cause) => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
resolve({ text: `the command could not run: ${(cause as Error).message}`, ok: false });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The redaction boundary (docs/secrets.md Phase 5.5).
|
|
3
|
+
*
|
|
4
|
+
* A secret's value is not stored anywhere except in a row of the vault, but it passes through a conversation
|
|
5
|
+
* as often as a user or a tool mentions it: memory bodies, turn rows, tool output, the HTML export, and the
|
|
6
|
+
* context of every later turn. So every durable write in flypi calls `redactSecrets` — `src/memory/store.ts`
|
|
7
|
+
* for turns and memory bodies, `src/daemon/jobs.ts` for job details, and the session extension in
|
|
8
|
+
* `src/secrets/extension.ts` for tool results and what the model says back.
|
|
9
|
+
*
|
|
10
|
+
* Which values count as secrets is *read from the vault*, not pushed at it: `setSecretSource` names where to
|
|
11
|
+
* ask, and the list is rebuilt when it is older than `DEFAULT_MAX_AGE_MS`. That is deliberate. The process
|
|
12
|
+
* that redacts is usually not the process that edits the vault — `flypi secret put` is its own process, and
|
|
13
|
+
* the memory server is another — and a pull with a short TTL needs no signalling between them. A process that
|
|
14
|
+
* changes the vault invalidates the cache itself, so it never redacts with a stale list.
|
|
15
|
+
*
|
|
16
|
+
* What is not here: the vault, or anything that decrypts. This module is given plaintext and turns text into
|
|
17
|
+
* text; `redactSecrets` is called on the hot path of every turn and must stay that cheap to reason about.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A placeholder must not survive a second pass as someone else's secret. */
|
|
21
|
+
export const placeholder = (name: string): string => `[secret:${name}]`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Below this, a value is not treated as a secret when redacting text. `hunter2` is a password; redacting
|
|
25
|
+
* every occurrence of `token` inside a memory about tokens would shred the memory. The vault refuses a name
|
|
26
|
+
* of its own below four characters, and anything shorter than this is reported by the status surface rather
|
|
27
|
+
* than redacted.
|
|
28
|
+
*/
|
|
29
|
+
export const MIN_SECRET_CHARS = 6;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How long a redactor may answer from the list it already read. Short enough that a secret stored in another
|
|
33
|
+
* process is protected within a turn or two; long enough that storing one does not mean a decrypt of the
|
|
34
|
+
* whole vault before every memory write.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_MAX_AGE_MS = 5_000;
|
|
37
|
+
|
|
38
|
+
export interface ActiveSecret {
|
|
39
|
+
name: string;
|
|
40
|
+
value: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let source: (() => ActiveSecret[]) | undefined;
|
|
44
|
+
let maxAgeMs = DEFAULT_MAX_AGE_MS;
|
|
45
|
+
let cached: ActiveSecret[] | undefined;
|
|
46
|
+
let cachedAt = 0;
|
|
47
|
+
let sorted: string[] = [];
|
|
48
|
+
/** Each value → the placeholders it maps to. A value stored under two names redacts to both, so the text says which. */
|
|
49
|
+
let placeholders = new Map<string, string>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where this process asks for the current secrets — usually `() => vault.activeValues()`. The list is
|
|
53
|
+
* decrypted on demand, once per `DEFAULT_MAX_AGE_MS`, and `invalidateSecrets()` ends that window the moment
|
|
54
|
+
* this process changes the vault.
|
|
55
|
+
*/
|
|
56
|
+
export function setSecretSource(next: () => ActiveSecret[], options: { maxAgeMs?: number } = {}): void {
|
|
57
|
+
source = next;
|
|
58
|
+
maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
59
|
+
invalidateSecrets();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* This process is finished with the vault it was reading (a CLI that opened one and closed it, or a daemon
|
|
64
|
+
* shutting down). Redaction goes back to matching nothing: a source pointing at a closed database would
|
|
65
|
+
* otherwise turn an unrelated write into an error three frames away from the cause.
|
|
66
|
+
*/
|
|
67
|
+
export function closeSecretSource(): void {
|
|
68
|
+
source = undefined;
|
|
69
|
+
invalidateSecrets();
|
|
70
|
+
buildIndex([]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop what this process has read, so the next redaction asks again. */
|
|
74
|
+
export function invalidateSecrets(): void {
|
|
75
|
+
cached = undefined;
|
|
76
|
+
cachedAt = 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function currentSecrets(): ActiveSecret[] {
|
|
80
|
+
// The cache is the whole point — a memory write must not decrypt the vault — and its expiry is the whole
|
|
81
|
+
// point of the cache being allowed at all: a secret stored by another process still gets redacted.
|
|
82
|
+
if (cached !== undefined && Date.now() - cachedAt < maxAgeMs) return cached;
|
|
83
|
+
const list = source === undefined ? [] : source();
|
|
84
|
+
cached = list;
|
|
85
|
+
cachedAt = Date.now();
|
|
86
|
+
buildIndex(list);
|
|
87
|
+
return list;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The active values, longest first, so `sk-very-long` wins over `sk` inside the same string. */
|
|
91
|
+
function buildIndex(list: ActiveSecret[]): void {
|
|
92
|
+
sorted = list.map((secret) => secret.value).filter((value) => value.length >= MIN_SECRET_CHARS).sort((a, b) => b.length - a.length);
|
|
93
|
+
placeholders = new Map<string, string>();
|
|
94
|
+
for (const { name, value } of list) {
|
|
95
|
+
const mark = placeholders.get(value) ?? "";
|
|
96
|
+
placeholders.set(value, mark === "" ? placeholder(name) : `${mark}${placeholder(name)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The names of the active values too short to redact — the gap in this boundary, made visible. */
|
|
101
|
+
export function shortSecrets(): string[] {
|
|
102
|
+
return currentSecrets()
|
|
103
|
+
.filter((secret) => secret.value.length < MIN_SECRET_CHARS)
|
|
104
|
+
.map((secret) => secret.name)
|
|
105
|
+
.sort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function escapeRegExp(value: string): string {
|
|
109
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pattern(): RegExp | undefined {
|
|
113
|
+
// Reads the source first (and only re-reads it when the cached list has aged out), then matches. Kept in
|
|
114
|
+
// one place so `redactSecrets` and `containsSecret` cannot disagree about what the current secrets are.
|
|
115
|
+
currentSecrets();
|
|
116
|
+
if (sorted.length === 0) return undefined;
|
|
117
|
+
return new RegExp(sorted.map(escapeRegExp).join("|"), "g");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Replace every active secret value in `text` with a named placeholder. Idempotent: a placeholder never matches. */
|
|
121
|
+
export function redactSecrets(text: string): string {
|
|
122
|
+
const regex = pattern();
|
|
123
|
+
if (regex === undefined) return text;
|
|
124
|
+
// Every match came out of `pattern()`, which is built from these same keys, so the lookup cannot miss.
|
|
125
|
+
return text.replace(regex, (value) => placeholders.get(value) as string);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Does this text contain an active secret? Used to refuse a memory rather than rewrite it. */
|
|
129
|
+
export function containsSecret(text: string): boolean {
|
|
130
|
+
const regex = pattern();
|
|
131
|
+
return regex !== undefined && regex.test(text);
|
|
132
|
+
}
|