mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 16:08:59 +02:00
fix: red-team findings — verify-gate identity, single quoting authority, Windows paths
Red-team pass over the hardened diff (several findings empirically verified by the reviewer before reporting): - KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented opt-in Stop hook). A tag-stripped verify-gate entry previously survived prune-stale --all and errored at the end of EVERY turn after uninstall deleted the install root — the exact phantom-hook class this branch fixes. Uninstall also sweeps its tagged form. - add-event is now the single quoting authority: every registered command is normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar canonical root registered broken plan-tune/AUQ/timeline hooks that the very next heal rewrote (the codebase disagreed with its own registrations). - Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks only (gsWinPath) — native bun resolved them drive-relative, so the heal judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion hooks and the Stop hook now also get the mandatory 'bash ' prefix on Windows (previously only SessionStart did; extensionless bash shims otherwise hit the file-association dialog). - CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes the home path — split-brain left such users permanently hookless). - prune-stale preserves foreign entries that STARTED empty (they were silently deleted, uncounted, on every heal). - The timeline Stop registration and its list-sources guard join the zero-silent-mutations contract (stderr attached). Tests: verify-gate tag-stripped heal+sweep, started-empty preservation, add-event quoting-authority round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
19eed1b392
commit
6a3cf611ea
@@ -81,7 +81,8 @@ var KNOWN_HOOKS = {
|
||||
"question-preference-hook": { source: "plan-tune-cathedral", event: "PreToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-preference-hook" },
|
||||
"auq-error-fallback-hook": { source: "auq-error-fallback", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/auq-error-fallback-hook" },
|
||||
"timeline-stop-hook": { source: "gstack-timeline-stop", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/timeline-stop-hook" },
|
||||
"gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" }
|
||||
"gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" },
|
||||
"gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" }
|
||||
};
|
||||
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
|
||||
function gsStripWrap(c) {
|
||||
@@ -105,9 +106,18 @@ function gsOwnedRow(cmd, event, matcher) {
|
||||
if (row.matcher && (matcher || "") !== row.matcher) return null;
|
||||
return row;
|
||||
}
|
||||
function gsWinPath(p) {
|
||||
// Git Bash writes MSYS-form paths (/c/Users/...) into settings.json, but
|
||||
// native bun resolves them drive-relative (C:\c\Users\...) -- translate for
|
||||
// fs calls only; stored commands keep the form the firing shell expects.
|
||||
if (process.platform === "win32" && /^\/[A-Za-z]\//.test(p)) {
|
||||
return p.charAt(1) + ":" + p.slice(2);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
function gsIsAlive(cmd) {
|
||||
var fs = require("fs");
|
||||
var p = gsStripWrap(cmd);
|
||||
var p = gsWinPath(gsStripWrap(cmd));
|
||||
if (!p) return false;
|
||||
try {
|
||||
if (process.platform === "win32") return fs.existsSync(p);
|
||||
@@ -364,7 +374,12 @@ case "$ACTION" in
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks[event]) settings.hooks[event] = [];
|
||||
|
||||
const hookEntry = { type: "command", command: cmd };
|
||||
// add-event is the single quoting authority: normalize the command
|
||||
// through the same round-trip the healer uses so metachar paths are
|
||||
// registered in the escaped-quoted form from the start (a caller-side
|
||||
// quoting step would drift per call site).
|
||||
const cmdNorm = gsQuoteCmd(gsStripWrap(cmd), gsHadBashPrefix(cmd));
|
||||
const hookEntry = { type: "command", command: cmdNorm };
|
||||
if (timeoutRaw) {
|
||||
const n = Number(timeoutRaw);
|
||||
if (Number.isFinite(n) && n > 0) hookEntry.timeout = n;
|
||||
@@ -379,12 +394,12 @@ case "$ACTION" in
|
||||
for (const entry of settings.hooks[event]) {
|
||||
if ((entry.matcher || "") !== matcher) continue;
|
||||
if (!Array.isArray(entry.hooks)) continue;
|
||||
let idx = entry.hooks.findIndex(h => h && h.command === cmd);
|
||||
let idx = entry.hooks.findIndex(h => h && h.command === cmdNorm);
|
||||
if (idx < 0) {
|
||||
idx = entry.hooks.findIndex(h => {
|
||||
if (!h || !h.command) return false;
|
||||
const row = gsOwnedRow(h.command, event, entry.matcher || "");
|
||||
return !!row && gsBaseOf(h.command) === gsBaseOf(cmd);
|
||||
return !!row && gsBaseOf(h.command) === gsBaseOf(cmdNorm);
|
||||
});
|
||||
}
|
||||
if (idx < 0 && entry._gstack_source === source && entry.hooks.length === 1) {
|
||||
@@ -542,6 +557,14 @@ case "$ACTION" in
|
||||
let removedHere = 0;
|
||||
const remain = [];
|
||||
const seenInEntry = new Set();
|
||||
if (entry.hooks.length === 0) {
|
||||
// Started-empty entries are foreign data we never touched --
|
||||
// preserve them (only a gstack-tagged empty entry is claimable,
|
||||
// and only by the --all sweep).
|
||||
if (all && entry._gstack_source) { removed++; continue; }
|
||||
rebuilt.push(entry);
|
||||
continue;
|
||||
}
|
||||
for (const h of entry.hooks) {
|
||||
const cmdRaw = h && h.command;
|
||||
const row = cmdRaw ? gsOwnedRow(cmdRaw, event, matcher) : null;
|
||||
|
||||
Reference in New Issue
Block a user