Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/env-file-secret-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ian-pascoe/pi-secret": minor
---

Add the Env File Secret Provider: grant Secrets from `.env` and `.env.*` files in the project, or files listed in global `secret.providers.dotenv.files`, through `dotenv://<path>#<KEY>` Secret Locators, read again at each use, and save typed Secrets into them as new keys. It is on when Pi starts in a directory with a top-level Env File, or when global settings list files or set `enabled: true`; global `enabled: false` turns it off. The approval dialogs warn when git does not ignore a project Env File. Project settings for one Secret Provider no longer replace global settings for the others.
4 changes: 4 additions & 0 deletions packages/pi-secret/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ _Avoid_: Path, address, provider reference
The Secret Locator form for 1Password: `op://vault/item/field`.
_Avoid_: Secret Reference (that term means the agent's stand-in for a Secret), op URI

**Env File**:
A file of `NAME=value` lines, named `.env` or `.env.*` in the project or listed in the user's settings, that Secret Grants can be read from and typed Secrets saved into.
_Avoid_: Dotenv, environment file, secrets file

**Secret Request**:
An agent's request for one or more Secret Names, optionally with a reason shown to the user, which the user answers by entering, skipping, or cancelling each value. Every request prompts again and replaces any Secret already held under the same name.
_Avoid_: Prompt, secret ask
Expand Down
55 changes: 44 additions & 11 deletions packages/pi-secret/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

`@ian-pascoe/pi-secret` lets a Pi agent use **Secrets**, such as API keys, passwords, and tokens, that the user types into a masked prompt. The values never reach the model provider, are never written to the session file in plaintext, and are never shown on screen. The agent works only with **Secret References**.

It can also read Secrets from a **Secret Provider**, currently 1Password, through **Secret Grants** the user approves, and save a typed Secret into one.
It can also read Secrets from a **Secret Provider**, 1Password or the project's **Env Files**, through **Secret Grants** the user approves, and save a typed Secret into one.

Requires Node `>=22.19.0` and Pi `>=1.1.0`. Secret Providers need the [1Password CLI](https://developer.1password.com/docs/cli/) (`op`), signed in.
Requires Node `>=22.19.0` and Pi `>=1.1.0`. The 1Password Secret Provider needs the [1Password CLI](https://developer.1password.com/docs/cli/) (`op`), signed in; Env Files need nothing else.

> **Pi Secret keeps values out of the conversation; it is not a sandbox.** A Secret is usable by any command the agent runs while it is held, and a misbehaving agent can still send it somewhere, for example with `curl`. Pi Secret redacts values it recognizes in tool output, but pair it with [Pi Guardian](../pi-guardian) to review what the agent does with a Secret.

Expand Down Expand Up @@ -32,14 +32,45 @@ To write either form as literal text, such as in documentation or tests, put a b

### Secret Providers and Secret Grants

When the 1Password CLI (`op`) is on `PATH` at load, Pi Secret adds `secret_ask_grant` and `secret_save`, and one line of guidance to the system prompt. Both are decided once, so the tool set and prompt never change within a session.
Pi Secret has two Secret Providers, each decided once at load:

1. The agent finds an item with `secret_list` (for example `query: "github"`), then lists its fields with `item`. Each field comes with its **Secret Locator**, the 1Password Reference `op://vault/item/field`. Values are never listed.
- **1Password** (`1password`), when the 1Password CLI (`op`) is on `PATH`.
- **Env files** (`dotenv`), when Pi starts in a directory with a `.env` or `.env.*` file at its top level, or when global settings list Env Files or set `enabled: true` (see [Env Files](#env-files)). Global `enabled: false` turns it off.

With at least one, Pi Secret adds `secret_ask_grant` and `secret_save`, and one line of guidance to the system prompt naming the providers loaded. Both are decided once, so the tool set and prompt never change within a session.

1. The agent finds an item with `secret_list` (for example `query: "github"`), then lists its fields with `item`. Each field comes with its **Secret Locator**: the 1Password Reference `op://vault/item/field`, or an Env File locator such as `dotenv://.env.local#API_KEY`. Values are never listed.
2. The agent calls `secret_ask_grant({ grants: [{ name: "GITHUB_TOKEN", locator: "op://Dev/GitHub/token" }], reason?: "…" })`. Pi shows each grant's Secret Name, vault, item, field, and field type. Every grant starts allowed: Space toggles one between allow and deny, Enter submits, and Escape cancels them all.
3. Pi Secret reads each allowed grant once, so a wrong locator or a locked 1Password shows up in the result, as `granted`, `denied`, `cancelled`, or `failed` with the reason. A value shorter than 8 characters is granted with a warning.
4. The agent uses the Secret through its Secret References, as with a typed Secret. Pi Secret reads the value again from 1Password on every tool call that references it, so one-time passwords (`?attribute=otp`) are always current and a value rotated or revoked in 1Password takes effect at once. If the read fails, the call is blocked, and the last value read is never used in its place.
3. Pi Secret reads each allowed grant once, so a wrong locator, a missing key, or a locked 1Password shows up in the result, as `granted`, `denied`, `cancelled`, or `failed` with the reason. A value shorter than 8 characters is granted with a warning.
4. The agent uses the Secret through its Secret References, as with a typed Secret. Pi Secret reads the value again from its Secret Provider on every tool call that references it, so one-time passwords (`?attribute=otp`) are always current and a value rotated, revoked, or edited takes effect at once. If the read fails, the call is blocked, and the last value read is never used in its place.

`secret_save({ name, locator, category? })` saves a Secret the user typed into this session somewhere new, after the user approves it in a dialog, and never changes anything that exists:

- Into a **new** 1Password item, such as `op://Dev/OpenAI API/credential`. The category defaults to API Credential. It refuses a title the vault already has. The value reaches `op` on stdin, never on its command line. The grant's locator uses the new item's ID, so it survives renames.
- As a **new key** in an Env File, such as `dotenv://.env.local#OPENAI_API_KEY`. It refuses a key the file already defines, and `category`. Pi Secret appends one `KEY=value` line itself, quoted so the file reads back exactly the value, and refuses a value no quoting can round-trip. A missing `.env` or `.env.*` file is created with mode `0600`, inside the project or at a listed path only.

The Secret then becomes a grant on the saved locator, and it keeps its original expiry.

### Env Files

An **Env File** is a file of `NAME=value` lines named `.env` or `.env.*` (not `.envrc`) anywhere in the project, the session's working directory. `secret_list` searches the whole project for them, gitignored ones included, but skips `.git` and `node_modules`. Each Env File is an item, shown in a vault named after the project directory, and its keys are its fields.

- **Locators** are `dotenv://<path>#<KEY>`. A path in the project is relative to it, such as `dotenv://packages/api/.env#DATABASE_URL`; a path outside is absolute, such as `dotenv:///Users/me/.config/tool/.env#TOKEN`, and `~/` is accepted. A relative locator in a resumed grant resolves against the session's project directory.
- **Values** are parsed as Node's `util.parseEnv` does: the last definition of a key wins, and `${VAR}` is kept literally, never expanded.
- **Links**: an Env File that links outside the project is used only when its target is listed in settings.
- **Git**: when git does not ignore a project Env File, the grant and save dialogs say "not ignored by git". This is a warning only; approving still grants or saves ([ADR-0003](docs/adr/0003-save-typed-secrets-into-env-files.md)).

To use other files, such as one in your home directory, list their exact paths, absolute or `~/…`, in global settings:

```json
{
"secret": {
"providers": { "dotenv": { "files": ["~/.config/tool/.env"] } }
}
}
```

`secret_save({ name, locator, category? })` saves a Secret the user typed into this session into a **new** 1Password item, such as `op://Dev/OpenAI API/credential`, after the user approves it in a dialog. The category defaults to API Credential. It never edits an existing item, and it refuses a title the vault already has. The value reaches `op` on stdin, never on its command line. The Secret then becomes a grant on a locator using the new item's ID, so it survives renames, and it keeps its original expiry.
Only global settings may list files outside the project; a trusted project's settings may list files inside it, and any entry outside it is ignored with a warning. `enabled: true` turns the provider on without a top-level Env File, and `enabled: false` turns it off; both work in global settings only, because Pi decides the tool set before it knows the project.

A Secret Name holds one Secret at a time: a newer `secret_ask`, `secret_ask_grant`, or `secret_save` replaces whatever was held under that name.

Expand All @@ -50,7 +81,7 @@ A Secret Name holds one Secret at a time: a newer `secret_ask`, `secret_ask_gran
| `secret_ask` | `names`, `reason?` | Each name's outcome, references, and expiry |
| `secret_ask_grant` | `grants` (`name`, `locator`), `reason?` | Each grant's outcome, references, and expiry |
| `secret_list` | `query?`, `provider?`, `vault?`, `item?`, `status?`, `limit?` | Held Secrets with their expiry and each grant's Secret Locator, and matching Secret Provider items or fields; never values |
| `secret_save` | `name`, `locator`, `category?` | The new item's Secret Locator |
| `secret_save` | `name`, `locator`, `category?` | The new item's or key's Secret Locator |
| `secret_forget` | `names` | Forgets the named Secrets, typed or granted, now, before their lifetime ends |

`secret_list` returns held Secrets and up to `limit` (default 20) available items from each Secret Provider. With `item`, it lists that item's fields instead. `status: "held"` skips the Secret Provider search, and a Secret Provider that cannot be searched is reported in the result, not as an error.
Expand Down Expand Up @@ -83,15 +114,15 @@ The 1Password Secret Provider uses `op`'s default account and every vault `op` c
}
```

Invalid settings fall back to a 15-minute lifetime and no 1Password vaults, with a warning, so a mistyped vault list never widens access.
Each Secret Provider's project settings replace that provider's global settings only; listed Env Files from both add up. Invalid settings fall back to a 15-minute lifetime, no 1Password vaults, and only the project's own Env Files, with a warning, so a mistyped list never widens access.

Each Secret is encrypted with its own random AES-256-GCM key. The ciphertext is stored as a `pi-secret` custom entry in the session; the key is stored in `~/.pi/agent/pi-secret/keys.json` (mode `0600`), outside the session. The ciphertext is bound to its name, key, and expiry. As a result:

- Resuming, forking, or reloading a session within the lifetime restores its Secrets. Navigating the session tree holds only the Secrets entered on the selected branch.
- When the lifetime ends, or the Secret is forgotten, its key is deleted, so the session's copy can never be decrypted again. Keys are deleted by a timer, before any tool call once expired, and when a session starts. After that the session file is safe to share as far as Pi Secret is concerned.
- This does not protect against someone who can read your disk while a Secret is held: the key file and the session file sit on the same machine.

A Secret Grant is stored differently ([ADR-0001](docs/adr/0001-read-secret-grants-at-each-use.md)): its `pi-secret-grant` entry holds only the Secret Name, Secret Locator, and expiry, never a value, since the value is read from the Secret Provider at each use. A resumed grant is not read until its first use, so resuming never prompts 1Password. Forgetting any Secret records a `pi-secret-forget` entry, so it is not restored. Saving a typed Secret deletes its key, as forgetting does.
A Secret Grant is stored differently ([ADR-0001](docs/adr/0001-read-secret-grants-at-each-use.md)): its `pi-secret-grant` entry holds only the Secret Name, Secret Locator, and expiry, never a value, since the value is read from the Secret Provider at each use. A resumed grant is not read until its first use, so resuming never prompts 1Password. Saving into an Env File writes the value to that file in plaintext; when the Secret Lifetime ends, the grant ends, but the key stays in the file. Forgetting any Secret records a `pi-secret-forget` entry, so it is not restored. Saving a typed Secret deletes its key, as forgetting does.

Child Agents, such as Minimal Subagents children in the same Pi process, can use Secrets their root session holds, through the process-wide registry in `@ian-pascoe/pi-utils` ([ADR-0008](../../docs/adr/0008-secret-redaction-registry-in-pi-utils.md)). They cannot ask for new ones or forget the root's. A Child Agent using a granted Secret reads it fresh from the Secret Provider, as the root does ([ADR-0010](../../docs/adr/0010-secret-registry-refresh-callbacks.md)).

Expand All @@ -100,7 +131,9 @@ Child Agents, such as Minimal Subagents children in the same Pi process, can use
- **Interactive terminal only**: `secret_ask`, `secret_ask_grant`, and `secret_save` fail in print, JSON, and RPC modes and in Child Agents, because only Pi's terminal UI can show their dialogs.
- **1Password approves the CLI, not each item**: once `op` is unlocked, it can read any item in the vaults it can see. Pi Secret reads only locators the user granted, but `secret_list` sends item titles and field labels from those vaults to the model provider. Use `secret.providers.1password.vaults` to narrow what it can see; it is checked at every read, so narrowing it also stops grants already held. The grant dialog names the account only when `account` is set.
- **One-time passwords stay redacted**: every value read is redacted for the rest of the process, including each 6-digit code, so a later output containing the same digits by chance shows the Secret Reference instead.
- **Reads cost time**: each tool call using a granted Secret runs `op read` first, typically well under a second, and fails while 1Password is locked or `op` is signed out.
- **Reads cost time**: each tool call using a granted 1Password Secret runs `op read` first, typically well under a second, and fails while 1Password is locked or `op` is signed out.
- **Env Files are plaintext**: Pi Secret reads them only through grants the user approves, but the agent's own tools can read an Env File unless Pi Guardian or another policy stops them. `secret_list` sends Env File paths and key names, never values, to the model provider.
- **Large projects**: `secret_list` walks the whole project for Env Files, outside `.git` and `node_modules`, on each search; a very large tree makes it slow.
- **A restored grant's variable**: `PI_SECRET_NAME` for a grant restored on resume is exported at its first use, so a program that reads it from the environment without the call naming it sees nothing until then.
- **Running processes keep their environment**: `PI_SECRET_NAME` is unset when a Secret expires or is forgotten, but a terminal or server started while it was held keeps its copy until it exits.
- **Streaming output**: Pi shows a running tool's partial output live, before the final result is redacted, so a command that prints a Secret can show it on screen until its result arrives. The stored result and the provider never see it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Save typed Secrets into Env Files, warning but not blocking when git does not ignore them

The Env File Secret Provider reads Secret Grants from `.env*` files in the project and from files listed in global settings, and it also saves typed Secrets into them, even though Pi Guardian treats `.env*` files as sensitive and a project Env File may be committed. Users keep project credentials in Env Files already, so a read-only provider would leave a typed Secret with no durable home except 1Password. Saving only ever adds a key: it refuses a key the file already defines, and creates a missing file with owner-only permissions. When git does not ignore a project Env File, the approval dialog says so, but the save or grant still goes ahead if the user approves. Only global settings may list Env Files outside the project, because a trusted project's own settings could otherwise point the agent at credentials anywhere in the user's home directory.

## Considered Options

- **Read-only Env Files**: rejected because users who keep credentials in Env Files would have to copy a typed value there by hand, and the agent can already write a value into any file through a Secret Reference.
- **Refuse files git does not ignore**: rejected because the user chose to accept that risk; the dialog warning makes it visible when they approve.
- **Let project settings list any Env File**: rejected for the reason above; this matches how the 1Password vault allowlist fails closed.

## Consequences

- A saved value lands in a plaintext file the user owns. Its Secret Lifetime still ends the grant, but it does not delete the key from the file.
Loading
Loading