@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported validation keywords are deliberately small and explicit:
|
|
3
|
+
* - `type`: object, array, string, number, integer, boolean, null (or a union array)
|
|
4
|
+
* - `required`, `properties`, and boolean `additionalProperties` for objects
|
|
5
|
+
* - one schema in `items` for arrays (tuple schemas are not supported)
|
|
6
|
+
* - `enum` containing JSON primitive values
|
|
7
|
+
* - annotation-only `title` and `description`
|
|
8
|
+
*
|
|
9
|
+
* Every other JSON-Schema keyword is rejected. In particular, combinators and references such as
|
|
10
|
+
* `$ref`, `allOf`, `anyOf`, `oneOf`, and `not` must never be silently accepted: doing so would make
|
|
11
|
+
* the CLI promise a constraint that this dependency-free validator did not enforce.
|
|
12
|
+
*/
|
|
13
|
+
const SUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
14
|
+
"type",
|
|
15
|
+
"required",
|
|
16
|
+
"properties",
|
|
17
|
+
"additionalProperties",
|
|
18
|
+
"items",
|
|
19
|
+
"enum",
|
|
20
|
+
"title",
|
|
21
|
+
"description",
|
|
22
|
+
]);
|
|
23
|
+
const SUPPORTED_SCHEMA_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
|
|
24
|
+
function isSchemaObject(value) {
|
|
25
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
26
|
+
return false;
|
|
27
|
+
const prototype = Object.getPrototypeOf(value);
|
|
28
|
+
return prototype === Object.prototype || prototype === null;
|
|
29
|
+
}
|
|
30
|
+
function own(schema, key) {
|
|
31
|
+
return Object.hasOwn(schema, key) ? schema[key] : undefined;
|
|
32
|
+
}
|
|
33
|
+
function schemaTypes(schema) {
|
|
34
|
+
const type = own(schema, "type");
|
|
35
|
+
return type === undefined ? [] : Array.isArray(type) ? type : [type];
|
|
36
|
+
}
|
|
37
|
+
function describeSchemaValue(value) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(value) ?? String(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return String(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function validateSchemaDefinition(schema, path = "$schema") {
|
|
46
|
+
if (!isSchemaObject(schema))
|
|
47
|
+
return `${path}: schema must be a plain JSON object`;
|
|
48
|
+
for (const keyword of Object.keys(schema)) {
|
|
49
|
+
if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword))
|
|
50
|
+
return `${path}.${keyword}: unsupported JSON-Schema keyword`;
|
|
51
|
+
}
|
|
52
|
+
const type = own(schema, "type");
|
|
53
|
+
if (type !== undefined) {
|
|
54
|
+
const types = Array.isArray(type) ? type : [type];
|
|
55
|
+
if (types.length === 0)
|
|
56
|
+
return `${path}.type: type array must not be empty`;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const candidate of types) {
|
|
59
|
+
if (typeof candidate !== "string" || !SUPPORTED_SCHEMA_TYPES.has(candidate)) {
|
|
60
|
+
return `${path}.type: unsupported JSON-Schema type ${describeSchemaValue(candidate)}`;
|
|
61
|
+
}
|
|
62
|
+
if (seen.has(candidate))
|
|
63
|
+
return `${path}.type: duplicate type ${describeSchemaValue(candidate)}`;
|
|
64
|
+
seen.add(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const annotation of ["title", "description"]) {
|
|
68
|
+
const value = own(schema, annotation);
|
|
69
|
+
if (value !== undefined && typeof value !== "string")
|
|
70
|
+
return `${path}.${annotation}: expected string`;
|
|
71
|
+
}
|
|
72
|
+
const enumValues = own(schema, "enum");
|
|
73
|
+
if (enumValues !== undefined) {
|
|
74
|
+
if (!Array.isArray(enumValues) || enumValues.length === 0)
|
|
75
|
+
return `${path}.enum: expected a non-empty array`;
|
|
76
|
+
for (const value of enumValues) {
|
|
77
|
+
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
78
|
+
return `${path}.enum: only JSON primitive values are supported`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
81
|
+
return `${path}.enum: numbers must be finite`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const required = own(schema, "required");
|
|
85
|
+
if (required !== undefined) {
|
|
86
|
+
if (!Array.isArray(required) || required.some((key) => typeof key !== "string")) {
|
|
87
|
+
return `${path}.required: expected an array of property names`;
|
|
88
|
+
}
|
|
89
|
+
if (new Set(required).size !== required.length)
|
|
90
|
+
return `${path}.required: property names must be unique`;
|
|
91
|
+
}
|
|
92
|
+
const properties = own(schema, "properties");
|
|
93
|
+
if (properties !== undefined) {
|
|
94
|
+
if (!isSchemaObject(properties))
|
|
95
|
+
return `${path}.properties: expected an object of property schemas`;
|
|
96
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
97
|
+
const error = validateSchemaDefinition(child, `${path}.properties.${key}`);
|
|
98
|
+
if (error)
|
|
99
|
+
return error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const additionalProperties = own(schema, "additionalProperties");
|
|
103
|
+
if (additionalProperties !== undefined && typeof additionalProperties !== "boolean") {
|
|
104
|
+
return `${path}.additionalProperties: only boolean values are supported`;
|
|
105
|
+
}
|
|
106
|
+
const items = own(schema, "items");
|
|
107
|
+
if (items !== undefined) {
|
|
108
|
+
const error = validateSchemaDefinition(items, `${path}.items`);
|
|
109
|
+
if (error)
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
const types = schemaTypes(schema);
|
|
113
|
+
const hasObjectKeyword = required !== undefined || properties !== undefined || additionalProperties !== undefined;
|
|
114
|
+
if (hasObjectKeyword && !types.includes("object")) {
|
|
115
|
+
return `${path}: object keywords require type "object"`;
|
|
116
|
+
}
|
|
117
|
+
if (items !== undefined && !types.includes("array"))
|
|
118
|
+
return `${path}: items requires type "array"`;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function actualType(value) {
|
|
122
|
+
return Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
123
|
+
}
|
|
124
|
+
function matchesType(value, type) {
|
|
125
|
+
const actual = actualType(value);
|
|
126
|
+
if (type === "integer")
|
|
127
|
+
return actual === "number" && Number.isFinite(value) && Number.isInteger(value);
|
|
128
|
+
if (type === "number")
|
|
129
|
+
return actual === "number" && Number.isFinite(value);
|
|
130
|
+
return type === actual;
|
|
131
|
+
}
|
|
132
|
+
function validateValue(value, schema, path) {
|
|
133
|
+
const types = schemaTypes(schema);
|
|
134
|
+
if (types.length && !types.some((type) => matchesType(value, type))) {
|
|
135
|
+
return `${path}: expected ${types.join("|")}, got ${actualType(value)}`;
|
|
136
|
+
}
|
|
137
|
+
const enumValues = own(schema, "enum");
|
|
138
|
+
if (enumValues && !enumValues.some((candidate) => candidate === value)) {
|
|
139
|
+
return `${path}: value not in enum ${JSON.stringify(enumValues)}`;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && types.includes("object")) {
|
|
142
|
+
const object = value;
|
|
143
|
+
const required = own(schema, "required");
|
|
144
|
+
for (const key of required ?? []) {
|
|
145
|
+
if (!Object.hasOwn(object, key))
|
|
146
|
+
return `${path}.${key}: required property missing`;
|
|
147
|
+
}
|
|
148
|
+
const properties = own(schema, "properties");
|
|
149
|
+
for (const [key, child] of Object.entries(properties ?? {})) {
|
|
150
|
+
if (Object.hasOwn(object, key)) {
|
|
151
|
+
const error = validateValue(object[key], child, `${path}.${key}`);
|
|
152
|
+
if (error)
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (own(schema, "additionalProperties") === false) {
|
|
157
|
+
for (const key of Object.keys(object)) {
|
|
158
|
+
if (!properties || !Object.hasOwn(properties, key))
|
|
159
|
+
return `${path}.${key}: unexpected property`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value) && types.includes("array")) {
|
|
164
|
+
const items = own(schema, "items");
|
|
165
|
+
if (items) {
|
|
166
|
+
for (let index = 0; index < value.length; index++) {
|
|
167
|
+
const error = validateValue(value[index], items, `${path}[${index}]`);
|
|
168
|
+
if (error)
|
|
169
|
+
return error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Validate `value` against the supported JSON-Schema subset. Returns null when valid, otherwise a path-rich
|
|
176
|
+
* error. Invalid or unsupported schemas are errors too; they are never treated as permissive schemas. */
|
|
177
|
+
export function validateAgainstSchema(value, schema, path = "$") {
|
|
178
|
+
const schemaError = validateSchemaDefinition(schema);
|
|
179
|
+
if (schemaError)
|
|
180
|
+
return `invalid schema — ${schemaError}`;
|
|
181
|
+
return validateValue(value, schema, path);
|
|
182
|
+
}
|
|
183
|
+
/** Parse a `--schema` CLI value: inline JSON, or (by the caller) file contents. Returns the schema object or
|
|
184
|
+
* an error string. Top level must be an object schema — tool arguments are always a JSON object. */
|
|
185
|
+
export function parseSchemaArg(raw) {
|
|
186
|
+
let schema;
|
|
187
|
+
try {
|
|
188
|
+
schema = JSON.parse(raw);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return { error: `--schema is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
192
|
+
}
|
|
193
|
+
if (!isSchemaObject(schema))
|
|
194
|
+
return { error: "--schema must be a JSON object (a JSON-Schema)" };
|
|
195
|
+
let normalized = schema;
|
|
196
|
+
if (!Object.hasOwn(normalized, "type"))
|
|
197
|
+
normalized = { ...normalized, type: "object" };
|
|
198
|
+
if (own(normalized, "type") !== "object") {
|
|
199
|
+
return { error: "--schema top level must have type:'object' (tool args are an object)" };
|
|
200
|
+
}
|
|
201
|
+
if (!Object.hasOwn(normalized, "properties"))
|
|
202
|
+
normalized = { ...normalized, properties: {} };
|
|
203
|
+
const schemaError = validateSchemaDefinition(normalized);
|
|
204
|
+
if (schemaError)
|
|
205
|
+
return { error: `--schema is unsupported or invalid: ${schemaError}` };
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
/** The instruction appended to the prompt so the model knows the contract. */
|
|
209
|
+
export const STRUCTURED_INSTRUCTION = "\n\nWhen you have the final answer, you MUST call the `structured_output` tool exactly once with the result " +
|
|
210
|
+
"matching its schema. Do not print the result as text — the tool call IS the answer.";
|
|
211
|
+
/** The retry nudge when a turn ends without the tool having been called. */
|
|
212
|
+
export const STRUCTURED_NUDGE = "You finished without calling `structured_output`. Call it NOW with your final result matching the schema — the tool call is the only accepted answer.";
|
|
213
|
+
/** Build the run-scoped structured_output tool. `sink` receives the validated payload (last call wins). */
|
|
214
|
+
export function structuredOutputTool(schema, sink) {
|
|
215
|
+
const schemaError = validateSchemaDefinition(schema);
|
|
216
|
+
if (schemaError)
|
|
217
|
+
throw new Error(`Invalid structured-output schema: ${schemaError}`);
|
|
218
|
+
return {
|
|
219
|
+
name: "structured_output",
|
|
220
|
+
description: "Record the final structured result of this task. Call exactly once, when done, with the complete answer.",
|
|
221
|
+
input_schema: schema,
|
|
222
|
+
kind: "read", // never prompts; the payload is data, not an action
|
|
223
|
+
async run(input) {
|
|
224
|
+
const err = validateAgainstSchema(input, schema);
|
|
225
|
+
if (err)
|
|
226
|
+
return `schema validation failed — ${err}. Fix the payload and call structured_output again.`;
|
|
227
|
+
sink(input);
|
|
228
|
+
return "structured result recorded.";
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
package/dist/agent/touched.js
CHANGED
|
@@ -2,17 +2,35 @@
|
|
|
2
2
|
// The loop records every file the MAIN conversation reads/edits; when the history is compacted to a
|
|
3
3
|
// summary, the top-N most-recent files get their CURRENT on-disk content re-attached — so the model
|
|
4
4
|
// doesn't lose the very files it was working on and re-read them all next turn.
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { isSensitiveFilePath } from "../security/sensitive-files.js";
|
|
6
|
+
const DEFAULT_SCOPE = "default";
|
|
7
|
+
const touchedByScope = new Map(); // scope → absolute path → last-touch timestamp
|
|
8
|
+
function scopedTouched(scope) {
|
|
9
|
+
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
10
|
+
const touched = touchedByScope.get(key) ?? new Map();
|
|
11
|
+
touchedByScope.set(key, touched);
|
|
12
|
+
return touched;
|
|
13
|
+
}
|
|
14
|
+
export function recordTouch(path, scope) {
|
|
15
|
+
if (isSensitiveFilePath(path))
|
|
16
|
+
return;
|
|
17
|
+
const touched = scopedTouched(scope);
|
|
18
|
+
touched.delete(path);
|
|
7
19
|
touched.set(path, Date.now());
|
|
20
|
+
if (touched.size > 200)
|
|
21
|
+
touched.delete(touched.keys().next().value);
|
|
8
22
|
}
|
|
9
23
|
/** Most-recently-touched first. */
|
|
10
|
-
export function recentTouched(n = 5) {
|
|
11
|
-
return [...
|
|
24
|
+
export function recentTouched(n = 5, scope) {
|
|
25
|
+
return [...scopedTouched(scope).entries()]
|
|
26
|
+
.filter(([path]) => !isSensitiveFilePath(path))
|
|
12
27
|
.sort((a, b) => b[1] - a[1])
|
|
13
28
|
.slice(0, n)
|
|
14
29
|
.map(([p]) => p);
|
|
15
30
|
}
|
|
16
|
-
export function clearTouched() {
|
|
17
|
-
|
|
31
|
+
export function clearTouched(scope) {
|
|
32
|
+
if (scope)
|
|
33
|
+
touchedByScope.delete(scope.trim() || DEFAULT_SCOPE);
|
|
34
|
+
else
|
|
35
|
+
touchedByScope.clear();
|
|
18
36
|
}
|
package/dist/checkpoints.js
CHANGED
|
@@ -5,22 +5,39 @@
|
|
|
5
5
|
// snapshot-then-checkout: SAFE — it reverts changed/deleted files to the checkpoint and never deletes files
|
|
6
6
|
// created since (so a stray restore can't nuke new work; it's also itself undoable via the auto-snapshot).
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
8
|
+
import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { findProjectRoot } from "./context/agents-md.js";
|
|
13
|
+
import { toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
14
|
+
import { sensitiveFileReason } from "./security/sensitive-files.js";
|
|
15
|
+
import { redactSensitiveText } from "./security/secrets.js";
|
|
13
16
|
// Heavy/derived dirs the shadow repo must never snapshot (in addition to the project's own .gitignore).
|
|
14
|
-
const
|
|
17
|
+
const PRIVATE_DATA_EXCLUDES = ["credentials", "credential", "secrets", "secret", "service-account", "service_account"]
|
|
18
|
+
.flatMap((stem) => ["json", "yaml", "yml", "toml", "ini", "cfg", "conf"].map((ext) => `**/${stem}.${ext}`));
|
|
19
|
+
const EXCLUDES = [
|
|
20
|
+
"node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/",
|
|
21
|
+
"__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store",
|
|
22
|
+
"**/.env", "**/.env.*", "!**/.env.example", "!**/.env.*.example", "!**/.env.sample", "!**/.env.*.sample",
|
|
23
|
+
"!**/.env.template", "!**/.env.*.template", "!**/.env.dist", "!**/.env.*.dist", "!**/.env.defaults", "!**/.env.*.defaults",
|
|
24
|
+
"**/.envrc", "**/.direnv/", "**/.netrc", "**/.npmrc", "**/.pypirc", "**/.git-credentials",
|
|
25
|
+
"**/credentials", "**/credential", "**/secrets", "**/secret", "**/service-account", "**/service_account",
|
|
26
|
+
...PRIVATE_DATA_EXCLUDES,
|
|
27
|
+
"**/application_default_credentials.json", "**/.aws/credentials", "**/.docker/config.json", "**/.kube/config",
|
|
28
|
+
"**/id_rsa", "**/id_ed25519", "**/id_ecdsa", "**/*.pem", "**/*.key", "**/*.p12", "**/*.pfx", "**/*.keystore",
|
|
29
|
+
];
|
|
30
|
+
const CHECKPOINT_FORMAT = "protected-files-v2";
|
|
15
31
|
function shadowGitDir(root) {
|
|
16
32
|
return join(homedir(), ".hara", "checkpoints", createHash("sha256").update(root).digest("hex").slice(0, 16), "git");
|
|
17
33
|
}
|
|
18
|
-
function git(root, gitDir, args) {
|
|
34
|
+
function git(root, gitDir, args, input) {
|
|
19
35
|
return execFileSync("git", args, {
|
|
20
36
|
cwd: root,
|
|
21
|
-
env:
|
|
37
|
+
env: toolSubprocessEnv(process.env, { GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" }),
|
|
22
38
|
encoding: "utf8",
|
|
23
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
|
|
40
|
+
...(input === undefined ? {} : { input }),
|
|
24
41
|
maxBuffer: 64 * 1024 * 1024,
|
|
25
42
|
// Bound it: the per-turn shadow `git add -A` over a large tree (or a hung git) must never freeze a
|
|
26
43
|
// turn. On timeout execFileSync throws → checkpoint()/ensureRepo() catch it → the snapshot is just
|
|
@@ -30,18 +47,61 @@ function git(root, gitDir, args) {
|
|
|
30
47
|
}
|
|
31
48
|
function ensureRepo(root, gitDir) {
|
|
32
49
|
try {
|
|
50
|
+
const stateDir = dirname(gitDir);
|
|
51
|
+
const checkpointRoot = dirname(stateDir);
|
|
52
|
+
const marker = join(stateDir, "format");
|
|
53
|
+
mkdirSync(checkpointRoot, { recursive: true, mode: 0o700 });
|
|
54
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(checkpointRoot, 0o700);
|
|
57
|
+
chmodSync(stateDir, 0o700);
|
|
58
|
+
}
|
|
59
|
+
catch { /* best effort */ }
|
|
60
|
+
// Older shadow repositories may contain secret blobs even after the path leaves the index. Checkpoints
|
|
61
|
+
// are a derived undo cache, so rotate the repository instead of pretending git-rm purged its history.
|
|
62
|
+
let format = "";
|
|
63
|
+
try {
|
|
64
|
+
format = readFileSync(marker, "utf8").trim();
|
|
65
|
+
}
|
|
66
|
+
catch { /* first protected open */ }
|
|
67
|
+
if (existsSync(gitDir) && format !== CHECKPOINT_FORMAT)
|
|
68
|
+
rmSync(gitDir, { recursive: true, force: true });
|
|
33
69
|
if (!existsSync(join(gitDir, "HEAD"))) {
|
|
34
|
-
mkdirSync(gitDir, { recursive: true });
|
|
70
|
+
mkdirSync(gitDir, { recursive: true, mode: 0o700 });
|
|
35
71
|
git(root, gitDir, ["init", "-q"]);
|
|
36
72
|
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
37
|
-
writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
|
|
38
73
|
}
|
|
74
|
+
try {
|
|
75
|
+
chmodSync(gitDir, 0o700);
|
|
76
|
+
}
|
|
77
|
+
catch { /* best effort */ }
|
|
78
|
+
writeFileSync(marker, CHECKPOINT_FORMAT + "\n", { mode: 0o600 });
|
|
79
|
+
try {
|
|
80
|
+
chmodSync(marker, 0o600);
|
|
81
|
+
}
|
|
82
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
83
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
84
|
+
const excludeFile = join(gitDir, "info", "exclude");
|
|
85
|
+
writeFileSync(excludeFile, EXCLUDES.join("\n") + "\n", { mode: 0o600 });
|
|
86
|
+
try {
|
|
87
|
+
chmodSync(excludeFile, 0o600);
|
|
88
|
+
}
|
|
89
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
39
90
|
return true;
|
|
40
91
|
}
|
|
41
92
|
catch {
|
|
42
93
|
return false;
|
|
43
94
|
}
|
|
44
95
|
}
|
|
96
|
+
function dropSensitiveIndexEntries(root, gitDir) {
|
|
97
|
+
const indexed = git(root, gitDir, ["ls-files", "-z"]).split("\0").filter(Boolean);
|
|
98
|
+
const protectedPaths = indexed.filter((path) => sensitiveFileReason(join(root, path)) !== null);
|
|
99
|
+
if (protectedPaths.length) {
|
|
100
|
+
// stdin pathspecs avoid ARG_MAX and preserve arbitrary filenames (including whitespace/newlines).
|
|
101
|
+
git(root, gitDir, ["update-index", "--force-remove", "-z", "--stdin"], protectedPaths.join("\0") + "\0");
|
|
102
|
+
}
|
|
103
|
+
return protectedPaths.length;
|
|
104
|
+
}
|
|
45
105
|
/** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
|
|
46
106
|
export function checkpoint(cwd, label) {
|
|
47
107
|
const root = findProjectRoot(cwd);
|
|
@@ -49,8 +109,22 @@ export function checkpoint(cwd, label) {
|
|
|
49
109
|
if (!ensureRepo(root, gitDir))
|
|
50
110
|
return null;
|
|
51
111
|
try {
|
|
112
|
+
// If a path became protected after this format was introduced, merely unstaging it would leave its
|
|
113
|
+
// historical blobs reachable. Rotate the derived repository before taking another snapshot.
|
|
114
|
+
if (dropSensitiveIndexEntries(root, gitDir) > 0) {
|
|
115
|
+
rmSync(gitDir, { recursive: true, force: true });
|
|
116
|
+
if (!ensureRepo(root, gitDir))
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
52
119
|
git(root, gitDir, ["add", "-A"]);
|
|
53
|
-
|
|
120
|
+
// Defense against a future exclude-list regression. Once git-add saw a protected path its blob may exist
|
|
121
|
+
// even if we unstage it, so fail closed and purge the derived repository instead of retaining that object.
|
|
122
|
+
if (dropSensitiveIndexEntries(root, gitDir) > 0) {
|
|
123
|
+
rmSync(gitDir, { recursive: true, force: true });
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const safeLabel = redactSensitiveText(label || "checkpoint").text.slice(0, 120);
|
|
127
|
+
git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", safeLabel]);
|
|
54
128
|
return git(root, gitDir, ["rev-parse", "--short", "HEAD"]).trim();
|
|
55
129
|
}
|
|
56
130
|
catch {
|
|
@@ -59,11 +133,12 @@ export function checkpoint(cwd, label) {
|
|
|
59
133
|
}
|
|
60
134
|
/** Recent checkpoints, newest first. */
|
|
61
135
|
export function listCheckpoints(cwd, n = 15) {
|
|
62
|
-
const
|
|
63
|
-
|
|
136
|
+
const root = findProjectRoot(cwd);
|
|
137
|
+
const gitDir = shadowGitDir(root);
|
|
138
|
+
if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
|
|
64
139
|
return [];
|
|
65
140
|
try {
|
|
66
|
-
const out = git(
|
|
141
|
+
const out = git(root, gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
|
|
67
142
|
if (!out)
|
|
68
143
|
return [];
|
|
69
144
|
return out.split("\n").map((l) => {
|
|
@@ -79,15 +154,26 @@ export function listCheckpoints(cwd, n = 15) {
|
|
|
79
154
|
* undoable). Files created AFTER the checkpoint are left in place — nothing is deleted. Returns the count of
|
|
80
155
|
* files restored, or null on failure. */
|
|
81
156
|
export function restoreCheckpoint(cwd, ref) {
|
|
157
|
+
if (!/^[0-9a-f]{4,64}$/i.test(ref))
|
|
158
|
+
return null; // checkpoint refs are hashes; reject option/ref injection
|
|
82
159
|
const root = findProjectRoot(cwd);
|
|
83
160
|
const gitDir = shadowGitDir(root);
|
|
84
|
-
if (!existsSync(join(gitDir, "HEAD")))
|
|
161
|
+
if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
|
|
85
162
|
return null;
|
|
86
163
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
164
|
+
// Restoring without a durable pre-restore snapshot can destroy the user's newest work. Checkpointing is
|
|
165
|
+
// best-effort during ordinary turns, but it is a hard prerequisite for this destructive operation.
|
|
166
|
+
if (!checkpoint(cwd, `before restore to ${ref}`))
|
|
167
|
+
return null;
|
|
168
|
+
const changed = new Set(git(root, gitDir, ["diff", "--name-only", "-z", ref, "--"]).split("\0").filter(Boolean));
|
|
169
|
+
// A pre-restore snapshot tracks files created after `ref`; `git checkout ref -- new-file` would make
|
|
170
|
+
// the whole checkout fail. Restore only paths that actually existed at the requested checkpoint.
|
|
171
|
+
const atRef = git(root, gitDir, ["ls-tree", "-r", "-z", "--name-only", ref]).split("\0").filter(Boolean);
|
|
172
|
+
const safe = atRef.filter((path) => changed.has(path) && sensitiveFileReason(join(root, path)) === null);
|
|
173
|
+
if (safe.length) {
|
|
174
|
+
git(root, gitDir, ["checkout", ref, "--pathspec-from-file=-", "--pathspec-file-nul"], safe.join("\0") + "\0");
|
|
175
|
+
}
|
|
176
|
+
return safe.length;
|
|
91
177
|
}
|
|
92
178
|
catch {
|
|
93
179
|
return null;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Keep the runtime gate dependency-free and ahead of the main CLI import. Older Node releases must see an
|
|
3
|
+
// actionable upgrade message even if a future dependency starts using syntax or APIs they cannot load.
|
|
4
|
+
import { unsupportedNodeMessage } from "./runtime.js";
|
|
5
|
+
const runtimeError = unsupportedNodeMessage();
|
|
6
|
+
if (runtimeError) {
|
|
7
|
+
process.stderr.write(`${runtimeError}\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
void import("./index.js").catch((error) => {
|
|
12
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13
|
+
process.stderr.write(`hara: failed to start: ${message}\n`);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
});
|
|
16
|
+
}
|