@hasna/hooks 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/bin/hooks-mcp.js +67 -65
- package/bin/index.js +404 -398
- package/bin/native-readiness-entry.js +3 -1
- package/bin/native-safety-entry.js +96 -8
- package/bin/serve.js +9 -9
- package/dist/index.js +74 -74
- package/dist/native-readiness.d.ts +3 -0
- package/dist/native-readiness.js +8 -2
- package/dist/sdk/index.js +1 -1
- package/hooks/hook-trash-guard/src/hook.ts +40 -10
- package/hooks/hook-workspace-repos-guard/src/hook.ts +46 -0
- package/package.json +1 -1
|
@@ -103,7 +103,7 @@ const REFUSED_VERBS: Record<string, string> = {
|
|
|
103
103
|
* place. When one of these is in command position and the command mentions a
|
|
104
104
|
* delete verb, the command is refused rather than guessed at.
|
|
105
105
|
*/
|
|
106
|
-
const OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser"]);
|
|
106
|
+
const OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser", "env"]);
|
|
107
107
|
|
|
108
108
|
/** Wrapper commands whose real command follows them (with their value-taking options). */
|
|
109
109
|
const WRAPPERS: Record<string, string[]> = {
|
|
@@ -119,7 +119,7 @@ const WRAPPERS: Record<string, string[]> = {
|
|
|
119
119
|
setsid: [],
|
|
120
120
|
command: [],
|
|
121
121
|
builtin: [],
|
|
122
|
-
exec: [],
|
|
122
|
+
exec: ["-a"],
|
|
123
123
|
};
|
|
124
124
|
|
|
125
125
|
/**
|
|
@@ -156,6 +156,12 @@ export function mentionsDeleteVerb(text: string): boolean {
|
|
|
156
156
|
return DELETE_VERB_WORD_RE.test(decodeWord(text));
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** env's split-string value may be attached to its option. This text is used
|
|
160
|
+
* only to refuse an opaque delete, never as executable text or a rewrite. */
|
|
161
|
+
export function envSplitCommandText(words: string[]): string {
|
|
162
|
+
return words.map((word) => word.replace(/^-[iv0]*S|^--split-string=/, " ")).join(" ");
|
|
163
|
+
}
|
|
164
|
+
|
|
159
165
|
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
160
166
|
|
|
161
167
|
/**
|
|
@@ -238,8 +244,8 @@ export function lexCommand(command: string): LexResult {
|
|
|
238
244
|
words.push({ text: command.slice(wordStart, end), start: wordStart, end });
|
|
239
245
|
if (pendingRedirect) redirects.add(index);
|
|
240
246
|
wordStart = -1;
|
|
247
|
+
pendingRedirect = false;
|
|
241
248
|
}
|
|
242
|
-
pendingRedirect = false;
|
|
243
249
|
};
|
|
244
250
|
|
|
245
251
|
const closeSegment = (end: number): void => {
|
|
@@ -247,6 +253,7 @@ export function lexCommand(command: string): LexResult {
|
|
|
247
253
|
if (words.length > 0) segments.push({ words, redirects });
|
|
248
254
|
words = [];
|
|
249
255
|
redirects = new Set<number>();
|
|
256
|
+
pendingRedirect = false;
|
|
250
257
|
};
|
|
251
258
|
|
|
252
259
|
const scanSingle = (from: number): number => {
|
|
@@ -390,9 +397,13 @@ export function lexCommand(command: string): LexResult {
|
|
|
390
397
|
continue;
|
|
391
398
|
}
|
|
392
399
|
if (ch === ">" || ch === "<") {
|
|
400
|
+
// An unquoted adjacent descriptor number belongs to the redirect, not
|
|
401
|
+
// the command. Its following operand is also never an executable.
|
|
402
|
+
if (wordStart >= 0 && /^\d+$/.test(command.slice(wordStart, i))) wordStart = -1;
|
|
393
403
|
closeWord(i);
|
|
394
404
|
pendingRedirect = true;
|
|
395
|
-
i
|
|
405
|
+
i++;
|
|
406
|
+
if (command[i] === ch || command[i] === "&" || (ch === ">" && command[i] === "|") || (ch === "<" && command[i] === ">")) i++;
|
|
396
407
|
continue;
|
|
397
408
|
}
|
|
398
409
|
if (ch === "'") {
|
|
@@ -521,10 +532,11 @@ export function decodeWord(text: string): string {
|
|
|
521
532
|
* command wrappers — NOT any word that happens to read like a verb, so
|
|
522
533
|
* `printf '%s\n' rm` is not a delete.
|
|
523
534
|
*/
|
|
524
|
-
function commandWordIndex(segment: Segment): number {
|
|
535
|
+
export function commandWordIndex(segment: Segment): number {
|
|
525
536
|
let index = 0;
|
|
526
537
|
let guard = 0;
|
|
527
538
|
while (index < segment.words.length && guard++ < 32) {
|
|
539
|
+
if (segment.redirects.has(index)) { index++; continue; }
|
|
528
540
|
const raw = segment.words[index].text;
|
|
529
541
|
const decoded = decodeWord(raw);
|
|
530
542
|
if (ASSIGNMENT_RE.test(decoded) && !decoded.startsWith("-")) {
|
|
@@ -535,20 +547,37 @@ function commandWordIndex(segment: Segment): number {
|
|
|
535
547
|
index++;
|
|
536
548
|
continue;
|
|
537
549
|
}
|
|
538
|
-
const
|
|
550
|
+
const wrapper = baseVerb(decoded);
|
|
551
|
+
// command -v/-V looks up every following name without executing one.
|
|
552
|
+
if (wrapper === "command") {
|
|
553
|
+
for (const word of segment.words.filter((_, at) => at > index && !segment.redirects.has(at))) {
|
|
554
|
+
const option = decodeWord(word.text);
|
|
555
|
+
if (option === "--" || !option.startsWith("-")) break;
|
|
556
|
+
if (/^-[pvV]*[vV][pvV]*$/.test(option)) return -1;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const wrapperValues = WRAPPERS[wrapper];
|
|
539
560
|
if (wrapperValues) {
|
|
561
|
+
const wrapperIndex = index;
|
|
540
562
|
index++;
|
|
541
563
|
while (index < segment.words.length) {
|
|
564
|
+
if (segment.redirects.has(index)) { index++; continue; }
|
|
542
565
|
const option = decodeWord(segment.words[index].text);
|
|
543
566
|
if (!option.startsWith("-") || option === "-") break;
|
|
544
567
|
if (option === "--") {
|
|
545
568
|
index++;
|
|
546
569
|
break;
|
|
547
570
|
}
|
|
571
|
+
// env -S reparses its value as a command string. Retain env in
|
|
572
|
+
// command position so the opaque-command refusal owns this form.
|
|
573
|
+
if (wrapper === "env" && (/^-[iv0]*S/.test(option) || /^--split-string(?:=|$)/.test(option))) return wrapperIndex;
|
|
548
574
|
index++;
|
|
549
|
-
if (wrapperValues.includes(option))
|
|
575
|
+
if (wrapperValues.includes(option) || (wrapper === "exec" && /^-[cl]*a$/.test(option))) {
|
|
576
|
+
while (segment.redirects.has(index)) index++;
|
|
577
|
+
index++;
|
|
578
|
+
}
|
|
550
579
|
}
|
|
551
|
-
let positionals = WRAPPER_POSITIONALS[
|
|
580
|
+
let positionals = WRAPPER_POSITIONALS[wrapper] ?? 0;
|
|
552
581
|
while (positionals > 0 && index < segment.words.length) {
|
|
553
582
|
const positional = decodeWord(segment.words[index].text);
|
|
554
583
|
if (positional.startsWith("-") && positional !== "-") break;
|
|
@@ -814,7 +843,8 @@ export function scanCommand(command: string, options: { cwd: string; home: strin
|
|
|
814
843
|
continue;
|
|
815
844
|
}
|
|
816
845
|
|
|
817
|
-
|
|
846
|
+
const opaqueText = verb === "env" ? envSplitCommandText(segment.words.map((word) => decodeWord(word.text))) : segmentText(segment);
|
|
847
|
+
if (OPAQUE_COMMANDS.has(verb) && mentionsDeleteVerb(opaqueText)) {
|
|
818
848
|
result.refusals.push({
|
|
819
849
|
reason: `\`${verb}\` runs a command string this hook cannot rewrite in place. Run the delete directly as \`rm -- <path>\` (intercepted and redirected into trash), or as a script that uses \`trash guard\` itself.`,
|
|
820
850
|
});
|
|
@@ -849,7 +879,7 @@ function segmentText(segment: Segment): string {
|
|
|
849
879
|
}
|
|
850
880
|
|
|
851
881
|
/** The git subcommand, skipping git's own value-taking global options. */
|
|
852
|
-
function gitSubcommand(segment: Segment, verbIndex: number): string | null {
|
|
882
|
+
export function gitSubcommand(segment: Segment, verbIndex: number): string | null {
|
|
853
883
|
let index = verbIndex + 1;
|
|
854
884
|
while (index < segment.words.length) {
|
|
855
885
|
const option = decodeWord(segment.words[index].text);
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
|
|
53
53
|
import { homedir } from "os";
|
|
54
54
|
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
|
|
55
|
+
import { commandWordIndex, decodeWord, envSplitCommandText, gitSubcommand, lexCommand, mentionsDeleteVerb } from "../../hook-trash-guard/src/hook";
|
|
55
56
|
import {
|
|
56
57
|
getCommand,
|
|
57
58
|
readInput,
|
|
@@ -364,6 +365,51 @@ function shellWords(command: string): ShellWord[] {
|
|
|
364
365
|
function segmentOperation(segment: string): Operation {
|
|
365
366
|
const trimmed = segment.trim();
|
|
366
367
|
if (!trimmed) return "read";
|
|
368
|
+
const parsed = lexCommand(trimmed);
|
|
369
|
+
// Preserve conservative classification where the shell body is opaque.
|
|
370
|
+
if (!parsed.trustworthy || parsed.substitutions.length || /\$\{[^}]*[|;()]|[<>]\(/.test(trimmed)) return opaqueOperation(trimmed);
|
|
371
|
+
const operations = parsed.segments.map((part): Operation => {
|
|
372
|
+
const index = commandWordIndex(part);
|
|
373
|
+
if (index < 0) return "read";
|
|
374
|
+
const words = part.words.filter((_, at) => at >= index && !part.redirects.has(at)).map((word) => decodeWord(word.text));
|
|
375
|
+
const tool = words[0].split("/").pop();
|
|
376
|
+
if (["rm", "rmdir", "unlink", "shred", "rmtree", "del"].includes(tool ?? "")) return "delete";
|
|
377
|
+
if (tool === "trash") {
|
|
378
|
+
// Hasna metadata/setup verbs take identifiers, not paths to remove.
|
|
379
|
+
// Keep legacy positional Trash clients and capture verbs guarded.
|
|
380
|
+
let at = 1;
|
|
381
|
+
while (at < words.length && words[at].startsWith("-")) {
|
|
382
|
+
const flag = words[at++];
|
|
383
|
+
if (["--help", "-h", "--version", "-V", "--identity"].includes(flag)) return "read";
|
|
384
|
+
if (["--agent", "--spool", "--files", "--info", "--config"].includes(flag)) at++;
|
|
385
|
+
else if (!["--json", "--local"].includes(flag)) return "delete";
|
|
386
|
+
}
|
|
387
|
+
if (at === words.length || ["status", "doctor", "list", "info", "get", "pending", "station", "setup", "backup", "storage", "retrieval", "backup-storage", "backup-inspect", "backup-refresh", "help"].includes(words[at])) return "read";
|
|
388
|
+
if (["restore", "restore-capsule"].includes(words[at])) return "write";
|
|
389
|
+
return "delete";
|
|
390
|
+
}
|
|
391
|
+
if (tool === "git") {
|
|
392
|
+
const sub = gitSubcommand(part, index);
|
|
393
|
+
if (sub === "clean" || sub === "rm") return "delete";
|
|
394
|
+
return sub === "clone" || sub === "init" ? "write" : "read";
|
|
395
|
+
}
|
|
396
|
+
if (tool === "env" && mentionsDeleteVerb(envSplitCommandText(words))) return "delete";
|
|
397
|
+
if (["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser", "env", "xargs", "find", "busybox", "toybox"].includes(tool ?? "")) {
|
|
398
|
+
return opaqueOperation(words.join(" "));
|
|
399
|
+
}
|
|
400
|
+
if (["mkdir", "mkfile", "touch", "mv", "cp", "ln", "tee", "install", "dd", "rsync", "scp", "truncate"].includes(tool ?? "")) return "write";
|
|
401
|
+
if (tool === "curl" || tool === "wget") return WRITE_FLAGS.test(words.join(" ")) ? "write" : "read";
|
|
402
|
+
if (tool === "sed" && words.slice(1).some((word) => /^-i|^--in-place(?:=|$)/.test(word))) return "write";
|
|
403
|
+
if (["python", "python3", "node", "bun"].includes(tool ?? "") && words.some((word) => word === "-c" || word === "-e")) return "write";
|
|
404
|
+
return "read";
|
|
405
|
+
});
|
|
406
|
+
return operations.includes("delete") ? "delete" : operations.includes("write") ? "write" : "read";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Retain the prior conservative behavior for embedded shell bodies.
|
|
410
|
+
function opaqueOperation(segment: string): Operation {
|
|
411
|
+
const trimmed = segment.trim();
|
|
412
|
+
if (!trimmed) return "read";
|
|
367
413
|
|
|
368
414
|
if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
|
|
369
415
|
if (/\bgit\b/.test(trimmed)) {
|
package/package.json
CHANGED