@hasna/hooks 0.10.4 → 0.10.5
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 +21 -7
- package/bin/index.js +42 -6
- 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
|
@@ -333,13 +333,27 @@ Trash script's Bun interpreter and all preceding PATH directories are checked
|
|
|
333
333
|
before its identity probe runs; unsafe or unsupported interpreter resolution
|
|
334
334
|
reports `unsafe_interpreter` without executing it.
|
|
335
335
|
|
|
336
|
-
Native adapters import `@hasna/hooks/native-safety
|
|
337
|
-
operation to `
|
|
338
|
-
|
|
339
|
-
adapter must
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
336
|
+
Native adapters import `@hasna/hooks/native-safety` and pass the final shell/patch
|
|
337
|
+
operation to `evaluateNativeSafetyForExecution`, with cancellation and an explicit
|
|
338
|
+
agent label. A rewrite returns the complete command, shell, resolved cwd and
|
|
339
|
+
environment. The adapter must use those fields together, disable environment
|
|
340
|
+
inheritance, and prevent later hooks from changing them. Every rewritten removal
|
|
341
|
+
uses the verified absolute Bun runtime with a clean environment and project
|
|
342
|
+
configuration, env files, macros and installation disabled. Deletions combined
|
|
343
|
+
with shell environment changes are refused; run those removals separately.
|
|
344
|
+
|
|
345
|
+
Use `hooks safety verify --target sumi --execution` for a small, read-only check
|
|
346
|
+
that the saved worker supports this execution contract. A legacy worker that only
|
|
347
|
+
classifies commands cannot pass it. The compatible `evaluateNativeSafety` API and
|
|
348
|
+
verification without `--execution` classify commands only; their success does not
|
|
349
|
+
prove execution compatibility. Neither check runs the returned deletion, creates
|
|
350
|
+
a test file, authenticates Trash or proves native adoption.
|
|
351
|
+
|
|
352
|
+
The bounded agent label can identify the Sumi session in Trash's existing name
|
|
353
|
+
field; it does not invent unsupported harness/session fields. Missing or unhealthy
|
|
354
|
+
bindings reject the call. This covers supported shell removal and whole-file patch
|
|
355
|
+
deletion, not arbitrary filesystem writes or plugin executor replacements.
|
|
356
|
+
Installing the record alone does not activate a harness.
|
|
343
357
|
|
|
344
358
|
## Data Directory
|
|
345
359
|
|
package/bin/index.js
CHANGED
|
@@ -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";
|
|
@@ -46759,12 +46795,12 @@ safety.command("install <guard>").option("--target <target>", "codex, claude or
|
|
|
46759
46795
|
if (!result.success)
|
|
46760
46796
|
process.exitCode = 1;
|
|
46761
46797
|
});
|
|
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) => {
|
|
46798
|
+
safety.command("verify").requiredOption("--target <target>", "sumi").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) => {
|
|
46763
46799
|
try {
|
|
46764
46800
|
if (options.target !== "sumi")
|
|
46765
46801
|
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 }));
|
|
46802
|
+
const current2 = await (options.execution ? verifyNativeSafetyExecutionRegistration() : verifyNativeSafetyRegistration());
|
|
46803
|
+
console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, ...options.execution ? { executionCapabilityVerified: true } : {}, nativeAdoptionVerified: false }));
|
|
46768
46804
|
} catch (error61) {
|
|
46769
46805
|
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
46806
|
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