@nanhara/hara 0.122.0 → 0.122.1

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 (60) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.122.1 — 2026-07-14 — protected files and explicit extension trust
9
+
10
+ - **The npm CLI now requires Node.js 22.12+.** Node 20 is end-of-life, so Hara no longer makes a security or
11
+ compatibility promise for it. npm metadata, `hara doctor`, and startup diagnostics now agree; an older
12
+ runtime exits with a direct `nvm install 22 && nvm use 22` upgrade hint. Standalone binaries and Desktop
13
+ sidecars remain self-contained and do not require a host Node installation.
14
+ - **Built-in file access fails closed before ordinary approval/dispatch.** Canonical and real-path checks
15
+ reject `.env`/`.env.*`, credential stores, private keys, and private Hara runtime state in file reads,
16
+ edit/patch pre-reads, grep/glob/ls, `@file`/completion, codebase and stale semantic indexes, checkpoints,
17
+ gateway file delivery, and cron command admission. Safe templates such as `.env.example`, `.env.sample`,
18
+ and `.env.template` remain readable. `HARA_ALLOW_SENSITIVE_FILES=1` is an explicit launch-time exposure
19
+ switch for one process; it removes both these built-in denies and that process's shell protected-read mask.
20
+ - **Shell protection now reflects the host's guarantees.** Tool subprocesses receive a scrubbed environment;
21
+ inherited variables can be explicitly retained with the launch-time `HARA_SUBPROCESS_ENV_ALLOW` list, and
22
+ output is redacted by secret pattern and exact inherited value. Under the default protected-file policy,
23
+ shell admission blocks literal protected paths and environment dump commands on every platform. macOS
24
+ additionally applies a Seatbelt read mask to existing protected
25
+ files/directories, even when the general write sandbox is off. Linux and Windows do not have that kernel
26
+ read mask: their arbitrary shell code is not a security sandbox and static preflight can be bypassed.
27
+ - **MCP servers and external coding agents are explicit trusted extensions.** They execute outside Hara's
28
+ protected-file boundary, require confirmation on every interactive tool call (including `full-auto`), and
29
+ are disabled in non-interactive runs by default. Reviewed automation may opt in before launch with
30
+ `HARA_ALLOW_TRUSTED_EXTENSIONS=1`; inherited credentials are still scrubbed.
31
+ - **Repositories no longer silently control Hara's privileged configuration.** Project config is limited by
32
+ default to validated presentation/model preferences; provider routes, credentials, hooks, MCP, sandbox,
33
+ guardian, approval, and automation settings require the launch-time `HARA_TRUST_PROJECT_CONFIG=1` opt-in.
34
+ Project permissions may tighten global policy but cannot grant new access, and Git-tracked `.hara-profile`
35
+ pins are ignored unless that same trust decision was made before startup. Symlink, hard-link, size, identity,
36
+ and concurrent-change checks protect each of these inputs, while diagnostics reveal key names but no values.
37
+ - **Coding and project-state writes are transactional.** Edit, patch, undo, memory, skills, plans, profiles,
38
+ indexes, and gateway state use no-follow descriptors, inode checks, bounded reads, atomic replace or
39
+ compare-and-swap semantics, and private modes where appropriate. Protected search uses the same verified
40
+ descriptor path instead of handing sensitive-path decisions to `rg`, closing replacement and hard-link
41
+ races. `AGENTS.md`, subdirectory hints, mentions, and touched-file recall are bounded and reject protected
42
+ aliases before adding repository content to model context.
43
+ - **Semantic search and review automation distrust stale or historical content.** Semantic indexes carry a
44
+ versioned manifest with content hashes and are rebuilt when source identity or bytes change. Review-chain
45
+ prompts receive bounded status/path metadata rather than Git patches; an auto-commit proceeds only when the
46
+ staged blob still matches the verified worktree descriptor, including deletion and SHA-256 repository edge
47
+ cases. Read-only auto-run Git policy excludes commands that can expose historical blobs, credentials, remote
48
+ URLs, or patch bodies.
49
+ - **Old private state is tightened and stale checkpoint history is rotated.** Startup repairs `~/.hara` and
50
+ sensitive runtime trees to owner-only modes on POSIX systems. The protected checkpoint format rebuilds old
51
+ derived shadow repositories, filters protected paths independently of the launch-time exposure switch, and
52
+ purges a checkpoint repository if a protected blob ever reaches its index.
53
+ - **Long-running work has bounded ownership and shutdown.** Shell jobs, external agents, gateway children,
54
+ approved organization flows, and cron commands terminate the whole process tree with TERM-to-KILL escalation
55
+ and stop accepting output after cancellation. Background job counts, cron run time/log size, lock takeover,
56
+ and context fan-out are capped; Windows shell aliases and environment-dump variants are denied consistently.
57
+ - **Gateway outbound files are immutable after admission.** Telegram, Feishu, Slack, Discord, Mattermost,
58
+ Matrix, WeCom, and Weixin upload verified in-memory bytes plus a safe filename, so a queued path cannot be
59
+ swapped for a secret before delivery. Counts and bytes are bounded at queue and consume time, cleanup checks
60
+ inode identity, and Signal outbound files fail closed because its RPC accepts only reopenable filesystem paths.
61
+
8
62
  ## 0.122.0 — 2026-07-13 — structured runs, durable work, and a fail-closed gateway
9
63
 
10
64
  - **Machine-safe headless runs.** `hara -p … --schema <json|file>` installs a run-scoped
package/README.md CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  **Highlights**
14
14
  - **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
15
- - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images**, resumable per-chat sessions, project/agent roaming, bounded per-thread queues, and approval-gated group automations. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
15
+ - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images where the platform has a byte-upload API**, resumable per-chat sessions, project/agent roaming, bounded per-thread queues, and approval-gated group automations. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
16
16
  - **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation → the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
17
17
  - **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
- - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. Gated by approval, trust-tiered, and never exposed to read-only sub-agents.
19
+ - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. It is a trusted extension outside Hara's protected-file boundary: every interactive call requires confirmation, and non-interactive use is disabled by default.
20
20
  - **Honest under a slow network** — a live "waiting for the model… Ns" status, a stall watchdog that
21
21
  auto-fails-over instead of hanging, big pastes folding to a token, and a startup update notice — the
22
22
  terminal never feels dead.
@@ -26,6 +26,9 @@ Track it: https://github.com/hara-cli/hara · https://hara.run
26
26
 
27
27
  ## Install
28
28
 
29
+ The npm package requires **Node.js 22.12 or newer**. If needed, upgrade first with
30
+ `nvm install 22 && nvm use 22`. Node.js 20 is end-of-life and is not a supported Hara runtime.
31
+
29
32
  ```bash
30
33
  npm i -g @nanhara/hara
31
34
  ```
@@ -135,9 +138,14 @@ hara expresses it the way each endpoint wants (OpenAI `reasoning_effort`, Anthro
135
138
  DashScope `enable_thinking`, **DeepSeek** V4 `thinking` + `reasoning_effort` where `max` genuinely raises the
136
139
  effort). In the TUI, bare `/model` opens a picker — ↑↓ pick a model, **←→ set the thinking level**.
137
140
 
138
- Config lives in `~/.hara/config.json`; the nearest project `.hara/config.json` can specialize it. Effective
139
- precedence is **environment > project > selected overlay > global**. Empty routing values are ignored, so an
140
- empty project/env value cannot hide a working global credential or endpoint. Env overrides include
141
+ Config lives in `~/.hara/config.json`; the nearest project `.hara/config.json` may set the explicitly safe
142
+ project preferences `model`, `theme`, `vimMode`, `autoCompact`, and `reasoningEffort`. Repository config is
143
+ untrusted by default: routing/credential, hook/MCP, approval/sandbox, computer-control, and other privileged
144
+ keys are ignored with a key-name-only warning. For a repository you have reviewed, launch with
145
+ `HARA_TRUST_PROJECT_CONFIG=1` to enable all of its project keys for that process. The opt-in is captured at
146
+ startup, and project config itself must be a bounded regular file under a real (non-symlink) `.hara` directory.
147
+ Effective precedence for enabled keys is **environment > project > selected overlay > global**. Empty routing
148
+ values are ignored, so an empty project/env value cannot hide a working global credential or endpoint. Env overrides include
141
149
  `HARA_PROVIDER`, `HARA_MODEL`, `HARA_BASE_URL`, `HARA_API_KEY`, and the provider key
142
150
  (`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
143
151
 
@@ -220,14 +228,14 @@ vector DB needed, and lexical still works when there's no index. Re-running `har
220
228
  only changed files re-embed (a full repo rebuild that takes ~a minute re-runs in well under a second).
221
229
 
222
230
  **Approval modes**: `suggest` confirms edits & shell · `auto-edit` auto-applies file edits but confirms shell · `full-auto` runs everything.
223
- **Sandbox** (macOS): `--sandbox workspace-write|read-only` runs the `bash` tool under Seatbelt **file-write confinement** (writes confined to the project / blocked). It does not restrict reads, network, or process exec; on non-macOS the shell runs unsandboxed (with a warning).
231
+ **Protected files and shell sandboxing**: built-in file, search, and context paths hard-reject `.env`/credential/private-key/private-Hara-state files before the ordinary approval/dispatch path can authorize them. Safe templates (`.env.example`, `.env.sample`, `.env.template`) remain readable. `HARA_ALLOW_SENSITIVE_FILES=1` is an explicit one-process exposure switch: it removes these built-in denies and that process's shell protected-read mask. Shell subprocesses receive a scrubbed environment; explicitly retain a named inherited variable with `HARA_SUBPROCESS_ENV_ALLOW=NAME[,NAME]` (output is still redacted). With the protected-file policy enabled, shell preflight rejects literal protected paths and environment-dump commands on every OS. On macOS, Seatbelt also masks existing protected files/directories from the shell and `--sandbox workspace-write|read-only` provides **file-write confinement**. Linux/Windows have no equivalent kernel read mask: static shell preflight is a useful guardrail, not a security sandbox, and arbitrary code can bypass it.
224
232
  **Screen control** (opt-in): the `computer` tool drives desktop software (screenshot → click/type), native per OS
225
233
  (mac `screencapture`+`cliclick` · Windows PowerShell · Linux `scrot`+`xdotool`). Off by default — enable a tier with
226
234
  `hara config set computerUse read|click|full` and allowlist apps with `hara config set computerApps "App, …"`. Guarded
227
235
  by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-per-session grant. Screenshots are read via your
228
236
  vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
229
237
  **Sessions**: conversations are saved automatically — `-c` / `--resume <id>` to continue, `hara sessions` to list, `hara export [id] [--out file]` to render one as a Markdown transcript.
230
- **MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
238
+ **MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch); their tools appear to the agent as `mcp__<server>__<tool>`. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
231
239
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
232
240
  **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`.
233
241
  **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
@@ -236,13 +244,18 @@ for work that outlives a conversation: add/update/list/remove items with `pendin
236
244
  owner, and `blockedBy` dependencies. The private, atomic store is shared by concurrent hara processes for the
237
245
  same project and rejects missing/self/cyclic dependencies.
238
246
  **Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
239
- **Hooks**: run your own shell commands around tool calls via a `"hooks"` map in config. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
247
+ **Hooks**: run your own shell commands around tool calls via a `"hooks"` map in global config; hooks from a reviewed project config require the launch-time `HARA_TRUST_PROJECT_CONFIG=1` opt-in. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
240
248
  Reviewer/read-only/plan runs and parallel read-only sub-agents suppress both hook phases: PreToolUse and
241
249
  PostToolUse commands are arbitrary shell, so either could otherwise bypass their read-only contract indirectly.
242
250
  They also skip configured and plugin-provided MCP server processes, so starting an external tool server cannot
243
251
  bypass the same contract before the first model turn.
244
252
  **Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
245
- `~/.hara/config.json` for named config overlays, and a project `.hara/config.json` for project-specific routing.
253
+ `~/.hara/config.json` for named config overlays. Project `.hara/config.json` files get the safe preference
254
+ allowlist above; project-specific routing requires `HARA_TRUST_PROJECT_CONFIG=1` before launch.
255
+ `.hara-profile` identity pins are read no-follow with size, single-inode, and hard-link checks; pin updates use
256
+ an atomic compare-and-swap, and invalid-pin warnings never echo file contents or paths. A Git-tracked pin is
257
+ repository-controlled and ignored by default; local untracked pins created with `hara profile pin` work
258
+ normally. The same launch-time `HARA_TRUST_PROJECT_CONFIG=1` opt-in enables a reviewed tracked pin.
246
259
  Long-lived `hara serve` processes reload provider credentials/routes and guardian settings for the target cwd on
247
260
  new sessions and turns. `models.list` and new sessions see current defaults; a resumed session keeps its explicit
248
261
  model pin while using the live provider route. No server restart is required after a credential rotation.
@@ -289,11 +302,11 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
289
302
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
290
303
  - **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
291
304
  - **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
292
- - **Chat gateway**: drive your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), agent switching (`/agent`), and **two-way images** (send a photo it sees it; ask for a file → it sends one). Setup and the group-flow security model: **[docs/gateway.md](docs/gateway.md)**.
305
+ - **Chat gateway**: drive your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), agent switching (`/agent`), and **two-way images on byte-upload-capable platforms**. Setup, platform capability details, and the group-flow security model: **[docs/gateway.md](docs/gateway.md)**.
293
306
 
294
307
  ### Roadmap
295
308
 
296
- **Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · global project-agent index · durable project tasks · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (10 platforms, two-way images)** · **single-binary distribution** · **Docker image** · `/compact` context management.
309
+ **Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · global project-agent index · durable project tasks · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (10 platforms, capability-aware media)** · **single-binary distribution** · **Docker image** · `/compact` context management.
297
310
  **Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
298
311
 
299
312
  ## Security
package/SECURITY.md CHANGED
@@ -29,9 +29,45 @@ local user (who already has your shell).
29
29
  - **`web_fetch` SSRF guard.** Refuses to fetch private / loopback / link-local / CGNAT addresses (resolving
30
30
  the hostname first), re-checks on every redirect hop, and reads the body under a byte ceiling — so the
31
31
  model can't reach cloud-metadata endpoints or internal services.
32
- - **Secrets.** `~/.hara/config.json` (API keys) and `~/.hara/qwen-oauth.json` (tokens) are written `0600`.
33
- The optional semantic index respects `.gitignore` and skips secret-named files, so keys aren't embedded or
34
- sent to an embedding provider. The memory guard screens secret-shaped strings out of what the agent saves.
32
+ - **Protected reads in built-in paths.** `.env`, `.env.*` (except explicit templates such as
33
+ `.env.example`), credential stores, private keys, and private Hara state are rejected before the ordinary
34
+ approval/dispatch path can authorize them. The same canonical/real-path check covers built-in file reads,
35
+ edit/patch pre-reads, grep/glob/ls, `@file`, completion, semantic/lexical indexes, checkpoints, gateway file
36
+ delivery, cron command admission, and symlink aliases. Headless, gateway, sub-agent, cron, and `full-auto`
37
+ execution cannot approve those built-in paths past the check. `HARA_ALLOW_SENSITIVE_FILES=1` is an explicit
38
+ launch-time exposure switch for one process: it removes the built-in denies and that process's shell
39
+ protected-read preflight/Seatbelt mask.
40
+ - **Shell guardrails differ by platform.** With the protected-file policy enabled, shell admission statically
41
+ rejects literal protected paths and environment-dump commands on every OS. On macOS, Hara additionally
42
+ applies a Seatbelt read mask to existing
43
+ protected files/directories even when the general write sandbox is off. Linux and Windows have no equivalent
44
+ kernel-enforced read mask: their arbitrary shell code is outside the protected-file boundary, and static
45
+ preflight can be bypassed by indirection or generated code.
46
+ - **Subprocess credentials.** Model-controlled Bash/background jobs, hooks, external agents, and MCP servers
47
+ inherit an environment with secret-shaped and code-injection variables removed. MCP `env` entries are an
48
+ explicit per-server grant; other commands can receive named variables only when the user launches Hara with
49
+ `HARA_SUBPROCESS_ENV_ALLOW=NAME,OTHER`. Tool output is pattern- and exact-value-redacted. Hara's own provider
50
+ process retains its keys.
51
+ - **Secrets at rest.** `~/.hara/config.json` (API keys) and `~/.hara/qwen-oauth.json` (tokens) are written
52
+ `0600`. The optional semantic index skips secret-named files and filters old indexes at query time, so keys
53
+ aren't embedded or sent to an embedding provider. The memory guard screens secret-shaped strings out of
54
+ what the agent saves.
55
+ - **Repository config is untrusted by default.** A project `.hara/config.json` can set only `model`, `theme`,
56
+ `vimMode`, `autoCompact`, and `reasoningEffort`. Endpoint/credential routing, hooks/MCP commands, approval,
57
+ sandbox, guardian, computer control, and all other keys remain global unless the user launches Hara with
58
+ `HARA_TRUST_PROJECT_CONFIG=1` after reviewing that repository. Warnings name ignored/enabled keys but never
59
+ their values. Project config reads reject a symlink `.hara`, final symlinks, hard links, oversized files,
60
+ and files/directories that change identity during the read.
61
+ - **Identity pins are bound, not followed.** `.hara-profile` reads reject symlink/hard-link aliases and
62
+ oversized or changing files. Writes use a canonical-parent, no-follow snapshot plus atomic compare-and-swap;
63
+ invalid pin warnings never include the pin's raw content or untrusted path. Git-tracked pins are ignored by
64
+ default so a cloned repository cannot silently switch to an existing personal/org identity; untracked local
65
+ pins remain usable, and the reviewed-repository `HARA_TRUST_PROJECT_CONFIG=1` launch opt-in enables tracked pins.
66
+ - **MCP/external agents are trusted extensions.** They execute outside Hara's protected-file boundary. Every
67
+ interactive tool call requires confirmation, including in `full-auto`; non-interactive runs disable them by
68
+ default. Reviewed automation can explicitly enable them before launch with
69
+ `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. Their inherited environment is still scrubbed, but the extension may use
70
+ its own credentials or access anything its host process permits.
35
71
  - **Plugins are code you trust.** Installing a plugin (`hara plugin add`) grants its author code execution:
36
72
  its MCP servers and hooks run shell commands on launch. `hara plugin add` **prints the exact commands** a
37
73
  plugin will run so you can review them; disable with `hara plugin disable <name>`.
@@ -39,13 +75,16 @@ local user (who already has your shell).
39
75
 
40
76
  ## What is *not* a security boundary
41
77
 
42
- - **The sandbox confines file writes only** — not reads, not network, not process exec; `/private/tmp`
43
- stays writable. It stops a stray `rm`/overwrite escaping the project, not a determined exfiltration. Treat
44
- a `full-auto` + network-capable shell as able to read and send anything your account can.
45
- - **`@file` mentions** read any file *you* name (including outside the project) that's you attaching
46
- context, not the model exfiltrating; mentions are expanded on your typed input only, never on model output.
78
+ - **The general sandbox confines file writes only** — not arbitrary reads, network, or process exec;
79
+ `/private/tmp` stays writable. The protected-file policy is a narrow boundary for Hara's built-in paths,
80
+ plus an OS-enforced shell read mask on macOS only; it is not a complete hostile-code jail. On Linux and
81
+ Windows, treat any allowed network-capable shell as able to read and send anything the Hara process account
82
+ can access. On macOS, the protected-path mask still does not confine ordinary files, network, or process exec.
83
+ - **`@file` mentions** can read ordinary files *you* name (including outside the project), but protected
84
+ files are refused unless you made the launch-time opt-in. Mentions are expanded on user input only.
47
85
  - **`full-auto` / `-y`** removes the human gate by your explicit choice. Use it on code and in directories
48
- you trust.
86
+ you trust. It does not remove built-in protected-path checks or subprocess environment scrubbing, but it
87
+ does not turn Linux/Windows shell preflight into a security boundary.
49
88
 
50
89
  ## Reporting a vulnerability
51
90
 
@@ -17,6 +17,7 @@ import { setTurnPhase } from "./phase.js";
17
17
  import { recordTouch } from "./touched.js";
18
18
  import { resolve as resolvePath } from "node:path";
19
19
  import { redactSensitiveText } from "../security/secrets.js";
20
+ import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
20
21
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
21
22
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
22
23
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -72,7 +73,17 @@ source file or shell command. Reference an environment variable instead (for exa
72
73
  $API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
73
74
  .env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
74
75
  echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
75
- not make embedding credentials acceptable.
76
+ not make embedding credentials acceptable. Built-in file, search, and context paths hard-reject protected
77
+ files (.env/.env.*, credential stores, private keys, and private Hara state) before ordinary approval/dispatch;
78
+ do not try to bypass that policy through shell indirection, another tool, a sub-agent, or full-auto. Safe
79
+ templates such as .env.example may be read. Only a user who restarts Hara with
80
+ HARA_ALLOW_SENSITIVE_FILES=1 explicitly removes the built-in deny and shell protected-read mask for that
81
+ process. Shell subprocesses have credentials removed from their environment. macOS also applies an OS read
82
+ mask to existing protected paths; Linux/Windows shell checks are
83
+ static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
84
+ only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
85
+ are disabled without an interactive approval channel unless the user launched with
86
+ HARA_ALLOW_TRUSTED_EXTENSIONS=1.
76
87
  For broad,
77
88
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
78
89
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
@@ -461,11 +472,21 @@ export async function runAgent(history, opts) {
461
472
  continue;
462
473
  }
463
474
  const input = tu.input;
464
- const preview = String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
475
+ const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
465
476
  .replace(/\s+/g, " ")
466
- .trim();
467
- // Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
468
- const alwaysGate = tool.kind === "computer";
477
+ .trim());
478
+ // Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
479
+ // and even full-auto must never silently turn them into a side channel.
480
+ const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
481
+ if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
482
+ plans.push({
483
+ tu,
484
+ tool,
485
+ denied: "Trusted extension blocked in this non-interactive run. MCP and external coding agents run outside Hara's file boundary; " +
486
+ "restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that extension.",
487
+ });
488
+ continue;
489
+ }
469
490
  // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
470
491
  // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
471
492
  const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
@@ -480,8 +501,9 @@ export async function runAgent(history, opts) {
480
501
  if (guardianOn && !breakerHalt) {
481
502
  const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
482
503
  if (risk.level === "high") {
483
- const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
484
- const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
504
+ const safeRiskReason = redactToolSubprocessOutput(risk.reason);
505
+ const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
506
+ const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: opts.signal });
485
507
  if (opts.signal?.aborted)
486
508
  return finalizeInterruptedToolRound();
487
509
  if (verdict.decision === "block") {
@@ -489,10 +511,10 @@ export async function runAgent(history, opts) {
489
511
  plans.push({
490
512
  tu,
491
513
  tool,
492
- denied: `Guardian blocked this high-risk action: ${verdict.reason || risk.reason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
514
+ denied: `Guardian blocked this high-risk action: ${verdict.reason || safeRiskReason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
493
515
  });
494
516
  if (!opts.quiet) {
495
- const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || risk.reason}`;
517
+ const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || safeRiskReason}`;
496
518
  if (sink)
497
519
  sink.notice(note);
498
520
  else
@@ -521,7 +543,8 @@ export async function runAgent(history, opts) {
521
543
  }
522
544
  }
523
545
  }
524
- if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
546
+ const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
547
+ if (shouldConfirm) {
525
548
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
526
549
  if (opts.signal?.aborted)
527
550
  return finalizeInterruptedToolRound();
@@ -2,6 +2,7 @@
2
2
  // The loop records every file the MAIN conversation reads/edits; when the history is compacted to a
3
3
  // summary, the top-N most-recent files get their CURRENT on-disk content re-attached — so the model
4
4
  // doesn't lose the very files it was working on and re-read them all next turn.
5
+ import { isSensitiveFilePath } from "../security/sensitive-files.js";
5
6
  const DEFAULT_SCOPE = "default";
6
7
  const touchedByScope = new Map(); // scope → absolute path → last-touch timestamp
7
8
  function scopedTouched(scope) {
@@ -11,6 +12,8 @@ function scopedTouched(scope) {
11
12
  return touched;
12
13
  }
13
14
  export function recordTouch(path, scope) {
15
+ if (isSensitiveFilePath(path))
16
+ return;
14
17
  const touched = scopedTouched(scope);
15
18
  touched.delete(path);
16
19
  touched.set(path, Date.now());
@@ -20,6 +23,7 @@ export function recordTouch(path, scope) {
20
23
  /** Most-recently-touched first. */
21
24
  export function recentTouched(n = 5, scope) {
22
25
  return [...scopedTouched(scope).entries()]
26
+ .filter(([path]) => !isSensitiveFilePath(path))
23
27
  .sort((a, b) => b[1] - a[1])
24
28
  .slice(0, n)
25
29
  .map(([p]) => p);
@@ -5,22 +5,39 @@
5
5
  // snapshot-then-checkout: SAFE — it reverts changed/deleted files to the checkpoint and never deletes files
6
6
  // created since (so a stray restore can't nuke new work; it's also itself undoable via the auto-snapshot).
7
7
  import { execFileSync } from "node:child_process";
8
- import { mkdirSync, writeFileSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { createHash } from "node:crypto";
12
12
  import { findProjectRoot } from "./context/agents-md.js";
13
+ import { toolSubprocessEnv } from "./security/subprocess-env.js";
14
+ import { sensitiveFileReason } from "./security/sensitive-files.js";
15
+ import { redactSensitiveText } from "./security/secrets.js";
13
16
  // Heavy/derived dirs the shadow repo must never snapshot (in addition to the project's own .gitignore).
14
- const EXCLUDES = ["node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/", "__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store"];
17
+ const PRIVATE_DATA_EXCLUDES = ["credentials", "credential", "secrets", "secret", "service-account", "service_account"]
18
+ .flatMap((stem) => ["json", "yaml", "yml", "toml", "ini", "cfg", "conf"].map((ext) => `**/${stem}.${ext}`));
19
+ const EXCLUDES = [
20
+ "node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/",
21
+ "__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store",
22
+ "**/.env", "**/.env.*", "!**/.env.example", "!**/.env.*.example", "!**/.env.sample", "!**/.env.*.sample",
23
+ "!**/.env.template", "!**/.env.*.template", "!**/.env.dist", "!**/.env.*.dist", "!**/.env.defaults", "!**/.env.*.defaults",
24
+ "**/.envrc", "**/.direnv/", "**/.netrc", "**/.npmrc", "**/.pypirc", "**/.git-credentials",
25
+ "**/credentials", "**/credential", "**/secrets", "**/secret", "**/service-account", "**/service_account",
26
+ ...PRIVATE_DATA_EXCLUDES,
27
+ "**/application_default_credentials.json", "**/.aws/credentials", "**/.docker/config.json", "**/.kube/config",
28
+ "**/id_rsa", "**/id_ed25519", "**/id_ecdsa", "**/*.pem", "**/*.key", "**/*.p12", "**/*.pfx", "**/*.keystore",
29
+ ];
30
+ const CHECKPOINT_FORMAT = "protected-files-v2";
15
31
  function shadowGitDir(root) {
16
32
  return join(homedir(), ".hara", "checkpoints", createHash("sha256").update(root).digest("hex").slice(0, 16), "git");
17
33
  }
18
- function git(root, gitDir, args) {
34
+ function git(root, gitDir, args, input) {
19
35
  return execFileSync("git", args, {
20
36
  cwd: root,
21
- env: { ...process.env, GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" },
37
+ env: toolSubprocessEnv(process.env, { GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" }),
22
38
  encoding: "utf8",
23
- stdio: ["ignore", "pipe", "ignore"],
39
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
40
+ ...(input === undefined ? {} : { input }),
24
41
  maxBuffer: 64 * 1024 * 1024,
25
42
  // Bound it: the per-turn shadow `git add -A` over a large tree (or a hung git) must never freeze a
26
43
  // turn. On timeout execFileSync throws → checkpoint()/ensureRepo() catch it → the snapshot is just
@@ -30,18 +47,61 @@ function git(root, gitDir, args) {
30
47
  }
31
48
  function ensureRepo(root, gitDir) {
32
49
  try {
50
+ const stateDir = dirname(gitDir);
51
+ const checkpointRoot = dirname(stateDir);
52
+ const marker = join(stateDir, "format");
53
+ mkdirSync(checkpointRoot, { recursive: true, mode: 0o700 });
54
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
55
+ try {
56
+ chmodSync(checkpointRoot, 0o700);
57
+ chmodSync(stateDir, 0o700);
58
+ }
59
+ catch { /* best effort */ }
60
+ // Older shadow repositories may contain secret blobs even after the path leaves the index. Checkpoints
61
+ // are a derived undo cache, so rotate the repository instead of pretending git-rm purged its history.
62
+ let format = "";
63
+ try {
64
+ format = readFileSync(marker, "utf8").trim();
65
+ }
66
+ catch { /* first protected open */ }
67
+ if (existsSync(gitDir) && format !== CHECKPOINT_FORMAT)
68
+ rmSync(gitDir, { recursive: true, force: true });
33
69
  if (!existsSync(join(gitDir, "HEAD"))) {
34
- mkdirSync(gitDir, { recursive: true });
70
+ mkdirSync(gitDir, { recursive: true, mode: 0o700 });
35
71
  git(root, gitDir, ["init", "-q"]);
36
72
  mkdirSync(join(gitDir, "info"), { recursive: true });
37
- writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
38
73
  }
74
+ try {
75
+ chmodSync(gitDir, 0o700);
76
+ }
77
+ catch { /* best effort */ }
78
+ writeFileSync(marker, CHECKPOINT_FORMAT + "\n", { mode: 0o600 });
79
+ try {
80
+ chmodSync(marker, 0o600);
81
+ }
82
+ catch { /* best effort on non-POSIX filesystems */ }
83
+ mkdirSync(join(gitDir, "info"), { recursive: true });
84
+ const excludeFile = join(gitDir, "info", "exclude");
85
+ writeFileSync(excludeFile, EXCLUDES.join("\n") + "\n", { mode: 0o600 });
86
+ try {
87
+ chmodSync(excludeFile, 0o600);
88
+ }
89
+ catch { /* best effort on non-POSIX filesystems */ }
39
90
  return true;
40
91
  }
41
92
  catch {
42
93
  return false;
43
94
  }
44
95
  }
96
+ function dropSensitiveIndexEntries(root, gitDir) {
97
+ const indexed = git(root, gitDir, ["ls-files", "-z"]).split("\0").filter(Boolean);
98
+ const protectedPaths = indexed.filter((path) => sensitiveFileReason(join(root, path)) !== null);
99
+ if (protectedPaths.length) {
100
+ // stdin pathspecs avoid ARG_MAX and preserve arbitrary filenames (including whitespace/newlines).
101
+ git(root, gitDir, ["update-index", "--force-remove", "-z", "--stdin"], protectedPaths.join("\0") + "\0");
102
+ }
103
+ return protectedPaths.length;
104
+ }
45
105
  /** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
46
106
  export function checkpoint(cwd, label) {
47
107
  const root = findProjectRoot(cwd);
@@ -49,8 +109,22 @@ export function checkpoint(cwd, label) {
49
109
  if (!ensureRepo(root, gitDir))
50
110
  return null;
51
111
  try {
112
+ // If a path became protected after this format was introduced, merely unstaging it would leave its
113
+ // historical blobs reachable. Rotate the derived repository before taking another snapshot.
114
+ if (dropSensitiveIndexEntries(root, gitDir) > 0) {
115
+ rmSync(gitDir, { recursive: true, force: true });
116
+ if (!ensureRepo(root, gitDir))
117
+ return null;
118
+ }
52
119
  git(root, gitDir, ["add", "-A"]);
53
- git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", (label || "checkpoint").slice(0, 120)]);
120
+ // Defense against a future exclude-list regression. Once git-add saw a protected path its blob may exist
121
+ // even if we unstage it, so fail closed and purge the derived repository instead of retaining that object.
122
+ if (dropSensitiveIndexEntries(root, gitDir) > 0) {
123
+ rmSync(gitDir, { recursive: true, force: true });
124
+ return null;
125
+ }
126
+ const safeLabel = redactSensitiveText(label || "checkpoint").text.slice(0, 120);
127
+ git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", safeLabel]);
54
128
  return git(root, gitDir, ["rev-parse", "--short", "HEAD"]).trim();
55
129
  }
56
130
  catch {
@@ -59,11 +133,12 @@ export function checkpoint(cwd, label) {
59
133
  }
60
134
  /** Recent checkpoints, newest first. */
61
135
  export function listCheckpoints(cwd, n = 15) {
62
- const gitDir = shadowGitDir(findProjectRoot(cwd));
63
- if (!existsSync(join(gitDir, "HEAD")))
136
+ const root = findProjectRoot(cwd);
137
+ const gitDir = shadowGitDir(root);
138
+ if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
64
139
  return [];
65
140
  try {
66
- const out = git(findProjectRoot(cwd), gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
141
+ const out = git(root, gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
67
142
  if (!out)
68
143
  return [];
69
144
  return out.split("\n").map((l) => {
@@ -79,15 +154,26 @@ export function listCheckpoints(cwd, n = 15) {
79
154
  * undoable). Files created AFTER the checkpoint are left in place — nothing is deleted. Returns the count of
80
155
  * files restored, or null on failure. */
81
156
  export function restoreCheckpoint(cwd, ref) {
157
+ if (!/^[0-9a-f]{4,64}$/i.test(ref))
158
+ return null; // checkpoint refs are hashes; reject option/ref injection
82
159
  const root = findProjectRoot(cwd);
83
160
  const gitDir = shadowGitDir(root);
84
- if (!existsSync(join(gitDir, "HEAD")))
161
+ if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
85
162
  return null;
86
163
  try {
87
- checkpoint(cwd, `before restore to ${ref}`); // make the restore itself undoable
88
- const changed = git(root, gitDir, ["diff", "--name-only", ref, "--"]).trim(); // files differing now vs ref
89
- git(root, gitDir, ["checkout", ref, "--", "."]);
90
- return changed ? changed.split("\n").length : 0;
164
+ // Restoring without a durable pre-restore snapshot can destroy the user's newest work. Checkpointing is
165
+ // best-effort during ordinary turns, but it is a hard prerequisite for this destructive operation.
166
+ if (!checkpoint(cwd, `before restore to ${ref}`))
167
+ return null;
168
+ const changed = new Set(git(root, gitDir, ["diff", "--name-only", "-z", ref, "--"]).split("\0").filter(Boolean));
169
+ // A pre-restore snapshot tracks files created after `ref`; `git checkout ref -- new-file` would make
170
+ // the whole checkout fail. Restore only paths that actually existed at the requested checkpoint.
171
+ const atRef = git(root, gitDir, ["ls-tree", "-r", "-z", "--name-only", ref]).split("\0").filter(Boolean);
172
+ const safe = atRef.filter((path) => changed.has(path) && sensitiveFileReason(join(root, path)) === null);
173
+ if (safe.length) {
174
+ git(root, gitDir, ["checkout", ref, "--pathspec-from-file=-", "--pathspec-file-nul"], safe.join("\0") + "\0");
175
+ }
176
+ return safe.length;
91
177
  }
92
178
  catch {
93
179
  return null;
package/dist/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Keep the runtime gate dependency-free and ahead of the main CLI import. Older Node releases must see an
3
+ // actionable upgrade message even if a future dependency starts using syntax or APIs they cannot load.
4
+ import { unsupportedNodeMessage } from "./runtime.js";
5
+ const runtimeError = unsupportedNodeMessage();
6
+ if (runtimeError) {
7
+ process.stderr.write(`${runtimeError}\n`);
8
+ process.exitCode = 1;
9
+ }
10
+ else {
11
+ void import("./index.js").catch((error) => {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ process.stderr.write(`hara: failed to start: ${message}\n`);
14
+ process.exitCode = 1;
15
+ });
16
+ }