stillhunter 0.0.1 → 0.2.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.
@@ -0,0 +1,255 @@
1
+ // Stillhunter's PreToolUse guard. Stillhunter's evidence lives in .stillhunter/:
2
+ // run folders the runner writes, kept tests, and the spec files a person writes.
3
+ // No agent may write there, so a finding, a kept test or a spec is never made up
4
+ // by the model it judges. The hunter also never reads secrets, the signed-in
5
+ // session a run saves, or the benchmark's answer key.
6
+ // node guard.mjs (Edit, Write, NotebookEdit, Bash, PowerShell)
7
+ // node guard.mjs read (Read, Grep, Glob)
8
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const HUNTER = 'stillhunter:hunter';
14
+ const FOLDER = '.stillhunter';
15
+
16
+ const WRITE_REASON = `Agents can't write in ${FOLDER}/: runs and kept tests come from the runner, and specs from you. Write the spec file yourself.`;
17
+ const SHELL_REASON = `Shell commands can't name ${FOLDER}. Read files there with the Read tool; the runner writes them.`;
18
+ const READ_REASON = 'The hunter does not read secrets, credentials or saved sign-in sessions.';
19
+ const SCRAPE_REASON = `The hunter reads a run folder's result files one at a time with the Read tool, not Grep or Glob across ${FOLDER}/runs.`;
20
+ const HUNTER_BASH_REASON = 'The hunter runs only the stillhunter runner, typed exactly as `node "<plugin root>/dist/hunt.mjs" start|probe|finish|diff ...` with at most a quoted heredoc, and nothing chained or redirected. Read the change with `diff --file <file>` and other files with the Read tool.';
21
+ const ANSWERS_REASON = 'The hunter does not read the benchmark\'s apps or comparison results, or earlier sessions\' transcripts, and does not search a folder that holds them: they are the answer key. Search inside the repository.';
22
+ const SEARCH_REASON = 'The hunter searches only inside the repository it is testing, with no `..` in a pattern. Read a file elsewhere with the Read tool.';
23
+
24
+ function deny(reason) {
25
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
26
+ }
27
+
28
+ function readStdin() {
29
+ try {
30
+ return JSON.parse(readFileSync(0, 'utf8'));
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ const segmentsOf = (p, cwd) => path.resolve(cwd, p).replace(/\\/g, '/').toLowerCase().split('/').filter(Boolean);
37
+ const inFolder = (p, cwd) => segmentsOf(p, cwd).includes(FOLDER);
38
+
39
+ // A shell line that names the folder in any spelling a shell would accept.
40
+ // Backslashes are stripped (a shell removes `\h` to `h`) as well as turned into
41
+ // '/', so `.still\hunter` and `.stillhunter/x` both read as the folder.
42
+ export function namesFolder(command) {
43
+ const lower = command.toLowerCase();
44
+ const stripped = lower.replace(/["'`\\]/g, '');
45
+ const slashed = lower.replace(/\\/g, '/').replace(/["'`]/g, '');
46
+ return stripped.includes(FOLDER) || slashed.includes(FOLDER) || /\.stillh[*?[]|\.st\*|stillhu~\d/.test(slashed);
47
+ }
48
+
49
+ // This file lives in <plugin root>/hooks/, and the hunter's runner is
50
+ // <plugin root>/dist/hunt.mjs, spelled as the skill gave it.
51
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
52
+ export function pluginRoots(env = process.env) {
53
+ const roots = new Set([path.resolve(HERE, '..')]);
54
+ if (env.CLAUDE_PLUGIN_ROOT) roots.add(path.resolve(env.CLAUDE_PLUGIN_ROOT));
55
+ for (const r of [...roots]) {
56
+ try { roots.add(realpathSync(r)); } catch {}
57
+ }
58
+ return [...roots];
59
+ }
60
+
61
+ const SUBCOMMANDS = new Set(['start', 'probe', 'finish', 'diff']);
62
+ // An argument is a plain word, or a quoted string with nothing a shell would
63
+ // expand inside it.
64
+ const ARG = /^(?:[A-Za-z0-9_.,:/=@+%-]+|"[^"\\$`!\n]*"|'[^'\n]*')$/;
65
+ const ARGS = /(?:[A-Za-z0-9_.,:/=@+%-]+|"[^"\\$`!\n]*"|'[^'\n]*')/g;
66
+ const HEREDOC = /\s<<(['"])([A-Za-z_][A-Za-z0-9_]*)\1[ \t]*$/;
67
+
68
+ // The hunter's whole Bash: the runner, exactly as the skill spelled it, with
69
+ // one of its hunt subcommands, plain or quoted arguments and at most a quoted
70
+ // heredoc for stdin. Nothing chains, pipes, substitutes or redirects, so no
71
+ // prefix that looks like the runner can carry another command. It does not
72
+ // parse the shell: it is a strict allow-list of one shape.
73
+ export function hunterBashAllowed(command, roots = pluginRoots()) {
74
+ if (typeof command !== 'string') return false;
75
+ const text = command.replace(/\s+$/, '');
76
+ const newline = text.indexOf('\n');
77
+ let line = newline === -1 ? text : text.slice(0, newline);
78
+ if (newline !== -1) {
79
+ // Only a heredoc may span lines: its quoted delimiter ends the first line,
80
+ // the body holds no line equal to it, and the delimiter alone ends it all.
81
+ const m = HEREDOC.exec(line);
82
+ if (!m) return false;
83
+ const lines = text.slice(newline + 1).split('\n');
84
+ const end = lines.indexOf(m[2]);
85
+ if (end === -1 || end !== lines.length - 1) return false;
86
+ line = line.slice(0, m.index);
87
+ } else if (HEREDOC.test(line)) {
88
+ return false;
89
+ }
90
+ const m = /^node (["'])([^"'\n]+)\/dist\/hunt\.mjs\1 (.*)$/.exec(line);
91
+ if (!m || !sameRoot(m[2], roots)) return false;
92
+ const rest = m[3].trim();
93
+ const words = rest.match(ARGS) ?? [];
94
+ // Every character of the rest must belong to an argument or to the single
95
+ // spaces between them.
96
+ if (words.join(' ') !== rest.replace(/[ \t]+/g, ' ') || !words.every((w) => ARG.test(w))) return false;
97
+ return SUBCOMMANDS.has(words[0] ?? '');
98
+ }
99
+
100
+ // The runner's root as typed: one of the plugin's roots, or another spelling
101
+ // of one (a symlinked temp folder, for example). The text is judged before any
102
+ // path is resolved, because the shell expands it before node starts and path
103
+ // resolution would hide that: `<root>/$(cmd)/..` resolves back to the root, but
104
+ // the shell runs cmd first. So a root must be absolute and literal, with
105
+ // nothing a shell expands or unescapes inside quotes ($, a backtick, a doubled
106
+ // backslash, !) and no `.` or `..` segment, which the system resolves after
107
+ // symlinks and path.resolve before them.
108
+ const ABSOLUTE = /^(?:\/|[A-Za-z]:[\\/])/;
109
+ export function literalRoot(typed) {
110
+ if (!ABSOLUTE.test(typed) || /[$`!\n]|\\\\/.test(typed)) return false;
111
+ return !typed.split(/[\\/]/).some((s) => s === '.' || s === '..');
112
+ }
113
+
114
+ function sameRoot(typed, roots) {
115
+ if (!literalRoot(typed)) return false;
116
+ if (roots.includes(typed)) return true;
117
+ try {
118
+ const real = realpathSync(typed);
119
+ return roots.some((r) => { try { return realpathSync(r) === real; } catch { return false; } });
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ function inRunsFolder(p, cwd) {
126
+ const segments = segmentsOf(p, cwd);
127
+ const i = segments.indexOf(FOLDER);
128
+ return i !== -1 && segments[i + 1] === 'runs';
129
+ }
130
+
131
+ const SECRET_FOLDERS = new Set(['.ssh', '.aws', '.gnupg', '.git']);
132
+ const SECRET_FILES = new Set(['.npmrc', '.netrc', '.pgpass', 'session.json']);
133
+
134
+ export function secretPath(p, cwd) {
135
+ const segments = segmentsOf(p, cwd);
136
+ const name = segments.at(-1) ?? '';
137
+ if (segments.some((s) => SECRET_FOLDERS.has(s))) return true;
138
+ if (SECRET_FILES.has(name)) return true;
139
+ if (name.startsWith('.env')) return true;
140
+ return /\.(pem|key|p12|pfx)$/.test(name);
141
+ }
142
+
143
+ // The benchmark's answer key: its apps hold each planted bug's patch and
144
+ // description, and its comparison folder holds a reproduction of each bug and
145
+ // earlier trials' results. Claude Code's own session transcripts hold earlier
146
+ // runs' findings. A plugin run from a checkout has the benchmark on disk beside
147
+ // it. The hunter reads none of these, and searches no folder that holds them:
148
+ // a Grep or Glob rooted above one sweeps it.
149
+ const ANSWER_FOLDERS = ['/benchmark/apps/', '/benchmark/comparison/'];
150
+ const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
151
+ const within = (p, place) => p === place || p.startsWith(`${place}/`);
152
+
153
+ export function answerPlaces(env = process.env) {
154
+ const transcripts = [path.join(homedir(), '.claude', 'projects')];
155
+ if (env.CLAUDE_CONFIG_DIR) transcripts.push(path.join(path.resolve(env.CLAUDE_CONFIG_DIR), 'projects'));
156
+ const checkouts = pluginRoots(env).map((r) => path.join(r, 'benchmark')).filter((b) => existsSync(b));
157
+ return { read: transcripts.map(norm), search: [...transcripts, ...checkouts].map(norm) };
158
+ }
159
+
160
+ export function answerKeyPath(p, cwd, search = false, places = answerPlaces()) {
161
+ const resolved = path.resolve(cwd, p);
162
+ const full = norm(resolved);
163
+ if (ANSWER_FOLDERS.some((f) => `${full}/`.includes(f)) || places.read.some((k) => within(full, k))) return true;
164
+ if (!search) return false;
165
+ if (full.endsWith('/benchmark') || existsSync(path.join(resolved, 'benchmark', 'apps'))) return true;
166
+ return places.search.some((k) => within(k, full) || within(full, k));
167
+ }
168
+
169
+ // The repository the hunter tests: the nearest folder at or above cwd that
170
+ // holds .git, or cwd itself.
171
+ export function repoRoot(cwd) {
172
+ const start = path.resolve(cwd);
173
+ for (let dir = start; ; dir = path.dirname(dir)) {
174
+ if (existsSync(path.join(dir, '.git'))) return dir;
175
+ if (path.dirname(dir) === dir) return start;
176
+ }
177
+ }
178
+
179
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
180
+ const inside = (p, root) => within(norm(p), norm(root)) || within(norm(real(p)), norm(real(root)));
181
+ const WILD = /[*?[\]{}!]/;
182
+
183
+ // A Grep or Glob by the hunter stays in the repository: its folder, and the
184
+ // literal folder an absolute pattern starts from, are inside it, and no
185
+ // pattern climbs with `..`. Searching anywhere else is how an answer key or
186
+ // another project's files would be found.
187
+ export function searchInRepo(tool, toolInput, cwd) {
188
+ const root = repoRoot(cwd);
189
+ const from = path.resolve(cwd, typeof toolInput.path === 'string' && toolInput.path !== '' ? toolInput.path : '.');
190
+ if (!inside(from, root)) return false;
191
+ const pattern = tool === 'Glob' ? toolInput.pattern : toolInput.glob;
192
+ if (typeof pattern !== 'string' || pattern === '') return true;
193
+ const segments = pattern.replace(/\\/g, '/').split('/');
194
+ if (segments.includes('..')) return false;
195
+ const literal = [];
196
+ for (const s of segments) {
197
+ if (WILD.test(s)) break;
198
+ literal.push(s);
199
+ }
200
+ return inside(path.resolve(from, literal.join('/') || '.'), root);
201
+ }
202
+
203
+ // Claude Code saves a tool result too long to show in the session's own
204
+ // tool-results folder and has the agent Read it there; that one folder of
205
+ // the transcripts is the hunter's own output.
206
+ function ownToolResult(p, input) {
207
+ if (typeof input.transcript_path !== 'string' || typeof input.session_id !== 'string' || !/^[\w-]+$/.test(input.session_id)) return false;
208
+ const folder = path.join(path.dirname(input.transcript_path), input.session_id, 'tool-results');
209
+ return !p.replace(/\\/g, '/').split('/').includes('..') && within(norm(path.resolve(p)), norm(folder));
210
+ }
211
+
212
+ export function guard(input, mode) {
213
+ const tool = typeof input.tool_name === 'string' ? input.tool_name : '';
214
+ const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
215
+ const cwd = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : process.cwd();
216
+ if (mode === 'read') {
217
+ if (input.agent_type !== HUNTER) return null;
218
+ const paths = [];
219
+ if (typeof toolInput.file_path === 'string') paths.push(toolInput.file_path);
220
+ if (typeof toolInput.path === 'string' && toolInput.path !== '') paths.push(toolInput.path);
221
+ const pattern = tool === 'Glob' ? toolInput.pattern : toolInput.glob;
222
+ if (typeof pattern === 'string' && pattern !== '') paths.push(path.join(typeof toolInput.path === 'string' ? toolInput.path : '.', pattern));
223
+ if (paths.some((p) => secretPath(p, cwd))) return deny(READ_REASON);
224
+ const searchRoots = tool === 'Read' ? [] : [typeof toolInput.path === 'string' && toolInput.path !== '' ? toolInput.path : '.'];
225
+ const ownResult = tool === 'Read' && paths.length === 1 && path.isAbsolute(paths[0]) && ownToolResult(paths[0], input);
226
+ if (!ownResult && (paths.some((p) => answerKeyPath(p, cwd)) || searchRoots.some((p) => answerKeyPath(p, cwd, true)))) return deny(ANSWERS_REASON);
227
+ if ((tool === 'Grep' || tool === 'Glob') && !searchInRepo(tool, toolInput, cwd)) return deny(SEARCH_REASON);
228
+ // Reading one result file is fine; sweeping the runs folder with Grep or
229
+ // Glob would scrape every run's saved session and sign-in values.
230
+ if ((tool === 'Grep' || tool === 'Glob') && paths.some((p) => inRunsFolder(p, cwd))) return deny(SCRAPE_REASON);
231
+ return null;
232
+ }
233
+ if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
234
+ const p = toolInput.file_path ?? toolInput.notebook_path;
235
+ return typeof p === 'string' && inFolder(p, cwd) ? deny(WRITE_REASON) : null;
236
+ }
237
+ if (tool === 'Bash' || tool === 'PowerShell') {
238
+ const command = typeof toolInput.command === 'string' ? toolInput.command : '';
239
+ // The hunter is held to a strict allow-list, so no spelling of the folder
240
+ // and no secret path can slip through a shell line it writes.
241
+ if (input.agent_type === HUNTER) return hunterBashAllowed(command) ? null : deny(HUNTER_BASH_REASON);
242
+ return namesFolder(command) ? deny(SHELL_REASON) : null;
243
+ }
244
+ return null;
245
+ }
246
+
247
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('guard.mjs')) {
248
+ try {
249
+ const out = guard(readStdin(), process.argv[2] ?? null);
250
+ if (out) process.stdout.write(JSON.stringify(out) + '\n');
251
+ } catch {
252
+ // A hook never fails the session.
253
+ }
254
+ process.exitCode = 0;
255
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|NotebookEdit|Bash|PowerShell",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node",
10
+ "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs"],
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "matcher": "Read|Grep|Glob",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node",
21
+ "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs", "read"],
22
+ "timeout": 5
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,11 +1,48 @@
1
1
  {
2
2
  "name": "stillhunter",
3
- "version": "0.0.1",
4
- "description": "Finds bugs in a web app change by testing it in a real browser, proving each one twice, and keeping it as a test. In development.",
3
+ "version": "0.2.0",
4
+ "description": "Hunts bugs in a web app change the way a careful QA engineer would, and reports only bugs it has made happen twice in a real browser.",
5
5
  "license": "MIT",
6
+ "keywords": [
7
+ "testing",
8
+ "bug-hunting",
9
+ "playwright",
10
+ "fault-injection",
11
+ "claude-code",
12
+ "claude-code-plugin"
13
+ ],
6
14
  "repository": {
7
15
  "type": "git",
8
16
  "url": "git+https://github.com/stillhunter/stillhunter.git"
9
17
  },
10
- "files": ["README.md", "LICENSE"]
18
+ "type": "module",
19
+ "bin": {
20
+ "stillhunter": "dist/hunt.mjs"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.22.0"
24
+ },
25
+ "files": [
26
+ ".claude-plugin/plugin.json",
27
+ "skills/",
28
+ "agents/",
29
+ "hooks/",
30
+ "dist/",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "node --test \"test/*.test.mjs\"",
36
+ "bench": "node benchmark/bench/cli.mts",
37
+ "bench:check": "tsc -p benchmark/bench/tsconfig.json",
38
+ "bench:test": "node --test \"benchmark/bench/test/*.test.mts\""
39
+ },
40
+ "dependencies": {
41
+ "playwright-core": "1.64.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "22.20.5",
45
+ "typescript": "7.0.2",
46
+ "yaml": "2.9.1"
47
+ }
11
48
  }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: hunt
3
+ description: Hunts for bugs in what you just changed in this web app, or in a feature you name. Tests each idea in a real browser with failing, slow and malformed requests and bad input, and reports only bugs that reproduce twice. Say what you want after it, for example "quick look at the invite dialog" or "the export, only serious problems, 20 minutes". About 10 minutes by default.
4
+ argument-hint: "[what you want hunted, how long, how serious]"
5
+ context: fork
6
+ agent: stillhunter:hunter
7
+ background: false
8
+ allowed-tools:
9
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" start *)
10
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" probe *)
11
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" finish *)
12
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" diff)
13
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" diff *)
14
+ ---
15
+ Hunt for bugs in this repository, following your instructions.
16
+
17
+ What the person asked for, in their words (empty means: the current change, default settings):
18
+
19
+ $ARGUMENTS
20
+
21
+ Your runner prefix `<HUNT>` is, typed exactly:
22
+
23
+ node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs"
24
+
25
+ Turn the request into `start` flags as your instructions say, then start with `<HUNT> start`.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: keep
3
+ description: Saves a finding Stillhunter confirmed as a test under .stillhunter/tests/, so it can be replayed with no model. Name the finding after it, by its number or title in the last hunt report.
4
+ argument-hint: "<finding number or title> [as <test name>]"
5
+ allowed-tools:
6
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" keep *)
7
+ - Read
8
+ - Glob
9
+ ---
10
+ Keep this Stillhunter finding as a test: $ARGUMENTS
11
+
12
+ 1. Find the finding in the most recent Stillhunter hunt report in this conversation. The run id is on the line that starts `stillhunter |` (`run <id>`), and each finding's `repro:` line names its probe (`probes/<probe>.mjs`) and its check (`expectation "<check>"`). Match what the person wrote by the finding's number or title. If the report ends with a `hidden:` line, or the person names a finding you cannot see in it, Read that run's `findings.json` (`.stillhunter/runs/<id>/findings.json`) and match against its full `confirmed` list, since the printed report shows only the findings at or above the severity floor. With no report in this conversation, use Glob for `.stillhunter/runs/*-hunt-*/findings.json`, Read the newest one and match a finding in its `confirmed` list (each has `probe`, `check` and `title`; the run id is the folder name).
13
+ 2. If more than one finding matches, or none does, list the confirmed findings and ask which one. Do not guess.
14
+ 3. Run, typed exactly with the values filled in:
15
+
16
+ node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" keep --run <id> --probe "<probe>" --check "<check>"
17
+
18
+ Quote `<probe>` and `<check>`: a check name can hold spaces. Escape any double quote inside the name with a backslash. Add `--as <name>` (lowercase letters, digits and dashes) when the person gave a name for the test.
19
+ 4. Reply with the block it prints, copied unchanged. Do not edit or write files under `.stillhunter/` yourself.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: replay
3
+ description: Runs the tests kept under .stillhunter/tests/ against the running app, with no model. A test passes when the bug it was kept for is gone. Name tests to run only those; add --watch to watch one in a visible browser.
4
+ argument-hint: "[test names] [--watch]"
5
+ allowed-tools:
6
+ - Bash(node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" replay*)
7
+ ---
8
+ Replay Stillhunter's kept tests. The person asked for: $ARGUMENTS
9
+
10
+ Run, typed exactly, with the test names and `--watch` from the request after `replay` (nothing after it runs every kept test):
11
+
12
+ node "${CLAUDE_PLUGIN_ROOT}/dist/hunt.mjs" replay
13
+
14
+ Use the Bash tool with timeout 600000. Reply with the block it prints, copied unchanged, then one sentence: which tests failed, if any. A failing kept test means the bug it was kept for is there.