@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
|
@@ -10,21 +10,58 @@
|
|
|
10
10
|
// (deny > ask > allow); anything we can't safely parse fails CLOSED to "ask".
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join, dirname, resolve } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
|
|
16
|
+
import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
|
|
17
|
+
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
14
18
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
19
|
+
const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
|
|
20
|
+
const projectPermissionWarnings = new Set();
|
|
15
21
|
// Programs that are read-only when run as a plain command (no dangerous flags / redirection — see below).
|
|
16
22
|
const READONLY_PROGRAMS = new Set([
|
|
17
|
-
"ls", "pwd", "cat", "head", "tail", "wc", "
|
|
23
|
+
"ls", "pwd", "cat", "head", "tail", "wc", "grep", "rg", "fd", "stat", "file", "which",
|
|
18
24
|
"type", "whoami", "id", "date", "tree", "du", "df", "ps", "uname", "hostname", "dirname", "basename",
|
|
19
|
-
"realpath", "sort", "uniq", "cut", "column", "jq", "true", "false",
|
|
25
|
+
"realpath", "sort", "uniq", "cut", "column", "jq", "true", "false",
|
|
20
26
|
]);
|
|
21
27
|
// `git <sub>` is read-only only for these subcommands.
|
|
22
28
|
const GIT_READONLY_SUB = new Set([
|
|
23
|
-
"
|
|
24
|
-
"ls-remote", "config", "blame", "shortlog", "cat-file", "whatchanged", "grep", "rev-list", "name-rev",
|
|
29
|
+
"describe", "rev-parse", "ls-files", "shortlog", "rev-list", "name-rev",
|
|
25
30
|
]);
|
|
26
31
|
// Flags that can turn an otherwise-read-only command destructive → disqualify from autorun.
|
|
27
32
|
const DANGER_FLAG = /(^|\s)(-i\b|-o\b|--output\b|--exec\b|-delete\b|-fprint\b|--write\b|-w\b|--in-place\b)/;
|
|
33
|
+
function gitPatchOutputRequested(args) {
|
|
34
|
+
for (const arg of args) {
|
|
35
|
+
if (arg === "--")
|
|
36
|
+
break;
|
|
37
|
+
if (/^-[^-]*[mpucUW]/u.test(arg))
|
|
38
|
+
return true;
|
|
39
|
+
if (/^(?:--patch|--patch-with-raw|--patch-with-stat|--unified|--word-diff|--word-diff-regex|--color-words|--cc|--combined-all-paths|--diff-merges|--remerge-diff|--binary|--check|--function-context|--inter-hunk-context)(?:=|$)/u.test(arg))
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function gitDiffIsMetadataOnly(args) {
|
|
45
|
+
let metadata = false;
|
|
46
|
+
for (const arg of args) {
|
|
47
|
+
if (arg === "--")
|
|
48
|
+
break;
|
|
49
|
+
if (gitPatchOutputRequested([arg]))
|
|
50
|
+
return false;
|
|
51
|
+
if (/^--(?:stat(?:=.*)?|name-only|name-status|numstat)$/u.test(arg)) {
|
|
52
|
+
metadata = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (/^(?:-z|--cached|--staged|--merge-base|--no-renames|--no-ext-diff|--no-textconv)$/u.test(arg)
|
|
56
|
+
|| /^--(?:relative|ignore-submodules|submodule|diff-filter|stat-width|stat-name-width|stat-graph-width|stat-count|abbrev)(?:=|$)/u.test(arg))
|
|
57
|
+
continue;
|
|
58
|
+
// Revisions and pathspecs are positional. Unknown options may enable content or external helpers, so
|
|
59
|
+
// a metadata flag does not make them safe automatically.
|
|
60
|
+
if (arg.startsWith("-"))
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return metadata;
|
|
64
|
+
}
|
|
28
65
|
/** Unwrap shell wrappers and strip leading env/wrappers to get the command's essential form. Lossy on
|
|
29
66
|
* purpose — used only for matching/classification, never for execution. */
|
|
30
67
|
export function canonicalize(command) {
|
|
@@ -51,12 +88,54 @@ export function isReadOnlyCommand(canonical) {
|
|
|
51
88
|
return false;
|
|
52
89
|
if (/[>]/.test(canonical))
|
|
53
90
|
return false; // any output redirection → not read-only
|
|
91
|
+
if (/\$(?:\{)?[A-Za-z_][A-Za-z0-9_]*/.test(canonical))
|
|
92
|
+
return false; // variable expansion may print a credential
|
|
93
|
+
if (/^jq\b[\s\S]*(?:\benv\b|\$ENV\b)/.test(canonical))
|
|
94
|
+
return false;
|
|
54
95
|
if (DANGER_FLAG.test(canonical))
|
|
55
96
|
return false;
|
|
56
97
|
const parts = canonical.split(" ");
|
|
57
98
|
const prog = parts[0];
|
|
58
|
-
|
|
59
|
-
|
|
99
|
+
// `ps` is normally read-only, but BSD/GNU environment display modifiers turn it into a credential
|
|
100
|
+
// disclosure command. It must never inherit readonly auto-approval (the shell boundary also hard-denies it).
|
|
101
|
+
if (prog === "ps" && psArgumentsExposeEnvironment(parts.slice(1)))
|
|
102
|
+
return false;
|
|
103
|
+
if (prog === "git") {
|
|
104
|
+
const sub = parts[1] ?? "";
|
|
105
|
+
const args = parts.slice(2);
|
|
106
|
+
// Git can expose deleted credential contents without touching the current filesystem. Autorun only
|
|
107
|
+
// explicit metadata diffs; history commands that inherently print file bodies always require approval.
|
|
108
|
+
if (sub === "diff")
|
|
109
|
+
return gitDiffIsMetadataOnly(args);
|
|
110
|
+
if (sub === "log" || sub === "whatchanged")
|
|
111
|
+
return !gitPatchOutputRequested(args);
|
|
112
|
+
if (sub === "status") {
|
|
113
|
+
return !args.some((arg) => arg === "--verbose" || /^-[^-]*v/u.test(arg));
|
|
114
|
+
}
|
|
115
|
+
if (sub === "config")
|
|
116
|
+
return false; // values may be credentials (http.extraHeader, credential helpers, URLs)
|
|
117
|
+
if (GIT_READONLY_SUB.has(sub))
|
|
118
|
+
return true;
|
|
119
|
+
if (sub === "branch") {
|
|
120
|
+
if (!args.length)
|
|
121
|
+
return true;
|
|
122
|
+
// Bare branch names create/delete/move branches. Autorun only explicit display/query forms.
|
|
123
|
+
return args.some((arg) => ["--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--format", "--sort"].includes(arg))
|
|
124
|
+
&& !args.some((arg) => ["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--edit-description", "--set-upstream-to", "--unset-upstream"].includes(arg));
|
|
125
|
+
}
|
|
126
|
+
if (sub === "tag") {
|
|
127
|
+
if (!args.length)
|
|
128
|
+
return true;
|
|
129
|
+
return args.some((arg) => ["--list", "-l", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--format", "--sort"].includes(arg))
|
|
130
|
+
&& !args.some((arg) => ["-d", "--delete", "-f", "--force", "-a", "--annotate", "-s", "--sign", "-u", "--local-user"].includes(arg));
|
|
131
|
+
}
|
|
132
|
+
if (sub === "remote") {
|
|
133
|
+
// Bare `git remote` lists names only. Verbose/get-url/show variants can print credential-bearing URLs;
|
|
134
|
+
// mutating and networked variants are not readonly-autorun candidates either.
|
|
135
|
+
return args.length === 0;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
60
139
|
return READONLY_PROGRAMS.has(prog);
|
|
61
140
|
}
|
|
62
141
|
/** Split a command on top-level shell operators (&&, ||, ;, |, newline), quote-aware. Returns null if the
|
|
@@ -149,11 +228,12 @@ export function decideCommand(command, rules) {
|
|
|
149
228
|
return strictest(parts.map((p) => decideSimple(p, rules)));
|
|
150
229
|
}
|
|
151
230
|
const DEFAULTS = { allow: [], deny: [], readonlyAutorun: true };
|
|
152
|
-
function
|
|
153
|
-
if (!existsSync(p))
|
|
154
|
-
return {};
|
|
231
|
+
function permissionRecord(raw) {
|
|
155
232
|
try {
|
|
156
|
-
const
|
|
233
|
+
const parsed = JSON.parse(raw);
|
|
234
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
235
|
+
return {};
|
|
236
|
+
const j = parsed;
|
|
157
237
|
return {
|
|
158
238
|
allow: Array.isArray(j.allow) ? j.allow.filter((x) => typeof x === "string") : undefined,
|
|
159
239
|
deny: Array.isArray(j.deny) ? j.deny.filter((x) => typeof x === "string") : undefined,
|
|
@@ -164,6 +244,105 @@ function readRules(p) {
|
|
|
164
244
|
return {};
|
|
165
245
|
}
|
|
166
246
|
}
|
|
247
|
+
function readGlobalRules(p) {
|
|
248
|
+
if (!existsSync(p))
|
|
249
|
+
return {};
|
|
250
|
+
try {
|
|
251
|
+
return permissionRecord(readFileSync(p, "utf8"));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function warnProjectPermissions(kind, message) {
|
|
258
|
+
if (projectPermissionWarnings.has(kind))
|
|
259
|
+
return;
|
|
260
|
+
projectPermissionWarnings.add(kind);
|
|
261
|
+
try {
|
|
262
|
+
process.stderr.write(`hara: ${message}\n`);
|
|
263
|
+
}
|
|
264
|
+
catch { /* best effort */ }
|
|
265
|
+
}
|
|
266
|
+
function unsafeProjectPermissions(kind) {
|
|
267
|
+
warnProjectPermissions(`unsafe-file:${kind}`, `ignored an unsafe project .hara/permissions.json (${kind}); no project permission values were loaded.`);
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
/** Read the nearest repository permission file through a bounded O_NOFOLLOW descriptor. The repository's
|
|
271
|
+
* `.hara` parent is part of the trust boundary too: neither it nor the final file may be an alias, and both
|
|
272
|
+
* identities are rechecked after the read. */
|
|
273
|
+
function readProjectRules(cwd) {
|
|
274
|
+
let dir;
|
|
275
|
+
try {
|
|
276
|
+
dir = realpathSync.native(resolve(cwd));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
dir = resolve(cwd);
|
|
280
|
+
}
|
|
281
|
+
for (;;) {
|
|
282
|
+
const hara = join(dir, ".hara");
|
|
283
|
+
const file = join(hara, "permissions.json");
|
|
284
|
+
let parentInfo;
|
|
285
|
+
try {
|
|
286
|
+
parentInfo = lstatSync(hara);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error?.code !== "ENOENT")
|
|
290
|
+
return unsafeProjectPermissions("unreadable parent");
|
|
291
|
+
}
|
|
292
|
+
if (parentInfo) {
|
|
293
|
+
if (parentInfo.isSymbolicLink())
|
|
294
|
+
return unsafeProjectPermissions("symlink parent");
|
|
295
|
+
if (!parentInfo.isDirectory())
|
|
296
|
+
return unsafeProjectPermissions("non-directory parent");
|
|
297
|
+
try {
|
|
298
|
+
if (realpathSync.native(hara) !== hara)
|
|
299
|
+
return unsafeProjectPermissions("non-canonical parent");
|
|
300
|
+
const fileInfo = lstatSync(file);
|
|
301
|
+
if (fileInfo.isSymbolicLink())
|
|
302
|
+
return unsafeProjectPermissions("symlink file");
|
|
303
|
+
if (!fileInfo.isFile())
|
|
304
|
+
return unsafeProjectPermissions("non-regular file");
|
|
305
|
+
const snapshot = readVerifiedRegularFileSnapshotSync(file, MAX_PROJECT_PERMISSIONS_BYTES, {
|
|
306
|
+
action: "read project permissions",
|
|
307
|
+
protectSensitive: false,
|
|
308
|
+
rejectHardLinks: true,
|
|
309
|
+
});
|
|
310
|
+
const parentAfter = lstatSync(hara);
|
|
311
|
+
if (!parentAfter.isDirectory()
|
|
312
|
+
|| parentAfter.isSymbolicLink()
|
|
313
|
+
|| parentAfter.dev !== parentInfo.dev
|
|
314
|
+
|| parentAfter.ino !== parentInfo.ino
|
|
315
|
+
|| realpathSync.native(hara) !== hara)
|
|
316
|
+
return unsafeProjectPermissions("changed parent");
|
|
317
|
+
return permissionRecord(snapshot.text);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error?.code === "ENOENT") {
|
|
321
|
+
// No file in this directory; continue toward the repository root.
|
|
322
|
+
}
|
|
323
|
+
else if (error?.code === "HARA_HARD_LINKED_FILE") {
|
|
324
|
+
return unsafeProjectPermissions("hard-linked file");
|
|
325
|
+
}
|
|
326
|
+
else if (error?.code === "HARA_FILE_TOO_LARGE") {
|
|
327
|
+
return unsafeProjectPermissions("oversized file");
|
|
328
|
+
}
|
|
329
|
+
else if (/changed while (?:opening|reading)|File changed|path changed/i.test(error?.message ?? "")) {
|
|
330
|
+
return unsafeProjectPermissions("changed file");
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
return unsafeProjectPermissions("invalid file");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(dir, marker))))
|
|
338
|
+
break;
|
|
339
|
+
const parent = dirname(dir);
|
|
340
|
+
if (parent === dir)
|
|
341
|
+
break;
|
|
342
|
+
dir = parent;
|
|
343
|
+
}
|
|
344
|
+
return {};
|
|
345
|
+
}
|
|
167
346
|
export function globalPermissionsPath() {
|
|
168
347
|
return join(homedir(), ".hara", "permissions.json");
|
|
169
348
|
}
|
|
@@ -183,16 +362,34 @@ export function projectPermissionsPath(cwd) {
|
|
|
183
362
|
}
|
|
184
363
|
return null;
|
|
185
364
|
}
|
|
186
|
-
/** Effective rules:
|
|
187
|
-
*
|
|
365
|
+
/** Effective rules: an untrusted repository may only tighten policy (add deny rules or turn readonly
|
|
366
|
+
* autorun off). Project allow rules and a readonlyAutorun=true expansion require the launch-time trust flag.
|
|
367
|
+
* Missing/invalid files use safe defaults; every deny remains deny-wins. */
|
|
188
368
|
export function loadPermissionRules(cwd) {
|
|
189
|
-
const g =
|
|
190
|
-
const
|
|
191
|
-
const
|
|
369
|
+
const g = readGlobalRules(globalPermissionsPath());
|
|
370
|
+
const p = readProjectRules(cwd);
|
|
371
|
+
const trustedProject = projectRepositoryTrustedAtStartup();
|
|
372
|
+
const globalReadonly = g.readonlyAutorun ?? DEFAULTS.readonlyAutorun;
|
|
373
|
+
const privileged = [
|
|
374
|
+
...(p.allow?.length ? ["allow"] : []),
|
|
375
|
+
...(p.readonlyAutorun === true ? ["readonlyAutorun"] : []),
|
|
376
|
+
];
|
|
377
|
+
if (privileged.length) {
|
|
378
|
+
const names = privileged.join(", ");
|
|
379
|
+
if (trustedProject) {
|
|
380
|
+
warnProjectPermissions(`trusted:${names}`, `trusted project permissions enabled for privileged key(s): ${names}.`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
warnProjectPermissions(`ignored:${names}`, `ignored untrusted project permission expansion(s): ${names}. Project deny rules and readonlyAutorun=false still tighten policy. ` +
|
|
384
|
+
"Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust.");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
192
387
|
return {
|
|
193
|
-
allow: [...(g.allow ?? []), ...(p.allow ?? [])],
|
|
388
|
+
allow: [...(g.allow ?? []), ...(trustedProject ? (p.allow ?? []) : [])],
|
|
194
389
|
deny: [...(g.deny ?? []), ...(p.deny ?? [])],
|
|
195
|
-
readonlyAutorun:
|
|
390
|
+
readonlyAutorun: trustedProject
|
|
391
|
+
? (p.readonlyAutorun ?? globalReadonly)
|
|
392
|
+
: (p.readonlyAutorun === false ? false : globalReadonly),
|
|
196
393
|
};
|
|
197
394
|
}
|
|
198
395
|
const SCAFFOLD = {
|
|
@@ -200,9 +397,119 @@ const SCAFFOLD = {
|
|
|
200
397
|
deny: ["git push", "rm -rf", "sudo", "git reset --hard"],
|
|
201
398
|
readonlyAutorun: true,
|
|
202
399
|
};
|
|
400
|
+
function verifiedDirectory(path, expected) {
|
|
401
|
+
const info = lstatSync(path);
|
|
402
|
+
if (!info.isDirectory()
|
|
403
|
+
|| info.isSymbolicLink()
|
|
404
|
+
|| realpathSync.native(path) !== path
|
|
405
|
+
|| (expected && (info.dev !== expected.dev || info.ino !== expected.ino)))
|
|
406
|
+
throw new Error(`refusing project permissions write: parent directory identity is unsafe or changed`);
|
|
407
|
+
return { dev: info.dev, ino: info.ino };
|
|
408
|
+
}
|
|
409
|
+
function existingScaffoldTarget(path) {
|
|
410
|
+
try {
|
|
411
|
+
const info = lstatSync(path);
|
|
412
|
+
if (info.isSymbolicLink())
|
|
413
|
+
throw new Error("refusing project permissions write: destination is a symlink");
|
|
414
|
+
if (!info.isFile())
|
|
415
|
+
throw new Error("refusing project permissions write: destination is not a regular file");
|
|
416
|
+
if (info.nlink > 1)
|
|
417
|
+
throw new Error("refusing project permissions write: destination is hard-linked");
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (error?.code === "ENOENT")
|
|
422
|
+
return false;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** Create project permissions without following `.hara` or replacing any existing directory entry. */
|
|
427
|
+
function scaffoldProjectPermissions(cwd) {
|
|
428
|
+
const project = realpathSync.native(resolve(cwd));
|
|
429
|
+
const projectIdentity = verifiedDirectory(project);
|
|
430
|
+
const parent = join(project, ".hara");
|
|
431
|
+
try {
|
|
432
|
+
lstatSync(parent);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (error?.code !== "ENOENT")
|
|
436
|
+
throw error;
|
|
437
|
+
verifiedDirectory(project, projectIdentity);
|
|
438
|
+
try {
|
|
439
|
+
mkdirSync(parent, { mode: 0o700 });
|
|
440
|
+
}
|
|
441
|
+
catch (mkdirError) {
|
|
442
|
+
if (mkdirError?.code !== "EEXIST")
|
|
443
|
+
throw mkdirError;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const parentIdentity = verifiedDirectory(parent);
|
|
447
|
+
const target = join(parent, "permissions.json");
|
|
448
|
+
if (existingScaffoldTarget(target))
|
|
449
|
+
return null;
|
|
450
|
+
const temp = join(parent, `.hara-permissions-${process.pid}-${randomUUID()}.tmp`);
|
|
451
|
+
let fd;
|
|
452
|
+
let tempIdentity;
|
|
453
|
+
let committed = false;
|
|
454
|
+
try {
|
|
455
|
+
fd = openSync(temp, "wx", 0o600);
|
|
456
|
+
writeFileSync(fd, JSON.stringify(SCAFFOLD, null, 2) + "\n", "utf8");
|
|
457
|
+
fsyncSync(fd);
|
|
458
|
+
const opened = fstatSync(fd);
|
|
459
|
+
tempIdentity = { dev: opened.dev, ino: opened.ino };
|
|
460
|
+
closeSync(fd);
|
|
461
|
+
fd = undefined;
|
|
462
|
+
verifiedDirectory(parent, parentIdentity);
|
|
463
|
+
const staged = lstatSync(temp);
|
|
464
|
+
if (!staged.isFile() || staged.isSymbolicLink() || staged.dev !== tempIdentity.dev || staged.ino !== tempIdentity.ino) {
|
|
465
|
+
throw new Error("refusing project permissions write: staging file identity changed");
|
|
466
|
+
}
|
|
467
|
+
verifiedDirectory(parent, parentIdentity);
|
|
468
|
+
try {
|
|
469
|
+
// Hard-link commit is create-if-absent on every supported platform; unlike rename it cannot overwrite
|
|
470
|
+
// a symlink/hard-link/ordinary file planted after preflight.
|
|
471
|
+
linkSync(temp, target);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error?.code === "EEXIST") {
|
|
475
|
+
if (existingScaffoldTarget(target))
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
verifiedDirectory(parent, parentIdentity);
|
|
481
|
+
const written = lstatSync(target);
|
|
482
|
+
if (!written.isFile() || written.isSymbolicLink() || written.dev !== tempIdentity.dev || written.ino !== tempIdentity.ino) {
|
|
483
|
+
throw new Error("refusing project permissions write: committed file identity changed");
|
|
484
|
+
}
|
|
485
|
+
unlinkSync(temp);
|
|
486
|
+
committed = true;
|
|
487
|
+
return target;
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
if (fd !== undefined)
|
|
491
|
+
try {
|
|
492
|
+
closeSync(fd);
|
|
493
|
+
}
|
|
494
|
+
catch { /* best effort */ }
|
|
495
|
+
if (!committed && tempIdentity) {
|
|
496
|
+
try {
|
|
497
|
+
verifiedDirectory(parent, parentIdentity);
|
|
498
|
+
const current = lstatSync(temp);
|
|
499
|
+
if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino)
|
|
500
|
+
unlinkSync(temp);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
/* Retain an uncertain private staging file rather than unlinking an attacker-controlled replacement. */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
203
508
|
/** Write a starter permissions.json (global by default). Returns the path, or null if one already exists. */
|
|
204
509
|
export function scaffoldPermissions(cwd, scope = "global") {
|
|
205
|
-
|
|
510
|
+
if (scope === "project")
|
|
511
|
+
return scaffoldProjectPermissions(cwd);
|
|
512
|
+
const p = globalPermissionsPath();
|
|
206
513
|
if (existsSync(p))
|
|
207
514
|
return null;
|
|
208
515
|
mkdirSync(dirname(p), { recursive: true });
|