@hasna/hooks 0.10.8 → 0.11.0
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 +48 -0
- package/bin/hooks-mcp.js +352 -182
- package/bin/index.js +2314 -1607
- package/bin/native-readiness-entry.js +355 -0
- package/bin/native-safety-entry.js +13 -4
- package/dist/index.js +502 -284
- package/dist/lib/codex-safety-check.d.ts +2 -1
- package/dist/lib/codex-safety-trust.d.ts +40 -8
- package/dist/lib/installer.d.ts +11 -0
- package/dist/lib/native-readiness-command.d.ts +14 -0
- package/dist/lib/native-safety-registration.d.ts +38 -7
- package/dist/lib/readiness-process.d.ts +20 -0
- package/dist/native-readiness.d.ts +68 -0
- package/dist/native-readiness.js +328 -0
- package/hooks/hook-workspace-repos-guard/README.md +5 -2
- package/hooks/hook-workspace-repos-guard/src/hook.ts +13 -4
- package/hooks/native-readiness-entry.ts +31 -0
- package/package.json +7 -2
|
@@ -28,10 +28,11 @@ type NativeGuard = {
|
|
|
28
28
|
};
|
|
29
29
|
/** Select only the exact packaged composite definition in native discovery.
|
|
30
30
|
* A name, a settings file, or a trusted hash by itself is not enough. */
|
|
31
|
-
export declare function selectCodexSafety(discovery: unknown, configuration: unknown, home: string, cwd: string, featureEnabled?: boolean, requireReady?: boolean): NativeGuard;
|
|
31
|
+
export declare function selectCodexSafety(discovery: unknown, configuration: unknown, home: string, cwd: string, featureEnabled?: boolean, requireReady?: boolean, readiness?: boolean): NativeGuard;
|
|
32
32
|
export declare function readCodexSafety(rpc: CodexSafetyRpc, home: string, cwd: string, options?: {
|
|
33
33
|
includeLayers?: boolean;
|
|
34
34
|
requireReady?: boolean;
|
|
35
|
+
readiness?: boolean;
|
|
35
36
|
}): Promise<{
|
|
36
37
|
guard: NativeGuard;
|
|
37
38
|
discovery: unknown;
|
|
@@ -9,9 +9,8 @@ interface Dependencies {
|
|
|
9
9
|
connect?: typeof connectCodexSafety;
|
|
10
10
|
verify?: typeof verifyNativeSafetyCommand;
|
|
11
11
|
}
|
|
12
|
-
type Options = CodexSafetyOptions;
|
|
13
12
|
export interface CodexTrustPlan {
|
|
14
|
-
schema: "hasna.hooks.codex-safety-trust/v1";
|
|
13
|
+
schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
|
|
15
14
|
planDigest: string;
|
|
16
15
|
nativeVersion: string;
|
|
17
16
|
cwd: string;
|
|
@@ -20,24 +19,56 @@ export interface CodexTrustPlan {
|
|
|
20
19
|
configPath: string;
|
|
21
20
|
configVersion: string;
|
|
22
21
|
action: "unchanged" | "trust_and_enable";
|
|
23
|
-
guardVerified:
|
|
22
|
+
guardVerified: boolean;
|
|
23
|
+
readinessRunnerVerified?: true;
|
|
24
24
|
nativeAdoptionVerified: false;
|
|
25
25
|
}
|
|
26
|
-
export declare
|
|
27
|
-
export declare
|
|
26
|
+
export declare const planCodexSafetyTrust: (options?: CodexSafetyOptions, dependencies?: Dependencies) => Promise<CodexTrustPlan>;
|
|
27
|
+
export declare const applyCodexSafetyTrust: (options: CodexSafetyOptions & {
|
|
28
28
|
expectedPlanDigest: string;
|
|
29
|
-
}, dependencies?: Dependencies)
|
|
29
|
+
}, dependencies?: Dependencies) => Promise<{
|
|
30
|
+
nativeDiscoveryVerified: boolean;
|
|
31
|
+
nativeAdoptionVerified: boolean;
|
|
32
|
+
unrelatedConfigurationPreserved: boolean;
|
|
33
|
+
readinessRunnerVerified?: boolean | undefined;
|
|
30
34
|
ok: boolean;
|
|
31
35
|
changed: boolean;
|
|
32
36
|
operationId: string;
|
|
33
37
|
planDigest: string;
|
|
34
38
|
configVersion: any;
|
|
35
39
|
guardVerified: boolean;
|
|
40
|
+
} | {
|
|
41
|
+
schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
|
|
42
|
+
planDigest: string;
|
|
43
|
+
nativeVersion: string;
|
|
44
|
+
cwd: string;
|
|
45
|
+
key: string;
|
|
46
|
+
currentHash: string;
|
|
47
|
+
configPath: string;
|
|
48
|
+
configVersion: string;
|
|
49
|
+
action: "unchanged" | "trust_and_enable";
|
|
50
|
+
guardVerified: boolean;
|
|
51
|
+
readinessRunnerVerified?: true;
|
|
52
|
+
nativeAdoptionVerified: false;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
changed: boolean;
|
|
55
|
+
}>;
|
|
56
|
+
export declare const planCodexReadinessTrust: (options?: CodexSafetyOptions, dependencies?: Dependencies) => Promise<CodexTrustPlan>;
|
|
57
|
+
export declare const applyCodexReadinessTrust: (options: CodexSafetyOptions & {
|
|
58
|
+
expectedPlanDigest: string;
|
|
59
|
+
}, dependencies?: Dependencies) => Promise<{
|
|
36
60
|
nativeDiscoveryVerified: boolean;
|
|
37
61
|
nativeAdoptionVerified: boolean;
|
|
38
62
|
unrelatedConfigurationPreserved: boolean;
|
|
63
|
+
readinessRunnerVerified?: boolean | undefined;
|
|
64
|
+
ok: boolean;
|
|
65
|
+
changed: boolean;
|
|
66
|
+
operationId: string;
|
|
67
|
+
planDigest: string;
|
|
68
|
+
configVersion: any;
|
|
69
|
+
guardVerified: boolean;
|
|
39
70
|
} | {
|
|
40
|
-
schema: "hasna.hooks.codex-safety-trust/v1";
|
|
71
|
+
schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
|
|
41
72
|
planDigest: string;
|
|
42
73
|
nativeVersion: string;
|
|
43
74
|
cwd: string;
|
|
@@ -46,7 +77,8 @@ export declare function applyCodexSafetyTrust(options: Options & {
|
|
|
46
77
|
configPath: string;
|
|
47
78
|
configVersion: string;
|
|
48
79
|
action: "unchanged" | "trust_and_enable";
|
|
49
|
-
guardVerified:
|
|
80
|
+
guardVerified: boolean;
|
|
81
|
+
readinessRunnerVerified?: true;
|
|
50
82
|
nativeAdoptionVerified: false;
|
|
51
83
|
ok: boolean;
|
|
52
84
|
changed: boolean;
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -72,6 +72,17 @@ export declare function previewNativeSafetyRegistration(target: "codex" | "claud
|
|
|
72
72
|
after: string;
|
|
73
73
|
command: string;
|
|
74
74
|
};
|
|
75
|
+
/** Fixed bundled startup observer, rendered through the same native writer as
|
|
76
|
+
* safety hooks. The ready-plan binding is installed after native trust, since
|
|
77
|
+
* trusting Codex changes the configuration that the ready plan must capture. */
|
|
78
|
+
export declare function previewNativeReadinessRegistration(target: "codex" | "claude"): {
|
|
79
|
+
before: string | null;
|
|
80
|
+
after: string;
|
|
81
|
+
command: string;
|
|
82
|
+
};
|
|
83
|
+
export declare function installNativeReadinessRegistration(target: "codex" | "claude", expectedSettingsSha256: string): {
|
|
84
|
+
success: boolean;
|
|
85
|
+
};
|
|
75
86
|
export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
|
|
76
87
|
export declare function getRegisteredHooksForTarget(scope?: Scope, target?: SingleTarget): string[];
|
|
77
88
|
export declare function getRegisteredHooks(scope?: Scope): string[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReadinessProvider } from "../native-readiness";
|
|
2
|
+
interface CommandBinding {
|
|
3
|
+
provider: ReadinessProvider;
|
|
4
|
+
home: string;
|
|
5
|
+
runtime: string;
|
|
6
|
+
runtimeSHA256: string;
|
|
7
|
+
worker: string;
|
|
8
|
+
workerSHA256: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildNativeReadinessCommand(binding: CommandBinding): string;
|
|
11
|
+
export declare function nativeReadinessCommandBinding(command: string): CommandBinding | undefined;
|
|
12
|
+
export declare function nativeReadinessCommandVerified(command: string): boolean;
|
|
13
|
+
export declare function installedNativeReadinessCommand(provider: ReadinessProvider): string;
|
|
14
|
+
export {};
|
|
@@ -8,7 +8,7 @@ export interface NativeRegistrationOptions {
|
|
|
8
8
|
target: "codex" | "claude";
|
|
9
9
|
}
|
|
10
10
|
export interface NativeRegistrationPlan {
|
|
11
|
-
schema: "hasna.hooks.native-safety-registration/v1";
|
|
11
|
+
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
12
12
|
target: "codex" | "claude";
|
|
13
13
|
settingsPath: string;
|
|
14
14
|
resolvedPath: string;
|
|
@@ -17,25 +17,55 @@ export interface NativeRegistrationPlan {
|
|
|
17
17
|
commandSHA256: string;
|
|
18
18
|
action: "unchanged" | "register";
|
|
19
19
|
planDigest: string;
|
|
20
|
-
guardVerified:
|
|
20
|
+
guardVerified: boolean;
|
|
21
|
+
readinessRunnerVerified?: true;
|
|
21
22
|
nativeAdoptionVerified: false;
|
|
22
23
|
}
|
|
23
24
|
interface Dependencies {
|
|
24
25
|
verify?: typeof verifyNativeSafetyCommand;
|
|
25
26
|
}
|
|
26
|
-
export declare
|
|
27
|
-
export declare
|
|
27
|
+
export declare const planNativeSafetyRegistration: (options: NativeRegistrationOptions, dependencies?: Dependencies) => Promise<NativeRegistrationPlan>;
|
|
28
|
+
export declare const applyNativeSafetyRegistration: (options: NativeRegistrationOptions & {
|
|
28
29
|
expectedPlanDigest: string;
|
|
29
|
-
}, dependencies?: Dependencies)
|
|
30
|
+
}, dependencies?: Dependencies) => Promise<{
|
|
31
|
+
nativeAdoptionVerified: boolean;
|
|
32
|
+
readinessRunnerVerified?: boolean | undefined;
|
|
30
33
|
ok: boolean;
|
|
31
34
|
changed: boolean;
|
|
32
35
|
operationId: `${string}-${string}-${string}-${string}-${string}`;
|
|
33
36
|
planDigest: string;
|
|
34
37
|
settingsSHA256: string;
|
|
35
38
|
guardVerified: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
41
|
+
target: "codex" | "claude";
|
|
42
|
+
settingsPath: string;
|
|
43
|
+
resolvedPath: string;
|
|
44
|
+
beforeSHA256: string;
|
|
45
|
+
desiredSHA256: string;
|
|
46
|
+
commandSHA256: string;
|
|
47
|
+
action: "unchanged" | "register";
|
|
48
|
+
planDigest: string;
|
|
49
|
+
guardVerified: boolean;
|
|
50
|
+
readinessRunnerVerified?: true;
|
|
51
|
+
nativeAdoptionVerified: false;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
changed: boolean;
|
|
54
|
+
}>;
|
|
55
|
+
export declare const planNativeReadinessRegistration: (options: NativeRegistrationOptions) => Promise<NativeRegistrationPlan>;
|
|
56
|
+
export declare const applyNativeReadinessRegistration: (options: NativeRegistrationOptions & {
|
|
57
|
+
expectedPlanDigest: string;
|
|
58
|
+
}) => Promise<{
|
|
36
59
|
nativeAdoptionVerified: boolean;
|
|
60
|
+
readinessRunnerVerified?: boolean | undefined;
|
|
61
|
+
ok: boolean;
|
|
62
|
+
changed: boolean;
|
|
63
|
+
operationId: `${string}-${string}-${string}-${string}-${string}`;
|
|
64
|
+
planDigest: string;
|
|
65
|
+
settingsSHA256: string;
|
|
66
|
+
guardVerified: boolean;
|
|
37
67
|
} | {
|
|
38
|
-
schema: "hasna.hooks.native-safety-registration/v1";
|
|
68
|
+
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
39
69
|
target: "codex" | "claude";
|
|
40
70
|
settingsPath: string;
|
|
41
71
|
resolvedPath: string;
|
|
@@ -44,7 +74,8 @@ export declare function applyNativeSafetyRegistration(options: NativeRegistratio
|
|
|
44
74
|
commandSHA256: string;
|
|
45
75
|
action: "unchanged" | "register";
|
|
46
76
|
planDigest: string;
|
|
47
|
-
guardVerified:
|
|
77
|
+
guardVerified: boolean;
|
|
78
|
+
readinessRunnerVerified?: true;
|
|
48
79
|
nativeAdoptionVerified: false;
|
|
49
80
|
ok: boolean;
|
|
50
81
|
changed: boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ReadinessChildResult {
|
|
2
|
+
status: number | null;
|
|
3
|
+
signal?: string | null;
|
|
4
|
+
error?: {
|
|
5
|
+
code?: string;
|
|
6
|
+
};
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ReadinessProcessOptions {
|
|
11
|
+
cwd: string;
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
timeout: number;
|
|
14
|
+
maxBuffer: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
/** Bound the whole freshly spawned read-only check tree, including nested
|
|
18
|
+
* Instructions/Hooks clients. Async execution keeps a shared harness responsive.
|
|
19
|
+
* Even a successful check is cleaned up before its result is returned. */
|
|
20
|
+
export declare function runReadinessProcess(command: string, args: string[], options: ReadinessProcessOptions): Promise<ReadinessChildResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ReadinessChildResult, type ReadinessProcessOptions } from "./lib/readiness-process";
|
|
2
|
+
export type ReadinessProvider = "claude" | "codex" | "sumi";
|
|
3
|
+
type Pin = {
|
|
4
|
+
path: string;
|
|
5
|
+
sha256: string;
|
|
6
|
+
};
|
|
7
|
+
export interface TrashReadinessBinding {
|
|
8
|
+
schema: "hasna.hooks.trash-readiness/v1";
|
|
9
|
+
provider: ReadinessProvider;
|
|
10
|
+
home: string;
|
|
11
|
+
runtime: Pin;
|
|
12
|
+
trash: {
|
|
13
|
+
root: string;
|
|
14
|
+
version: string;
|
|
15
|
+
cliSha256: string;
|
|
16
|
+
};
|
|
17
|
+
plan: Pin;
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
}
|
|
20
|
+
export type ReadinessCode = "ready" | "binding_missing" | "binding_invalid" | "unsafe_path" | "file_changed" | "package_mismatch" | "plan_invalid" | "deadline_exceeded" | "execution_failed" | "output_invalid" | "unsupported_platform" | "station_detection_failed" | "station_mismatch" | "retention_mismatch" | "hosted_unavailable" | "authentication_refused" | "station_setup_required" | "invalid_status" | "managed_instructions_refused" | "removal_instruction_missing" | "native_config_changed" | "instructions_changed" | "guard_refused" | "owner_refused" | "check_failed" | "input_invalid";
|
|
21
|
+
export interface TrashReadinessResult {
|
|
22
|
+
schema: "hasna.hooks.trash-readiness-result/v1";
|
|
23
|
+
provider: ReadinessProvider;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
code: ReadinessCode;
|
|
26
|
+
station?: string;
|
|
27
|
+
planDigest?: string;
|
|
28
|
+
nativeAdoptionVerified: false;
|
|
29
|
+
}
|
|
30
|
+
export declare function isReadinessProvider(value: unknown): value is ReadinessProvider;
|
|
31
|
+
export declare function parseTrashReadinessBinding(input: unknown, provider: ReadinessProvider, home: string): TrashReadinessBinding;
|
|
32
|
+
export declare function trashReadinessBindingPath(provider: ReadinessProvider, home?: string): string;
|
|
33
|
+
/** Opening a pipe must never delay the startup check. Read canonical regular
|
|
34
|
+
* files only, with safe owner/root ancestry and bounded bytes. */
|
|
35
|
+
declare function readSafe(path: string, maximum: number, executable?: boolean): Buffer;
|
|
36
|
+
/** Fixed-schema operator input; never a credential or a free-form command. */
|
|
37
|
+
export declare function readTrashReadinessBindingFile(path: string, provider: ReadinessProvider, home?: string): {
|
|
38
|
+
binding: TrashReadinessBinding;
|
|
39
|
+
raw: Buffer<ArrayBufferLike>;
|
|
40
|
+
sha256: string;
|
|
41
|
+
};
|
|
42
|
+
interface Dependencies {
|
|
43
|
+
home?: string;
|
|
44
|
+
read?: typeof readSafe;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
spawn?: (runtime: string, args: string[], options: ReadinessProcessOptions) => ReadinessChildResult | Promise<ReadinessChildResult>;
|
|
47
|
+
}
|
|
48
|
+
/** Executes only the selected installed Trash CLI's read-only check. Never
|
|
49
|
+
* forwards either stream: provider bodies and unrelated diagnostics stay out
|
|
50
|
+
* of the native prompt. No retry, repair, binding write or removal occurs. */
|
|
51
|
+
export declare function runTrashReadiness(provider: ReadinessProvider, d?: Dependencies): Promise<TrashReadinessResult>;
|
|
52
|
+
/** Check a proposed binding without installing it. This shares the exact
|
|
53
|
+
* runtime check; only the not-yet-written fixed binding file is supplied from
|
|
54
|
+
* the captured input. Every artifact, plan and service read remains real. */
|
|
55
|
+
export declare function checkTrashReadinessBinding(input: TrashReadinessBinding): Promise<TrashReadinessResult>;
|
|
56
|
+
/** SessionStart is observational, not a removal authorization. A failed check
|
|
57
|
+
* stays an explicit attention diagnostic; the removal guard remains separate. */
|
|
58
|
+
export declare function readinessContext(result: TrashReadinessResult): string;
|
|
59
|
+
export declare function readinessSessionStartOutput(result: TrashReadinessResult): {
|
|
60
|
+
hookSpecificOutput: {
|
|
61
|
+
hookEventName: string;
|
|
62
|
+
additionalContext: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
/** Session payloads are untrusted data. Read only the documented event/source;
|
|
66
|
+
* never follow transcript paths, forward prompt text or run on tool events. */
|
|
67
|
+
export declare function handleReadinessSessionStart(provider: ReadinessProvider, event: unknown, dependencies?: Dependencies): Promise<{}>;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/native-readiness.ts
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
|
|
5
|
+
// src/lib/readiness-process.ts
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
var supervisor = String.raw`
|
|
8
|
+
const {spawn}=require("node:child_process");
|
|
9
|
+
const spec=JSON.parse(process.argv.at(-1));
|
|
10
|
+
// This runtime was created detached and is its group's live leader. Its
|
|
11
|
+
// independent timer and parent pipe remain effective if the observer exits
|
|
12
|
+
// or its event loop stops responding. Only this freshly created tree is hit.
|
|
13
|
+
const cleanup=()=>{try{process.kill(-process.pid,"SIGKILL")}catch{process.exit(1)}};
|
|
14
|
+
setTimeout(cleanup,spec.timeout);
|
|
15
|
+
process.stdin.once("end",cleanup);
|
|
16
|
+
process.stdin.once("error",cleanup);
|
|
17
|
+
process.stdin.on("data",cleanup);
|
|
18
|
+
process.stdin.resume();
|
|
19
|
+
for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.once(signal,cleanup);
|
|
20
|
+
let done=false, out=Buffer.alloc(0), err=Buffer.alloc(0);
|
|
21
|
+
function report(status,error){
|
|
22
|
+
if(done)return; done=true;
|
|
23
|
+
process.stdout.write(JSON.stringify({status,error,stdout:out.toString("utf8"),stderr:err.toString("utf8")})+"\n");
|
|
24
|
+
}
|
|
25
|
+
const child=spawn(spec.command,spec.args,{stdio:["ignore","pipe","pipe"],env:process.env});
|
|
26
|
+
for(const [stream,name] of [[child.stdout,"stdout"],[child.stderr,"stderr"]]){
|
|
27
|
+
stream.on("data",bytes=>{
|
|
28
|
+
if(done)return;
|
|
29
|
+
const before=name==="stdout"?out:err;
|
|
30
|
+
if(before.length+bytes.length>spec.maxBuffer){report(null,{code:"ENOBUFS"});return}
|
|
31
|
+
if(name==="stdout")out=Buffer.concat([out,bytes]);else err=Buffer.concat([err,bytes]);
|
|
32
|
+
});
|
|
33
|
+
stream.on("error",()=>report(null,{code:"ECHILD"}));
|
|
34
|
+
}
|
|
35
|
+
child.on("error",()=>report(null,{code:"ECHILD"}));
|
|
36
|
+
// A successful observation includes complete streams. A nested client that
|
|
37
|
+
// keeps them open after its parent exits is a failure requiring group cleanup.
|
|
38
|
+
child.on("exit",()=>setTimeout(()=>report(null,{code:"ECHILD"}),100));
|
|
39
|
+
child.on("close",(status,signal)=>report(status,signal?{code:"ECHILD"}:undefined));
|
|
40
|
+
`;
|
|
41
|
+
function runReadinessProcess(command, args, options) {
|
|
42
|
+
if (!["darwin", "linux"].includes(process.platform)) {
|
|
43
|
+
return Promise.resolve({ status: null, error: { code: "ENOTSUP" }, stdout: "", stderr: "" });
|
|
44
|
+
}
|
|
45
|
+
if (options.signal?.aborted) {
|
|
46
|
+
return Promise.resolve({ status: null, error: { code: "ABORT_ERR" }, stdout: "", stderr: "" });
|
|
47
|
+
}
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
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 })], {
|
|
50
|
+
cwd: options.cwd,
|
|
51
|
+
env: options.env,
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
54
|
+
});
|
|
55
|
+
let status = null, error;
|
|
56
|
+
let stopping = false, exited = false, finished = false;
|
|
57
|
+
let stdout = "", stderr = "", control = "";
|
|
58
|
+
const maximumEnvelope = options.maxBuffer * 12 + 1024;
|
|
59
|
+
let cleanup;
|
|
60
|
+
const finish = () => {
|
|
61
|
+
if (finished)
|
|
62
|
+
return;
|
|
63
|
+
finished = true;
|
|
64
|
+
clearTimeout(deadline);
|
|
65
|
+
clearTimeout(cleanup);
|
|
66
|
+
options.signal?.removeEventListener("abort", abort);
|
|
67
|
+
child.stdin?.destroy();
|
|
68
|
+
child.stdout?.destroy();
|
|
69
|
+
child.stderr?.destroy();
|
|
70
|
+
resolve({ status, error, stdout, stderr });
|
|
71
|
+
};
|
|
72
|
+
const stop = (code) => {
|
|
73
|
+
if (code && !finished)
|
|
74
|
+
error ??= { code };
|
|
75
|
+
if (finished || stopping)
|
|
76
|
+
return;
|
|
77
|
+
stopping = true;
|
|
78
|
+
if (exited || !child.pid) {
|
|
79
|
+
error ??= { code: "ECHILD" };
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
child.stdin.end();
|
|
83
|
+
} catch {
|
|
84
|
+
error = { code: "ECHILD" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
cleanup = setTimeout(() => {
|
|
88
|
+
error = { code: "ECHILD" };
|
|
89
|
+
finish();
|
|
90
|
+
}, 1000);
|
|
91
|
+
};
|
|
92
|
+
const abort = () => stop("ABORT_ERR");
|
|
93
|
+
const deadline = setTimeout(() => stop("ETIMEDOUT"), options.timeout);
|
|
94
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
95
|
+
if (options.signal?.aborted)
|
|
96
|
+
abort();
|
|
97
|
+
child.stdin?.on("error", () => {
|
|
98
|
+
if (!stopping)
|
|
99
|
+
stop("ECHILD");
|
|
100
|
+
});
|
|
101
|
+
child.stdout?.on("data", (bytes) => {
|
|
102
|
+
if (stopping)
|
|
103
|
+
return;
|
|
104
|
+
control += bytes.toString("utf8");
|
|
105
|
+
if (Buffer.byteLength(control) > maximumEnvelope)
|
|
106
|
+
return stop("ENOBUFS");
|
|
107
|
+
if (!control.endsWith(`
|
|
108
|
+
`))
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(control);
|
|
112
|
+
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))) {
|
|
113
|
+
return stop("EPROTO");
|
|
114
|
+
}
|
|
115
|
+
status = value.status;
|
|
116
|
+
stdout = value.stdout;
|
|
117
|
+
stderr = value.stderr;
|
|
118
|
+
stop(value.error?.code);
|
|
119
|
+
} catch {
|
|
120
|
+
stop("EPROTO");
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
child.stdout?.on("error", () => stop("EPROTO"));
|
|
124
|
+
child.stderr?.on("data", () => stop("ECHILD"));
|
|
125
|
+
child.stderr?.on("error", () => stop("ECHILD"));
|
|
126
|
+
child.on("error", () => {
|
|
127
|
+
error = { code: "ECHILD" };
|
|
128
|
+
stop();
|
|
129
|
+
});
|
|
130
|
+
child.on("exit", () => {
|
|
131
|
+
exited = true;
|
|
132
|
+
if (!stopping)
|
|
133
|
+
stop("ECHILD");
|
|
134
|
+
});
|
|
135
|
+
child.on("close", () => {
|
|
136
|
+
if (!stopping)
|
|
137
|
+
error = { code: "ECHILD" };
|
|
138
|
+
finish();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/native-readiness.ts
|
|
144
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "fs";
|
|
145
|
+
import { homedir } from "os";
|
|
146
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
147
|
+
|
|
148
|
+
class ReadinessError extends Error {
|
|
149
|
+
code;
|
|
150
|
+
constructor(code) {
|
|
151
|
+
super(code);
|
|
152
|
+
this.code = code;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function need(value, code) {
|
|
156
|
+
if (!value)
|
|
157
|
+
throw new ReadinessError(code);
|
|
158
|
+
}
|
|
159
|
+
var sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
160
|
+
var digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
|
|
161
|
+
var absolute = (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && resolve(v) === v && !/[\0\r\n]/.test(v);
|
|
162
|
+
var object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
163
|
+
var keys = (v, expected) => object(v) && Object.keys(v).sort().join() === expected.sort().join();
|
|
164
|
+
var pin = (v) => keys(v, ["path", "sha256"]) && absolute(v.path) && digest(v.sha256);
|
|
165
|
+
function isReadinessProvider(value) {
|
|
166
|
+
return value === "claude" || value === "codex" || value === "sumi";
|
|
167
|
+
}
|
|
168
|
+
function inside(home, path) {
|
|
169
|
+
const part = relative(home, path);
|
|
170
|
+
return part !== "" && part !== ".." && !part.startsWith("../") && !isAbsolute(part);
|
|
171
|
+
}
|
|
172
|
+
function parseTrashReadinessBinding(input, provider, home) {
|
|
173
|
+
const b = input;
|
|
174
|
+
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
175
|
+
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");
|
|
176
|
+
return structuredClone(b);
|
|
177
|
+
}
|
|
178
|
+
function trashReadinessBindingPath(provider, home = homedir()) {
|
|
179
|
+
need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
|
|
180
|
+
return join(home, ".hasna", "hooks", "native", "trash-readiness", provider + ".json");
|
|
181
|
+
}
|
|
182
|
+
function readSafe(path, maximum, executable = false) {
|
|
183
|
+
need(absolute(path) && realpathSync(path) === path, "unsafe_path");
|
|
184
|
+
for (let parent = dirname(path);; parent = dirname(parent)) {
|
|
185
|
+
const stat = lstatSync(parent);
|
|
186
|
+
need(stat.isDirectory() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18), "unsafe_path");
|
|
187
|
+
if (parent === dirname(parent))
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
191
|
+
try {
|
|
192
|
+
const stat = fstatSync(fd);
|
|
193
|
+
need(stat.isFile() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18) && stat.size <= maximum && (!executable || !!(stat.mode & 64)), "unsafe_path");
|
|
194
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
195
|
+
let length = 0;
|
|
196
|
+
while (length < bytes.length) {
|
|
197
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
198
|
+
if (!count)
|
|
199
|
+
break;
|
|
200
|
+
length += count;
|
|
201
|
+
}
|
|
202
|
+
const after = fstatSync(fd);
|
|
203
|
+
need(length === stat.size && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs, "file_changed");
|
|
204
|
+
return bytes.subarray(0, length);
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function readTrashReadinessBindingFile(path, provider, home = realpathSync(homedir())) {
|
|
210
|
+
const raw = readSafe(path, 65536);
|
|
211
|
+
return { binding: parseTrashReadinessBinding(JSON.parse(raw.toString("utf8")), provider, home), raw, sha256: sha(raw) };
|
|
212
|
+
}
|
|
213
|
+
var safeFailureCodes = new Set([
|
|
214
|
+
"unsupported_platform",
|
|
215
|
+
"station_detection_failed",
|
|
216
|
+
"station_mismatch",
|
|
217
|
+
"hosted_unavailable",
|
|
218
|
+
"authentication_refused",
|
|
219
|
+
"station_setup_required",
|
|
220
|
+
"invalid_status",
|
|
221
|
+
"deadline_exceeded",
|
|
222
|
+
"retention_mismatch",
|
|
223
|
+
"managed_instructions_refused",
|
|
224
|
+
"removal_instruction_missing",
|
|
225
|
+
"instructions_changed",
|
|
226
|
+
"native_config_changed",
|
|
227
|
+
"guard_refused",
|
|
228
|
+
"owner_refused"
|
|
229
|
+
]);
|
|
230
|
+
async function runTrashReadiness(provider, d = {}) {
|
|
231
|
+
const started = performance.now();
|
|
232
|
+
const result = (code, extra = {}) => ({
|
|
233
|
+
schema: "hasna.hooks.trash-readiness-result/v1",
|
|
234
|
+
provider,
|
|
235
|
+
ready: code === "ready",
|
|
236
|
+
code,
|
|
237
|
+
nativeAdoptionVerified: false,
|
|
238
|
+
...extra
|
|
239
|
+
});
|
|
240
|
+
let phase = "binding";
|
|
241
|
+
try {
|
|
242
|
+
const home = d.home ?? realpathSync(homedir()), read = d.read ?? readSafe;
|
|
243
|
+
need(isReadinessProvider(provider), "binding_invalid");
|
|
244
|
+
const bindingPath = trashReadinessBindingPath(provider, home), bindingBytes = read(bindingPath, 65536);
|
|
245
|
+
const b = parseTrashReadinessBinding(JSON.parse(bindingBytes.toString("utf8")), provider, home);
|
|
246
|
+
phase = "consumer";
|
|
247
|
+
const checkPin = (p, maximum, executable = false) => {
|
|
248
|
+
const bytes = read(p.path, maximum, executable);
|
|
249
|
+
need(sha(bytes) === p.sha256, "file_changed");
|
|
250
|
+
return bytes;
|
|
251
|
+
};
|
|
252
|
+
checkPin(b.runtime, 200000000, true);
|
|
253
|
+
const manifestPath = join(b.trash.root, "package.json"), manifestBytes = read(manifestPath, 65536);
|
|
254
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
255
|
+
need(manifest.name === "@hasna/trash" && manifest.version === b.trash.version && manifest.bin?.trash === "dist/cli/index.js", "package_mismatch");
|
|
256
|
+
const cli = join(b.trash.root, "dist", "cli", "index.js");
|
|
257
|
+
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
258
|
+
const plan = JSON.parse(checkPin(b.plan, 65536).toString("utf8"));
|
|
259
|
+
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");
|
|
260
|
+
const args = ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", cli, "station", "check", "--plan", b.plan.path];
|
|
261
|
+
const remaining = () => Math.floor(b.timeoutMs - (performance.now() - started));
|
|
262
|
+
const timeout = remaining();
|
|
263
|
+
need(timeout > 0, "deadline_exceeded");
|
|
264
|
+
const child = await (d.spawn ?? runReadinessProcess)(b.runtime.path, args, {
|
|
265
|
+
cwd: home,
|
|
266
|
+
env: { HOME: home, PATH: plan.spec.path, LANG: "C", NO_COLOR: "1" },
|
|
267
|
+
timeout,
|
|
268
|
+
maxBuffer: 16384,
|
|
269
|
+
signal: d.signal
|
|
270
|
+
});
|
|
271
|
+
need(remaining() > 0, "deadline_exceeded");
|
|
272
|
+
if (child.error && "code" in child.error && child.error.code === "ETIMEDOUT")
|
|
273
|
+
return result("deadline_exceeded");
|
|
274
|
+
if (child.error || child.signal || child.status === null)
|
|
275
|
+
return result("execution_failed");
|
|
276
|
+
need(typeof child.stdout === "string" && typeof child.stderr === "string" && Buffer.byteLength(child.stdout) <= 16384 && Buffer.byteLength(child.stderr) <= 16384, "output_invalid");
|
|
277
|
+
let value;
|
|
278
|
+
try {
|
|
279
|
+
value = JSON.parse(child.stdout);
|
|
280
|
+
} catch {
|
|
281
|
+
return result("output_invalid");
|
|
282
|
+
}
|
|
283
|
+
if (child.status !== 0 || value?.configurationReady !== true) {
|
|
284
|
+
return result(safeFailureCodes.has(value?.code) ? value.code : "check_failed");
|
|
285
|
+
}
|
|
286
|
+
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");
|
|
287
|
+
checkPin(b.plan, 65536);
|
|
288
|
+
checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
|
|
289
|
+
checkPin(b.runtime, 200000000, true);
|
|
290
|
+
need(read(bindingPath, 65536).equals(bindingBytes) && read(manifestPath, 65536).equals(manifestBytes), "file_changed");
|
|
291
|
+
need(remaining() > 0, "deadline_exceeded");
|
|
292
|
+
return result("ready", { station: value.station, planDigest: value.planDigest });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return result(error instanceof ReadinessError ? error.code : phase === "binding" && error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function checkTrashReadinessBinding(input) {
|
|
298
|
+
const home = realpathSync(homedir()), b = parseTrashReadinessBinding(input, input?.provider, home);
|
|
299
|
+
const path = trashReadinessBindingPath(b.provider, home), raw = Buffer.from(JSON.stringify(b) + `
|
|
300
|
+
`);
|
|
301
|
+
return runTrashReadiness(b.provider, { home, read: (selected, maximum, executable) => selected === path ? raw : readSafe(selected, maximum, executable) });
|
|
302
|
+
}
|
|
303
|
+
function readinessContext(result) {
|
|
304
|
+
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.`;
|
|
305
|
+
}
|
|
306
|
+
function readinessSessionStartOutput(result) {
|
|
307
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: readinessContext(result) } };
|
|
308
|
+
}
|
|
309
|
+
async function handleReadinessSessionStart(provider, event, dependencies = {}) {
|
|
310
|
+
const input = event;
|
|
311
|
+
if (!object(input) || input.hook_event_name !== "SessionStart" || !["startup", "resume", "clear", "fork", "compact"].includes(input.source)) {
|
|
312
|
+
return readinessSessionStartOutput({ schema: "hasna.hooks.trash-readiness-result/v1", provider, ready: false, code: "input_invalid", nativeAdoptionVerified: false });
|
|
313
|
+
}
|
|
314
|
+
if (input.source === "compact")
|
|
315
|
+
return {};
|
|
316
|
+
return readinessSessionStartOutput(await runTrashReadiness(provider, dependencies));
|
|
317
|
+
}
|
|
318
|
+
export {
|
|
319
|
+
trashReadinessBindingPath,
|
|
320
|
+
runTrashReadiness,
|
|
321
|
+
readinessSessionStartOutput,
|
|
322
|
+
readinessContext,
|
|
323
|
+
readTrashReadinessBindingFile,
|
|
324
|
+
parseTrashReadinessBinding,
|
|
325
|
+
isReadinessProvider,
|
|
326
|
+
handleReadinessSessionStart,
|
|
327
|
+
checkTrashReadinessBinding
|
|
328
|
+
};
|
|
@@ -51,8 +51,11 @@ word of a later read-only stage (`| head -60`) is never taken as the write
|
|
|
51
51
|
target of an earlier stage. Pipes inside quotes or command substitutions are
|
|
52
52
|
not stage boundaries.
|
|
53
53
|
A protected path named anywhere in a pipeline takes the strongest operation
|
|
54
|
-
of any stage (`find <root>/x | xargs rm -rf` is a delete). Deletes
|
|
55
|
-
`mkdir`/`touch`
|
|
54
|
+
of any stage (`find <root>/x | xargs rm -rf` is a delete). Deletes,
|
|
55
|
+
`mkdir`/`touch`, `tee`, `truncate`, `install`, `mv` and `sed -i` check every
|
|
56
|
+
operand (options end at `--` or at the first operand); other writers check
|
|
57
|
+
their last one, plus any `-t`/`--target-directory` value. A delete tool's
|
|
58
|
+
subcommand word is not an operand (`trash put <path>`, `git rm <path>`).
|
|
56
59
|
An operand or redirect target that cannot be read statically (a variable
|
|
57
60
|
other than `$HOME`, `$( )`, backticks, a glob) fails closed to the directory
|
|
58
61
|
the command runs in, so `rm -rf "$TMPDIR/x"` run from a protected checkout is
|