@hasna/hooks 0.10.6 → 0.10.8
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 +82 -1
- package/bin/hooks-mcp.js +100 -44
- package/bin/index.js +7835 -6947
- package/bin/native-safety-entry.js +196 -15
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1082 -68
- package/dist/lib/claude-safety-check.d.ts +32 -0
- package/dist/lib/codex-safety-check.d.ts +58 -0
- package/dist/lib/codex-safety-trust.d.ts +54 -0
- package/dist/lib/codex-settings.d.ts +5 -1
- package/dist/lib/installer.d.ts +8 -0
- package/dist/lib/native-safety-registration.d.ts +52 -0
- package/dist/lib/native-safety.d.ts +4 -0
- package/dist/native-safety.d.ts +6 -0
- package/dist/native-safety.js +18 -0
- package/hooks/hook-workspace-repos-guard/README.md +12 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +247 -14
- package/package.json +2 -2
- package/scripts/ensure-profiles-dir.mjs +65 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { verifyNativeSafetyCommand } from "../native-safety.js";
|
|
2
|
+
export declare class ClaudeSafetyError extends Error {
|
|
3
|
+
readonly code: "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_failed";
|
|
4
|
+
constructor(code: "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_failed");
|
|
5
|
+
}
|
|
6
|
+
export interface ClaudeSafetyOptions {
|
|
7
|
+
home?: string;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
/** Exact user settings path, for the native CLAUDE_CONFIG_DIR override. */
|
|
10
|
+
userSettingsPath?: string;
|
|
11
|
+
/** An already-resolved main-checkout local file may be outside cwd. */
|
|
12
|
+
localSettingsPath?: string;
|
|
13
|
+
}
|
|
14
|
+
/** This does not claim to reproduce Claude's MDM/server/CLI policy merge.
|
|
15
|
+
* It checks these selected native settings sources and their exact bytes. */
|
|
16
|
+
export declare function verifyClaudeSafetyConfiguration(options?: ClaudeSafetyOptions, dependencies?: {
|
|
17
|
+
verify?: typeof verifyNativeSafetyCommand;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
ok: true;
|
|
20
|
+
target: "claude";
|
|
21
|
+
cwd: string;
|
|
22
|
+
configurationVerified: boolean;
|
|
23
|
+
guardVerified: boolean;
|
|
24
|
+
sources: {
|
|
25
|
+
path: string;
|
|
26
|
+
sha256: string | null;
|
|
27
|
+
}[];
|
|
28
|
+
guardSHA256: string;
|
|
29
|
+
runtimePolicyVerified: boolean;
|
|
30
|
+
nativeDiscoveryVerified: boolean;
|
|
31
|
+
nativeAdoptionVerified: boolean;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { verifyNativeSafetyCommand } from "../native-safety.js";
|
|
2
|
+
export type CodexSafetyCode = "native_unavailable" | "native_unsafe_executable" | "native_executable_required" | "native_unsupported" | "native_failed" | "native_discovery_failed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_disabled" | "guard_untrusted" | "guard_changed" | "guard_failed";
|
|
3
|
+
export declare class CodexSafetyError extends Error {
|
|
4
|
+
readonly code: CodexSafetyCode;
|
|
5
|
+
constructor(code: CodexSafetyCode);
|
|
6
|
+
}
|
|
7
|
+
export interface CodexSafetyRpc {
|
|
8
|
+
version: string;
|
|
9
|
+
request(method: "hooks/list" | "config/read" | "experimentalFeature/list" | "config/batchWrite", params: unknown): Promise<unknown>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export type CodexSafetyOptions = {
|
|
13
|
+
command?: string;
|
|
14
|
+
home?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
codexHome?: string;
|
|
17
|
+
};
|
|
18
|
+
/** Owns only its fresh app-server subprocess. It never reloads a running agent,
|
|
19
|
+
* enrolls trust, starts a model turn or forwards native diagnostic text. */
|
|
20
|
+
export declare function connectCodexSafety(options: Required<Pick<CodexSafetyOptions, "home">> & CodexSafetyOptions): Promise<CodexSafetyRpc>;
|
|
21
|
+
type NativeGuard = {
|
|
22
|
+
key: string;
|
|
23
|
+
command: string;
|
|
24
|
+
currentHash: string;
|
|
25
|
+
sourcePath: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
trustStatus: string;
|
|
28
|
+
};
|
|
29
|
+
/** Select only the exact packaged composite definition in native discovery.
|
|
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;
|
|
32
|
+
export declare function readCodexSafety(rpc: CodexSafetyRpc, home: string, cwd: string, options?: {
|
|
33
|
+
includeLayers?: boolean;
|
|
34
|
+
requireReady?: boolean;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
guard: NativeGuard;
|
|
37
|
+
discovery: unknown;
|
|
38
|
+
configuration: unknown;
|
|
39
|
+
}>;
|
|
40
|
+
/** Two native observations surround stateless guard classification. No requested
|
|
41
|
+
* file operation is executed and no Hooks registry credential is consulted. */
|
|
42
|
+
export declare function verifyCodexNativeSafety(options?: CodexSafetyOptions, dependencies?: {
|
|
43
|
+
connect?: typeof connectCodexSafety;
|
|
44
|
+
verify?: typeof verifyNativeSafetyCommand;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
ok: true;
|
|
47
|
+
target: "codex";
|
|
48
|
+
nativeVersion: string;
|
|
49
|
+
cwd: string;
|
|
50
|
+
key: string;
|
|
51
|
+
currentHash: string;
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
trusted: boolean;
|
|
54
|
+
guardVerified: boolean;
|
|
55
|
+
nativeDiscoveryVerified: boolean;
|
|
56
|
+
nativeAdoptionVerified: boolean;
|
|
57
|
+
}>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { connectCodexSafety, type CodexSafetyOptions } from "./codex-safety-check.js";
|
|
2
|
+
import { verifyNativeSafetyCommand } from "../native-safety.js";
|
|
3
|
+
export declare class CodexTrustError extends Error {
|
|
4
|
+
readonly code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain";
|
|
5
|
+
readonly operationId?: string | undefined;
|
|
6
|
+
constructor(code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain", operationId?: string | undefined);
|
|
7
|
+
}
|
|
8
|
+
interface Dependencies {
|
|
9
|
+
connect?: typeof connectCodexSafety;
|
|
10
|
+
verify?: typeof verifyNativeSafetyCommand;
|
|
11
|
+
}
|
|
12
|
+
type Options = CodexSafetyOptions;
|
|
13
|
+
export interface CodexTrustPlan {
|
|
14
|
+
schema: "hasna.hooks.codex-safety-trust/v1";
|
|
15
|
+
planDigest: string;
|
|
16
|
+
nativeVersion: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
key: string;
|
|
19
|
+
currentHash: string;
|
|
20
|
+
configPath: string;
|
|
21
|
+
configVersion: string;
|
|
22
|
+
action: "unchanged" | "trust_and_enable";
|
|
23
|
+
guardVerified: true;
|
|
24
|
+
nativeAdoptionVerified: false;
|
|
25
|
+
}
|
|
26
|
+
export declare function planCodexSafetyTrust(options?: Options, dependencies?: Dependencies): Promise<CodexTrustPlan>;
|
|
27
|
+
export declare function applyCodexSafetyTrust(options: Options & {
|
|
28
|
+
expectedPlanDigest: string;
|
|
29
|
+
}, dependencies?: Dependencies): Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
changed: boolean;
|
|
32
|
+
operationId: string;
|
|
33
|
+
planDigest: string;
|
|
34
|
+
configVersion: any;
|
|
35
|
+
guardVerified: boolean;
|
|
36
|
+
nativeDiscoveryVerified: boolean;
|
|
37
|
+
nativeAdoptionVerified: boolean;
|
|
38
|
+
unrelatedConfigurationPreserved: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
schema: "hasna.hooks.codex-safety-trust/v1";
|
|
41
|
+
planDigest: string;
|
|
42
|
+
nativeVersion: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
key: string;
|
|
45
|
+
currentHash: string;
|
|
46
|
+
configPath: string;
|
|
47
|
+
configVersion: string;
|
|
48
|
+
action: "unchanged" | "trust_and_enable";
|
|
49
|
+
guardVerified: true;
|
|
50
|
+
nativeAdoptionVerified: false;
|
|
51
|
+
ok: boolean;
|
|
52
|
+
changed: boolean;
|
|
53
|
+
}>;
|
|
54
|
+
export {};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare function readCodexSettings(path: string): Record<string, any>;
|
|
2
2
|
/** Preserve the exact read version. Never write trust state or config.toml. */
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function capturedCodexSettings(settings: Record<string, any>): string | null;
|
|
4
|
+
export declare function assertSettingsPreimage(raw: string | null, expected?: string): void;
|
|
5
|
+
export declare function writeCodexSettings(path: string, settings: Record<string, any>, expectedSha256?: string): void;
|
|
6
|
+
/** Shared standalone writer. Managed Claude settings still use Skills. */
|
|
7
|
+
export declare function writeHookSettingsPreimage(path: string, settings: Record<string, any>, before: string | null): void;
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface InstallResult {
|
|
|
30
30
|
configPath?: string;
|
|
31
31
|
}
|
|
32
32
|
export interface InstallOptions {
|
|
33
|
+
/** Optional exact predecessor for a planned native safety registration. */
|
|
34
|
+
expectedSettingsSha256?: string;
|
|
33
35
|
/** Explicit persistent opt-in for mementos-context; omitted updates retain its current choice. */
|
|
34
36
|
mementos?: MementosRegistration;
|
|
35
37
|
scope?: Scope;
|
|
@@ -64,6 +66,12 @@ export declare function detectRewriteConflict(name: string, scope: Scope, target
|
|
|
64
66
|
/** Injected for tests: the rewrite claim is a registry concern, the overlap is not. */
|
|
65
67
|
lookup?: (hookName: string) => HookMeta | undefined): string | undefined;
|
|
66
68
|
export declare function installHook(name: string, options?: InstallOptions): InstallResult;
|
|
69
|
+
/** Render through the installer without writing settings or trust state. */
|
|
70
|
+
export declare function previewNativeSafetyRegistration(target: "codex" | "claude", scope?: Scope): {
|
|
71
|
+
before: string | null;
|
|
72
|
+
after: string;
|
|
73
|
+
command: string;
|
|
74
|
+
};
|
|
67
75
|
export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
|
|
68
76
|
export declare function getRegisteredHooksForTarget(scope?: Scope, target?: SingleTarget): string[];
|
|
69
77
|
export declare function getRegisteredHooks(scope?: Scope): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { verifyNativeSafetyCommand } from "../native-safety.js";
|
|
2
|
+
export declare class NativeRegistrationError extends Error {
|
|
3
|
+
readonly code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain";
|
|
4
|
+
readonly operationId?: string | undefined;
|
|
5
|
+
constructor(code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain", operationId?: string | undefined);
|
|
6
|
+
}
|
|
7
|
+
export interface NativeRegistrationOptions {
|
|
8
|
+
target: "codex" | "claude";
|
|
9
|
+
}
|
|
10
|
+
export interface NativeRegistrationPlan {
|
|
11
|
+
schema: "hasna.hooks.native-safety-registration/v1";
|
|
12
|
+
target: "codex" | "claude";
|
|
13
|
+
settingsPath: string;
|
|
14
|
+
resolvedPath: string;
|
|
15
|
+
beforeSHA256: string;
|
|
16
|
+
desiredSHA256: string;
|
|
17
|
+
commandSHA256: string;
|
|
18
|
+
action: "unchanged" | "register";
|
|
19
|
+
planDigest: string;
|
|
20
|
+
guardVerified: true;
|
|
21
|
+
nativeAdoptionVerified: false;
|
|
22
|
+
}
|
|
23
|
+
interface Dependencies {
|
|
24
|
+
verify?: typeof verifyNativeSafetyCommand;
|
|
25
|
+
}
|
|
26
|
+
export declare function planNativeSafetyRegistration(options: NativeRegistrationOptions, dependencies?: Dependencies): Promise<NativeRegistrationPlan>;
|
|
27
|
+
export declare function applyNativeSafetyRegistration(options: NativeRegistrationOptions & {
|
|
28
|
+
expectedPlanDigest: string;
|
|
29
|
+
}, dependencies?: Dependencies): Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
changed: boolean;
|
|
32
|
+
operationId: `${string}-${string}-${string}-${string}-${string}`;
|
|
33
|
+
planDigest: string;
|
|
34
|
+
settingsSHA256: string;
|
|
35
|
+
guardVerified: boolean;
|
|
36
|
+
nativeAdoptionVerified: boolean;
|
|
37
|
+
} | {
|
|
38
|
+
schema: "hasna.hooks.native-safety-registration/v1";
|
|
39
|
+
target: "codex" | "claude";
|
|
40
|
+
settingsPath: string;
|
|
41
|
+
resolvedPath: string;
|
|
42
|
+
beforeSHA256: string;
|
|
43
|
+
desiredSHA256: string;
|
|
44
|
+
commandSHA256: string;
|
|
45
|
+
action: "unchanged" | "register";
|
|
46
|
+
planDigest: string;
|
|
47
|
+
guardVerified: true;
|
|
48
|
+
nativeAdoptionVerified: false;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
changed: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
export {};
|
|
@@ -12,5 +12,9 @@ export declare function isNativeSafetyName(name: string): name is NativeSafetyNa
|
|
|
12
12
|
export declare function buildNativeSafetyCommand(binding: NativeSafetyBinding): string;
|
|
13
13
|
export declare function nativeSafetyRegistration(command: string): NativeSafetyName | undefined;
|
|
14
14
|
export declare function nativeSafetyCommandBinding(command: string): NativeSafetyBinding | undefined;
|
|
15
|
+
/** Whether a saved supervisor command's pinned runtime and worker still verify
|
|
16
|
+
* on disk. `undefined` means the command is not a native-safety binding at all.
|
|
17
|
+
* Read-only: it never registers, rewrites or executes anything. */
|
|
18
|
+
export declare function nativeSafetyBindingVerified(command: string): boolean | undefined;
|
|
15
19
|
/** Built-in capability only: no registry, custom source, profile or app store. */
|
|
16
20
|
export declare function installedNativeSafetyCommand(name: NativeSafetyName): string;
|
package/dist/native-safety.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export declare function readNativeSafetyRegistration(home?: string): {
|
|
|
43
43
|
sha256: string;
|
|
44
44
|
path: string;
|
|
45
45
|
};
|
|
46
|
+
/** Bounded, non-leaking explanation for a refused binding: which integrity
|
|
47
|
+
* property the saved record fails. A binding that a third party could rewrite
|
|
48
|
+
* can point the supervisor at a different worker, so its owner and mode are part
|
|
49
|
+
* of the trust decision, not decoration. Read-only — it never repairs, rewrites
|
|
50
|
+
* or executes anything, and returns undefined when the record looks intact. */
|
|
51
|
+
export declare function nativeSafetyBindingIntegrityReason(home?: string): string | undefined;
|
|
46
52
|
/** Register only the bundled composite guard. Updates preserve the exact prior
|
|
47
53
|
* record and require its digest. A lock coordinates package-owned writers; an
|
|
48
54
|
* unexpected preimage or a retained interrupted lock requires reconciliation. */
|
package/dist/native-safety.js
CHANGED
|
@@ -210,6 +210,23 @@ function readNativeSafetyRegistration(home = homedir2()) {
|
|
|
210
210
|
throw new NativeSafetyError(error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
function nativeSafetyBindingIntegrityReason(home = homedir2()) {
|
|
214
|
+
try {
|
|
215
|
+
const path = join2(canonicalHome(home), ".hasna/hooks/native", filename);
|
|
216
|
+
const stat = lstatSync2(path);
|
|
217
|
+
if (!stat.isFile())
|
|
218
|
+
return "the saved binding is not a regular file";
|
|
219
|
+
if (!owner(stat.uid))
|
|
220
|
+
return "the saved binding is not owned by the current user";
|
|
221
|
+
if (stat.mode & 63)
|
|
222
|
+
return `the saved binding is accessible by group or others (mode ${(stat.mode & 511).toString(8).padStart(3, "0")})`;
|
|
223
|
+
if (stat.size > 32768)
|
|
224
|
+
return "the saved binding exceeds its size bound";
|
|
225
|
+
return;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return error?.code === "ENOENT" ? "the saved binding is missing" : undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
213
230
|
function registerNativeSafety(options = {}) {
|
|
214
231
|
const home = canonicalHome(options.home ?? homedir2());
|
|
215
232
|
const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
|
|
@@ -395,6 +412,7 @@ export {
|
|
|
395
412
|
verifyNativeSafetyCommand,
|
|
396
413
|
registerNativeSafety,
|
|
397
414
|
readNativeSafetyRegistration,
|
|
415
|
+
nativeSafetyBindingIntegrityReason,
|
|
398
416
|
evaluateNativeSafetyForExecution,
|
|
399
417
|
evaluateNativeSafety,
|
|
400
418
|
NativeSafetyError
|
|
@@ -45,6 +45,18 @@ a newline or `||` the `cd` may have failed, so the earlier directories stay
|
|
|
45
45
|
in scope. A `cd` inside `( ... )`, a pipeline or the background does not move
|
|
46
46
|
it, and neither does a `cd` whose target cannot be resolved statically.
|
|
47
47
|
Descriptor duplication (`2>&1`, `>&2`, `1>&-`) is not a write.
|
|
48
|
+
Each pipeline stage is classified on its own, with its output redirections
|
|
49
|
+
split off: `ls <root> 2>/dev/null | head` reads `<root>`, and the trailing
|
|
50
|
+
word of a later read-only stage (`| head -60`) is never taken as the write
|
|
51
|
+
target of an earlier stage. Pipes inside quotes or command substitutions are
|
|
52
|
+
not stage boundaries.
|
|
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 and
|
|
55
|
+
`mkdir`/`touch` check every operand; other writers check their last one.
|
|
56
|
+
An operand or redirect target that cannot be read statically (a variable
|
|
57
|
+
other than `$HOME`, `$( )`, backticks, a glob) fails closed to the directory
|
|
58
|
+
the command runs in, so `rm -rf "$TMPDIR/x"` run from a protected checkout is
|
|
59
|
+
refused. Use a literal absolute path for deletes outside the roots.
|
|
48
60
|
|
|
49
61
|
## Configuration
|
|
50
62
|
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
* redirection operands) are resolved against the directory the segment runs
|
|
33
33
|
* in: the command's cwd, narrowed by a plain `cd` only across `&&` (see
|
|
34
34
|
* bashTargets). Descriptor duplication (`2>&1`, `>&2`) is not a write.
|
|
35
|
+
* Each pipeline stage is classified on its own, with its redirections split
|
|
36
|
+
* off, so a later read-only stage never supplies an earlier stage's target.
|
|
35
37
|
* apply_patch tools are inspected through their `*** Add File:` /
|
|
36
38
|
* `*** Update File:` / `*** Delete File:` markers. Parenthesized command
|
|
37
39
|
* groups are unwrapped.
|
|
@@ -215,6 +217,140 @@ function withoutFdDuplication(segment: string): string {
|
|
|
215
217
|
return segment.replace(FD_DUPLICATION, " ");
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Split one command segment into its pipeline stages at every unquoted `|`
|
|
222
|
+
* (and `|&`) that sits outside quotes, backticks, every parenthesis (`$( )`,
|
|
223
|
+
* `$(( ))`, `<( )`, `>( )`, `( )`) and every `${ }` expansion. The `|` of a
|
|
224
|
+
* `>|` clobber redirect is not a pipe, and `||` never reaches here because
|
|
225
|
+
* bashTargets splits on it first. When quoting, parentheses or braces do not
|
|
226
|
+
* balance, the segment is returned whole (fail closed to the whole-segment
|
|
227
|
+
* classification).
|
|
228
|
+
*/
|
|
229
|
+
export function pipelineStages(segment: string): string[] {
|
|
230
|
+
const stages: string[] = [];
|
|
231
|
+
let quote: "'" | '"' | null = null;
|
|
232
|
+
let parens = 0;
|
|
233
|
+
let braces = 0;
|
|
234
|
+
let backtick = false;
|
|
235
|
+
let start = 0;
|
|
236
|
+
for (let i = 0; i < segment.length; i++) {
|
|
237
|
+
const ch = segment[i];
|
|
238
|
+
if (quote === "'") {
|
|
239
|
+
if (ch === "'") quote = null;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ch === "\\") {
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === "(") {
|
|
247
|
+
parens++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === ")") {
|
|
251
|
+
if (--parens < 0) return [segment];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ch === "$" && segment[i + 1] === "{") {
|
|
255
|
+
braces++;
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === "}" && braces > 0) {
|
|
260
|
+
braces--;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ch === "\x60") {
|
|
264
|
+
backtick = !backtick;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (quote === '"') {
|
|
268
|
+
if (ch === '"') quote = null;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (ch === "'" || ch === '"') {
|
|
272
|
+
quote = ch;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch !== "|" || parens > 0 || braces > 0 || backtick || segment[i - 1] === ">") continue;
|
|
276
|
+
stages.push(segment.slice(start, i));
|
|
277
|
+
if (segment[i + 1] === "&") i++;
|
|
278
|
+
start = i + 1;
|
|
279
|
+
}
|
|
280
|
+
if (quote || parens !== 0 || braces !== 0 || backtick) return [segment];
|
|
281
|
+
stages.push(segment.slice(start));
|
|
282
|
+
return stages;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface ShellWord {
|
|
286
|
+
text: string;
|
|
287
|
+
opaque: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Split a command into shell words, honouring single and double quotes and
|
|
292
|
+
* backslash escapes, and removing the quotes. A word is opaque when its
|
|
293
|
+
* value cannot be read statically: it holds an expansion other than a
|
|
294
|
+
* leading `$HOME`/`${HOME}`, a command substitution, a backtick, a glob or
|
|
295
|
+
* an unbalanced quote.
|
|
296
|
+
*/
|
|
297
|
+
function shellWords(command: string): ShellWord[] {
|
|
298
|
+
const words: ShellWord[] = [];
|
|
299
|
+
let text = "";
|
|
300
|
+
let raw = "";
|
|
301
|
+
let quote: "'" | '"' | null = null;
|
|
302
|
+
let inWord = false;
|
|
303
|
+
const flush = () => {
|
|
304
|
+
if (!inWord) return;
|
|
305
|
+
const body = raw.replace(/^"?\$\{?HOME\}?"?(?=\/|$)/, "");
|
|
306
|
+
// Bash expands `~` only when the whole tilde prefix (up to the first
|
|
307
|
+
// unquoted `/`) is unquoted and unescaped. `~"/x"`, `~\/x` and `~""`
|
|
308
|
+
// name an entry called `~` in the current directory.
|
|
309
|
+
const literalTilde = text.startsWith("~") && !/^~(?:\/|$)/.test(raw);
|
|
310
|
+
words.push({ text: literalTilde ? `./${text}` : text, opaque: /[$\x60*?[]/.test(body) || /[()]/.test(body) });
|
|
311
|
+
text = "";
|
|
312
|
+
raw = "";
|
|
313
|
+
inWord = false;
|
|
314
|
+
};
|
|
315
|
+
for (let i = 0; i < command.length; i++) {
|
|
316
|
+
const ch = command[i];
|
|
317
|
+
if (quote === "'") {
|
|
318
|
+
if (ch === "'") quote = null;
|
|
319
|
+
else text += ch;
|
|
320
|
+
raw += ch;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (ch === "\\" && i + 1 < command.length) {
|
|
324
|
+
const next = command[i + 1];
|
|
325
|
+
// Inside double quotes a backslash escapes only $ ` " \ and newline.
|
|
326
|
+
text += quote === '"' && !/[$\x60"\\\n]/.test(next) ? ch + next : next;
|
|
327
|
+
raw += ch + next;
|
|
328
|
+
inWord = true;
|
|
329
|
+
i++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (quote === '"') {
|
|
333
|
+
if (ch === '"') quote = null;
|
|
334
|
+
else text += ch;
|
|
335
|
+
raw += ch;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (/\s/.test(ch)) {
|
|
339
|
+
flush();
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
inWord = true;
|
|
343
|
+
raw += ch;
|
|
344
|
+
if (ch === "'" || ch === '"') quote = ch;
|
|
345
|
+
else text += ch;
|
|
346
|
+
}
|
|
347
|
+
if (quote) {
|
|
348
|
+
raw += "$";
|
|
349
|
+
}
|
|
350
|
+
flush();
|
|
351
|
+
return words;
|
|
352
|
+
}
|
|
353
|
+
|
|
218
354
|
/**
|
|
219
355
|
* Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
|
|
220
356
|
* unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
|
|
@@ -364,7 +500,6 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
|
|
|
364
500
|
for (let i = 0; i < opens; i++) subshells.push({ current: [...current], reachable: new Set(reachable), certain });
|
|
365
501
|
|
|
366
502
|
const segment = unwrapSegment(rawSegment);
|
|
367
|
-
const op = segmentOperation(segment);
|
|
368
503
|
|
|
369
504
|
const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
|
|
370
505
|
const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
|
|
@@ -380,27 +515,125 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
|
|
|
380
515
|
const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
|
|
381
516
|
const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/(?:${suffix})`);
|
|
382
517
|
const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
518
|
+
const explicitTargets = (text: string): string[] => {
|
|
519
|
+
const found: string[] = [];
|
|
520
|
+
let m: RegExpExecArray | null;
|
|
521
|
+
re.lastIndex = 0;
|
|
522
|
+
while ((m = re.exec(text)) !== null) found.push(normalize(expandHomeSpelling(m[0], home)).replace(/\/+$/, ""));
|
|
523
|
+
return found;
|
|
524
|
+
};
|
|
390
525
|
|
|
391
|
-
|
|
526
|
+
// Each pipeline stage is classified on its own, with its output
|
|
527
|
+
// redirections split off: a redirect operand is a write on that operand
|
|
528
|
+
// only, so `ls <root> 2>/dev/null | head` reads <root>. A stage's relative
|
|
529
|
+
// trailing operand counts only when that stage's own command writes or
|
|
530
|
+
// deletes, so the last word of a later read-only stage (`| head -60`) is
|
|
531
|
+
// never taken as the write target of an earlier stage.
|
|
532
|
+
const stages = pipelineStages(segment).map((stage) => {
|
|
392
533
|
const operands: string[] = [];
|
|
393
|
-
|
|
534
|
+
// A redirect whose target cannot be read statically (empty, or built
|
|
535
|
+
// from `$...`/`$( )`/backticks) may write anywhere, including a
|
|
536
|
+
// protected path the stage names: the stage then counts as a write.
|
|
537
|
+
let opaqueRedirect = false;
|
|
538
|
+
const commandPart = withoutFdDuplication(stage).replace(OUTPUT_REDIRECT, (_match, operand: string) => {
|
|
539
|
+
if (!operand || /[$\x60]/.test(operand)) opaqueRedirect = true;
|
|
394
540
|
if (operand) operands.push(operand);
|
|
395
541
|
return " ";
|
|
396
542
|
});
|
|
397
543
|
const commandOp = segmentOperation(commandPart);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
544
|
+
return { commandPart, operands, op: opaqueRedirect && commandOp === "read" ? ("write" as Operation) : commandOp };
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// A protected path named in one stage can be acted on by another
|
|
548
|
+
// (`find <root>/x | xargs rm -rf`, `printf <root>/x | xargs mkdir`), so
|
|
549
|
+
// every explicit path outside a redirect operand takes the strongest
|
|
550
|
+
// operation of any stage's command. Only redirects are attributed to
|
|
551
|
+
// their own operand.
|
|
552
|
+
const pipelineOp: Operation = stages.some((stage) => stage.op === "delete")
|
|
553
|
+
? "delete"
|
|
554
|
+
: stages.some((stage) => stage.op === "write")
|
|
555
|
+
? "write"
|
|
556
|
+
: "read";
|
|
557
|
+
const bases = current.filter(underRoot);
|
|
558
|
+
for (const stage of stages) {
|
|
559
|
+
let foundExplicit = false;
|
|
560
|
+
for (const path of explicitTargets(stage.commandPart)) {
|
|
561
|
+
foundExplicit = true;
|
|
562
|
+
targets.push({ path, op: pipelineOp });
|
|
563
|
+
}
|
|
564
|
+
// Every redirect operand is a write: an explicit protected path as
|
|
565
|
+
// named, and any other operand resolved against each protected
|
|
566
|
+
// directory the stage can run in, whether or not the stage also names
|
|
567
|
+
// a protected path.
|
|
568
|
+
for (const operand of stage.operands) {
|
|
569
|
+
const explicit = explicitTargets(operand);
|
|
570
|
+
for (const path of explicit) targets.push({ path, op: "write" });
|
|
571
|
+
if (explicit.length > 0) continue;
|
|
572
|
+
for (const base of bases) {
|
|
401
573
|
targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op: "write" });
|
|
402
574
|
}
|
|
403
|
-
|
|
575
|
+
}
|
|
576
|
+
if (foundExplicit || stage.op === "read") continue;
|
|
577
|
+
// A writing or deleting stage that names no protected path acts on its
|
|
578
|
+
// trailing operand, resolved against each protected directory it can
|
|
579
|
+
// run in. When no operand can be read statically (quoted, `$( )`), it
|
|
580
|
+
// fails closed to that directory itself.
|
|
581
|
+
// Deletes, mkdir/touch, tee, truncate, install, mv and sed -i act on
|
|
582
|
+
// every operand, so each operand word counts; other writers (cp, ln,
|
|
583
|
+
// ...) act on their last one. A word that cannot be read statically
|
|
584
|
+
// fails closed to the directory.
|
|
585
|
+
const words = shellWords(stage.commandPart);
|
|
586
|
+
// Operands after the command word. Options end at `--` or, as BSD
|
|
587
|
+
// getopt does, at the first operand: every later word is an operand
|
|
588
|
+
// even when it starts with `-`.
|
|
589
|
+
let endOfOptions = false;
|
|
590
|
+
const nonFlag = words.slice(1).flatMap((word) => {
|
|
591
|
+
if (!endOfOptions && word.text === "--") {
|
|
592
|
+
endOfOptions = true;
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
if (!endOfOptions && word.text.startsWith("-")) return [];
|
|
596
|
+
endOfOptions = true;
|
|
597
|
+
return [word.opaque ? "." : word.text];
|
|
598
|
+
});
|
|
599
|
+
// mv changes its sources too, so every mv operand counts as a write.
|
|
600
|
+
const everyOperand =
|
|
601
|
+
stage.op === "delete" ||
|
|
602
|
+
/(?:^|\s)(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) ||
|
|
603
|
+
(/(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart));
|
|
604
|
+
const operandTargets: Array<{ operand: string; op: Operation }> = [];
|
|
605
|
+
if (everyOperand) {
|
|
606
|
+
for (const operand of nonFlag.length > 0 ? nonFlag : ["."]) operandTargets.push({ operand, op: stage.op });
|
|
607
|
+
} else {
|
|
608
|
+
const relMatch = stage.commandPart.match(REL_OPERAND);
|
|
609
|
+
const last = words.at(-1);
|
|
610
|
+
operandTargets.push({ operand: relMatch ? relMatch[1] : !last || last.opaque ? "." : last.text, op: stage.op });
|
|
611
|
+
// cp/ln/install/mv -t DIR write into DIR: `-t DIR`, `-tDIR`, `-t` in
|
|
612
|
+
// a short-flag cluster (`-rt DIR`, `-rtDIR`) and any abbreviation of
|
|
613
|
+
// `--target-directory[=]DIR` (GNU).
|
|
614
|
+
if (/(?:^|\s)(?:cp|ln|install|mv)(?:\s|$)/.test(stage.commandPart)) {
|
|
615
|
+
words.forEach((word, index) => {
|
|
616
|
+
let value: ShellWord | undefined;
|
|
617
|
+
const short = word.text.match(/^-[A-Za-z]*?t(.*)$/);
|
|
618
|
+
const long = word.text.match(/^(--t[a-z-]*)(?:=(.*))?$/);
|
|
619
|
+
if (short && !word.text.startsWith("--")) {
|
|
620
|
+
value = short[1] ? { text: short[1], opaque: word.opaque } : words[index + 1];
|
|
621
|
+
} else if (long && "--target-directory".startsWith(long[1])) {
|
|
622
|
+
value = long[2] !== undefined ? { text: long[2], opaque: word.opaque } : words[index + 1];
|
|
623
|
+
}
|
|
624
|
+
if (value) operandTargets.push({ operand: value.opaque || !value.text ? "." : value.text, op: stage.op });
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
// An inline script (python3 -c, node -e, bun -e) can write anywhere
|
|
628
|
+
// relative to where it runs, whatever its arguments.
|
|
629
|
+
if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(stage.commandPart)) {
|
|
630
|
+
operandTargets.push({ operand: ".", op: stage.op });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const base of bases) {
|
|
634
|
+
for (const { operand, op } of operandTargets) {
|
|
635
|
+
targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op });
|
|
636
|
+
}
|
|
404
637
|
}
|
|
405
638
|
}
|
|
406
639
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.8",
|
|
4
4
|
"description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@hasna/contracts": "~1.2.1"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@hasna/skills": "0.9.
|
|
67
|
+
"@hasna/skills": "0.9.17",
|
|
68
68
|
"@hasna/events": "^0.1.16",
|
|
69
69
|
"@hasna/secrets": "0.4.2",
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.26.0",
|