@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.
- package/CHANGELOG.md +54 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- 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.
|
|
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`
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
**
|
|
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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
43
|
-
stays writable.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
468
|
-
|
|
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
|
|
484
|
-
const
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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();
|
package/dist/agent/touched.js
CHANGED
|
@@ -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);
|
package/dist/checkpoints.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|