@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.
- package/CHANGELOG.md +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- 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/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- 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/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/profile/profile.js
CHANGED
|
@@ -30,8 +30,12 @@
|
|
|
30
30
|
// tolerates reading it from a legacy config.json (it just maps to the migrated gateway profile).
|
|
31
31
|
// ────────────────────────────────────────────────────────────────────────────────
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
33
34
|
import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
34
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync,
|
|
35
|
+
import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, chmodSync, realpathSync, renameSync } from "node:fs";
|
|
36
|
+
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
37
|
+
import { atomicWriteText, bindProfilePinWritePath, discardClaimedPath, verifyAtomicWriteBoundary, } from "../fs-write.js";
|
|
38
|
+
import { projectRepositoryTrustedAtStartup } from "../security/project-trust.js";
|
|
35
39
|
const PERSONAL_ID = "personal";
|
|
36
40
|
const DEFAULT_ORG_ID = "default-org";
|
|
37
41
|
function haraDir() {
|
|
@@ -149,6 +153,86 @@ export function listProfiles() {
|
|
|
149
153
|
// the global default (~/.hara/profiles.json `active`).
|
|
150
154
|
// ────────────────────────────────────────────────────────────────────────────────
|
|
151
155
|
const PIN_FILE = ".hara-profile";
|
|
156
|
+
const MAX_PIN_BYTES = 4096;
|
|
157
|
+
const warnedPinFiles = new Set();
|
|
158
|
+
function canonicalExistingDirectory(path) {
|
|
159
|
+
try {
|
|
160
|
+
return realpathSync.native(resolvePath(path));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return resolvePath(path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function warnIgnoredPin(file, reason) {
|
|
167
|
+
const key = `${file}:${reason}`;
|
|
168
|
+
if (warnedPinFiles.has(key))
|
|
169
|
+
return;
|
|
170
|
+
warnedPinFiles.add(key);
|
|
171
|
+
const detail = {
|
|
172
|
+
unavailable: "names a profile that is not available",
|
|
173
|
+
tracked: "is tracked by Git and repository identity pins are untrusted by default",
|
|
174
|
+
unverified: "is inside a Git worktree but its tracked status could not be verified",
|
|
175
|
+
invalid: "has an invalid format",
|
|
176
|
+
unsafe: "failed filesystem identity checks",
|
|
177
|
+
}[reason];
|
|
178
|
+
const trustHint = reason === "tracked" || reason === "unverified"
|
|
179
|
+
? " Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust."
|
|
180
|
+
: "";
|
|
181
|
+
try {
|
|
182
|
+
// The directory path is repository input and may itself contain token-shaped text. The fixed basename
|
|
183
|
+
// is enough to identify the feature without reflecting any attacker-controlled path or file content.
|
|
184
|
+
process.stderr.write(`hara: ignored .hara-profile: it ${detail}.${trustHint} Run \`hara profile pin <id>\` or \`hara profile unpin\` to fix.\n`);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* best effort */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function gitMarkerAbove(start) {
|
|
191
|
+
let dir = start;
|
|
192
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
193
|
+
try {
|
|
194
|
+
lstatSync(join(dir, ".git"));
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error?.code !== "ENOENT")
|
|
199
|
+
return true; // unreadable/suspicious marker: require a successful Git check
|
|
200
|
+
}
|
|
201
|
+
const parent = dirname(dir);
|
|
202
|
+
if (parent === dir)
|
|
203
|
+
return false;
|
|
204
|
+
dir = parent;
|
|
205
|
+
}
|
|
206
|
+
return true; // fail closed on a pathological path depth
|
|
207
|
+
}
|
|
208
|
+
/** Check only the index, with no shell and no repository-controlled command string. Unknown/error is distinct
|
|
209
|
+
* from untracked so a missing/failed git binary can never silently bless a committed identity pin. */
|
|
210
|
+
function pinTracking(file) {
|
|
211
|
+
const dir = dirname(file);
|
|
212
|
+
if (!gitMarkerAbove(dir))
|
|
213
|
+
return "outside-git";
|
|
214
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
215
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
216
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
217
|
+
const result = spawnSync("git", [
|
|
218
|
+
"-c", "core.fsmonitor=false",
|
|
219
|
+
"-c", "core.untrackedCache=false",
|
|
220
|
+
"-C", dir,
|
|
221
|
+
"ls-files", "--error-unmatch", "--", PIN_FILE,
|
|
222
|
+
], {
|
|
223
|
+
env,
|
|
224
|
+
stdio: "ignore",
|
|
225
|
+
timeout: 2000,
|
|
226
|
+
windowsHide: true,
|
|
227
|
+
});
|
|
228
|
+
if (result.error || result.signal)
|
|
229
|
+
return "unknown";
|
|
230
|
+
if (result.status === 0)
|
|
231
|
+
return "tracked";
|
|
232
|
+
if (result.status === 1)
|
|
233
|
+
return "untracked";
|
|
234
|
+
return "unknown";
|
|
235
|
+
}
|
|
152
236
|
export function pinFilePath(dir) {
|
|
153
237
|
return join(dir, PIN_FILE);
|
|
154
238
|
}
|
|
@@ -157,31 +241,46 @@ export function pinFilePath(dir) {
|
|
|
157
241
|
* If a pin file exists but names an unknown profile, we emit a one-line stderr warn
|
|
158
242
|
* and return null (non-fatal — the active resolution falls through to the next layer). */
|
|
159
243
|
export function findPinnedProfile(startDir) {
|
|
160
|
-
const home = homedir();
|
|
161
|
-
|
|
162
|
-
|
|
244
|
+
const home = canonicalExistingDirectory(homedir());
|
|
245
|
+
let dir = canonicalExistingDirectory(startDir);
|
|
246
|
+
const { root } = parsePath(dir);
|
|
163
247
|
// Track visited to defend against pathological symlink loops (best-effort).
|
|
164
248
|
const seen = new Set();
|
|
165
249
|
while (!seen.has(dir)) {
|
|
166
250
|
seen.add(dir);
|
|
167
251
|
const file = pinFilePath(dir);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
252
|
+
try {
|
|
253
|
+
const snapshot = readVerifiedRegularFileSnapshotSync(file, MAX_PIN_BYTES, {
|
|
254
|
+
action: "read profile pin",
|
|
255
|
+
protectSensitive: false,
|
|
256
|
+
rejectHardLinks: true,
|
|
257
|
+
});
|
|
258
|
+
if (snapshot.text.includes("\0")) {
|
|
259
|
+
warnIgnoredPin(file, "invalid");
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (!projectRepositoryTrustedAtStartup()) {
|
|
263
|
+
const tracking = pinTracking(file);
|
|
264
|
+
if (tracking === "tracked") {
|
|
265
|
+
warnIgnoredPin(file, "tracked");
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (tracking === "unknown") {
|
|
269
|
+
warnIgnoredPin(file, "unverified");
|
|
270
|
+
return null;
|
|
181
271
|
}
|
|
182
272
|
}
|
|
183
|
-
|
|
184
|
-
|
|
273
|
+
const id = snapshot.text.split(/\r?\n/)[0].trim();
|
|
274
|
+
if (id && getProfile(id))
|
|
275
|
+
return { id, file };
|
|
276
|
+
if (id)
|
|
277
|
+
warnIgnoredPin(file, "unavailable");
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error?.code !== "ENOENT") {
|
|
282
|
+
warnIgnoredPin(file, error?.code === "HARA_FILE_TOO_LARGE" ? "invalid" : "unsafe");
|
|
283
|
+
return null;
|
|
185
284
|
}
|
|
186
285
|
}
|
|
187
286
|
// Stop walking once we're at $HOME or the fs root (don't escape into shared parent dirs).
|
|
@@ -194,24 +293,50 @@ export function findPinnedProfile(startDir) {
|
|
|
194
293
|
}
|
|
195
294
|
return null;
|
|
196
295
|
}
|
|
197
|
-
/**
|
|
198
|
-
export function writePin(dir, id) {
|
|
296
|
+
/** Atomically write `.hara-profile` in the given dir with `id` as the only line. */
|
|
297
|
+
export async function writePin(dir, id) {
|
|
199
298
|
if (!getProfile(id))
|
|
200
299
|
throw new Error(`no profile '${id}' — list with \`hara profile list\``);
|
|
201
300
|
const file = pinFilePath(dir);
|
|
202
|
-
|
|
203
|
-
|
|
301
|
+
const boundary = bindProfilePinWritePath(file);
|
|
302
|
+
let snapshot = null;
|
|
303
|
+
try {
|
|
304
|
+
snapshot = readVerifiedRegularFileSnapshotSync(boundary.target, MAX_PIN_BYTES, {
|
|
305
|
+
action: "write profile pin",
|
|
306
|
+
protectSensitive: false,
|
|
307
|
+
rejectHardLinks: true,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error?.code !== "ENOENT")
|
|
312
|
+
throw new Error("refusing to replace an unsafe profile pin");
|
|
313
|
+
}
|
|
314
|
+
await atomicWriteText(boundary.target, id + "\n", {
|
|
315
|
+
expected: snapshot?.text ?? null,
|
|
316
|
+
expectedIdentity: snapshot ?? undefined,
|
|
317
|
+
boundary,
|
|
318
|
+
mode: 0o600,
|
|
319
|
+
});
|
|
320
|
+
return { file: boundary.target };
|
|
204
321
|
}
|
|
205
322
|
/** Remove `.hara-profile` from the given dir. Returns true if it was there. */
|
|
206
323
|
export function removePin(dir) {
|
|
207
324
|
const file = pinFilePath(dir);
|
|
208
|
-
|
|
209
|
-
return false;
|
|
325
|
+
let boundary;
|
|
210
326
|
try {
|
|
211
|
-
|
|
327
|
+
boundary = bindProfilePinWritePath(file, "remove profile pin");
|
|
328
|
+
const snapshot = readVerifiedRegularFileSnapshotSync(boundary.target, MAX_PIN_BYTES, {
|
|
329
|
+
action: "remove profile pin",
|
|
330
|
+
protectSensitive: false,
|
|
331
|
+
rejectHardLinks: true,
|
|
332
|
+
});
|
|
333
|
+
verifyAtomicWriteBoundary(boundary);
|
|
334
|
+
discardClaimedPath(boundary.target, snapshot);
|
|
212
335
|
return true;
|
|
213
336
|
}
|
|
214
|
-
catch {
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error?.code !== "ENOENT")
|
|
339
|
+
warnIgnoredPin(boundary?.target ?? file, "unsafe");
|
|
215
340
|
return false;
|
|
216
341
|
}
|
|
217
342
|
}
|
package/dist/recall.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Phase-C v0: lexical-first (no embeddings); reuses the shared filesystem walker.
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
7
7
|
import { walkFiles } from "./fs-walk.js";
|
|
8
8
|
import { skillsDirs } from "./skills/skills.js";
|
|
9
|
+
import { readModelContextFileSync } from "./fs-read.js";
|
|
10
|
+
const MAX_RECALL_SOURCE_BYTES = 256 * 1024;
|
|
9
11
|
export function assetsDir() {
|
|
10
12
|
return process.env.HARA_ASSETS || join(homedir(), ".hara", "code-assets");
|
|
11
13
|
}
|
|
@@ -56,7 +58,7 @@ export function searchAssets(query, limit = 5, roots) {
|
|
|
56
58
|
for (const rel of walkFiles(dir).filter((f) => f.endsWith(".md"))) {
|
|
57
59
|
let text;
|
|
58
60
|
try {
|
|
59
|
-
text =
|
|
61
|
+
text = readModelContextFileSync(join(dir, rel), MAX_RECALL_SOURCE_BYTES);
|
|
60
62
|
}
|
|
61
63
|
catch {
|
|
62
64
|
continue;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const MIN_NODE_MAJOR = 22;
|
|
2
|
+
// Commander 15 (a direct runtime dependency) requires this exact floor. Keep package engines, startup,
|
|
3
|
+
// doctor, and docs aligned so an early Node 22 release gets an upgrade hint before dependencies load.
|
|
4
|
+
export const MIN_NODE_VERSION = "22.12.0";
|
|
5
|
+
function supportedNodeVersion(version) {
|
|
6
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
7
|
+
if (!match)
|
|
8
|
+
return false;
|
|
9
|
+
const current = match.slice(1).map(Number);
|
|
10
|
+
const minimum = MIN_NODE_VERSION.split(".").map(Number);
|
|
11
|
+
for (let index = 0; index < minimum.length; index++) {
|
|
12
|
+
if (current[index] > minimum[index])
|
|
13
|
+
return true;
|
|
14
|
+
if (current[index] < minimum[index])
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
/** Bun powers Hara's standalone binaries and does not require a host Node installation. */
|
|
20
|
+
export function unsupportedNodeMessage(versions = process.versions) {
|
|
21
|
+
if (versions.bun)
|
|
22
|
+
return null;
|
|
23
|
+
const version = String(versions.node ?? "unknown");
|
|
24
|
+
if (supportedNodeVersion(version))
|
|
25
|
+
return null;
|
|
26
|
+
const major = Number.parseInt(version, 10);
|
|
27
|
+
const detail = major === MIN_NODE_MAJOR
|
|
28
|
+
? `This Node.js ${MIN_NODE_MAJOR} release is below Hara's supported ${MIN_NODE_VERSION} floor.`
|
|
29
|
+
: `This Node.js release is below Hara's supported ${MIN_NODE_VERSION} floor.`;
|
|
30
|
+
return [
|
|
31
|
+
`Hara requires Node.js ${MIN_NODE_VERSION} or newer (detected ${version}).`,
|
|
32
|
+
detail,
|
|
33
|
+
`Upgrade with: nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR}`,
|
|
34
|
+
"Or install the standalone Hara binary, which does not require Node.js:",
|
|
35
|
+
" curl -fsSL https://raw.githubusercontent.com/hara-cli/hara/main/install.sh | sh",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
package/dist/sandbox.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// OS sandboxing for the bash tool
|
|
1
|
+
// OS sandboxing for the bash tool. macOS Seatbelt provides write confinement plus a narrow protected-file
|
|
2
|
+
// read mask. Other reads, network, and process exec are not generally confined; non-macOS shells have only
|
|
3
|
+
// Hara's command preflight and must not be described as a kernel-enforced read sandbox.
|
|
2
4
|
// (sandbox-exec) restricting `file-write*` to the workspace (workspace-write) or to nothing outside
|
|
3
5
|
// temp (read-only). Reads, network, and process exec are NOT restricted, and /private/tmp +
|
|
4
6
|
// /private/var/folders stay writable in every mode — so this stops a stray `rm`/overwrite escaping the
|
|
@@ -6,9 +8,9 @@
|
|
|
6
8
|
// emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
|
|
7
9
|
// shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
|
|
8
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { platform } from "node:os";
|
|
12
|
+
import { existingSensitiveSeatbeltMasks, sensitiveFilesAllowed, sensitiveShellCommandReason, } from "./security/sensitive-files.js";
|
|
13
|
+
import { terminateSubprocessTree, toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
12
14
|
// Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
|
|
13
15
|
// `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
|
|
14
16
|
// have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
|
|
@@ -34,8 +36,15 @@ export function resolveShellArgv(command, plat, bash) {
|
|
|
34
36
|
}
|
|
35
37
|
return { cmd: "/bin/sh", args: ["-c", command] };
|
|
36
38
|
}
|
|
37
|
-
const
|
|
39
|
+
const MAX_INLINE_SEATBELT_PROFILE_BYTES = 96 * 1024;
|
|
40
|
+
const sbQuote = (s) => {
|
|
41
|
+
if (/[\u0000-\u001f\u007f]/u.test(s)) {
|
|
42
|
+
throw new Error("cannot safely encode a protected path containing control characters in a Seatbelt profile");
|
|
43
|
+
}
|
|
44
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
45
|
+
};
|
|
38
46
|
function seatbeltProfile(cwd, mode) {
|
|
47
|
+
const rules = ["(version 1)", "(allow default)"];
|
|
39
48
|
const writable = [
|
|
40
49
|
'(literal "/dev/null")',
|
|
41
50
|
'(literal "/dev/stdout")',
|
|
@@ -45,9 +54,38 @@ function seatbeltProfile(cwd, mode) {
|
|
|
45
54
|
'(subpath "/private/tmp")',
|
|
46
55
|
'(subpath "/private/var/folders")',
|
|
47
56
|
];
|
|
48
|
-
if (mode
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
if (mode !== "off") {
|
|
58
|
+
if (mode === "workspace-write")
|
|
59
|
+
writable.push(`(subpath ${sbQuote(cwd)})`);
|
|
60
|
+
rules.push(`(deny file-write*)\n(allow file-write*\n ${writable.join("\n ")})`);
|
|
61
|
+
}
|
|
62
|
+
const masks = existingSensitiveSeatbeltMasks(cwd);
|
|
63
|
+
const unreadable = masks.files;
|
|
64
|
+
if (unreadable.length) {
|
|
65
|
+
const literals = unreadable.map((path) => `(literal ${sbQuote(path)})`).join("\n ");
|
|
66
|
+
rules.push(`(deny file-read*\n ${literals})`);
|
|
67
|
+
// Protect metadata mutations too: unlink/rename/link/chmod all sit under file-write* in Seatbelt.
|
|
68
|
+
// This remains active in sandbox=off; the opt-out is the explicit launch-time secret-file waiver.
|
|
69
|
+
rules.push(`(deny file-write*\n ${literals})`);
|
|
70
|
+
}
|
|
71
|
+
const unreadableDirs = masks.directories;
|
|
72
|
+
if (unreadableDirs.length) {
|
|
73
|
+
// `subpath` masks descendants; the paired `literal` masks rename/unlink of the directory entry itself.
|
|
74
|
+
const subpaths = unreadableDirs
|
|
75
|
+
.flatMap((path) => [`(literal ${sbQuote(path)})`, `(subpath ${sbQuote(path)})`])
|
|
76
|
+
.join("\n ");
|
|
77
|
+
rules.push(`(deny file-read*\n ${subpaths})`);
|
|
78
|
+
rules.push(`(deny file-write*\n ${subpaths})`);
|
|
79
|
+
}
|
|
80
|
+
const writeContainers = masks.writeContainers;
|
|
81
|
+
if (writeContainers.length) {
|
|
82
|
+
rules.push(`(deny file-write*\n ${writeContainers.map((path) => `(literal ${sbQuote(path)})`).join("\n ")})`);
|
|
83
|
+
}
|
|
84
|
+
const profile = rules.join("\n") + "\n";
|
|
85
|
+
if (Buffer.byteLength(profile) > MAX_INLINE_SEATBELT_PROFILE_BYTES) {
|
|
86
|
+
throw new Error(`protected-file Seatbelt profile exceeds ${MAX_INLINE_SEATBELT_PROFILE_BYTES} bytes; refusing an incomplete or unlaunchable mask`);
|
|
87
|
+
}
|
|
88
|
+
return profile;
|
|
51
89
|
}
|
|
52
90
|
export function sandboxSupported() {
|
|
53
91
|
return platform() === "darwin";
|
|
@@ -61,11 +99,17 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
|
|
|
61
99
|
/** Build the (sandboxed, when supported) argv for a shell command — shared by runShell + background jobs
|
|
62
100
|
* so the seatbelt write-confinement is identical for both. */
|
|
63
101
|
export function shellCommand(command, cwd, mode) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
102
|
+
const protectedReason = sensitiveShellCommandReason(command, cwd);
|
|
103
|
+
if (protectedReason) {
|
|
104
|
+
throw new Error(`shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
|
|
105
|
+
"Restart hara with HARA_ALLOW_SENSITIVE_FILES=1 only for an intentional, user-approved exposure.");
|
|
106
|
+
}
|
|
107
|
+
// sandbox=off still retains the narrow secret-file read mask on macOS. It places no restriction on
|
|
108
|
+
// ordinary reads/writes/network and is removed only by the explicit launch-time sensitive-file opt-in.
|
|
109
|
+
if ((mode !== "off" || !sensitiveFilesAllowed()) && platform() === "darwin") {
|
|
110
|
+
// Non-login shell: inheriting the already-scrubbed PATH is sufficient, while -l would source the
|
|
111
|
+
// user's profile again and silently re-introduce credentials we deliberately removed from env.
|
|
112
|
+
return { cmd: "sandbox-exec", args: ["-p", seatbeltProfile(cwd, mode), "/bin/bash", "-c", command] };
|
|
69
113
|
}
|
|
70
114
|
maybeWarnUnsandboxed(mode);
|
|
71
115
|
const plat = platform();
|
|
@@ -97,54 +141,119 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
97
141
|
// https op against a private repo would otherwise sit silently until the timeout (observed as
|
|
98
142
|
// "git hangs 5 minutes"). With prompts disabled it fails in seconds with a real auth error.
|
|
99
143
|
// Users' credential helpers (keychain/GCM store) still work — only interactive PROMPTS are off.
|
|
100
|
-
const env = {
|
|
101
|
-
...process.env,
|
|
144
|
+
const env = toolSubprocessEnv(process.env, {
|
|
102
145
|
GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT ?? "0",
|
|
103
146
|
GCM_INTERACTIVE: process.env.GCM_INTERACTIVE ?? "never",
|
|
104
|
-
};
|
|
105
|
-
|
|
147
|
+
});
|
|
148
|
+
// A dedicated POSIX process group lets timeout/output-cap termination reach grandchildren (shell →
|
|
149
|
+
// node/npm → worker). Windows uses taskkill /T in terminateSubprocessTree instead.
|
|
150
|
+
const processGroup = platform() !== "win32";
|
|
151
|
+
const child = spawn(cmd, args, { cwd, env, detached: processGroup });
|
|
106
152
|
let stdout = "";
|
|
107
153
|
let stderr = "";
|
|
108
154
|
let timedOut = false;
|
|
109
155
|
let killedForSize = false;
|
|
110
|
-
|
|
156
|
+
let done = false;
|
|
157
|
+
let receivedBytes = 0;
|
|
158
|
+
let forceIssued = false;
|
|
159
|
+
let closeBeforeForce = false;
|
|
160
|
+
let closeBeforeForceCode = null;
|
|
161
|
+
let cancelTermination;
|
|
162
|
+
const grow = (cur, add) => {
|
|
163
|
+
const remaining = opts.maxBuffer - stdout.length - stderr.length;
|
|
164
|
+
if (remaining <= 0)
|
|
165
|
+
return cur;
|
|
166
|
+
return cur + add.slice(0, remaining);
|
|
167
|
+
};
|
|
168
|
+
const hardSettle = () => {
|
|
169
|
+
// A daemon can intentionally create a new process group while retaining our pipes. We cannot signal
|
|
170
|
+
// that unknown group safely, but we can destroy our pipe ends and guarantee the API settles on time.
|
|
171
|
+
child.stdout.destroy();
|
|
172
|
+
child.stderr.destroy();
|
|
173
|
+
settle(null);
|
|
174
|
+
};
|
|
175
|
+
const stopTree = () => {
|
|
176
|
+
if (cancelTermination)
|
|
177
|
+
return;
|
|
178
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
179
|
+
processGroup,
|
|
180
|
+
graceMs: 250,
|
|
181
|
+
fallbackMs: 1_000,
|
|
182
|
+
onFallback: hardSettle,
|
|
183
|
+
onForce: () => {
|
|
184
|
+
forceIssued = true;
|
|
185
|
+
// A quiet descendant can let the direct shell close after TERM while remaining alive itself.
|
|
186
|
+
// Do not claim the timed-out/capped tree is gone until the forced group kill has been issued.
|
|
187
|
+
if (closeBeforeForce)
|
|
188
|
+
settle(closeBeforeForceCode);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const settle = (code, error) => {
|
|
193
|
+
if (done)
|
|
194
|
+
return;
|
|
195
|
+
done = true;
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
cancelTermination?.();
|
|
198
|
+
if (error) {
|
|
199
|
+
reject(Object.assign(error, { stdout, stderr }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (killedForSize) {
|
|
203
|
+
resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process tree killed]` });
|
|
204
|
+
}
|
|
205
|
+
else if (timedOut) {
|
|
206
|
+
reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
|
|
207
|
+
}
|
|
208
|
+
else if (code !== 0) {
|
|
209
|
+
reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
resolve({ stdout, stderr });
|
|
213
|
+
}
|
|
214
|
+
};
|
|
111
215
|
const timer = setTimeout(() => {
|
|
112
216
|
timedOut = true;
|
|
113
|
-
|
|
217
|
+
stopTree();
|
|
114
218
|
}, opts.timeout);
|
|
115
219
|
// Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
|
|
116
220
|
// until the timeout just because we stopped retaining the bytes.
|
|
117
221
|
const checkOverflow = () => {
|
|
118
|
-
if (!killedForSize &&
|
|
222
|
+
if (!killedForSize && receivedBytes > opts.maxBuffer) {
|
|
119
223
|
killedForSize = true;
|
|
120
|
-
|
|
224
|
+
stopTree();
|
|
121
225
|
}
|
|
122
226
|
};
|
|
123
227
|
child.stdout.on("data", (d) => {
|
|
228
|
+
if (done || timedOut || killedForSize)
|
|
229
|
+
return;
|
|
124
230
|
const s = d.toString();
|
|
231
|
+
receivedBytes += d.length;
|
|
125
232
|
stdout = grow(stdout, s);
|
|
126
|
-
opts.
|
|
233
|
+
if (receivedBytes <= opts.maxBuffer)
|
|
234
|
+
opts.onData?.(s, "stdout");
|
|
127
235
|
checkOverflow();
|
|
128
236
|
});
|
|
129
237
|
child.stderr.on("data", (d) => {
|
|
238
|
+
if (done || timedOut || killedForSize)
|
|
239
|
+
return;
|
|
130
240
|
const s = d.toString();
|
|
241
|
+
receivedBytes += d.length;
|
|
131
242
|
stderr = grow(stderr, s);
|
|
132
|
-
opts.
|
|
243
|
+
if (receivedBytes <= opts.maxBuffer)
|
|
244
|
+
opts.onData?.(s, "stderr");
|
|
133
245
|
checkOverflow();
|
|
134
246
|
});
|
|
135
247
|
child.on("error", (e) => {
|
|
136
|
-
|
|
137
|
-
reject(Object.assign(e, { stdout, stderr }));
|
|
248
|
+
settle(null, e);
|
|
138
249
|
});
|
|
139
250
|
child.on("close", (code) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
|
|
147
|
-
resolve({ stdout, stderr });
|
|
251
|
+
if ((timedOut || killedForSize) && !forceIssued) {
|
|
252
|
+
closeBeforeForce = true;
|
|
253
|
+
closeBeforeForceCode = code;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
settle(code);
|
|
148
257
|
});
|
|
149
258
|
});
|
|
150
259
|
}
|