@nanhara/hara 0.121.1 → 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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -1,25 +1,32 @@
1
- import { readFile, stat } from "node:fs/promises";
2
1
  import { existsSync, readFileSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
3
  import { resolve, isAbsolute } from "node:path";
5
4
  import { stdout as procOut } from "node:process";
6
5
  import { registerTool } from "./registry.js";
7
6
  import { runShell } from "../sandbox.js";
8
- import { isProbablyBinary, nearestPaths } from "../fs-walk.js";
7
+ import { nearestPaths } from "../fs-walk.js";
9
8
  import { emitDiff } from "../diff.js";
10
9
  import { recordEdit } from "../undo.js";
11
- import { atomicWriteText } from "../fs-write.js";
10
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
12
11
  import { invalidateFileCandidates } from "../context/mentions.js";
13
- import { BinaryFileError, streamFileSlice } from "../fs-read.js";
12
+ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, streamFileSlice } from "../fs-read.js";
14
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";
15
16
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
16
17
  const MAX = 100_000;
17
- /** Package installs are network-bound and routinely exceed the foreground cap. When the model omits an
18
- * explicit foreground/background choice, detach these commands into the existing job system so the UI
19
- * remains responsive and the agent can poll output instead of waiting five minutes for a hard kill. */
18
+ /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
20
19
  export function isPackageInstallCommand(command) {
21
20
  return /(?:^|[;&|]\s*)(?:npm\s+(?:i|install|ci)\b|pnpm\s+(?:i|install|add)\b|yarn(?:\s+(?:install|add))?(?:\s|$)|bun\s+(?:i|install|add)\b)/i.test(command.trim());
22
21
  }
22
+ /** Resolve a bounded foreground timeout. Installs get a longer default, but remain attached so a headless
23
+ * run cannot exit, kill its background child, and leave node_modules half-written. */
24
+ export function shellTimeoutMs(command, requested) {
25
+ if (Number.isFinite(requested) && requested > 0) {
26
+ return Math.min(Math.max(Math.trunc(requested), 1_000), 3_600_000);
27
+ }
28
+ return isPackageInstallCommand(command) ? 900_000 : 300_000;
29
+ }
23
30
  export function isNgrokTunnelCommand(command) {
24
31
  return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
25
32
  }
@@ -79,7 +86,6 @@ export function capHeadTail(s, max = MAX) {
79
86
  }
80
87
  const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
81
88
  const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
82
- const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
83
89
  /** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
84
90
  * tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
85
91
  * re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
@@ -119,20 +125,23 @@ registerTool({
119
125
  kind: "read",
120
126
  async run(input, ctx) {
121
127
  const p = abs(input.path, ctx.cwd);
128
+ const denied = sensitiveFileError(p, "read");
129
+ if (denied)
130
+ return denied;
122
131
  try {
123
- const info = await stat(p);
124
- if (info.size > BUFFERED_READ_BYTES)
125
- return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
126
- const buf = await readFile(p);
127
- if (isProbablyBinary(buf))
128
- throw new BinaryFileError(p);
129
- return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
132
+ const target = resolveVerifiedModelPath(p, "read");
133
+ // streamFileSlice opens O_NONBLOCK, validates the same fd as a regular file, and stops after the
134
+ // requested window. Using it for every size removes the path-level stat→read race entirely.
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)
133
142
  return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
134
143
  const near = nearestPaths(ctx.cwd, input.path);
135
- return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
144
+ return `Error: cannot read ${input.path}: ${e.message ?? e.code}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
136
145
  }
137
146
  },
138
147
  });
@@ -150,28 +159,41 @@ 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
- let prev = null;
167
+ let prevSnapshot = null;
168
+ let boundary;
156
169
  try {
157
- prev = await readFile(p, "utf8");
170
+ boundary = bindAtomicWritePath(p, "write");
171
+ prevSnapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "write");
158
172
  }
159
173
  catch (error) {
160
- if (error?.code !== "ENOENT")
161
- return `Error: cannot inspect ${input.path}: ${error?.code ?? error?.message}. No changes written.`;
174
+ if (error?.code !== "ENOENT" || !boundary)
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.`;
179
+ const prev = prevSnapshot?.text ?? null;
163
180
  if (prev === input.content)
164
181
  return `Unchanged ${p} (${input.content.length} chars already match).`;
182
+ let committed;
165
183
  try {
166
- await atomicWriteText(p, input.content, { expected: prev });
184
+ committed = await atomicWriteText(boundary.target, input.content, {
185
+ expected: prev,
186
+ expectedIdentity: prevSnapshot ?? undefined,
187
+ boundary,
188
+ });
167
189
  }
168
190
  catch (error) {
169
191
  return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
170
192
  }
171
193
  emitDiff(input.path, prev ?? "", input.content, ctx.ui);
172
- recordEdit([{ path: input.path, absPath: p, before: prev }]);
194
+ recordEdit([{ path: input.path, absPath: boundary.target, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
173
195
  invalidateFileCandidates(ctx.cwd);
174
- return `Wrote ${String(input.content).length} chars to ${p}`;
196
+ return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
175
197
  },
176
198
  });
177
199
  registerTool({
@@ -181,21 +203,26 @@ registerTool({
181
203
  type: "object",
182
204
  properties: {
183
205
  command: { type: "string" },
184
- timeout_ms: { type: "number", description: "default 300000 (5 min); set explicitly for a foreground long build/transform" },
185
- background: { type: "boolean", description: "run as a background job (dev server, watcher, long task). Package installs auto-background when this field and timeout_ms are omitted. Poll with the `job` tool before depending on completion." },
206
+ timeout_ms: { type: "number", description: "default 300000 (5 min), or 900000 (15 min) for package installs; bounded to 1s..1h" },
207
+ background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); package installs stay foreground unless explicitly requested" },
186
208
  },
187
209
  required: ["command"],
188
210
  },
189
211
  kind: "exec",
190
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
+ }
191
218
  if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
192
219
  return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
193
220
  "Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
194
221
  }
195
- const autoPackageJob = input.background === undefined && input.timeout_ms === undefined && isPackageInstallCommand(input.command);
196
- if (input.background || autoPackageJob) {
222
+ if (input.background) {
197
223
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
198
- return `${autoPackageJob ? "Package install auto-started" : "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 installed packages.`;
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.`;
199
226
  }
200
227
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
201
228
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
@@ -213,37 +240,38 @@ registerTool({
213
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)}`;
214
241
  }
215
242
  }
216
- let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
217
- const live = ctx.ui
218
- ? (s) => {
219
- buf += s;
220
- let i;
221
- while ((i = buf.indexOf("\n")) >= 0) {
222
- ctx.ui.notice(buf.slice(0, i));
223
- buf = buf.slice(i + 1);
224
- }
225
- }
243
+ const liveEmit = ctx.ui
244
+ ? (line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))
226
245
  : procOut.isTTY
227
- ? (s) => procOut.write(s) // stream output in a plain terminal
228
- : 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(); };
256
+ const timeout = shellTimeoutMs(input.command, input.timeout_ms);
229
257
  try {
230
258
  const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
231
- timeout: input.timeout_ms ?? 300_000, // was 120s — a long file transform/build legitimately runs longer
259
+ timeout,
232
260
  maxBuffer: 10 * 1024 * 1024,
233
261
  onData: live,
234
262
  });
235
- if (ctx.ui && buf)
236
- ctx.ui.notice(buf); // flush trailing partial line
263
+ flushLive();
237
264
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
238
- return capHeadTail(combined.trim() || "(no output)");
265
+ return capHeadTail(redactToolSubprocessOutput(combined.trim() || "(no output)"));
239
266
  }
240
267
  catch (e) {
268
+ flushLive();
241
269
  let base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
242
270
  // Timeout gets an ACTIONABLE next step, not just a corpse — the model (and user) should pick a
243
271
  // lane instead of blind-retrying into the same wall.
244
272
  if (/timed out after \d+ms/.test(String(e.message))) {
245
273
  base +=
246
- `\n⏱ hara: the command hit its ${input.timeout_ms ?? 300_000}ms cap and was killed. Pick ONE: ` +
274
+ `\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
247
275
  `a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
248
276
  `a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
249
277
  }
@@ -256,10 +284,10 @@ registerTool({
256
284
  if (host) {
257
285
  markHostUnreachable(host);
258
286
  ctx.ui?.notice(`↯ ${host} marked unreachable for this session — won't retry network ops to it`);
259
- return capHeadTail(base + proxyHint(host));
287
+ return capHeadTail(redactToolSubprocessOutput(base + proxyHint(host)));
260
288
  }
261
289
  }
262
- return capHeadTail(base);
290
+ return capHeadTail(redactToolSubprocessOutput(base));
263
291
  }
264
292
  },
265
293
  });
@@ -282,14 +310,14 @@ registerTool({
282
310
  const js = listJobs();
283
311
  if (!js.length)
284
312
  return "(no background jobs)";
285
- 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");
286
314
  }
287
315
  const id = String(input.id ?? "");
288
316
  if (!id)
289
317
  return "Error: `id` is required for tail/kill.";
290
318
  if (action === "tail") {
291
319
  const t = tailJob(id, Number(input.lines) || 40);
292
- return t == null ? `No job ${id}.` : t.trim() || "(no output yet)";
320
+ return t == null ? `No job ${id}.` : redactToolSubprocessOutput(t.trim() || "(no output yet)");
293
321
  }
294
322
  if (action === "kill")
295
323
  return killJob(id) ? `Killed ${id}.` : `No running job ${id} (already exited/killed or unknown).`;
@@ -72,7 +72,9 @@ registerTool({
72
72
  // to pure lexical when no index/embedder — zero behaviour change for the default install.
73
73
  const out = [];
74
74
  const seen = new Set();
75
- const cfg = loadConfig();
75
+ // Tool execution can be dispatched to a registered agent home without changing process.cwd(). Its
76
+ // semantic-search provider/index settings belong to the tool context, not to the launcher directory.
77
+ const cfg = loadConfig({ cwd: ctx.cwd });
76
78
  const embed = getEmbedder(cfg);
77
79
  if (embed && indexExists("repo", ctx.cwd)) {
78
80
  try {
@@ -284,98 +284,104 @@ export function computerBackends() {
284
284
  return "PowerShell (built-in)";
285
285
  return `unsupported (${process.platform})`;
286
286
  }
287
- registerTool({
288
- name: "computer",
289
- description: "Control the screen to operate desktop software (not just the browser). ALWAYS `activate` the target app " +
290
- "FIRST (e.g. activate WeChat) otherwise screenshots/clicks hit the terminal hara runs in, not the app. " +
291
- "Then prefer grounding over guessing pixels: pass `target` (e.g. 'the Send button') to click/move and it's " +
292
- "located by a vision model; or `find` to just get coordinates. Workflow: activate → screenshot → click a " +
293
- "target → re-screenshot to verify. When typing, type the ACTUAL text — never placeholders. Opt-in and " +
294
- "permission-gated (tier + per-app allowlist).",
295
- input_schema: {
296
- type: "object",
297
- properties: {
298
- action: { type: "string", enum: ["screenshot", "activate", "find", "click", "move", "type", "key"] },
299
- app: { type: "string", description: "app to bring to the foreground (activate) e.g. 'WeChat'. Do this BEFORE screenshot/click so they hit the app, not the terminal." },
300
- target: { type: "string", description: "describe a UI element to locate (find) or click/move to — e.g. 'the Send button'. Preferred over x,y." },
301
- x: { type: "number", description: "x pixel (click/move; or use `target`)" },
302
- y: { type: "number", description: "y pixel (click/move; or use `target`)" },
303
- text: { type: "string", description: "text to type (type)" },
304
- keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
305
- focus: { type: "string", description: "screenshot only: what to look forfocuses the read" },
287
+ // Gateway runs get NO computer tool by default (HARA_GATEWAY_COMPUTER=1 opts back in): a chat-driven, full-auto
288
+ // agent reaching for desktop automation is how "check Feishu" turns into blindly clicking another app's windows.
289
+ // In a gateway context the right lever is an API/skill, and send_file already covers delivery so the safe
290
+ // default is to not offer screen control at all rather than trust the model to decline it.
291
+ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
292
+ registerTool({
293
+ name: "computer",
294
+ description: "Control the screen to operate desktop software (not just the browser). ALWAYS `activate` the target app " +
295
+ "FIRST (e.g. activate WeChat) — otherwise screenshots/clicks hit the terminal hara runs in, not the app. " +
296
+ "Then prefer grounding over guessing pixels: pass `target` (e.g. 'the Send button') to click/move and it's " +
297
+ "located by a vision model; or `find` to just get coordinates. Workflow: activate → screenshot → click a " +
298
+ "target re-screenshot to verify. When typing, type the ACTUAL text — never placeholders. Opt-in and " +
299
+ "permission-gated (tier + per-app allowlist).",
300
+ input_schema: {
301
+ type: "object",
302
+ properties: {
303
+ action: { type: "string", enum: ["screenshot", "activate", "find", "click", "move", "type", "key"] },
304
+ app: { type: "string", description: "app to bring to the foreground (activate) — e.g. 'WeChat'. Do this BEFORE screenshot/click so they hit the app, not the terminal." },
305
+ target: { type: "string", description: "describe a UI element to locate (find) or click/move to e.g. 'the Send button'. Preferred over x,y." },
306
+ x: { type: "number", description: "x pixel (click/move; or use `target`)" },
307
+ y: { type: "number", description: "y pixel (click/move; or use `target`)" },
308
+ text: { type: "string", description: "text to type (type)" },
309
+ keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
310
+ focus: { type: "string", description: "screenshot only: what to look for — focuses the read" },
311
+ },
312
+ required: ["action"],
306
313
  },
307
- required: ["action"],
308
- },
309
- kind: "computer",
310
- async run(input, ctx) {
311
- const cfg = loadConfig();
312
- const tier = cfg.computerUse;
313
- if (tier === "off")
314
- return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
315
- const action = String(input.action ?? "");
316
- if (!actionAllowed(tier, action))
317
- return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
318
- // Bring the target app to the foreground first — without this, clicks land on the terminal hara runs in.
319
- if (action === "activate") {
320
- const app = String(input.app ?? input.target ?? "");
321
- if (!app)
322
- return "activate needs an `app` name (e.g. 'WeChat').";
323
- if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
324
- return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
325
- const r = activateApp(app);
326
- return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
327
- }
328
- if (action !== "screenshot" && action !== "find") {
329
- // per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
330
- if (!cfg.computerApps.length)
331
- return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
332
- const app = frontmostApp();
333
- const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
334
- if (!allowed)
335
- return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
336
- }
337
- if (action === "screenshot") {
338
- const s = screenshot();
339
- if (s.error)
340
- return fail(`screenshot — ${s.error}`);
341
- if (ctx.describeImage) {
342
- try {
343
- const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
344
- if (desc)
345
- return ok(`Screenshot (read via vision):\n${desc}`);
346
- }
347
- catch {
348
- /* fall through to path */
314
+ kind: "computer",
315
+ async run(input, ctx) {
316
+ const cfg = loadConfig();
317
+ const tier = cfg.computerUse;
318
+ if (tier === "off")
319
+ return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
320
+ const action = String(input.action ?? "");
321
+ if (!actionAllowed(tier, action))
322
+ return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
323
+ // Bring the target app to the foreground first — without this, clicks land on the terminal hara runs in.
324
+ if (action === "activate") {
325
+ const app = String(input.app ?? input.target ?? "");
326
+ if (!app)
327
+ return "activate needs an `app` name (e.g. 'WeChat').";
328
+ if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
329
+ return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
330
+ const r = activateApp(app);
331
+ return r.ok ? ok(`✓ ${r.msg} now screenshot/find/click to act on it`) : fail(r.msg);
332
+ }
333
+ if (action !== "screenshot" && action !== "find") {
334
+ // per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
335
+ if (!cfg.computerApps.length)
336
+ return "No apps allowlisted set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
337
+ const app = frontmostApp();
338
+ const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
339
+ if (!allowed)
340
+ return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
341
+ }
342
+ if (action === "screenshot") {
343
+ const s = screenshot();
344
+ if (s.error)
345
+ return fail(`screenshot ${s.error}`);
346
+ if (ctx.describeImage) {
347
+ try {
348
+ const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
349
+ if (desc)
350
+ return ok(`Screenshot (read via vision):\n${desc}`);
351
+ }
352
+ catch {
353
+ /* fall through to path */
354
+ }
349
355
  }
356
+ return ok(`Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`);
350
357
  }
351
- return ok(`Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`);
352
- }
353
- // Grounding: locate a described element and turn it into screen coordinates (more reliable than guessing
354
- // pixels from a text description). Used for `find`, and for click/move when given a `target` and no x,y.
355
- const needsLocate = action === "find" || ((action === "click" || action === "move") && input.target != null && (input.x == null || input.y == null));
356
- if (needsLocate) {
357
- const target = String(input.target ?? "");
358
- if (!target)
359
- return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
360
- if (!ctx.locate)
361
- return "Grounding needs a vision model that can see images — set one: `hara config set visionModel <model>`.";
362
- const s = screenshot();
363
- if (s.error)
364
- return fail(`screenshot — ${s.error}`);
365
- const loc = await ctx.locate(s.path, target);
366
- if (!loc)
367
- return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
368
- const size = screenSize();
369
- if (!size)
370
- return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
371
- const gx = Math.round(loc.x * size.w);
372
- const gy = Math.round(loc.y * size.h);
373
- if (action === "find")
374
- return ok(`"${target}" is at ~${gx},${gy} (${Math.round(loc.x * 100)}% across, ${Math.round(loc.y * 100)}% down).`);
375
- input.x = gx;
376
- input.y = gy;
377
- }
378
- const r = pointerOrKeyboard(action, input);
379
- return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
380
- },
381
- });
358
+ // Grounding: locate a described element and turn it into screen coordinates (more reliable than guessing
359
+ // pixels from a text description). Used for `find`, and for click/move when given a `target` and no x,y.
360
+ const needsLocate = action === "find" || ((action === "click" || action === "move") && input.target != null && (input.x == null || input.y == null));
361
+ if (needsLocate) {
362
+ const target = String(input.target ?? "");
363
+ if (!target)
364
+ return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
365
+ if (!ctx.locate)
366
+ return "Grounding needs a vision model that can see images set one: `hara config set visionModel <model>`.";
367
+ const s = screenshot();
368
+ if (s.error)
369
+ return fail(`screenshot ${s.error}`);
370
+ const loc = await ctx.locate(s.path, target);
371
+ if (!loc)
372
+ return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
373
+ const size = screenSize();
374
+ if (!size)
375
+ return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
376
+ const gx = Math.round(loc.x * size.w);
377
+ const gy = Math.round(loc.y * size.h);
378
+ if (action === "find")
379
+ return ok(`"${target}" is at ~${gx},${gy} (${Math.round(loc.x * 100)}% across, ${Math.round(loc.y * 100)}% down).`);
380
+ input.x = gx;
381
+ input.y = gy;
382
+ }
383
+ const r = pointerOrKeyboard(action, input);
384
+ return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
385
+ },
386
+ });
387
+ }
@@ -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,
@@ -1,12 +1,13 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { isAbsolute, resolve } from "node:path";
3
2
  import { registerTool } from "./registry.js";
4
3
  import { nearestPaths } from "../fs-walk.js";
5
4
  import { emitDiff } from "../diff.js";
6
5
  import { applyEdits } from "./apply-core.js";
7
6
  import { recordEdit } from "../undo.js";
8
- import { atomicWriteText } from "../fs-write.js";
7
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
9
8
  import { invalidateFileCandidates } from "../context/mentions.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,31 +42,43 @@ 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
- let text;
51
+ let snapshot;
52
+ let boundary;
48
53
  try {
49
- text = await readFile(p, "utf8");
54
+ boundary = bindAtomicWritePath(p, "edit");
55
+ snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "edit");
50
56
  }
51
- catch {
57
+ catch (error) {
52
58
  const near = nearestPaths(ctx.cwd, input.path);
53
- return `Error: cannot read ${input.path} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
59
+ return `Error: cannot read ${input.path}: ${error?.message ?? "unknown error"} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
54
60
  }
61
+ const text = snapshot.text;
55
62
  const res = applyEdits(text, edits);
56
63
  if ("error" in res)
57
64
  return `Error: ${res.error} in ${input.path}. No changes written.`;
65
+ let committed;
58
66
  try {
59
- await atomicWriteText(p, res.text, { expected: text });
67
+ committed = await atomicWriteText(boundary.target, res.text, {
68
+ expected: text,
69
+ expectedIdentity: snapshot,
70
+ boundary,
71
+ });
60
72
  }
61
73
  catch (error) {
62
74
  return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
63
75
  }
64
76
  emitDiff(input.path, text, res.text, ctx.ui);
65
- recordEdit([{ path: input.path, absPath: p, before: text }]);
77
+ recordEdit([{ path: input.path, absPath: boundary.target, before: text, beforeMode: snapshot.mode, committed, after: res.text }]);
66
78
  invalidateFileCandidates(ctx.cwd);
67
79
  const note = res.fuzzy ? " (quote-normalized)" : "";
68
80
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
69
- return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
81
+ return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.` +
82
+ (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
70
83
  },
71
84
  });