@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.
- package/CHANGELOG.md +62 -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
|
@@ -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
|
+
}
|
package/dist/serve/server.js
CHANGED
|
@@ -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
|
|
469
|
+
return readModelContextFileSync(f, 32 * 1024);
|
|
469
470
|
}
|
|
470
471
|
catch {
|
|
471
472
|
return null;
|
package/dist/skills/skills.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
138
|
+
const boundary = bindAtomicWritePath(p, "scaffold skill");
|
|
139
|
+
try {
|
|
140
|
+
await readVerifiedRegularFileSnapshot(boundary.target, undefined, "scaffold skill");
|
|
137
141
|
return [];
|
|
138
|
-
|
|
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
|
}
|
package/dist/tools/builtin.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
? (
|
|
229
|
-
:
|
|
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
|
-
|
|
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).`;
|
package/dist/tools/cron.js
CHANGED
|
@@ -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,
|
package/dist/tools/edit.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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"}`;
|