@tekyzinc/gsd-t 5.18.10 → 5.19.10
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/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/bin/gsd-t-code-search-classifier.cjs +318 -0
- package/bin/gsd-t-pick-worktree.cjs +58 -23
- package/bin/gsd-t.js +117 -2
- package/commands/cpua.md +9 -1
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +490 -0
- package/scripts/gsd-t-graph-use-report.js +179 -0
- package/templates/CLAUDE-global.md +3 -1
package/bin/gsd-t.js
CHANGED
|
@@ -514,6 +514,24 @@ const FALLBACK_HOOK_MARKER = "gsd-t-fallback-guard";
|
|
|
514
514
|
const FALLBACK_HOOK_COMMAND =
|
|
515
515
|
'node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-fallback-guard.js"';
|
|
516
516
|
|
|
517
|
+
// ─── M117 Graph search guard (PreToolUse Bash|Grep) ─────────────────────────
|
|
518
|
+
// Blocks a search that asks a structural question about code, and names the
|
|
519
|
+
// graph command that answers it. NO `|| true`, for the same reason as the
|
|
520
|
+
// fallback guard: a missing script must fail loudly, not silently re-open the
|
|
521
|
+
// hole. The hole it closes is specific — the Grep-tool and Read-tool hooks
|
|
522
|
+
// never fired, because bypass mode routes every search through Bash.
|
|
523
|
+
const GRAPH_SEARCH_HOOK_MARKER = "gsd-t-graph-search-guard";
|
|
524
|
+
const GRAPH_SEARCH_HOOK_COMMAND =
|
|
525
|
+
'node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-search-guard.js"';
|
|
526
|
+
|
|
527
|
+
// ─── M117 Graph use report (Stop) ───────────────────────────────────────────
|
|
528
|
+
// Reports a turn that hit structural questions and never asked the graph. It
|
|
529
|
+
// reports rather than blocks: a Stop hook fires after the work is done, so
|
|
530
|
+
// blocking there punishes rather than redirects. Prevention is the guard above.
|
|
531
|
+
const GRAPH_USE_REPORT_MARKER = "gsd-t-graph-use-report";
|
|
532
|
+
const GRAPH_USE_REPORT_COMMAND =
|
|
533
|
+
'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-use-report.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-use-report.js" || true\'';
|
|
534
|
+
|
|
517
535
|
// ─── M108 Install self-heal (SessionStart) ──────────────────────────────────
|
|
518
536
|
// Checks the project's tools before any work starts, restores what is missing,
|
|
519
537
|
// and reports what it could not fix. Not a fallback — it repairs the failure
|
|
@@ -992,6 +1010,69 @@ function configureFallbackGuardHook(settingsPath) {
|
|
|
992
1010
|
return configureWriteEditHook(settingsPath, FALLBACK_HOOK_MARKER, FALLBACK_HOOK_COMMAND, "fallback guard");
|
|
993
1011
|
}
|
|
994
1012
|
|
|
1013
|
+
// M117 — register the graph search guard on Bash|Grep. Both doors: bypass mode
|
|
1014
|
+
// routes searches through Bash, and leaving either open makes it the habit.
|
|
1015
|
+
function configureGraphSearchGuardHook(settingsPath) {
|
|
1016
|
+
return configurePreToolUseHook(
|
|
1017
|
+
settingsPath, GRAPH_SEARCH_HOOK_MARKER, GRAPH_SEARCH_HOOK_COMMAND,
|
|
1018
|
+
"graph search guard", "Bash|Grep"
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// M117 — register the Stop-time graph use report.
|
|
1023
|
+
function configureGraphUseReportHook(settingsPath) {
|
|
1024
|
+
return configureStopHook(
|
|
1025
|
+
settingsPath, GRAPH_USE_REPORT_MARKER, GRAPH_USE_REPORT_COMMAND, "graph use report"
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Register a Stop hook by marker. Same find-refresh-or-add shape as the
|
|
1030
|
+
// PreToolUse registrar; Stop entries carry no matcher.
|
|
1031
|
+
function configureStopHook(settingsPath, marker, command, label) {
|
|
1032
|
+
const targetPath = settingsPath || SETTINGS_JSON;
|
|
1033
|
+
let settings = {};
|
|
1034
|
+
if (fs.existsSync(targetPath)) {
|
|
1035
|
+
try {
|
|
1036
|
+
settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
1037
|
+
if (!settings || typeof settings !== "object") settings = {};
|
|
1038
|
+
} catch {
|
|
1039
|
+
warn(`settings.json has invalid JSON — cannot configure ${label} hook`);
|
|
1040
|
+
return { installed: false, action: "noop" };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!settings.hooks) settings.hooks = {};
|
|
1044
|
+
if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
|
|
1045
|
+
|
|
1046
|
+
let action = "noop";
|
|
1047
|
+
let found = false;
|
|
1048
|
+
for (const entry of settings.hooks.Stop) {
|
|
1049
|
+
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
1050
|
+
for (const h of entry.hooks) {
|
|
1051
|
+
if (!h || typeof h.command !== "string") continue;
|
|
1052
|
+
if (h.command === command || h.command.includes(marker)) {
|
|
1053
|
+
found = true;
|
|
1054
|
+
if (h.command !== command) { h.command = command; action = "updated"; }
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (!found) {
|
|
1059
|
+
settings.hooks.Stop.push({ hooks: [{ type: "command", command }] });
|
|
1060
|
+
action = "added";
|
|
1061
|
+
}
|
|
1062
|
+
if (action === "noop") return { installed: true, action: "noop" };
|
|
1063
|
+
if (isSymlink(targetPath)) {
|
|
1064
|
+
warn("Skipping settings.json write — target is a symlink");
|
|
1065
|
+
return { installed: false, action: "noop" };
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
warn(`Failed to write settings.json: ${e.message}`);
|
|
1071
|
+
return { installed: false, action: "noop" };
|
|
1072
|
+
}
|
|
1073
|
+
return { installed: true, action };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
995
1076
|
// M107 RETIRED (v5.11.15). The rewriter shortened a reply after it was written,
|
|
996
1077
|
// and a Stop hook cannot unsay what is already on screen — so David read the
|
|
997
1078
|
// long version, then the short one. It also cost a whole extra turn, and its
|
|
@@ -1097,6 +1178,13 @@ function configureEventHook(settingsPath, event, marker, command, label) {
|
|
|
1097
1178
|
}
|
|
1098
1179
|
|
|
1099
1180
|
function configureWriteEditHook(settingsPath, marker, command, label) {
|
|
1181
|
+
return configurePreToolUseHook(settingsPath, marker, command, label, "Write|Edit");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// M117 — the same registrar, with the matcher named rather than assumed. The
|
|
1185
|
+
// graph search guard watches Bash|Grep, not Write|Edit, and a second copy of
|
|
1186
|
+
// this function is where the two would drift apart.
|
|
1187
|
+
function configurePreToolUseHook(settingsPath, marker, command, label, matcher) {
|
|
1100
1188
|
const targetPath = settingsPath || SETTINGS_JSON;
|
|
1101
1189
|
let settings = {};
|
|
1102
1190
|
if (fs.existsSync(targetPath)) {
|
|
@@ -1121,13 +1209,13 @@ function configureWriteEditHook(settingsPath, marker, command, label) {
|
|
|
1121
1209
|
if (h.command === cmd || h.command.includes(marker)) {
|
|
1122
1210
|
found = true;
|
|
1123
1211
|
if (h.command !== cmd) { h.command = cmd; action = "updated"; }
|
|
1124
|
-
if (entry.matcher !==
|
|
1212
|
+
if (entry.matcher !== matcher) { entry.matcher = matcher; action = action === "noop" ? "updated" : action; }
|
|
1125
1213
|
}
|
|
1126
1214
|
}
|
|
1127
1215
|
}
|
|
1128
1216
|
if (!found) {
|
|
1129
1217
|
settings.hooks.PreToolUse.push({
|
|
1130
|
-
matcher
|
|
1218
|
+
matcher,
|
|
1131
1219
|
hooks: [{ type: "command", command: cmd }],
|
|
1132
1220
|
});
|
|
1133
1221
|
action = "added";
|
|
@@ -1806,6 +1894,9 @@ const GLOBAL_BIN_TOOLS = [
|
|
|
1806
1894
|
// write rather than allowing it unchecked, so an omission here breaks every
|
|
1807
1895
|
// Write/Edit rather than failing silently. Also in PROJECT_BIN_TOOLS below.
|
|
1808
1896
|
"gsd-t-fallback-detect.cjs",
|
|
1897
|
+
// M117 — Search classifier. The graph search guard resolves it from the
|
|
1898
|
+
// package when a project has no copy, so it must ship globally too.
|
|
1899
|
+
"gsd-t-code-search-classifier.cjs",
|
|
1809
1900
|
// M108 — Install self-check, run by the SessionStart hook and by
|
|
1810
1901
|
// `gsd-t install-check`.
|
|
1811
1902
|
"gsd-t-install-check.cjs",
|
|
@@ -2346,6 +2437,25 @@ async function doInstall(opts = {}) {
|
|
|
2346
2437
|
}
|
|
2347
2438
|
|
|
2348
2439
|
|
|
2440
|
+
// M117 — the graph search guard and its Stop-time report. The graph rule had
|
|
2441
|
+
// three enforcement points before this and all three missed the path actually
|
|
2442
|
+
// taken: the Grep-tool and Read-tool hooks never fire in bypass mode (every
|
|
2443
|
+
// search goes out through Bash), and the runtime use-gate only runs inside
|
|
2444
|
+
// verify, never in a plain conversation.
|
|
2445
|
+
const gsHook = configureGraphSearchGuardHook(SETTINGS_JSON);
|
|
2446
|
+
if (gsHook.installed) {
|
|
2447
|
+
if (gsHook.action === "added") success("Graph search guard added (blocks a structural code search, names the graph query — M117)");
|
|
2448
|
+
else if (gsHook.action === "updated") success("Graph search guard refreshed");
|
|
2449
|
+
else info("Graph search guard already configured");
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
const gurHook = configureGraphUseReportHook(SETTINGS_JSON);
|
|
2453
|
+
if (gurHook.installed) {
|
|
2454
|
+
if (gurHook.action === "added") success("Graph use report added (flags a turn that did structural work without asking the graph — M117)");
|
|
2455
|
+
else if (gurHook.action === "updated") success("Graph use report refreshed");
|
|
2456
|
+
else info("Graph use report already configured");
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2349
2459
|
const ccHook = removeConciseHook(SETTINGS_JSON);
|
|
2350
2460
|
if (ccHook.removed) success("Concise-rewrite hook removed — retired in v5.11.15");
|
|
2351
2461
|
|
|
@@ -3507,6 +3617,11 @@ const PROJECT_BIN_TOOLS = [
|
|
|
3507
3617
|
// and it reads the project's OWN .gsd-t/graphDB/logs ledger, so it must live
|
|
3508
3618
|
// in the project — [[project_global_bin_propagation_gap]].
|
|
3509
3619
|
"gsd-t-graph-use-gate.cjs",
|
|
3620
|
+
// M117 — Search classifier for the graph search guard. The PreToolUse guard
|
|
3621
|
+
// prefers the project-local copy and DENIES every search if it cannot load
|
|
3622
|
+
// one, so an omission here blocks all work rather than failing quietly —
|
|
3623
|
+
// [[project_global_bin_propagation_gap]].
|
|
3624
|
+
"gsd-t-code-search-classifier.cjs",
|
|
3510
3625
|
// M107 — Concise rewriter, invoked by the Stop hook.
|
|
3511
3626
|
// M108 — Install self-check. Every project carries its own copy so it can
|
|
3512
3627
|
// verify and repair itself even when the global install is what broke.
|
package/commands/cpua.md
CHANGED
|
@@ -133,8 +133,16 @@ if [ "$ON_DISK" != "{NEW_VERSION}" ]; then
|
|
|
133
133
|
ON_DISK=$(node -p "require('$G/package.json').version")
|
|
134
134
|
[ "$ON_DISK" = "{NEW_VERSION}" ] || { echo "HALT: global install still $ON_DISK"; exit 1; }
|
|
135
135
|
fi
|
|
136
|
-
# 3.
|
|
136
|
+
# 3. Refresh the HOME install (commands + ~/.claude/.gsd-t-version) BEFORE propagating.
|
|
137
|
+
# v5.18.10 (2026-09-03): with the version file still on the old number, `update-all`
|
|
138
|
+
# reported all 33 projects "already current", copied nothing, and the global package
|
|
139
|
+
# was found back on the old version afterwards. `gsd-t install` writes the version
|
|
140
|
+
# file and the command files; only then does update-all see the new release.
|
|
141
|
+
gsd-t install 2>&1 | tail -5
|
|
142
|
+
[ "$(cat ~/.claude/.gsd-t-version)" = "{NEW_VERSION}" ] || { echo "HALT: ~/.claude/.gsd-t-version did not advance"; exit 1; }
|
|
143
|
+
# 4. Propagate, then prove the global is STILL the new version and a NEW file reached a project.
|
|
137
144
|
gsd-t update-all 2>&1 | tail -30
|
|
145
|
+
[ "$(node -p "require('$G/package.json').version")" = "{NEW_VERSION}" ] || { echo "HALT: global package reverted during update-all"; exit 1; }
|
|
138
146
|
```
|
|
139
147
|
|
|
140
148
|
Never `npm update -g` (npm rejects it for legacy version strings like `3.19.00`). After `update-all`, prove propagation by `ls`/`grep` of one NEW or CHANGED file in a real registered project — the "copied N tool(s)" line is a report, not proof. Note: `update-all` also overwrites `~/.claude/commands/*.md` from the package, so THIS file's source of truth is `commands/cpua.md` in the GSD-T repo; an edit made only in `~/.claude/commands/` is lost on the next propagation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.19.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-graph-search-guard.js
|
|
4
|
+
*
|
|
5
|
+
* M117 - PreToolUse hook on Bash and Grep. Blocks a search that asks a
|
|
6
|
+
* structural question about code, and names the graph command that answers it.
|
|
7
|
+
*
|
|
8
|
+
* [RULE] search-guard-blocks-structural-code-search
|
|
9
|
+
* [RULE] search-guard-unclear-blocks-never-allows
|
|
10
|
+
* [RULE] search-guard-missing-graph-blocks-never-degrades
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS
|
|
13
|
+
* The graph rule already said "query the graph, do not grep around it", and
|
|
14
|
+
* two hooks already enforced it - on the Grep tool and the Read tool. Neither
|
|
15
|
+
* ever fired in real work. In bypass-permissions mode the standing house rule
|
|
16
|
+
* is to work through Bash, so every structural search went out as `grep` in a
|
|
17
|
+
* Bash call and sailed past both hooks. The ledger records the result: two
|
|
18
|
+
* grep events in three months, both from June test probes, against 34,418
|
|
19
|
+
* graph queries all from the graph's own tooling. The rule had no trigger on
|
|
20
|
+
* the path actually taken.
|
|
21
|
+
*
|
|
22
|
+
* THE THREE OUTCOMES (there is no fourth, and none of them continues past a
|
|
23
|
+
* failure):
|
|
24
|
+
* structural BLOCK. Print the graph command to run instead.
|
|
25
|
+
* content ALLOW. Markdown, JSON, SQL, config, prose - the graph does not
|
|
26
|
+
* index them, so it has no answer to route to. Not a bypass.
|
|
27
|
+
* unclear BLOCK. The classifier could not read the intent, and guessing
|
|
28
|
+
* "probably text" is the guess that keeps the rule toothless.
|
|
29
|
+
*
|
|
30
|
+
* A MISSING OR BROKEN GRAPH ALSO BLOCKS. Allowing the grep because the graph is
|
|
31
|
+
* unavailable is the exact fallback that hid the binvoice failure - a project
|
|
32
|
+
* grepping its way through 827 files while the graph sat unbuilt. The answer is
|
|
33
|
+
* `gsd-t graph index`, which the block message says.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IT CANNOT SEE, STATED PLAINLY: it governs tool calls, not reasoning.
|
|
36
|
+
* Reading a file end to end to work out who calls something leaves no pattern
|
|
37
|
+
* for any guard to match. The Stop-time check (gsd-t-graph-use-report.js) is
|
|
38
|
+
* what covers that, by comparing structural work against graph queries issued.
|
|
39
|
+
*
|
|
40
|
+
* --- Stdin (Claude Code PreToolUse payload) --------------------------------
|
|
41
|
+
* { "tool_name": "Bash"|"Grep", "cwd": "...",
|
|
42
|
+
* "tool_input": { "command": "..." } | { "pattern": "...", "glob": "..." } }
|
|
43
|
+
*
|
|
44
|
+
* --- Decision contract -----------------------------------------------------
|
|
45
|
+
* Deny: {"hookSpecificOutput":{"hookEventName":"PreToolUse",
|
|
46
|
+
* "permissionDecision":"deny","permissionDecisionReason":"..."}}
|
|
47
|
+
* Allow: exit 0, no output.
|
|
48
|
+
*
|
|
49
|
+
* Zero dependencies.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
"use strict";
|
|
53
|
+
|
|
54
|
+
const fs = require("fs");
|
|
55
|
+
const path = require("path");
|
|
56
|
+
|
|
57
|
+
function deny(reason) {
|
|
58
|
+
process.stdout.write(JSON.stringify({
|
|
59
|
+
hookSpecificOutput: {
|
|
60
|
+
hookEventName: "PreToolUse",
|
|
61
|
+
permissionDecision: "deny",
|
|
62
|
+
permissionDecisionReason: reason,
|
|
63
|
+
},
|
|
64
|
+
}) + "\n");
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function allow() { process.exit(0); }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Locate the classifier. A project without one has an incomplete install, and
|
|
72
|
+
* the repair is `gsd-t install-check` - not a hunt for a copy elsewhere, and
|
|
73
|
+
* not silently letting every search through.
|
|
74
|
+
*/
|
|
75
|
+
function findClassifier(projectDir) {
|
|
76
|
+
const inProject = path.join(projectDir, "bin", "gsd-t-code-search-classifier.cjs");
|
|
77
|
+
if (fs.existsSync(inProject)) return inProject;
|
|
78
|
+
|
|
79
|
+
const inPackage = path.join(__dirname, "..", "bin", "gsd-t-code-search-classifier.cjs");
|
|
80
|
+
if (fs.existsSync(inPackage)) return inPackage;
|
|
81
|
+
|
|
82
|
+
throw new Error(
|
|
83
|
+
"This project has no copy of the search classifier at " + inProject + ", which " +
|
|
84
|
+
"means its GSD-T install is incomplete. Run 'gsd-t install-check' to repair it."
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Thrown when the settings file exists but cannot be understood. */
|
|
89
|
+
class ConfigUnreadable extends Error {}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Is the guard switched on for this project?
|
|
93
|
+
* An unreadable settings file throws - assuming "on" or "off" would be a guess
|
|
94
|
+
* about what the project wanted. Only an ABSENT file means on, because absence
|
|
95
|
+
* is unambiguous.
|
|
96
|
+
*/
|
|
97
|
+
function isEnabled(projectDir) {
|
|
98
|
+
const p = path.join(projectDir, ".gsd-t", "graph-search-gate.json");
|
|
99
|
+
if (!fs.existsSync(p)) return true;
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFileSync(p, "utf8");
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new ConfigUnreadable(p + " could not be read: " + e.message);
|
|
105
|
+
}
|
|
106
|
+
const cfg = JSON.parse(raw);
|
|
107
|
+
return cfg.enabled !== false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Command parsing -------------------------------------------------------
|
|
111
|
+
//
|
|
112
|
+
// Split a shell command into its pipeline stages, then look at each stage that
|
|
113
|
+
// runs a search program. A structural search buried in the middle of a pipe is
|
|
114
|
+
// still a structural search.
|
|
115
|
+
|
|
116
|
+
const SEARCH_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "ripgrep", "ag", "ack", "find", "ugrep"]);
|
|
117
|
+
|
|
118
|
+
// Flags that take a value in the NEXT argument, so that value is not the pattern.
|
|
119
|
+
const FLAGS_TAKING_VALUE = new Set([
|
|
120
|
+
"-e", "--regexp", "-f", "--file", "--include", "--exclude", "--glob", "-g",
|
|
121
|
+
"-m", "--max-count", "-A", "-B", "-C", "--after-context", "--before-context",
|
|
122
|
+
"--context", "-d", "--directories", "--color", "--colour", "-t", "--type",
|
|
123
|
+
"-name", "-iname", "-path", "-type", "-maxdepth", "-mindepth",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Break a command line into tokens, keeping quoted runs together and dropping
|
|
128
|
+
* the quotes. Good enough for reading a search invocation; it is not a shell.
|
|
129
|
+
*/
|
|
130
|
+
function tokenize(command) {
|
|
131
|
+
const tokens = [];
|
|
132
|
+
let cur = "";
|
|
133
|
+
let quote = null;
|
|
134
|
+
let started = false;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < command.length; i++) {
|
|
137
|
+
const ch = command[i];
|
|
138
|
+
|
|
139
|
+
if (quote) {
|
|
140
|
+
if (ch === quote) { quote = null; continue; }
|
|
141
|
+
cur += ch;
|
|
142
|
+
started = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
|
|
146
|
+
if (ch === "\\" && i + 1 < command.length) { cur += command[i + 1]; started = true; i++; continue; }
|
|
147
|
+
if (/\s/.test(ch)) {
|
|
148
|
+
if (started) { tokens.push(cur); cur = ""; started = false; }
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
cur += ch;
|
|
152
|
+
started = true;
|
|
153
|
+
}
|
|
154
|
+
if (started) tokens.push(cur);
|
|
155
|
+
return tokens;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Split a token list on shell stage separators. */
|
|
159
|
+
function pipelineStages(tokens) {
|
|
160
|
+
const SEPARATORS = new Set(["|", "||", "&&", ";", "&"]);
|
|
161
|
+
const stages = [];
|
|
162
|
+
let cur = [];
|
|
163
|
+
for (const t of tokens) {
|
|
164
|
+
if (SEPARATORS.has(t)) {
|
|
165
|
+
if (cur.length) stages.push(cur);
|
|
166
|
+
cur = [];
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
cur.push(t);
|
|
170
|
+
}
|
|
171
|
+
if (cur.length) stages.push(cur);
|
|
172
|
+
return stages;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
// --- Where the pattern sits, one reader per spelling -----------------------
|
|
177
|
+
|
|
178
|
+
function hasExplicitRegexpFlag(argv) {
|
|
179
|
+
for (const a of argv) {
|
|
180
|
+
if (a === "-e") return true;
|
|
181
|
+
if (a === "--regexp") return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function patternFromRegexpFlag(argv) {
|
|
187
|
+
for (let i = 0; i < argv.length; i++) {
|
|
188
|
+
if (argv[i] === "-e" || argv[i] === "--regexp") return argv[i + 1];
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function patternFromNameFlag(argv) {
|
|
194
|
+
for (let i = 0; i < argv.length; i++) {
|
|
195
|
+
if (argv[i] === "-name" || argv[i] === "-iname") return argv[i + 1];
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function firstPositional(argv) {
|
|
201
|
+
for (let i = 0; i < argv.length; i++) {
|
|
202
|
+
const a = argv[i];
|
|
203
|
+
if (FLAGS_TAKING_VALUE.has(a)) { i++; continue; }
|
|
204
|
+
if (a.startsWith("-")) continue;
|
|
205
|
+
return a;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* From one pipeline stage, work out the search program and its pattern.
|
|
212
|
+
* Returns null when the stage runs no search program.
|
|
213
|
+
*/
|
|
214
|
+
function readSearchStage(stage) {
|
|
215
|
+
let idx = 0;
|
|
216
|
+
|
|
217
|
+
// Step past environment assignments and common prefixes.
|
|
218
|
+
while (idx < stage.length) {
|
|
219
|
+
const t = stage[idx];
|
|
220
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { idx++; continue; }
|
|
221
|
+
if (t === "sudo" || t === "command" || t === "time" || t === "xargs") { idx++; continue; }
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
if (idx >= stage.length) return null;
|
|
225
|
+
|
|
226
|
+
const program = path.basename(stage[idx]);
|
|
227
|
+
if (!SEARCH_PROGRAMS.has(program)) return null;
|
|
228
|
+
|
|
229
|
+
const argv = stage.slice(idx + 1);
|
|
230
|
+
|
|
231
|
+
// Where the pattern sits depends only on how the command was spelled. These
|
|
232
|
+
// are three SPELLINGS of the same argument, not three attempts with earlier
|
|
233
|
+
// ones falling back to later ones: `find` always carries it after -name,
|
|
234
|
+
// grep-family tools carry it after -e when that flag is used and positionally
|
|
235
|
+
// otherwise. Which reader applies is decided up front, from the command
|
|
236
|
+
// itself, so nothing here substitutes for a value that went missing.
|
|
237
|
+
const pattern = program === "find"
|
|
238
|
+
? patternFromNameFlag(argv)
|
|
239
|
+
: (hasExplicitRegexpFlag(argv) ? patternFromRegexpFlag(argv) : firstPositional(argv));
|
|
240
|
+
|
|
241
|
+
// A search program was invoked but no pattern could be read out of it. That
|
|
242
|
+
// is NOT "no search here" - it is a search this guard could not inspect, and
|
|
243
|
+
// reporting null would let it run unexamined. It is returned as unreadable so
|
|
244
|
+
// the caller blocks and says so.
|
|
245
|
+
if (typeof pattern !== "string") {
|
|
246
|
+
return { program, pattern: null, argv, unreadable: true };
|
|
247
|
+
}
|
|
248
|
+
return { program, pattern, argv, unreadable: false };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Graph availability ----------------------------------------------------
|
|
252
|
+
//
|
|
253
|
+
// A structural question needs a graph to answer it. No graph is a BLOCK with
|
|
254
|
+
// "build it", never a quiet permission to grep instead.
|
|
255
|
+
|
|
256
|
+
function graphStorePath(projectDir) {
|
|
257
|
+
const candidates = [
|
|
258
|
+
path.join(projectDir, ".gsd-t", "graphDB", "graph.db"),
|
|
259
|
+
path.join(projectDir, ".gsd-t", "graph.db"),
|
|
260
|
+
];
|
|
261
|
+
for (const c of candidates) {
|
|
262
|
+
if (fs.existsSync(c)) return c;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildNoGraphReason(found, cls) {
|
|
268
|
+
return [
|
|
269
|
+
"This is a structural question about code, and this project has no code graph built.",
|
|
270
|
+
"",
|
|
271
|
+
" searching for: " + found.pattern,
|
|
272
|
+
" which is: " + cls.reason,
|
|
273
|
+
"",
|
|
274
|
+
"Build the graph, then ask it:",
|
|
275
|
+
"",
|
|
276
|
+
" gsd-t graph index",
|
|
277
|
+
"",
|
|
278
|
+
"Grepping instead would answer a question about relationships by matching text,",
|
|
279
|
+
"which is a different and wrong answer. A missing graph is a repairable condition,",
|
|
280
|
+
"not a reason to fall back to grep.",
|
|
281
|
+
].join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildStructuralReason(found, cls) {
|
|
285
|
+
const lines = [];
|
|
286
|
+
|
|
287
|
+
const symbol = cls.symbol === null ? found.pattern : cls.symbol;
|
|
288
|
+
const verb = cls.verb === null ? "who-calls" : cls.verb;
|
|
289
|
+
|
|
290
|
+
lines.push(
|
|
291
|
+
"This is a structural question about code. The graph answers it; grep only matches text.",
|
|
292
|
+
"",
|
|
293
|
+
" searching for: " + found.pattern,
|
|
294
|
+
" which is: " + cls.reason,
|
|
295
|
+
"",
|
|
296
|
+
"Ask the graph instead:",
|
|
297
|
+
"",
|
|
298
|
+
" gsd-t graph " + verb + " " + symbol,
|
|
299
|
+
"",
|
|
300
|
+
"Other verbs: who-imports, who-calls, defines, blast-radius, body.",
|
|
301
|
+
"",
|
|
302
|
+
"If this really is a text search - a phrase in prose, a key in config, a string in a",
|
|
303
|
+
"document - scope it to the files the graph does not index, and it will run:",
|
|
304
|
+
"",
|
|
305
|
+
" grep --include='*.md' --include='*.json' ..."
|
|
306
|
+
);
|
|
307
|
+
return lines.join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildUnclearReason(found, cls) {
|
|
311
|
+
return [
|
|
312
|
+
"This search could be asking about code structure, and that has to be settled before",
|
|
313
|
+
"it runs - a guess in either direction is how the graph rule stopped having teeth.",
|
|
314
|
+
"",
|
|
315
|
+
" searching for: " + found.pattern,
|
|
316
|
+
" why unclear: " + cls.reason,
|
|
317
|
+
"",
|
|
318
|
+
"Say which it is:",
|
|
319
|
+
"",
|
|
320
|
+
" Structure (who calls, who imports, where defined):",
|
|
321
|
+
" gsd-t graph who-calls <symbol>",
|
|
322
|
+
"",
|
|
323
|
+
" Text in files the graph does not index (.md, .json, .sql, config, prose):",
|
|
324
|
+
" add --include='*.md' (or the right extensions) and run it again",
|
|
325
|
+
].join("\n");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
// --- Recording the decision ------------------------------------------------
|
|
330
|
+
//
|
|
331
|
+
// One line per block, into the same ledger the graph's own tooling writes. The
|
|
332
|
+
// Stop-time report reads these to spot a turn that hit structural questions and
|
|
333
|
+
// never asked the graph. Writing it must never change the decision, so a sink
|
|
334
|
+
// failure is swallowed here and nowhere else in this file.
|
|
335
|
+
|
|
336
|
+
function recordBlock(projectDir, program, pattern, verdict) {
|
|
337
|
+
try {
|
|
338
|
+
const dir = path.join(projectDir, ".gsd-t", "graphDB", "logs");
|
|
339
|
+
if (!fs.existsSync(dir)) return;
|
|
340
|
+
let names = fs.readdirSync(dir)
|
|
341
|
+
.filter((n) => n.startsWith("graph-events-") && n.endsWith(".jsonl"));
|
|
342
|
+
if (names.length === 0) return;
|
|
343
|
+
names.sort();
|
|
344
|
+
const file = path.join(dir, names[names.length - 1]);
|
|
345
|
+
const line = JSON.stringify({
|
|
346
|
+
kind: "search-blocked",
|
|
347
|
+
ts: new Date().toISOString(),
|
|
348
|
+
program,
|
|
349
|
+
verdict,
|
|
350
|
+
patternShape: String(pattern).slice(0, 120),
|
|
351
|
+
consumer: "search-guard",
|
|
352
|
+
});
|
|
353
|
+
fs.appendFileSync(file, line + "\n");
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// The block still happens - the decision is made above and does not depend
|
|
356
|
+
// on this record. But the failure is SAID, not swallowed: the Stop-time
|
|
357
|
+
// report reads these lines, so a ledger that silently stopped accepting
|
|
358
|
+
// them would make that report quietly under-count and look clean.
|
|
359
|
+
// stderr, because stdout carries the permission decision.
|
|
360
|
+
process.stderr.write(
|
|
361
|
+
"[GSD-T GRAPH] the search-block record could not be written (" + e.message + "). " +
|
|
362
|
+
"The search was still blocked; the Stop-time graph-use report will under-count.\n"
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function main() {
|
|
368
|
+
let input = "";
|
|
369
|
+
let done = false;
|
|
370
|
+
|
|
371
|
+
process.stdin.setEncoding("utf8");
|
|
372
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
373
|
+
process.stdin.on("end", () => {
|
|
374
|
+
if (done) return;
|
|
375
|
+
done = true;
|
|
376
|
+
decide(input);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// A payload that never arrives is not a search to judge. Exiting 0 here is
|
|
380
|
+
// "nothing was asked", not "a failure was ignored".
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
if (done) return;
|
|
383
|
+
done = true;
|
|
384
|
+
decide(input);
|
|
385
|
+
}, 4000);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function decide(raw) {
|
|
389
|
+
let payload;
|
|
390
|
+
try {
|
|
391
|
+
payload = JSON.parse(raw);
|
|
392
|
+
} catch {
|
|
393
|
+
// No readable payload means no search to classify. Not applicable.
|
|
394
|
+
allow();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const toolName = payload.tool_name;
|
|
399
|
+
if (toolName !== "Bash" && toolName !== "Grep") { allow(); return; }
|
|
400
|
+
|
|
401
|
+
const projectDir = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
|
|
402
|
+
|
|
403
|
+
// Not a GSD-T project - this rule is GSD-T's, so it does not apply.
|
|
404
|
+
if (!fs.existsSync(path.join(projectDir, ".gsd-t"))) { allow(); return; }
|
|
405
|
+
|
|
406
|
+
let enabled;
|
|
407
|
+
try {
|
|
408
|
+
enabled = isEnabled(projectDir);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
deny(
|
|
411
|
+
"The graph-search gate's settings could not be read, so it cannot be known whether " +
|
|
412
|
+
"this search is allowed: " + e.message + "\n\n" +
|
|
413
|
+
"Fix or delete .gsd-t/graph-search-gate.json."
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!enabled) { allow(); return; }
|
|
418
|
+
|
|
419
|
+
let classifySearch;
|
|
420
|
+
try {
|
|
421
|
+
const mod = require(findClassifier(projectDir));
|
|
422
|
+
classifySearch = mod.classifySearch;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
deny("The graph-search gate could not load its classifier: " + e.message);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Gather the search stages this call would run.
|
|
429
|
+
const found = [];
|
|
430
|
+
if (toolName === "Grep") {
|
|
431
|
+
const gi = payload.tool_input;
|
|
432
|
+
if (!gi) { allow(); return; }
|
|
433
|
+
const argv = [];
|
|
434
|
+
if (typeof gi.glob === "string") { argv.push("--glob", gi.glob); }
|
|
435
|
+
if (typeof gi.path === "string") { argv.push(gi.path); }
|
|
436
|
+
if (typeof gi.type === "string") { argv.push("-t" + gi.type); }
|
|
437
|
+
if (typeof gi.pattern !== "string") { allow(); return; }
|
|
438
|
+
found.push({ program: "rg", pattern: gi.pattern, argv });
|
|
439
|
+
} else {
|
|
440
|
+
const command = payload.tool_input === undefined ? undefined : payload.tool_input.command;
|
|
441
|
+
if (typeof command !== "string") { allow(); return; }
|
|
442
|
+
for (const stage of pipelineStages(tokenize(command))) {
|
|
443
|
+
const s = readSearchStage(stage);
|
|
444
|
+
if (s !== null) found.push(s);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (found.length === 0) { allow(); return; }
|
|
449
|
+
|
|
450
|
+
const hasGraph = graphStorePath(projectDir) !== null;
|
|
451
|
+
|
|
452
|
+
for (const f of found) {
|
|
453
|
+
// A search whose pattern could not be read is a search that cannot be
|
|
454
|
+
// judged. It blocks: letting it through would be deciding it is safe on
|
|
455
|
+
// the strength of evidence that could not be gathered.
|
|
456
|
+
if (f.unreadable === true) {
|
|
457
|
+
deny(
|
|
458
|
+
"A " + f.program + " search was invoked but this guard could not read what it " +
|
|
459
|
+
"searches for, so it cannot tell whether the code graph should answer it instead.\n\n" +
|
|
460
|
+
"Rewrite it so the pattern is a plain argument (or use -e <pattern>), or ask the " +
|
|
461
|
+
"graph directly:\n\n gsd-t graph who-calls <symbol>"
|
|
462
|
+
);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let cls;
|
|
467
|
+
try {
|
|
468
|
+
cls = classifySearch({ pattern: f.pattern, argv: f.argv, program: f.program });
|
|
469
|
+
} catch (e) {
|
|
470
|
+
deny("The graph-search gate could not classify this search: " + e.message);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (cls.verdict === "structural") {
|
|
475
|
+
recordBlock(projectDir, f.program, f.pattern, "structural");
|
|
476
|
+
if (hasGraph) deny(buildStructuralReason(f, cls));
|
|
477
|
+
else deny(buildNoGraphReason(f, cls));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (cls.verdict === "unclear") {
|
|
481
|
+
recordBlock(projectDir, f.program, f.pattern, "unclear");
|
|
482
|
+
deny(buildUnclearReason(f, cls));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
allow();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
main();
|