@nanhara/hara 0.122.0 → 0.122.2

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 +62 -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
@@ -0,0 +1,210 @@
1
+ // Environment boundary for model-controlled subprocesses. Provider credentials stay in the Hara process;
2
+ // Bash/jobs/hooks/MCP/external agents receive ordinary build/runtime variables but not secret-shaped values.
3
+ // A user can explicitly pass selected names with HARA_SUBPROCESS_ENV_ALLOW=NAME,OTHER before launching Hara.
4
+ import { redactSensitiveText } from "./secrets.js";
5
+ import { spawn } from "node:child_process";
6
+ import { platform } from "node:os";
7
+ const SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|COOKIE|JWT|AUTH)(?:_|$)/i;
8
+ const SECRET_EXACT = new Set([
9
+ "DATABASE_URL",
10
+ "REDIS_URL",
11
+ "MONGODB_URI",
12
+ "MONGO_URL",
13
+ "PGPASSWORD",
14
+ ]);
15
+ const INJECTION_NAME = /^(?:BASH_ENV|ENV|NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|RUBYOPT|RUBYLIB|PERL5OPT|PERL5LIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_.*|GIT_ASKPASS|SSH_ASKPASS|SUDO_ASKPASS|GIT_SSH_COMMAND|GIT_CONFIG(?:_.*)?|GIT_EXEC_PATH|GIT_EXTERNAL_DIFF|GIT_EDITOR|GIT_SEQUENCE_EDITOR|GIT_PAGER|PAGER|MANPAGER|EDITOR|VISUAL|LESSOPEN|LESSCLOSE|NPM_CONFIG_SCRIPT_SHELL|NPM_CONFIG_NODE_GYP|NPM_CONFIG_USERCONFIG|PIP_CONFIG_FILE|AWS_SHARED_CREDENTIALS_FILE|AWS_CONFIG_FILE|DOCKER_CONFIG|KUBECONFIG|NETRC|AZURE_CONFIG_DIR)$/;
16
+ const SAFE_EXCEPTIONS = new Set(["SSH_AUTH_SOCK"]); // socket path, not the credential itself
17
+ export function isSecretEnvironmentName(name) {
18
+ const normalized = name.toUpperCase(); // Windows environment names are case-insensitive
19
+ if (SAFE_EXCEPTIONS.has(normalized))
20
+ return false;
21
+ return SECRET_EXACT.has(normalized) || SECRET_NAME.test(normalized) || INJECTION_NAME.test(normalized);
22
+ }
23
+ function explicitAllow(env) {
24
+ return new Set(String(env.HARA_SUBPROCESS_ENV_ALLOW ?? "")
25
+ .split(",")
26
+ .map((name) => name.trim())
27
+ .filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
28
+ .map((name) => name.toUpperCase()));
29
+ }
30
+ /** Build an own copy so callers never mutate process.env. Explicit overrides (for example an MCP server's
31
+ * configured env) are intentional and win after the inherited environment has been scrubbed. */
32
+ export function toolSubprocessEnv(source = process.env, overrides = {}) {
33
+ const allow = explicitAllow(source);
34
+ const out = {};
35
+ for (const [name, value] of Object.entries(source)) {
36
+ if (value === undefined)
37
+ continue;
38
+ const normalized = name.toUpperCase();
39
+ if (normalized === "HARA_SUBPROCESS_ENV_ALLOW")
40
+ continue;
41
+ if (isSecretEnvironmentName(normalized) && !allow.has(normalized))
42
+ continue;
43
+ out[name] = value;
44
+ }
45
+ for (const [name, value] of Object.entries(overrides)) {
46
+ if (value === undefined)
47
+ delete out[name];
48
+ else
49
+ out[name] = value;
50
+ }
51
+ return out;
52
+ }
53
+ /** Redact both recognizable credential syntax and the exact values of secret-named environment variables.
54
+ * Exact-value matching closes the gap for opaque provider tokens that have no standard prefix. */
55
+ export function redactToolSubprocessOutput(text, source = process.env, explicit = {}) {
56
+ let out = redactSensitiveText(text).text;
57
+ const values = new Set();
58
+ for (const env of [source, explicit]) {
59
+ for (const [name, value] of Object.entries(env)) {
60
+ if (!value || value.length < 6 || !isSecretEnvironmentName(name))
61
+ continue;
62
+ values.add(value);
63
+ }
64
+ }
65
+ for (const value of [...values].sort((a, b) => b.length - a.length)) {
66
+ if (out.includes(value))
67
+ out = out.split(value).join("***");
68
+ }
69
+ return out;
70
+ }
71
+ /** Line-buffered streaming redactor. Redacting each transport chunk independently is unsafe because a token
72
+ * may be split between two chunks. Holding the partial line until the next newline/end closes that gap. */
73
+ export function createToolOutputLineRedactor(emit, source = process.env, explicit = {}, maxPendingCharacters = 64 * 1024) {
74
+ if (!Number.isSafeInteger(maxPendingCharacters) || maxPendingCharacters < 1024) {
75
+ throw new Error("streaming redactor maxPendingCharacters must be an integer >= 1024");
76
+ }
77
+ let pending = "";
78
+ let droppingLongLine = false;
79
+ const feed = (piece, endsLine) => {
80
+ if (droppingLongLine) {
81
+ if (endsLine)
82
+ droppingLongLine = false;
83
+ return;
84
+ }
85
+ if (pending.length + piece.length > maxPendingCharacters) {
86
+ // Never release a prefix of an overlong line: a credential could straddle that release boundary.
87
+ // Drop through the newline instead, keeping memory fixed and preserving only a safe diagnostic.
88
+ pending = "";
89
+ droppingLongLine = !endsLine;
90
+ emit(`[output line omitted — exceeded ${maxPendingCharacters} characters]\n`);
91
+ return;
92
+ }
93
+ pending += piece;
94
+ if (endsLine) {
95
+ emit(redactToolSubprocessOutput(pending, source, explicit));
96
+ pending = "";
97
+ }
98
+ };
99
+ return {
100
+ push(chunk) {
101
+ let start = 0;
102
+ let newline;
103
+ while ((newline = chunk.indexOf("\n", start)) >= 0) {
104
+ feed(chunk.slice(start, newline + 1), true);
105
+ start = newline + 1;
106
+ }
107
+ if (start < chunk.length)
108
+ feed(chunk.slice(start), false);
109
+ },
110
+ flush() {
111
+ if (pending)
112
+ emit(redactToolSubprocessOutput(pending, source, explicit));
113
+ pending = "";
114
+ droppingLongLine = false;
115
+ },
116
+ };
117
+ }
118
+ /** Terminate a spawned tool and, when it owns a POSIX process group, all descendants. On Windows,
119
+ * taskkill /T is the supported process-tree primitive. Callers should spawn with `detached: true` on POSIX
120
+ * and pass `processGroup: true`; the explicit flag avoids ever signaling Hara's own process group. */
121
+ export function terminateSubprocessTree(child, options = {}) {
122
+ const force = options.force === true;
123
+ const pid = child.pid;
124
+ if (!pid)
125
+ return () => { };
126
+ const signal = (hard) => {
127
+ const direct = () => {
128
+ try {
129
+ child.kill(hard ? "SIGKILL" : "SIGTERM");
130
+ }
131
+ catch { /* already gone */ }
132
+ };
133
+ if (platform() === "win32") {
134
+ try {
135
+ // Asynchronous taskkill preserves the caller's wall-clock fallback; a synchronous 5s taskkill
136
+ // timeout here could itself make a 200ms tool timeout take ten seconds across TERM + force passes.
137
+ const killer = spawn("taskkill", ["/pid", String(pid), "/t", ...(hard ? ["/f"] : [])], {
138
+ stdio: "ignore",
139
+ windowsHide: true,
140
+ env: toolSubprocessEnv(),
141
+ });
142
+ killer.once("error", direct);
143
+ killer.once("close", (code) => { if (code !== 0)
144
+ direct(); });
145
+ killer.unref();
146
+ return;
147
+ }
148
+ catch {
149
+ // Fall back to Node's direct-child signal below.
150
+ }
151
+ }
152
+ else if (options.processGroup) {
153
+ try {
154
+ process.kill(-pid, hard ? "SIGKILL" : "SIGTERM");
155
+ return;
156
+ }
157
+ catch {
158
+ // The group may have exited between the check and signal; direct-child kill is a safe fallback.
159
+ }
160
+ }
161
+ direct();
162
+ };
163
+ signal(force);
164
+ if (force)
165
+ options.onForce?.();
166
+ const graceMs = options.graceMs;
167
+ if (force || graceMs === undefined)
168
+ return () => { };
169
+ const grace = Math.max(0, Math.min(graceMs, 30_000));
170
+ const fallback = Math.max(0, Math.min(options.fallbackMs ?? 1_000, 30_000));
171
+ let fallbackActive = true;
172
+ let forceTimer;
173
+ let fallbackTimer;
174
+ const cancelFallback = () => {
175
+ if (!fallbackActive)
176
+ return;
177
+ fallbackActive = false;
178
+ if (fallbackTimer)
179
+ clearTimeout(fallbackTimer);
180
+ child.off("close", cancelFallback);
181
+ child.off("error", cancelFallback);
182
+ };
183
+ // `close` only proves the direct child's stdio is closed. A grandchild can redirect stdio, ignore TERM,
184
+ // and remain in the owned process group, so the forced group kill MUST NOT be cancelled by child close.
185
+ // We cancel only the API hard-settle fallback; the short force timer is intentionally kept referenced so
186
+ // natural process exit cannot orphan a resistant descendant before escalation runs.
187
+ child.once("close", cancelFallback);
188
+ child.once("error", cancelFallback);
189
+ forceTimer = setTimeout(() => {
190
+ forceTimer = undefined;
191
+ signal(true);
192
+ options.onForce?.();
193
+ }, grace);
194
+ if (options.onFallback) {
195
+ fallbackTimer = setTimeout(() => {
196
+ if (!fallbackActive)
197
+ return;
198
+ const callback = options.onFallback;
199
+ cancelFallback();
200
+ callback?.();
201
+ }, grace + fallback);
202
+ }
203
+ return (cancelForce = false) => {
204
+ cancelFallback();
205
+ if (cancelForce && forceTimer) {
206
+ clearTimeout(forceTimer);
207
+ forceTimer = undefined;
208
+ }
209
+ };
210
+ }
@@ -31,6 +31,7 @@ import { disposeTodoScope, restoreTodos, serializeTodos } from "../tools/todo.js
31
31
  import { disposeReminderScope } from "../agent/reminders.js";
32
32
  import { SessionHub, realStore } from "./sessions.js";
33
33
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
34
+ import { readModelContextFileSync } from "../fs-read.js";
34
35
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
35
36
  const COMPACT_TIMEOUT_MS = 60_000;
36
37
  const SHUTDOWN_GRACE_MS = 2_000;
@@ -465,7 +466,7 @@ export async function startServe(opts, deps) {
465
466
  }).slice(0, 5);
466
467
  const restore = buildFileRestore(touched, (f) => {
467
468
  try {
468
- return readFileSync(f, "utf8");
469
+ return readModelContextFileSync(f, 32 * 1024);
469
470
  }
470
471
  catch {
471
472
  return null;
@@ -2,12 +2,15 @@
2
2
  // ~/.hara/skills). Frontmatter: name, description (required) + when_to_use / allowed-tools /
3
3
  // context inline|fork / model / paths / user-invocable / disable-model-invocation. The body is the
4
4
  // instructions, loaded ON DEMAND (progressive disclosure) — only the frontmatter index sits in context.
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
5
+ import { existsSync, readdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { findProjectRoot } from "../context/agents-md.js";
9
9
  import { scanMemory } from "../memory/guard.js";
10
10
  import { pluginSkillDirs } from "../plugins/plugins.js";
11
+ import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
12
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
13
+ const MAX_SKILL_BYTES = 512 * 1024;
11
14
  export function skillsDir(cwd) {
12
15
  return join(findProjectRoot(cwd), ".hara", "skills");
13
16
  }
@@ -60,7 +63,7 @@ export function loadSkillIndex(cwd) {
60
63
  if (!existsSync(file))
61
64
  continue;
62
65
  try {
63
- const { fm } = parseFrontmatter(readFileSync(file, "utf8"));
66
+ const { fm } = parseFrontmatter(readModelContextFileSync(file, MAX_SKILL_BYTES));
64
67
  const id = fm.name || entry;
65
68
  byId.set(id, {
66
69
  id,
@@ -86,7 +89,7 @@ export function loadSkillIndex(cwd) {
86
89
  /** Read a skill's instruction body (progressive disclosure — only when the model/user opens it). */
87
90
  export function loadSkillBody(skill) {
88
91
  try {
89
- return parseFrontmatter(readFileSync(skill.file, "utf8")).body;
92
+ return parseFrontmatter(readModelContextFileSync(skill.file, MAX_SKILL_BYTES)).body;
90
93
  }
91
94
  catch {
92
95
  return "";
@@ -129,13 +132,19 @@ when_to_use: after editing code, before declaring a task done
129
132
  4. Summarize: what you ran, pass/fail, and anything still unverified.
130
133
  `;
131
134
  /** Create ~/.hara/skills/verify-change/SKILL.md as a starter example. Returns the paths written. */
132
- export function scaffoldSkills(cwd) {
135
+ export async function scaffoldSkills(cwd) {
133
136
  const dir = join(skillsDir(cwd), "verify-change");
134
- mkdirSync(dir, { recursive: true });
135
137
  const p = join(dir, "SKILL.md");
136
- if (existsSync(p))
138
+ const boundary = bindAtomicWritePath(p, "scaffold skill");
139
+ try {
140
+ await readVerifiedRegularFileSnapshot(boundary.target, undefined, "scaffold skill");
137
141
  return [];
138
- writeFileSync(p, SCAFFOLD, "utf8");
142
+ }
143
+ catch (error) {
144
+ if (error?.code !== "ENOENT")
145
+ throw error;
146
+ }
147
+ await atomicWriteText(boundary.target, SCAFFOLD, { expected: null, boundary });
139
148
  invalidateSkillsCache();
140
149
  return [p];
141
150
  }
@@ -7,10 +7,12 @@ import { runShell } from "../sandbox.js";
7
7
  import { nearestPaths } from "../fs-walk.js";
8
8
  import { emitDiff } from "../diff.js";
9
9
  import { recordEdit } from "../undo.js";
10
- import { atomicWriteText } from "../fs-write.js";
10
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
11
11
  import { invalidateFileCandidates } from "../context/mentions.js";
12
- import { BinaryFileError, readRegularFileSnapshot, streamFileSlice } from "../fs-read.js";
12
+ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, streamFileSlice } from "../fs-read.js";
13
13
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
14
+ import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
15
+ import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
14
16
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
15
17
  const MAX = 100_000;
16
18
  /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
@@ -123,10 +125,17 @@ registerTool({
123
125
  kind: "read",
124
126
  async run(input, ctx) {
125
127
  const p = abs(input.path, ctx.cwd);
128
+ const denied = sensitiveFileError(p, "read");
129
+ if (denied)
130
+ return denied;
126
131
  try {
132
+ const target = resolveVerifiedModelPath(p, "read");
127
133
  // streamFileSlice opens O_NONBLOCK, validates the same fd as a regular file, and stops after the
128
134
  // requested window. Using it for every size removes the path-level stat→read race entirely.
129
- return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
135
+ return cap(await streamFileSlice(target, input.offset, input.limit ?? READ_LINES, {
136
+ lineCap: LINE_CAP,
137
+ protectSensitive: true,
138
+ }));
130
139
  }
131
140
  catch (e) {
132
141
  if (e instanceof BinaryFileError)
@@ -150,28 +159,39 @@ registerTool({
150
159
  kind: "edit",
151
160
  async run(input, ctx) {
152
161
  const p = abs(input.path, ctx.cwd);
162
+ const denied = sensitiveFileError(p, "write");
163
+ if (denied)
164
+ return denied;
153
165
  if (typeof input.content !== "string")
154
166
  return "Error: write_file `content` must be a string. No changes written.";
155
167
  let prevSnapshot = null;
168
+ let boundary;
156
169
  try {
157
- prevSnapshot = await readRegularFileSnapshot(p);
170
+ boundary = bindAtomicWritePath(p, "write");
171
+ prevSnapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "write");
158
172
  }
159
173
  catch (error) {
160
- if (error?.code !== "ENOENT")
174
+ if (error?.code !== "ENOENT" || !boundary)
161
175
  return `Error: cannot inspect ${input.path}: ${error?.message ?? error?.code}. No changes written.`;
162
176
  }
177
+ if (!boundary)
178
+ return `Error: cannot bind ${input.path} to a stable parent. No changes written.`;
163
179
  const prev = prevSnapshot?.text ?? null;
164
180
  if (prev === input.content)
165
181
  return `Unchanged ${p} (${input.content.length} chars already match).`;
166
182
  let committed;
167
183
  try {
168
- committed = await atomicWriteText(p, input.content, { expected: prev, expectedIdentity: prevSnapshot ?? undefined });
184
+ committed = await atomicWriteText(boundary.target, input.content, {
185
+ expected: prev,
186
+ expectedIdentity: prevSnapshot ?? undefined,
187
+ boundary,
188
+ });
169
189
  }
170
190
  catch (error) {
171
191
  return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
172
192
  }
173
193
  emitDiff(input.path, prev ?? "", input.content, ctx.ui);
174
- recordEdit([{ path: input.path, absPath: p, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
194
+ recordEdit([{ path: input.path, absPath: boundary.target, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
175
195
  invalidateFileCandidates(ctx.cwd);
176
196
  return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
177
197
  },
@@ -190,13 +210,19 @@ registerTool({
190
210
  },
191
211
  kind: "exec",
192
212
  async run(input, ctx) {
213
+ const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
214
+ if (protectedReason) {
215
+ return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
216
+ "This deny is not bypassed by full-auto. Restart hara with HARA_ALLOW_SENSITIVE_FILES=1 only for an intentional, user-approved exposure.");
217
+ }
193
218
  if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
194
219
  return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
195
220
  "Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
196
221
  }
197
222
  if (input.background) {
198
223
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
199
- return `Started background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
224
+ const safeCommand = redactToolSubprocessOutput(String(input.command));
225
+ return `Started background job ${id}: \`${safeCommand}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
200
226
  }
201
227
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
202
228
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
@@ -214,19 +240,19 @@ registerTool({
214
240
  return `Skipped without running: host "${blocked}" already failed to connect earlier in THIS session — hara does not retry network operations to a host known unreachable this session (a retry just hangs ~75s again). Do not swap in a public mirror (won't serve private repos) or switch protocols; diagnose instead.${proxyHint(blocked)}`;
215
241
  }
216
242
  }
217
- let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
218
- const live = ctx.ui
219
- ? (s) => {
220
- buf += s;
221
- let i;
222
- while ((i = buf.indexOf("\n")) >= 0) {
223
- ctx.ui.notice(buf.slice(0, i));
224
- buf = buf.slice(i + 1);
225
- }
226
- }
243
+ const liveEmit = ctx.ui
244
+ ? (line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))
227
245
  : procOut.isTTY
228
- ? (s) => procOut.write(s) // stream output in a plain terminal
229
- : undefined;
246
+ ? (line) => procOut.write(line)
247
+ : null;
248
+ // stdout/stderr are independent byte streams. A shared partial-line buffer could splice stderr into
249
+ // the middle of a stdout credential and defeat exact-value redaction in the live UI.
250
+ const liveStdout = liveEmit ? createToolOutputLineRedactor(liveEmit) : null;
251
+ const liveStderr = liveEmit ? createToolOutputLineRedactor(liveEmit) : null;
252
+ const live = liveEmit
253
+ ? (s, stream) => (stream === "stdout" ? liveStdout : liveStderr).push(s)
254
+ : undefined;
255
+ const flushLive = () => { liveStdout?.flush(); liveStderr?.flush(); };
230
256
  const timeout = shellTimeoutMs(input.command, input.timeout_ms);
231
257
  try {
232
258
  const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
@@ -234,12 +260,12 @@ registerTool({
234
260
  maxBuffer: 10 * 1024 * 1024,
235
261
  onData: live,
236
262
  });
237
- if (ctx.ui && buf)
238
- ctx.ui.notice(buf); // flush trailing partial line
263
+ flushLive();
239
264
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
240
- return capHeadTail(combined.trim() || "(no output)");
265
+ return capHeadTail(redactToolSubprocessOutput(combined.trim() || "(no output)"));
241
266
  }
242
267
  catch (e) {
268
+ flushLive();
243
269
  let base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
244
270
  // Timeout gets an ACTIONABLE next step, not just a corpse — the model (and user) should pick a
245
271
  // lane instead of blind-retrying into the same wall.
@@ -258,10 +284,10 @@ registerTool({
258
284
  if (host) {
259
285
  markHostUnreachable(host);
260
286
  ctx.ui?.notice(`↯ ${host} marked unreachable for this session — won't retry network ops to it`);
261
- return capHeadTail(base + proxyHint(host));
287
+ return capHeadTail(redactToolSubprocessOutput(base + proxyHint(host)));
262
288
  }
263
289
  }
264
- return capHeadTail(base);
290
+ return capHeadTail(redactToolSubprocessOutput(base));
265
291
  }
266
292
  },
267
293
  });
@@ -284,14 +310,14 @@ registerTool({
284
310
  const js = listJobs();
285
311
  if (!js.length)
286
312
  return "(no background jobs)";
287
- return js.map((j) => `${j.id} [${j.status}${j.code != null ? " " + j.code : ""}] ${Math.round(j.ageMs / 1000)}s ${j.command}`).join("\n");
313
+ return js.map((j) => `${j.id} [${j.status}${j.code != null ? " " + j.code : ""}] ${Math.round(j.ageMs / 1000)}s ${redactToolSubprocessOutput(j.command)}`).join("\n");
288
314
  }
289
315
  const id = String(input.id ?? "");
290
316
  if (!id)
291
317
  return "Error: `id` is required for tail/kill.";
292
318
  if (action === "tail") {
293
319
  const t = tailJob(id, Number(input.lines) || 40);
294
- return t == null ? `No job ${id}.` : t.trim() || "(no output yet)";
320
+ return t == null ? `No job ${id}.` : redactToolSubprocessOutput(t.trim() || "(no output yet)");
295
321
  }
296
322
  if (action === "kill")
297
323
  return killJob(id) ? `Killed ${id}.` : `No running job ${id} (already exited/killed or unknown).`;
@@ -10,6 +10,7 @@ import { parseSchedule, describeSchedule, nextRun, validTz } from "../cron/sched
10
10
  import { runJobOnce } from "../cron/runner.js";
11
11
  import { parseDeliver } from "../cron/deliver.js";
12
12
  import { isInstalled } from "../cron/install.js";
13
+ import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
13
14
  const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
14
15
  registerTool({
15
16
  name: "cronjob",
@@ -70,6 +71,11 @@ registerTool({
70
71
  return `Error: ${d.error}`;
71
72
  }
72
73
  const mode = input.mode === "org" || input.mode === "command" ? input.mode : "print";
74
+ if (mode === "command") {
75
+ const denied = sensitiveShellCommandReason(task, ctx.cwd);
76
+ if (denied)
77
+ return `Error: scheduled shell command crosses Hara's protected secret boundary (${denied}).`;
78
+ }
73
79
  const job = addJob({
74
80
  name: input.name ? String(input.name) : task.slice(0, 48),
75
81
  schedule: sched,
@@ -4,9 +4,10 @@ import { nearestPaths } from "../fs-walk.js";
4
4
  import { emitDiff } from "../diff.js";
5
5
  import { applyEdits } from "./apply-core.js";
6
6
  import { recordEdit } from "../undo.js";
7
- import { atomicWriteText } from "../fs-write.js";
7
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
8
8
  import { invalidateFileCandidates } from "../context/mentions.js";
9
- import { readRegularFileSnapshot } from "../fs-read.js";
9
+ import { readVerifiedRegularFileSnapshot } from "../fs-read.js";
10
+ import { sensitiveFileError } from "../security/sensitive-files.js";
10
11
  registerTool({
11
12
  name: "edit_file",
12
13
  description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
@@ -41,12 +42,17 @@ registerTool({
41
42
  kind: "edit",
42
43
  async run(input, ctx) {
43
44
  const p = isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path);
45
+ const denied = sensitiveFileError(p, "edit");
46
+ if (denied)
47
+ return denied;
44
48
  const edits = Array.isArray(input.edits) && input.edits.length
45
49
  ? input.edits
46
50
  : [{ old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }];
47
51
  let snapshot;
52
+ let boundary;
48
53
  try {
49
- snapshot = await readRegularFileSnapshot(p);
54
+ boundary = bindAtomicWritePath(p, "edit");
55
+ snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "edit");
50
56
  }
51
57
  catch (error) {
52
58
  const near = nearestPaths(ctx.cwd, input.path);
@@ -58,13 +64,17 @@ registerTool({
58
64
  return `Error: ${res.error} in ${input.path}. No changes written.`;
59
65
  let committed;
60
66
  try {
61
- committed = await atomicWriteText(p, res.text, { expected: text, expectedIdentity: snapshot });
67
+ committed = await atomicWriteText(boundary.target, res.text, {
68
+ expected: text,
69
+ expectedIdentity: snapshot,
70
+ boundary,
71
+ });
62
72
  }
63
73
  catch (error) {
64
74
  return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
65
75
  }
66
76
  emitDiff(input.path, text, res.text, ctx.ui);
67
- recordEdit([{ path: input.path, absPath: p, before: text, beforeMode: snapshot.mode, committed, after: res.text }]);
77
+ recordEdit([{ path: input.path, absPath: boundary.target, before: text, beforeMode: snapshot.mode, committed, after: res.text }]);
68
78
  invalidateFileCandidates(ctx.cwd);
69
79
  const note = res.fuzzy ? " (quote-normalized)" : "";
70
80
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;