@alexcodeplace/slopgate 0.3.3 → 0.3.4

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 CHANGED
@@ -41,10 +41,10 @@ A global code-quality / anti-slop gate for Claude Code and git. Engine is shared
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- npm install -g slopgate
44
+ npm install -g @alexcodeplace/slopgate@0.3.4
45
45
  ```
46
46
 
47
- The matching prebuilt native engine for your platform (linux / macOS / Windows × x64 / arm64) is pulled in automatically as an optional dependency no toolchain or build step required.
47
+ The matching prebuilt native engine for your platform is included in the package, and the pinned `ast-grep` runtime needed for structural rules is installed as a package dependency. No Rust toolchain or separate scanner install is required.
48
48
 
49
49
  Then onboard a project:
50
50
 
@@ -90,7 +90,7 @@ slopgate baseline --prune --config .slopgate/config.toml
90
90
 
91
91
  ### Run self-test (validate the engine against bundled fixtures):
92
92
  ```bash
93
- slopgate --self-test --config "$(npm root -g)/slopgate/rules/baseline/selftest.config.toml"
93
+ slopgate --self-test --config "$(npm root -g)/@alexcodeplace/slopgate/rules/baseline/selftest.config.toml"
94
94
  ```
95
95
 
96
96
  ### Run immutable full-repository CI gate:
@@ -113,7 +113,7 @@ jobs:
113
113
  uses: alexcodeplace/slopgate/.github/workflows/slopgate.yml@v1
114
114
  ```
115
115
 
116
- Self-hosted jobs reject fork pull requests before checkout. Use repository-owned branches or isolated disposable runners for untrusted forks.
116
+ The reusable workflow defaults to GitHub-hosted `ubuntu-latest`, so fork pull requests run in an isolated disposable runner. If you override `runner-json` to use a persistent self-hosted runner, do not execute untrusted fork code there.
117
117
 
118
118
  ### Install or reinstall hooks:
119
119
  ```bash
@@ -365,7 +365,7 @@ skipDirs = ["node_modules", "dist"] # dirs to skip
365
365
 
366
366
  # Rule packs
367
367
  baseline = ["no-stubs", "ts-suppress", "as-any"] # built-in baseline packs to enable (opt-in)
368
- rules = [] # project regex rule packs — must be [] (PHASE-2, not yet supported)
368
+ rules = ["./rules/project.json"] # optional project-owned regex packs
369
369
  astRules = "./rules/ast" # dir of .yml AST rules (optional)
370
370
  astDisable = [] # rule ids to disable (escape hatch)
371
371
 
@@ -409,7 +409,7 @@ All are opt-in via the `baseline` array in config. Severity drives the gate thre
409
409
  |------|----------|----------|---------|
410
410
  | `no-stubs` | critical | convention | Stub / placeholder / "not implemented" / deferred-work markers |
411
411
  | `ts-suppress` | high | convention | `@ts-ignore` / `@ts-expect-error` — suppressing tsc instead of fixing the cause |
412
- | `as-any` | high | convention | `as any` casts that disable type safety |
412
+ | `as-any` | high | convention | `as any`, `: any`, `Array<any>`, `Promise<any>`, `Record<string, any>` escape hatches that disable type safety |
413
413
  | `no-narration-comments` | high | convention | Comments that narrate a diff/history/removal (`changed from`, `used to`, `no longer needed`, `track later`) instead of explaining a non-obvious constraint |
414
414
  | `raw-hex` | high | convention | Hardcoded hex / `rgb()` colors + raw multi-digit `px` — use design tokens |
415
415
  | `sql-safety` | critical | convention | `SELECT … FOR UPDATE` with an aggregate (Postgres rejects this at runtime) |
@@ -427,6 +427,7 @@ Loaded automatically alongside the regex packs (the resolver always adds `rules/
427
427
  |---------|---------|
428
428
  | `empty-catch` (ts + tsx) | Empty `catch` block silently swallowing an error |
429
429
  | `inner-html` | Unsafe `innerHTML` / `dangerouslySetInnerHTML` assignment |
430
+ | `focused-test` | Focused tests committed via `test.only` / `it.only` / `describe.only` / `fit` / `fdescribe` |
430
431
  | `target-blank-norel` | `target="_blank"` anchor missing `rel="noopener"` |
431
432
  | `window-in-render` | `window`/`document` access during render (SSR hazard) |
432
433
 
@@ -440,7 +441,7 @@ Opt-in via `stack = ["cloudflare"]`:
440
441
 
441
442
  **Planned (v2+):**
442
443
  - Depth rules — pass-through-fn, delegating-wrapper (Ousterhout symptoms)
443
- - Test-slop rules — test-no-assertion, test-skip-only
444
+ - Test-slop rules — test-no-assertion, test-skip
444
445
  - Custom project **regex** rule packs (the `rules = [...]` field — see [Project-Owned Rules](#project-owned-rules))
445
446
 
446
447
  ### Project-Owned Rules
@@ -463,7 +464,7 @@ Point `astRules` at the directory holding them:
463
464
  astRules = "./rules/ast" # auto-loads all .yml files in this dir
464
465
  ```
465
466
 
466
- > **Note:** Custom **project regex rule packs** (the `rules = [...]` field) are **not yet supported** by the native engine. `rules` must currently be `[]`; a non-empty value errors with `slopgate: project rule pack "<path>" cannot be loaded by the native TOML resolver (PHASE-2: project rule packs)`. Project regex packs are planned (PHASE-2). For now, use ast-grep YAML for custom rules, or one of the built-in baseline/stack packs.
467
+ Project-owned regex rule packs are supported as JSON files listed in `rules = ["./rules/my-pack.json"]`. Use regex rules for precise line/token patterns and ast-grep YAML for structural code shapes.
467
468
 
468
469
  ---
469
470
 
@@ -471,7 +472,7 @@ astRules = "./rules/ast" # auto-loads all .yml files in this dir
471
472
 
472
473
  ### Regex Rules
473
474
 
474
- > **Note:** Authoring *custom project* regex rule packs is **not yet supported** by the native engine (PHASE-2 — see [Project-Owned Rules](#project-owned-rules)). The shape below describes how the **built-in** regex packs are defined (compiled into the engine); it is reference, not a workflow you can wire in via `rules` today. Use ast-grep YAML for custom rules.
475
+ Custom project regex packs use the same keyed JSON shape as the built-in packs and are loaded from paths listed in `rules = [...]` in `.slopgate/config.toml`.
475
476
 
476
477
  Patterns are regex strings with flags (i, m, s, etc.). A pattern matches any line containing the regex.
477
478
 
@@ -723,7 +724,7 @@ Tool crash / timeout → `⚠ skipped: <id> (<reason>)` warning, gate continues
723
724
 
724
725
  - **Git-only** — no other VCS support
725
726
  - **No auto-fix** — violations are reported, not automatically corrected
726
- - **`slopgate audit` command** — planned for v2 (non-gating architecture-health report: hotspots, module shape, co-change coupling, ratchet progress tracking)
727
+ - **`slopgate audit` is advisory** — it reports architecture/ratchet health but does not gate commits
727
728
  - **Embeddings-based semantic duplicate detection** — planned, not in v1
728
729
  - **API-surface diff gate** — track breaking changes to public exports (future)
729
730
  - **LLM-judge skill** — on-demand deep review of architectural debt (separate sub-project)
package/bin/slopgate CHANGED
@@ -11,21 +11,67 @@
11
11
  // 2. <pkg>/vendor/<platform>-<arch>/slopgate-rs (bundled prebuilt; npm-install path)
12
12
  // 3. <pkg>/target/release/slopgate-rs (cargo build --release; dev)
13
13
  import { spawnSync } from 'node:child_process';
14
- import { existsSync } from 'node:fs';
14
+ import { existsSync, realpathSync, readFileSync } from 'node:fs';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { dirname, join } from 'node:path';
17
+ import os from 'node:os';
18
+
19
+ // Re-entry guard: if a resolution bug ever routes the engine back to this shim,
20
+ // fail closed instead of forking forever (a self-pointing $SLOPGATE_BIN froze a
21
+ // workstation with an unbounded nice -10 spawn chain).
22
+ if (process.env.SLOPGATE_SHIM_ACTIVE === '1') {
23
+ process.stderr.write(
24
+ 'slopgate: recursive shim invocation detected (SLOPGATE_BIN points back at the slopgate launcher?) — aborting.\n',
25
+ );
26
+ process.exit(1);
27
+ }
28
+ process.env.SLOPGATE_SHIM_ACTIVE = '1';
29
+
30
+ // Git hooks inherit the caller's nice; never let the gate outrank the desktop.
31
+ try {
32
+ if (os.getPriority() < 0) os.setPriority(0);
33
+ } catch {}
34
+
35
+ const selfPath = fileURLToPath(import.meta.url);
36
+ const pkgRoot = join(dirname(selfPath), '..');
37
+
38
+ // Report the public package version, not the Rust engine's internal crate version.
39
+ if (process.argv.length === 3 && ['--version', '-V'].includes(process.argv[2])) {
40
+ try {
41
+ const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
42
+ process.stdout.write(`slopgate ${version}\n`);
43
+ process.exit(0);
44
+ } catch (error) {
45
+ process.stderr.write(`slopgate: failed to read package version: ${error.message}\n`);
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ // Runtime dependencies such as ast-grep live inside this npm package. Make
51
+ // their bins visible to the native engine even when slopgate is installed
52
+ // globally and the target project has no node_modules of its own.
53
+ const depBin = join(pkgRoot, 'node_modules', '.bin');
54
+ const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || 'PATH';
55
+ process.env[pathKey] = process.env[pathKey] ? `${depBin}${process.platform === 'win32' ? ';' : ':'}${process.env[pathKey]}` : depBin;
17
56
 
18
- const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
19
57
  const binName = process.platform === 'win32' ? 'slopgate-rs.exe' : 'slopgate-rs';
20
58
  const host = `${process.platform}-${process.arch}`;
21
59
 
60
+ const isSelf = (p) => {
61
+ try {
62
+ return realpathSync(p) === realpathSync(selfPath);
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
22
68
  const candidates = [
23
69
  process.env.SLOPGATE_BIN,
24
70
  join(pkgRoot, 'vendor', host, binName),
25
71
  join(pkgRoot, 'target', 'release', binName),
26
72
  ].filter(Boolean);
27
73
 
28
- const bin = candidates.find((p) => existsSync(p));
74
+ const bin = candidates.find((p) => existsSync(p) && !isSelf(p));
29
75
  if (!bin) {
30
76
  process.stderr.write(
31
77
  `slopgate: native engine binary not found for ${host}.\n` +
@@ -0,0 +1,88 @@
1
+ // Slopgate baseline-guard — PreToolUse hook blocking agent bypass of the gate.
2
+ // Covers: Edit/Write to baseline.json/suppressions.json, Bash rm/mv on baseline*,
3
+ // Bash `slopgate baseline` in any form.
4
+ // Exit 2 = block the tool use (PreToolUse protocol). Exit 0 = allow. Always fail-open on errors.
5
+
6
+ import crypto from "node:crypto";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+
12
+ const BASELINE_FILE = /\.slopgate\/(baseline|suppressions)\.json$/;
13
+ const BASELINE_CMD =
14
+ /(^|[;&|\s])(npx\s+|pnpm\s+exec\s+|yarn\s+)?(node\s+[^ ]+\/bin\/slopgate|slopgate)\s+baseline(\s|$)/;
15
+ const BASELINE_RM = /(rm|mv)\s+.*\.slopgate\/baseline/;
16
+
17
+ function payload() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(0, "utf8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function verdict(input) {
26
+ const toolInput = input.tool_input || {};
27
+ switch (input.tool_name) {
28
+ case "Edit":
29
+ case "Write": {
30
+ const filePath = String(toolInput.file_path || "");
31
+ if (BASELINE_FILE.test(filePath)) {
32
+ return `direct write to ${filePath} blocked — edit baseline/suppressions via slopgate CLI only`;
33
+ }
34
+ return null;
35
+ }
36
+ case "Bash": {
37
+ const command = String(toolInput.command || "");
38
+ if (BASELINE_RM.test(command)) {
39
+ return "rm/mv of .slopgate/baseline blocked — baseline integrity guard";
40
+ }
41
+ if (BASELINE_CMD.test(command)) {
42
+ return "slopgate baseline blocked — agent cannot update/create baseline; run in your own terminal";
43
+ }
44
+ return null;
45
+ }
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function recordBypassAttempt(reason) {
52
+ let root;
53
+ try {
54
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
55
+ } catch {
56
+ root = process.cwd();
57
+ }
58
+ try {
59
+ const key = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
60
+ const sess = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".slopgate", "sessions", `${key}.json`), "utf8"));
61
+ const row = JSON.stringify({
62
+ ts: new Date().toISOString(),
63
+ project: path.basename(root),
64
+ projectPath: root,
65
+ model: sess.model || "unknown",
66
+ sessionId: sess.sessionId || null,
67
+ mode: "bypass-attempt",
68
+ ruleId: "baseline-tamper",
69
+ severity: "critical",
70
+ category: "security",
71
+ engine: "bypass-attempt",
72
+ file: null,
73
+ line: null,
74
+ reason
75
+ });
76
+ fs.appendFileSync(path.join(os.homedir(), ".slopgate", "stats.jsonl"), `${row}\n`);
77
+ } catch {
78
+ // fail-open: the deny still stands even when stats cannot be written
79
+ }
80
+ }
81
+
82
+ const reason = verdict(payload());
83
+ if (reason) {
84
+ fs.writeSync(2, `⛔ SLOPGATE GUARD: ${reason}\n`);
85
+ recordBypassAttempt(reason);
86
+ process.exit(2);
87
+ }
88
+ process.exit(0);
@@ -1,60 +1,15 @@
1
1
  #!/usr/bin/env bash
2
- # Slopgate baseline-guard PreToolUse hook blocking agent bypass of the gate.
3
- # Covers: Edit/Write to baseline.json/suppressions.json, Bash rm/mv on baseline*,
4
- # Bash `slopgate baseline` in any form.
5
- # Exit 2 = block the tool use (PreToolUse protocol). Exit 0 = allow. Always fail-open on errors.
6
-
7
- set -euo pipefail
8
- INPUT=$(cat)
9
-
10
- tool_name=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.tool_name||'')}catch{}" "$INPUT" 2>/dev/null || echo '')
11
- tool_input=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(JSON.stringify(j.tool_input||{}))}catch{}" "$INPUT" 2>/dev/null || echo '{}')
12
-
13
- blocked=0
14
- reason=''
15
-
16
- case "$tool_name" in
17
- Edit|Write)
18
- file_path=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.file_path||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
19
- if echo "$file_path" | grep -qE '\.slopgate/(baseline|suppressions)\.json$'; then
20
- blocked=1
21
- reason="direct write to $file_path blocked — edit baseline/suppressions via slopgate CLI only"
22
- fi
23
- ;;
24
- Bash)
25
- cmd=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.command||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
26
- # Block: slopgate baseline (create or --update)
27
- if echo "$cmd" | grep -qE '(^|[;&|[:space:]])(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+|yarn[[:space:]]+)?(node[[:space:]]+[^ ]+/bin/slopgate|slopgate)[[:space:]]+baseline([[:space:]]|$)'; then
28
- blocked=1
29
- reason="slopgate baseline blocked — agent cannot update/create baseline; run in your own terminal"
30
- fi
31
- # Block: rm/mv targeting .slopgate/baseline*
32
- if echo "$cmd" | grep -qE '(rm|mv)\s+.*\.slopgate/baseline'; then
33
- blocked=1
34
- reason="rm/mv of .slopgate/baseline blocked — baseline integrity guard"
35
- fi
36
- ;;
2
+ # Fast-path launcher: every trigger pattern in baseline-guard.mjs requires the literal
3
+ # "slopgate" somewhere in the payload (.slopgate/ paths, `slopgate baseline` commands).
4
+ # The common call carries no such substring, so a pure-bash check answers it without a
5
+ # runtime start (measured: ~67ms runtime boot -> ~5ms). Payload with the literal falls
6
+ # through to the real guard, verdict semantics unchanged.
7
+ payload=""; IFS= read -rd '' payload || true
8
+ case "$payload" in
9
+ *slopgate*) ;;
10
+ *) exit 0 ;;
37
11
  esac
38
-
39
- if [ "$blocked" -eq 1 ]; then
40
- echo " SLOPGATE GUARD: $reason" >&2
41
- # Write bypass-attempt stats row (fail-open)
42
- ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
43
- node -e "
44
- const crypto=require('crypto'),fs=require('fs'),path=require('path'),os=require('os');
45
- const root=process.argv[1], reason=process.argv[2];
46
- try {
47
- const key=crypto.createHash('sha256').update(root).digest('hex').slice(0,16);
48
- const sess=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.slopgate','sessions',key+'.json'),'utf8'));
49
- const row=JSON.stringify({ts:new Date().toISOString(),project:path.basename(root),projectPath:root,
50
- model:sess.model||'unknown',sessionId:sess.sessionId||null,mode:'bypass-attempt',
51
- ruleId:'baseline-tamper',severity:'critical',category:'security',engine:'bypass-attempt',
52
- file:null,line:null,reason});
53
- const gp=path.join(os.homedir(),'.slopgate','stats.jsonl');
54
- fs.appendFileSync(gp,row+'\n');
55
- } catch { /* fail-open */ }
56
- " "$ROOT" "$reason" 2>/dev/null || true
57
- exit 2
58
- fi
59
-
60
- exit 0
12
+ HERE="${BASH_SOURCE[0]%/*}"
13
+ . "$HERE/runtime.sh"
14
+ RUNTIME="$(slopgate_runtime_or_warn baseline-guard)" || exit 0
15
+ printf '%s' "$payload" | "$RUNTIME" "$HERE/baseline-guard.mjs"
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env bash
2
2
  # Slopgate PreToolUse hook — runs --staged before a git commit. Exit 1 → commit blocked.
3
3
  TOOL_JSON=$(cat)
4
- CMD=$(/usr/bin/bun -e "
4
+ # Cheap raw prefilter: a git commit always carries the substring "commit" in the payload,
5
+ # so skip the interpreter spawn on every non-commit Bash call.
6
+ printf '%s' "$TOOL_JSON" | grep -q 'commit' || exit 0
7
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ . "$HERE/runtime.sh"
9
+ RUNTIME="$(slopgate_runtime_or_warn commit-hook)" || exit 0
10
+ CMD=$("$RUNTIME" -e "
5
11
  let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null)
6
12
  echo "$CMD" | grep -qE 'git[[:space:]]+commit' || exit 0
7
13
 
8
14
  ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
9
15
  CONFIG="$ROOT/.slopgate/config.toml"
10
16
  [ -f "$CONFIG" ] || exit 0
11
- HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
- exec /usr/bin/bun "$HERE/../bin/slopgate" --staged --config "$CONFIG"
17
+ exec "$RUNTIME" "$HERE/../bin/slopgate" --staged --config "$CONFIG"
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  # Slopgate PostToolUse hook — single-file scan after Edit/Write.
3
3
  # Exit 2 → stderr feeds back into the agent turn. FAIL-OPEN: any error/timeout → exit 0.
4
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ . "$HERE/runtime.sh"
6
+ RUNTIME="$(slopgate_runtime_or_warn edit-hook)" || exit 0
4
7
  TOOL_JSON=$(cat)
5
- FILE=$(/usr/bin/bun -e "
8
+ FILE=$("$RUNTIME" -e "
6
9
  let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null) || exit 0
7
10
  [ -n "$FILE" ] || exit 0
8
11
  case "$FILE" in *.test.ts|*.test.tsx) exit 0 ;; *.ts|*.tsx|*.astro) ;; *) exit 0 ;; esac
@@ -13,7 +16,6 @@ ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null) || exi
13
16
  CONFIG="$ROOT/.slopgate/config.toml"
14
17
  [ -f "$CONFIG" ] || exit 0
15
18
 
16
- HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
- OUT=$(timeout 5 /usr/bin/bun "$HERE/../bin/slopgate" --file "$FILE" --config "$CONFIG" 2>&1)
19
+ OUT=$(timeout 5 "$RUNTIME" "$HERE/../bin/slopgate" --file "$FILE" --config "$CONFIG" 2>&1)
18
20
  [ "$?" -eq 1 ] && { echo "$OUT" >&2; exit 2; }
19
21
  exit 0
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Resolves the JS runtime the hooks exec. /usr/bin/bun is the workstation's real binary and
3
+ # is tried first so the hot hooks skip the PATH bun wrapper; a host without it (buildboxes)
4
+ # reaches bun or node through PATH, and every hook payload runs on either. A candidate must
5
+ # be named bun or node: a runtime pointed back at a hook would re-enter it on every exec.
6
+ # SLOPGATE_RUNTIME pins one explicitly; SLOPGATE_SYSTEM_RUNTIME overrides the absolute
7
+ # fast-path candidate (set empty to force PATH resolution).
8
+ slopgate_runtime() { # -> path to bun or node on stdout, 1 when neither resolves
9
+ local c
10
+ for c in "${SLOPGATE_RUNTIME:-}" "${SLOPGATE_SYSTEM_RUNTIME-/usr/bin/bun}"; do
11
+ case "${c##*/}" in bun | node) ;; *) continue ;; esac
12
+ [ -x "$c" ] || continue
13
+ printf '%s\n' "$c"
14
+ return 0
15
+ done
16
+ c="$(command -v bun || command -v node)" || return 1
17
+ [ -n "$c" ] || return 1
18
+ printf '%s\n' "$c"
19
+ }
20
+
21
+ slopgate_runtime_or_warn() { # hook-name -> runtime on stdout; warns once, never blocks
22
+ local rt
23
+ rt="$(slopgate_runtime)" || {
24
+ echo "slopgate $1: no bun or node on this host — hook did nothing" >&2
25
+ return 1
26
+ }
27
+ printf '%s\n' "$rt"
28
+ }
@@ -2,8 +2,10 @@
2
2
  # Slopgate SessionStart hook — capture the session-start model for stats attribution.
3
3
  # Only SessionStart receives a `model` field; mid-session /model switches are invisible.
4
4
  # Fail-open: any error leaves no file -> stats resolves model to 'unknown'.
5
+ . "${BASH_SOURCE[0]%/*}/runtime.sh"
6
+ RUNTIME="$(slopgate_runtime_or_warn session-start)" || exit 0
5
7
  ROOT=$(realpath "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null || pwd)
6
- exec node -e '
8
+ exec "$RUNTIME" -e '
7
9
  const crypto = require("crypto"), fs = require("fs"), path = require("path"), os = require("os");
8
10
  const root = process.argv[1];
9
11
  let d = "";
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # Decision regression tests for the agent-facing hooks (baseline-guard, commit-hook,
3
+ # session-start). Guards the block/allow contract across interpreter changes.
4
+ # HOME is sandboxed so stats/session writes never touch the real ~/.slopgate.
5
+ # Run: bash hooks/test-hooks-decisions.sh (exit 0 = all pass)
6
+ set -uo pipefail
7
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ SANDBOX="$(mktemp -d)"
9
+ trap 'rm -rf "$SANDBOX"' EXIT
10
+ export HOME="$SANDBOX"
11
+
12
+ pass=0; fail=0
13
+ chk(){ # script expected_rc label payload
14
+ printf '%s' "$4" | bash "$HERE/$1" >/dev/null 2>&1
15
+ local rc=$?
16
+ if [ "$rc" -eq "$2" ]; then echo "PASS ($rc) $3"; pass=$((pass+1))
17
+ else echo "FAIL (got $rc want $2) $3"; fail=$((fail+1)); fi
18
+ }
19
+
20
+ echo "== baseline-guard (2=block, 0=allow) =="
21
+ chk baseline-guard.sh 2 "Edit baseline.json blocked" '{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}'
22
+ chk baseline-guard.sh 2 "Edit suppressions.json blocked" '{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/suppressions.json"}}'
23
+ chk baseline-guard.sh 0 "Edit normal file allowed" '{"tool_name":"Edit","tool_input":{"file_path":"/x/src/app.ts"}}'
24
+ chk baseline-guard.sh 2 "Bash slopgate baseline blocked" '{"tool_name":"Bash","tool_input":{"command":"slopgate baseline --update"}}'
25
+ BJSON=".slopgate/baseline.json"
26
+ chk baseline-guard.sh 2 "Bash rm baseline blocked" "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"rm $BJSON\"}}"
27
+ chk baseline-guard.sh 0 "Bash benign allowed" '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'
28
+ BCMD="slopgate ""baseline"
29
+ chk baseline-guard.sh 2 "Write baseline.json blocked" '{"tool_name":"Write","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}'
30
+ chk baseline-guard.sh 2 "Bash npx wrapper blocked" "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"npx $BCMD\"}}"
31
+ chk baseline-guard.sh 2 "Bash node bin wrapper blocked" "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"node ./dist/bin/$BCMD --update\"}}"
32
+ chk baseline-guard.sh 2 "Bash mv baseline blocked" "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"mv $BJSON /tmp/x\"}}"
33
+ chk baseline-guard.sh 0 "Read is not gated" '{"tool_name":"Read","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}'
34
+ chk baseline-guard.sh 0 "empty payload allowed" ''
35
+
36
+ # A block must still emit the operator-facing reason and the bypass-attempt stats row.
37
+ mkdir -p "$SANDBOX/.slopgate/sessions"
38
+ skey=$(printf '%s' "$PWD" | sha256sum | cut -c1-16)
39
+ printf '{"model":"opus","sessionId":"s1"}' > "$SANDBOX/.slopgate/sessions/$skey.json"
40
+ blk_err=$(printf '%s' '{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}' | bash "$HERE/baseline-guard.sh" 2>&1 >/dev/null)
41
+ if [[ "$blk_err" == *"SLOPGATE GUARD"* ]]; then echo "PASS (0) block prints the guard reason"; pass=$((pass+1));
42
+ else echo "FAIL block prints the guard reason: $blk_err"; fail=$((fail+1)); fi
43
+ if grep -q '"ruleId":"baseline-tamper"' "$SANDBOX/.slopgate/stats.jsonl" 2>/dev/null; then
44
+ echo "PASS (0) block records a bypass-attempt stats row"; pass=$((pass+1));
45
+ else echo "FAIL block records a bypass-attempt stats row"; fail=$((fail+1)); fi
46
+
47
+ echo "== commit-hook (0=allow/skip; no .slopgate config in sandbox HOME) =="
48
+ chk commit-hook.sh 0 "non-commit Bash fast-skips" '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'
49
+
50
+ echo "== session-start (0, records model, no crash) =="
51
+ chk session-start.sh 0 "session-start records model" '{"model":"opus","session_id":"s1","cwd":"'"$PWD"'"}'
52
+
53
+ echo "== runtime resolution (a host without /usr/bin/bun still runs every hook) =="
54
+ node_bin="$(command -v node)"
55
+ if [ -n "$node_bin" ]; then
56
+ export SLOPGATE_RUNTIME="$node_bin"
57
+ chk baseline-guard.sh 2 "node runtime blocks baseline edit" \
58
+ '{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}'
59
+ chk session-start.sh 0 "node runtime records model" \
60
+ '{"model":"opus","session_id":"s1","cwd":"'"$PWD"'"}'
61
+ unset SLOPGATE_RUNTIME
62
+ else
63
+ echo "FAIL no node on PATH to prove runtime independence"; fail=$((fail+1))
64
+ fi
65
+
66
+ # The hooks name no interpreter by absolute path: one that a box does not have makes every
67
+ # gate exit 0 without a word, which is indistinguishable from a clean scan.
68
+ hardcoded=$(grep -l '/usr/bin/\(bun\|node\)' "$HERE"/baseline-guard.sh "$HERE"/commit-hook.sh \
69
+ "$HERE"/edit-hook.sh "$HERE"/session-start.sh 2>/dev/null)
70
+ if [ -z "$hardcoded" ]; then echo "PASS (0) no hook hardcodes an interpreter path"; pass=$((pass+1));
71
+ else echo "FAIL hooks hardcode an interpreter path: $hardcoded"; fail=$((fail+1)); fi
72
+
73
+ warn=$(printf '%s' '{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}' \
74
+ | env -i HOME="$SANDBOX" PATH=/nonexistent SLOPGATE_SYSTEM_RUNTIME= \
75
+ /bin/bash "$HERE/baseline-guard.sh" 2>&1 >/dev/null)
76
+ rc=$?
77
+ if [ "$rc" -eq 0 ] && [[ "$warn" == *"no bun or node on this host"* ]]; then
78
+ echo "PASS (0) missing runtime says so on stderr"; pass=$((pass+1));
79
+ else echo "FAIL missing runtime is silent (rc=$rc): $warn"; fail=$((fail+1)); fi
80
+
81
+ if (. "$HERE/runtime.sh"; SLOPGATE_RUNTIME="$HERE/baseline-guard.sh" \
82
+ SLOPGATE_SYSTEM_RUNTIME= PATH=/nonexistent slopgate_runtime >/dev/null 2>&1); then
83
+ echo "FAIL a hook path is accepted as the runtime"; fail=$((fail+1));
84
+ else echo "PASS (0) a hook path is rejected as the runtime"; pass=$((pass+1)); fi
85
+
86
+ # Claude Code hands hooks a SOCKET stdin; open("/dev/stdin") on a socket fails ENXIO,
87
+ # so the payload read must consume fd 0 directly (read builtin), never reopen the path.
88
+ sock_err=$(python3 - "$HERE/baseline-guard.sh" <<'PYEOF' 2>&1
89
+ import socket, subprocess, sys
90
+ a, b = socket.socketpair()
91
+ b.send(b'{"tool_name":"Edit","tool_input":{"file_path":"/x/.slopgate/baseline.json"}}')
92
+ b.shutdown(socket.SHUT_WR)
93
+ r = subprocess.run(["bash", sys.argv[1]], stdin=a.fileno(), capture_output=True, text=True, timeout=30)
94
+ sys.stderr.write(r.stderr)
95
+ sys.exit(0 if (r.returncode == 2 and "/dev/stdin" not in r.stderr) else 1)
96
+ PYEOF
97
+ )
98
+ if [ $? -eq 0 ]; then echo "PASS (0) socket stdin still blocks baseline edit"; pass=$((pass+1));
99
+ else echo "FAIL socket stdin: $sock_err"; fail=$((fail+1)); fi
100
+
101
+ echo "---- pass=$pass fail=$fail ----"
102
+ [ "$fail" -eq 0 ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexcodeplace/slopgate",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Global code-quality / anti-slop gate — engine shared, rules per-project.",
6
6
  "license": "MIT",
@@ -30,5 +30,8 @@
30
30
  "scripts": {
31
31
  "self-test": "node bin/slopgate --self-test --config rules/baseline/selftest.config.toml",
32
32
  "test": "cargo test --workspace"
33
+ },
34
+ "dependencies": {
35
+ "@ast-grep/cli": "0.45.3"
33
36
  }
34
37
  }
@@ -0,0 +1,10 @@
1
+ id: focused-test-only-ts
2
+ language: typescript
3
+ severity: error
4
+ message: Focused test committed
5
+ note: '{"severity":"critical","category":"test","resolution":"Remove .only before committing so the full test suite runs."}'
6
+ rule:
7
+ any:
8
+ - pattern: test.only($$$)
9
+ - pattern: it.only($$$)
10
+ - pattern: describe.only($$$)
@@ -0,0 +1,10 @@
1
+ id: focused-test-only-tsx
2
+ language: tsx
3
+ severity: error
4
+ message: Focused test committed
5
+ note: '{"severity":"critical","category":"test","resolution":"Remove .only before committing so the full test suite runs."}'
6
+ rule:
7
+ any:
8
+ - pattern: test.only($$$)
9
+ - pattern: it.only($$$)
10
+ - pattern: describe.only($$$)
@@ -0,0 +1,15 @@
1
+ id: focused-test-shorthand-ts
2
+ language: typescript
3
+ severity: error
4
+ message: Focused test helper committed
5
+ note: '{"severity":"critical","category":"test","resolution":"Replace fit/fdescribe with test/it/describe before committing so the full test suite runs."}'
6
+ files:
7
+ - '**/*.test.ts'
8
+ - '**/*.spec.ts'
9
+ - '**/__tests__/**/*.ts'
10
+ - '**/tests/**/*.ts'
11
+ - '**/fixtures/**/*.ts'
12
+ rule:
13
+ any:
14
+ - pattern: fit($$$)
15
+ - pattern: fdescribe($$$)
@@ -0,0 +1,15 @@
1
+ id: focused-test-shorthand-tsx
2
+ language: tsx
3
+ severity: error
4
+ message: Focused test helper committed
5
+ note: '{"severity":"critical","category":"test","resolution":"Replace fit/fdescribe with test/it/describe before committing so the full test suite runs."}'
6
+ files:
7
+ - '**/*.test.tsx'
8
+ - '**/*.spec.tsx'
9
+ - '**/__tests__/**/*.tsx'
10
+ - '**/tests/**/*.tsx'
11
+ - '**/fixtures/**/*.tsx'
12
+ rule:
13
+ any:
14
+ - pattern: fit($$$)
15
+ - pattern: fdescribe($$$)
@@ -1,12 +1,15 @@
1
1
  id: inner-html-assignment
2
2
  language: tsx
3
3
  severity: error
4
- message: innerHTML / insertAdjacentHTML assignment — XSS surface
4
+ message: innerHTML / insertAdjacentHTML / dangerouslySetInnerHTML — XSS surface
5
5
  note: '{"severity":"high","category":"security","resolution":"Build DOM via safe APIs or sanitize with isomorphic-dompurify."}'
6
6
  rule:
7
7
  any:
8
8
  - pattern: $X.innerHTML = $$$
9
9
  - pattern: $X.insertAdjacentHTML($$$)
10
+ - all:
11
+ - kind: jsx_attribute
12
+ - regex: '^dangerouslySetInnerHTML='
10
13
  ignores:
11
14
  - '**/*.test.ts'
12
15
  - '**/*.test.tsx'
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "file": ".github/workflows/ci.yml",
4
+ "line": 21,
5
+ "column": 20,
6
+ "kind": "expression",
7
+ "message": "property \"platform\" is not defined in object type {os: string}"
8
+ }
9
+ ]
@@ -0,0 +1,11 @@
1
+ [
2
+ {
3
+ "message": "property \"platform\" is not defined in object type {os: string}",
4
+ "snippet": " key: ${{ matrix.platform }}",
5
+ "kind": "expression",
6
+ "filepath": ".github/workflows/ci.yml",
7
+ "line": 21,
8
+ "column": 20,
9
+ "end_column": 35
10
+ }
11
+ ]
@@ -0,0 +1,16 @@
1
+ [
2
+ {
3
+ "file": "scripts/deploy.sh",
4
+ "line": 7,
5
+ "code": "SC2086",
6
+ "level": "warning",
7
+ "message": "Double quote to prevent globbing and word splitting."
8
+ },
9
+ {
10
+ "file": "hooks/commit-hook.sh",
11
+ "line": 2,
12
+ "code": "SC2148",
13
+ "level": "style",
14
+ "message": "Tips depend on target shell and yours is unknown."
15
+ }
16
+ ]
@@ -0,0 +1,18 @@
1
+ [
2
+ {
3
+ "file": "scripts/deploy.sh",
4
+ "line": 7,
5
+ "column": 9,
6
+ "level": "warning",
7
+ "code": 2086,
8
+ "message": "Double quote to prevent globbing and word splitting."
9
+ },
10
+ {
11
+ "file": "hooks/commit-hook.sh",
12
+ "line": 2,
13
+ "column": 1,
14
+ "level": "style",
15
+ "code": "SC2148",
16
+ "message": "Tips depend on target shell and yours is unknown."
17
+ }
18
+ ]
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "file": "./src/lib.rs",
4
+ "line": 3,
5
+ "typo": "retrive",
6
+ "corrections": ["retrieve"]
7
+ },
8
+ {
9
+ "file": "docs/guide.md",
10
+ "line": 9,
11
+ "typo": "succesfully",
12
+ "corrections": ["successfully"]
13
+ }
14
+ ]
@@ -0,0 +1,3 @@
1
+ {"type":"typo","path":"./src/lib.rs","line_num":3,"byte_offset":8,"typo":"retrive","corrections":["retrieve"]}
2
+ {"type":"binary-file","path":"assets/logo.png"}
3
+ {"type":"typo","path":"docs/guide.md","line_num":9,"byte_offset":4,"typo":"succesfully","corrections":["successfully"]}
@@ -13,6 +13,12 @@ const namePlaceholder = 1;
13
13
  /* eslint-disable zync/no-raw-html-in-pages */
14
14
  // changed from foo to bar
15
15
  function bad(el: HTMLElement) { el.innerHTML = '<b>hi</b>'; }
16
+ const _dangerous = <div dangerouslySetInnerHTML={{ __html: html }} />;
17
+ const _safeAttrName = { dangerouslySetInnerHTML: html };
18
+ describe.only('focused suite canary', () => {});
19
+ fdescribe('focused suite shorthand canary', () => {});
20
+ describe('regular suite negative canary', () => {});
21
+ // describe.only('commented focused suite negative canary', () => {});
16
22
  // live-secrets canary:
17
23
  const _sk = "sk_live_aaaaaaaaaaaa";
18
24
  // eval-ban canary:
@@ -0,0 +1,11 @@
1
+ // Focused-test AST canaries.
2
+ test.only('focused test canary', () => {});
3
+ it.only('focused spec canary', () => {});
4
+ fit('focused shorthand canary', () => {});
5
+
6
+ // Negative canaries: regular tests and comments should not match.
7
+ test('regular test', () => {});
8
+ it('regular spec', () => {});
9
+ describe('regular suite', () => {});
10
+ // test.only('commented focused test', () => {});
11
+ // fit('commented focused shorthand', () => {});
Binary file
Binary file
Binary file
Binary file
Binary file