@turmind/halo 1.3.4-c3090bf → 1.4.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.
Files changed (39) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +1 -0
  3. package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +1 -0
  4. package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +3 -0
  5. package/admin-out/index.html +1 -1
  6. package/admin-out/index.txt +5 -5
  7. package/bundled-docs/design/agent.md +12 -6
  8. package/bundled-docs/design/cron.md +19 -11
  9. package/bundled-docs/design/express-self.md +1 -1
  10. package/bundled-docs/design/storage.md +7 -3
  11. package/bundled-docs/design/wechat.md +1 -1
  12. package/bundled-docs/design/ws.md +2 -1
  13. package/bundled-docs/dev/add-model-provider.md +1 -1
  14. package/bundled-docs/dev/api.md +6 -6
  15. package/bundled-docs/dev/deploy.md +1 -1
  16. package/bundled-docs/dev/desktop-packaging.md +1 -1
  17. package/bundled-docs/dev/env.md +4 -4
  18. package/bundled-docs/dev/tools.md +39 -17
  19. package/bundled-docs/guide/chat.md +10 -0
  20. package/bundled-docs/guide/cli.md +8 -3
  21. package/bundled-docs/requirements/chat.md +8 -0
  22. package/dist/index.js +498 -68
  23. package/package.json +1 -1
  24. package/templates/agents/__apply_agent__/agent.yaml +1 -1
  25. package/templates/agents/__evo_agent__/agent.yaml +1 -1
  26. package/templates/agents/__score__/agent.yaml +1 -1
  27. package/templates/agents/deep-executor/agent.yaml +1 -1
  28. package/templates/agents/default/agent.yaml +1 -1
  29. package/templates/agents/goal/agent.yaml +1 -1
  30. package/templates/models/mimo-token-plan-china.yaml +13 -6
  31. package/templates/skills/agent/SKILL.md +2 -2
  32. package/templates/skills/cron/SKILL.md +15 -0
  33. package/templates/skills/cron/manage-cron.py +28 -3
  34. package/templates/skills/send-file/SKILL.md +1 -0
  35. package/admin-out/_next/static/chunks/989-e660d0dd799950f2.js +0 -1
  36. package/admin-out/_next/static/chunks/app/page-18ab83dbdcc3897c.js +0 -1
  37. package/admin-out/_next/static/css/59402ac06b2b1a10.css +0 -3
  38. /package/admin-out/_next/static/{BVMDWdlRNum62_KgIRZVc → 9_eFiojb_-MS8CJ2oJg8_}/_buildManifest.js +0 -0
  39. /package/admin-out/_next/static/{BVMDWdlRNum62_KgIRZVc → 9_eFiojb_-MS8CJ2oJg8_}/_ssgManifest.js +0 -0
@@ -12,7 +12,7 @@ Compression: every response negotiates gzip/deflate via `hono/compress` (`thresh
12
12
 
13
13
  | Method | Path | Purpose |
14
14
  |---|---|---|
15
- | GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine, version, gitSha}` (`gitSha`: short sha on source builds, `-dirty` suffix if tree modified, `null` on published bundles) |
15
+ | GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine, version, gitSha, sandbox}` (`gitSha`: short sha on source builds, `-dirty` suffix if tree modified, `null` on published bundles; `sandbox`: OS sandbox backing non-full access levels — `'bwrap'` / `'seatbelt'` / `null`; the admin locks its access-level selector to Full on `null`) |
16
16
 
17
17
  ## Authentication
18
18
 
@@ -86,7 +86,7 @@ Unified session log API — list + read session files across all agents.
86
86
 
87
87
  | Method | Path | Purpose |
88
88
  |---|---|---|
89
- | GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-header dropdown) |
89
+ | GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-header dropdown). A `projectId` with no `.halo/` (plain or missing directory) returns `{sessions: [], nextCursor: null}` without scaffolding it — a list never turns a directory into a workspace |
90
90
  | GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) — the **active** file only; archived history is a separate call |
91
91
  | GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
92
92
  | DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
@@ -184,9 +184,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
184
184
 
185
185
  | Method | Path | Purpose |
186
186
  |---|---|---|
187
- | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded and a live `nextRunAt` (computed from schedule/runAt) |
188
- | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. Returns `{ok, id}` |
189
- | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
187
+ | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt), and `sessionId` (`null` = runs in `cron-<jobId>`) |
188
+ | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the cli to run in — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own `cron-<jobId>` session. A picked session that already exists keeps its own agent and access level (`agentId` only applies when the cli creates it); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. Returns `{ok, id}` |
189
+ | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `sessionId` likewise: omitted = unchanged, `null` / `''` = back to `cron-<jobId>`, a string is validated same as create. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
190
190
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
191
191
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
192
192
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
@@ -317,7 +317,7 @@ File: `packages/server/src/routes/agent-configs.ts`
317
317
 
318
318
  | Method | Path | Purpose |
319
319
  |---|---|---|
320
- | GET | `/api/agent-configs?projectId=` | List agents (global + workspace merged) |
320
+ | GET | `/api/agent-configs?projectId=` | List agents (global + workspace merged). A `projectId` with no `.halo/` yields the global agents only and is not scaffolded |
321
321
  | GET | `/api/agent-configs/tools` | Available workspace tools |
322
322
  | GET | `/api/agent-configs/models` | Available model providers and models |
323
323
  | POST | `/api/agent-configs` | Create a new agent |
@@ -202,7 +202,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
202
202
  2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it, and roll the link references at the bottom (add `[x.y.z]: compare/v<prev>...vx.y.z`, repoint `[Unreleased]` to `compare/vx.y.z...HEAD`).
203
203
  3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched. `build-bundle.mjs` enforces this: it diffs `packages/server/templates` against the previous release tag and exits 1 when files changed but the number didn't move (so `pnpm bundle` and every desktop `dist:*` refuse to package a silent template edit).
204
204
  4. **Build admin**: `pnpm --filter @turmind/halo-admin build` — verify `admin/out/monaco/vs/loader.js` exists.
205
- 5. Commit, tag `vx.y.z`, push, then `npm publish`. The registry replicates with a ~2–3 min lag: `npm view @turmind/halo version` may still show the old version right after publish — a `409 Conflict` on retry confirms the first publish landed; don't re-publish.
205
+ 5. Commit, tag `vx.y.z`, push, then `HALO_RELEASE=1 pnpm --filter @turmind/halo-cli bundle` and `npm publish` from `packages/cli/dist-pub/` — **exactly once**. Confirm with `npm view @turmind/halo version` (the registry replicates with a ~2–3 min lag, so wait and re-`view`; never "retry" the publish to see what happens). `dist-pub/` is only written in release mode; desktop `dist:*` builds stage into `dist-dev/` instead, so a stale or sha-suffixed bundle can't end up there — but `npm publish` is still not idempotent (v1.3.4 shipped a `1.3.4-<sha>` prerelease to npm from a second publish and it became `latest` for ~90s).
206
206
  6. **GitHub release**: `gh release create vx.y.z --notes-from-tag` (or paste the CHANGELOG section).
207
207
  7. **Windows exe** (the step both v1.3.0 and v1.3.1 skipped): `HALO_STAGE_FULL=1 CI=true pnpm dist:win`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. The macOS dmg is built on a Mac and uploaded the same way.
208
208
  8. **Post-check** (whoever ran the release does NOT get to skip this): `gh release view vx.y.z --json assets` must list the exe, and `npm view @turmind/halo version` must print `x.y.z`. A release without the exe attached is not done.
@@ -121,7 +121,7 @@ dependency work. Three levers, in order of how often they help:
121
121
 
122
122
  1. **Auto-fast (automatic).** A full `pnpm dist:arm64` now fingerprints the
123
123
  dependency inputs (`packages/server/package.json`, `core/package.json`, the
124
- cli bundle's `dist-pub/package.json`, `pnpm-lock.yaml`, + target/node
124
+ cli bundle's `dist-dev/package.json`, `pnpm-lock.yaml`, + target/node
125
125
  version) and records it in `resources/.stage-meta`. On the next run, if a
126
126
  prior full stage exists for the same target **and the fingerprint is
127
127
  unchanged**, staging auto-takes the fast path: it re-syncs only the compiled
@@ -124,9 +124,9 @@ settings.yaml only (no env override):
124
124
  - `general.compact.max_message_slice` (default 800) — local compaction per-message cap
125
125
  - `general.compact.summarize_timeout_sec` (default 300) — self-compact timeout
126
126
  - `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
127
- - `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — bwrap tmpfs overlays, scope: global
128
- - `general.sandbox.writable_dirs` (default empty) — dirs bind-mounted read-write inside the bwrap sandbox, for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
129
- - `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — bwrap /dev/null binds, scope: global
127
+ - `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — hidden from workspace/readonly sessions (bwrap tmpfs overlays on Linux, Seatbelt deny on macOS), scope: global
128
+ - `general.sandbox.writable_dirs` (default empty) — dirs writable inside the sandbox besides the workspace (Linux / macOS), for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
129
+ - `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), scope: global
130
130
  - `general.logging.level` (default `warn`) — log level: debug | info | warn | error
131
131
  - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required. See [design/observability.md](../design/observability.md)
132
132
  - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
@@ -180,4 +180,4 @@ curl -s -o /dev/null -w "%{http_code}" http://localhost:9527
180
180
  - React functional components; styling via Tailwind only; UI prefers shadcn/ui
181
181
  - Log format `[Module] message`
182
182
  - Bedrock model ID: `global.anthropic.claude-sonnet-4-6`, default region `us-east-1` (configured per agent via `agent.yaml model.endpoint`)
183
- - File operations are sandboxed by bwrap (OS-level) + `assertPathAllowed` (app-level fallback) for non-full sessions
183
+ - File operations are sandboxed for non-full sessions by the OS sandbox (bwrap on Linux; Seatbelt for `shell_exec` on macOS) + `assertPathAllowed` (in-process file tools); `shell_exec` also runs an rm guard at every level — see [tools.md](tools.md#access-level-per-session-dynamic)
@@ -112,24 +112,46 @@ Timeout 10 s. Max body 50 KB (truncated if larger). Returns status + content-typ
112
112
 
113
113
  ### Access level (per-session, dynamic)
114
114
 
115
- Each session carries `accessLevel: 'readonly' | 'workspace' | null` (persisted in `agent_sessions.access_level`). Sub-sessions inherit their parent's access level. Access level is re-evaluated on every user message — if the channel account's access level has changed, the agent instance is rebuilt with the new tool set and sandbox config.
115
+ Each session carries `accessLevel: 'readonly' | 'workspace' | null` (persisted in `agent_sessions.access_level`). Sub-sessions inherit their parent's access level. Access level is re-evaluated on every user message — if the channel account's access level has changed, the agent instance is rebuilt with the new tool set and sandbox config. In the admin, the level comes from the chat-input selector: the `chat` WS frame carries `accessLevel`, and `handleChat` applies it on the idle path (`'full'`, or any level on a host without an OS sandbox → `null`; a message queued behind a running turn keeps the level that turn was built with).
116
116
 
117
- | Level | DB value | Tools (with bwrap) | Tools (without bwrap) | Sandbox |
117
+ | Level | DB value | Tools (with OS sandbox) | Tools (without) | Sandbox |
118
118
  |---|---|---|---|---|
119
- | `null` (full) | `full` | All 9 tools | All 9 tools | None |
120
- | `workspace` | `workspace` | All 9 tools | All 9 tools | bwrap: workspace rw, sensitive paths + workspace runtime state hidden |
121
- | `readonly` | `readonly` | All 9 tools | file_read, view_image, file_list, grep, glob (5 tools) | bwrap: workspace ro, sensitive paths + workspace runtime state hidden |
119
+ | `null` (full) | `full` | All 9 tools | All 9 tools | None (rm guard still applies) |
120
+ | `workspace` | `workspace` | All 9 tools | All 9 tools | workspace rw, sensitive paths + workspace runtime state hidden |
121
+ | `readonly` | `readonly` | All 9 tools | file_read, view_image, file_list, grep, glob (5 tools) | workspace ro, sensitive paths + workspace runtime state hidden |
122
122
 
123
123
  `view_image` is additionally gated on `capabilities.image` (see its section above). Models without vision support get the same lists minus `view_image` — so a non-vision model on `readonly` ends up with 4 tools, not 5.
124
124
 
125
+ OS sandbox backend per platform — `getSandboxBackend()` in `sandbox.ts` returns `'bwrap' | 'seatbelt' | null` after the boot probe (`initBwrapCheck()`), logged at startup as `[Server] OS sandbox: …` and exposed as `sandbox` on `GET /api/health`:
126
+
127
+ | Platform | `shell_exec` | File tools | Dependency |
128
+ |---|---|---|---|
129
+ | Linux | bwrap | bwrap | `bubblewrap` (`apt install bubblewrap`); the probe runs a real sandboxed no-op, so an install that can't create namespaces (Ubuntu 24.04 AppArmor userns restriction) counts as unavailable |
130
+ | macOS | Seatbelt (`/usr/bin/sandbox-exec -p <profile>`) | in-process, behind `assertPathAllowed` | none — ships with macOS (Apple marks it deprecated, but it's present on every release). The probe runs `sandbox-exec -p '(version 1)(allow default)' /usr/bin/true` |
131
+ | Windows | none | none | — see below |
132
+
125
133
  Enforcement layers:
126
- 1. **OS sandbox (bwrap)**: `--ro-bind / /` mounts the entire filesystem read-only, then configurable overlays hide sensitive paths (`--tmpfs` for directories, `--ro-bind /dev/null` for files). Workspace level adds `--bind` (rw) for the workspace directory. `--tmpfs /tmp` provides isolated writable temp per invocation. Tool execution uses `execFileAsync` (not shell) to prevent escape. Error messages are sanitized to strip sandbox internals — the agent never sees bwrap flags or mount details.
127
- 2. **Tool filtering (bwrap fallback only)**: when bwrap is unavailable, `createWorkspaceTools()` returns a reduced 5-tool set for readonly (no file_write, file_edit, shell_exec, web_fetch). Workspace retains all tools.
128
- 3. **App-level path validation (bwrap fallback only)**: `assertPathAllowed()` validates every path against workspace + `~/.halo/global/`; `shell_exec` is blocked entirely without bwrap for non-full sessions.
134
+ 1. **OS sandbox**
135
+ - **bwrap (Linux)**: `--ro-bind / /` mounts the entire filesystem read-only, then configurable overlays hide sensitive paths (`--tmpfs` for directories; files are covered by `--ro-bind ~/.halo/.sandbox-empty <file>`, a zero-byte file created on demand — **not** `/dev/null`, whose bind reads as EACCES inside bwrap and makes git abort on `~/.gitconfig`). Workspace level adds `--bind` (rw) for the workspace directory. `--tmpfs /tmp` provides isolated writable temp per invocation. `--clearenv` + a minimal `PATH` / `HOME` / `TERM`.
136
+ - **Seatbelt (macOS)**: `buildSeatbeltProfile()` emits `(allow default)` → `(deny file-write*)` → `(allow file-write*)` for the workspace + `writable_dirs` + `/private/tmp`, `/private/var/folders`, `/dev` (readonly: temp dirs and `/dev` only) → `(deny file-read* file-write*)` on every hidden path (global lists + workspace-relative set; `subpath` for dirs, `literal` for files). Later rules win in SBPL, so the final hidden deny overrides the write allow. Paths are realpath'd (Seatbelt matches `/private/tmp`, not `/tmp`). The child gets a minimal env with `GIT_CONFIG_GLOBAL=/dev/null` and `NPM_CONFIG_USERCONFIG=/dev/null` so git / npm don't trip over the denied `~/.gitconfig` / `~/.npmrc`. Unlike bwrap there's no private `/tmp` — writes there persist.
137
+ - Both backends pass the host git identity (`git config --global user.name/user.email`, read once at boot) as `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, so `git commit` works with `~/.gitconfig` hidden. Nothing else from the host git config crosses over.
138
+ - Tool execution uses `execFileAsync` / `spawn` (not shell) to prevent escape. Error messages are sanitized to strip sandbox internals — the agent never sees bwrap flags or mount details.
139
+ - When a non-full `shell_exec` fails with a write-denial (`Read-only file system` / `Operation not permitted` / `Permission denied` / `EROFS` / `EPERM`), the tool result gets a `[Sandbox] This session runs at "<level>" access: …` hint telling the agent to ask the user to switch to Full in the chat input box, rather than retrying variations.
140
+ 2. **Tool filtering (no OS sandbox only)**: when `getSandboxBackend()` is `null`, `createWorkspaceTools()` returns a reduced 5-tool set for readonly (no file_write, file_edit, shell_exec, web_fetch). Workspace retains all tools.
141
+ 3. **In-process path validation**: `assertPathAllowed()` gates every file-tool path whenever the file tools don't run inside bwrap (macOS always; Linux without bwrap). Same rules as the OS sandbox: **read** anywhere except the hidden sets (global + workspace-relative), **write** only to the workspace (minus the hidden set) and `writable_dirs`, readonly never writes. Paths are realpath'd first, so a symlink is judged by its target. Without any OS sandbox, `shell_exec` is blocked entirely for non-full sessions.
142
+
143
+ On Linux without bwrap only layers 2 + 3 are active.
144
+
145
+ #### rm guard
146
+
147
+ `assertRmSafe(command, workspaceRoot)` runs before every `shell_exec` at **every** access level (full included) on every platform except Windows — the OS sandbox limits where writes land, but not a mistyped `rm -r` inside the writable area. It refuses an `rm` / `rmdir` whose target resolves to:
148
+ - `/`, `$HOME`, `~/.halo`, the workspace root, or any parent of those
149
+ - a system directory or its direct child (`/etc`, `/usr`, `/var`, `/home`, `/opt`, … plus macOS `/Applications`, `/Library`, `/System`, `/Users`, `/Volumes`, `/private`)
150
+ - `/tmp`, `/private/tmp`, `/root` themselves (their children are ordinary deletes — `/root` is `$HOME` in most containers)
129
151
 
130
- Dependency: `bubblewrap` (`apt install bubblewrap`) on Linux. Without it, only layers 2 + 3 are active.
152
+ Parsing: the command is split into simple commands on unquoted `;` `&` `|` newline, quotes stripped, heredoc bodies skipped (they're data being written, not commands). Leading wrappers / keywords (`sudo`, `env`, `xargs`, `nohup`, `time`, `if`/`then`/`do`/`while`, `VAR=…` assignments) are skipped to find the real command; `cd` earlier in the line moves the base for relative targets (default base = workspace root). `~`, `$HOME`, `$PWD` / `$(pwd)` expand; any other variable or substitution expands to empty — its value when unset, which is how `rm -rf "$DIR/"` goes wrong. A glob meaning "everything in X" (`*`, `.*`, `**`) is judged by X; narrower globs (`*.log`) pass. A block throws `[Sandbox] rm blocked: "<word>" resolves to <path>, which is <reason>. Name the specific files or subdirectories to delete instead.` It's a heuristic against mistakes, not a parser — `eval`, `bash -c "…"`, scripts and command substitution aren't followed.
131
153
 
132
- **Windows has no sandbox.** There is no bwrap equivalent, so `sandbox.ts` (`normalizeOptsForPlatform`) promotes every non-full call to `full` before it reaches layer 1 or layer 3 — `assertPathAllowed()` returns immediately without checking the workspace boundary or the hidden-path lists. Only layer 2 (tool filtering) survives, because `isBwrapCached()` is always false there. Net effect on Windows:
154
+ **Windows has no sandbox.** There is no bwrap / Seatbelt equivalent, so `sandbox.ts` (`normalizeOptsForPlatform`) promotes every non-full call to `full` before it reaches layer 1 or layer 3 — `assertPathAllowed()` returns immediately without checking the workspace boundary or the hidden-path lists, and the rm guard is skipped (cmd syntax, no `rm`). Only layer 2 (tool filtering) survives, because `getSandboxBackend()` is always `null` there. The admin's access-level selector reads `sandbox: null` from `/api/health` and locks itself to Full. Net effect on Windows:
133
155
 
134
156
  | Level | Effective behavior on Windows |
135
157
  |---|---|
@@ -141,15 +163,15 @@ This is a known, unfixed gap (not a bug in a specific route): don't hand out `wo
141
163
 
142
164
  ### Sandbox hidden paths
143
165
 
144
- Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays (and the same lists gate `assertPathAllowed` on the no-bwrap fallback). Paths that don't exist on the filesystem are silently skipped. Two categories coexist:
166
+ Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays / Seatbelt deny rules (and the same lists gate `assertPathAllowed` for in-process file tools). Under bwrap a hidden file reads as empty and a hidden dir as an empty directory; under Seatbelt both are denied outright. Paths that don't exist on the filesystem are silently skipped. Two categories coexist:
145
167
 
146
168
  **Global lists (configurable)** — credentials and cross-workspace state, configured in `settings.yaml` under `general.sandbox`:
147
169
 
148
170
  | Setting | Default | Method |
149
171
  |---|---|---|
150
- | `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | `--tmpfs` overlay (empty directory) |
151
- | `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | `--ro-bind /dev/null` (empty file) |
152
- | `writable_dirs` | (empty) | `--bind` read-write — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
172
+ | `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | bwrap `--tmpfs` overlay (empty directory); Seatbelt `subpath` deny |
173
+ | `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | bwrap `--ro-bind ~/.halo/.sandbox-empty` (reads as empty); Seatbelt `literal` deny |
174
+ | `writable_dirs` | (empty) | bwrap `--bind` read-write / Seatbelt write allow — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
153
175
 
154
176
  Changes take effect immediately — `config.ts` reads settings.yaml via an mtime-watched lazy cache, so the next `shell_exec` reads the latest values. These keys are `globalOnly` in the schema — a workspace `settings.yaml` cannot override them, since they define the security boundary agents run inside.
155
177
 
@@ -157,12 +179,12 @@ Changes take effect immediately — `config.ts` reads settings.yaml via an mtime
157
179
 
158
180
  | Constant | Entries | Method |
159
181
  |---|---|---|
160
- | `WORKSPACE_HIDDEN_DIRS` | `.halo/sessions` (session transcripts), `.halo/logs`, `.halo/evo` (run dirs contain full source-session snapshots) | `--tmpfs` overlay |
161
- | `WORKSPACE_HIDDEN_FILES` | `.halo/halo.db` + `-wal`/`-shm` (sqlite `agent_sessions` rows) | `--ro-bind /dev/null` |
182
+ | `WORKSPACE_HIDDEN_DIRS` | `.halo/sessions` (session transcripts), `.halo/logs`, `.halo/evo` (run dirs contain full source-session snapshots) | `--tmpfs` overlay / Seatbelt `subpath` deny |
183
+ | `WORKSPACE_HIDDEN_FILES` | `.halo/halo.db` + `-wal`/`-shm` (sqlite `agent_sessions` rows) | `--ro-bind ~/.halo/.sandbox-empty` / Seatbelt `literal` deny |
162
184
 
163
185
  The rest of `.halo/` (INSTRUCTIONS.md, INDEX.md, docs/, memory/, skills/, agents/, prompts/, tmp/, canvas/, goal/, settings.yaml) stays readable — it's workspace knowledge agents need to work. `full` sessions bypass the sandbox entirely and see everything.
164
186
 
165
- `/tmp` is not in the hidden list — it receives a standalone `--tmpfs` mount for process isolation (each bwrap invocation gets its own empty `/tmp`), not for hiding secrets.
187
+ `/tmp` is not in the hidden list — under bwrap it receives a standalone `--tmpfs` mount for process isolation (each invocation gets its own empty `/tmp`), not for hiding secrets. Seatbelt has no mount namespace, so on macOS `/tmp` is the real one and writes to it persist.
166
188
 
167
189
  Per-channel defaults:
168
190
  - **Web** — inherits account's `access_level` (default `full`)
@@ -10,6 +10,16 @@ The dropdown to the left of the input lists every agent (global + workspace).
10
10
  - Once you manually pick an agent, that choice is remembered across `clear` / new sessions until you pick something else.
11
11
  - **A conversation is locked to one agent**: once you start chatting, the dropdown locks. To change agent, start a new session (`/session new`).
12
12
 
13
+ ## Access level
14
+
15
+ The leftmost button of the input toolbar sets what the agent is allowed to change:
16
+
17
+ - **Full** — no restrictions.
18
+ - **Workspace** — the agent can read anywhere but write only inside this workspace. Credential files such as `~/.gitconfig` and `~/.git-credentials` are hidden from it. `git commit` still works, using your git name and email.
19
+ - **Readonly** — the agent can't write anything.
20
+
21
+ A change applies from the next message you send, and the level is saved per session. If a command fails because of the level, the agent will ask you to switch to Full instead of retrying. The button is locked to Full when the host can't enforce the other levels (Windows, or Linux without bubblewrap). On macOS the built-in `sandbox-exec` is used.
22
+
13
23
  ## Sending messages
14
24
 
15
25
  - Enter to send
@@ -18,8 +18,11 @@ pnpm --filter @turmind/halo-cli link --global
18
18
 
19
19
  The published package is `@turmind/halo` — a self-contained esbuild bundle
20
20
  (CLI + server + admin-out + templates), staged under `packages/cli/dist-pub/`
21
- by `scripts/build-bundle.mjs`. The workspace `@turmind/halo-cli` can't be
22
- published directly (it has `workspace:*` deps); the bundle flattens them.
21
+ by `scripts/build-bundle.mjs` when run with `HALO_RELEASE=1`. Without that flag
22
+ (desktop packaging, local bundle tests) the script writes a sha-suffixed build to
23
+ `packages/cli/dist-dev/` instead and never touches `dist-pub/`. The workspace
24
+ `@turmind/halo-cli` can't be published directly (it has `workspace:*` deps); the
25
+ bundle flattens them.
23
26
 
24
27
  ```bash
25
28
  # 1. bump packages/cli/package.json "version" (e.g. 0.1.2)
@@ -30,11 +33,13 @@ cd packages/admin && npx next build --no-lint && node scripts/copy-monaco.mjs &&
30
33
  # 3. stage the release bundle — HALO_RELEASE=1 stamps the bare version
31
34
  # (0.1.2), NOT the default <version>-<sha>: a `-<sha>` suffix is a semver
32
35
  # prerelease that `npm install` skips and never tags as `latest`.
36
+ # It also selects dist-pub/ as the output dir (dev builds go to dist-dev/).
33
37
  HALO_RELEASE=1 node packages/cli/scripts/build-bundle.mjs
34
- # 4. preview + publish
38
+ # 4. preview + publish — publish ONCE, then confirm with `npm view`
35
39
  cd packages/cli/dist-pub
36
40
  npm pack --dry-run # inspect tarball contents
37
41
  npm publish # @turmind/halo@<version>, tag latest, public
42
+ npm view @turmind/halo version # may lag 2-3 min; re-view, don't re-publish
38
43
  ```
39
44
 
40
45
  npm versions are immutable — a published version can never be overwritten,
@@ -11,6 +11,14 @@ The primary surface for talking to an agent.
11
11
  - **Locked during an active session** — the agent is bound to the session; to change, start a new session (/session new)
12
12
  - The Agents panel's "Test" button can also preselect an agent
13
13
 
14
+ ### Access level
15
+ - Leftmost control of the input toolbar (just left of the attach button), fixed width so switching doesn't shift the row; colored badge per level — Full (amber) / Workspace (blue) / Readonly (emerald). The dropdown shows each level with a one-line description
16
+ - **Full** — no restrictions; **Workspace** — writes limited to this workspace; **Readonly** — read only, no writes (OS-sandbox semantics in [dev/tools.md](../dev/tools.md#access-level-per-session-dynamic))
17
+ - Applies to the **next message** sent while the session is idle; disabled while a response is streaming (a message queued mid-turn runs at the level already in effect)
18
+ - Opening an existing session shows its stored level; a new session starts at Full, and a level picked before the first send sticks
19
+ - **Locked to Full** when the host has no OS sandbox (`/api/health` `sandbox: null` — e.g. Windows, or Linux without bubblewrap); the tooltip says why
20
+ - When a command fails because of the sandbox, the agent is told to ask the user to switch to Full here rather than retrying
21
+
14
22
  ### Message rendering
15
23
  - Markdown + code-block highlighting
16
24
  - Tool-call card: expandable, shows tool name / input / output