pi-roundtable 0.32.1 → 0.33.0
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/CHANGELOG.md +22 -0
- package/README.md +1 -0
- package/README.zh-TW.md +1 -0
- package/docs/plugins.md +40 -5
- package/package.json +1 -1
- package/src/core/config/config-schema.ts +3 -0
- package/src/core/config/config.ts +2 -1
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/host-registration.ts +3 -0
- package/src/core/host.ts +2 -1
- package/src/core/modules/host-shell/shell-policy.ts +34 -18
- package/src/core/modules/host-shell/shell-programs.ts +38 -6
- package/src/core/modules/host-shell/shell-secrets.ts +306 -0
- package/src/core/plugin.ts +3 -0
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +7 -1
- package/src/core/runtime/session-factory.ts +4 -4
- package/src/core/runtime/session-helpers.ts +15 -3
- package/src/core/shared/child-env.ts +115 -0
- package/src/core/shared/secret-mask.ts +64 -0
- package/src/index.ts +1 -0
- package/src/kit/index.ts +9 -0
- package/src/kit/shell.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.33.0] - 2026-11-04
|
|
9
|
+
|
|
10
|
+
This is a minor release with breaking defaults: the agents' shell and the coding plugin's workers no longer inherit the host's environment, and more shell commands are held. See the migration notes below.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The agents' `bash` gets only the names in `SHELL_ENV_NAMES` (`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LANGUAGE`, `LC_*`, `TZ`, `TERM`, `TMPDIR`, the proxy variables, `SSL_CERT_FILE`, `SSL_CERT_DIR`, and Pi's `PI_*` session names) instead of the whole host environment, so `env` no longer prints the bot tokens, model logins, `GH_TOKEN`, `DATABASE_URL` and keys the host holds. The new config key and `HostEnvironment` field `shellEnv: { pass?, set? }` (types `ShellEnv` and `ShellEnvConfig`, main entry) names more host variables to pass and fixed values to set; plugins read it as `context.env.shellEnv`. Without `pass` the shell keeps `GH_TOKEN` (`DEFAULT_SHELL_PASS`), so `gh` and a `git push` through `gh auth git-credential` keep working; `pass: []` drops it. A session without a scratch dir now gets the same whitelisted `bash`.
|
|
15
|
+
- The shell rule holds reading secrets as the new class `shell.secret-read`, which `YOLO_CARDED_SHELL_CLASSES` includes, so yolo mode cards it: `env` or `printenv` with no command to run, `export`/`export -p`, `declare -p`, `declare -x` and `typeset` with no names, `set` alone, `compgen -e`; a file reader (`cat`, `less`, `head`, `tail`, `grep`, `sed`, `awk`, `base64`, `xxd`, `cp`, `tar`, `zip`, `curl -d @`/`-F @`/`--data-binary @`/`-T`, and the like) or a `<` redirect of a secret file; Pi's `read` tool on one; `gh auth token`, `gh auth status -t`/`--show-token`; `git credential`; and `git config` reading `credential.*` or listing everything. Secret files are `DEFAULT_SECRET_PATHS` (`/proc/*/environ`, `*.env`, `.env*` except `.env.example`, `.env.sample`, `.env.template`, `~/.ssh/**`, `~/.config/gh/**`, `.git-credentials`, `.netrc`, `.pgpass`, Pi's `auth.json`) plus the globs of the new `shellHoldRuleFor` option `secretPaths` (its options are now the type `ShellPolicy`, which extends `PushPolicy`). `env FOO=1 cmd` is still judged as `cmd`.
|
|
16
|
+
- `docker run`, `exec`, `create` and `cp`, also under `docker container` and as `docker compose run`, `exec` and `cp`, are held as `shell.docker`: they run commands or copy files as the daemon's root.
|
|
17
|
+
- Tool output is masked before it enters the model's context: each value of a host variable whose name matches `SECRET_NAME` (`TOKEN`, `SECRET`, `KEY`, `PASS`, `PASSWORD`, `DATABASE_URL`, `JWT`, `CREDENTIAL`) and that is at least 8 characters becomes `****`, in every tool's result, by the core's `secretMaskExtension()`.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- In `pi-roundtable/kit`, for a plugin that spawns a child process or runs a session of its own: `childEnv` (`childEnv(source, { pass?, set? })`, the environment of a child process), `shellChildEnv` (the same for an agent's shell, `pass` defaulting to `DEFAULT_SHELL_PASS`), `DEFAULT_SHELL_PASS`, `SHELL_ENV_NAMES`, `ShellPolicy`, `DEFAULT_SECRET_PATHS`, `maskSecrets`, `SECRET_NAME` and `secretMaskExtension`. `ShellEnv` and `ShellEnvConfig` are exported from the main entry.
|
|
22
|
+
- README and site: a threat model of the host shell. It runs as the host's user; holds are not a sandbox; the whitelist, the secret-read hold and the mask stop casual and accidental leaks, not a determined read by the same user. Run agent shells under a dedicated account without the `docker` group, which equals root, or use pi-roundtable-sandbox.
|
|
23
|
+
|
|
24
|
+
### Migration
|
|
25
|
+
|
|
26
|
+
- A command in the agents' shell that relied on a host variable other than `GH_TOKEN` now misses it. A CLI that reads an API key needs it named: `shellEnv: { pass: ["GH_TOKEN", "SOME_API_KEY"] }` (a given `pass` replaces the default, so keep `GH_TOKEN` in it if the shell pushes). To keep `GH_TOKEN` out of the shell too, set `shellEnv: { pass: [] }`. Prefer a fixed, narrower value in `set`, or a credential the tool reads from its own config, to passing a host secret.
|
|
27
|
+
- A yolo conversation now asks on a card for `env`, `printenv`, reading `.env` files and the other `shell.secret-read` commands, and for `docker run`/`exec`/`create`/`cp`; list the class in `autonomy.yolo.allow` to approve it without a card. A hold rule built with `shellHoldRuleFor` takes `secretPaths` to add files.
|
|
28
|
+
- An exhaustive switch over the shell classes gains `shell.secret-read`.
|
|
29
|
+
|
|
8
30
|
## [0.32.1] - 2026-11-04
|
|
9
31
|
|
|
10
32
|
- Release in lockstep with pi-roundtable-web 0.32.1 (optimistic mark-as-read, a Home button at the foot of the room drawer, Discord's -# subtext and line breaks in messages, the shared prompt's description in zh-TW, and the push device list centred); no core behavior changes.
|
package/README.md
CHANGED
|
@@ -274,6 +274,7 @@ pi-roundtable runs one assistant for one owner.
|
|
|
274
274
|
- **Approvals.** A held call is approved only by the speaker whose turn held it, at a tier that still holds the call, or, in a shared conversation, by an owner, on its card or by a confirming message. In a private conversation nobody else may, and a call above the speaker's tier is not approved there.
|
|
275
275
|
- **Conversations.** In Discord an agent's channel and a group room are shared: everyone who writes there adds to one conversation the agent reads. A web chat conversation belongs to the person who opened it: in the web chat only they may read it, write in it, or answer its prompts, but the owner can read it through the owner console ([pi-roundtable-web](packages/web)), and the operator through the database and the data directory.
|
|
276
276
|
- **Memory isolation.** Memory follows the principal, not owner tier: a private conversation's person, or the current speaker in a shared room. Link accounts to share one person's memory across Discord and web. In shared requests and summaries, memory-tool and memory-loaded-worker exchanges (arguments and results together) are hidden from other speakers, and earlier reasoning is removed with provider-aware handling. Other tools' arguments and public assistant replies remain public, even if the model repeats private memory there; this is not general secret-flow prevention. Third-party compactors must use `privateCompaction` before summarizing shared history. `claude-bridge` shared turns refuse when raw reader records, including compacted turns, show another reader's private memory (even prompt-only memory and its reasoning), or an exchange is hidden from the current reader. A turn with no private memory does not block other readers, and disabling future memory does not bypass prior private history; admitting other people only warns at boot and in doctor. SYSTEM counts as primary owner for this core guard alone, so it can read and publicly repeat the owner's retained exchanges/reasoning; pre-0.9 untagged built-in memory results before the first scope record retain owner attribution. Sandbox party-wide prompt blocks must explicitly use `memory.visibility: "shared"`, and private turns require worker capabilities. Bridge replays unfiltered history outside these projections; see the [exact rule and remaining risk](docs/migration-0.9.md#claude-bridge-guard).
|
|
277
|
+
- **The host shell.** An agent's shell runs as the host's user. Holds keep risky commands behind a card; they are not a sandbox. The shell gets only part of the host's environment (the paths, user, locale, terminal, `TMPDIR` and proxies, plus `GH_TOKEN` and what `shellEnv` adds), so the bot tokens, model logins, `DATABASE_URL` and keys the host holds stay in the host process; printing the environment, reading a secret file such as `.env` or `~/.ssh/*`, and `gh auth token` are held as `shell.secret-read`, which yolo mode always cards; a secret's value in a tool's output is replaced with `****`. This stops casual and accidental leaks, not a determined command run as the same user, which can read `/proc/<host pid>/environ` and any file that user can. To keep secrets from the agents, run their shells under a dedicated account without the `docker` group (membership in it equals root), or use [pi-roundtable-sandbox](packages/sandbox).
|
|
277
278
|
- **Model credentials.** Every turn, whoever speaks, runs on the host's model login and is billed to it. Per-person credentials are planned for a later release.
|
|
278
279
|
- **Data.** Conversations, memory, and schedules sit unencrypted in PostgreSQL and the data directory.
|
|
279
280
|
|
package/README.zh-TW.md
CHANGED
|
@@ -264,6 +264,7 @@ pi-roundtable 為一位擁有者執行騎士。
|
|
|
264
264
|
- **核准。** 暫緩的呼叫只由當輪暫緩它的發言者核准,而且當下 tier 仍要足夠;在共用對話裡,擁有者也可以在卡片上或用確認訊息核准。私人對話裡別人都不能核准,高於發言者 tier 的呼叫在那裡也不會被核准。
|
|
265
265
|
- **對話。** Discord 裡智慧體的頻道和群組房間是共用的:在那裡發言的每個人,都會寫進智慧體讀取的同一段對話。網頁聊天的對話屬於開啟它的人:在網頁聊天裡只有開啟者本人能讀、能發言、能回答它的提問,但擁有者可以透過擁有者主控台([pi-roundtable-web](packages/web))讀取,營運者也能從資料庫和資料目錄讀到。
|
|
266
266
|
- **記憶隔離。** 記憶依 principal 分開,不依擁有者 tier 判斷。私人對話讀本人的,共用對話讀當輪發言者的;連結 Discord 與網頁身分後,同一個人共用一份記憶。在共用對話送給模型的請求和摘要裡,記憶工具與載入記憶的 worker 的呼叫(參數和結果成對)會對其他發言者隱藏,先前回合的 reasoning 則依 provider 清除。其他工具的參數和公開的騎士回覆仍是公開內容,即使模型在裡面重述私人記憶;這不是一般的秘密流向防護。第三方摘要器必須先使用 `privateCompaction`,才能摘要共用歷史。`claude-bridge` 的共用回合只在原始讀者紀錄(含已壓縮的回合)顯示另一位讀者的私人記憶(連只放進 prompt 的記憶與其 reasoning 也算),或某個呼叫已對當前讀者隱藏時才被拒絕。沒有私人記憶的回合不會擋其他讀者,關掉之後的記憶也繞不過先前的私人歷史;開放其他人進來只會在開機和 doctor 裡警告。SYSTEM 只在這項核心防護裡算作主要擁有者,所以它能讀取並公開重述擁有者保留的呼叫與 reasoning;0.9 之前、第一筆範圍紀錄之前未標記的內建記憶結果,仍歸屬擁有者。沙盒的全場共用 prompt 區塊必須明確使用 `memory.visibility: "shared"`,私人回合需要 worker 能力。Bridge 會在這些投影之外重播未過濾的歷史;見[確切規則與剩餘風險](docs/migration-0.9.md#claude-bridge-guard)。
|
|
267
|
+
- **主機上的 shell。** 智慧體的 shell 以主機的使用者身分執行。暫緩只是把有風險的指令擋在卡片後面,不是沙箱。shell 只拿到主機環境變數的一部分(路徑、使用者、語系、終端機、`TMPDIR`、proxy,加上 `GH_TOKEN` 與 `shellEnv` 額外給的),所以主機持有的 bot token、模型登入、`DATABASE_URL` 和金鑰都留在主機行程裡;印出環境變數、讀取 `.env` 或 `~/.ssh/*` 這類秘密檔案、`gh auth token` 會以 `shell.secret-read` 暫緩,yolo 模式一律出卡片;工具輸出裡的秘密值會換成 `****`。這擋得住隨手和意外的外洩,擋不住以同一個使用者刻意執行的指令:它讀得到 `/proc/<主機 pid>/environ`,也讀得到那個使用者能讀的任何檔案。要讓智慧體碰不到秘密,請讓它們的 shell 以不在 `docker` 群組裡的專用帳號執行(加入這個群組等同 root),或改用 [pi-roundtable-sandbox](packages/sandbox)。
|
|
267
268
|
- **模型憑證。** 每一輪不論誰發言,都用主機的模型登入執行、由它計費。每個人自己的憑證預計在之後的版本加入。
|
|
268
269
|
- **資料。** 對話、記憶和排程以未加密的形式存在 PostgreSQL 和資料目錄裡。
|
|
269
270
|
|
package/docs/plugins.md
CHANGED
|
@@ -169,7 +169,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
169
169
|
| Field | What it is |
|
|
170
170
|
|---|---|
|
|
171
171
|
| `logger` | The host's logger with `plugin: <your name>` on every line; each line is JSON on stdout. Its type is `Logger` (`debug`, `info`, `warn`, `error`, `fatal`, `child(fields)`), which a pino logger satisfies |
|
|
172
|
-
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one, `characterSkins`, the six skin tones the host draws its agents with ([host skins](#host-skins-the-charactersskins-option)),
|
|
172
|
+
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one, `characterSkins`, the six skin tones the host draws its agents with ([host skins](#host-skins-the-charactersskins-option)), `agentTools`, `"full"` or `"chat-only"` ([chat-only agents](#chat-only-agents-no-shell-file-or-workspace-tools)), and `shellEnv` when the host sets one ([the agents' shell environment](#the-agents-shell-environment)); read the zone from here rather than from the process, give `scratchDir` to a shell the plugin runs itself, and pass `characterSkins` to `composeSvg` when the plugin draws a character |
|
|
173
173
|
| `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
|
|
174
174
|
| `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
|
|
175
175
|
| `events` | Where the core reports turns and team changes to every plugin's handlers |
|
|
@@ -1135,7 +1135,7 @@ The mode is read when the card is asked for, so a toggle applies to the next car
|
|
|
1135
1135
|
|
|
1136
1136
|
#### The shell rule
|
|
1137
1137
|
|
|
1138
|
-
`shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write` and `
|
|
1138
|
+
`shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write`, `edit` and `read` calls (a `read` only for a secret file); the agent server links it, and a session without a workspace has no shell.
|
|
1139
1139
|
Its scratch roots are the shared workspace (`HoldContext.workspace`) and the scratch dir (`HoldContext.scratchDir`).
|
|
1140
1140
|
The scratch dir is the config's `scratchDir`, by default `<os temp dir>/<discord.rootCommand>-scratch` (such as `/tmp/roundtable-scratch`); the agent server creates it with mode 0700 at startup and refuses one that is a symlink or another user's, and the agents' `bash` runs with `TMPDIR` pointing to it, so tools that read `TMPDIR` and GNU `mktemp` write there. macOS's BSD `mktemp` ignores `TMPDIR` for a bare `mktemp`; `mktemp -p "$TMPDIR"` works with both, and the agents' prompt tells them to use it.
|
|
1141
1141
|
`AgentSessions.scratchDir` carries it to a runtime, and the agents' prompt tells them to write temporary files there, and to show a file with `attach_file` or a `discord_send_message` file `path` rather than reading an image or base64-encoding it.
|
|
@@ -1156,7 +1156,7 @@ The rule reports the class of **every** held command in the line, including thos
|
|
|
1156
1156
|
| `shell.rm` | an `rm` outside the scratch roots, or one the line cannot resolve |
|
|
1157
1157
|
| `shell.write` | a redirect, `tee`, `write` or `edit` outside the scratch roots |
|
|
1158
1158
|
| `shell.service` | `systemctl` and `service` changes |
|
|
1159
|
-
| `shell.docker` | stopping, removing or pruning containers, images and volumes; `docker compose down` |
|
|
1159
|
+
| `shell.docker` | stopping, removing or pruning containers, images and volumes; `docker compose down`; `docker run`, `exec`, `create` and `cp` (also under `docker container` and `docker compose run`, `exec`, `cp`), which run commands or copy files as the daemon's root |
|
|
1160
1160
|
| `shell.firewall` | `ufw`, `iptables`, `ip6tables`, `nft` changes |
|
|
1161
1161
|
| `shell.packages` | `apt`, `apt-get`, `snap`, `dpkg` installs and removals |
|
|
1162
1162
|
| `shell.push` | a plain `git push` the push policy holds |
|
|
@@ -1164,6 +1164,7 @@ The rule reports the class of **every** held command in the line, including thos
|
|
|
1164
1164
|
| `shell.reset-hard` | `git reset --hard` |
|
|
1165
1165
|
| `shell.gh` | `gh` writes |
|
|
1166
1166
|
| `shell.crontab` | `crontab -r` |
|
|
1167
|
+
| `shell.secret-read` | printing the environment (`env` or `printenv` with no command to run, `export` or `export -p`, `declare -p`, `declare -x` and `typeset` with no names, `set` alone, `compgen -e`), a file reader (`cat`, `less`, `head`, `tail`, `grep`, `sed`, `awk`, `base64`, `xxd`, `cp`, `tar`, `zip`, `curl -d @`/`-F @`/`--data-binary @`/`-T`, and the like) or a `<` redirect of a secret file (`DEFAULT_SECRET_PATHS` and the rule's `secretPaths`), Pi's `read` tool on one, `gh auth token`, `gh auth status -t`/`--show-token`, `git credential`, and `git config` reading `credential.*` or listing everything |
|
|
1167
1168
|
| `shell.unreadable` | a line the rule cannot read to the end, so it cannot rule out a destructive command: longer than 200,000 characters, nested deeper than 48 levels, a here-document or `case` it cannot place, a script built at run time (`eval`, `bash -c` of a variable or a substitution), a shell that reads a script the line does not show as a word (`echo x \| bash`, a here-document or here-string for a shell, `source <(…)`, `. /dev/stdin`, a file the line wrote earlier and then runs, anything `xargs` fills in), `env -S`, a builtin's subscript that runs a substitution (`declare 'a[$(x)]=1'`), `PS4`, `PROMPT_COMMAND` and the other variables that run a command, or a rule that failed while judging |
|
|
1168
1169
|
|
|
1169
1170
|
The rule reads a line the way bash does, so text that only looks like a command stays unheld and text that runs is judged: quotes (`'…'`, `"…"`, `$'…'`, where a backslash escapes the next character), `$(…)` and backticks at any depth, `${…}`, comments (a `)` or a quote in one means nothing), here-document bodies up to their delimiter line (an unquoted body is still searched for substitutions, a quoted one is not), `case` patterns, arithmetic (`$((1<<2))` is a shift), line continuations, leading keywords (`then sudo ls`, `do rm -rf /x`), a descriptor before the command (`3>&1 sudo ls`), `eval`, `bash -c` (also `-ec`), and `find -exec`. Where it cannot tell, it holds the whole line as `shell.unreadable` instead of guessing; an error inside the rule also holds the call. So does a command whose name is a brace expansion, a pattern or a variable the line did not set to a literal (`{sudo,ls}`, `/usr/bin/su*`, `$cmd args`). The rule names programs it knows; a wrapper it does not list (`flock`, `chroot`, `ssh host`) runs its command unjudged. Known gaps: words in arithmetic (`$(( ))`, `(( ))`) are not commands, but a `$(` or backtick inside them is judged. A `bash -c` script is held whole only when the outer shell expands a substitution in it; a single-quoted script is judged like a line. `trap` scripts are judged. `alias NAME=…`, an assignment to `PROMPT_COMMAND`, `PS0`–`PS4`, `BASH_ENV`, `ENV` or `SHELLOPTS`, and `${x@P}` are held as `shell.unreadable`, because bash runs their value later. A line that evaluates arithmetic and also holds a literal `$(` or backtick (`x='a[$(cmd)]'; echo $((x))`) is held as `shell.unreadable`, so `echo '$(date)'; echo $((1 + 2))` is held too. Not followed: array subscripts and `printf -v` that evaluate a value, `source` of a file that defines aliases or traps, and a command run by a program the rule does not list.
|
|
@@ -1183,9 +1184,11 @@ ROUNDTABLE_SHELL_FUZZ=1 ROUNDTABLE_SHELL_FUZZ_BASE=<seed> bun test src/core/modu
|
|
|
1183
1184
|
|
|
1184
1185
|
`ROUNDTABLE_SHELL_FUZZ` is the number of lines, `ROUNDTABLE_SHELL_FUZZ_BASE` the first seed (the others follow it) and `ROUNDTABLE_SHELL_FUZZ_JOBS` how many bash processes run at once (up to six by default; use `1` on a busy machine); a line is a function of its seed alone. Each line runs in a process group of its own, killed as a whole after 5 seconds or when the line ends, with a cap on the user's processes. A line the fuzz finds goes into the hand corpus, minimized, so the fix stays tested without the generator.
|
|
1185
1186
|
|
|
1186
|
-
Yolo mode never approves these unless `autonomy.yolo.allow` lists them: `YOLO_CARDED_SHELL_CLASSES` is every class above except `shell.write` and `shell.push`; that includes `shell.unreadable`.
|
|
1187
|
+
Yolo mode never approves these unless `autonomy.yolo.allow` lists them: `YOLO_CARDED_SHELL_CLASSES` is every class above except `shell.write` and `shell.push`; that includes `shell.unreadable` and `shell.secret-read`.
|
|
1187
1188
|
|
|
1188
|
-
`shellHoldRuleFor({ ownPushOwners?, heldPushRepos? })` (its options are the type `PushPolicy`) is the same rule with plain pushes to the owner's own repositories let through; `shellHoldRule` is `shellHoldRuleFor()`, which holds every push.
|
|
1189
|
+
`shellHoldRuleFor({ ownPushOwners?, heldPushRepos?, secretPaths? })` (its options are the type `ShellPolicy`, which extends `PushPolicy`) is the same rule with plain pushes to the owner's own repositories let through; `shellHoldRule` is `shellHoldRuleFor()`, which holds every push.
|
|
1190
|
+
`secretPaths` adds globs to `DEFAULT_SECRET_PATHS` (`/proc/*/environ`, `*.env`, `.env*` but `.env.example`, `.env.sample` and `.env.template`, `~/.ssh/**`, `~/.config/gh/**`, `.git-credentials`, `.netrc`, `.pgpass`, `~/.pi/**/auth.json`, and `auth.json` in `PI_CODING_AGENT_DIR`): a glob with no `/` matches a file name anywhere, `~/` is the home directory, `*` stays within a directory, `**` crosses them, a trailing `/**` takes the directory too, and a leading `!` lets a match run.
|
|
1191
|
+
The rule matches the paths a command line names; a program it does not list as a reader (`python3 -c 'open(".env")'`), a variable it cannot follow, or a glob in a directory part is not caught. See [the agents' shell environment](#the-agents-shell-environment) for what keeps secrets out of the shell in the first place.
|
|
1189
1192
|
A push runs without a hold when all of these hold:
|
|
1190
1193
|
|
|
1191
1194
|
- it does not force (`-f`, `--force*`, a `+` refspec), delete (`--delete`, `-d`, a `:ref` refspec), or push tags or more than its refs (`--tags`, `--follow-tags`, `--mirror`, `--all`, `--prune`), takes no other option than `-u`, `-q`, `-v`, `-n`, `--no-verify`, `--atomic`, `--porcelain`, `--progress` and `-o`, and names no `refs/tags/…` ref or local tag;
|
|
@@ -1203,6 +1206,27 @@ const shell = shellHoldRuleFor({
|
|
|
1203
1206
|
});
|
|
1204
1207
|
```
|
|
1205
1208
|
|
|
1209
|
+
#### The agents' shell environment
|
|
1210
|
+
|
|
1211
|
+
The agents' `bash` does not inherit the host process's environment.
|
|
1212
|
+
It gets the names in `SHELL_ENV_NAMES` (`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LANGUAGE`, `LC_*`, `TZ`, `TERM`, `TMPDIR`, the proxy variables in both cases, `SSL_CERT_FILE`, `SSL_CERT_DIR`, and Pi's `PI_*` session names), and what the host's `shellEnv` adds: `pass`, more host variables by name (a trailing `*` matches a prefix), and `set`, fixed values that win.
|
|
1213
|
+
Without `pass` it is `DEFAULT_SHELL_PASS`, `["GH_TOKEN"]`, so `gh` and a `git push` through `gh auth git-credential` work in the shell; `pass: []` keeps it out.
|
|
1214
|
+
Any other secret the host needs (a bot token, a model login, `DATABASE_URL`) stays in the host process; give a shell one only by naming it:
|
|
1215
|
+
|
|
1216
|
+
```ts
|
|
1217
|
+
export default {
|
|
1218
|
+
// ...
|
|
1219
|
+
shellEnv: { pass: ["GH_TOKEN", "SOME_CLI_KEY"], set: { GIT_PAGER: "cat" } },
|
|
1220
|
+
} satisfies RoundtableConfig;
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
A host composed by hand sets the same object as `RoundtableOptions.environment.shellEnv` (type `ShellEnvConfig`); plugins read it as `context.env.shellEnv` and give it to the shells they run, as the coding plugin's workers do.
|
|
1224
|
+
`childEnv(source, { pass?, set? })` (`pi-roundtable/kit`) builds such an environment for any child process: a plugin that spawns a tool needing a secret passes that one secret, to that tool only.
|
|
1225
|
+
|
|
1226
|
+
Before a tool's result enters the model's context, `maskSecrets` replaces each value of a host variable whose name `SECRET_NAME` matches (`TOKEN`, `SECRET`, `KEY`, `PASS`, `PASSWORD`, `DATABASE_URL`, `JWT`, `CREDENTIAL`, any case) and that is at least 8 characters with `****`; every session runs `secretMaskExtension()` after the other extensions' `tool_result` handlers.
|
|
1227
|
+
|
|
1228
|
+
None of this is a sandbox. The shell runs as the host's user, so a determined command reads what that user can: `/proc/<host pid>/environ`, a file the rule does not know, a token in a program's memory. The whitelist, the `shell.secret-read` hold and the mask stop casual and accidental leaks. To keep secrets from the agents, run their shells under a dedicated account without the `docker` group (membership in it equals root), or use the sandbox package.
|
|
1229
|
+
|
|
1206
1230
|
### `PROMPTS`: prompt texts the owner edits
|
|
1207
1231
|
|
|
1208
1232
|
A prompt that comes from a file or the configuration can be made editable by the owner without touching the file.
|
|
@@ -4519,6 +4543,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
4519
4543
|
| `HoldRule` | `pi-roundtable` | type |
|
|
4520
4544
|
| `HostEnv` | `pi-roundtable` | type |
|
|
4521
4545
|
| `HostEnvironment` | `pi-roundtable` | type |
|
|
4546
|
+
| `ShellEnv` | `pi-roundtable` | type |
|
|
4547
|
+
| `ShellEnvConfig` | `pi-roundtable` | type |
|
|
4522
4548
|
| `HostStoppingError` | `pi-roundtable` | value |
|
|
4523
4549
|
| `HttpRoute` | `pi-roundtable` | type |
|
|
4524
4550
|
| `IDENTITY` | `pi-roundtable` | value |
|
|
@@ -4857,6 +4883,11 @@ Import from the entries listed below; source area files are internal.
|
|
|
4857
4883
|
| `PushPolicy` | `pi-roundtable/kit` | type |
|
|
4858
4884
|
| `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
|
|
4859
4885
|
| `SHELL_TOOLS` | `pi-roundtable/kit` | value |
|
|
4886
|
+
| `SHELL_ENV_NAMES` | `pi-roundtable/kit` | value |
|
|
4887
|
+
| `DEFAULT_SHELL_PASS` | `pi-roundtable/kit` | value |
|
|
4888
|
+
| `DEFAULT_SECRET_PATHS` | `pi-roundtable/kit` | value |
|
|
4889
|
+
| `SECRET_NAME` | `pi-roundtable/kit` | value |
|
|
4890
|
+
| `ShellPolicy` | `pi-roundtable/kit` | type |
|
|
4860
4891
|
| `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
|
|
4861
4892
|
| `createCodexImages` | `pi-roundtable/kit` | value |
|
|
4862
4893
|
| `drawCodexImage` | `pi-roundtable/kit` | value |
|
|
@@ -4955,6 +4986,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
4955
4986
|
| `settleTurn` | `pi-roundtable/kit` | value |
|
|
4956
4987
|
| `shellHoldRule` | `pi-roundtable/kit` | value |
|
|
4957
4988
|
| `shellHoldRuleFor` | `pi-roundtable/kit` | value |
|
|
4989
|
+
| `childEnv` | `pi-roundtable/kit` | value |
|
|
4990
|
+
| `shellChildEnv` | `pi-roundtable/kit` | value |
|
|
4991
|
+
| `maskSecrets` | `pi-roundtable/kit` | value |
|
|
4992
|
+
| `secretMaskExtension` | `pi-roundtable/kit` | value |
|
|
4958
4993
|
| `skillListExtension` | `pi-roundtable/kit` | value |
|
|
4959
4994
|
| `splitReply` | `pi-roundtable/kit` | value |
|
|
4960
4995
|
| `stringList` | `pi-roundtable/kit` | value |
|
package/package.json
CHANGED
|
@@ -141,6 +141,9 @@ export const configSchema = shape({
|
|
|
141
141
|
avatar: optional(text),
|
|
142
142
|
workDir: optional(text),
|
|
143
143
|
scratchDir: optional(text),
|
|
144
|
+
shellEnv: optional(
|
|
145
|
+
shape({ pass: optional(list(text)), set: optional(record(text)) }),
|
|
146
|
+
),
|
|
144
147
|
skills: optional(skillsField),
|
|
145
148
|
memory: optional(bool),
|
|
146
149
|
ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type { PerPrincipalLimits } from "../modules/background/personal-target.ts";
|
|
19
19
|
import type { AgentTools, RoundtablePlugin } from "../plugin.ts";
|
|
20
20
|
import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
|
|
21
|
+
import type { ShellEnvConfig } from "../shared/child-env.ts";
|
|
21
22
|
import type { Tier, TierMembers } from "../speakers.ts";
|
|
22
23
|
import { type AccessConfig, accessOf } from "./access.ts";
|
|
23
24
|
import {
|
|
@@ -77,7 +78,7 @@ export interface BackgroundConfig {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** What `roundtable.config.ts` gives `defineRoundtable`. */
|
|
80
|
-
export interface RoundtableConfig extends LimitsConfig {
|
|
81
|
+
export interface RoundtableConfig extends LimitsConfig, ShellEnvConfig {
|
|
81
82
|
/** The assistant's display name; default "Roundtable". */
|
|
82
83
|
name?: string;
|
|
83
84
|
/**
|
|
@@ -326,6 +326,7 @@ export async function defineRoundtable(
|
|
|
326
326
|
rootCommand: config.slug,
|
|
327
327
|
agentDir: config.agentDir,
|
|
328
328
|
scratchDir: config.scratchDir,
|
|
329
|
+
...(input.shellEnv ? { shellEnv: input.shellEnv } : {}),
|
|
329
330
|
characterSkins: config.characters.skins,
|
|
330
331
|
agentTools: config.agentTools,
|
|
331
332
|
},
|
|
@@ -44,6 +44,7 @@ import { PeerBots } from "./routing/peer-bots.ts";
|
|
|
44
44
|
import { surfacePort } from "./routing/surface-port.ts";
|
|
45
45
|
import { refuseHostAccess } from "./runtime/chat-only.ts";
|
|
46
46
|
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
47
|
+
import { frozenShellEnv } from "./shared/child-env.ts";
|
|
47
48
|
import { setTimeZone } from "./time.ts";
|
|
48
49
|
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
49
50
|
|
|
@@ -133,6 +134,7 @@ export class Linkage {
|
|
|
133
134
|
scratchDir,
|
|
134
135
|
characterSkins,
|
|
135
136
|
agentTools = "full",
|
|
137
|
+
shellEnv,
|
|
136
138
|
} = this.#options.environment ?? {};
|
|
137
139
|
// Checked before anything is applied, so a bad set stops the start with the process untouched.
|
|
138
140
|
const skins = characterSkinsOf(characterSkins);
|
|
@@ -147,6 +149,7 @@ export class Linkage {
|
|
|
147
149
|
...(scratchDir !== undefined ? { scratchDir } : {}),
|
|
148
150
|
characterSkins: skins,
|
|
149
151
|
agentTools,
|
|
152
|
+
...(shellEnv ? { shellEnv: frozenShellEnv(shellEnv) } : {}),
|
|
150
153
|
};
|
|
151
154
|
}
|
|
152
155
|
|
package/src/core/host.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { AgentTools, RoundtablePlugin, Service } from "./plugin.ts";
|
|
|
15
15
|
import type { PeerBotsConfig } from "./routing/peer-bots.ts";
|
|
16
16
|
import { RUNTIME } from "./services.ts";
|
|
17
17
|
import type { ChannelKey } from "./sessions.ts";
|
|
18
|
+
import type { ShellEnvConfig } from "./shared/child-env.ts";
|
|
18
19
|
import type { ToolTierTable } from "./tool-tiers.ts";
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -22,7 +23,7 @@ import type { ToolTierTable } from "./tool-tiers.ts";
|
|
|
22
23
|
* directory its packages read. `run()` applies them to the process when the host starts, and
|
|
23
24
|
* plugins read the locale and zone from `PluginContext.env`.
|
|
24
25
|
*/
|
|
25
|
-
export interface HostEnvironment {
|
|
26
|
+
export interface HostEnvironment extends ShellEnvConfig {
|
|
26
27
|
/** The language of the Discord text; default en. */
|
|
27
28
|
locale?: Locale;
|
|
28
29
|
/** An IANA time zone; default UTC. */
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import type { HoldContext, HoldRule } from "../../holds.ts";
|
|
3
|
-
import type { PushPolicy } from "./git-push.ts";
|
|
4
3
|
import { COMMAND_PREFIX_WORDS, EXPANDED, lexShell } from "./shell-lexer.ts";
|
|
5
4
|
import {
|
|
6
5
|
bindingCounts,
|
|
@@ -22,6 +21,14 @@ import {
|
|
|
22
21
|
shellScript,
|
|
23
22
|
teeOperands,
|
|
24
23
|
} from "./shell-scripts.ts";
|
|
24
|
+
import {
|
|
25
|
+
ENV_PRINTED,
|
|
26
|
+
type ShellPolicy,
|
|
27
|
+
secretFileRead,
|
|
28
|
+
secretPrinted,
|
|
29
|
+
secretReadHeld,
|
|
30
|
+
secretRedirect,
|
|
31
|
+
} from "./shell-secrets.ts";
|
|
25
32
|
import {
|
|
26
33
|
ASSIGNMENT_WORD,
|
|
27
34
|
program,
|
|
@@ -32,6 +39,7 @@ import {
|
|
|
32
39
|
export type { PushPolicy } from "./git-push.ts";
|
|
33
40
|
export { simpleCommands } from "./shell-lexer.ts";
|
|
34
41
|
export { YOLO_CARDED_SHELL_CLASSES } from "./shell-programs.ts";
|
|
42
|
+
export { DEFAULT_SECRET_PATHS, type ShellPolicy } from "./shell-secrets.ts";
|
|
35
43
|
|
|
36
44
|
const MAX_SHOWN_COMMAND = 120;
|
|
37
45
|
|
|
@@ -173,7 +181,7 @@ const ALWAYS_HELD_CLASS: ReadonlyMap<string, ShellClass> = new Map([
|
|
|
173
181
|
function heldReasons(
|
|
174
182
|
words: string[],
|
|
175
183
|
state: LineState,
|
|
176
|
-
policy:
|
|
184
|
+
policy: ShellPolicy,
|
|
177
185
|
): Held[] {
|
|
178
186
|
const found: Held[] = [];
|
|
179
187
|
for (let i = 0; i < words.length; i++) {
|
|
@@ -186,6 +194,9 @@ function heldReasons(
|
|
|
186
194
|
class: deviceTarget(target, state) ? "shell.disk" : "shell.write",
|
|
187
195
|
});
|
|
188
196
|
}
|
|
197
|
+
const secret =
|
|
198
|
+
op === "<" ? secretRedirect(words[i + 1], state, policy) : undefined;
|
|
199
|
+
if (secret) found.push(secret);
|
|
189
200
|
}
|
|
190
201
|
return [...found, ...programHeld(words, state, policy)];
|
|
191
202
|
}
|
|
@@ -194,7 +205,7 @@ function heldReasons(
|
|
|
194
205
|
function programHeld(
|
|
195
206
|
words: string[],
|
|
196
207
|
state: LineState,
|
|
197
|
-
policy:
|
|
208
|
+
policy: ShellPolicy,
|
|
198
209
|
): Held[] {
|
|
199
210
|
const prefix: string[] = [];
|
|
200
211
|
// A redirection is no part of the command: not its operator nor its target.
|
|
@@ -207,11 +218,12 @@ function programHeld(
|
|
|
207
218
|
if (setsSelfRunningVariable(own) || prefix.includes("env -S"))
|
|
208
219
|
return [DYNAMIC_SCRIPT];
|
|
209
220
|
const [word, ...args] = argv;
|
|
210
|
-
// `xargs env` runs what its input says.
|
|
211
|
-
if (!word)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
// `xargs env` runs what its input says, and an `env` with no command prints the environment.
|
|
222
|
+
if (!word) {
|
|
223
|
+
if (prefix.includes("xargs") && prefix.at(-1) !== "xargs")
|
|
224
|
+
return [DYNAMIC_SCRIPT];
|
|
225
|
+
return prefix.includes("env") ? [ENV_PRINTED] : [];
|
|
226
|
+
}
|
|
215
227
|
const head = commandName(word, state);
|
|
216
228
|
if (head === undefined) {
|
|
217
229
|
// A variable the line binds may be empty when bash gets there, so the name is unknown; what
|
|
@@ -277,6 +289,9 @@ function programHeld(
|
|
|
277
289
|
const exec = findExec(args);
|
|
278
290
|
if (exec) return programHeld(exec, state, policy);
|
|
279
291
|
}
|
|
292
|
+
const secret =
|
|
293
|
+
secretPrinted(name, args) ?? secretFileRead(name, args, state, policy);
|
|
294
|
+
if (secret) return [secret];
|
|
280
295
|
const held = verbHeld(name, args, prefix, state, policy);
|
|
281
296
|
return held ? [held] : [];
|
|
282
297
|
}
|
|
@@ -307,7 +322,7 @@ const UNREADABLE: Held = {
|
|
|
307
322
|
function scriptHeld(
|
|
308
323
|
script: string,
|
|
309
324
|
state: LineState,
|
|
310
|
-
policy:
|
|
325
|
+
policy: ShellPolicy,
|
|
311
326
|
): Held[] {
|
|
312
327
|
if (script.includes(EXPANDED)) return [DYNAMIC_SCRIPT];
|
|
313
328
|
if (script.trimStart().startsWith("$")) {
|
|
@@ -328,7 +343,7 @@ const DYNAMIC_SCRIPT: Held = {
|
|
|
328
343
|
function commandHeld(
|
|
329
344
|
command: string,
|
|
330
345
|
outer: LineState,
|
|
331
|
-
policy:
|
|
346
|
+
policy: ShellPolicy,
|
|
332
347
|
): Held[] {
|
|
333
348
|
const state: LineState = {
|
|
334
349
|
...lineState(command, outer.workspace, undefined),
|
|
@@ -368,13 +383,13 @@ function commandHeld(
|
|
|
368
383
|
/**
|
|
369
384
|
* Pi's shell and file tools: a bash command that is destructive or reaches outside the scratch
|
|
370
385
|
* roots (the shared workspace and the scratch dir), and a write or edit outside them, wait for the
|
|
371
|
-
* owner. Reading
|
|
386
|
+
* owner. Reading runs, but for a file that holds a secret (`shell.secret-read`).
|
|
372
387
|
*/
|
|
373
388
|
export function shellActionNeedingConfirmation(
|
|
374
389
|
toolName: string,
|
|
375
390
|
input: Record<string, unknown>,
|
|
376
391
|
context: HoldContext & { workspace: string },
|
|
377
|
-
policy:
|
|
392
|
+
policy: ShellPolicy = {},
|
|
378
393
|
): string | undefined {
|
|
379
394
|
return shellHold(toolName, input, context, policy)?.action;
|
|
380
395
|
}
|
|
@@ -387,7 +402,7 @@ function shellHold(
|
|
|
387
402
|
toolName: string,
|
|
388
403
|
input: Record<string, unknown>,
|
|
389
404
|
context: HoldContext & { workspace: string },
|
|
390
|
-
policy:
|
|
405
|
+
policy: ShellPolicy,
|
|
391
406
|
): { action: string; classes: ShellClass[] } | undefined {
|
|
392
407
|
try {
|
|
393
408
|
return judge(toolName, input, context, policy);
|
|
@@ -412,7 +427,7 @@ function judge(
|
|
|
412
427
|
toolName: string,
|
|
413
428
|
input: Record<string, unknown>,
|
|
414
429
|
context: HoldContext & { workspace: string },
|
|
415
|
-
policy:
|
|
430
|
+
policy: ShellPolicy,
|
|
416
431
|
): { action: string; classes: ShellClass[] } | undefined {
|
|
417
432
|
const state = lineState(
|
|
418
433
|
typeof input.command === "string" ? input.command : "",
|
|
@@ -439,17 +454,18 @@ function judge(
|
|
|
439
454
|
classes: ["shell.write"],
|
|
440
455
|
};
|
|
441
456
|
}
|
|
442
|
-
return undefined;
|
|
457
|
+
return toolName === "read" ? secretReadHeld(input, state, policy) : undefined;
|
|
443
458
|
}
|
|
444
459
|
|
|
445
460
|
/**
|
|
446
461
|
* The shell rule with a push policy: a plain push to a repository of `ownPushOwners`, other than
|
|
447
|
-
* `heldPushRepos`, runs without a hold. Shell calls of a session without a workspace have no shell.
|
|
462
|
+
* `heldPushRepos`, runs without a hold; `secretPaths` adds files whose reading is held. Shell calls of a session without a workspace have no shell.
|
|
448
463
|
*/
|
|
449
|
-
export function shellHoldRuleFor(policy:
|
|
450
|
-
const frozen:
|
|
464
|
+
export function shellHoldRuleFor(policy: ShellPolicy = {}): Readonly<HoldRule> {
|
|
465
|
+
const frozen: ShellPolicy = {
|
|
451
466
|
ownPushOwners: [...(policy.ownPushOwners ?? [])],
|
|
452
467
|
heldPushRepos: [...(policy.heldPushRepos ?? [])],
|
|
468
|
+
...(policy.secretPaths ? { secretPaths: [...policy.secretPaths] } : {}),
|
|
453
469
|
};
|
|
454
470
|
return Object.freeze<HoldRule>({
|
|
455
471
|
name: "shell",
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { assistantName } from "../../i18n/index.ts";
|
|
2
|
-
import { ownPush
|
|
2
|
+
import { ownPush } from "./git-push.ts";
|
|
3
3
|
import { deviceTarget, type LineState, writable } from "./shell-paths.ts";
|
|
4
|
+
import {
|
|
5
|
+
ghTokenPrinted,
|
|
6
|
+
gitCredentialRead,
|
|
7
|
+
type ShellPolicy,
|
|
8
|
+
} from "./shell-secrets.ts";
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* The hold classes of the shell rule, as `shell.<class>` ids that `HoldRule.holdClasses` lists for
|
|
7
12
|
* every command a call holds. Yolo mode approves `shell.write` (a write outside the scratch roots)
|
|
8
13
|
* and `shell.push` (a plain `git push` the push policy holds), and cards the rest unless
|
|
9
14
|
* `autonomy.yolo.allow` lists them. `shell.unreadable` is a line the rule could not read to the
|
|
10
|
-
* end, so it holds what it cannot tell apart from a destructive command.
|
|
15
|
+
* end, so it holds what it cannot tell apart from a destructive command. `shell.secret-read` prints
|
|
16
|
+
* the environment or reads a file that holds a secret.
|
|
11
17
|
*/
|
|
12
18
|
export const SHELL_CLASSES = Object.freeze([
|
|
13
19
|
"shell.privilege",
|
|
@@ -25,6 +31,7 @@ export const SHELL_CLASSES = Object.freeze([
|
|
|
25
31
|
"shell.reset-hard",
|
|
26
32
|
"shell.gh",
|
|
27
33
|
"shell.crontab",
|
|
34
|
+
"shell.secret-read",
|
|
28
35
|
"shell.unreadable",
|
|
29
36
|
] as const);
|
|
30
37
|
|
|
@@ -59,7 +66,19 @@ const SYSTEMD_VERBS = new Set([
|
|
|
59
66
|
"kill",
|
|
60
67
|
"isolate",
|
|
61
68
|
]);
|
|
62
|
-
|
|
69
|
+
/** Docker verbs that stop or remove, and those that run a command or copy files as the daemon's root. */
|
|
70
|
+
const DOCKER_VERBS = new Set([
|
|
71
|
+
"stop",
|
|
72
|
+
"kill",
|
|
73
|
+
"restart",
|
|
74
|
+
"rm",
|
|
75
|
+
"rmi",
|
|
76
|
+
"prune",
|
|
77
|
+
"run",
|
|
78
|
+
"exec",
|
|
79
|
+
"create",
|
|
80
|
+
"cp",
|
|
81
|
+
]);
|
|
63
82
|
const DOCKER_OBJECTS = new Set([
|
|
64
83
|
"container",
|
|
65
84
|
"image",
|
|
@@ -68,7 +87,16 @@ const DOCKER_OBJECTS = new Set([
|
|
|
68
87
|
"system",
|
|
69
88
|
"builder",
|
|
70
89
|
]);
|
|
71
|
-
const COMPOSE_VERBS = new Set([
|
|
90
|
+
const COMPOSE_VERBS = new Set([
|
|
91
|
+
"down",
|
|
92
|
+
"stop",
|
|
93
|
+
"kill",
|
|
94
|
+
"restart",
|
|
95
|
+
"rm",
|
|
96
|
+
"run",
|
|
97
|
+
"exec",
|
|
98
|
+
"cp",
|
|
99
|
+
]);
|
|
72
100
|
const PACKAGE_VERBS = new Set([
|
|
73
101
|
"install",
|
|
74
102
|
"reinstall",
|
|
@@ -98,7 +126,7 @@ export function verbHeld(
|
|
|
98
126
|
args: string[],
|
|
99
127
|
prefix: string[],
|
|
100
128
|
state: LineState,
|
|
101
|
-
policy:
|
|
129
|
+
policy: ShellPolicy,
|
|
102
130
|
): Held | undefined {
|
|
103
131
|
const verbs = args.filter((a) => !a.startsWith("-"));
|
|
104
132
|
const because = (
|
|
@@ -177,6 +205,8 @@ export function verbHeld(
|
|
|
177
205
|
case "git":
|
|
178
206
|
return gitHeld(args, prefix, state, policy);
|
|
179
207
|
case "gh": {
|
|
208
|
+
const token = ghTokenPrinted(args);
|
|
209
|
+
if (token) return token;
|
|
180
210
|
const reason = ghHeld(args);
|
|
181
211
|
return reason ? { reason, class: "shell.gh" } : undefined;
|
|
182
212
|
}
|
|
@@ -203,9 +233,11 @@ function gitHeld(
|
|
|
203
233
|
args: string[],
|
|
204
234
|
prefix: string[],
|
|
205
235
|
state: LineState,
|
|
206
|
-
policy:
|
|
236
|
+
policy: ShellPolicy,
|
|
207
237
|
): Held | undefined {
|
|
208
238
|
const [globals, [verb, ...rest]] = gitCommand(args);
|
|
239
|
+
const credential = gitCredentialRead(verb, rest);
|
|
240
|
+
if (credential) return credential;
|
|
209
241
|
if (verb === "push") {
|
|
210
242
|
const plain = !prefix.some((w) => w.startsWith("GIT_"));
|
|
211
243
|
if (plain && ownPush(globals, rest, state, policy)) return undefined;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { PushPolicy } from "./git-push.ts";
|
|
4
|
+
import { type LineState, resolvePath } from "./shell-paths.ts";
|
|
5
|
+
import type { Held, ShellClass } from "./shell-programs.ts";
|
|
6
|
+
|
|
7
|
+
/** What the shell rule lets run: its push policy, and the files that hold secrets. */
|
|
8
|
+
export interface ShellPolicy extends PushPolicy {
|
|
9
|
+
/**
|
|
10
|
+
* More files whose reading is held as `shell.secret-read`, on top of `DEFAULT_SECRET_PATHS`, as
|
|
11
|
+
* the same globs: a pattern with no `/` matches a file name anywhere, `~/` is the home
|
|
12
|
+
* directory, `**` crosses directories, and a leading `!` lets a match run.
|
|
13
|
+
*/
|
|
14
|
+
secretPaths?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The files whose reading is held as `shell.secret-read`: environment files, ssh and gh logins, git,
|
|
19
|
+
* curl and PostgreSQL credentials, Pi's model logins, and a process's environment. Example env
|
|
20
|
+
* files (`.env.example`) run.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_SECRET_PATHS: readonly string[] = Object.freeze([
|
|
23
|
+
"/proc/*/environ",
|
|
24
|
+
"/proc/self/environ",
|
|
25
|
+
"*.env",
|
|
26
|
+
".env*",
|
|
27
|
+
"!.env.example",
|
|
28
|
+
"!.env.sample",
|
|
29
|
+
"!.env.template",
|
|
30
|
+
"~/.ssh/**",
|
|
31
|
+
"~/.config/gh/**",
|
|
32
|
+
".git-credentials",
|
|
33
|
+
".netrc",
|
|
34
|
+
".pgpass",
|
|
35
|
+
"~/.pi/**/auth.json",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const SECRET_READ: ShellClass = "shell.secret-read";
|
|
39
|
+
|
|
40
|
+
/** A glob as a regular expression over a whole path, or over a file name when it has no `/`. */
|
|
41
|
+
function globPattern(glob: string): { name: boolean; pattern: RegExp } {
|
|
42
|
+
const name = !glob.includes("/");
|
|
43
|
+
const path = glob.startsWith("~/")
|
|
44
|
+
? `${homedir()}${glob.slice(1)}`
|
|
45
|
+
: name || glob.startsWith("/")
|
|
46
|
+
? glob
|
|
47
|
+
: `**/${glob}`;
|
|
48
|
+
let source = "";
|
|
49
|
+
for (let i = 0; i < path.length; i++) {
|
|
50
|
+
const ch = path[i] as string;
|
|
51
|
+
// A trailing `/**` takes the directory itself too, as `tar ~/.ssh` reads all of it.
|
|
52
|
+
if (i === path.length - 3 && path.endsWith("/**")) {
|
|
53
|
+
source += "(?:/.*)?";
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (path.startsWith("**/", i)) {
|
|
57
|
+
source += "(?:.*/)?";
|
|
58
|
+
i += 2;
|
|
59
|
+
} else if (path.startsWith("**", i)) {
|
|
60
|
+
source += ".*";
|
|
61
|
+
i += 1;
|
|
62
|
+
} else if (ch === "*") source += "[^/]*";
|
|
63
|
+
else if (ch === "?") source += "[^/]";
|
|
64
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
65
|
+
}
|
|
66
|
+
return { name, pattern: new RegExp(`^${source}$`) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface SecretGlobs {
|
|
70
|
+
held: { name: boolean; pattern: RegExp }[];
|
|
71
|
+
allowed: { name: boolean; pattern: RegExp }[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const compiled = new WeakMap<readonly string[], SecretGlobs>();
|
|
75
|
+
|
|
76
|
+
function secretGlobs(extra: readonly string[] | undefined): SecretGlobs {
|
|
77
|
+
const key = extra ?? DEFAULT_SECRET_PATHS;
|
|
78
|
+
const known = compiled.get(key);
|
|
79
|
+
if (known) return known;
|
|
80
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
|
81
|
+
const globs = [
|
|
82
|
+
...DEFAULT_SECRET_PATHS,
|
|
83
|
+
...(agentDir ? [`${agentDir.replace(/\/+$/, "")}/auth.json`] : []),
|
|
84
|
+
...(extra ?? []),
|
|
85
|
+
];
|
|
86
|
+
const made: SecretGlobs = {
|
|
87
|
+
held: globs.filter((g) => !g.startsWith("!")).map(globPattern),
|
|
88
|
+
allowed: globs
|
|
89
|
+
.filter((g) => g.startsWith("!"))
|
|
90
|
+
.map((g) => globPattern(g.slice(1))),
|
|
91
|
+
};
|
|
92
|
+
compiled.set(key, made);
|
|
93
|
+
return made;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a path matches one of the globs. */
|
|
97
|
+
function matchesAny(
|
|
98
|
+
path: string,
|
|
99
|
+
globs: readonly { name: boolean; pattern: RegExp }[],
|
|
100
|
+
): boolean {
|
|
101
|
+
return globs.some(({ name, pattern }) =>
|
|
102
|
+
pattern.test(name ? basename(path) : path),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The paths an argument may name: itself, the value of `--opt=value`, and a curl `@file` or `field=@file`. */
|
|
107
|
+
function candidates(arg: string): string[] {
|
|
108
|
+
const found = [arg];
|
|
109
|
+
const eq = arg.indexOf("=");
|
|
110
|
+
if (arg.startsWith("-") && eq !== -1) found.push(arg.slice(eq + 1));
|
|
111
|
+
const at = arg.indexOf("@");
|
|
112
|
+
if (at !== -1 && (at === 0 || arg[at - 1] === "=" || arg.startsWith("-")))
|
|
113
|
+
found.push(arg.slice(at + 1).replace(/;.*$/, ""));
|
|
114
|
+
return found.filter((path) => path !== "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The secret file an argument names, or undefined. */
|
|
118
|
+
export function secretFile(
|
|
119
|
+
arg: string,
|
|
120
|
+
state: LineState,
|
|
121
|
+
policy: ShellPolicy,
|
|
122
|
+
): string | undefined {
|
|
123
|
+
const globs = secretGlobs(policy.secretPaths);
|
|
124
|
+
for (const word of candidates(arg)) {
|
|
125
|
+
const path = resolvePath(word, state);
|
|
126
|
+
if (path === undefined) continue;
|
|
127
|
+
if (matchesAny(path, globs.held) && !matchesAny(path, globs.allowed))
|
|
128
|
+
return word;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Why a redirect from `source` (`< file`) is held: it reads a secret file. */
|
|
134
|
+
export function secretRedirect(
|
|
135
|
+
source: string | undefined,
|
|
136
|
+
state: LineState,
|
|
137
|
+
policy: ShellPolicy,
|
|
138
|
+
): Held | undefined {
|
|
139
|
+
const file =
|
|
140
|
+
source === undefined ? undefined : secretFile(source, state, policy);
|
|
141
|
+
return file === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: { reason: `reads the secret file ${file}`, class: SECRET_READ };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What a call of Pi's read tool is held for: a file that holds a secret. */
|
|
147
|
+
export function secretReadHeld(
|
|
148
|
+
input: Record<string, unknown>,
|
|
149
|
+
state: LineState,
|
|
150
|
+
policy: ShellPolicy,
|
|
151
|
+
): { action: string; classes: ShellClass[] } | undefined {
|
|
152
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
153
|
+
return secretFile(path.replace(/^@/, ""), state, policy) === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: {
|
|
156
|
+
action: `read the secret file ${path} on the host`,
|
|
157
|
+
classes: [SECRET_READ],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Why an `env` with no command is held: it prints the environment. */
|
|
162
|
+
export const ENV_PRINTED: Held = Object.freeze({
|
|
163
|
+
reason: "prints the environment",
|
|
164
|
+
class: SECRET_READ,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/** Programs that read the files they are given, or send them on: their path arguments are checked. */
|
|
168
|
+
const READERS = new Set([
|
|
169
|
+
"cat",
|
|
170
|
+
"tac",
|
|
171
|
+
"less",
|
|
172
|
+
"more",
|
|
173
|
+
"most",
|
|
174
|
+
"bat",
|
|
175
|
+
"batcat",
|
|
176
|
+
"head",
|
|
177
|
+
"tail",
|
|
178
|
+
"nl",
|
|
179
|
+
"grep",
|
|
180
|
+
"egrep",
|
|
181
|
+
"fgrep",
|
|
182
|
+
"rg",
|
|
183
|
+
"ag",
|
|
184
|
+
"sed",
|
|
185
|
+
"awk",
|
|
186
|
+
"gawk",
|
|
187
|
+
"mawk",
|
|
188
|
+
"cut",
|
|
189
|
+
"sort",
|
|
190
|
+
"uniq",
|
|
191
|
+
"paste",
|
|
192
|
+
"diff",
|
|
193
|
+
"cmp",
|
|
194
|
+
"od",
|
|
195
|
+
"xxd",
|
|
196
|
+
"hexdump",
|
|
197
|
+
"strings",
|
|
198
|
+
"base64",
|
|
199
|
+
"base32",
|
|
200
|
+
"cp",
|
|
201
|
+
"mv",
|
|
202
|
+
"rsync",
|
|
203
|
+
"scp",
|
|
204
|
+
"tar",
|
|
205
|
+
"zip",
|
|
206
|
+
"gzip",
|
|
207
|
+
"bzip2",
|
|
208
|
+
"xz",
|
|
209
|
+
"zstd",
|
|
210
|
+
"7z",
|
|
211
|
+
"curl",
|
|
212
|
+
"wget",
|
|
213
|
+
"jq",
|
|
214
|
+
"yq",
|
|
215
|
+
"source",
|
|
216
|
+
".",
|
|
217
|
+
"vi",
|
|
218
|
+
"vim",
|
|
219
|
+
"view",
|
|
220
|
+
"nano",
|
|
221
|
+
"openssl",
|
|
222
|
+
"gpg",
|
|
223
|
+
"split",
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
/** Why a read of a secret file by `name` with `args` is held, or undefined. */
|
|
227
|
+
export function secretFileRead(
|
|
228
|
+
name: string,
|
|
229
|
+
args: readonly string[],
|
|
230
|
+
state: LineState,
|
|
231
|
+
policy: ShellPolicy,
|
|
232
|
+
): Held | undefined {
|
|
233
|
+
if (!READERS.has(name)) return undefined;
|
|
234
|
+
for (const arg of args) {
|
|
235
|
+
const file = secretFile(arg, state, policy);
|
|
236
|
+
if (file !== undefined)
|
|
237
|
+
return { reason: `reads the secret file ${file}`, class: SECRET_READ };
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Why a command that prints the environment, or a stored credential, is held; undefined when it does not. */
|
|
243
|
+
export function secretPrinted(
|
|
244
|
+
name: string,
|
|
245
|
+
args: readonly string[],
|
|
246
|
+
): Held | undefined {
|
|
247
|
+
const flags = args.filter((a) => a.startsWith("-"));
|
|
248
|
+
const operands = args.filter((a) => !a.startsWith("-"));
|
|
249
|
+
const prints = (reason: string): Held => ({ reason, class: SECRET_READ });
|
|
250
|
+
switch (name) {
|
|
251
|
+
case "printenv":
|
|
252
|
+
return prints("prints the environment");
|
|
253
|
+
case "export":
|
|
254
|
+
return operands.length === 0 || flags.includes("-p")
|
|
255
|
+
? prints("prints the environment")
|
|
256
|
+
: undefined;
|
|
257
|
+
case "declare":
|
|
258
|
+
case "typeset":
|
|
259
|
+
return flags.some((f) => /^-[A-Za-z]*p/.test(f)) ||
|
|
260
|
+
(operands.length === 0 && !flags.some((f) => /^-[A-Za-z]*[fF]/.test(f)))
|
|
261
|
+
? prints("prints the variables")
|
|
262
|
+
: undefined;
|
|
263
|
+
case "set":
|
|
264
|
+
return args.length === 0 ? prints("prints the variables") : undefined;
|
|
265
|
+
case "compgen":
|
|
266
|
+
return args.some(
|
|
267
|
+
(a, i) =>
|
|
268
|
+
a === "-e" ||
|
|
269
|
+
a === "-v" ||
|
|
270
|
+
(args[i - 1] === "-A" && (a === "variable" || a === "export")),
|
|
271
|
+
)
|
|
272
|
+
? prints("lists the environment")
|
|
273
|
+
: undefined;
|
|
274
|
+
default:
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Why a `gh` command that prints its token is held. */
|
|
280
|
+
export function ghTokenPrinted(args: readonly string[]): Held | undefined {
|
|
281
|
+
const [group, verb] = args.filter((a) => !a.startsWith("-"));
|
|
282
|
+
if (group !== "auth") return undefined;
|
|
283
|
+
if (verb === "token")
|
|
284
|
+
return { reason: "prints the GitHub token", class: SECRET_READ };
|
|
285
|
+
if (
|
|
286
|
+
verb === "status" &&
|
|
287
|
+
args.some((a) => a === "--show-token" || /^-[A-Za-z]*t/.test(a))
|
|
288
|
+
)
|
|
289
|
+
return { reason: "prints the GitHub token", class: SECRET_READ };
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Why a git subcommand that reads stored credentials is held: `git credential`, a credential helper, `git config` of `credential.*`. */
|
|
294
|
+
export function gitCredentialRead(
|
|
295
|
+
verb: string | undefined,
|
|
296
|
+
rest: readonly string[],
|
|
297
|
+
): Held | undefined {
|
|
298
|
+
if (verb?.startsWith("credential"))
|
|
299
|
+
return { reason: "reads git credentials", class: SECRET_READ };
|
|
300
|
+
if (
|
|
301
|
+
verb === "config" &&
|
|
302
|
+
rest.some((a) => /credential/i.test(a) || a === "-l" || a === "--list")
|
|
303
|
+
)
|
|
304
|
+
return { reason: "reads git credentials", class: SECRET_READ };
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
package/src/core/plugin.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
SessionTool,
|
|
36
36
|
ToolSelection,
|
|
37
37
|
} from "./sessions.ts";
|
|
38
|
+
import type { ShellEnv } from "./shared/child-env.ts";
|
|
38
39
|
import type { Speaker, Tier } from "./speakers.ts";
|
|
39
40
|
import type { ToolTiers } from "./tool-tiers.ts";
|
|
40
41
|
|
|
@@ -323,6 +324,8 @@ export interface HostEnv {
|
|
|
323
324
|
* plugin that declares `hostAccess`.
|
|
324
325
|
*/
|
|
325
326
|
readonly agentTools: AgentTools;
|
|
327
|
+
/** What the agents' shell gets of the host's environment besides `SHELL_ENV_NAMES`; plugins that run shells of their own (coding workers) give them the same. */
|
|
328
|
+
readonly shellEnv?: ShellEnv;
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
/** What the host gives every plugin. */
|
|
@@ -200,6 +200,7 @@ export function runtimePlugin(
|
|
|
200
200
|
...turnPolicy(options),
|
|
201
201
|
sessions: context.sessions,
|
|
202
202
|
agentTools: context.env.agentTools,
|
|
203
|
+
...(context.env.shellEnv ? { shellEnv: context.env.shellEnv } : {}),
|
|
203
204
|
agentDir: options.agentDir,
|
|
204
205
|
modelRuntime: options.modelRuntime,
|
|
205
206
|
dataDir: options.dataDir,
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
type SessionPlan,
|
|
25
25
|
type SessionTool,
|
|
26
26
|
} from "../sessions.ts";
|
|
27
|
+
import type { ShellEnv } from "../shared/child-env.ts";
|
|
28
|
+
import { secretMaskExtension } from "../shared/secret-mask.ts";
|
|
27
29
|
import type { ToolTiers } from "../tool-tiers.ts";
|
|
28
30
|
import type { MemoryDraws } from "./extensions/private-memory.ts";
|
|
29
31
|
import type { PendingConfirmationStore } from "./pending-confirmation-store.ts";
|
|
@@ -77,6 +79,8 @@ export interface PiAgentRuntimeOptions {
|
|
|
77
79
|
agents?: AgentSessions;
|
|
78
80
|
/** "chat-only": no turn carries the shell and file tools, and agent sessions have no workspace; default "full". */
|
|
79
81
|
agentTools?: AgentTools;
|
|
82
|
+
/** What the sessions' shell gets of the host's environment besides `SHELL_ENV_NAMES`; see `HostEnvironment.shellEnv`. */
|
|
83
|
+
shellEnv?: ShellEnv;
|
|
80
84
|
/**
|
|
81
85
|
* The prompts of a channel for an interactive turn's scope, made from its speaker and its
|
|
82
86
|
* conversation's visibility; undefined for a channel that cannot show them. Without it every
|
|
@@ -171,7 +175,7 @@ export interface CoreExtensions {
|
|
|
171
175
|
* A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
|
|
172
176
|
* and compact guard, the compactor, the MCP phase, the memory projection, so each request is
|
|
173
177
|
* projected after every other extension changed it, the surface's formatting guidance, so it follows
|
|
174
|
-
* the persona and everything appended before it, and active-tools last, so its
|
|
178
|
+
* the persona and everything appended before it, the secret mask, and active-tools last, so its
|
|
175
179
|
* before_agent_start handler runs after every other extension's.
|
|
176
180
|
*/
|
|
177
181
|
export function sessionExtensions(
|
|
@@ -202,6 +206,8 @@ export function sessionExtensions(
|
|
|
202
206
|
...contributed(plan.mcp),
|
|
203
207
|
{ name: "private-memory", factory: core.privateMemory },
|
|
204
208
|
{ name: "surface-formatting", factory: core.surfaceFormatting },
|
|
209
|
+
// After every other tool_result handler, so what they add is masked too.
|
|
210
|
+
{ name: "secret-mask", factory: secretMaskExtension() },
|
|
205
211
|
{ name: "active-tools", factory: core.activeTools },
|
|
206
212
|
];
|
|
207
213
|
}
|
|
@@ -462,10 +462,10 @@ export class SessionFactory {
|
|
|
462
462
|
const { session } = await createAgentSession({
|
|
463
463
|
cwd,
|
|
464
464
|
agentDir,
|
|
465
|
-
// Pi's bash under its own name,
|
|
466
|
-
|
|
467
|
-
?
|
|
468
|
-
:
|
|
465
|
+
// Pi's bash under its own name, with the shell's environment and TMPDIR at the agents' scratch dir.
|
|
466
|
+
customTools: this.chatOnly
|
|
467
|
+
? []
|
|
468
|
+
: [scratchBash(cwd, agents?.scratchDir, this.#options.shellEnv)],
|
|
469
469
|
thinkingLevel: thinking,
|
|
470
470
|
modelRuntime: this.#modelRuntime,
|
|
471
471
|
resourceLoader,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type ToolDefinition,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { ImageDrawer } from "../contract/providers.ts";
|
|
6
|
+
import { type ShellEnv, shellChildEnv } from "../shared/child-env.ts";
|
|
6
7
|
import { generateImageExtension } from "../shared/generate-image-tool.ts";
|
|
7
8
|
import type { WorkspaceRoots } from "../shared/workspace-files.ts";
|
|
8
9
|
import type { CoreExtensions } from "./runtime-types.ts";
|
|
@@ -15,13 +16,24 @@ export function imageTools(
|
|
|
15
16
|
return images ? { generateImage: generateImageExtension(images, roots) } : {};
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Pi's bash tool with the host's environment cut to `SHELL_ENV_NAMES` and `shellEnv` (see `shellChildEnv`), and TMPDIR
|
|
21
|
+
* set to `scratchDir` when there is one, so tools that read TMPDIR and GNU `mktemp` write there
|
|
22
|
+
* (BSD `mktemp` needs `-p "$TMPDIR"`).
|
|
23
|
+
*/
|
|
24
|
+
export function scratchBash(
|
|
25
|
+
cwd: string,
|
|
26
|
+
scratchDir: string | undefined,
|
|
27
|
+
shellEnv: ShellEnv = {},
|
|
28
|
+
): ToolDefinition {
|
|
20
29
|
// SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
|
|
21
30
|
return createBashToolDefinition(cwd, {
|
|
22
31
|
spawnHook: (context) => ({
|
|
23
32
|
...context,
|
|
24
|
-
env: {
|
|
33
|
+
env: {
|
|
34
|
+
...shellChildEnv(context.env, shellEnv),
|
|
35
|
+
...(scratchDir === undefined ? {} : { TMPDIR: scratchDir }),
|
|
36
|
+
},
|
|
25
37
|
}),
|
|
26
38
|
}) as unknown as ToolDefinition;
|
|
27
39
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an agent's shell and the host's other child processes get of the host's environment: the
|
|
5
|
+
* names on a whitelist, and nothing else. Secrets the host needs stay in the host process; a child
|
|
6
|
+
* that needs one gets it from code, through `pass` or `set`, for that child only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Extra names and fixed values for the agents' shells, on top of `SHELL_ENV_NAMES`. */
|
|
10
|
+
export interface ShellEnv {
|
|
11
|
+
/** Host variables the shell gets too, by exact name (`LC_*`-style trailing `*` matches a prefix). */
|
|
12
|
+
pass?: readonly string[];
|
|
13
|
+
/** Fixed values the shell gets, set after the passed names, so they win. */
|
|
14
|
+
set?: Readonly<Record<string, string>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The `shellEnv` option of the host's environment and of `roundtable.config.ts`. */
|
|
18
|
+
export interface ShellEnvConfig {
|
|
19
|
+
/**
|
|
20
|
+
* What the agents' shell gets of the host's environment besides `SHELL_ENV_NAMES` (the user,
|
|
21
|
+
* locale, terminal, paths and proxies): host variables to `pass` by name and fixed values to
|
|
22
|
+
* `set`. Plugins read it as `env.shellEnv` for shells of their own. Default: `pass` is
|
|
23
|
+
* `DEFAULT_SHELL_PASS` (GH_TOKEN, so gh and git in the shell keep working); give `pass` to
|
|
24
|
+
* replace it, `pass: []` for no host secret in the shell at all.
|
|
25
|
+
*/
|
|
26
|
+
shellEnv?: ShellEnv;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The host variables every child gets: the user, the locale and the terminal, the paths, proxies
|
|
31
|
+
* and certificates; `LC_*` is every locale category. Pi's own `PI_*` session names (the model and
|
|
32
|
+
* session the shell runs in) carry no secret, so the shell keeps them.
|
|
33
|
+
*/
|
|
34
|
+
export const SHELL_ENV_NAMES: readonly string[] = Object.freeze([
|
|
35
|
+
"PATH",
|
|
36
|
+
"HOME",
|
|
37
|
+
"USER",
|
|
38
|
+
"LOGNAME",
|
|
39
|
+
"SHELL",
|
|
40
|
+
"LANG",
|
|
41
|
+
"LANGUAGE",
|
|
42
|
+
"LC_*",
|
|
43
|
+
"TZ",
|
|
44
|
+
"TERM",
|
|
45
|
+
"TMPDIR",
|
|
46
|
+
"HTTP_PROXY",
|
|
47
|
+
"HTTPS_PROXY",
|
|
48
|
+
"NO_PROXY",
|
|
49
|
+
"http_proxy",
|
|
50
|
+
"https_proxy",
|
|
51
|
+
"no_proxy",
|
|
52
|
+
"SSL_CERT_FILE",
|
|
53
|
+
"SSL_CERT_DIR",
|
|
54
|
+
"PI_SESSION_ID",
|
|
55
|
+
"PI_SESSION_FILE",
|
|
56
|
+
"PI_PROVIDER",
|
|
57
|
+
"PI_MODEL",
|
|
58
|
+
"PI_REASONING_LEVEL",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function matches(name: string, pattern: string): boolean {
|
|
62
|
+
return pattern.endsWith("*")
|
|
63
|
+
? name.startsWith(pattern.slice(0, -1))
|
|
64
|
+
: name === pattern;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The environment of a child process: the variables of `source` that `SHELL_ENV_NAMES` or `pass`
|
|
69
|
+
* names, then `set`.
|
|
70
|
+
*/
|
|
71
|
+
export function childEnv(
|
|
72
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
73
|
+
options: ShellEnv = {},
|
|
74
|
+
): Record<string, string> {
|
|
75
|
+
const names = [...SHELL_ENV_NAMES, ...(options.pass ?? [])];
|
|
76
|
+
const env: Record<string, string> = {};
|
|
77
|
+
for (const [name, value] of Object.entries(source))
|
|
78
|
+
if (value !== undefined && names.some((pattern) => matches(name, pattern)))
|
|
79
|
+
env[name] = value;
|
|
80
|
+
return { ...env, ...options.set };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
84
|
+
|
|
85
|
+
/** A copy of `shellEnv` that later changes to the caller's object do not reach; a name that is not a variable name stops the start. */
|
|
86
|
+
export function frozenShellEnv(shellEnv: ShellEnv): Readonly<ShellEnv> {
|
|
87
|
+
for (const name of shellEnv.pass ?? [])
|
|
88
|
+
if (!ENV_NAME.test(name.endsWith("*") ? name.slice(0, -1) : name))
|
|
89
|
+
throw new ConfigError(`shellEnv.pass: "${name}" is not a variable name`);
|
|
90
|
+
for (const [name, value] of Object.entries(shellEnv.set ?? {}))
|
|
91
|
+
if (!ENV_NAME.test(name) || typeof value !== "string")
|
|
92
|
+
throw new ConfigError(
|
|
93
|
+
`shellEnv.set: "${name}" needs a variable name and a string value`,
|
|
94
|
+
);
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
...(shellEnv.pass ? { pass: Object.freeze([...shellEnv.pass]) } : {}),
|
|
97
|
+
...(shellEnv.set ? { set: Object.freeze({ ...shellEnv.set }) } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The host variables an agent's shell gets beyond `SHELL_ENV_NAMES` when `shellEnv.pass` is not given. */
|
|
102
|
+
export const DEFAULT_SHELL_PASS: readonly string[] = Object.freeze([
|
|
103
|
+
"GH_TOKEN",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** The environment of an agent's shell: `childEnv` with `shellEnv`, whose `pass` defaults to `DEFAULT_SHELL_PASS`. */
|
|
107
|
+
export function shellChildEnv(
|
|
108
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
109
|
+
shellEnv: ShellEnv = {},
|
|
110
|
+
): Record<string, string> {
|
|
111
|
+
return childEnv(source, {
|
|
112
|
+
...shellEnv,
|
|
113
|
+
pass: shellEnv.pass ?? DEFAULT_SHELL_PASS,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** The names of host variables whose values are masked in tool output. */
|
|
4
|
+
export const SECRET_NAME: RegExp = Object.freeze(
|
|
5
|
+
/(TOKEN|SECRET|KEY|PASS(WORD)?|DATABASE_URL|JWT|CREDENTIAL)/i,
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
/** Shorter values are too likely to be ordinary words to mask. */
|
|
9
|
+
const MIN_SECRET_CHARS = 8;
|
|
10
|
+
|
|
11
|
+
/** What a masked value is replaced with. */
|
|
12
|
+
export const MASK = "****";
|
|
13
|
+
|
|
14
|
+
/** The values of the variables `SECRET_NAME` matches, longest first, so a value inside another is masked as part of it. */
|
|
15
|
+
function secretValues(
|
|
16
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
17
|
+
): string[] {
|
|
18
|
+
const values = new Set<string>();
|
|
19
|
+
for (const [name, value] of Object.entries(env))
|
|
20
|
+
if (
|
|
21
|
+
value !== undefined &&
|
|
22
|
+
value.length >= MIN_SECRET_CHARS &&
|
|
23
|
+
SECRET_NAME.test(name)
|
|
24
|
+
)
|
|
25
|
+
values.add(value);
|
|
26
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The text with every value of a secret variable of `env` replaced with `****`: a variable whose
|
|
31
|
+
* name `SECRET_NAME` matches and whose value is at least 8 characters.
|
|
32
|
+
*/
|
|
33
|
+
export function maskSecrets(
|
|
34
|
+
text: string,
|
|
35
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
36
|
+
): string {
|
|
37
|
+
let masked = text;
|
|
38
|
+
for (const value of secretValues(env))
|
|
39
|
+
masked = masked.replaceAll(value, MASK);
|
|
40
|
+
return masked;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Masks secret values of `env` in the text of every tool result before it enters the model's
|
|
45
|
+
* context; read again at each result, so a variable set later is masked too.
|
|
46
|
+
*/
|
|
47
|
+
export function secretMaskExtension(
|
|
48
|
+
env: () => Readonly<Record<string, string | undefined>> = () => process.env,
|
|
49
|
+
): ExtensionFactory {
|
|
50
|
+
return (pi) => {
|
|
51
|
+
pi.on("tool_result", (event) => {
|
|
52
|
+
const source = env();
|
|
53
|
+
let changed = false;
|
|
54
|
+
const content = event.content.map((part) => {
|
|
55
|
+
if (part.type !== "text") return part;
|
|
56
|
+
const text = maskSecrets(part.text, source);
|
|
57
|
+
if (text === part.text) return part;
|
|
58
|
+
changed = true;
|
|
59
|
+
return { ...part, text };
|
|
60
|
+
});
|
|
61
|
+
return changed ? { content } : undefined;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -433,6 +433,7 @@ export type {
|
|
|
433
433
|
TransientTask,
|
|
434
434
|
TurnSelection,
|
|
435
435
|
} from "./core/sessions.ts";
|
|
436
|
+
export type { ShellEnv, ShellEnvConfig } from "./core/shared/child-env.ts";
|
|
436
437
|
export type { Speaker, Tier } from "./core/speakers.ts";
|
|
437
438
|
export { THE_SPEAKER, TIERS } from "./core/speakers.ts";
|
|
438
439
|
export type { ToolTiers, ToolTierTable } from "./core/tool-tiers.ts";
|
package/src/kit/index.ts
CHANGED
|
@@ -137,8 +137,17 @@ export {
|
|
|
137
137
|
} from "./presentation.ts";
|
|
138
138
|
export { packageDir, serveUnix } from "./process.ts";
|
|
139
139
|
export {
|
|
140
|
+
childEnv,
|
|
141
|
+
DEFAULT_SECRET_PATHS,
|
|
142
|
+
DEFAULT_SHELL_PASS,
|
|
143
|
+
maskSecrets,
|
|
140
144
|
type PushPolicy,
|
|
145
|
+
SECRET_NAME,
|
|
146
|
+
SHELL_ENV_NAMES,
|
|
141
147
|
SHELL_TOOLS,
|
|
148
|
+
type ShellPolicy,
|
|
149
|
+
secretMaskExtension,
|
|
150
|
+
shellChildEnv,
|
|
142
151
|
shellHoldRule,
|
|
143
152
|
shellHoldRuleFor,
|
|
144
153
|
YOLO_CARDED_SHELL_CLASSES,
|
package/src/kit/shell.ts
CHANGED
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
// The host-shell tools and the hold rule that keeps risky commands behind the owner's approval.
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
|
+
DEFAULT_SECRET_PATHS,
|
|
5
6
|
type PushPolicy,
|
|
6
7
|
SHELL_TOOLS,
|
|
8
|
+
type ShellPolicy,
|
|
7
9
|
shellHoldRule,
|
|
8
10
|
shellHoldRuleFor,
|
|
9
11
|
YOLO_CARDED_SHELL_CLASSES,
|
|
10
12
|
} from "../core/modules/host-shell/shell-policy.ts";
|
|
13
|
+
// What child processes get of the host's environment, and the mask of secret values in tool output.
|
|
14
|
+
export {
|
|
15
|
+
childEnv,
|
|
16
|
+
DEFAULT_SHELL_PASS,
|
|
17
|
+
SHELL_ENV_NAMES,
|
|
18
|
+
shellChildEnv,
|
|
19
|
+
} from "../core/shared/child-env.ts";
|
|
20
|
+
export {
|
|
21
|
+
maskSecrets,
|
|
22
|
+
SECRET_NAME,
|
|
23
|
+
secretMaskExtension,
|
|
24
|
+
} from "../core/shared/secret-mask.ts";
|