Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ Rules:
- Inject secrets for the **primary user turn only** — never for introspection, aux, or secondary read-only analysis queries.
- Claude injects at the `options.env` layer (kept out of `buildClaudeDiagnostics`); Codex injects shell variables via per-thread `shell_environment_policy.set.<KEY>` overrides, forwarded on **both** `thread/start` and `thread/resume`. Secret-bound primary Codex turns use a disposable App Server process with the same environment so `bearer_token_env_var` MCP authentication works without exposing values to shared clients.
- Never write a secret value to `console.*`, a `BridgeEvent`, a transcript, or the thread key. Log only counts, env-var names, and skip reasons.
- Parse `@secret:{ENV_VAR_NAME}` only from the current primary input. Resolve the key through the main-owned vault and add only value-free availability guidance to provider prompt text; never substitute plaintext into the prompt.
- Missing, malformed, over-limit, and reserved prompt references must stay unavailable. Never satisfy an unresolved reference from the ambient process environment.
- This is an *automatic-leak* guarantee, not a sandbox: a deliberate `echo $NAME` can still surface a bound value. Keep the Settings > Secrets copy honest about this.

## Terminal Surface Guardrails
Expand Down
22 changes: 22 additions & 0 deletions docs/providers/provider-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ decisions are recorded in

The renderer submits a selected provider and model with each turn. `electron/main/ipc/provider.ts` validates the request, forwards it into the dedicated desktop `host-service` child process, and `electron/providers/runtime.ts` dispatches to the matching provider runtime.

## Prompt secret references

A primary task prompt may reference an injectable Settings → Secrets entry by
its environment-variable name: `@secret:{OPENAI_API_KEY}`. The current input is
parsed inside the provider runtime, and only the non-secret key is used to ask
the main-owned vault for the matching value. The value follows the existing
bound-secret environment path: Claude receives it through `options.env`, while
Codex uses a disposable App Server process plus per-thread
`shell_environment_policy.set.*` overrides. It never replaces the token with
plaintext or adds the value to renderer state, provider prompt text, events,
transcripts, diagnostics, or logs.

The provider prompt receives value-free guidance that maps a resolved reference
to `$OPENAI_API_KEY`, so shell commands and supported MCP authentication can use
it without the model seeing the value. Missing, malformed, over-limit, and
reserved references are described as unavailable and are never satisfied from
an ambient process variable. Reference resolution applies only to the current
primary input; secondary read-only execution does not receive referenced or
manually bound secrets. A command that deliberately prints the environment
variable can still surface its value, so this remains an automatic-leak
boundary rather than a sandbox.

## On-demand Advisor consults

`Settings → Providers → Advisor` arms an isolated read-only Advisor that the
Expand Down
48 changes: 33 additions & 15 deletions electron/main/browser/secret-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export async function revealSecret(id: string) {
}

/**
* Resolve a task's bound secret ids to an environment map for provider shell
* commands and supported MCP authentication. MAIN-PROCESS ONLY: this returns
* plaintext values, so it must never reach preload or the renderer.
* Resolve a task's bound secret ids and explicit prompt reference keys to an
* environment map for provider shell commands and supported MCP
* authentication. MAIN-PROCESS ONLY: this returns plaintext values, so it must
* never reach preload, the renderer, model-visible text, or logs.
*
* Returns an empty map on any resolution failure rather than throwing, so a
* misconfigured or unavailable vault degrades to "no injected secrets" instead
Expand All @@ -59,30 +60,47 @@ export async function revealSecret(id: string) {
*/
export async function resolveBoundSecretEnv(args: {
ids: readonly string[];
referenceKeys?: readonly string[];
}): Promise<Record<string, string>> {
const ids = args.ids.filter(
(id): id is string => typeof id === "string" && id.length > 0,
);
if (ids.length === 0) {
const referenceKeys = (args.referenceKeys ?? []).filter(
(key): key is string => typeof key === "string" && key.trim().length > 0,
);
if (ids.length === 0 && referenceKeys.length === 0) {
return {};
}
try {
const { env, skipped } = await getVault().resolveEnvForIds(ids);
const secretVault = getVault();
const [boundResult, referenceResult] = await Promise.all([
ids.length > 0
? secretVault.resolveEnvForIds(ids)
: Promise.resolve({ env: {}, skipped: [] }),
referenceKeys.length > 0
? secretVault.resolveEnvForReferences(referenceKeys)
: Promise.resolve({ env: {}, skipped: [] }),
]);
const env = { ...boundResult.env, ...referenceResult.env };
const skippedLabels = [
...boundResult.skipped.map((entry) =>
entry.envVarName
? `${entry.envVarName}:${entry.reason}`
: entry.reason,
),
...referenceResult.skipped.map((entry) =>
entry.key ? `${entry.key}:${entry.reason}` : entry.reason,
),
];
const injectedNames = Object.keys(env);
if (skipped.length > 0 || injectedNames.length > 0) {
if (skippedLabels.length > 0 || injectedNames.length > 0) {
console.info(
`[secrets] resolved ${injectedNames.length}/${ids.length} bound secret(s) for injection` +
`[secrets] resolved ${injectedNames.length}/${ids.length + referenceKeys.length} secret binding/reference(s) for injection` +
(injectedNames.length > 0
? ` (${injectedNames.join(", ")})`
: "") +
(skipped.length > 0
? `; skipped ${skipped
.map((entry) =>
entry.envVarName
? `${entry.envVarName}:${entry.reason}`
: entry.reason,
)
.join(", ")}`
(skippedLabels.length > 0
? `; skipped ${skippedLabels.join(", ")}`
: ""),
);
}
Expand Down
63 changes: 63 additions & 0 deletions electron/main/browser/secret-vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildSecretPreview,
ENV_VAR_NAME_MAX_LENGTH,
ENV_VAR_NAME_PATTERN,
MAX_BOUND_SECRETS,
isReservedEnvVarName,
normalizeEnvVarName,
normalizeSecretName,
Expand Down Expand Up @@ -256,6 +257,68 @@ export class SecretVault {
return { env, skipped };
}

/**
* Resolve `@secret:{ENV_VAR_NAME}` keys without exposing vault metadata or
* plaintext outside the main-owned provider runtime path.
*
* Validation and the reserved-key denylist are repeated here even though the
* prompt parser filters candidates first. This is the security boundary: a
* direct or future caller must not be able to claim PATH, CODEX_HOME, or any
* other Stave/runtime-owned variable.
*/
async resolveEnvForReferences(keys: readonly string[]): Promise<{
env: Record<string, string>;
skipped: Array<{ key: string; reason: string }>;
}> {
const env: Record<string, string> = {};
const skipped: Array<{ key: string; reason: string }> = [];
const uniqueKeys = [
...new Set(
keys
.filter((key): key is string => typeof key === "string")
.map((key) => key.trim()),
),
];
if (uniqueKeys.length === 0) {
return { env, skipped };
}

this.assertSecureEncryption();
const document = await this.readDocument();
for (const [index, key] of uniqueKeys.entries()) {
if (index >= MAX_BOUND_SECRETS) {
skipped.push({ key, reason: "limit-exceeded" });
continue;
}
if (
key.length === 0 ||
key.length > ENV_VAR_NAME_MAX_LENGTH ||
!ENV_VAR_NAME_PATTERN.test(key)
) {
skipped.push({ key: "", reason: "invalid-name" });
continue;
}
if (isReservedEnvVarName(key)) {
skipped.push({ key, reason: "reserved-name" });
continue;
}

const matches = document.secrets.filter(
(entry) => entry.envVarName === key,
);
if (matches.length === 0) {
skipped.push({ key, reason: "not-found" });
continue;
}
if (matches.length > 1) {
skipped.push({ key, reason: "duplicate-name" });
continue;
}
env[key] = this.decryptValue(matches[0]!);
}
return { env, skipped };
}

private decryptValue(entry: StoredSecret): string {
try {
return this.args.crypto.decryptString(
Expand Down
36 changes: 30 additions & 6 deletions electron/providers/claude-sdk-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import {
buildProviderTurnPrompt,
filterPromptRetrievedContext,
getProviderNativeSlashCommandInput,
resolveProviderResumeSessionId,
} from "../../src/lib/providers/provider-request-translators";
import {
Expand Down Expand Up @@ -86,6 +87,11 @@ import {
toClaudeSdkMcpServerConfig,
} from "../main/stave-local-mcp-manifest";
import { resolveBoundSecretEnv } from "../main/browser/secret-service";
import { MAX_BOUND_SECRETS } from "../../src/lib/secrets/secrets";
import {
appendPromptSecretReferenceContext,
parsePromptSecretReferences,
} from "../../src/lib/secrets/secret-references";
import {
parseBooleanEnv,
parsePositiveIntEnv,
Expand Down Expand Up @@ -4793,15 +4799,22 @@ export async function streamClaudeWithSdk(
}

const secondaryReadOnly = args.executionPolicy === "secondary-read-only";
// Resolve bound-secret env for the primary user turn only. Secondary
// read-only analysis turns never receive injected secrets.
const boundSecretIds = args.runtimeOptions?.boundSecretIds ?? [];
const promptSecretReferences = parsePromptSecretReferences({
prompt: args.prompt,
maxResolvableReferences:
MAX_BOUND_SECRETS - new Set(boundSecretIds).size,
});
// Resolve bound and explicitly referenced secrets for the primary user
// turn only. Secondary read-only analysis turns never receive them.
const boundSecretEnv =
secondaryReadOnly ||
!args.runtimeOptions?.boundSecretIds ||
args.runtimeOptions.boundSecretIds.length === 0
(boundSecretIds.length === 0 &&
promptSecretReferences.resolutionKeys.length === 0)
? {}
: await resolveBoundSecretEnv({
ids: args.runtimeOptions.boundSecretIds,
ids: boundSecretIds,
referenceKeys: promptSecretReferences.resolutionKeys,
});
const existingSessionId = secondaryReadOnly
? undefined
Expand Down Expand Up @@ -4880,12 +4893,23 @@ export async function streamClaudeWithSdk(
: ["stave:current-task-awareness"],
})
: args.conversation;
const providerPrompt = buildProviderTurnPrompt({
const nativeSlashCommandInput = promptConversation
? getProviderNativeSlashCommandInput(promptConversation)
: null;
const providerPromptBase = buildProviderTurnPrompt({
providerId: args.providerId,
prompt: args.prompt,
conversation: promptConversation,
activeResumeSessionId: existingSessionId ?? null,
});
const providerPrompt = nativeSlashCommandInput
? providerPromptBase
: appendPromptSecretReferenceContext({
prompt: providerPromptBase,
parsed: promptSecretReferences,
availableEnvNames: Object.keys(boundSecretEnv),
disabledForSecondaryReadOnly: secondaryReadOnly,
});
const activatedSkillSlugs = collectClaudeActivatedSkillSlugs({
conversation: args.conversation,
});
Expand Down
33 changes: 28 additions & 5 deletions electron/providers/codex-app-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ import {
import { getCodexMcpRegistrationStatus } from "../main/codex-mcp";
import { readPrimaryStaveLocalMcpManifest } from "../main/stave-local-mcp-manifest";
import { resolveBoundSecretEnv } from "../main/browser/secret-service";
import { MAX_BOUND_SECRETS } from "../../src/lib/secrets/secrets";
import {
appendPromptSecretReferenceContext,
parsePromptSecretReferences,
} from "../../src/lib/secrets/secret-references";
import { buildCodexInstructionProfileKey, resolveCodexWorkerProfile } from "./codex-runtime-config";
import { buildWorkerExecutionMetadata, type WorkerExecutionMetadata } from "../../src/lib/providers/worker-mode";
import {
Expand Down Expand Up @@ -2303,14 +2308,24 @@ export async function streamCodexWithAppServer(
const workerExecution = workerProfile ? buildWorkerExecutionMetadata(workerProfile) : null;
const codexCapabilities = getCodexVersionCapabilities(codexExecutablePath);

const boundSecretIds = runtimeOptions?.boundSecretIds ?? [];
const promptSecretReferences = parsePromptSecretReferences({
prompt: args.prompt,
maxResolvableReferences:
MAX_BOUND_SECRETS - new Set(boundSecretIds).size,
});
// A per-turn process lets Codex resolve MCP bearer_token_env_var settings
// without exposing bound values to shared clients or read-only analysis.
// without exposing bound or explicitly referenced values to shared clients,
// model-visible text, or read-only analysis.
const boundSecretEnv =
secondaryReadOnly ||
!runtimeOptions?.boundSecretIds ||
runtimeOptions.boundSecretIds.length === 0
(boundSecretIds.length === 0 &&
promptSecretReferences.resolutionKeys.length === 0)
? {}
: await resolveBoundSecretEnv({ ids: runtimeOptions.boundSecretIds });
: await resolveBoundSecretEnv({
ids: boundSecretIds,
referenceKeys: promptSecretReferences.resolutionKeys,
});
const codexRuntimeEnv = buildCodexCliEnv({
executablePath: codexExecutablePath,
});
Expand Down Expand Up @@ -2519,7 +2534,7 @@ export async function streamCodexWithAppServer(
? false
: await hasConnectedStaveLocalMcpForCodex();

const providerPrompt =
const providerPromptBase =
nativeSlashCommandInput ??
buildProviderTurnPrompt({
providerId: args.providerId,
Expand All @@ -2534,6 +2549,14 @@ export async function streamCodexWithAppServer(
})
: args.conversation,
});
const providerPrompt = nativeSlashCommandInput
? providerPromptBase
: appendPromptSecretReferenceContext({
prompt: providerPromptBase,
parsed: promptSecretReferences,
availableEnvNames: Object.keys(boundSecretEnv),
disabledForSecondaryReadOnly: secondaryReadOnly,
});

const goalCommandEvents = await runCodexGoalSlashCommand({
client,
Expand Down
18 changes: 12 additions & 6 deletions src/components/layout/settings-dialog-secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export function SecretsSettingsCard() {
<>
<SettingsCard
title="Secrets"
description="Store API tokens and other secret values. Values are encrypted by the operating system and stay out of Stave settings, chat, and MCP responses. They are revealed only when you explicitly ask, or injected into a bound task's provider runtime as an environment variable."
description="Store API tokens and other secret values. Assign an environment variable name, then reference it in a prompt as @secret:{NAME}. Values are encrypted by the operating system and stay out of Stave settings, chat, and MCP responses. They are revealed only when you explicitly ask, or injected into a turn's provider runtime as an environment variable."
titleAccessory={
<Button
type="button"
Expand All @@ -241,10 +241,12 @@ export function SecretsSettingsCard() {
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-success" />
<p className="text-xs leading-5 text-muted-foreground">
A secret's value is never shown to an agent. Give a secret an
environment variable name to bind it to a task from the composer —
its value is then available to that task's shell and supported MCP
authentication (e.g. <code>$OPENAI_API_KEY</code>) without entering
the model's context. A command that echoes the variable can still
environment variable name, then reference it in a prompt as
<code className="mx-1">@secret:{"{OPENAI_API_KEY}"}</code>
or bind it from the composer. Its value is available to that
turn's shell and supported MCP authentication as
<code className="mx-1">$OPENAI_API_KEY</code> without entering the
model's context. A command that echoes the variable can still
surface it.
</p>
</div>
Expand Down Expand Up @@ -316,7 +318,11 @@ export function SecretsSettingsCard() {
onChange={(event) => setEnvVarName(event.target.value)}
/>
<span className="block font-normal leading-4 text-muted-foreground">
Set this to let a task inject the value into its runtime as
Set this to reference the secret in a prompt as
<code className="mx-1 rounded bg-muted px-1 py-0.5">
@secret:{`{${envVarName.trim() || "NAME"}}`}
</code>
or bind it from the composer. The value is injected as
<code className="mx-1 rounded bg-muted px-1 py-0.5">
${envVarName.trim() || "NAME"}
</code>
Expand Down
Loading
Loading