@hasna/hooks 0.11.2 → 0.11.4
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 +6 -1
- package/bin/hooks-mcp.js +65 -63
- package/bin/index.js +382 -376
- package/bin/native-readiness-entry.js +3 -1
- package/dist/index.js +63 -63
- package/dist/native-readiness.d.ts +3 -0
- package/dist/native-readiness.js +8 -2
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -732,19 +732,350 @@ var init_registry = __esm(() => {
|
|
|
732
732
|
];
|
|
733
733
|
});
|
|
734
734
|
|
|
735
|
+
// src/lib/readiness-process.ts
|
|
736
|
+
import { spawn } from "child_process";
|
|
737
|
+
function runReadinessProcess(command, args, options) {
|
|
738
|
+
if (!["darwin", "linux"].includes(process.platform)) {
|
|
739
|
+
return Promise.resolve({ status: null, error: { code: "ENOTSUP" }, stdout: "", stderr: "" });
|
|
740
|
+
}
|
|
741
|
+
if (options.signal?.aborted) {
|
|
742
|
+
return Promise.resolve({ status: null, error: { code: "ABORT_ERR" }, stdout: "", stderr: "" });
|
|
743
|
+
}
|
|
744
|
+
return new Promise((resolve2) => {
|
|
745
|
+
const child = spawn(command, ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", "--eval", supervisor, "--", JSON.stringify({ command, args, maxBuffer: options.maxBuffer, timeout: options.timeout })], {
|
|
746
|
+
cwd: options.cwd,
|
|
747
|
+
env: options.env,
|
|
748
|
+
detached: true,
|
|
749
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
750
|
+
});
|
|
751
|
+
let status = null, error;
|
|
752
|
+
let stopping = false, exited = false, finished = false;
|
|
753
|
+
let stdout = "", stderr = "", control = "";
|
|
754
|
+
const maximumEnvelope = options.maxBuffer * 12 + 1024;
|
|
755
|
+
let cleanup;
|
|
756
|
+
const finish = () => {
|
|
757
|
+
if (finished)
|
|
758
|
+
return;
|
|
759
|
+
finished = true;
|
|
760
|
+
clearTimeout(deadline);
|
|
761
|
+
clearTimeout(cleanup);
|
|
762
|
+
options.signal?.removeEventListener("abort", abort);
|
|
763
|
+
child.stdin?.destroy();
|
|
764
|
+
child.stdout?.destroy();
|
|
765
|
+
child.stderr?.destroy();
|
|
766
|
+
resolve2({ status, error, stdout, stderr });
|
|
767
|
+
};
|
|
768
|
+
const stop = (code) => {
|
|
769
|
+
if (code && !finished)
|
|
770
|
+
error ??= { code };
|
|
771
|
+
if (finished || stopping)
|
|
772
|
+
return;
|
|
773
|
+
stopping = true;
|
|
774
|
+
if (exited || !child.pid) {
|
|
775
|
+
error ??= { code: "ECHILD" };
|
|
776
|
+
} else {
|
|
777
|
+
try {
|
|
778
|
+
child.stdin.end();
|
|
779
|
+
} catch {
|
|
780
|
+
error = { code: "ECHILD" };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
cleanup = setTimeout(() => {
|
|
784
|
+
error = { code: "ECHILD" };
|
|
785
|
+
finish();
|
|
786
|
+
}, 1000);
|
|
787
|
+
};
|
|
788
|
+
const abort = () => stop("ABORT_ERR");
|
|
789
|
+
const deadline = setTimeout(() => stop("ETIMEDOUT"), options.timeout);
|
|
790
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
791
|
+
if (options.signal?.aborted)
|
|
792
|
+
abort();
|
|
793
|
+
child.stdin?.on("error", () => {
|
|
794
|
+
if (!stopping)
|
|
795
|
+
stop("ECHILD");
|
|
796
|
+
});
|
|
797
|
+
child.stdout?.on("data", (bytes) => {
|
|
798
|
+
if (stopping)
|
|
799
|
+
return;
|
|
800
|
+
control += bytes.toString("utf8");
|
|
801
|
+
if (Buffer.byteLength(control) > maximumEnvelope)
|
|
802
|
+
return stop("ENOBUFS");
|
|
803
|
+
if (!control.endsWith(`
|
|
804
|
+
`))
|
|
805
|
+
return;
|
|
806
|
+
try {
|
|
807
|
+
const value = JSON.parse(control);
|
|
808
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["status", "error", "stdout", "stderr"].includes(key)) || !(value.status === null || Number.isInteger(value.status) && value.status >= 0 && value.status <= 255) || typeof value.stdout !== "string" || typeof value.stderr !== "string" || Buffer.byteLength(value.stdout) > options.maxBuffer || Buffer.byteLength(value.stderr) > options.maxBuffer || value.error !== undefined && (Object.keys(value.error).join() !== "code" || !["ENOBUFS", "ECHILD"].includes(value.error.code))) {
|
|
809
|
+
return stop("EPROTO");
|
|
810
|
+
}
|
|
811
|
+
status = value.status;
|
|
812
|
+
stdout = value.stdout;
|
|
813
|
+
stderr = value.stderr;
|
|
814
|
+
stop(value.error?.code);
|
|
815
|
+
} catch {
|
|
816
|
+
stop("EPROTO");
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
child.stdout?.on("error", () => stop("EPROTO"));
|
|
820
|
+
child.stderr?.on("data", () => stop("ECHILD"));
|
|
821
|
+
child.stderr?.on("error", () => stop("ECHILD"));
|
|
822
|
+
child.on("error", () => {
|
|
823
|
+
error = { code: "ECHILD" };
|
|
824
|
+
stop();
|
|
825
|
+
});
|
|
826
|
+
child.on("exit", () => {
|
|
827
|
+
exited = true;
|
|
828
|
+
if (!stopping)
|
|
829
|
+
stop("ECHILD");
|
|
830
|
+
});
|
|
831
|
+
child.on("close", () => {
|
|
832
|
+
if (!stopping)
|
|
833
|
+
error = { code: "ECHILD" };
|
|
834
|
+
finish();
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
var supervisor;
|
|
839
|
+
var init_readiness_process = __esm(() => {
|
|
840
|
+
supervisor = String.raw`
|
|
841
|
+
const {spawn}=require("node:child_process");
|
|
842
|
+
const spec=JSON.parse(process.argv.at(-1));
|
|
843
|
+
// This runtime was created detached and is its group's live leader. Its
|
|
844
|
+
// independent timer and parent pipe remain effective if the observer exits
|
|
845
|
+
// or its event loop stops responding. Only this freshly created tree is hit.
|
|
846
|
+
const cleanup=()=>{try{process.kill(-process.pid,"SIGKILL")}catch{process.exit(1)}};
|
|
847
|
+
setTimeout(cleanup,spec.timeout);
|
|
848
|
+
process.stdin.once("end",cleanup);
|
|
849
|
+
process.stdin.once("error",cleanup);
|
|
850
|
+
process.stdin.on("data",cleanup);
|
|
851
|
+
process.stdin.resume();
|
|
852
|
+
for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.once(signal,cleanup);
|
|
853
|
+
let done=false, out=Buffer.alloc(0), err=Buffer.alloc(0);
|
|
854
|
+
function report(status,error){
|
|
855
|
+
if(done)return; done=true;
|
|
856
|
+
process.stdout.write(JSON.stringify({status,error,stdout:out.toString("utf8"),stderr:err.toString("utf8")})+"\n");
|
|
857
|
+
}
|
|
858
|
+
const child=spawn(spec.command,spec.args,{stdio:["ignore","pipe","pipe"],env:process.env});
|
|
859
|
+
for(const [stream,name] of [[child.stdout,"stdout"],[child.stderr,"stderr"]]){
|
|
860
|
+
stream.on("data",bytes=>{
|
|
861
|
+
if(done)return;
|
|
862
|
+
const before=name==="stdout"?out:err;
|
|
863
|
+
if(before.length+bytes.length>spec.maxBuffer){report(null,{code:"ENOBUFS"});return}
|
|
864
|
+
if(name==="stdout")out=Buffer.concat([out,bytes]);else err=Buffer.concat([err,bytes]);
|
|
865
|
+
});
|
|
866
|
+
stream.on("error",()=>report(null,{code:"ECHILD"}));
|
|
867
|
+
}
|
|
868
|
+
child.on("error",()=>report(null,{code:"ECHILD"}));
|
|
869
|
+
// A successful observation includes complete streams. A nested client that
|
|
870
|
+
// keeps them open after its parent exits is a failure requiring group cleanup.
|
|
871
|
+
child.on("exit",()=>setTimeout(()=>report(null,{code:"ECHILD"}),100));
|
|
872
|
+
child.on("close",(status,signal)=>report(status,signal?{code:"ECHILD"}:undefined));
|
|
873
|
+
`;
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// src/native-readiness.ts
|
|
877
|
+
var exports_native_readiness = {};
|
|
878
|
+
__export(exports_native_readiness, {
|
|
879
|
+
trashReadinessBindingPath: () => trashReadinessBindingPath,
|
|
880
|
+
runTrashReadiness: () => runTrashReadiness,
|
|
881
|
+
readinessSessionStartOutput: () => readinessSessionStartOutput,
|
|
882
|
+
readinessContext: () => readinessContext,
|
|
883
|
+
readTrashReadinessBindingFile: () => readTrashReadinessBindingFile,
|
|
884
|
+
parseTrashReadinessBinding: () => parseTrashReadinessBinding,
|
|
885
|
+
isReadinessProvider: () => isReadinessProvider,
|
|
886
|
+
handleReadinessSessionStart: () => handleReadinessSessionStart,
|
|
887
|
+
checkTrashReadinessBinding: () => checkTrashReadinessBinding,
|
|
888
|
+
TRASH_READINESS_NATIVE_TIMEOUT_SECONDS: () => TRASH_READINESS_NATIVE_TIMEOUT_SECONDS,
|
|
889
|
+
TRASH_READINESS_MAX_TIMEOUT_MS: () => TRASH_READINESS_MAX_TIMEOUT_MS,
|
|
890
|
+
TRASH_READINESS_DEFAULT_TIMEOUT_MS: () => TRASH_READINESS_DEFAULT_TIMEOUT_MS
|
|
891
|
+
});
|
|
892
|
+
import { createHash } from "crypto";
|
|
893
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "fs";
|
|
894
|
+
import { homedir as homedir2 } from "os";
|
|
895
|
+
import { dirname, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
|
|
896
|
+
function need(value, code) {
|
|
897
|
+
if (!value)
|
|
898
|
+
throw new ReadinessError(code);
|
|
899
|
+
}
|
|
900
|
+
function isReadinessProvider(value) {
|
|
901
|
+
return value === "claude" || value === "codex" || value === "sumi";
|
|
902
|
+
}
|
|
903
|
+
function inside(home, path) {
|
|
904
|
+
const part = relative(home, path);
|
|
905
|
+
return part !== "" && part !== ".." && !part.startsWith("../") && !isAbsolute(part);
|
|
906
|
+
}
|
|
907
|
+
function parseTrashReadinessBinding(input, provider, home) {
|
|
908
|
+
const b = input;
|
|
909
|
+
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
910
|
+
need(keys(b, ["schema", "provider", "home", "runtime", "trash", "plan", "timeoutMs"]) && b.schema === "hasna.hooks.trash-readiness/v1" && b.provider === provider && b.home === home && pin(b.runtime) && pin(b.plan) && keys(b.trash, ["root", "version", "cliSha256"]) && absolute(b.trash.root) && typeof b.trash.version === "string" && b.trash.version.length <= 128 && /^\d+\.\d+\.\d+$/.test(b.trash.version) && digest(b.trash.cliSha256) && Number.isInteger(b.timeoutMs) && b.timeoutMs >= 1000 && b.timeoutMs <= TRASH_READINESS_MAX_TIMEOUT_MS && inside(home, b.plan.path) && inside(home, b.trash.root), "binding_invalid");
|
|
911
|
+
return structuredClone(b);
|
|
912
|
+
}
|
|
913
|
+
function trashReadinessBindingPath(provider, home = homedir2()) {
|
|
914
|
+
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
915
|
+
return join2(home, ".hasna", "hooks", "native", "trash-readiness", provider + ".json");
|
|
916
|
+
}
|
|
917
|
+
function readSafe(path, maximum, executable = false) {
|
|
918
|
+
need(absolute(path) && realpathSync(path) === path, "unsafe_path");
|
|
919
|
+
for (let parent = dirname(path);; parent = dirname(parent)) {
|
|
920
|
+
const stat = lstatSync(parent);
|
|
921
|
+
need(stat.isDirectory() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18), "unsafe_path");
|
|
922
|
+
if (parent === dirname(parent))
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
926
|
+
try {
|
|
927
|
+
const stat = fstatSync(fd);
|
|
928
|
+
need(stat.isFile() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18) && stat.size <= maximum && (!executable || !!(stat.mode & 64)), "unsafe_path");
|
|
929
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
930
|
+
let length = 0;
|
|
931
|
+
while (length < bytes.length) {
|
|
932
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
933
|
+
if (!count)
|
|
934
|
+
break;
|
|
935
|
+
length += count;
|
|
936
|
+
}
|
|
937
|
+
const after = fstatSync(fd);
|
|
938
|
+
need(length === stat.size && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs, "file_changed");
|
|
939
|
+
return bytes.subarray(0, length);
|
|
940
|
+
} finally {
|
|
941
|
+
closeSync(fd);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function readTrashReadinessBindingFile(path, provider, home = realpathSync(homedir2())) {
|
|
945
|
+
const raw = readSafe(path, 65536);
|
|
946
|
+
return { binding: parseTrashReadinessBinding(JSON.parse(raw.toString("utf8")), provider, home), raw, sha256: sha(raw) };
|
|
947
|
+
}
|
|
948
|
+
async function runTrashReadiness(provider, d = {}) {
|
|
949
|
+
const started = performance.now();
|
|
950
|
+
const result = (code, extra = {}) => ({
|
|
951
|
+
schema: "hasna.hooks.trash-readiness-result/v1",
|
|
952
|
+
provider,
|
|
953
|
+
ready: code === "ready",
|
|
954
|
+
code,
|
|
955
|
+
nativeAdoptionVerified: false,
|
|
956
|
+
...extra
|
|
957
|
+
});
|
|
958
|
+
let phase = "binding";
|
|
959
|
+
try {
|
|
960
|
+
const home = d.home ?? realpathSync(homedir2()), read = d.read ?? readSafe;
|
|
961
|
+
need(isReadinessProvider(provider), "binding_invalid");
|
|
962
|
+
const bindingPath = trashReadinessBindingPath(provider, home), bindingBytes = read(bindingPath, 65536);
|
|
963
|
+
const b = parseTrashReadinessBinding(JSON.parse(bindingBytes.toString("utf8")), provider, home);
|
|
964
|
+
phase = "consumer";
|
|
965
|
+
const checkPin = (p, maximum, executable = false) => {
|
|
966
|
+
const bytes = read(p.path, maximum, executable);
|
|
967
|
+
need(sha(bytes) === p.sha256, "file_changed");
|
|
968
|
+
return bytes;
|
|
969
|
+
};
|
|
970
|
+
checkPin(b.runtime, 200000000, true);
|
|
971
|
+
const manifestPath = join2(b.trash.root, "package.json"), manifestBytes = read(manifestPath, 65536);
|
|
972
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
973
|
+
need(manifest.name === "@hasna/trash" && manifest.version === b.trash.version && manifest.bin?.trash === "dist/cli/index.js", "package_mismatch");
|
|
974
|
+
const cli = join2(b.trash.root, "dist", "cli", "index.js");
|
|
975
|
+
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
976
|
+
const plan = JSON.parse(checkPin(b.plan, 65536).toString("utf8"));
|
|
977
|
+
need(plan?.schema === "hasna.trash.station-setup-plan/v1" && plan.stage === "ready" && plan.configurationReady === true && plan.nativeAdoptionVerified === false && digest(plan.planDigest) && plan.spec?.provider === provider && plan.spec.home === home && typeof plan.spec.station === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(plan.spec.station) && plan.spec.runtime?.path === b.runtime.path && plan.spec.runtime?.sha256 === b.runtime.sha256 && absolute(plan.spec.cwd) && (plan.spec.cwd === home || inside(home, plan.spec.cwd)) && typeof plan.spec.path === "string" && plan.spec.path.length <= 8192 && plan.spec.path.split(":").every(absolute), "plan_invalid");
|
|
978
|
+
const args = ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", cli, "station", "check", "--plan", b.plan.path];
|
|
979
|
+
const remaining = () => Math.floor(b.timeoutMs - (performance.now() - started));
|
|
980
|
+
const timeout = remaining();
|
|
981
|
+
need(timeout > 0, "deadline_exceeded");
|
|
982
|
+
const child = await (d.spawn ?? runReadinessProcess)(b.runtime.path, args, {
|
|
983
|
+
cwd: home,
|
|
984
|
+
env: { HOME: home, PATH: plan.spec.path, LANG: "C", NO_COLOR: "1" },
|
|
985
|
+
timeout,
|
|
986
|
+
maxBuffer: 16384,
|
|
987
|
+
signal: d.signal
|
|
988
|
+
});
|
|
989
|
+
need(remaining() > 0, "deadline_exceeded");
|
|
990
|
+
if (child.error && "code" in child.error && child.error.code === "ETIMEDOUT")
|
|
991
|
+
return result("deadline_exceeded");
|
|
992
|
+
if (child.error || child.signal || child.status === null)
|
|
993
|
+
return result("execution_failed");
|
|
994
|
+
need(typeof child.stdout === "string" && typeof child.stderr === "string" && Buffer.byteLength(child.stdout) <= 16384 && Buffer.byteLength(child.stderr) <= 16384, "output_invalid");
|
|
995
|
+
let value;
|
|
996
|
+
try {
|
|
997
|
+
value = JSON.parse(child.stdout);
|
|
998
|
+
} catch {
|
|
999
|
+
return result("output_invalid");
|
|
1000
|
+
}
|
|
1001
|
+
if (child.status !== 0 || value?.configurationReady !== true) {
|
|
1002
|
+
return result(safeFailureCodes.has(value?.code) ? value.code : "check_failed");
|
|
1003
|
+
}
|
|
1004
|
+
need(value.schema === "hasna.trash.station-setup-result/v1" && value.station === plan.spec.station && value.provider === provider && value.planDigest === plan.planDigest && value.serviceReady === true && value.guardVerified === true && value.managedInstructionsVerified === true && value.mcpConfigurationVerified === true && value.mcpExecutionVerified === false && value.nativeAdoptionVerified === false && child.stderr.length === 0, "output_invalid");
|
|
1005
|
+
checkPin(b.plan, 65536);
|
|
1006
|
+
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
1007
|
+
checkPin(b.runtime, 200000000, true);
|
|
1008
|
+
need(read(bindingPath, 65536).equals(bindingBytes) && read(manifestPath, 65536).equals(manifestBytes), "file_changed");
|
|
1009
|
+
need(remaining() > 0, "deadline_exceeded");
|
|
1010
|
+
return result("ready", { station: value.station, planDigest: value.planDigest });
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
return result(error instanceof ReadinessError ? error.code : phase === "binding" && error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
function checkTrashReadinessBinding(input) {
|
|
1016
|
+
const home = realpathSync(homedir2()), b = parseTrashReadinessBinding(input, input?.provider, home);
|
|
1017
|
+
const path = trashReadinessBindingPath(b.provider, home), raw = Buffer.from(JSON.stringify(b) + `
|
|
1018
|
+
`);
|
|
1019
|
+
return runTrashReadiness(b.provider, { home, read: (selected, maximum, executable) => selected === path ? raw : readSafe(selected, maximum, executable) });
|
|
1020
|
+
}
|
|
1021
|
+
function readinessContext(result) {
|
|
1022
|
+
return result.ready ? `Trash readiness verified for ${result.station} (${result.provider}). Use Trash for recoverable file removal.` : `Trash readiness needs attention (${result.provider}: ${result.code}). Preserve files; do not fall back to direct deletion. Refresh the station setup before removal.`;
|
|
1023
|
+
}
|
|
1024
|
+
function readinessSessionStartOutput(result) {
|
|
1025
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: readinessContext(result) } };
|
|
1026
|
+
}
|
|
1027
|
+
async function handleReadinessSessionStart(provider, event, dependencies = {}) {
|
|
1028
|
+
const input = event;
|
|
1029
|
+
if (!object(input) || input.hook_event_name !== "SessionStart" || !["startup", "resume", "clear", "fork", "compact"].includes(input.source)) {
|
|
1030
|
+
return readinessSessionStartOutput({ schema: "hasna.hooks.trash-readiness-result/v1", provider, ready: false, code: "input_invalid", nativeAdoptionVerified: false });
|
|
1031
|
+
}
|
|
1032
|
+
if (input.source === "compact")
|
|
1033
|
+
return {};
|
|
1034
|
+
return readinessSessionStartOutput(await runTrashReadiness(provider, dependencies));
|
|
1035
|
+
}
|
|
1036
|
+
var TRASH_READINESS_DEFAULT_TIMEOUT_MS = 45000, TRASH_READINESS_MAX_TIMEOUT_MS = 60000, TRASH_READINESS_NATIVE_TIMEOUT_SECONDS, ReadinessError, sha = (value) => createHash("sha256").update(value).digest("hex"), digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v), absolute = (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && resolve2(v) === v && !/[\0\r\n]/.test(v), object = (v) => v !== null && typeof v === "object" && !Array.isArray(v), keys = (v, expected) => object(v) && Object.keys(v).sort().join() === expected.sort().join(), pin = (v) => keys(v, ["path", "sha256"]) && absolute(v.path) && digest(v.sha256), safeFailureCodes;
|
|
1037
|
+
var init_native_readiness = __esm(() => {
|
|
1038
|
+
init_readiness_process();
|
|
1039
|
+
TRASH_READINESS_NATIVE_TIMEOUT_SECONDS = TRASH_READINESS_MAX_TIMEOUT_MS / 1000 + 5;
|
|
1040
|
+
ReadinessError = class ReadinessError extends Error {
|
|
1041
|
+
code;
|
|
1042
|
+
constructor(code) {
|
|
1043
|
+
super(code);
|
|
1044
|
+
this.code = code;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
safeFailureCodes = new Set([
|
|
1048
|
+
"unsupported_platform",
|
|
1049
|
+
"station_detection_failed",
|
|
1050
|
+
"station_mismatch",
|
|
1051
|
+
"hosted_unavailable",
|
|
1052
|
+
"authentication_refused",
|
|
1053
|
+
"station_setup_required",
|
|
1054
|
+
"invalid_status",
|
|
1055
|
+
"deadline_exceeded",
|
|
1056
|
+
"retention_mismatch",
|
|
1057
|
+
"managed_instructions_refused",
|
|
1058
|
+
"removal_instruction_missing",
|
|
1059
|
+
"instructions_changed",
|
|
1060
|
+
"native_config_changed",
|
|
1061
|
+
"guard_refused",
|
|
1062
|
+
"owner_refused"
|
|
1063
|
+
]);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
735
1066
|
// src/config.ts
|
|
736
|
-
import { join as
|
|
1067
|
+
import { join as join3 } from "path";
|
|
737
1068
|
function getHooksDataDir() {
|
|
738
1069
|
return getEffectiveDataRoot();
|
|
739
1070
|
}
|
|
740
1071
|
function getCustomHooksDir() {
|
|
741
|
-
return
|
|
1072
|
+
return join3(getHooksDataDir(), "hooks");
|
|
742
1073
|
}
|
|
743
1074
|
function getLockPath() {
|
|
744
1075
|
const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
|
|
745
1076
|
if (explicit)
|
|
746
1077
|
return explicit;
|
|
747
|
-
return
|
|
1078
|
+
return join3(getHooksDataDir(), "hooks.lock");
|
|
748
1079
|
}
|
|
749
1080
|
var init_config = __esm(() => {
|
|
750
1081
|
init_app_home();
|
|
@@ -774,8 +1105,8 @@ __export(exports_manifest, {
|
|
|
774
1105
|
ScriptContainmentError: () => ScriptContainmentError,
|
|
775
1106
|
HOOK_NAME_RE: () => HOOK_NAME_RE
|
|
776
1107
|
});
|
|
777
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
778
|
-
import { basename, dirname, isAbsolute, join as
|
|
1108
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync as realpathSync2, writeFileSync } from "fs";
|
|
1109
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join4, normalize, resolve as resolve3, sep } from "path";
|
|
779
1110
|
import { z } from "zod";
|
|
780
1111
|
function parseManifest(json) {
|
|
781
1112
|
const parsed = JSON.parse(json);
|
|
@@ -791,8 +1122,8 @@ function isWithin(child, parent) {
|
|
|
791
1122
|
return child === parent || child.startsWith(parent + sep);
|
|
792
1123
|
}
|
|
793
1124
|
function assertContainedLexical(target, root, raw) {
|
|
794
|
-
const lexical =
|
|
795
|
-
const rootLexical =
|
|
1125
|
+
const lexical = resolve3(target);
|
|
1126
|
+
const rootLexical = resolve3(root);
|
|
796
1127
|
if (!isWithin(lexical, rootLexical)) {
|
|
797
1128
|
throw new ScriptContainmentError(target, root, raw);
|
|
798
1129
|
}
|
|
@@ -802,16 +1133,16 @@ function realpathNearest(path) {
|
|
|
802
1133
|
const tail = [];
|
|
803
1134
|
let cur = path;
|
|
804
1135
|
while (!existsSync2(cur)) {
|
|
805
|
-
const parent =
|
|
1136
|
+
const parent = dirname2(cur);
|
|
806
1137
|
if (parent === cur)
|
|
807
1138
|
break;
|
|
808
1139
|
tail.unshift(basename(cur));
|
|
809
1140
|
cur = parent;
|
|
810
1141
|
}
|
|
811
|
-
return
|
|
1142
|
+
return join4(realpathSync2(cur), ...tail);
|
|
812
1143
|
}
|
|
813
1144
|
function assertContainedReal(root, target, raw) {
|
|
814
|
-
const rootReal =
|
|
1145
|
+
const rootReal = realpathSync2(root);
|
|
815
1146
|
const targetReal = realpathNearest(target);
|
|
816
1147
|
if (!isWithin(targetReal, rootReal)) {
|
|
817
1148
|
throw new ScriptContainmentError(target, root, raw);
|
|
@@ -832,7 +1163,7 @@ function scriptRelFor(manifest) {
|
|
|
832
1163
|
return `script${ext}`;
|
|
833
1164
|
}
|
|
834
1165
|
const rel = normalize(manifest.script);
|
|
835
|
-
if (
|
|
1166
|
+
if (isAbsolute2(rel)) {
|
|
836
1167
|
throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
|
|
837
1168
|
}
|
|
838
1169
|
return rel;
|
|
@@ -841,25 +1172,25 @@ function resolveScript(manifest, manifestDir) {
|
|
|
841
1172
|
if (isInlineScript(manifest))
|
|
842
1173
|
return { path: scriptRelFor(manifest), content: manifest.script };
|
|
843
1174
|
const rel = scriptRelFor(manifest);
|
|
844
|
-
const resolved = assertContained(
|
|
1175
|
+
const resolved = assertContained(join4(manifestDir, rel), manifestDir, manifest.script);
|
|
845
1176
|
if (!existsSync2(resolved)) {
|
|
846
1177
|
throw new Error(`manifest script file not found: ${resolved}`);
|
|
847
1178
|
}
|
|
848
1179
|
return { path: rel, content: readFileSync(resolved, "utf-8") };
|
|
849
1180
|
}
|
|
850
1181
|
function customHookDir(name) {
|
|
851
|
-
return
|
|
1182
|
+
return join4(getCustomHooksDir(), shortManifestName(name));
|
|
852
1183
|
}
|
|
853
1184
|
function readCustomManifest(name) {
|
|
854
1185
|
const dir = customHookDir(name);
|
|
855
|
-
const manifestPath =
|
|
1186
|
+
const manifestPath = join4(dir, "manifest.json");
|
|
856
1187
|
if (!existsSync2(manifestPath))
|
|
857
1188
|
return;
|
|
858
1189
|
try {
|
|
859
1190
|
const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
|
|
860
1191
|
const script = resolveScript(manifest, dir);
|
|
861
1192
|
const scriptIsInline = isInlineScript(manifest);
|
|
862
|
-
return { manifest, scriptPath:
|
|
1193
|
+
return { manifest, scriptPath: join4(dir, script.path), scriptContent: script.content, scriptIsInline };
|
|
863
1194
|
} catch (err) {
|
|
864
1195
|
if (err instanceof ScriptContainmentError)
|
|
865
1196
|
throw err;
|
|
@@ -880,18 +1211,18 @@ function listCustomHooks() {
|
|
|
880
1211
|
}
|
|
881
1212
|
function writeCustomHook(name, manifest, scriptContent, scriptRelPath) {
|
|
882
1213
|
const dir = customHookDir(name);
|
|
883
|
-
if (
|
|
1214
|
+
if (isAbsolute2(scriptRelPath)) {
|
|
884
1215
|
throw new ScriptContainmentError(scriptRelPath, dir);
|
|
885
1216
|
}
|
|
886
|
-
const scriptPath = scriptRelPath.includes("/") ?
|
|
1217
|
+
const scriptPath = scriptRelPath.includes("/") ? join4(dir, scriptRelPath) : join4(dir, scriptRelPath);
|
|
887
1218
|
assertContainedLexical(scriptPath, dir, scriptRelPath);
|
|
888
1219
|
mkdirSync(dir, { recursive: true });
|
|
889
1220
|
assertContainedReal(dir, scriptPath, scriptRelPath);
|
|
890
|
-
const scriptDir =
|
|
1221
|
+
const scriptDir = dirname2(scriptPath);
|
|
891
1222
|
if (scriptDir !== dir)
|
|
892
1223
|
mkdirSync(scriptDir, { recursive: true });
|
|
893
1224
|
writeFileSync(scriptPath, scriptContent, "utf-8");
|
|
894
|
-
writeFileSync(
|
|
1225
|
+
writeFileSync(join4(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + `
|
|
895
1226
|
`, "utf-8");
|
|
896
1227
|
return { dir, scriptPath };
|
|
897
1228
|
}
|
|
@@ -935,14 +1266,14 @@ __export(exports_resolve, {
|
|
|
935
1266
|
resolveHook: () => resolveHook,
|
|
936
1267
|
resolveBundledHooksDir: () => resolveBundledHooksDir
|
|
937
1268
|
});
|
|
938
|
-
import { dirname as
|
|
1269
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
939
1270
|
import { existsSync as existsSync3 } from "fs";
|
|
940
1271
|
import { fileURLToPath } from "url";
|
|
941
|
-
function resolveBundledHooksDir(moduleDir =
|
|
942
|
-
const sourceLayout =
|
|
943
|
-
if (existsSync3(
|
|
1272
|
+
function resolveBundledHooksDir(moduleDir = dirname3(fileURLToPath(import.meta.url))) {
|
|
1273
|
+
const sourceLayout = join5(moduleDir, "..", "..", "hooks");
|
|
1274
|
+
if (existsSync3(join5(sourceLayout, "hook-gitguard")))
|
|
944
1275
|
return sourceLayout;
|
|
945
|
-
return
|
|
1276
|
+
return join5(moduleDir, "..", "hooks");
|
|
946
1277
|
}
|
|
947
1278
|
function resolveHookDir(name) {
|
|
948
1279
|
const custom = readCustomManifest(name);
|
|
@@ -953,10 +1284,10 @@ function resolveHookDir(name) {
|
|
|
953
1284
|
return;
|
|
954
1285
|
const short = shortManifestName(name);
|
|
955
1286
|
const bundledBase = resolveBundledHooksDir();
|
|
956
|
-
const direct =
|
|
1287
|
+
const direct = join5(bundledBase, short);
|
|
957
1288
|
if (existsSync3(direct))
|
|
958
1289
|
return direct;
|
|
959
|
-
const prefixed =
|
|
1290
|
+
const prefixed = join5(bundledBase, `hook-${short}`);
|
|
960
1291
|
return existsSync3(prefixed) ? prefixed : undefined;
|
|
961
1292
|
}
|
|
962
1293
|
function resolveScriptPath(name) {
|
|
@@ -966,7 +1297,7 @@ function resolveScriptPath(name) {
|
|
|
966
1297
|
const dir = resolveHookDir(name);
|
|
967
1298
|
if (!dir)
|
|
968
1299
|
return;
|
|
969
|
-
const script =
|
|
1300
|
+
const script = join5(dir, "src", "hook.ts");
|
|
970
1301
|
return existsSync3(script) ? script : undefined;
|
|
971
1302
|
}
|
|
972
1303
|
function resolveHookMeta(name) {
|
|
@@ -1023,15 +1354,15 @@ var init_resolve = __esm(() => {
|
|
|
1023
1354
|
});
|
|
1024
1355
|
|
|
1025
1356
|
// src/lib/native-safety.ts
|
|
1026
|
-
import { createHash } from "crypto";
|
|
1027
|
-
import { closeSync, constants, existsSync as existsSync4, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, realpathSync as
|
|
1028
|
-
import { dirname as
|
|
1029
|
-
import { homedir as
|
|
1357
|
+
import { createHash as createHash2 } from "crypto";
|
|
1358
|
+
import { closeSync as closeSync2, constants as constants2, existsSync as existsSync4, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readFileSync as readFileSync2, realpathSync as realpathSync3 } from "fs";
|
|
1359
|
+
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join6 } from "path";
|
|
1360
|
+
import { homedir as homedir3 } from "os";
|
|
1030
1361
|
function isNativeSafetyName(name) {
|
|
1031
1362
|
return name === "trash-guard" || name === "workspace-repos-guard";
|
|
1032
1363
|
}
|
|
1033
1364
|
function validateBinding(b) {
|
|
1034
|
-
if (!b || !isNativeSafetyName(b.name) || ![b.runtime, b.worker, b.home].every((p) => typeof p === "string" &&
|
|
1365
|
+
if (!b || !isNativeSafetyName(b.name) || ![b.runtime, b.worker, b.home].every((p) => typeof p === "string" && isAbsolute3(p) && !/[\0\r\n]/.test(p)) || ![b.runtimeSHA256, b.workerSHA256].every((h) => /^[0-9a-f]{64}$/.test(h)) || typeof b.path !== "string" || b.path.split(":").some((p) => !isAbsolute3(p) || /[\0\r\n]/.test(p)))
|
|
1035
1366
|
throw new Error("Invalid native safety binding");
|
|
1036
1367
|
}
|
|
1037
1368
|
function buildNativeSafetyCommand(binding) {
|
|
@@ -1056,27 +1387,27 @@ function nativeSafetyCommandBinding(command) {
|
|
|
1056
1387
|
}
|
|
1057
1388
|
}
|
|
1058
1389
|
function pinEligibleFile(file, maximum, executable = false) {
|
|
1059
|
-
if (!
|
|
1390
|
+
if (!isAbsolute3(file) || realpathSync3(file) !== file)
|
|
1060
1391
|
throw new Error("Native safety requires canonical files");
|
|
1061
|
-
for (let parent =
|
|
1062
|
-
const s =
|
|
1392
|
+
for (let parent = dirname4(file);; ) {
|
|
1393
|
+
const s = lstatSync2(parent);
|
|
1063
1394
|
if (!s.isDirectory() || ![0, process.getuid()].includes(s.uid) || s.mode & 18)
|
|
1064
1395
|
throw new Error("Native safety requires trusted, non-writable file ancestry");
|
|
1065
|
-
if (parent ===
|
|
1396
|
+
if (parent === dirname4(parent))
|
|
1066
1397
|
break;
|
|
1067
|
-
parent =
|
|
1398
|
+
parent = dirname4(parent);
|
|
1068
1399
|
}
|
|
1069
|
-
const fd =
|
|
1400
|
+
const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
1070
1401
|
try {
|
|
1071
|
-
const s =
|
|
1402
|
+
const s = fstatSync2(fd);
|
|
1072
1403
|
if (!s.isFile() || ![0, process.getuid()].includes(s.uid) || s.mode & 18 || s.size > maximum || executable && !(s.mode & 64))
|
|
1073
1404
|
throw new Error("Native safety requires an eligible installed file");
|
|
1074
1405
|
const bytes = readFileSync2(fd);
|
|
1075
1406
|
if (bytes.length > maximum)
|
|
1076
1407
|
throw new Error("Native safety file exceeds its bound");
|
|
1077
|
-
return
|
|
1408
|
+
return createHash2("sha256").update(bytes).digest("hex");
|
|
1078
1409
|
} finally {
|
|
1079
|
-
|
|
1410
|
+
closeSync2(fd);
|
|
1080
1411
|
}
|
|
1081
1412
|
}
|
|
1082
1413
|
function pinVerified(file, expected, maximum, executable = false) {
|
|
@@ -1093,14 +1424,14 @@ function nativeSafetyBindingVerified(command) {
|
|
|
1093
1424
|
return pinVerified(binding.runtime, binding.runtimeSHA256, 200000000, true) && pinVerified(binding.worker, binding.workerSHA256, 256000);
|
|
1094
1425
|
}
|
|
1095
1426
|
function installedNativeSafetyCommand(name) {
|
|
1096
|
-
const home =
|
|
1097
|
-
const runtime =
|
|
1098
|
-
const candidates = [
|
|
1427
|
+
const home = realpathSync3(homedir3());
|
|
1428
|
+
const runtime = realpathSync3(process.execPath);
|
|
1429
|
+
const candidates = [join6(import.meta.dir, "../../bin/native-safety-entry.js"), join6(import.meta.dir, "../bin/native-safety-entry.js"), join6(import.meta.dir, "native-safety-entry.js")];
|
|
1099
1430
|
const worker = candidates.find(existsSync4);
|
|
1100
1431
|
if (!worker)
|
|
1101
1432
|
throw new Error("Bundled native safety runner is missing; build or reinstall @hasna/hooks before installing its guards.");
|
|
1102
|
-
const canonicalWorker =
|
|
1103
|
-
return buildNativeSafetyCommand({ name, runtime, runtimeSHA256: pinEligibleFile(runtime, 200000000, true), worker: canonicalWorker, workerSHA256: pinEligibleFile(canonicalWorker, 256000), home, path: `${
|
|
1433
|
+
const canonicalWorker = realpathSync3(worker);
|
|
1434
|
+
return buildNativeSafetyCommand({ name, runtime, runtimeSHA256: pinEligibleFile(runtime, 200000000, true), worker: canonicalWorker, workerSHA256: pinEligibleFile(canonicalWorker, 256000), home, path: `${dirname4(runtime)}:${join6(home, ".bun/bin")}:/usr/local/bin:/usr/bin:/bin` });
|
|
1104
1435
|
}
|
|
1105
1436
|
var supervisorSource, quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'", program, shell, prefix;
|
|
1106
1437
|
var init_native_safety = __esm(() => {
|
|
@@ -1171,333 +1502,6 @@ if [ "$result" -ne 0 ]; then printf '%s\\n' 'Native safety guard refused: verifi
|
|
|
1171
1502
|
prefix = `/usr/bin/env -i /bin/sh -c ${quote(shell)} hasna-native-safety-v1 `;
|
|
1172
1503
|
});
|
|
1173
1504
|
|
|
1174
|
-
// src/lib/readiness-process.ts
|
|
1175
|
-
import { spawn } from "child_process";
|
|
1176
|
-
function runReadinessProcess(command, args, options) {
|
|
1177
|
-
if (!["darwin", "linux"].includes(process.platform)) {
|
|
1178
|
-
return Promise.resolve({ status: null, error: { code: "ENOTSUP" }, stdout: "", stderr: "" });
|
|
1179
|
-
}
|
|
1180
|
-
if (options.signal?.aborted) {
|
|
1181
|
-
return Promise.resolve({ status: null, error: { code: "ABORT_ERR" }, stdout: "", stderr: "" });
|
|
1182
|
-
}
|
|
1183
|
-
return new Promise((resolve3) => {
|
|
1184
|
-
const child = spawn(command, ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", "--eval", supervisor, "--", JSON.stringify({ command, args, maxBuffer: options.maxBuffer, timeout: options.timeout })], {
|
|
1185
|
-
cwd: options.cwd,
|
|
1186
|
-
env: options.env,
|
|
1187
|
-
detached: true,
|
|
1188
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1189
|
-
});
|
|
1190
|
-
let status = null, error;
|
|
1191
|
-
let stopping = false, exited = false, finished = false;
|
|
1192
|
-
let stdout = "", stderr = "", control = "";
|
|
1193
|
-
const maximumEnvelope = options.maxBuffer * 12 + 1024;
|
|
1194
|
-
let cleanup;
|
|
1195
|
-
const finish = () => {
|
|
1196
|
-
if (finished)
|
|
1197
|
-
return;
|
|
1198
|
-
finished = true;
|
|
1199
|
-
clearTimeout(deadline);
|
|
1200
|
-
clearTimeout(cleanup);
|
|
1201
|
-
options.signal?.removeEventListener("abort", abort);
|
|
1202
|
-
child.stdin?.destroy();
|
|
1203
|
-
child.stdout?.destroy();
|
|
1204
|
-
child.stderr?.destroy();
|
|
1205
|
-
resolve3({ status, error, stdout, stderr });
|
|
1206
|
-
};
|
|
1207
|
-
const stop = (code) => {
|
|
1208
|
-
if (code && !finished)
|
|
1209
|
-
error ??= { code };
|
|
1210
|
-
if (finished || stopping)
|
|
1211
|
-
return;
|
|
1212
|
-
stopping = true;
|
|
1213
|
-
if (exited || !child.pid) {
|
|
1214
|
-
error ??= { code: "ECHILD" };
|
|
1215
|
-
} else {
|
|
1216
|
-
try {
|
|
1217
|
-
child.stdin.end();
|
|
1218
|
-
} catch {
|
|
1219
|
-
error = { code: "ECHILD" };
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
cleanup = setTimeout(() => {
|
|
1223
|
-
error = { code: "ECHILD" };
|
|
1224
|
-
finish();
|
|
1225
|
-
}, 1000);
|
|
1226
|
-
};
|
|
1227
|
-
const abort = () => stop("ABORT_ERR");
|
|
1228
|
-
const deadline = setTimeout(() => stop("ETIMEDOUT"), options.timeout);
|
|
1229
|
-
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1230
|
-
if (options.signal?.aborted)
|
|
1231
|
-
abort();
|
|
1232
|
-
child.stdin?.on("error", () => {
|
|
1233
|
-
if (!stopping)
|
|
1234
|
-
stop("ECHILD");
|
|
1235
|
-
});
|
|
1236
|
-
child.stdout?.on("data", (bytes) => {
|
|
1237
|
-
if (stopping)
|
|
1238
|
-
return;
|
|
1239
|
-
control += bytes.toString("utf8");
|
|
1240
|
-
if (Buffer.byteLength(control) > maximumEnvelope)
|
|
1241
|
-
return stop("ENOBUFS");
|
|
1242
|
-
if (!control.endsWith(`
|
|
1243
|
-
`))
|
|
1244
|
-
return;
|
|
1245
|
-
try {
|
|
1246
|
-
const value = JSON.parse(control);
|
|
1247
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["status", "error", "stdout", "stderr"].includes(key)) || !(value.status === null || Number.isInteger(value.status) && value.status >= 0 && value.status <= 255) || typeof value.stdout !== "string" || typeof value.stderr !== "string" || Buffer.byteLength(value.stdout) > options.maxBuffer || Buffer.byteLength(value.stderr) > options.maxBuffer || value.error !== undefined && (Object.keys(value.error).join() !== "code" || !["ENOBUFS", "ECHILD"].includes(value.error.code))) {
|
|
1248
|
-
return stop("EPROTO");
|
|
1249
|
-
}
|
|
1250
|
-
status = value.status;
|
|
1251
|
-
stdout = value.stdout;
|
|
1252
|
-
stderr = value.stderr;
|
|
1253
|
-
stop(value.error?.code);
|
|
1254
|
-
} catch {
|
|
1255
|
-
stop("EPROTO");
|
|
1256
|
-
}
|
|
1257
|
-
});
|
|
1258
|
-
child.stdout?.on("error", () => stop("EPROTO"));
|
|
1259
|
-
child.stderr?.on("data", () => stop("ECHILD"));
|
|
1260
|
-
child.stderr?.on("error", () => stop("ECHILD"));
|
|
1261
|
-
child.on("error", () => {
|
|
1262
|
-
error = { code: "ECHILD" };
|
|
1263
|
-
stop();
|
|
1264
|
-
});
|
|
1265
|
-
child.on("exit", () => {
|
|
1266
|
-
exited = true;
|
|
1267
|
-
if (!stopping)
|
|
1268
|
-
stop("ECHILD");
|
|
1269
|
-
});
|
|
1270
|
-
child.on("close", () => {
|
|
1271
|
-
if (!stopping)
|
|
1272
|
-
error = { code: "ECHILD" };
|
|
1273
|
-
finish();
|
|
1274
|
-
});
|
|
1275
|
-
});
|
|
1276
|
-
}
|
|
1277
|
-
var supervisor;
|
|
1278
|
-
var init_readiness_process = __esm(() => {
|
|
1279
|
-
supervisor = String.raw`
|
|
1280
|
-
const {spawn}=require("node:child_process");
|
|
1281
|
-
const spec=JSON.parse(process.argv.at(-1));
|
|
1282
|
-
// This runtime was created detached and is its group's live leader. Its
|
|
1283
|
-
// independent timer and parent pipe remain effective if the observer exits
|
|
1284
|
-
// or its event loop stops responding. Only this freshly created tree is hit.
|
|
1285
|
-
const cleanup=()=>{try{process.kill(-process.pid,"SIGKILL")}catch{process.exit(1)}};
|
|
1286
|
-
setTimeout(cleanup,spec.timeout);
|
|
1287
|
-
process.stdin.once("end",cleanup);
|
|
1288
|
-
process.stdin.once("error",cleanup);
|
|
1289
|
-
process.stdin.on("data",cleanup);
|
|
1290
|
-
process.stdin.resume();
|
|
1291
|
-
for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.once(signal,cleanup);
|
|
1292
|
-
let done=false, out=Buffer.alloc(0), err=Buffer.alloc(0);
|
|
1293
|
-
function report(status,error){
|
|
1294
|
-
if(done)return; done=true;
|
|
1295
|
-
process.stdout.write(JSON.stringify({status,error,stdout:out.toString("utf8"),stderr:err.toString("utf8")})+"\n");
|
|
1296
|
-
}
|
|
1297
|
-
const child=spawn(spec.command,spec.args,{stdio:["ignore","pipe","pipe"],env:process.env});
|
|
1298
|
-
for(const [stream,name] of [[child.stdout,"stdout"],[child.stderr,"stderr"]]){
|
|
1299
|
-
stream.on("data",bytes=>{
|
|
1300
|
-
if(done)return;
|
|
1301
|
-
const before=name==="stdout"?out:err;
|
|
1302
|
-
if(before.length+bytes.length>spec.maxBuffer){report(null,{code:"ENOBUFS"});return}
|
|
1303
|
-
if(name==="stdout")out=Buffer.concat([out,bytes]);else err=Buffer.concat([err,bytes]);
|
|
1304
|
-
});
|
|
1305
|
-
stream.on("error",()=>report(null,{code:"ECHILD"}));
|
|
1306
|
-
}
|
|
1307
|
-
child.on("error",()=>report(null,{code:"ECHILD"}));
|
|
1308
|
-
// A successful observation includes complete streams. A nested client that
|
|
1309
|
-
// keeps them open after its parent exits is a failure requiring group cleanup.
|
|
1310
|
-
child.on("exit",()=>setTimeout(()=>report(null,{code:"ECHILD"}),100));
|
|
1311
|
-
child.on("close",(status,signal)=>report(status,signal?{code:"ECHILD"}:undefined));
|
|
1312
|
-
`;
|
|
1313
|
-
});
|
|
1314
|
-
|
|
1315
|
-
// src/native-readiness.ts
|
|
1316
|
-
var exports_native_readiness = {};
|
|
1317
|
-
__export(exports_native_readiness, {
|
|
1318
|
-
trashReadinessBindingPath: () => trashReadinessBindingPath,
|
|
1319
|
-
runTrashReadiness: () => runTrashReadiness,
|
|
1320
|
-
readinessSessionStartOutput: () => readinessSessionStartOutput,
|
|
1321
|
-
readinessContext: () => readinessContext,
|
|
1322
|
-
readTrashReadinessBindingFile: () => readTrashReadinessBindingFile,
|
|
1323
|
-
parseTrashReadinessBinding: () => parseTrashReadinessBinding,
|
|
1324
|
-
isReadinessProvider: () => isReadinessProvider,
|
|
1325
|
-
handleReadinessSessionStart: () => handleReadinessSessionStart,
|
|
1326
|
-
checkTrashReadinessBinding: () => checkTrashReadinessBinding
|
|
1327
|
-
});
|
|
1328
|
-
import { createHash as createHash2 } from "crypto";
|
|
1329
|
-
import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readSync, realpathSync as realpathSync3 } from "fs";
|
|
1330
|
-
import { homedir as homedir3 } from "os";
|
|
1331
|
-
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join6, relative, resolve as resolve3 } from "path";
|
|
1332
|
-
function need(value, code) {
|
|
1333
|
-
if (!value)
|
|
1334
|
-
throw new ReadinessError(code);
|
|
1335
|
-
}
|
|
1336
|
-
function isReadinessProvider(value) {
|
|
1337
|
-
return value === "claude" || value === "codex" || value === "sumi";
|
|
1338
|
-
}
|
|
1339
|
-
function inside(home, path) {
|
|
1340
|
-
const part = relative(home, path);
|
|
1341
|
-
return part !== "" && part !== ".." && !part.startsWith("../") && !isAbsolute3(part);
|
|
1342
|
-
}
|
|
1343
|
-
function parseTrashReadinessBinding(input, provider, home) {
|
|
1344
|
-
const b = input;
|
|
1345
|
-
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
1346
|
-
need(keys(b, ["schema", "provider", "home", "runtime", "trash", "plan", "timeoutMs"]) && b.schema === "hasna.hooks.trash-readiness/v1" && b.provider === provider && b.home === home && pin(b.runtime) && pin(b.plan) && keys(b.trash, ["root", "version", "cliSha256"]) && absolute(b.trash.root) && typeof b.trash.version === "string" && b.trash.version.length <= 128 && /^\d+\.\d+\.\d+$/.test(b.trash.version) && digest(b.trash.cliSha256) && Number.isInteger(b.timeoutMs) && b.timeoutMs >= 1000 && b.timeoutMs <= 15000 && inside(home, b.plan.path) && inside(home, b.trash.root), "binding_invalid");
|
|
1347
|
-
return structuredClone(b);
|
|
1348
|
-
}
|
|
1349
|
-
function trashReadinessBindingPath(provider, home = homedir3()) {
|
|
1350
|
-
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
1351
|
-
return join6(home, ".hasna", "hooks", "native", "trash-readiness", provider + ".json");
|
|
1352
|
-
}
|
|
1353
|
-
function readSafe(path, maximum, executable = false) {
|
|
1354
|
-
need(absolute(path) && realpathSync3(path) === path, "unsafe_path");
|
|
1355
|
-
for (let parent = dirname4(path);; parent = dirname4(parent)) {
|
|
1356
|
-
const stat = lstatSync2(parent);
|
|
1357
|
-
need(stat.isDirectory() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18), "unsafe_path");
|
|
1358
|
-
if (parent === dirname4(parent))
|
|
1359
|
-
break;
|
|
1360
|
-
}
|
|
1361
|
-
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
1362
|
-
try {
|
|
1363
|
-
const stat = fstatSync2(fd);
|
|
1364
|
-
need(stat.isFile() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18) && stat.size <= maximum && (!executable || !!(stat.mode & 64)), "unsafe_path");
|
|
1365
|
-
const bytes = Buffer.alloc(stat.size + 1);
|
|
1366
|
-
let length = 0;
|
|
1367
|
-
while (length < bytes.length) {
|
|
1368
|
-
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
1369
|
-
if (!count)
|
|
1370
|
-
break;
|
|
1371
|
-
length += count;
|
|
1372
|
-
}
|
|
1373
|
-
const after = fstatSync2(fd);
|
|
1374
|
-
need(length === stat.size && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs, "file_changed");
|
|
1375
|
-
return bytes.subarray(0, length);
|
|
1376
|
-
} finally {
|
|
1377
|
-
closeSync2(fd);
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
function readTrashReadinessBindingFile(path, provider, home = realpathSync3(homedir3())) {
|
|
1381
|
-
const raw = readSafe(path, 65536);
|
|
1382
|
-
return { binding: parseTrashReadinessBinding(JSON.parse(raw.toString("utf8")), provider, home), raw, sha256: sha(raw) };
|
|
1383
|
-
}
|
|
1384
|
-
async function runTrashReadiness(provider, d = {}) {
|
|
1385
|
-
const started = performance.now();
|
|
1386
|
-
const result = (code, extra = {}) => ({
|
|
1387
|
-
schema: "hasna.hooks.trash-readiness-result/v1",
|
|
1388
|
-
provider,
|
|
1389
|
-
ready: code === "ready",
|
|
1390
|
-
code,
|
|
1391
|
-
nativeAdoptionVerified: false,
|
|
1392
|
-
...extra
|
|
1393
|
-
});
|
|
1394
|
-
let phase = "binding";
|
|
1395
|
-
try {
|
|
1396
|
-
const home = d.home ?? realpathSync3(homedir3()), read = d.read ?? readSafe;
|
|
1397
|
-
need(isReadinessProvider(provider), "binding_invalid");
|
|
1398
|
-
const bindingPath = trashReadinessBindingPath(provider, home), bindingBytes = read(bindingPath, 65536);
|
|
1399
|
-
const b = parseTrashReadinessBinding(JSON.parse(bindingBytes.toString("utf8")), provider, home);
|
|
1400
|
-
phase = "consumer";
|
|
1401
|
-
const checkPin = (p, maximum, executable = false) => {
|
|
1402
|
-
const bytes = read(p.path, maximum, executable);
|
|
1403
|
-
need(sha(bytes) === p.sha256, "file_changed");
|
|
1404
|
-
return bytes;
|
|
1405
|
-
};
|
|
1406
|
-
checkPin(b.runtime, 200000000, true);
|
|
1407
|
-
const manifestPath = join6(b.trash.root, "package.json"), manifestBytes = read(manifestPath, 65536);
|
|
1408
|
-
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
1409
|
-
need(manifest.name === "@hasna/trash" && manifest.version === b.trash.version && manifest.bin?.trash === "dist/cli/index.js", "package_mismatch");
|
|
1410
|
-
const cli = join6(b.trash.root, "dist", "cli", "index.js");
|
|
1411
|
-
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
1412
|
-
const plan = JSON.parse(checkPin(b.plan, 65536).toString("utf8"));
|
|
1413
|
-
need(plan?.schema === "hasna.trash.station-setup-plan/v1" && plan.stage === "ready" && plan.configurationReady === true && plan.nativeAdoptionVerified === false && digest(plan.planDigest) && plan.spec?.provider === provider && plan.spec.home === home && typeof plan.spec.station === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(plan.spec.station) && plan.spec.runtime?.path === b.runtime.path && plan.spec.runtime?.sha256 === b.runtime.sha256 && absolute(plan.spec.cwd) && (plan.spec.cwd === home || inside(home, plan.spec.cwd)) && typeof plan.spec.path === "string" && plan.spec.path.length <= 8192 && plan.spec.path.split(":").every(absolute), "plan_invalid");
|
|
1414
|
-
const args = ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", cli, "station", "check", "--plan", b.plan.path];
|
|
1415
|
-
const remaining = () => Math.floor(b.timeoutMs - (performance.now() - started));
|
|
1416
|
-
const timeout = remaining();
|
|
1417
|
-
need(timeout > 0, "deadline_exceeded");
|
|
1418
|
-
const child = await (d.spawn ?? runReadinessProcess)(b.runtime.path, args, {
|
|
1419
|
-
cwd: home,
|
|
1420
|
-
env: { HOME: home, PATH: plan.spec.path, LANG: "C", NO_COLOR: "1" },
|
|
1421
|
-
timeout,
|
|
1422
|
-
maxBuffer: 16384,
|
|
1423
|
-
signal: d.signal
|
|
1424
|
-
});
|
|
1425
|
-
need(remaining() > 0, "deadline_exceeded");
|
|
1426
|
-
if (child.error && "code" in child.error && child.error.code === "ETIMEDOUT")
|
|
1427
|
-
return result("deadline_exceeded");
|
|
1428
|
-
if (child.error || child.signal || child.status === null)
|
|
1429
|
-
return result("execution_failed");
|
|
1430
|
-
need(typeof child.stdout === "string" && typeof child.stderr === "string" && Buffer.byteLength(child.stdout) <= 16384 && Buffer.byteLength(child.stderr) <= 16384, "output_invalid");
|
|
1431
|
-
let value;
|
|
1432
|
-
try {
|
|
1433
|
-
value = JSON.parse(child.stdout);
|
|
1434
|
-
} catch {
|
|
1435
|
-
return result("output_invalid");
|
|
1436
|
-
}
|
|
1437
|
-
if (child.status !== 0 || value?.configurationReady !== true) {
|
|
1438
|
-
return result(safeFailureCodes.has(value?.code) ? value.code : "check_failed");
|
|
1439
|
-
}
|
|
1440
|
-
need(value.schema === "hasna.trash.station-setup-result/v1" && value.station === plan.spec.station && value.provider === provider && value.planDigest === plan.planDigest && value.serviceReady === true && value.guardVerified === true && value.managedInstructionsVerified === true && value.mcpConfigurationVerified === true && value.mcpExecutionVerified === false && value.nativeAdoptionVerified === false && child.stderr.length === 0, "output_invalid");
|
|
1441
|
-
checkPin(b.plan, 65536);
|
|
1442
|
-
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
1443
|
-
checkPin(b.runtime, 200000000, true);
|
|
1444
|
-
need(read(bindingPath, 65536).equals(bindingBytes) && read(manifestPath, 65536).equals(manifestBytes), "file_changed");
|
|
1445
|
-
need(remaining() > 0, "deadline_exceeded");
|
|
1446
|
-
return result("ready", { station: value.station, planDigest: value.planDigest });
|
|
1447
|
-
} catch (error) {
|
|
1448
|
-
return result(error instanceof ReadinessError ? error.code : phase === "binding" && error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
function checkTrashReadinessBinding(input) {
|
|
1452
|
-
const home = realpathSync3(homedir3()), b = parseTrashReadinessBinding(input, input?.provider, home);
|
|
1453
|
-
const path = trashReadinessBindingPath(b.provider, home), raw = Buffer.from(JSON.stringify(b) + `
|
|
1454
|
-
`);
|
|
1455
|
-
return runTrashReadiness(b.provider, { home, read: (selected, maximum, executable) => selected === path ? raw : readSafe(selected, maximum, executable) });
|
|
1456
|
-
}
|
|
1457
|
-
function readinessContext(result) {
|
|
1458
|
-
return result.ready ? `Trash readiness verified for ${result.station} (${result.provider}). Use Trash for recoverable file removal.` : `Trash readiness needs attention (${result.provider}: ${result.code}). Preserve files; do not fall back to direct deletion. Refresh the station setup before removal.`;
|
|
1459
|
-
}
|
|
1460
|
-
function readinessSessionStartOutput(result) {
|
|
1461
|
-
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: readinessContext(result) } };
|
|
1462
|
-
}
|
|
1463
|
-
async function handleReadinessSessionStart(provider, event, dependencies = {}) {
|
|
1464
|
-
const input = event;
|
|
1465
|
-
if (!object(input) || input.hook_event_name !== "SessionStart" || !["startup", "resume", "clear", "fork", "compact"].includes(input.source)) {
|
|
1466
|
-
return readinessSessionStartOutput({ schema: "hasna.hooks.trash-readiness-result/v1", provider, ready: false, code: "input_invalid", nativeAdoptionVerified: false });
|
|
1467
|
-
}
|
|
1468
|
-
if (input.source === "compact")
|
|
1469
|
-
return {};
|
|
1470
|
-
return readinessSessionStartOutput(await runTrashReadiness(provider, dependencies));
|
|
1471
|
-
}
|
|
1472
|
-
var ReadinessError, sha = (value) => createHash2("sha256").update(value).digest("hex"), digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v), absolute = (v) => typeof v === "string" && v.length <= 4096 && isAbsolute3(v) && resolve3(v) === v && !/[\0\r\n]/.test(v), object = (v) => v !== null && typeof v === "object" && !Array.isArray(v), keys = (v, expected) => object(v) && Object.keys(v).sort().join() === expected.sort().join(), pin = (v) => keys(v, ["path", "sha256"]) && absolute(v.path) && digest(v.sha256), safeFailureCodes;
|
|
1473
|
-
var init_native_readiness = __esm(() => {
|
|
1474
|
-
init_readiness_process();
|
|
1475
|
-
ReadinessError = class ReadinessError extends Error {
|
|
1476
|
-
code;
|
|
1477
|
-
constructor(code) {
|
|
1478
|
-
super(code);
|
|
1479
|
-
this.code = code;
|
|
1480
|
-
}
|
|
1481
|
-
};
|
|
1482
|
-
safeFailureCodes = new Set([
|
|
1483
|
-
"unsupported_platform",
|
|
1484
|
-
"station_detection_failed",
|
|
1485
|
-
"station_mismatch",
|
|
1486
|
-
"hosted_unavailable",
|
|
1487
|
-
"authentication_refused",
|
|
1488
|
-
"station_setup_required",
|
|
1489
|
-
"invalid_status",
|
|
1490
|
-
"deadline_exceeded",
|
|
1491
|
-
"retention_mismatch",
|
|
1492
|
-
"managed_instructions_refused",
|
|
1493
|
-
"removal_instruction_missing",
|
|
1494
|
-
"instructions_changed",
|
|
1495
|
-
"native_config_changed",
|
|
1496
|
-
"guard_refused",
|
|
1497
|
-
"owner_refused"
|
|
1498
|
-
]);
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
1505
|
// src/lib/native-readiness-command.ts
|
|
1502
1506
|
import { createHash as createHash3 } from "crypto";
|
|
1503
1507
|
import { constants as constants3, closeSync as closeSync3, existsSync as existsSync5, fstatSync as fstatSync3, lstatSync as lstatSync3, openSync as openSync3, readFileSync as readFileSync3, realpathSync as realpathSync4 } from "fs";
|
|
@@ -33965,7 +33969,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
33965
33969
|
name,
|
|
33966
33970
|
event: "SessionStart",
|
|
33967
33971
|
matcher: "^(startup|resume|clear|fork)$",
|
|
33968
|
-
timeoutSeconds:
|
|
33972
|
+
timeoutSeconds: TRASH_READINESS_NATIVE_TIMEOUT_SECONDS,
|
|
33969
33973
|
displayName: "Trash Readiness",
|
|
33970
33974
|
description: "Read-only native startup readiness",
|
|
33971
33975
|
version: "0.1.0",
|
|
@@ -34351,6 +34355,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
|
|
|
34351
34355
|
var __dirname2, HOOKS_DIR, EVENT_MAP, settingsPreimages, getInstalledHooks, JSON_SETTINGS_TARGETS;
|
|
34352
34356
|
var init_installer = __esm(() => {
|
|
34353
34357
|
init_registry();
|
|
34358
|
+
init_native_readiness();
|
|
34354
34359
|
init_resolve();
|
|
34355
34360
|
init_manifest();
|
|
34356
34361
|
init_registration();
|
|
@@ -35177,7 +35182,7 @@ function selectCodexSafety(discovery, configuration, home, cwd, featureEnabled,
|
|
|
35177
35182
|
need8(selected.length !== 0, "guard_missing");
|
|
35178
35183
|
need8(selected.length === 1, "guard_ambiguous");
|
|
35179
35184
|
const hook = selected[0], binding = readiness ? nativeReadinessCommandBinding(hook.command) : nativeSafetyCommandBinding(hook.command);
|
|
35180
|
-
need8(binding.home === home && (!readiness || binding?.provider === "codex") && hook.eventName === (readiness ? "sessionStart" : "preToolUse") && hook.handlerType === "command" && hook.matcher === (readiness ? "^(startup|resume|clear|fork)$" : matcher) && hook.timeoutSec === (readiness ?
|
|
35185
|
+
need8(binding.home === home && (!readiness || binding?.provider === "codex") && hook.eventName === (readiness ? "sessionStart" : "preToolUse") && hook.handlerType === "command" && hook.matcher === (readiness ? "^(startup|resume|clear|fork)$" : matcher) && hook.timeoutSec === (readiness ? TRASH_READINESS_NATIVE_TIMEOUT_SECONDS : 10) && hook.async === false && hook.statusMessage === null && hook.additionalContextLimit === null && typeof hook.key === "string" && hook.key.length <= 4096 && typeof hook.sourcePath === "string" && isAbsolute18(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");
|
|
35181
35186
|
need8(typeof hook.enabled === "boolean" && ["trusted", "untrusted", "modified"].includes(hook.trustStatus), "guard_definition_invalid");
|
|
35182
35187
|
if (requireReady) {
|
|
35183
35188
|
need8(hook.enabled === true, "guard_disabled");
|
|
@@ -35248,7 +35253,8 @@ var init_codex_safety_check = __esm(() => {
|
|
|
35248
35253
|
init_native_safety();
|
|
35249
35254
|
init_native_readiness_command();
|
|
35250
35255
|
init_native_safety2();
|
|
35251
|
-
|
|
35256
|
+
init_native_readiness();
|
|
35257
|
+
versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.154.0-alpha.6.1", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1", "codex-cli 0.157.0", "codex-cli 0.157.1"]);
|
|
35252
35258
|
CodexSafetyError = class CodexSafetyError extends Error {
|
|
35253
35259
|
code;
|
|
35254
35260
|
constructor(code2) {
|