@hasna/hooks 0.10.4 → 0.10.6
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/README.md +51 -7
- package/bin/index.js +372 -61
- package/bin/native-safety-entry.js +47 -4
- package/dist/native-safety.d.ts +30 -0
- package/dist/native-safety.js +41 -3
- package/hooks/hook-trash-guard/src/hook.ts +28 -3
- package/hooks/native-safety-entry.ts +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,36 @@ Repair unsafe installation permissions with a verified package installer, then
|
|
|
92
92
|
check again. This check alone does not prove hosted authentication, native hook
|
|
93
93
|
trust or a running agent's interception.
|
|
94
94
|
|
|
95
|
+
## Codex safety readiness
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
hooks safety verify --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This bounded, read-only check starts its own fresh Codex app-server and asks the
|
|
102
|
+
native consumer for the selected project's hook definitions and feature state.
|
|
103
|
+
It requires exactly one enabled, trusted, synchronous bundled Trash guard, checks
|
|
104
|
+
its installed supervisor by classifying data, and verifies that the native
|
|
105
|
+
definition stayed unchanged. It returns compact JSON and a nonzero exit on
|
|
106
|
+
missing, disabled, untrusted, changed or unsafe state. No deletion is executed.
|
|
107
|
+
|
|
108
|
+
The command accepts a native Linux ELF or macOS Mach-O executable for the host
|
|
109
|
+
architecture (arm64 or x64); script launchers are refused before execution. When
|
|
110
|
+
Codex is installed through npm, pass the platform package's native binary rather
|
|
111
|
+
than its JavaScript launcher. Executable ownership, permissions, ancestry and
|
|
112
|
+
bytes are checked. Supported protocol versions are 0.153.0, 0.154.0, 0.155.0,
|
|
113
|
+
0.155.1 and 0.156.1; other versions refuse pending compatibility validation.
|
|
114
|
+
The version probe has a five-second forced timeout and native discovery a
|
|
115
|
+
fifteen-second lifetime budget. Neither step restarts an existing agent.
|
|
116
|
+
|
|
117
|
+
Success reports `nativeDiscoveryVerified: true`, `guardVerified: true` and
|
|
118
|
+
`nativeAdoptionVerified: false`. It does not enroll trust, change configuration,
|
|
119
|
+
authenticate Trash, verify MCP or Instructions, or prove interception by an
|
|
120
|
+
already-running session. Complete those separate setup checks and a fresh native
|
|
121
|
+
interception proof before declaring station support. Use this small readiness
|
|
122
|
+
check after installation, meaningful upgrades and during fresh-agent startup;
|
|
123
|
+
never substitute repeated deletion smoke tests for a startup check.
|
|
124
|
+
|
|
95
125
|
## Optional Mementos prompt context
|
|
96
126
|
|
|
97
127
|
`hooks install mementos-context --target codex` registers a native prompt hook.
|
|
@@ -333,13 +363,27 @@ Trash script's Bun interpreter and all preceding PATH directories are checked
|
|
|
333
363
|
before its identity probe runs; unsafe or unsupported interpreter resolution
|
|
334
364
|
reports `unsafe_interpreter` without executing it.
|
|
335
365
|
|
|
336
|
-
Native adapters import `@hasna/hooks/native-safety
|
|
337
|
-
operation to `
|
|
338
|
-
|
|
339
|
-
adapter must
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
366
|
+
Native adapters import `@hasna/hooks/native-safety` and pass the final shell/patch
|
|
367
|
+
operation to `evaluateNativeSafetyForExecution`, with cancellation and an explicit
|
|
368
|
+
agent label. A rewrite returns the complete command, shell, resolved cwd and
|
|
369
|
+
environment. The adapter must use those fields together, disable environment
|
|
370
|
+
inheritance, and prevent later hooks from changing them. Every rewritten removal
|
|
371
|
+
uses the verified absolute Bun runtime with a clean environment and project
|
|
372
|
+
configuration, env files, macros and installation disabled. Deletions combined
|
|
373
|
+
with shell environment changes are refused; run those removals separately.
|
|
374
|
+
|
|
375
|
+
Use `hooks safety verify --target sumi --execution` for a small, read-only check
|
|
376
|
+
that the saved worker supports this execution contract. A legacy worker that only
|
|
377
|
+
classifies commands cannot pass it. The compatible `evaluateNativeSafety` API and
|
|
378
|
+
verification without `--execution` classify commands only; their success does not
|
|
379
|
+
prove execution compatibility. Neither check runs the returned deletion, creates
|
|
380
|
+
a test file, authenticates Trash or proves native adoption.
|
|
381
|
+
|
|
382
|
+
The bounded agent label can identify the Sumi session in Trash's existing name
|
|
383
|
+
field; it does not invent unsupported harness/session fields. Missing or unhealthy
|
|
384
|
+
bindings reject the call. This covers supported shell removal and whole-file patch
|
|
385
|
+
deletion, not arbitrary filesystem writes or plugin executor replacements.
|
|
386
|
+
Installing the record alone does not activate a harness.
|
|
343
387
|
|
|
344
388
|
## Data Directory
|
|
345
389
|
|
package/bin/index.js
CHANGED
|
@@ -37388,7 +37388,7 @@ var init_redact = __esm(() => {
|
|
|
37388
37388
|
});
|
|
37389
37389
|
|
|
37390
37390
|
// src/lib/profiles.ts
|
|
37391
|
-
import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as
|
|
37391
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as readFileSync15, writeFileSync as writeFileSync10, readdirSync as readdirSync8, rmSync, cpSync as cpSync2 } from "fs";
|
|
37392
37392
|
import { join as join19 } from "path";
|
|
37393
37393
|
function getProfilesDir() {
|
|
37394
37394
|
const newDir = join19(getEffectiveDataRoot(), "profiles");
|
|
@@ -37434,7 +37434,7 @@ function getProfile(id) {
|
|
|
37434
37434
|
try {
|
|
37435
37435
|
if (!existsSync15(path))
|
|
37436
37436
|
return null;
|
|
37437
|
-
return JSON.parse(
|
|
37437
|
+
return JSON.parse(readFileSync15(path, "utf-8"));
|
|
37438
37438
|
} catch {
|
|
37439
37439
|
return null;
|
|
37440
37440
|
}
|
|
@@ -37448,7 +37448,7 @@ function listProfiles() {
|
|
|
37448
37448
|
const profiles = [];
|
|
37449
37449
|
for (const file2 of files) {
|
|
37450
37450
|
try {
|
|
37451
|
-
const content =
|
|
37451
|
+
const content = readFileSync15(join19(dir, file2), "utf-8");
|
|
37452
37452
|
profiles.push(JSON.parse(content));
|
|
37453
37453
|
} catch {}
|
|
37454
37454
|
}
|
|
@@ -37498,12 +37498,12 @@ import { spawnSync as spawnSync2 } from "child_process";
|
|
|
37498
37498
|
import { closeSync as closeSync22, fstatSync as fstatSync22, openSync as openSync22, readFileSync as readFileSync22 } from "fs";
|
|
37499
37499
|
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
37500
37500
|
import { hostname as osHostname } from "os";
|
|
37501
|
-
import { isAbsolute as
|
|
37501
|
+
import { isAbsolute as isAbsolute18, join as join22 } from "path";
|
|
37502
37502
|
import { createRequire as createRequire3 } from "module";
|
|
37503
37503
|
import { fileURLToPath as fileURLToPath22 } from "url";
|
|
37504
|
-
import { closeSync as
|
|
37504
|
+
import { closeSync as closeSync7, fstatSync as fstatSync6, lstatSync as lstatSync15, openSync as openSync7, readFileSync as readFileSync17, realpathSync as realpathSync10, statSync as statSync2 } from "fs";
|
|
37505
37505
|
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
37506
|
-
import { basename as basename5, dirname as
|
|
37506
|
+
import { basename as basename5, dirname as dirname20, join as join20, relative, sep as sep3 } from "path";
|
|
37507
37507
|
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
|
|
37508
37508
|
function envToken2(name) {
|
|
37509
37509
|
return name.toUpperCase().replace(/-/g, "_");
|
|
@@ -37577,16 +37577,16 @@ function o(e2, r2, t2) {
|
|
|
37577
37577
|
}
|
|
37578
37578
|
}
|
|
37579
37579
|
function entryPoint(directory2) {
|
|
37580
|
-
const root =
|
|
37581
|
-
const fd =
|
|
37580
|
+
const root = realpathSync10(directory2);
|
|
37581
|
+
const fd = openSync7(join20(root, "package.json"), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
37582
37582
|
let pkg;
|
|
37583
37583
|
try {
|
|
37584
|
-
const stat2 =
|
|
37584
|
+
const stat2 = fstatSync6(fd);
|
|
37585
37585
|
if (!stat2.isFile() || stat2.size > MAX_PACKAGE_BYTES3)
|
|
37586
37586
|
throw new Error("Invalid Secrets package metadata");
|
|
37587
|
-
pkg = JSON.parse(
|
|
37587
|
+
pkg = JSON.parse(readFileSync17(fd, "utf8"));
|
|
37588
37588
|
} finally {
|
|
37589
|
-
|
|
37589
|
+
closeSync7(fd);
|
|
37590
37590
|
}
|
|
37591
37591
|
if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== PACKAGE) {
|
|
37592
37592
|
throw new Error("Invalid Secrets package identity");
|
|
@@ -37598,7 +37598,7 @@ function entryPoint(directory2) {
|
|
|
37598
37598
|
if (/[\\%?#\0]/.test(target) || parts.some((part) => !part || part === "." || part === ".." || part === "node_modules")) {
|
|
37599
37599
|
throw new Error("Invalid Secrets import export");
|
|
37600
37600
|
}
|
|
37601
|
-
const file2 =
|
|
37601
|
+
const file2 = realpathSync10(join20(root, ...parts));
|
|
37602
37602
|
const within = relative(root, file2);
|
|
37603
37603
|
if (!within || within === ".." || within.startsWith(`..${sep3}`) || !statSync2(file2).isFile()) {
|
|
37604
37604
|
throw new Error("Secrets import export escapes its package or is not a file");
|
|
@@ -37606,13 +37606,13 @@ function entryPoint(directory2) {
|
|
|
37606
37606
|
return pathToFileURL(file2).href;
|
|
37607
37607
|
}
|
|
37608
37608
|
function resolveInstalledSecrets(parentUrl) {
|
|
37609
|
-
let directory2 =
|
|
37609
|
+
let directory2 = dirname20(fileURLToPath3(parentUrl));
|
|
37610
37610
|
for (;; ) {
|
|
37611
37611
|
if (basename5(directory2) !== "node_modules") {
|
|
37612
37612
|
const candidate = join20(directory2, "node_modules", PACKAGE);
|
|
37613
37613
|
let absent = false;
|
|
37614
37614
|
try {
|
|
37615
|
-
|
|
37615
|
+
lstatSync15(candidate);
|
|
37616
37616
|
} catch (error61) {
|
|
37617
37617
|
if (error61.code !== "ENOENT")
|
|
37618
37618
|
throw error61;
|
|
@@ -37621,7 +37621,7 @@ function resolveInstalledSecrets(parentUrl) {
|
|
|
37621
37621
|
if (!absent)
|
|
37622
37622
|
return entryPoint(candidate);
|
|
37623
37623
|
}
|
|
37624
|
-
const parent =
|
|
37624
|
+
const parent = dirname20(directory2);
|
|
37625
37625
|
if (parent === directory2)
|
|
37626
37626
|
throw new Error("Secrets SDK is not installed for this consumer");
|
|
37627
37627
|
directory2 = parent;
|
|
@@ -37633,7 +37633,7 @@ function homeDir(env) {
|
|
|
37633
37633
|
}
|
|
37634
37634
|
function absoluteOverride(env, key) {
|
|
37635
37635
|
const value = env[key]?.trim();
|
|
37636
|
-
return value &&
|
|
37636
|
+
return value && isAbsolute18(value) ? value : null;
|
|
37637
37637
|
}
|
|
37638
37638
|
function hasnaHomeDir(env) {
|
|
37639
37639
|
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
@@ -38740,8 +38740,8 @@ var init_db_writer = __esm(() => {
|
|
|
38740
38740
|
});
|
|
38741
38741
|
|
|
38742
38742
|
// src/lib/hook-env.ts
|
|
38743
|
-
import { dirname as
|
|
38744
|
-
import { existsSync as existsSync16, realpathSync as
|
|
38743
|
+
import { dirname as dirname21, isAbsolute as isAbsolute19 } from "path";
|
|
38744
|
+
import { existsSync as existsSync16, realpathSync as realpathSync11, statSync as statSync4 } from "fs";
|
|
38745
38745
|
import { tmpdir } from "os";
|
|
38746
38746
|
function isDeniedEnvName(name) {
|
|
38747
38747
|
const upper = name.toUpperCase();
|
|
@@ -38760,7 +38760,7 @@ function isBashFunctionEnvName(name) {
|
|
|
38760
38760
|
return name.startsWith("BASH_FUNC_");
|
|
38761
38761
|
}
|
|
38762
38762
|
function isUnsafePathEntry(entry, home) {
|
|
38763
|
-
if (entry === "" || !
|
|
38763
|
+
if (entry === "" || !isAbsolute19(entry))
|
|
38764
38764
|
return true;
|
|
38765
38765
|
if (home && (entry === home || entry.startsWith(home + "/")))
|
|
38766
38766
|
return true;
|
|
@@ -38770,7 +38770,7 @@ function isUnsafePathEntry(entry, home) {
|
|
|
38770
38770
|
return true;
|
|
38771
38771
|
const canonical4 = (path) => {
|
|
38772
38772
|
try {
|
|
38773
|
-
return
|
|
38773
|
+
return realpathSync11(path);
|
|
38774
38774
|
} catch {
|
|
38775
38775
|
return path;
|
|
38776
38776
|
}
|
|
@@ -38791,7 +38791,7 @@ function sanitizeHookPath(pathValue, home) {
|
|
|
38791
38791
|
const baseline = PATH_BASELINE.slice();
|
|
38792
38792
|
if (process.platform === "darwin" && existsSync16("/opt/homebrew/bin"))
|
|
38793
38793
|
baseline.push("/opt/homebrew/bin");
|
|
38794
|
-
const bunDir =
|
|
38794
|
+
const bunDir = dirname21(process.execPath);
|
|
38795
38795
|
const seen = new Set;
|
|
38796
38796
|
const out = [];
|
|
38797
38797
|
for (const dir of baseline) {
|
|
@@ -38916,7 +38916,7 @@ __export(exports_run, {
|
|
|
38916
38916
|
});
|
|
38917
38917
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
38918
38918
|
import { rmSync as rmSync2, writeFileSync as writeFileSync11 } from "fs";
|
|
38919
|
-
import { basename as basename9, dirname as
|
|
38919
|
+
import { basename as basename9, dirname as dirname22, isAbsolute as isAbsolute20, join as join24 } from "path";
|
|
38920
38920
|
function extensionOf(scriptPath) {
|
|
38921
38921
|
const base = basename9(scriptPath);
|
|
38922
38922
|
const dot = base.lastIndexOf(".");
|
|
@@ -38985,7 +38985,7 @@ async function readPipeWithDeadline(stream, deadlineMs) {
|
|
|
38985
38985
|
return chunks.join("");
|
|
38986
38986
|
}
|
|
38987
38987
|
async function executeVerifiedScript(options) {
|
|
38988
|
-
const scriptDir =
|
|
38988
|
+
const scriptDir = dirname22(options.scriptPath);
|
|
38989
38989
|
const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
|
|
38990
38990
|
const tempPath = join24(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
|
|
38991
38991
|
let proc = null;
|
|
@@ -38996,7 +38996,7 @@ async function executeVerifiedScript(options) {
|
|
|
38996
38996
|
}, options.timeout) : null;
|
|
38997
38997
|
try {
|
|
38998
38998
|
writeFileSync11(tempPath, options.content, { flag: "wx", mode: 384 });
|
|
38999
|
-
proc = Bun.spawn([...interpreter.command,
|
|
38999
|
+
proc = Bun.spawn([...interpreter.command, isAbsolute20(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
|
|
39000
39000
|
stdin: new Response(options.stdin),
|
|
39001
39001
|
stdout: "pipe",
|
|
39002
39002
|
stderr: "pipe",
|
|
@@ -39078,7 +39078,7 @@ __export(exports_custom_install, {
|
|
|
39078
39078
|
installCustomSource: () => installCustomSource,
|
|
39079
39079
|
ensureCustomDir: () => ensureCustomDir
|
|
39080
39080
|
});
|
|
39081
|
-
import { existsSync as existsSync17, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as
|
|
39081
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as readFileSync19, rmSync as rmSync4 } from "fs";
|
|
39082
39082
|
import { join as join27 } from "path";
|
|
39083
39083
|
import { tmpdir as tmpdir2 } from "os";
|
|
39084
39084
|
function manifestAt(dir) {
|
|
@@ -39086,7 +39086,7 @@ function manifestAt(dir) {
|
|
|
39086
39086
|
if (!existsSync17(path))
|
|
39087
39087
|
return;
|
|
39088
39088
|
try {
|
|
39089
|
-
return parseManifest(
|
|
39089
|
+
return parseManifest(readFileSync19(path, "utf-8"));
|
|
39090
39090
|
} catch {
|
|
39091
39091
|
return;
|
|
39092
39092
|
}
|
|
@@ -39147,7 +39147,7 @@ async function installCustomSource(source) {
|
|
|
39147
39147
|
throw new Error(`No such local path: ${source}`);
|
|
39148
39148
|
}
|
|
39149
39149
|
if (source.endsWith("manifest.json")) {
|
|
39150
|
-
const manifest = parseManifest(
|
|
39150
|
+
const manifest = parseManifest(readFileSync19(source, "utf-8"));
|
|
39151
39151
|
return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
|
|
39152
39152
|
}
|
|
39153
39153
|
return copyManifestDir(source, source, "local");
|
|
@@ -39196,13 +39196,13 @@ __export(exports_sync, {
|
|
|
39196
39196
|
fetchPinnedHook: () => fetchPinnedHook,
|
|
39197
39197
|
commitSyncArtifacts: () => commitSyncArtifacts
|
|
39198
39198
|
});
|
|
39199
|
-
import { createHash as
|
|
39200
|
-
import { readFileSync as
|
|
39199
|
+
import { createHash as createHash17 } from "crypto";
|
|
39200
|
+
import { readFileSync as readFileSync20 } from "fs";
|
|
39201
39201
|
function sha256OfText(text3) {
|
|
39202
|
-
return
|
|
39202
|
+
return createHash17("sha256").update(text3).digest("hex");
|
|
39203
39203
|
}
|
|
39204
39204
|
function sha256OfPath(path) {
|
|
39205
|
-
return
|
|
39205
|
+
return createHash17("sha256").update(readFileSync20(path)).digest("hex");
|
|
39206
39206
|
}
|
|
39207
39207
|
function collectBundledCatalog() {
|
|
39208
39208
|
const out = [];
|
|
@@ -40357,13 +40357,13 @@ __export(exports_serve, {
|
|
|
40357
40357
|
SERVE_HOST: () => SERVE_HOST,
|
|
40358
40358
|
DEFAULT_SERVE_PORT: () => DEFAULT_SERVE_PORT
|
|
40359
40359
|
});
|
|
40360
|
-
import { readFileSync as
|
|
40361
|
-
import { dirname as
|
|
40360
|
+
import { readFileSync as readFileSync21 } from "fs";
|
|
40361
|
+
import { dirname as dirname23, join as join29 } from "path";
|
|
40362
40362
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
40363
40363
|
function packageVersion() {
|
|
40364
40364
|
try {
|
|
40365
|
-
const pkgPath = join29(
|
|
40366
|
-
return JSON.parse(
|
|
40365
|
+
const pkgPath = join29(dirname23(fileURLToPath4(import.meta.url)), "..", "package.json");
|
|
40366
|
+
return JSON.parse(readFileSync21(pkgPath, "utf-8")).version || "0.0.0";
|
|
40367
40367
|
} catch {
|
|
40368
40368
|
return "0.0.0";
|
|
40369
40369
|
}
|
|
@@ -40663,8 +40663,8 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
|
40663
40663
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
40664
40664
|
import { z as z3 } from "zod";
|
|
40665
40665
|
import { createServer } from "http";
|
|
40666
|
-
import { existsSync as existsSync18, readFileSync as
|
|
40667
|
-
import { join as join30, dirname as
|
|
40666
|
+
import { existsSync as existsSync18, readFileSync as readFileSync23 } from "fs";
|
|
40667
|
+
import { join as join30, dirname as dirname24 } from "path";
|
|
40668
40668
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
40669
40669
|
async function runVerifiedHook(name, stdin, timeoutMs) {
|
|
40670
40670
|
const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
|
|
@@ -40673,7 +40673,7 @@ async function runVerifiedHook(name, stdin, timeoutMs) {
|
|
|
40673
40673
|
if (!resolved || !hookScript || !existsSync18(hookScript)) {
|
|
40674
40674
|
throw new Error(`Hook '${name}' not found`);
|
|
40675
40675
|
}
|
|
40676
|
-
const content =
|
|
40676
|
+
const content = readFileSync23(hookScript);
|
|
40677
40677
|
const sha4 = sha256Of(content);
|
|
40678
40678
|
const check2 = checkScriptHash(name, sha4);
|
|
40679
40679
|
if (!check2.ok) {
|
|
@@ -40905,7 +40905,7 @@ function createHooksServer() {
|
|
|
40905
40905
|
}
|
|
40906
40906
|
if (meta3 && settingsExist) {
|
|
40907
40907
|
try {
|
|
40908
|
-
const settings = JSON.parse(
|
|
40908
|
+
const settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
|
|
40909
40909
|
if (!hookRegisteredInSettings(settings, name, meta3.event, meta3.matcher)) {
|
|
40910
40910
|
const eventName = meta3.event.split(":")[0];
|
|
40911
40911
|
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
@@ -40945,7 +40945,7 @@ function createHooksServer() {
|
|
|
40945
40945
|
const readmePath = join30(hookPath, "README.md");
|
|
40946
40946
|
let readme = "";
|
|
40947
40947
|
if (existsSync18(readmePath)) {
|
|
40948
|
-
readme =
|
|
40948
|
+
readme = readFileSync23(readmePath, "utf-8");
|
|
40949
40949
|
}
|
|
40950
40950
|
if (verbose) {
|
|
40951
40951
|
return { content: [{ type: "text", text: JSON.stringify({ ...meta3, readme }) }] };
|
|
@@ -41275,7 +41275,7 @@ function createHooksServer() {
|
|
|
41275
41275
|
let settings = {};
|
|
41276
41276
|
try {
|
|
41277
41277
|
if (existsSync18(settingsPath))
|
|
41278
|
-
settings = JSON.parse(
|
|
41278
|
+
settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
|
|
41279
41279
|
} catch {}
|
|
41280
41280
|
if (!settings.hooks)
|
|
41281
41281
|
settings.hooks = {};
|
|
@@ -41298,7 +41298,7 @@ function createHooksServer() {
|
|
|
41298
41298
|
let settings = {};
|
|
41299
41299
|
try {
|
|
41300
41300
|
if (existsSync18(settingsPath))
|
|
41301
|
-
settings = JSON.parse(
|
|
41301
|
+
settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
|
|
41302
41302
|
} catch {}
|
|
41303
41303
|
if (settings.hooks?.__disabled) {
|
|
41304
41304
|
settings.hooks.__disabled = settings.hooks.__disabled.filter((n2) => n2 !== name);
|
|
@@ -41668,13 +41668,13 @@ var init_server = __esm(() => {
|
|
|
41668
41668
|
init_redact();
|
|
41669
41669
|
init_authority();
|
|
41670
41670
|
init_storage();
|
|
41671
|
-
__dirname3 =
|
|
41671
|
+
__dirname3 = dirname24(fileURLToPath5(import.meta.url));
|
|
41672
41672
|
pkg = { name: "@hasna/hooks", version: "0.0.0" };
|
|
41673
41673
|
try {
|
|
41674
41674
|
for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
|
|
41675
41675
|
const p = join30(__dirname3, rel);
|
|
41676
41676
|
if (existsSync18(p)) {
|
|
41677
|
-
pkg = JSON.parse(
|
|
41677
|
+
pkg = JSON.parse(readFileSync23(p, "utf-8"));
|
|
41678
41678
|
break;
|
|
41679
41679
|
}
|
|
41680
41680
|
}
|
|
@@ -43416,8 +43416,8 @@ var {
|
|
|
43416
43416
|
|
|
43417
43417
|
// src/cli/index.tsx
|
|
43418
43418
|
import chalk2 from "chalk";
|
|
43419
|
-
import { existsSync as existsSync19, readFileSync as
|
|
43420
|
-
import { join as join31, dirname as
|
|
43419
|
+
import { existsSync as existsSync19, readFileSync as readFileSync25, rmSync as rmSync6 } from "fs";
|
|
43420
|
+
import { join as join31, dirname as dirname25 } from "path";
|
|
43421
43421
|
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
43422
43422
|
|
|
43423
43423
|
// src/cli/components/App.tsx
|
|
@@ -44610,6 +44610,7 @@ class NativeSafetyError extends Error {
|
|
|
44610
44610
|
this.name = "NativeSafetyError";
|
|
44611
44611
|
}
|
|
44612
44612
|
}
|
|
44613
|
+
var executionSchema = "hasna.hooks.execution.v1";
|
|
44613
44614
|
var hash = (value) => createHash7("sha256").update(value).digest("hex");
|
|
44614
44615
|
var filename = "sumi-trash-guard.json";
|
|
44615
44616
|
var owner2 = (uid2) => uid2 === 0 || uid2 === process.getuid?.();
|
|
@@ -44764,10 +44765,31 @@ function registerNativeSafety(options = {}) {
|
|
|
44764
44765
|
syncDirectory(directory2);
|
|
44765
44766
|
}
|
|
44766
44767
|
}
|
|
44767
|
-
async function
|
|
44768
|
+
async function evaluateNativeSafetyForExecution(event, options) {
|
|
44769
|
+
if (!options || typeof options.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(options.agent))
|
|
44770
|
+
throw new NativeSafetyError("input_invalid");
|
|
44771
|
+
let snapshot2;
|
|
44772
|
+
try {
|
|
44773
|
+
snapshot2 = JSON.parse(JSON.stringify(event));
|
|
44774
|
+
} catch {
|
|
44775
|
+
throw new NativeSafetyError("input_invalid");
|
|
44776
|
+
}
|
|
44777
|
+
if (snapshot2?.tool_input && Object.hasOwn(snapshot2.tool_input, "hasna_execution"))
|
|
44778
|
+
throw new NativeSafetyError("input_invalid");
|
|
44779
|
+
const before = readNativeSafetyRegistration(options.home);
|
|
44780
|
+
const binding = nativeSafetyCommandBinding(before.record.command);
|
|
44781
|
+
const decision = await evaluateRecord(snapshot2, before.record, options.signal, options.agent);
|
|
44782
|
+
if (readNativeSafetyRegistration(options.home).sha256 !== before.sha256)
|
|
44783
|
+
throw new NativeSafetyError("binding_changed");
|
|
44784
|
+
return decision.decision === "rewrite" ? {
|
|
44785
|
+
decision: "rewrite",
|
|
44786
|
+
execution: { command: decision.command, shell: "/bin/sh", cwd: snapshot2.cwd, env: { HOME: binding.home, PATH: binding.path } }
|
|
44787
|
+
} : decision;
|
|
44788
|
+
}
|
|
44789
|
+
async function evaluateRecord(event, record3, signal, executionAgent) {
|
|
44768
44790
|
if (!event || event.hook_event_name !== "PreToolUse" || !["Bash", "apply_patch"].includes(event.tool_name) || typeof event.cwd !== "string" || !isAbsolute7(event.cwd) || !event.tool_input || typeof event.tool_input !== "object" || Array.isArray(event.tool_input))
|
|
44769
44791
|
throw new NativeSafetyError("input_invalid");
|
|
44770
|
-
const input2 = JSON.stringify(event);
|
|
44792
|
+
const input2 = JSON.stringify(executionAgent === undefined ? event : { ...event, hasna_execution: { schema: executionSchema, agent: executionAgent } });
|
|
44771
44793
|
if (Buffer.byteLength(input2) > 1048576)
|
|
44772
44794
|
throw new NativeSafetyError("input_invalid");
|
|
44773
44795
|
const output2 = await new Promise((accept, reject) => {
|
|
@@ -44792,7 +44814,7 @@ async function evaluateRecord(event, record3, signal) {
|
|
|
44792
44814
|
throw new Error;
|
|
44793
44815
|
if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
|
|
44794
44816
|
return { decision: "deny", reason: verdict.permissionDecisionReason };
|
|
44795
|
-
if (event.tool_name === "Bash" && verdict.permissionDecision === "allow" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,updatedInput" && typeof verdict.updatedInput?.command === "string" && verdict.updatedInput.command.trim())
|
|
44817
|
+
if (event.tool_name === "Bash" && verdict.permissionDecision === "allow" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,updatedInput" && typeof verdict.updatedInput?.command === "string" && verdict.updatedInput.command.trim() && (executionAgent === undefined || verdict.updatedInput.hasna_execution === executionSchema))
|
|
44796
44818
|
return { decision: "rewrite", command: verdict.updatedInput.command };
|
|
44797
44819
|
} catch {}
|
|
44798
44820
|
throw new NativeSafetyError("guard_failed");
|
|
@@ -44812,6 +44834,20 @@ async function verifyNativeSafetyRegistration(home = homedir7()) {
|
|
|
44812
44834
|
throw new NativeSafetyError("binding_changed");
|
|
44813
44835
|
return { path: before.path, sha256: before.sha256 };
|
|
44814
44836
|
}
|
|
44837
|
+
async function verifyNativeSafetyExecutionRegistration(home = homedir7()) {
|
|
44838
|
+
const before = readNativeSafetyRegistration(home);
|
|
44839
|
+
const verdict = await evaluateNativeSafetyForExecution({
|
|
44840
|
+
hook_event_name: "PreToolUse",
|
|
44841
|
+
tool_name: "Bash",
|
|
44842
|
+
cwd: home,
|
|
44843
|
+
tool_input: { command: "rm -- .hasna-trash-verification-only" }
|
|
44844
|
+
}, { home, agent: "native-safety-verification" });
|
|
44845
|
+
if (verdict.decision !== "rewrite")
|
|
44846
|
+
throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
|
|
44847
|
+
if (readNativeSafetyRegistration(home).sha256 !== before.sha256)
|
|
44848
|
+
throw new NativeSafetyError("binding_changed");
|
|
44849
|
+
return { path: before.path, sha256: before.sha256 };
|
|
44850
|
+
}
|
|
44815
44851
|
|
|
44816
44852
|
// hooks/hook-trash-guard/src/binary.ts
|
|
44817
44853
|
import { dirname as dirname17, isAbsolute as isAbsolute15, join as join17, resolve as resolve17 } from "path";
|
|
@@ -44987,6 +45023,268 @@ function inspectTrashBinary(env = process.env) {
|
|
|
44987
45023
|
return { ok: false, code: failure };
|
|
44988
45024
|
}
|
|
44989
45025
|
|
|
45026
|
+
// src/lib/codex-safety-check.ts
|
|
45027
|
+
init_native_safety();
|
|
45028
|
+
import { spawn as spawn2, execFile as execFile2 } from "child_process";
|
|
45029
|
+
import { createHash as createHash12 } from "crypto";
|
|
45030
|
+
import { constants as constants12, openSync as openSync6, closeSync as closeSync6, fstatSync as fstatSync5, lstatSync as lstatSync10, readFileSync as readFileSync14, realpathSync as realpathSync9 } from "fs";
|
|
45031
|
+
import { homedir as homedir8 } from "os";
|
|
45032
|
+
import { dirname as dirname19, isAbsolute as isAbsolute17 } from "path";
|
|
45033
|
+
import { promisify } from "util";
|
|
45034
|
+
var versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1"]);
|
|
45035
|
+
var matcher = "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$";
|
|
45036
|
+
|
|
45037
|
+
class CodexSafetyError extends Error {
|
|
45038
|
+
code;
|
|
45039
|
+
constructor(code2) {
|
|
45040
|
+
super(`Codex deletion safety check failed (${code2}).`);
|
|
45041
|
+
this.code = code2;
|
|
45042
|
+
this.name = "CodexSafetyError";
|
|
45043
|
+
}
|
|
45044
|
+
}
|
|
45045
|
+
function need5(condition, code2) {
|
|
45046
|
+
if (!condition)
|
|
45047
|
+
throw new CodexSafetyError(code2);
|
|
45048
|
+
}
|
|
45049
|
+
var digest3 = (bytes) => createHash12("sha256").update(bytes).digest("hex");
|
|
45050
|
+
function nativeHeader(bytes) {
|
|
45051
|
+
if (bytes.length < 64 || !["arm64", "x64"].includes(process.arch))
|
|
45052
|
+
return false;
|
|
45053
|
+
if (process.platform === "darwin") {
|
|
45054
|
+
const cpu = process.arch === "arm64" ? 16777228 : 16777223;
|
|
45055
|
+
return bytes.readUInt32LE(0) === 4277009103 && bytes.readUInt32LE(4) === cpu && bytes.readUInt32LE(12) === 2 && bytes.readUInt32LE(16) > 0 && bytes.readUInt32LE(16) <= 4096 && bytes.readUInt32LE(20) > 0 && bytes.readUInt32LE(20) <= bytes.length - 32;
|
|
45056
|
+
}
|
|
45057
|
+
if (process.platform === "linux") {
|
|
45058
|
+
const offset = bytes.readBigUInt64LE(32), count = bytes.readUInt16LE(56);
|
|
45059
|
+
return bytes.subarray(0, 7).equals(Buffer.from([127, 69, 76, 70, 2, 1, 1])) && [2, 3].includes(bytes.readUInt16LE(16)) && bytes.readUInt16LE(18) === (process.arch === "arm64" ? 183 : 62) && bytes.readUInt32LE(20) === 1 && bytes.readUInt16LE(52) === 64 && bytes.readUInt16LE(54) === 56 && count > 0 && count <= 1024 && offset >= 64n && offset + BigInt(count * 56) <= BigInt(bytes.length);
|
|
45060
|
+
}
|
|
45061
|
+
return false;
|
|
45062
|
+
}
|
|
45063
|
+
function executable(command) {
|
|
45064
|
+
const found = isAbsolute17(command) ? command : Bun.which(command);
|
|
45065
|
+
need5(found, "native_unavailable");
|
|
45066
|
+
try {
|
|
45067
|
+
const path = realpathSync9(found);
|
|
45068
|
+
for (let parent = dirname19(path);; parent = dirname19(parent)) {
|
|
45069
|
+
const stat2 = lstatSync10(parent);
|
|
45070
|
+
need5(stat2.isDirectory() && [0, process.getuid?.()].includes(stat2.uid) && !(stat2.mode & 18), "native_unsafe_executable");
|
|
45071
|
+
if (parent === dirname19(parent))
|
|
45072
|
+
break;
|
|
45073
|
+
}
|
|
45074
|
+
const fd = openSync6(path, constants12.O_RDONLY | constants12.O_NOFOLLOW | constants12.O_NONBLOCK);
|
|
45075
|
+
try {
|
|
45076
|
+
const stat2 = fstatSync5(fd);
|
|
45077
|
+
need5(stat2.isFile() && [0, process.getuid?.()].includes(stat2.uid) && !(stat2.mode & 18) && !!(stat2.mode & 64) && stat2.size <= 256000000, "native_unsafe_executable");
|
|
45078
|
+
const bytes = readFileSync14(fd);
|
|
45079
|
+
need5(bytes.length <= 256000000, "native_unavailable");
|
|
45080
|
+
need5(nativeHeader(bytes), "native_executable_required");
|
|
45081
|
+
return { path, sha256: digest3(bytes) };
|
|
45082
|
+
} finally {
|
|
45083
|
+
closeSync6(fd);
|
|
45084
|
+
}
|
|
45085
|
+
} catch (error61) {
|
|
45086
|
+
if (error61 instanceof CodexSafetyError)
|
|
45087
|
+
throw error61;
|
|
45088
|
+
throw new CodexSafetyError("native_unavailable");
|
|
45089
|
+
}
|
|
45090
|
+
}
|
|
45091
|
+
async function connectCodexSafety(options) {
|
|
45092
|
+
const command = options.command ?? "codex", before = executable(command);
|
|
45093
|
+
const env = { ...process.env, HOME: options.home, NO_COLOR: "1" };
|
|
45094
|
+
for (const key of ["NODE_OPTIONS", "NODE_PATH", "BUN_OPTIONS", "BASH_ENV", "ENV", "DYLD_INSERT_LIBRARIES", "LD_PRELOAD"])
|
|
45095
|
+
delete env[key];
|
|
45096
|
+
if (options.codexHome === undefined)
|
|
45097
|
+
delete env.CODEX_HOME;
|
|
45098
|
+
else
|
|
45099
|
+
env.CODEX_HOME = options.codexHome;
|
|
45100
|
+
let version2;
|
|
45101
|
+
try {
|
|
45102
|
+
version2 = (await promisify(execFile2)(before.path, ["--version"], { env, cwd: options.home, timeout: 5000, killSignal: "SIGKILL", maxBuffer: 4096 })).stdout.trim();
|
|
45103
|
+
} catch {
|
|
45104
|
+
throw new CodexSafetyError("native_unavailable");
|
|
45105
|
+
}
|
|
45106
|
+
need5(versions.has(version2), "native_unsupported");
|
|
45107
|
+
need5(JSON.stringify(executable(command)) === JSON.stringify(before), "guard_changed");
|
|
45108
|
+
const child = spawn2(before.path, ["app-server", "--strict-config", "--stdio"], { env, cwd: options.home, stdio: ["pipe", "pipe", "pipe"] });
|
|
45109
|
+
let stopped = false, buffer = Buffer.alloc(0), total = 0, nextId = 1;
|
|
45110
|
+
const pending = new Map;
|
|
45111
|
+
const fail2 = () => {
|
|
45112
|
+
stopped = true;
|
|
45113
|
+
for (const waiter of pending.values())
|
|
45114
|
+
waiter.reject(new CodexSafetyError("native_failed"));
|
|
45115
|
+
pending.clear();
|
|
45116
|
+
};
|
|
45117
|
+
const deadline = setTimeout(() => {
|
|
45118
|
+
fail2();
|
|
45119
|
+
child.kill("SIGKILL");
|
|
45120
|
+
}, 15000);
|
|
45121
|
+
const exited = new Promise((done) => {
|
|
45122
|
+
child.once("exit", () => {
|
|
45123
|
+
fail2();
|
|
45124
|
+
clearTimeout(deadline);
|
|
45125
|
+
done();
|
|
45126
|
+
});
|
|
45127
|
+
child.once("error", () => {
|
|
45128
|
+
fail2();
|
|
45129
|
+
clearTimeout(deadline);
|
|
45130
|
+
done();
|
|
45131
|
+
});
|
|
45132
|
+
});
|
|
45133
|
+
child.stdin.on("error", fail2);
|
|
45134
|
+
const account = (length) => {
|
|
45135
|
+
total += length;
|
|
45136
|
+
if (total > 8388608) {
|
|
45137
|
+
fail2();
|
|
45138
|
+
child.kill("SIGKILL");
|
|
45139
|
+
return false;
|
|
45140
|
+
}
|
|
45141
|
+
return true;
|
|
45142
|
+
};
|
|
45143
|
+
child.stderr.on("data", (bytes) => {
|
|
45144
|
+
account(bytes.length);
|
|
45145
|
+
});
|
|
45146
|
+
child.stdout.on("data", (bytes) => {
|
|
45147
|
+
if (stopped || !account(bytes.length))
|
|
45148
|
+
return;
|
|
45149
|
+
buffer = Buffer.concat([buffer, bytes]);
|
|
45150
|
+
for (;; ) {
|
|
45151
|
+
const end = buffer.indexOf(10);
|
|
45152
|
+
if (end < 0)
|
|
45153
|
+
return;
|
|
45154
|
+
const line = buffer.subarray(0, end);
|
|
45155
|
+
buffer = buffer.subarray(end + 1);
|
|
45156
|
+
try {
|
|
45157
|
+
const value = JSON.parse(line.toString("utf8"));
|
|
45158
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
45159
|
+
throw Error();
|
|
45160
|
+
if (!Object.hasOwn(value, "id"))
|
|
45161
|
+
continue;
|
|
45162
|
+
const waiter = pending.get(value.id);
|
|
45163
|
+
if (!waiter)
|
|
45164
|
+
throw Error();
|
|
45165
|
+
pending.delete(value.id);
|
|
45166
|
+
if (value.error || !Object.hasOwn(value, "result"))
|
|
45167
|
+
waiter.reject(new CodexSafetyError("native_failed"));
|
|
45168
|
+
else
|
|
45169
|
+
waiter.resolve(value.result);
|
|
45170
|
+
} catch {
|
|
45171
|
+
fail2();
|
|
45172
|
+
child.kill("SIGKILL");
|
|
45173
|
+
return;
|
|
45174
|
+
}
|
|
45175
|
+
}
|
|
45176
|
+
});
|
|
45177
|
+
const request = (method, params) => new Promise((accept, reject) => {
|
|
45178
|
+
if (stopped) {
|
|
45179
|
+
reject(new CodexSafetyError("native_failed"));
|
|
45180
|
+
return;
|
|
45181
|
+
}
|
|
45182
|
+
const id = nextId++;
|
|
45183
|
+
pending.set(id, { resolve: accept, reject });
|
|
45184
|
+
child.stdin.write(JSON.stringify({ id, method, params }) + `
|
|
45185
|
+
`);
|
|
45186
|
+
});
|
|
45187
|
+
const rpc = {
|
|
45188
|
+
version: version2,
|
|
45189
|
+
request,
|
|
45190
|
+
async close() {
|
|
45191
|
+
fail2();
|
|
45192
|
+
child.stdin.end();
|
|
45193
|
+
const force = setTimeout(() => child.kill("SIGKILL"), 1000);
|
|
45194
|
+
await exited;
|
|
45195
|
+
clearTimeout(force);
|
|
45196
|
+
clearTimeout(deadline);
|
|
45197
|
+
need5(JSON.stringify(executable(command)) === JSON.stringify(before), "guard_changed");
|
|
45198
|
+
}
|
|
45199
|
+
};
|
|
45200
|
+
try {
|
|
45201
|
+
await request("initialize", { clientInfo: { name: "hooks-safety-check", version: "1" }, capabilities: { experimentalApi: true } });
|
|
45202
|
+
child.stdin.write(`{"method":"initialized"}
|
|
45203
|
+
`);
|
|
45204
|
+
return rpc;
|
|
45205
|
+
} catch {
|
|
45206
|
+
await rpc.close();
|
|
45207
|
+
throw new CodexSafetyError("native_failed");
|
|
45208
|
+
}
|
|
45209
|
+
}
|
|
45210
|
+
function selectCodexSafety(discovery, configuration, home, cwd, featureEnabled) {
|
|
45211
|
+
const data = discovery?.data;
|
|
45212
|
+
need5(Array.isArray(data) && data.length === 1 && data[0]?.cwd === cwd && Array.isArray(data[0]?.hooks) && data[0].hooks.length <= 1e4 && Array.isArray(data[0].errors) && data[0].errors.length === 0 && Array.isArray(data[0].warnings) && data[0].warnings.length === 0, "native_discovery_failed");
|
|
45213
|
+
const config2 = configuration?.config;
|
|
45214
|
+
need5(config2 && typeof config2 === "object" && !Array.isArray(config2), "native_discovery_failed");
|
|
45215
|
+
need5((config2.features?.hooks ?? featureEnabled) === true, "hooks_disabled");
|
|
45216
|
+
for (const hook2 of data[0].hooks)
|
|
45217
|
+
if (typeof hook2?.command === "string") {
|
|
45218
|
+
if (/\bhasna-native-safety-v\d+\b/.test(hook2.command))
|
|
45219
|
+
need5(nativeSafetyCommandBinding(hook2.command), "guard_definition_invalid");
|
|
45220
|
+
need5(!/^(?:hooks run trash-guard|hook-trash-guard)(?:\s|$)/.test(hook2.command.trim()), "guard_definition_invalid");
|
|
45221
|
+
}
|
|
45222
|
+
const selected = data[0].hooks.filter((hook2) => typeof hook2?.command === "string" && nativeSafetyCommandBinding(hook2.command)?.name === "trash-guard");
|
|
45223
|
+
need5(selected.length !== 0, "guard_missing");
|
|
45224
|
+
need5(selected.length === 1, "guard_ambiguous");
|
|
45225
|
+
const hook = selected[0], binding = nativeSafetyCommandBinding(hook.command);
|
|
45226
|
+
need5(binding.home === home && hook.eventName === "preToolUse" && hook.handlerType === "command" && hook.matcher === matcher && hook.timeoutSec === 10 && hook.async === false && hook.statusMessage === null && hook.additionalContextLimit === null && typeof hook.key === "string" && hook.key.length <= 4096 && typeof hook.sourcePath === "string" && isAbsolute17(hook.sourcePath) && hook.pluginId === null && hook.isManaged === false && ["user", "project"].includes(hook.source) && typeof hook.currentHash === "string" && /^sha256:[a-f0-9]{64}$/.test(hook.currentHash), "guard_definition_invalid");
|
|
45227
|
+
need5(hook.enabled === true, "guard_disabled");
|
|
45228
|
+
need5(hook.trustStatus === "trusted", "guard_untrusted");
|
|
45229
|
+
return { key: hook.key, command: hook.command, currentHash: hook.currentHash, sourcePath: hook.sourcePath, enabled: true, trustStatus: "trusted" };
|
|
45230
|
+
}
|
|
45231
|
+
async function verifyCodexNativeSafety(options = {}, dependencies = {}) {
|
|
45232
|
+
const home = realpathSync9(options.home ?? homedir8()), cwd = realpathSync9(options.cwd ?? process.cwd());
|
|
45233
|
+
need5(isAbsolute17(home) && isAbsolute17(cwd), "native_unavailable");
|
|
45234
|
+
const rpc = await (dependencies.connect ?? connectCodexSafety)({ ...options, home });
|
|
45235
|
+
try {
|
|
45236
|
+
need5(versions.has(rpc.version), "native_unsupported");
|
|
45237
|
+
const inspect = async () => {
|
|
45238
|
+
const hooks = await rpc.request("hooks/list", { cwds: [cwd] });
|
|
45239
|
+
const configuration = await rpc.request("config/read", { cwd, includeLayers: false });
|
|
45240
|
+
let enabled;
|
|
45241
|
+
if (configuration?.config?.features?.hooks === undefined) {
|
|
45242
|
+
let cursor = null, count = 0;
|
|
45243
|
+
const seen = new Set;
|
|
45244
|
+
do {
|
|
45245
|
+
const page3 = await rpc.request("experimentalFeature/list", { cursor, limit: 100 });
|
|
45246
|
+
need5(Array.isArray(page3?.data) && page3.data.length <= 100 && (page3.nextCursor === null || typeof page3.nextCursor === "string"), "native_discovery_failed");
|
|
45247
|
+
count += page3.data.length;
|
|
45248
|
+
need5(count <= 1000, "native_discovery_failed");
|
|
45249
|
+
for (const feature of page3.data)
|
|
45250
|
+
if (feature?.name === "hooks") {
|
|
45251
|
+
need5(enabled === undefined && typeof feature.enabled === "boolean", "native_discovery_failed");
|
|
45252
|
+
enabled = feature.enabled;
|
|
45253
|
+
}
|
|
45254
|
+
cursor = page3.nextCursor;
|
|
45255
|
+
if (cursor !== null) {
|
|
45256
|
+
need5(cursor.length > 0 && cursor.length <= 4096 && !seen.has(cursor) && page3.data.length > 0, "native_discovery_failed");
|
|
45257
|
+
seen.add(cursor);
|
|
45258
|
+
}
|
|
45259
|
+
} while (cursor !== null);
|
|
45260
|
+
}
|
|
45261
|
+
return selectCodexSafety(hooks, configuration, home, cwd, enabled);
|
|
45262
|
+
};
|
|
45263
|
+
const before = await inspect();
|
|
45264
|
+
try {
|
|
45265
|
+
await (dependencies.verify ?? verifyNativeSafetyCommand)(before.command, home);
|
|
45266
|
+
} catch {
|
|
45267
|
+
throw new CodexSafetyError("guard_failed");
|
|
45268
|
+
}
|
|
45269
|
+
need5(JSON.stringify(await inspect()) === JSON.stringify(before), "guard_changed");
|
|
45270
|
+
return {
|
|
45271
|
+
ok: true,
|
|
45272
|
+
target: "codex",
|
|
45273
|
+
nativeVersion: rpc.version,
|
|
45274
|
+
cwd,
|
|
45275
|
+
key: before.key,
|
|
45276
|
+
currentHash: before.currentHash,
|
|
45277
|
+
enabled: true,
|
|
45278
|
+
trusted: true,
|
|
45279
|
+
guardVerified: true,
|
|
45280
|
+
nativeDiscoveryVerified: true,
|
|
45281
|
+
nativeAdoptionVerified: false
|
|
45282
|
+
};
|
|
45283
|
+
} finally {
|
|
45284
|
+
await rpc.close();
|
|
45285
|
+
}
|
|
45286
|
+
}
|
|
45287
|
+
|
|
44990
45288
|
// src/cli/index.tsx
|
|
44991
45289
|
init_registry();
|
|
44992
45290
|
init_installer();
|
|
@@ -45003,9 +45301,9 @@ init_local_opt_in();
|
|
|
45003
45301
|
init_transport2();
|
|
45004
45302
|
init_db();
|
|
45005
45303
|
import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
|
|
45006
|
-
var __dirname4 =
|
|
45304
|
+
var __dirname4 = dirname25(fileURLToPath6(import.meta.url));
|
|
45007
45305
|
var pkgPath = existsSync19(join31(__dirname4, "..", "package.json")) ? join31(__dirname4, "..", "package.json") : join31(__dirname4, "..", "..", "package.json");
|
|
45008
|
-
var pkg2 = JSON.parse(
|
|
45306
|
+
var pkg2 = JSON.parse(readFileSync25(pkgPath, "utf-8"));
|
|
45009
45307
|
var program3 = new Command;
|
|
45010
45308
|
function resolveScope(options) {
|
|
45011
45309
|
if (options.project)
|
|
@@ -45085,7 +45383,7 @@ async function openLocalStoreOrExit(json3) {
|
|
|
45085
45383
|
function readToken(tokenFile) {
|
|
45086
45384
|
if (tokenFile) {
|
|
45087
45385
|
try {
|
|
45088
|
-
return
|
|
45386
|
+
return readFileSync25(tokenFile, "utf-8").trim();
|
|
45089
45387
|
} catch {
|
|
45090
45388
|
return;
|
|
45091
45389
|
}
|
|
@@ -45099,9 +45397,9 @@ function readmePreview(readme, max = 280) {
|
|
|
45099
45397
|
return preview2 ? truncateText2(preview2, max) : undefined;
|
|
45100
45398
|
}
|
|
45101
45399
|
function hookSummaryLine(hook, options = {}) {
|
|
45102
|
-
const
|
|
45400
|
+
const matcher2 = hook.matcher ? ` ${hook.matcher}` : "";
|
|
45103
45401
|
const description = options.verbose ? ` - ${truncateText2(hook.description, 110)}` : "";
|
|
45104
|
-
return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${
|
|
45402
|
+
return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher2}]`)} ${chalk2.dim(hook.category)}${description}`;
|
|
45105
45403
|
}
|
|
45106
45404
|
function listStoreHooks() {
|
|
45107
45405
|
const out = [];
|
|
@@ -45251,7 +45549,7 @@ program3.command("run").argument("<hook>", "Hook to run").option("--profile <id>
|
|
|
45251
45549
|
console.error(JSON.stringify({ error: `Hook script not found: ${hookScript ?? hook}` }));
|
|
45252
45550
|
process.exit(1);
|
|
45253
45551
|
}
|
|
45254
|
-
const content =
|
|
45552
|
+
const content = readFileSync25(hookScript);
|
|
45255
45553
|
const sha4 = sha256Of2(content);
|
|
45256
45554
|
const check2 = checkScriptHash2(hook, sha4);
|
|
45257
45555
|
if (!check2.ok) {
|
|
@@ -45853,7 +46151,7 @@ program3.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
45853
46151
|
let wiringCount = 0;
|
|
45854
46152
|
if (settingsExist) {
|
|
45855
46153
|
try {
|
|
45856
|
-
const settings = JSON.parse(
|
|
46154
|
+
const settings = JSON.parse(readFileSync25(settingsPath, "utf-8"));
|
|
45857
46155
|
wiringCount = countSettingsWiring(settings);
|
|
45858
46156
|
} catch {}
|
|
45859
46157
|
}
|
|
@@ -45883,7 +46181,7 @@ program3.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
45883
46181
|
}
|
|
45884
46182
|
if (meta3 && settingsExist) {
|
|
45885
46183
|
try {
|
|
45886
|
-
const settings = JSON.parse(
|
|
46184
|
+
const settings = JSON.parse(readFileSync25(settingsPath, "utf-8"));
|
|
45887
46185
|
if (!hookRegisteredInSettings(settings, name, meta3.event, meta3.matcher)) {
|
|
45888
46186
|
const eventName = meta3.event.split(":")[0];
|
|
45889
46187
|
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
@@ -46089,7 +46387,7 @@ program3.command("docs").argument("[hook]", "Hook name (shows general docs if om
|
|
|
46089
46387
|
const readmePath = join31(hookPath, "README.md");
|
|
46090
46388
|
let readme = "";
|
|
46091
46389
|
if (existsSync19(readmePath)) {
|
|
46092
|
-
readme =
|
|
46390
|
+
readme = readFileSync25(readmePath, "utf-8");
|
|
46093
46391
|
}
|
|
46094
46392
|
if (options.json) {
|
|
46095
46393
|
console.log(JSON.stringify({ ...meta3, readme }));
|
|
@@ -46759,12 +47057,25 @@ safety.command("install <guard>").option("--target <target>", "codex, claude or
|
|
|
46759
47057
|
if (!result.success)
|
|
46760
47058
|
process.exitCode = 1;
|
|
46761
47059
|
});
|
|
46762
|
-
safety.command("verify").requiredOption("--target <target>", "sumi").description("Verify a required native binding and its supervisor without running a file operation").action(async (options) => {
|
|
47060
|
+
safety.command("verify").requiredOption("--target <target>", "sumi or codex").option("--cwd <path>", "Codex project context to inspect (defaults to the current directory)").option("--codex-command <path>", "Installed Codex executable").option("--execution", "Also require the pinned native execution capability; no file operation is run").description("Verify a required native binding and its supervisor without running a file operation").action(async (options) => {
|
|
47061
|
+
if (options.target === "codex") {
|
|
47062
|
+
try {
|
|
47063
|
+
if (options.execution)
|
|
47064
|
+
throw new CodexSafetyError("native_unsupported");
|
|
47065
|
+
console.log(JSON.stringify(await verifyCodexNativeSafety({ command: options.codexCommand, cwd: options.cwd, codexHome: process.env.CODEX_HOME })));
|
|
47066
|
+
} catch (error61) {
|
|
47067
|
+
console.log(JSON.stringify({ ok: false, target: "codex", code: error61 instanceof CodexSafetyError ? error61.code : "native_failed" }));
|
|
47068
|
+
process.exitCode = 1;
|
|
47069
|
+
}
|
|
47070
|
+
return;
|
|
47071
|
+
}
|
|
46763
47072
|
try {
|
|
47073
|
+
if (options.cwd || options.codexCommand)
|
|
47074
|
+
throw new NativeSafetyError("input_invalid");
|
|
46764
47075
|
if (options.target !== "sumi")
|
|
46765
47076
|
throw new NativeSafetyError("input_invalid");
|
|
46766
|
-
const current2 = await verifyNativeSafetyRegistration();
|
|
46767
|
-
console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, nativeAdoptionVerified: false }));
|
|
47077
|
+
const current2 = await (options.execution ? verifyNativeSafetyExecutionRegistration() : verifyNativeSafetyRegistration());
|
|
47078
|
+
console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, ...options.execution ? { executionCapabilityVerified: true } : {}, nativeAdoptionVerified: false }));
|
|
46768
47079
|
} catch (error61) {
|
|
46769
47080
|
console.log(JSON.stringify({ ok: false, target: "sumi", code: error61 instanceof NativeSafetyError ? error61.code : "guard_failed", ...error61 instanceof NativeSafetyError && error61.reason ? { reason: error61.reason } : {} }));
|
|
46770
47081
|
process.exitCode = 1;
|
|
@@ -911,8 +911,21 @@ function shellQuoteWord(word) {
|
|
|
911
911
|
return word;
|
|
912
912
|
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
913
913
|
}
|
|
914
|
-
function rewriteCommand(command, hits, trashPath) {
|
|
915
|
-
const replacement =
|
|
914
|
+
function rewriteCommand(command, hits, trashPath, execution) {
|
|
915
|
+
const replacement = execution ? [
|
|
916
|
+
"/usr/bin/env",
|
|
917
|
+
"-i",
|
|
918
|
+
`HOME=${execution.home}`,
|
|
919
|
+
`PATH=${execution.path}`,
|
|
920
|
+
`HASNA_TRASH_AGENT=${execution.agent}`,
|
|
921
|
+
execution.runtime,
|
|
922
|
+
"--config=/dev/null",
|
|
923
|
+
"--no-env-file",
|
|
924
|
+
"--no-macros",
|
|
925
|
+
"--no-install",
|
|
926
|
+
trashPath,
|
|
927
|
+
"guard"
|
|
928
|
+
].map(shellQuoteWord).join(" ") : `${shellQuoteWord(trashPath)} guard`;
|
|
916
929
|
const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
|
|
917
930
|
let out = command;
|
|
918
931
|
for (const hit of ordered) {
|
|
@@ -964,6 +977,20 @@ function updatedInputIsComplete(updated, original, hits, cwd, home) {
|
|
|
964
977
|
return false;
|
|
965
978
|
return rewriteIsComplete(original, updated.command, hits, cwd, home);
|
|
966
979
|
}
|
|
980
|
+
function changesExecutionEnvironment(command) {
|
|
981
|
+
const stateCommands = new Set(["export", "set", "unset", "readonly", "declare", "typeset", "local", "alias", "unalias", "read", "readarray", "mapfile", "getopts", "trap", "enable", "function", "env", "sudo", "doas", "stdbuf"]);
|
|
982
|
+
const parsed = lexCommand(command);
|
|
983
|
+
return parsed.segments.some((segment) => {
|
|
984
|
+
const index = commandWordIndex(segment);
|
|
985
|
+
const prefix = segment.words.slice(0, index < 0 ? segment.words.length : index);
|
|
986
|
+
if (prefix.some((word2) => ASSIGNMENT_RE.test(decodeWord(word2.text)) || stateCommands.has(baseVerb(decodeWord(word2.text)))))
|
|
987
|
+
return true;
|
|
988
|
+
if (index < 0)
|
|
989
|
+
return false;
|
|
990
|
+
const word = segment.words[index];
|
|
991
|
+
return stateCommands.has(baseVerb(decodeWord(word.text))) || /^\s*\(\s*\)\s*\{/.test(command.slice(word.end));
|
|
992
|
+
});
|
|
993
|
+
}
|
|
967
994
|
function deny(reason) {
|
|
968
995
|
return {
|
|
969
996
|
hookSpecificOutput: {
|
|
@@ -1042,8 +1069,13 @@ function evaluate(input, deps) {
|
|
|
1042
1069
|
return deny(ABSENT_REASON);
|
|
1043
1070
|
}
|
|
1044
1071
|
const trashPath = trash.path;
|
|
1045
|
-
|
|
1072
|
+
if (deps.execution && changesExecutionEnvironment(command)) {
|
|
1073
|
+
return deny("[trash-guard] A deletion combined with shell environment changes cannot use the verified native execution context. Run the deletion as a separate plain rm command.");
|
|
1074
|
+
}
|
|
1075
|
+
const rewritten = rewriteCommand(command, scanned.rmHits, trashPath, deps.execution);
|
|
1046
1076
|
const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
|
|
1077
|
+
if (deps.execution)
|
|
1078
|
+
updatedInput.hasna_execution = "hasna.hooks.execution.v1";
|
|
1047
1079
|
if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
|
|
1048
1080
|
return deny("[trash-guard] The rewrite of this command did not verify (the delete could not be redirected cleanly), so the command is refused rather than run unredirected. Re-run the delete as a plain `rm -- <path>` command.");
|
|
1049
1081
|
}
|
|
@@ -1379,6 +1411,17 @@ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" ||
|
|
|
1379
1411
|
throw new Error("Invalid safety input");
|
|
1380
1412
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
|
|
1381
1413
|
throw new Error("Invalid shell input");
|
|
1414
|
+
var requestedExecution = input.hasna_execution;
|
|
1415
|
+
if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema" || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent)))
|
|
1416
|
+
throw new Error("Invalid execution capability");
|
|
1417
|
+
var execution = requestedExecution === undefined ? undefined : {
|
|
1418
|
+
runtime: process.execPath,
|
|
1419
|
+
home: homedir2(),
|
|
1420
|
+
path: process.env.PATH,
|
|
1421
|
+
agent: requestedExecution.agent
|
|
1422
|
+
};
|
|
1423
|
+
if (execution && (!isAbsolute4(execution.runtime) || !execution.path))
|
|
1424
|
+
throw new Error("Invalid bound execution context");
|
|
1382
1425
|
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
|
|
1383
1426
|
if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch)
|
|
1384
1427
|
throw new Error("Conflicting patch aliases");
|
|
@@ -1389,5 +1432,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
|
|
|
1389
1432
|
}
|
|
1390
1433
|
var repos = evaluate2(input).output;
|
|
1391
1434
|
var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
|
|
1392
|
-
var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
|
|
1435
|
+
var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
|
|
1393
1436
|
process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
|
package/dist/native-safety.d.ts
CHANGED
|
@@ -19,6 +19,20 @@ export type NativeSafetyDecision = {
|
|
|
19
19
|
decision: "rewrite";
|
|
20
20
|
command: string;
|
|
21
21
|
};
|
|
22
|
+
export type NativeSafetyExecutionDecision = Exclude<NativeSafetyDecision, {
|
|
23
|
+
decision: "rewrite";
|
|
24
|
+
}> | {
|
|
25
|
+
decision: "rewrite";
|
|
26
|
+
execution: {
|
|
27
|
+
command: string;
|
|
28
|
+
shell: "/bin/sh";
|
|
29
|
+
cwd: string;
|
|
30
|
+
env: {
|
|
31
|
+
HOME: string;
|
|
32
|
+
PATH: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
22
36
|
export type NativeSafetyRecord = {
|
|
23
37
|
schema: "hasna.hooks.native-safety.v1";
|
|
24
38
|
harness: "sumi";
|
|
@@ -48,6 +62,15 @@ export declare function evaluateNativeSafety(event: NativeSafetyEvent, options?:
|
|
|
48
62
|
home?: string;
|
|
49
63
|
signal?: AbortSignal;
|
|
50
64
|
}): Promise<NativeSafetyDecision>;
|
|
65
|
+
/** Native tool dispatch consumes this entire context, with environment inheritance
|
|
66
|
+
* disabled. The worker pins each Trash invocation to its verified Bun runtime,
|
|
67
|
+
* ignoring caller PATH, preload variables and cwd Bun configuration. `agent` is
|
|
68
|
+
* the bounded explicit Trash label; it does not invent a harness/session enum. */
|
|
69
|
+
export declare function evaluateNativeSafetyForExecution(event: NativeSafetyEvent, options: {
|
|
70
|
+
home?: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
agent: string;
|
|
73
|
+
}): Promise<NativeSafetyExecutionDecision>;
|
|
51
74
|
/** Classify a synthetic removal through this exact supervisor without executing
|
|
52
75
|
* the returned command or creating a file. A harmless ':' would not test whether
|
|
53
76
|
* the saved PATH can actually resolve Trash. */
|
|
@@ -56,3 +79,10 @@ export declare function verifyNativeSafetyRegistration(home?: string): Promise<{
|
|
|
56
79
|
path: string;
|
|
57
80
|
sha256: string;
|
|
58
81
|
}>;
|
|
82
|
+
/** Read-only compatibility check for native dispatch. This classifies one
|
|
83
|
+
* synthetic removal and requires the execution capability; it never executes
|
|
84
|
+
* the rewrite, creates a fixture, authenticates Trash or proves harness adoption. */
|
|
85
|
+
export declare function verifyNativeSafetyExecutionRegistration(home?: string): Promise<{
|
|
86
|
+
path: string;
|
|
87
|
+
sha256: string;
|
|
88
|
+
}>;
|
package/dist/native-safety.js
CHANGED
|
@@ -148,6 +148,7 @@ class NativeSafetyError extends Error {
|
|
|
148
148
|
this.name = "NativeSafetyError";
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
var executionSchema = "hasna.hooks.execution.v1";
|
|
151
152
|
var hash = (value) => createHash2("sha256").update(value).digest("hex");
|
|
152
153
|
var filename = "sumi-trash-guard.json";
|
|
153
154
|
var owner = (uid) => uid === 0 || uid === process.getuid?.();
|
|
@@ -305,10 +306,31 @@ function registerNativeSafety(options = {}) {
|
|
|
305
306
|
async function evaluateNativeSafety(event, options = {}) {
|
|
306
307
|
return evaluateRecord(event, readNativeSafetyRegistration(options.home).record, options.signal);
|
|
307
308
|
}
|
|
308
|
-
async function
|
|
309
|
+
async function evaluateNativeSafetyForExecution(event, options) {
|
|
310
|
+
if (!options || typeof options.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(options.agent))
|
|
311
|
+
throw new NativeSafetyError("input_invalid");
|
|
312
|
+
let snapshot;
|
|
313
|
+
try {
|
|
314
|
+
snapshot = JSON.parse(JSON.stringify(event));
|
|
315
|
+
} catch {
|
|
316
|
+
throw new NativeSafetyError("input_invalid");
|
|
317
|
+
}
|
|
318
|
+
if (snapshot?.tool_input && Object.hasOwn(snapshot.tool_input, "hasna_execution"))
|
|
319
|
+
throw new NativeSafetyError("input_invalid");
|
|
320
|
+
const before = readNativeSafetyRegistration(options.home);
|
|
321
|
+
const binding = nativeSafetyCommandBinding(before.record.command);
|
|
322
|
+
const decision = await evaluateRecord(snapshot, before.record, options.signal, options.agent);
|
|
323
|
+
if (readNativeSafetyRegistration(options.home).sha256 !== before.sha256)
|
|
324
|
+
throw new NativeSafetyError("binding_changed");
|
|
325
|
+
return decision.decision === "rewrite" ? {
|
|
326
|
+
decision: "rewrite",
|
|
327
|
+
execution: { command: decision.command, shell: "/bin/sh", cwd: snapshot.cwd, env: { HOME: binding.home, PATH: binding.path } }
|
|
328
|
+
} : decision;
|
|
329
|
+
}
|
|
330
|
+
async function evaluateRecord(event, record, signal, executionAgent) {
|
|
309
331
|
if (!event || event.hook_event_name !== "PreToolUse" || !["Bash", "apply_patch"].includes(event.tool_name) || typeof event.cwd !== "string" || !isAbsolute2(event.cwd) || !event.tool_input || typeof event.tool_input !== "object" || Array.isArray(event.tool_input))
|
|
310
332
|
throw new NativeSafetyError("input_invalid");
|
|
311
|
-
const input = JSON.stringify(event);
|
|
333
|
+
const input = JSON.stringify(executionAgent === undefined ? event : { ...event, hasna_execution: { schema: executionSchema, agent: executionAgent } });
|
|
312
334
|
if (Buffer.byteLength(input) > 1048576)
|
|
313
335
|
throw new NativeSafetyError("input_invalid");
|
|
314
336
|
const output = await new Promise((accept, reject) => {
|
|
@@ -333,7 +355,7 @@ async function evaluateRecord(event, record, signal) {
|
|
|
333
355
|
throw new Error;
|
|
334
356
|
if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
|
|
335
357
|
return { decision: "deny", reason: verdict.permissionDecisionReason };
|
|
336
|
-
if (event.tool_name === "Bash" && verdict.permissionDecision === "allow" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,updatedInput" && typeof verdict.updatedInput?.command === "string" && verdict.updatedInput.command.trim())
|
|
358
|
+
if (event.tool_name === "Bash" && verdict.permissionDecision === "allow" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,updatedInput" && typeof verdict.updatedInput?.command === "string" && verdict.updatedInput.command.trim() && (executionAgent === undefined || verdict.updatedInput.hasna_execution === executionSchema))
|
|
337
359
|
return { decision: "rewrite", command: verdict.updatedInput.command };
|
|
338
360
|
} catch {}
|
|
339
361
|
throw new NativeSafetyError("guard_failed");
|
|
@@ -353,11 +375,27 @@ async function verifyNativeSafetyRegistration(home = homedir2()) {
|
|
|
353
375
|
throw new NativeSafetyError("binding_changed");
|
|
354
376
|
return { path: before.path, sha256: before.sha256 };
|
|
355
377
|
}
|
|
378
|
+
async function verifyNativeSafetyExecutionRegistration(home = homedir2()) {
|
|
379
|
+
const before = readNativeSafetyRegistration(home);
|
|
380
|
+
const verdict = await evaluateNativeSafetyForExecution({
|
|
381
|
+
hook_event_name: "PreToolUse",
|
|
382
|
+
tool_name: "Bash",
|
|
383
|
+
cwd: home,
|
|
384
|
+
tool_input: { command: "rm -- .hasna-trash-verification-only" }
|
|
385
|
+
}, { home, agent: "native-safety-verification" });
|
|
386
|
+
if (verdict.decision !== "rewrite")
|
|
387
|
+
throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
|
|
388
|
+
if (readNativeSafetyRegistration(home).sha256 !== before.sha256)
|
|
389
|
+
throw new NativeSafetyError("binding_changed");
|
|
390
|
+
return { path: before.path, sha256: before.sha256 };
|
|
391
|
+
}
|
|
356
392
|
export {
|
|
357
393
|
verifyNativeSafetyRegistration,
|
|
394
|
+
verifyNativeSafetyExecutionRegistration,
|
|
358
395
|
verifyNativeSafetyCommand,
|
|
359
396
|
registerNativeSafety,
|
|
360
397
|
readNativeSafetyRegistration,
|
|
398
|
+
evaluateNativeSafetyForExecution,
|
|
361
399
|
evaluateNativeSafety,
|
|
362
400
|
NativeSafetyError
|
|
363
401
|
};
|
|
@@ -886,8 +886,11 @@ function shellQuoteWord(word: string): string {
|
|
|
886
886
|
* Replace the verb token of every owned `rm` with `<quoted trash> guard`,
|
|
887
887
|
* leaving every other byte of the command untouched.
|
|
888
888
|
*/
|
|
889
|
-
export function rewriteCommand(command: string, hits: RmHit[], trashPath: string): string {
|
|
890
|
-
const replacement =
|
|
889
|
+
export function rewriteCommand(command: string, hits: RmHit[], trashPath: string, execution?: GuardDependencies["execution"]): string {
|
|
890
|
+
const replacement = execution
|
|
891
|
+
? ["/usr/bin/env", "-i", `HOME=${execution.home}`, `PATH=${execution.path}`, `HASNA_TRASH_AGENT=${execution.agent}`,
|
|
892
|
+
execution.runtime, "--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", trashPath, "guard"].map(shellQuoteWord).join(" ")
|
|
893
|
+
: `${shellQuoteWord(trashPath)} guard`;
|
|
891
894
|
const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
|
|
892
895
|
let out = command;
|
|
893
896
|
for (const hit of ordered) {
|
|
@@ -956,6 +959,24 @@ export interface GuardDependencies {
|
|
|
956
959
|
cwd: string;
|
|
957
960
|
findTrash: (env?: NodeJS.ProcessEnv) => string | null;
|
|
958
961
|
inspectTrash?: (env?: NodeJS.ProcessEnv) => TrashBinaryInspection;
|
|
962
|
+
/** Only the verified native worker supplies this execution capability. */
|
|
963
|
+
execution?: { runtime: string; home: string; path: string; agent: string };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Environment-changing shell constructs could run before even /usr/bin/env.
|
|
967
|
+
* Refuse them in execution mode rather than pretending an outer clean env is
|
|
968
|
+
* enough. Ordinary command/control-flow/quoting stays with the existing lexer. */
|
|
969
|
+
function changesExecutionEnvironment(command: string): boolean {
|
|
970
|
+
const stateCommands = new Set(["export", "set", "unset", "readonly", "declare", "typeset", "local", "alias", "unalias", "read", "readarray", "mapfile", "getopts", "trap", "enable", "function", "env", "sudo", "doas", "stdbuf"]);
|
|
971
|
+
const parsed = lexCommand(command);
|
|
972
|
+
return parsed.segments.some((segment) => {
|
|
973
|
+
const index = commandWordIndex(segment);
|
|
974
|
+
const prefix = segment.words.slice(0, index < 0 ? segment.words.length : index);
|
|
975
|
+
if (prefix.some((word) => ASSIGNMENT_RE.test(decodeWord(word.text)) || stateCommands.has(baseVerb(decodeWord(word.text))))) return true;
|
|
976
|
+
if (index < 0) return false;
|
|
977
|
+
const word = segment.words[index]!;
|
|
978
|
+
return stateCommands.has(baseVerb(decodeWord(word.text))) || /^\s*\(\s*\)\s*\{/.test(command.slice(word.end));
|
|
979
|
+
});
|
|
959
980
|
}
|
|
960
981
|
|
|
961
982
|
function deny(reason: string): CodewithHookOutput {
|
|
@@ -1078,8 +1099,12 @@ export function evaluate(input: CodewithHookInput, deps: GuardDependencies): Cod
|
|
|
1078
1099
|
}
|
|
1079
1100
|
const trashPath = trash.path;
|
|
1080
1101
|
|
|
1081
|
-
|
|
1102
|
+
if (deps.execution && changesExecutionEnvironment(command)) {
|
|
1103
|
+
return deny("[trash-guard] A deletion combined with shell environment changes cannot use the verified native execution context. Run the deletion as a separate plain rm command.");
|
|
1104
|
+
}
|
|
1105
|
+
const rewritten = rewriteCommand(command, scanned.rmHits, trashPath, deps.execution);
|
|
1082
1106
|
const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
|
|
1107
|
+
if (deps.execution) updatedInput.hasna_execution = "hasna.hooks.execution.v1";
|
|
1083
1108
|
|
|
1084
1109
|
if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
|
|
1085
1110
|
// Never allow a partial rewrite: the harness falls back to the ORIGINAL
|
|
@@ -13,6 +13,14 @@ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" ||
|
|
|
13
13
|
|| !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
|
|
14
14
|
|| typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
|
|
15
15
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
|
|
16
|
+
const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
|
|
17
|
+
if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
|
|
18
|
+
|| requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
|
|
19
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
|
|
20
|
+
const execution = requestedExecution === undefined ? undefined : {
|
|
21
|
+
runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
|
|
22
|
+
};
|
|
23
|
+
if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
|
|
16
24
|
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
|
|
17
25
|
if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
|
|
18
26
|
const patch = input.tool_input.command ?? input.tool_input.patch;
|
|
@@ -24,5 +32,5 @@ const handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.comma
|
|
|
24
32
|
const verdict = repos.decision === "block"
|
|
25
33
|
? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } }
|
|
26
34
|
: handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } }
|
|
27
|
-
: name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
|
|
35
|
+
: name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
|
|
28
36
|
process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
|
package/package.json
CHANGED