@staix/agent-hub 0.10.0 → 0.11.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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
4
4
 
5
+ ## 0.11.0
6
+
7
+ - Task operations settle model-written arguments before anything reaches the board: an `owner` or `peer` that is not a peer id, or a task id that is not a whole number, is refused with a clear message instead of leaving an ownerless task behind after a database error; `null` and an empty owner mean no owner (#70).
8
+ - Review outcomes are credited to the work they judged in more cases: task paths are stored in one spelling (`./src/a.ts` and `src/a.ts` are one file for blame, and `.` blames nobody), and handing a task to the owner it already has keeps an earlier catch; ownership events now record the owner in the task history (#67).
9
+ - After a crash, `pi.auto_start` brings Pi back on its recorded headless session, with or without `recovery.auto_resume_after_crash`, and on a fresh session (on the recorded backend and model) if that resume fails; before, a fresh Pi started and the recorded session could not be resumed while it ran (#66).
10
+ - Tests cover a refused `hub_send` from the local worker and from Pi, a Pi resumed after `kill -9` on the session file the dead run recorded, and the crash report for Codex and Claude (#68).
11
+ - Model-written text on an ordinary task is screened before it leaves the hub: a done summary, a review note, an unmet item or a budget handoff that matches a PII pattern is not saved to claude-mem, and every peer (the local worker included) gets a stub naming `ahub task show <id>`; the board keeps the text, and `ahub ask` shows such a note only on campus (#69).
12
+ - With `local.bash_network` on, the local worker's commands can read Python's own CA bundle (`certifi/cacert.pem`, also vendored by pip), so `pip install` and `requests` work over HTTPS; every other `.pem` stays denied (#64).
13
+ - Each command the local worker runs gets a temp dir of its own (`TMPDIR`), removed when it ends, a timeout or kill included; under deny-default the shared temp dirs (the user's and `/private/tmp`) are closed, so a command can no longer read what other tools left there (#63).
14
+ - Under deny-default, Apple's `/usr/bin` shims (python3 among them) work with a full Xcode selected: the sandbox opens the app's whole `Contents`, since its tools load `SharedFrameworks`; 0.10.0 opened only `Contents/Developer`, so they worked with the Command Line Tools only.
15
+ - With `local.bash_network: true` the local worker's and Pi's commands reach the network only through the hub's egress proxy: HTTPS to the hosts in `local.network_allow` (machine-local; by default the npm, PyPI, crates.io and Go module registries and GitHub), with names that resolve to internal addresses refused and each policy refusal logged by method and host; direct egress and loopback to claude-mem or the Codex app-server are denied. `"direct"` keeps the open network for one release (#65).
16
+
5
17
  ## 0.10.0
6
18
 
7
19
  - Review checklists and review outcomes: review requests ask the reviewer to map signatures and call sites to the plan, read the check result and list unmet items (`hub_review {unmet}`); the hub records approved, caught, contradicted and escalated reviews per implementer, reviewer and class, shown by `ahub task show` and `ahub route explain`, and `review.adaptive` (off by default) orders reviewers by that record, after idle before busy and ahead of quota. A record counts tasks, a catch belongs to the owner whose work was caught, and only a failure on the same file or symbol contradicts an approval (#35).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.10.0, control protocol 10. Durable delivery records distinguish queued
7
+ Status: 0.11.0, control protocol 10. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up && ahub tail
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.10.0 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.11.0 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.10.0 and control protocol 10. Live verification
3
+ This guide describes ahub 0.11.0 and control protocol 10. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Install and start
@@ -27,7 +27,7 @@ what the hub runs, which files it sends as credentials, where task text goes,
27
27
  or how far the local worker's sandbox reaches (`kimi_cmd`, `codex_bin`,
28
28
  `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`,
29
29
  `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`,
30
- `local.read_allow`, `local.bash_network`, `local.sandbox`) are machine-local:
30
+ `local.read_allow`, `local.bash_network`, `local.network_allow`, `local.sandbox`) are machine-local:
31
31
  they apply only from a file git confirms nobody committed. Put them in
32
32
  `.agenthub/config.local.json` (`ahub init` adds it to `.gitignore`), which is
33
33
  read after `config.json`; outside a git repository they keep their defaults, and
@@ -35,9 +35,26 @@ an empty value always means the default.
35
35
 
36
36
  The local worker's commands run under a sandbox that starts from deny default
37
37
  (0.10): they may run and read the system, toolchain and project directories and
38
- the selected Xcode or Command Line Tools dir (`xcode-select -p`), write the
39
- project and temp, and nothing else. With `local.bash_network` on they may also
40
- read the public CA bundles, which the `*.pem` key deny would otherwise hide.
38
+ the selected Xcode or Command Line Tools dir (`xcode-select -p`; for an Xcode
39
+ app, its whole `Contents`, whose `SharedFrameworks` its tools load), write the
40
+ project and a temp dir of their own (`TMPDIR`, made for each command and
41
+ removed when it ends; one left by a hub crash, named `ahub-cmd-*`, goes with
42
+ the OS temp cleanup), and nothing else; the shared temp dirs are closed.
43
+
44
+ Network is off unless `local.bash_network` says otherwise. With `true` it goes
45
+ only through the hub's egress proxy on a loopback port (0.11): commands get
46
+ `HTTPS_PROXY` and the other proxy variables, the proxy opens HTTPS (`CONNECT`,
47
+ port 443 unless an entry names one) to the hosts in `local.network_allow`, and
48
+ the profile denies every other connection, direct egress and other loopback
49
+ ports (claude-mem's, the Codex app-server's) included. A listed name also
50
+ covers its subdomains; a name that resolves to a loopback or private address is
51
+ refused, and so is plain HTTP. Each such refusal is a `network: refused` line
52
+ in `hub.log`, by method and host. The default list holds the npm, PyPI, crates.io and Go module
53
+ registries and GitHub's code hosts; set `local.network_allow` in
54
+ `config.local.json` to replace it. `"direct"` keeps the open network of 0.10 and
55
+ earlier for one release. With network on, commands may also read the public CA
56
+ bundles and Python's `certifi/cacert.pem`, which the `*.pem` key deny would
57
+ otherwise hide.
41
58
  `"local": { "sandbox": "allow-default" }` in `config.local.json` brings back the
42
59
  profile of 0.9 and earlier for one release, should a toolchain need a path the
43
60
  new one lacks; please report it. Newly closed outside home: `/Applications`
@@ -425,8 +442,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
425
442
  project directory, without replacing the global CLI first:
426
443
 
427
444
  ```bash
428
- bunx --package @staix/agent-hub@0.10.0 ahub upgrade --to 0.10.0 --dry-run
429
- bunx --package @staix/agent-hub@0.10.0 ahub upgrade --to 0.10.0 --yes
445
+ bunx --package @staix/agent-hub@0.11.0 ahub upgrade --to 0.11.0 --dry-run
446
+ bunx --package @staix/agent-hub@0.11.0 ahub upgrade --to 0.11.0 --yes
430
447
  ```
431
448
 
432
449
  | Running now | Coordinator to use |
@@ -449,26 +466,28 @@ no blocker, and `--yes` stops at staging ("target protocol requires a newer
449
466
  coordinator") with an operation left to clear by `ahub recovery abort <id>`. An
450
467
  older 0.7.x CLI may lack recovery fixes released after it. The
451
468
  [smoke ledger](smoke.md) records dry-runs from real 0.6.4 and 0.7.5 hubs (issue
452
- #75), an applied upgrade with the 0.7.0 coordinator, and one from a running
453
- 0.8.1 hub with tasks and a budget pause with the 0.9.0 coordinator.
469
+ #75) and these applied upgrades: one with the 0.7.0 coordinator, one with the
470
+ 0.9.0 coordinator from a running 0.8.1 hub with tasks and a budget pause, and
471
+ one with the 0.10.0 coordinator from a 0.9.0 hub with one completion check
472
+ running and one queued.
454
473
 
455
474
  The coordinator verifies and retains the exact target package, preserves its
456
475
  own source, and promotes the global CLI only after restored projects pass
457
476
  readback.
458
477
 
459
- Once the installed CLI is 0.10.0, review the current project or all registered
478
+ Once the installed CLI is 0.11.0, review the current project or all registered
460
479
  projects first:
461
480
 
462
481
  ```bash
463
482
  ahub restart --dry-run
464
- ahub upgrade --to 0.10.0 --dry-run
483
+ ahub upgrade --to 0.11.0 --dry-run
465
484
  ```
466
485
 
467
486
  Apply only after reviewing the plan:
468
487
 
469
488
  ```bash
470
489
  ahub restart --yes
471
- ahub upgrade --to 0.10.0 --yes
490
+ ahub upgrade --to 0.11.0 --yes
472
491
  ahub recovery status <operation-id>
473
492
  ahub recovery resume <operation-id>
474
493
  ahub recovery abort <operation-id>
@@ -499,6 +518,15 @@ in `config.local.json` restores the old profile for one release. Off unless set:
499
518
  before 0.10.0 keeps no session record, so a crash of one is not reported as such
500
519
  by the next start.
501
520
 
521
+ 0.11.0 changes what `local.bash_network: true` means: the local worker's and
522
+ Pi's commands reach the network only through the hub's egress proxy, to the
523
+ hosts in `local.network_allow` (package registries and GitHub's code hosts by
524
+ default). `local.network_allow` is machine-local and replaces the default list:
525
+ a project that needs another host sets it in `config.local.json` with the
526
+ defaults it still needs. `"direct"` keeps the open network of 0.10.0 for one
527
+ release. Each command gets a temp dir of its own, and under deny-default the
528
+ shared temp dirs are closed.
529
+
502
530
  The 0.7.0 transition stages the verified package and runs a retained
503
531
  coordinator from the source tree. It accepts a verified protocol-9 source and
504
532
  moves to a protocol-10 target. The source journal, queued envelopes, tasks,
@@ -537,9 +565,13 @@ crash of one is not reported this way.
537
565
  hub starts them again: Kimi loads its recorded session (ACP `session/load`), Pi
538
566
  resumes its session file, and the local worker starts without its history, on
539
567
  its recorded route (or pinned model). Off by default: the report then says
540
- what to start. A Pi that ran in a terminal (`--mode tui`) is never started by
541
- the hub; the report gives the command. With `pi.auto_start` and no resume,
542
- Pi starts on a fresh session as usual.
568
+ what to start. With `pi.auto_start` on, Pi comes back on its recorded
569
+ headless session whether or not auto-resume is on (it runs on-prem, so this
570
+ spends no cloud quota), and on a fresh session if that fails, keeping the
571
+ recorded backend and model; the report says which. Malformed records in
572
+ `sessions.json` are skipped. A Pi that ran in a terminal (`--mode tui`) is never started on its
573
+ recorded session by the hub; the report gives the command, and with
574
+ `pi.auto_start` a fresh headless Pi starts instead.
543
575
  - Codex's app-server died with the hub; run `ahub codex` again. Claude Code's
544
576
  plugin reconnects by itself while that session is open.
545
577
 
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.10.0
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.11.0
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/security.md CHANGED
@@ -7,14 +7,15 @@ agent-hub connects agents that can each run commands. This page says what the hu
7
7
  - **Other agents' text is untrusted.** Every message that crosses from one peer to another is framed as untrusted input (a channel tag with `meta.source` for Claude, a fixed header line plus a standing instruction for the others). A message body cannot forge the hub's own headers: such lines are quoted (`sanitize`). Replies inherit a hop count capped at 3, so agents cannot ping-pong forever; neither a digest nor a steer can reset it.
8
8
  - **The control link is loopback plus a secret.** The daemon and the Codex proxy bind 127.0.0.1 only. The control WebSocket requires a per-run token (`.agenthub/state/control-token`, mode 600), and both servers refuse any request that carries an `Origin` header: any web page can open a WebSocket to localhost, and browsers always send `Origin`. External clients cannot claim the console user's id or a hub-managed peer's id.
9
9
  - **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after `approvals.timeout_s` (default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They touch no file and run no process, and every call passes the hub's own checks. The match relies on the agent putting the tool name in the request's `title`, as Kimi does; an ACP agent that titles calls with model-written text must not be configured as `kimi_cmd`. A payload longer than the console shows is marked as cut and never offers a session-wide grant. `--unattended` turns prompts off, says so loudly, and is never the default.
10
- - **A committed config cannot choose launch commands, credential files, data endpoints or a wider sandbox.** The machine-local fields (`kimi_cmd`, `codex_bin`, `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`, `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`, `local.read_allow`, `local.bash_network`) apply only from a config file git confirms nobody committed: `.agenthub/config.json` or `.agenthub/config.local.json`, matched by file identity so no other spelling the file system accepts slips past, and `.agenthub` itself not a committed symlink or submodule. Without a repository, or when git fails, they keep their defaults; an empty value always means the default. "Untracked" is answered by the repository that contains the project: a checkout copied or extracted into an unrelated repository, or into an ignored directory of one, is trusted like your own files. So a cloned repository cannot choose a launch command, a completion check, a gateway to send a key file to, a memory endpoint, or a wider sandbox. A command in `checks` runs as you, outside the local worker's sandbox, like a git hook. Nothing in `routing.toml` or in task text is ever run. `routing.toml` and the other shared fields still come from the checkout, and they matter: `routing.toml` picks the models the local worker and the hub's inference use at your gateway and can turn the PII constraint off, and roles and budget shape who does what. Review them in a repository you do not trust.
10
+ - **A committed config cannot choose launch commands, credential files, data endpoints or a wider sandbox.** The machine-local fields (`kimi_cmd`, `codex_bin`, `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`, `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`, `local.read_allow`, `local.bash_network`, `local.network_allow`) apply only from a config file git confirms nobody committed: `.agenthub/config.json` or `.agenthub/config.local.json`, matched by file identity so no other spelling the file system accepts slips past, and `.agenthub` itself not a committed symlink or submodule. Without a repository, or when git fails, they keep their defaults; an empty value always means the default. "Untracked" is answered by the repository that contains the project: a checkout copied or extracted into an unrelated repository, or into an ignored directory of one, is trusted like your own files. So a cloned repository cannot choose a launch command, a completion check, a gateway to send a key file to, a memory endpoint, or a wider sandbox. A command in `checks` runs as you, outside the local worker's sandbox, like a git hook. Nothing in `routing.toml` or in task text is ever run. `routing.toml` and the other shared fields still come from the checkout, and they matter: `routing.toml` picks the models the local worker and the hub's inference use at your gateway and can turn the PII constraint off, and roles and budget shape who does what. Review them in a repository you do not trust.
11
11
  - **Telemetry holds no bodies.** `.agenthub/state/events.jsonl` (issue #40) records envelope ids, routing and sizes, task ids and states, overlapping paths and token counts. It never records a message body, a task title or detail, and marks private (PII) envelopes and tasks as such. It stays on the machine; `ahub export` only prints it.
12
12
  - **Snapshots stay in your repository.** Per-turn snapshots (issue #33) are git objects in the project's own object store, written through a temporary index; nothing is referenced, pushed or copied elsewhere, and `git gc` prunes them. They hold what the work tree held, including untracked files that are not ignored, so keep secrets in ignored files. They carry the repository's own permissions, and nothing caps their disk use but `git gc`. A turn of a peer holding an open PII task is not snapshotted; a PII file left in the project is snapshotted by later turns like any other file. `ahub undo` restores only files whose current content is exactly what the turn left.
13
13
  - **The edit hook reads, never decides.** `ahub check-path --hook` (issue #32) reads hub.db and returns context for Claude and a line for you; it sets no permission decision, so your permission rules stay in charge. It names other owners' task ids, titles and states, which then reach Claude's model; PII tasks are left out.
14
14
  - **The session record holds identities only.** `.agenthub/state/sessions.json` (issue #37, mode 600) keeps each attached peer's recovery metadata: launch options, session and thread ids, Pi's session file path. No message or task text; loss notices name deliveries by id, sender and public task title.
15
- - **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir, the selected developer dir and temp; with network on, the public CA bundles); no writes outside the project, its git dir and temp; no `.git/hooks` or `.git/config` writes; no network, loopback included, unless `local.bash_network`. `local.sandbox: "allow-default"`, machine-local, brings back the profile of 0.9 and earlier for one release. The user's temp dir is still readable in both: a command can read another tool's leftovers there. Without the sandbox there is no `bash` tool.
15
+ - **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir and the selected developer dir; with network on, the public CA bundles); no writes outside the project, its git dir and a temp dir of its own (`TMPDIR`, made for each command and removed when it ends; issue #63); no `.git/hooks` or `.git/config` writes; no network, loopback included, unless `local.bash_network`; with it, only through the hub's egress proxy to the hosts in `local.network_allow` (issue #65), which refuses names that resolve to internal addresses, so claude-mem and the Codex app-server stay out of reach (`"direct"` opens everything, for one release). The allowlist is a deny-default guarantee: under allow-default, DNS and the system's network daemons stay reachable around the proxy. One channel stays open under deny-default too: `trustd`, which TLS clients need, can fetch a certificate's AIA or OCSP URL on a command's behalf, outside the proxy. `local.sandbox: "allow-default"`, machine-local, brings back the profile of 0.9 and earlier for one release. Under deny-default the shared temp dirs (the user's and `/private/tmp`) are closed; allow-default still reads them. Without the sandbox there is no `bash` tool.
16
16
  - **Capabilities are enforced, not suggested.** `capabilities` (issue #39) is checked by the daemon where task operations and messages arrive, so a peer cannot get round it by phrasing. A peer can never answer a permission request: the control link takes `permit` from the console role only, and a message that quotes a permit command is just text. Both bind the hub's own tool paths: a vendor agent with its own shell in the project (Codex, Claude, Kimi) can read `.agenthub/state/control-token` and connect as the console, which only the local worker's and Pi's sandbox prevents.
17
17
  - **PII has an enforced path.** A task matching `signals.pii_patterns` goes to `local` or to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it; `local` answers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it is sent to claude-mem, whose observer is a cloud model.
18
+ - **Free text is screened too.** On an ordinary task, a done summary (with its check output: a match withholds the whole note), a review note, an unmet item or a budget handoff that matches a PII pattern (issue #69) is not saved to claude-mem, and every peer, `local` included, gets a stub naming `ahub task show <id>`; the board keeps the text, `hub.log` notes the withholding by task id only, and `ahub ask` shows such a note only when its model is reached on campus. `hub_remember` refuses a match; what a vendor agent writes to its own memory is not screened.
18
19
  - **Secrets stay where they are read.** The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory, or the generated Switchyard config (the key travels by environment variable name). Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
19
20
  - **The hub's own model calls are fenced.** Digest condensation and task triage read agent-written text as data; their output is capped text framed as untrusted, or a value checked against a closed list. It is never used as a route, a peer id, a tool call or an instruction.
20
21
 
package/docs/smoke.md CHANGED
@@ -937,3 +937,30 @@ configured or running, and no peer was attached at the upgrade.
937
937
  - For about two minutes after the publish step logged `+ @staix/agent-hub@0.9.0`,
938
938
  `npm view` still showed `latest: 0.8.1`; the registry caught up without any
939
939
  action.
940
+
941
+ ## 0.9.0 to 0.10.0 attended upgrade (2026-10-01)
942
+
943
+ A scratch project (a git work tree initialized by the 0.9.0 `ahub init`) ran a
944
+ 0.9.0 hub with tasks on its board and a completion check for the review class, set in
945
+ `.agenthub/config.local.json` (`"review": "sleep 40"`, timeout 120 s). No peer
946
+ was attached. Times are UTC.
947
+
948
+ - At 05:54:46 the console marked task #1 done, which started its 40 s check.
949
+ `bunx --package @staix/agent-hub@0.10.0 ahub upgrade --to 0.10.0 --yes` right
950
+ after, about 90 s after the publish, failed with "registry metadata
951
+ unavailable for @staix/agent-hub@0.10.0" and scheduled nothing; the 0.9.0 hub
952
+ kept running. This is the registry lag from the 0.9.0 entry, met here as an
953
+ error instead of a stale `npm view`.
954
+ - At 05:55:03 the console marked task #3 done, which queued its check behind
955
+ #1's (checks run one at a time), and `--yes` was applied in the same second.
956
+ Its plan listed one project, source 0.9.0, no blockers. The source did not
957
+ commit while a check was running or queued: #1's passed at 05:55:26 and #3's
958
+ at 05:56:07, the source committed and stopped at 05:56:07.1, and the 0.10.0
959
+ hub was up at 05:56:07.7. The operation, global install included, completed
960
+ at 05:56:15.
961
+ - After release the hub ran 0.10.0 under a new instance id. All three tasks
962
+ were on the board with their full history (#1 and #3 approved by their
963
+ checks, #2 still proposed), and no history entry recorded an interrupted
964
+ check.
965
+ - The coordinator promoted the global CLI to 0.10.0, and `ahub setup --yes`
966
+ installed the 0.10.0 plugin.
@@ -1037,6 +1037,15 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
1037
1037
  `min_reviews` by held, others keep their place, and the implementer is never
1038
1038
  a candidate. The record is applied after quota, so the order is idle before
1039
1039
  busy, then the record, then quota.
1040
+ - Task paths are stored in one spelling (issue #67): no leading `./`, no repeated
1041
+ or trailing `/`, the root as `.`; older rows are compared in that spelling.
1042
+ Blame needs the same path, so a directory never blames the files under it,
1043
+ though two tasks that both claim the same directory still blame each other: a
1044
+ path does not say whether it is a directory. Existing rows are never
1045
+ rewritten.
1046
+ Ownership events (`assigned`, `escalated`, `reassigned`, `unassigned`) record
1047
+ the owner they leave, so a reassignment to the same owner does not end the
1048
+ window in which a catch counts; rows written before keep the old rule.
1040
1049
 
1041
1050
  ## Amendment: recovery after an unplanned stop (issue #37)
1042
1051
 
@@ -1051,6 +1060,12 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
1051
1060
  journal rows stay in `needs_review`, shown by `ahub queue list`), and the first
1052
1061
  attach rewrites the record with only the attached peers. A Pi in TUI mode is
1053
1062
  reported with its command, not resumed: the CLI runs its terminal.
1063
+ - `pi.auto_start` (issue #66) counts as consent to start Pi after a crash too:
1064
+ crash recovery, not the plain auto-start, starts it, on the recorded headless
1065
+ session first and with a `fresh` start (one that does not inherit the failed
1066
+ launch's pending session, keeps the recorded backend and model) if that fails
1067
+ or nothing headless was recorded. If crash recovery itself fails, the plain
1068
+ auto-start runs after all.
1054
1069
  - Resume goes through the same start path as `ahub kimi` / `ahub pi` / `ahub
1055
1070
  local`: Kimi with `sessionId` (ACP `session/load`, refused when the agent does
1056
1071
  not offer `loadSession`), Pi with its session file, the local worker afresh.
@@ -1070,16 +1085,19 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
1070
1085
  (`/usr`, `/bin`, `/sbin`, `/System`, `/Library`, `/opt`, `/private/etc`, the
1071
1086
  dyld and timezone databases), the toolchain directories in home, `read_allow`,
1072
1087
  the project, its external git dirs, the selected developer dir (`xcode-select
1073
- -p`) and temp; writes as before; a short list of mach services (directory
1088
+ -p`) and a temp dir made for each command (issue #63; the shared user temp
1089
+ dir and `/private/tmp` are not open); writes to the project, its git dirs and
1090
+ that temp dir; a short list of mach services (directory
1074
1091
  lookups, logging, notifications), plus name resolution and TLS trust when
1075
1092
  network is on, the trust being the public CA bundles allowed by exact path
1076
- after the denies (the `*.pem` key deny matches them); no brokers that act
1093
+ after the denies (the `*.pem` key deny matches them), and any path ending in
1094
+ `/certifi/cacert.pem` (Python's own bundle, issue #64); no brokers that act
1077
1095
  outside the sandbox (LaunchServices, SecurityServer). The denies at the end (credential
1078
1096
  stores, the denylist, `.agenthub`, git hooks and config) are shared by both
1079
1097
  bases.
1080
1098
  - The issue's allowlist proxy for `local.bash_network` is not built here: with
1081
- the flag on, network is allowed as in 0.9 and earlier. It is left for a
1082
- follow-up issue.
1099
+ the flag on, network is allowed as in 0.9 and earlier. Issue #65 builds it
1100
+ (amendment below).
1083
1101
  - `local.sandbox` ("deny-default" | "allow-default") is machine-local, since
1084
1102
  "allow-default" widens the sandbox.
1085
1103
  - Capabilities: `propose`, `assign` (a proposal naming another peer as owner),
@@ -1092,3 +1110,20 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
1092
1110
  `important` is checked where messages are admitted, the others in the task
1093
1111
  operations, so in-process tools (the local worker, Pi) are covered too.
1094
1112
 
1113
+ ## Amendment: egress proxy for the local worker (issue #65)
1114
+
1115
+ - With `local.bash_network: true` the daemon runs an HTTP `CONNECT` proxy on a
1116
+ loopback port for the hub run (`src/local/proxy.ts`, built-ins only). The
1117
+ profile allows outbound network only to that port, looks up TLS trust but no
1118
+ DNS (the proxy resolves), and commands get the proxy variables.
1119
+ - The proxy opens a tunnel only to a host in `local.network_allow`
1120
+ (machine-local): a name also covers its subdomains, an address only itself,
1121
+ port 443 unless an entry names one (`host:port`). A name that resolves to a
1122
+ loopback, private, link-local or carrier-grade NAT address is refused; an
1123
+ address is reached only when listed. Plain HTTP is refused. No TLS
1124
+ interception. Each refusal is logged with the host only.
1125
+ - Decisions recorded in the issue: the default list holds the npm, Yarn, PyPI,
1126
+ crates.io and Go module registries and GitHub's code hosts, so a project that
1127
+ already had network on keeps installing packages; `"direct"` keeps the open
1128
+ network of 0.10 and earlier for one release.
1129
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15727,7 +15727,7 @@ class ControlClient {
15727
15727
  // package.json
15728
15728
  var package_default = {
15729
15729
  name: "@staix/agent-hub",
15730
- version: "0.10.0",
15730
+ version: "0.11.0",
15731
15731
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15732
15732
  license: "MIT",
15733
15733
  type: "module",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
3
3
  import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { profile } from "../local/sandbox.ts";
5
+ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
6
6
  import { runTool, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
7
7
  import type { Capture } from "../memory/capture.ts";
8
8
  import type { ChatMessage, ChatResult, OmniRoute } from "../omniroute/client.ts";
@@ -17,7 +17,7 @@ export interface LocalOptions {
17
17
  route?: string;
18
18
  /** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
19
19
  fixedModel: string;
20
- tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: boolean; readAllow?: string[]; sandbox?: "deny-default" | "allow-default" };
20
+ tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; sandbox?: "deny-default" | "allow-default" };
21
21
  capture?: Capture;
22
22
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
23
23
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
@@ -165,6 +165,7 @@ export class LocalPeer extends BasePeer {
165
165
  deny: this.opts.tools.deny,
166
166
  permit: this.opts.tools.permit,
167
167
  sandboxProfile: this.sandboxProfile,
168
+ sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
168
169
  send: (text, to) => {
169
170
  const refused = this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
170
171
  if (typeof refused === "string") return `not sent: ${refused}`;
package/src/hub/ask.ts CHANGED
@@ -85,14 +85,19 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
85
85
 
86
86
  // Matching tasks first, then the most recently touched; PII rows keep their place with a stub, so counts stay right.
87
87
  const tasks = d.board.list().sort((a, b) => hit(b.title) - hit(a.title) || b.updated - a.updated).slice(0, MAX_TASKS);
88
- const anyPii = tasks.some((t) => d.isPii(t));
88
+ // An ordinary task's last note is model-written (a summary, a review note): one that matches a PII pattern is shown
89
+ // only on campus, like a PII task (#69).
90
+ const notePii = (t: (typeof tasks)[number]) => !d.isPii(t) && !!t.history.at(-1)?.note && (d.isPiiText?.(t.history.at(-1)!.note!) ?? false);
91
+ const anyPii = tasks.some((t) => d.isPii(t) || notePii(t));
89
92
  const showPii = anyPii ? await onCampus() : false;
90
93
  let piiShown = false;
91
94
  const taskRows: Evidence[] = tasks.map((t) => {
92
95
  const secret = d.isPii(t);
93
96
  if (secret && showPii) piiShown = true;
94
97
  const last = t.history.at(-1);
95
- const text = secret && !showPii ? "[pii] (its text is shown only when the model is reached on campus)" : `${t.title}${last ? ` | last: ${last.event} by ${last.by}${last.note ? ` (${last.note})` : ""}` : ""}`;
98
+ if (notePii(t) && showPii) piiShown = true;
99
+ const note = last?.note && notePii(t) && !showPii ? "its note is shown only when the model is reached on campus" : last?.note;
100
+ const text = secret && !showPii ? "[pii] (its text is shown only when the model is reached on campus)" : `${t.title}${last ? ` | last: ${last.event} by ${last.by}${note ? ` (${note})` : ""}` : ""}`;
96
101
  return { id: `task #${t.id}`, kind: "task", text: `[${t.class}] ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}: ${text}`.slice(0, ITEM_CHARS) };
97
102
  });
98
103
 
package/src/hub/board.ts CHANGED
@@ -22,6 +22,8 @@ export interface HistoryEntry {
22
22
  by: PeerId;
23
23
  event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
24
24
  note?: string;
25
+ /** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
26
+ owner?: PeerId | null;
25
27
  }
26
28
  export interface Task {
27
29
  id: number;
@@ -122,7 +124,7 @@ export class Board {
122
124
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
123
125
  }
124
126
  const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
125
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}) }];
127
+ const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}) }];
126
128
  this.db
127
129
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
128
130
  .run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
@@ -24,6 +24,7 @@ export const MACHINE_LOCAL = [
24
24
  "memory.worker_url",
25
25
  "local.read_allow",
26
26
  "local.bash_network",
27
+ "local.network_allow", // the hosts the egress proxy opens (#65)
27
28
  "local.sandbox", // "allow-default" widens what the worker's commands may do
28
29
  ] as const;
29
30
 
package/src/hub/crash.ts CHANGED
@@ -31,7 +31,9 @@ export function readSessions(stateDir: string): SessionsFile | undefined {
31
31
  if (!existsSync(fileOf(stateDir))) return undefined;
32
32
  try {
33
33
  const s = JSON.parse(readFileSync(fileOf(stateDir), "utf8")) as SessionsFile;
34
- return Array.isArray(s.peers) ? s : undefined;
34
+ if (!Array.isArray(s.peers)) return undefined;
35
+ // A record without a peer id or metadata says nothing to resume, and reading it would stop recovery for the rest.
36
+ return { ...s, peers: s.peers.filter((p) => !!p && typeof p.peer === "string" && !!p.meta && typeof p.meta === "object") };
35
37
  } catch {
36
38
  return undefined; // cut short by the crash: nothing to resume from
37
39
  }
@@ -51,7 +53,7 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
51
53
  * What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
52
54
  * ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
53
55
  */
54
- export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string }[] {
56
+ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
55
57
  return records.map(({ peer, meta }) => {
56
58
  const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
57
59
  const model = str(launch.model);
@@ -66,13 +68,16 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
66
68
  return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `kimi: session ${sessionId} can be loaded again (ACP session/load)` };
67
69
  }
68
70
  case "pi": {
71
+ // `fresh`: what a new headless session keeps of the recorded one (pi.auto_start's fallback, #66).
72
+ const fresh: Record<string, string> = {};
73
+ for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
69
74
  const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
70
- if (!sessionFile) return { peer, how: "pi: no session file was recorded; start it again with ahub pi" };
75
+ if (!sessionFile) return { peer, fresh, how: "pi: no session file was recorded; start it again with ahub pi" };
71
76
  // A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
72
- if (str(launch.mode) === "tui") return { peer, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
77
+ if (str(launch.mode) === "tui") return { peer, fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
73
78
  const args: Record<string, string> = { sessionFile };
74
79
  for (const k of ["mode", "backend", "model"] as const) if (str(launch[k])) args[k] = str(launch[k])!;
75
- return { peer, resume: args, how: `pi: its session file can be resumed (${sessionFile})` };
80
+ return { peer, fresh, resume: args, how: `pi: its session file can be resumed (${sessionFile})` };
76
81
  }
77
82
  case "local": {
78
83
  // `model` is also recorded as the route's fallback, and a model given at start pins it: pass one or the other.
package/src/hub/daemon.ts CHANGED
@@ -14,7 +14,8 @@ import { PiPeer } from "../adapters/pi.ts";
14
14
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
15
15
  import type { MlxOptions } from "../models/mlx.ts";
16
16
  import { PiToolReceipts } from "../pi/tool-receipts.ts";
17
- import { profile } from "../local/sandbox.ts";
17
+ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
18
+ import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
18
19
  import { runTool, TOOL_SCHEMAS, type ToolContext } from "../local/tools.ts";
19
20
  import { LocalPeer } from "../adapters/local-worker.ts";
20
21
  import { Capture, skipTools } from "../memory/capture.ts";
@@ -63,7 +64,8 @@ export interface HubConfig {
63
64
  pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
64
65
  mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
65
66
  /** `sandbox`: "deny-default" (issue #39), or "allow-default", the profile of 0.9 and earlier, kept for one release. */
66
- local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[]; sandbox: "deny-default" | "allow-default" };
67
+ /** `bash_network`: true is network through the egress proxy to `network_allow` (#65); "direct" is everything, for one release. */
68
+ local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[]; sandbox: "deny-default" | "allow-default" };
67
69
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
68
70
  approvals: { timeout_s: number; notify: boolean };
69
71
  /** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
@@ -97,7 +99,7 @@ export const DEFAULT_CONFIG: HubConfig = {
97
99
  omniroute: DEFAULT_OMNIROUTE,
98
100
  pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
99
101
  mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
100
- local: { deny: [], bash_network: false, max_steps: 30, read_allow: [], sandbox: "deny-default" },
102
+ local: { deny: [], bash_network: false, network_allow: DEFAULT_NETWORK_ALLOW, max_steps: 30, read_allow: [], sandbox: "deny-default" },
101
103
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
102
104
  approvals: { timeout_s: 120, notify: false },
103
105
  tasks: { release_after_min: 30 },
@@ -301,6 +303,7 @@ export async function startDaemon(opts: DaemonOptions) {
301
303
  // a record left now would make the next ordinary start look like one.
302
304
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
303
305
  const autoResume = config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
306
+ const piAutoStart = config.pi.enabled && config.pi.auto_start;
304
307
  const startedAt = Date.now();
305
308
  /** What crash recovery did or asks the user to do, for `ahub status`. */
306
309
  const crashReport: string[] = [];
@@ -334,6 +337,16 @@ export async function startDaemon(opts: DaemonOptions) {
334
337
  return [k, fallback];
335
338
  })) as unknown as LimitsConfig;
336
339
  const limiter = new Limiter(limits);
340
+ // The local worker's and Pi's commands reach the network only through this proxy (issue #65); "direct" keeps the
341
+ // open network of 0.10 and earlier for one release, anything else means none.
342
+ let egress: EgressProxy | undefined;
343
+ if (config.local.bash_network === true) {
344
+ const allow = Array.isArray(config.local.network_allow) ? config.local.network_allow.filter((h): h is string => typeof h === "string") : DEFAULT_NETWORK_ALLOW;
345
+ egress = await startEgressProxy({ allow, log });
346
+ startupCleanup.push(() => void egress?.close());
347
+ log(`network: egress proxy on 127.0.0.1:${egress.port}, ${allow.length} allowed host(s) (local.network_allow)`);
348
+ }
349
+ const sandboxNetwork: SandboxNetwork = config.local.bash_network === "direct" ? true : egress ? { proxyPort: egress.port } : false;
337
350
  const admit = (env: Envelope, parent?: string): string | undefined => {
338
351
  // [FYI] is recorded and costs nobody a turn: nothing to limit.
339
352
  if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
@@ -601,6 +614,13 @@ export async function startDaemon(opts: DaemonOptions) {
601
614
  taskOpsInFlight--;
602
615
  }
603
616
  };
617
+ /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
618
+ const peerArg = (v: unknown, name: string): PeerId | undefined => {
619
+ if (v == null || v === "") return undefined;
620
+ const id = typeof v === "string" ? v.trim().toLowerCase() : undefined; // peer ids are lowercase; "Codex" means codex
621
+ if (!id || !PEER_ID.test(id)) throw new Error(`${name} must be a peer id, not ${JSON.stringify(v).slice(0, 60)}`);
622
+ return id;
623
+ };
604
624
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
605
625
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
606
626
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
@@ -621,9 +641,11 @@ export async function startDaemon(opts: DaemonOptions) {
621
641
  log(`capabilities: ${by} may not ${what} (${op})`);
622
642
  throw new Error(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
623
643
  };
644
+ // Tool callers are models (#70): `owner` is a peer id or nothing, settled before anything reaches the board.
624
645
  if (op === "hub_task_propose") {
646
+ a = { ...a, owner: peerArg(a.owner, "owner") };
625
647
  need("propose", "propose tasks");
626
- if (typeof a.owner === "string" && a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
648
+ if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
627
649
  }
628
650
  if (op === "hub_remember") need("remember", "save notes to shared memory");
629
651
  switch (op) {
@@ -661,8 +683,11 @@ export async function startDaemon(opts: DaemonOptions) {
661
683
  switch (op) {
662
684
  case "task_show":
663
685
  return JSON.stringify(board.get(Number(a.id)) ? { ...board.get(Number(a.id)), reviews: board.reviews({ task: Number(a.id) }) } : `no task #${a.id}`, null, 2);
664
- case "task_assign":
665
- return line(await tasks.assignTo(a.id, String(a.peer)));
686
+ case "task_assign": {
687
+ const peer = peerArg(a.peer, "peer");
688
+ if (!peer) throw new Error("peer is required");
689
+ return line(await tasks.assignTo(a.id, peer));
690
+ }
666
691
  case "task_escalate":
667
692
  return line(await tasks.escalate(USER, a.id));
668
693
  case "route_explain":
@@ -908,13 +933,26 @@ export async function startDaemon(opts: DaemonOptions) {
908
933
  const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
909
934
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
910
935
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
936
+ const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
911
937
  for (const step of crashPlan(prev.peers)) {
938
+ // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
939
+ // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
940
+ if (step.peer === "pi" && piAutoStart) {
941
+ if (step.resume) {
942
+ const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
943
+ if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
944
+ report(`pi not resumed (${String(r.error)}); ${step.how}`);
945
+ } else report(step.how);
946
+ const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
947
+ const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
948
+ report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
949
+ continue;
950
+ }
912
951
  if (!step.resume || !autoResume) {
913
- const fresh = step.peer === "pi" && config.pi.enabled && config.pi.auto_start ? "; pi.auto_start starts a fresh session" : "";
914
- report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}${fresh}`);
952
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
915
953
  continue;
916
954
  }
917
- const r = await startPeer(step.peer, step.resume as Parameters<typeof startPeer>[1]).catch((e: Error) => ({ ok: false, error: e.message }));
955
+ const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
918
956
  report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`);
919
957
  }
920
958
  writeStatus();
@@ -1038,7 +1076,7 @@ export async function startDaemon(opts: DaemonOptions) {
1038
1076
 
1039
1077
  // One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
1040
1078
  const starting = new Map<string, Promise<Record<string, unknown>>>();
1041
- function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
1079
+ function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }): Promise<Record<string, unknown>> {
1042
1080
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
1043
1081
  if (peer === "pi" && starting.has(peer)) return Promise.resolve({ ok: false, error: "Pi start is in progress; inspect status before retrying" });
1044
1082
  const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
@@ -1062,7 +1100,7 @@ export async function startDaemon(opts: DaemonOptions) {
1062
1100
  };
1063
1101
  }
1064
1102
 
1065
- async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
1103
+ async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }): Promise<Record<string, unknown>> {
1066
1104
  let unmute: (() => void) | undefined;
1067
1105
  try {
1068
1106
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
@@ -1071,7 +1109,7 @@ export async function startDaemon(opts: DaemonOptions) {
1071
1109
  }
1072
1110
  }
1073
1111
 
1074
- async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
1112
+ async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
1075
1113
  if (peer === "pi") {
1076
1114
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Pi mode" };
1077
1115
  if (args.backend !== undefined && !["auto", "dgx", "mlx"].includes(args.backend)) return { ok: false, error: "invalid Pi backend" };
@@ -1087,7 +1125,8 @@ export async function startDaemon(opts: DaemonOptions) {
1087
1125
  const unclaimed = existing.state === "offline" && !saved.sessionId && !saved.sessionFile;
1088
1126
  if (changesOwner && (existing.state === "busy" || (existing.state !== "offline" && !existing.recoveryReady))) return { ok: false, error: "Pi is busy; wait for agent_settled before changing mode/backend" };
1089
1127
  if (unclaimed) {
1090
- if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
1128
+ // `fresh` (crash recovery's fallback, #66): the pending session is the one that just failed to load.
1129
+ if (!args.sessionId && !args.sessionFile && !args.fresh) args = { ...args, ...existing.pendingResume };
1091
1130
  // Revoke the previous launch bridge before issuing another launch. A late
1092
1131
  // process from the abandoned CLI cannot claim the replacement owner.
1093
1132
  // The replacement's start event is the only state change the console should see (issue #42).
@@ -1177,7 +1216,8 @@ export async function startDaemon(opts: DaemonOptions) {
1177
1216
  let piReply: Envelope | undefined;
1178
1217
  const ctx: ToolContext = {
1179
1218
  cwd: opts.cwd, deny: config.local.deny,
1180
- sandboxProfile: profile(opts.cwd, config.local.bash_network, config.local.read_allow, config.local.deny, config.local.sandbox === "allow-default" ? "allow" : "deny"),
1219
+ sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.local.sandbox === "allow-default" ? "allow" : "deny"),
1220
+ sandboxEnv: proxyEnv(sandboxNetwork),
1181
1221
  permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
1182
1222
  send: (text, to) => {
1183
1223
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
@@ -1268,7 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
1268
1308
  omni,
1269
1309
  ...(sidecar && route ? { sidecar, route } : {}),
1270
1310
  fixedModel: args.model ?? routing.local.fixed_model,
1271
- tools: { deny: config.local.deny, bashNetwork: config.local.bash_network, readAllow: config.local.read_allow, sandbox: config.local.sandbox, permit },
1311
+ tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, sandbox: config.local.sandbox, permit },
1272
1312
  ...(capture ? { capture } : {}),
1273
1313
  taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
1274
1314
  turnPolicy: (envs) => tasks.turnPolicy(envs),
@@ -1733,6 +1773,7 @@ export async function startDaemon(opts: DaemonOptions) {
1733
1773
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
1734
1774
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
1735
1775
  await modelRelay?.close();
1776
+ await egress?.close();
1736
1777
  await piReceipts?.close();
1737
1778
  budget.close();
1738
1779
  board.close();
@@ -1774,11 +1815,15 @@ export async function startDaemon(opts: DaemonOptions) {
1774
1815
  if (crashed) {
1775
1816
  // This run owns the record now, so its clean stop removes it even if no peer attaches to rewrite it.
1776
1817
  try { if (readSessions(opts.stateDir)?.instanceId === crashed.instanceId) writeSessions(opts.stateDir, { ...crashed, instanceId, at: Date.now() }); } catch (error) { log(`session record not adopted: ${(error as Error).message}`); }
1777
- void recoverAfterCrash(crashed).catch((error) => log(`crash recovery failed: ${(error as Error).message}`));
1818
+ void recoverAfterCrash(crashed).catch((error) => {
1819
+ log(`crash recovery failed: ${(error as Error).message}`);
1820
+ // pi.auto_start still holds: a running Pi answers `already`, a starting one refuses the second start.
1821
+ if (piAutoStart) void startPeer("pi", {}).catch((e) => log(`Pi auto-start failed: ${e.message}`));
1822
+ });
1778
1823
  }
1779
- // Skipped only when crash recovery itself starts Pi again on its recorded session.
1780
- const piResumes = !!crashed && autoResume && crashPlan(crashed.peers).some((s) => s.peer === "pi" && s.resume);
1781
- if (config.pi.enabled && config.pi.auto_start && !recoveryActive() && !piResumes) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1824
+ // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
1825
+ const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
1826
+ if (piAutoStart && !recoveryActive() && !piRecovered) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1782
1827
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
1783
1828
  } finally {
1784
1829
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
package/src/hub/tasks.ts CHANGED
@@ -45,13 +45,18 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
45
45
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
46
46
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
47
47
 
48
+ /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
49
+ export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
50
+
48
51
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
49
52
  export const samePlace = (a: string, b: string) => {
50
- const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
51
- const [x, y] = [norm(a), norm(b)];
53
+ const [x, y] = [normPath(a), normPath(b)];
52
54
  return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
53
55
  };
54
56
 
57
+ /** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
58
+ const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
59
+
55
60
  /** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
56
61
  const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
57
62
  /** A list of short strings; a lone string counts as a list of one. */
@@ -61,7 +66,7 @@ const fields = (input: unknown) => (input && typeof input === "object" && !Array
61
66
  /** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
62
67
  function cleanRefs(input: unknown): TaskRefs {
63
68
  const r = fields(input);
64
- const paths = textList(r.paths);
69
+ const paths = [...new Set(textList(r.paths).map(normPath))];
65
70
  const out: TaskRefs = {};
66
71
  for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
67
72
  if (paths.length) out.paths = paths;
@@ -71,7 +76,7 @@ function cleanRefs(input: unknown): TaskRefs {
71
76
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
72
77
  function cleanPlan(input: unknown): TaskPlan {
73
78
  const r = fields(input);
74
- return Object.fromEntries(PLAN_KEYS.map((k) => [k, textList(r[k])] as const).filter(([, v]) => v.length));
79
+ return Object.fromEntries(PLAN_KEYS.map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
75
80
  }
76
81
 
77
82
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -147,7 +152,15 @@ export class Tasks {
147
152
 
148
153
  /** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
149
154
  private requestedChanges(task: Task): Set<PeerId> {
150
- const since = task.history.findLastIndex((h) => ["assigned", "escalated", "reassigned", "unassigned"].includes(h.event));
155
+ // The window starts where the owner last changed. Newer entries carry the owner, so handing a task to the owner it
156
+ // already had (`ahub task assign <id> <owner>`) keeps the window; older ones do not, and any of them starts it.
157
+ let since = -1;
158
+ let owner: PeerId | null | undefined = null;
159
+ task.history.forEach((h, i) => {
160
+ if (!OWNERSHIP_EVENTS.has(h.event)) return;
161
+ if (h.owner === undefined || h.owner !== owner) since = i;
162
+ owner = h.owner;
163
+ });
151
164
  return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
152
165
  }
153
166
 
@@ -171,8 +184,10 @@ export class Tasks {
171
184
  const approval = [...t.history].reverse().find((h) => h.event === "approved");
172
185
  if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
173
186
  const theirs = this.places(t);
174
- // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it.
175
- const same = mine.paths.some((p) => theirs.paths.includes(p)) || mine.symbols.some((x) => theirs.symbols.includes(x));
187
+ // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
188
+ // were stored as written, so both sides are compared in one spelling.
189
+ const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
190
+ const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
176
191
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
177
192
  this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
178
193
  }
@@ -251,6 +266,18 @@ export class Tasks {
251
266
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
252
267
  nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
253
268
 
269
+ /**
270
+ * Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
271
+ * that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
272
+ * ordinary task's turn like any other (gateway off campus allowed, memory capture on). The board keeps the text for
273
+ * `ahub task show`. A PII task's messages are private already.
274
+ */
275
+ private screen(task: Task, text: string, what: string, to: PeerId | null | undefined): string {
276
+ if (!text || this.isPii(task) || to === USER || this.nameable(text)) return text;
277
+ this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (it matches a PII pattern)`);
278
+ return `[${what} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
279
+ }
280
+
254
281
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
255
282
  private where(hit: { paths: string[]; symbols: string[] }): string {
256
283
  const shown = [...hit.paths.filter(this.nameable), ...hit.symbols.filter(this.nameable).map((s) => `symbol ${s}`)];
@@ -368,7 +395,7 @@ export class Tasks {
368
395
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
369
396
  const pii = this.isPii(task);
370
397
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
371
- const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
398
+ const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
372
399
  const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
373
400
  const plan = planText(task.plan);
374
401
  const body = [
@@ -380,7 +407,7 @@ export class Tasks {
380
407
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
381
408
  brief ?? "",
382
409
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
383
- context && !pii ? `Handoff from the previous owner:\n${context.slice(0, 3000)}` : "",
410
+ context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
384
411
  `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
385
412
  ].filter(Boolean).join("\n\n");
386
413
  this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
@@ -431,8 +458,11 @@ export class Tasks {
431
458
  }
432
459
 
433
460
  private need(id: unknown, open = false): Task {
434
- const task = this.d.board.get(Number(id));
435
- if (!task) throw new Error(`no task #${id}`);
461
+ // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
462
+ const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
463
+ if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
464
+ const task = this.d.board.get(n);
465
+ if (!task) throw new Error(`no task #${n}`);
436
466
  // Handing a task to someone else only makes sense while there is work left on it.
437
467
  if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
438
468
  return task;
@@ -590,7 +620,8 @@ export class Tasks {
590
620
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
591
621
  `- List what is unmet in hub_review's unmet, one item each.`,
592
622
  ].join("\n");
593
- const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
623
+ const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
624
+ const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
594
625
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
595
626
  }
596
627
 
@@ -613,7 +644,7 @@ export class Tasks {
613
644
  }
614
645
  }
615
646
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
616
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
647
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner)}` : ""}`, pii);
617
648
  await this.releaseDependents(next);
618
649
  return next;
619
650
  }
@@ -625,11 +656,11 @@ export class Tasks {
625
656
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
626
657
  if (moved.owner !== rejected.owner) return moved;
627
658
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
628
- this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${note}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
659
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
629
660
  return moved;
630
661
  }
631
662
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
632
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${note}` : ""} Fix it and call hub_task_done again.`, pii);
663
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner)}` : ""} Fix it and call hub_task_done again.`, pii);
633
664
  return reopened;
634
665
  }
635
666
 
@@ -719,6 +750,8 @@ export class Tasks {
719
750
  /** Auto notes: only transitions that carry content, never for PII. */
720
751
  private note(task: Task, by: PeerId, kind: string, text: string): void {
721
752
  if (this.isPii(task) || !this.d.memory) return;
753
+ // claude-mem's observer is a cloud model: model-written text that matches a PII pattern is not sent (#69).
754
+ if (!this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (it matches a PII pattern)`);
722
755
  void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
723
756
  }
724
757
 
package/src/local/deny.ts CHANGED
@@ -30,14 +30,14 @@ export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"
30
30
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
31
31
  * anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
32
32
  */
33
- export function denyRegexes(root: string, extra: string[] = []): string[] {
33
+ export function denyRegexes(root: string, extra: string[] = [], withNames = true): string[] {
34
34
  const names = DENY_NAMES.map((re) => {
35
35
  const body = re.startsWith("^") ? re.slice(1) : `[^/]*${re}`;
36
36
  return `/${body}${body.endsWith("$") ? "" : "[^/]*$"}`;
37
37
  });
38
38
  return [
39
39
  ...DENY_SEGMENTS.map((s) => `/${sbplEscape(s)}(/|$)`),
40
- ...names,
40
+ ...(withNames ? names : []),
41
41
  ...extra.filter(Boolean).map((e) => `^${sbplEscape(root)}/.*${sbplEscape(e)}`),
42
42
  ].map((re) => `(regex ${sbplString(re)})`);
43
43
  }
@@ -0,0 +1,142 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { BlockList, isIP, connect, createServer, type Server, type Socket } from "node:net";
3
+
4
+ /**
5
+ * The only way out for the local worker's commands when `local.bash_network` is on (issue #65): an HTTP CONNECT proxy
6
+ * on loopback that opens tunnels to allowlisted hosts only. CONNECT only, so TLS stays end to end; plain HTTP is
7
+ * refused. A name that resolves to a loopback, private or link-local address is refused unless it is listed as an
8
+ * address: claude-mem and the Codex app-server listen on loopback without auth.
9
+ */
10
+
11
+ /** What a project gets unless `local.network_allow` says otherwise: package registries and code hosts. */
12
+ export const DEFAULT_NETWORK_ALLOW = [
13
+ "registry.npmjs.org",
14
+ "registry.yarnpkg.com",
15
+ "pypi.org",
16
+ "files.pythonhosted.org",
17
+ "github.com",
18
+ "codeload.github.com",
19
+ "objects.githubusercontent.com",
20
+ "raw.githubusercontent.com",
21
+ "crates.io",
22
+ "static.crates.io",
23
+ "index.crates.io",
24
+ "proxy.golang.org",
25
+ "sum.golang.org",
26
+ ];
27
+
28
+ export interface EgressProxy {
29
+ port: number;
30
+ url: string;
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
35
+ export function allowed(list: string[], host: string, port: number): boolean {
36
+ const h = host.toLowerCase().replace(/\.$/, "");
37
+ return list.some((raw) => {
38
+ const entry = raw.trim().toLowerCase();
39
+ const at = entry.lastIndexOf(":");
40
+ const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
41
+ if (!name || p !== port) return false;
42
+ return h === name || (!isIP(name) && h.endsWith(`.${name}`));
43
+ });
44
+ }
45
+
46
+ // Parsed, not prefix-matched: an IPv4-mapped IPv6 address in any spelling (`::ffff:7f00:1`) checks as its IPv4 address.
47
+ const INTERNAL = new BlockList();
48
+ // IPv4 nets as leading octets: the package check refuses private address literals in published files.
49
+ for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
50
+ for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
51
+
52
+ /** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
53
+ export function isInternal(address: string): boolean {
54
+ const family = isIP(address);
55
+ return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
56
+ }
57
+
58
+ const hostOf = (target: string) => {
59
+ try {
60
+ return ` ${new URL(target).host}`;
61
+ } catch {
62
+ return "";
63
+ }
64
+ };
65
+
66
+ export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
67
+ const sockets = new Set<Socket>();
68
+ // Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
69
+ const server: Server = createServer({ allowHalfOpen: true }, (client) => {
70
+ sockets.add(client);
71
+ client.on("close", () => sockets.delete(client));
72
+ client.on("error", () => client.destroy());
73
+ let head = Buffer.alloc(0);
74
+ const onData = (chunk: Buffer) => {
75
+ head = Buffer.concat([head, chunk]);
76
+ const end = head.indexOf("\r\n\r\n");
77
+ if (end < 0) {
78
+ if (head.length > 8192) refuse(client, 431, "request header too large");
79
+ return;
80
+ }
81
+ client.off("data", onData);
82
+ client.pause();
83
+ const rest = head.subarray(end + 4);
84
+ const [method = "", target = "", version = ""] = head.subarray(0, end).toString("latin1").split("\r\n")[0]!.split(" ");
85
+ // Answer in the version asked (macOS nc wants HTTP/1.0 back); curl and git take either.
86
+ void tunnel(client, method, target, rest, version === "HTTP/1.0" ? "HTTP/1.0" : "HTTP/1.1");
87
+ };
88
+ client.on("data", onData);
89
+ });
90
+
91
+ const refuse = (client: Socket, code: number, why: string, target = "") => {
92
+ if (target) opts.log(`network: refused ${target} (${why})`);
93
+ client.end(`HTTP/1.1 ${code} ${code === 403 ? "Forbidden" : "Bad Request"}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nagent-hub: ${why}\n`);
94
+ };
95
+
96
+ const tunnel = async (client: Socket, method: string, target: string, rest: Buffer, http: string) => {
97
+ // The log gets a method and a host, never a path, a query or userinfo: those carry tokens.
98
+ if (method.toUpperCase() !== "CONNECT") return refuse(client, 403, "only CONNECT (https) goes through this proxy", `${/^[A-Za-z]{1,16}$/.test(method) ? method : "a request"}${hostOf(target)}`);
99
+ const m = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(target);
100
+ const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
101
+ if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
102
+ const port = Number(m[2]);
103
+ if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
104
+ let address = host;
105
+ if (!isIP(host)) {
106
+ try {
107
+ address = (await lookup(host)).address;
108
+ } catch {
109
+ return refuse(client, 403, `${host} does not resolve`, `${host}:${port}`);
110
+ }
111
+ // A listed name must not lead back into this machine or its network (DNS can say anything).
112
+ if (isInternal(address)) return refuse(client, 403, `${host} resolves to an internal address`, `${host}:${port}`);
113
+ }
114
+ const upstream = connect({ host: address, port, allowHalfOpen: true });
115
+ sockets.add(upstream);
116
+ upstream.on("close", () => sockets.delete(upstream));
117
+ upstream.once("connect", () => {
118
+ client.write(`${http} 200 Connection Established\r\n\r\n`);
119
+ if (rest.length) upstream.write(rest);
120
+ client.pipe(upstream);
121
+ upstream.pipe(client);
122
+ client.resume();
123
+ });
124
+ upstream.on("error", () => (client.writable && !client.destroyed ? refuse(client, 403, `${host}:${port} is unreachable`) : client.destroy()));
125
+ client.on("close", () => upstream.destroy());
126
+ };
127
+
128
+ return new Promise((resolve, reject) => {
129
+ server.once("error", reject);
130
+ server.listen(0, "127.0.0.1", () => {
131
+ const port = (server.address() as { port: number }).port;
132
+ resolve({
133
+ port,
134
+ url: `http://127.0.0.1:${port}`,
135
+ close: () => new Promise((done) => {
136
+ for (const s of sockets) s.destroy();
137
+ server.close(() => done());
138
+ }),
139
+ });
140
+ });
141
+ });
142
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
@@ -26,10 +26,16 @@ const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache",
26
26
  */
27
27
  const CA_BUNDLES = ["/private/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"];
28
28
 
29
- /** The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it. */
29
+ /**
30
+ * The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it.
31
+ * Inside an app bundle that is the whole `Contents`: its tools load frameworks from `SharedFrameworks` next to it.
32
+ */
30
33
  function developerDir(): string | undefined {
31
34
  const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
32
- return out.status === 0 && out.stdout.trim() ? out.stdout.trim() : undefined;
35
+ let dir = out.status === 0 ? out.stdout.trim() : "";
36
+ // Seatbelt matches real paths: an Xcode selected through a symlink (`Xcode.app` -> `Xcode-26.0.app`) needs its target.
37
+ try { dir = dir && realPath(dir); } catch { /* gone since it was selected: nothing the shims could run either */ }
38
+ return dir ? dir.replace(/(\.app\/Contents)\/Developer\/?$/, "$1") : undefined;
33
39
  }
34
40
 
35
41
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
@@ -49,13 +55,21 @@ const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt",
49
55
  */
50
56
  const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
51
57
  const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "com.apple.trustd.agent", "com.apple.networkd"];
58
+ /** Through the egress proxy (#65) the proxy resolves names: only TLS trust is looked up here, no DNS that could carry data. */
59
+ const PROXY_MACH_SERVICES = ["com.apple.trustd", "com.apple.trustd.agent"];
60
+
61
+ /**
62
+ * Network for the commands: none, direct (`local.bash_network: "direct"`, everything, kept for one release), or only
63
+ * the hub's egress proxy on a loopback port (#65), which opens allowlisted hosts.
64
+ */
65
+ export type SandboxNetwork = boolean | { proxyPort: number };
52
66
 
53
67
  /**
54
68
  * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
55
69
  * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
56
70
  * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
57
71
  */
58
- export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
72
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
59
73
  const home = homedir();
60
74
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
61
75
  const root = realPath(cwd);
@@ -66,41 +80,46 @@ export function profile(cwd: string, network: boolean, readAllow: string[] = [],
66
80
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
67
81
  const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
68
82
  const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
83
+ const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
69
84
  const start = base === "deny"
70
85
  ? [
71
86
  "(deny default)",
72
87
  "(allow process-fork)",
73
88
  // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
74
- `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable, tmp, "/private/tmp"])})`,
89
+ // The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
90
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
75
91
  "(allow signal (target same-sandbox))",
76
92
  "(allow process-info* (target same-sandbox))",
77
93
  "(allow sysctl-read)",
78
- `(allow mach-lookup ${globals([...MACH_SERVICES, ...(network ? NETWORK_MACH_SERVICES : [])])})`,
94
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
79
95
  '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
80
96
  "(allow file-read-metadata)",
81
- `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, tmp, "/private/tmp", "/dev"])} ${subpaths(readable)})`,
97
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
82
98
  '(allow file-ioctl (regex #"^/dev/"))',
83
- ...(network ? ["(allow network*)"] : []),
99
+ ...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
84
100
  ]
85
101
  : [
86
102
  "(allow default)",
87
- ...(network ? [] : ["(deny network*)"]),
103
+ ...(network === true ? [] : ["(deny network*)", ...(proxy ? [proxy] : [])]),
88
104
  // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
89
105
  // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
90
106
  `(deny file-read* (subpath ${q(home)}))`,
91
107
  `(allow file-read-metadata (subpath ${q(home)}))`,
92
108
  `(allow file-read* ${subpaths(readable)})`,
93
109
  ];
110
+ const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
94
111
  return [
95
112
  "(version 1)",
96
113
  ...start,
97
114
  "(deny file-write*)",
98
- `(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
115
+ `(allow file-write* (subpath ${q(root)}) ${base === "deny" ? "" : `(subpath ${q(tmp)}) (subpath "/private/tmp") `}(regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
99
116
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
100
117
  `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
101
118
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
102
119
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
103
- ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")})`] : []),
120
+ // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
121
+ // The last match wins: the denied places come again after it, so it overrides only the `.pem` name rule.
122
+ ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")} (regex ${q("/certifi/cacert\\.pem$")}))`, `(deny file-read* file-write* ${places})`] : []),
104
123
  ].join("\n");
105
124
  }
106
125
 
@@ -110,12 +129,16 @@ export interface ExecResult {
110
129
  }
111
130
 
112
131
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
113
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number }): Promise<ExecResult> {
132
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string> }): Promise<ExecResult> {
114
133
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
115
134
  return new Promise((resolve) => {
116
- const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", TMPDIR: tmpdir() };
135
+ // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
136
+ // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
137
+ const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
138
+ const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
139
+ const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
117
140
  // Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
118
- const child = spawn(SANDBOX_EXEC, ["-p", opts.profile, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
141
+ const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
119
142
  let output = "";
120
143
  const take = (d: Buffer) => {
121
144
  if (output.length < OUTPUT_CAP) output += d.toString();
@@ -129,18 +152,33 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
129
152
  child.kill("SIGKILL");
130
153
  }
131
154
  };
155
+ // Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
156
+ // the plain remove throws. What still fails is left to the OS temp cleanup.
157
+ const dropOwn = () => {
158
+ try { rmSync(own, { recursive: true, force: true }); } catch {
159
+ try { spawnSync("chflags", ["-R", "nouchg", own]); rmSync(own, { recursive: true, force: true }); } catch { /* left behind */ }
160
+ }
161
+ };
132
162
  const timer = setTimeout(killGroup, opts.timeoutMs ?? 120_000);
133
163
  let done = false;
134
164
  const finish = (code: number | null, signal: NodeJS.Signals | null) => {
135
165
  if (done) return;
136
166
  done = true;
137
167
  clearTimeout(timer);
168
+ dropOwn();
138
169
  const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
139
170
  resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
140
171
  };
141
- child.on("error", (e) => (done || ((done = true), resolve({ code: null, output: `error: ${e.message}` }))));
172
+ child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
142
173
  child.on("close", finish);
143
174
  // `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
144
175
  child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
145
176
  });
146
177
  }
178
+
179
+ /** The environment that sends a command's HTTPS through the egress proxy (#65); empty without one. */
180
+ export function proxyEnv(network: SandboxNetwork): Record<string, string> {
181
+ if (typeof network !== "object") return {};
182
+ const url = `http://127.0.0.1:${network.proxyPort}`;
183
+ return { HTTPS_PROXY: url, https_proxy: url, HTTP_PROXY: url, http_proxy: url, ALL_PROXY: url, all_proxy: url, NO_PROXY: "", no_proxy: "" };
184
+ }
@@ -12,6 +12,8 @@ export interface ToolContext {
12
12
  deny: string[];
13
13
  /** Seatbelt profile for this project, built once per worker (`profile()` in sandbox.ts): it spawns git and must not run per tool call. */
14
14
  sandboxProfile: string;
15
+ /** Extra environment for sandboxed commands: the egress proxy's variables when network goes through it (#65). */
16
+ sandboxEnv?: Record<string, string>;
15
17
  /** Ask the console. Resolves false on deny or timeout. */
16
18
  permit: (title: string) => Promise<boolean>;
17
19
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -136,7 +138,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
136
138
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
137
139
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
138
140
  if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
139
- const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000 });
141
+ const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
140
142
  return `${res.output}\n(exit ${res.code})`;
141
143
  }
142
144
  case "git": {
@@ -147,7 +149,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
147
149
  if (problem) return `error: git ${sub}: ${problem}`;
148
150
  if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
149
151
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
150
- const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile });
152
+ const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
151
153
  return `${res.output}\n(exit ${res.code})`;
152
154
  }
153
155
  case "hub_send":