diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c7c2a..6caf4d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,8 @@ jobs: - name: Verify standalone artifact run: | test -s "dist/${{ matrix.artifact }}" - set +e output=$("./dist/${{ matrix.artifact }}" __distribution_probe__ 2>&1) - status=$? - set -e - test "$status" -eq 1 - printf '%s\n' "$output" | grep -F "WHAT failed: expected" + printf '%s\n' "$output" | grep -Fx "vibebloat:dist:ok" shell: bash validate: @@ -71,6 +67,9 @@ jobs: run: python -m pip install --disable-pip-version-check pytest==8.4.1 - name: Run Bun tests run: bun test + env: + TEMP: ${{ runner.temp }} + TMPDIR: ${{ runner.temp }} - name: Run Hermes tests run: python -m pytest -q hermes/tests # Host-platform build only on PRs; full cross-target build runs on diff --git a/INSTALL.md b/INSTALL.md index 98fb102..2ba8d7a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,7 +8,8 @@ install a development checkout without claiming release provenance. - Bun `>=1.3.0` - Git - Python for Hermes integration -- Controlled Presidio and Gitleaks scrubber commands before history scanning +- No external scrubber needed: a source checkout uses the built-in in-process + scrubber. Controlled Presidio and Gitleaks commands are used only by a signed release. ## Source checkout @@ -46,9 +47,11 @@ Start Hermes without safe mode; safe mode intentionally skips user shell hooks. ## OpenClaw -The npm package contains the `vibebloat` runtime extension for OpenClaw `>=2026.4.0`. -Load it through OpenClaw's host-managed extension flow. Do not treat an -`OPENCLAW_SESSION` environment variable as proof that the plugin is installed. +A source checkout builds the `vibebloat` runtime extension for OpenClaw `>=2026.4.0` +to `dist/openclaw-plugin.js` (run `bun run build:openclaw`); once VibeBloat is +published, the npm package will ship it. Load it through OpenClaw's host-managed +extension flow. Do not treat an `OPENCLAW_SESSION` environment variable as proof +that the plugin is installed. ## Universal fallback @@ -63,9 +66,17 @@ sessions before installation. Missing shells or PATH drift stop fallback install ## Verify +The `git-stash-u` guard is situational: it only fires in a working tree that has +uncommitted state to lose, so verify from inside a dirty repo. In a scratch directory: + ```sh +git init -q -b main +printf 'baseline\n' > tracked.txt && git add tracked.txt +git -c user.email=t@t -c user.name=t commit -q -m init +printf 'edit\n' > tracked.txt # leave an uncommitted change printf '%s' '{"tool_input":{"command":"git stash -u"}}' | vibebloat hook ``` -Expected result: exit code `2` plus a four-line scrubbed block receipt. Repeat through -each installed agent before relying on a guard. +Expected result: exit code `2` plus a four-line scrubbed block receipt. Run the same +command from a clean, committed tree and it exits `0` — the guard has nothing to +protect there. Repeat through each installed agent before relying on a guard. diff --git a/README.md b/README.md index e99c5dc..0067fbf 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,8 @@ VibeBloat reads your agent history, finds the commands that actually burned you, ## Quickstart -Fastest, once the package is published: - -```sh -npx vibebloat # zero install, runs on plain Node, bundles its own Bun -``` - -Works right now, from a source checkout (needs [Bun](https://bun.sh) >= 1.3): +VibeBloat is not published to npm yet, so run it from a source checkout (needs +[Bun](https://bun.sh) >= 1.3): ```sh git clone https://github.com/veltri-23/vibebloat @@ -73,7 +68,7 @@ These are the seed set. Your own guards compile from your history on first `init 1. **Discover** local agent histories without printing their paths. 2. **Consent.** Require explicit source confirmation and privacy opt-in before reading anything. -3. **Scrub** locally with Presidio and Gitleaks. Any scrub failure halts the ingest. Nothing reaches a model unscrubbed. +3. **Scrub** locally before anything reaches a model, fail closed. A source or npm install runs the built-in in-process scrubber — known key formats, credentials in URLs, and high-entropy strings — and halts the ingest if a known secret survives. A signed release can instead drive controlled external Presidio and Gitleaks commands. Either way, only the mined rule is ever shared, never raw history. 4. **Mine, review, compile, prove.** Turn repeated incidents into declarative guards and prove each one blocks the command that caused it. 5. **Install** only the guards a human approved, through native agent hooks with shell, git, and filesystem fallbacks. @@ -89,20 +84,15 @@ The core of VibeBloat was designed and built with Codex on GPT-5.6 during OpenAI Where it earned its keep was the boring, load-bearing parts that are easy to get wrong: making the scrubber fail closed instead of fail open, proving each compiled guard against the incident that produced it, and keeping guards as inert data so a community contribution can never become a code-execution vector. Those were the decisions worth getting right, and they are the ones Codex moved fastest on. -> **Codex session ID:** `TBD` — the core-build session, required for submission eligibility. Fill in from the `/feedback` session before submitting. - -## Watch the 3-minute demo - - -[![VibeBloat demo](assets/block.png)](https://youtu.be/YOUR_VIDEO_ID) +## The demo is a reproducible test, not a recording -The same block acceptance path is reproducible locally, no video needed: +Run the two end-to-end shots yourself: ```sh bun test tests/e2e/demo-shot-5.test.ts tests/e2e/demo-shot-6.test.ts ``` -Shot 5 is the kill shot: a fresh Claude Code session hitting the `git stash -u` block in a live tree. Shot 6 is a different agent, Codex, in a fresh session that never saw the incident, hitting the same guard at a different chokepoint. +Shot 5 runs the full learn-then-enforce cycle through the git shell-shim: it lets `git clean -fd` delete a real untracked operational file, detects the live incident, compiles a guard proposal *off* the enforcement path, requires a human approval, then re-runs the command and blocks it with `exit 2` — the file survives the second time. Shot 6 takes one `git-stash-u` guard and asserts it blocks across independent agent transports — the Claude Code `PreToolUse` hook and the OpenClaw plugin chokepoint — so one learned guard enforces identically no matter which agent runs the command. ## Platforms and release verification diff --git a/src/cli.ts b/src/cli.ts index 80c663c..14c67d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1417,7 +1417,10 @@ if (mode === "init") { await coordinator.permitSetupAndDiscover(true); } if (validChoice && before.gate === "B1" && next.gate === "D1") coordinator.confirmEnvironments(true); - if (validChoice && before.gate === "B1.missing") addMissingEnvironment(coordinator, home, effectiveAnswer); + if (validChoice && before.gate === "B1.missing") { + addMissingEnvironment(coordinator, home, effectiveAnswer); + delete next.answers["B1.missing"]; + } if (validChoice && before.gate === "B1.ignore") ignoreEnvironments(coordinator, home, effectiveAnswer); if (validChoice && before.gate === "D1" && next.gate === "D1") { const requested = argumentAssignment("--sources")?.split(",").map((id) => id.trim()).filter(Boolean); diff --git a/src/hooks/shell-shim-handler.ts b/src/hooks/shell-shim-handler.ts index d2ab051..8b7ec04 100644 --- a/src/hooks/shell-shim-handler.ts +++ b/src/hooks/shell-shim-handler.ts @@ -53,7 +53,7 @@ export function runShellShimCommand( undefined, undefined, createFiringRecorder(globalGuardHome()), - )); + ), gitExecutable); if (response.exitCode !== 0) { process.stderr.write(`${response.stderr}\n`); if (response.localWarning) process.stderr.write(`${response.localWarning}\n`); diff --git a/src/hooks/shell-shim.ts b/src/hooks/shell-shim.ts index 1dc98ff..5ea513d 100644 --- a/src/hooks/shell-shim.ts +++ b/src/hooks/shell-shim.ts @@ -3,8 +3,8 @@ import { enrichEventWithContext } from "../situational/context"; import type { Guard } from "../types"; import { hookResponseForVerdict, type HookResponse } from "../hooks"; -export function runShellShim(guards: Guard[], command: string, _shell: "bash" | "zsh" | "fish" | "pwsh", runtime = new Runtime()): HookResponse { - const event = enrichEventWithContext({ chokepoint: "shell", command }, guards); +export function runShellShim(guards: Guard[], command: string, _shell: "bash" | "zsh" | "fish" | "pwsh", runtime = new Runtime(), gitExecutable: string = "git"): HookResponse { + const event = enrichEventWithContext({ chokepoint: "shell", command }, guards, gitExecutable); const verdict = runtime.evaluate(guards, event); return hookResponseForVerdict(verdict); } diff --git a/src/ingest/discovery.ts b/src/ingest/discovery.ts index 1fed7fe..b919408 100644 --- a/src/ingest/discovery.ts +++ b/src/ingest/discovery.ts @@ -48,12 +48,17 @@ interface DiscoveredFile { path: string; device: number; inode: number; + size: number; + modifiedAt: number; + changedAt: number; } function regularFile(path: string): DiscoveredFile | undefined { try { const stat = lstatSync(path); - return stat.isFile() && !stat.isSymbolicLink() ? { path, device: stat.dev, inode: stat.ino } : undefined; + return stat.isFile() && !stat.isSymbolicLink() + ? { path, device: stat.dev, inode: stat.ino, size: stat.size, modifiedAt: stat.mtimeMs, changedAt: stat.ctimeMs } + : undefined; } catch { return undefined; } @@ -61,6 +66,12 @@ function regularFile(path: string): DiscoveredFile | undefined { function collectFiles(root: string, extension: ".json" | ".jsonl"): DiscoveredFile[] { if (!existsSync(root)) return []; + try { + const rootStat = lstatSync(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return []; + } catch { + return []; + } const pending = [root]; const files: DiscoveredFile[] = []; while (pending.length > 0) { @@ -132,7 +143,14 @@ function readDiscoveredFile(file: DiscoveredFile): string { const descriptor = openSync(file.path, "r"); try { const stat = fstatSync(descriptor); - if (!stat.isFile() || stat.dev !== file.device || stat.ino !== file.inode) throw new Error("History file changed after discovery."); + if (!stat.isFile() + || stat.dev !== file.device + || stat.ino !== file.inode + || stat.size !== file.size + || stat.mtimeMs !== file.modifiedAt + || stat.ctimeMs !== file.changedAt) { + throw new Error("History file changed after discovery."); + } return readFileSync(descriptor, "utf8"); } finally { closeSync(descriptor); diff --git a/src/situational/context.ts b/src/situational/context.ts index 130681c..f41c0d1 100644 --- a/src/situational/context.ts +++ b/src/situational/context.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import type { Event, Guard } from "../types"; /** @@ -6,9 +6,9 @@ import type { Event, Guard } from "../types"; * on demand -- see enrichEventWithContext, which gathers a fact only when an * enabled situational guard both needs it and could match the command. */ -export function gitHasUnstagedChanges(cwd: string): boolean { - try { - const out = execSync("git status --porcelain", { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); +export function gitHasUnstagedChanges(cwd: string, gitExecutable: string = "git"): boolean { + try { + const out = execFileSync(gitExecutable, ["status", "--porcelain"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); return out.trim().length > 0; } catch { return false; @@ -62,7 +62,7 @@ export function neededFacts(guards: readonly Guard[], event: Event): NeededFacts * situational guard is in play (the common case) this returns the event * untouched and does zero I/O -- enforcement stays exactly as fast as before. */ -export function enrichEventWithContext(event: Event, guards: readonly Guard[]): Event { +export function enrichEventWithContext(event: Event, guards: readonly Guard[], gitExecutable: string = "git"): Event { const needs = neededFacts(guards, event); if (!needs.cwd && !needs.processes && !needs.unstaged) return event; const cwd = process.cwd(); @@ -70,7 +70,7 @@ export function enrichEventWithContext(event: Event, guards: readonly Guard[]): ...event, ...(needs.cwd ? { cwd } : {}), ...(needs.processes ? { runningProcesses: runningProcessNames() } : {}), - ...(needs.unstaged ? { hasUnstagedChanges: gitHasUnstagedChanges(cwd) } : {}), + ...(needs.unstaged ? { hasUnstagedChanges: gitHasUnstagedChanges(cwd, gitExecutable) } : {}), }; } diff --git a/tests/guard-home.test.ts b/tests/guard-home.test.ts index 10a5494..4840b96 100644 --- a/tests/guard-home.test.ts +++ b/tests/guard-home.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { join } from "node:path"; import { guardDirectories, guardHomeForScope, guardHomes, globalGuardHome, onboardingHome, projectGuardHome } from "../src/guard-home"; const environment = { USERPROFILE: "C:\\Users\\vibe" } as NodeJS.ProcessEnv; @@ -6,19 +7,19 @@ const project = "D:\\code\\demo"; test("guard homes layer global then project by default", () => { expect(guardHomes(environment, project)).toEqual([globalGuardHome(environment), projectGuardHome(project)]); - expect(guardDirectories(environment, project)).toEqual(["C:\\Users\\vibe\\.vibebloat\\guards", "D:\\code\\demo\\.vibebloat\\guards"]); + expect(guardDirectories(environment, project)).toEqual([join("C:\\Users\\vibe", ".vibebloat", "guards"), join(project, ".vibebloat", "guards")]); }); test("VIBEBLOAT_HOME remains an exclusive legacy override", () => { const override = { ...environment, VIBEBLOAT_HOME: "E:\\isolated" } as NodeJS.ProcessEnv; expect(guardHomes(override, project)).toEqual(["E:\\isolated"]); - expect(guardDirectories(override, project)).toEqual(["E:\\isolated\\guards"]); + expect(guardDirectories(override, project)).toEqual([join("E:\\isolated", "guards")]); expect(guardHomeForScope("repo", override, project)).toBe("E:\\isolated"); expect(onboardingHome(override, project)).toBe("E:\\isolated"); }); test("scope selects project or machine write target without migration", () => { - expect(guardHomeForScope("repo", environment, project)).toBe("D:\\code\\demo\\.vibebloat"); - expect(guardHomeForScope("machine", environment, project)).toBe("C:\\Users\\vibe\\.vibebloat"); - expect(onboardingHome(environment, project)).toBe("D:\\code\\demo\\.vibebloat"); + expect(guardHomeForScope("repo", environment, project)).toBe(join(project, ".vibebloat")); + expect(guardHomeForScope("machine", environment, project)).toBe(join("C:\\Users\\vibe", ".vibebloat")); + expect(onboardingHome(environment, project)).toBe(join(project, ".vibebloat")); }); diff --git a/tests/shell-shim.test.ts b/tests/shell-shim.test.ts index 4b9cf4c..723daae 100644 --- a/tests/shell-shim.test.ts +++ b/tests/shell-shim.test.ts @@ -1,7 +1,10 @@ -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { gitStashUntrackedGuard } from "../src/guards"; -import { runShellShim } from "../src/hooks/shell-shim"; -import { type DirtyGitContext, restoreCwd, useDirtyGitCwd } from "./helpers/dirty-git-cwd"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { gitStashUntrackedGuard } from "../src/guards"; +import { runShellShim } from "../src/hooks/shell-shim"; +import { type DirtyGitContext, restoreCwd, useDirtyGitCwd } from "./helpers/dirty-git-cwd"; let dirtyCtx: DirtyGitContext; let originalCwd: string; @@ -19,6 +22,34 @@ test("shell shim allows scoped stash true-negative", () => { expect(runShellShim([gitStashUntrackedGuard], "git stash -u -- src/file.ts", "bash")).toEqual({ exitCode: 0 }); }); -test.each(["--include-untracked", "-a", "--all"])('shell shim blocks destructive stash variant %s', (flag) => { - expect(runShellShim([gitStashUntrackedGuard], `git stash ${flag}`, "bash")).toMatchObject({ exitCode: 2 }); -}); +test.each(["--include-untracked", "-a", "--all"])('shell shim blocks destructive stash variant %s', (flag) => { + expect(runShellShim([gitStashUntrackedGuard], `git stash ${flag}`, "bash")).toMatchObject({ exitCode: 2 }); +}); + +test("shell shim situational context uses recorded real Git instead of PATH shim", () => { + const realGit = Bun.which("git"); + expect(realGit).toBeTruthy(); + const fakeDirectory = mkdtempSync(join(process.env.TEMP ?? ".", "vibebloat-shell-shim-path-")); + const marker = join(fakeDirectory, "path-shim-ran"); + const fakeGit = join(fakeDirectory, process.platform === "win32" ? "git.cmd" : "git"); + writeFileSync(fakeGit, process.platform === "win32" + ? `@echo touched>"${marker}"\r\n@exit /b 0\r\n` + : `#!/bin/sh\nprintf touched > "${marker}"\n`, { mode: 0o755 }); + try { + const handlerUrl = pathToFileURL(join(originalCwd, "src", "hooks", "shell-shim-handler.ts")).href; + const script = ` + import { runShellShimCommand } from ${JSON.stringify(handlerUrl)}; + process.exit(runShellShimCommand(${JSON.stringify(realGit)}, ["status", "--porcelain"], { cliCommand: [process.execPath] })); + `; + const result = Bun.spawnSync([process.execPath, "-e", script], { + cwd: process.cwd(), + env: { ...process.env, PATH: `${fakeDirectory}${delimiter}${process.env.PATH ?? ""}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(existsSync(marker)).toBeFalse(); + } finally { + rmSync(fakeDirectory, { recursive: true, force: true }); + } +}); diff --git a/tests/updater-windows-swap.test.ts b/tests/updater-windows-swap.test.ts index b04416d..21539e0 100644 --- a/tests/updater-windows-swap.test.ts +++ b/tests/updater-windows-swap.test.ts @@ -91,7 +91,7 @@ test("launch uses literal arguments without a command shell", () => { expect(windowsSwapHelperScript).toContain("[IO.File]::Replace"); }); -test("failed detached launch removes staged executable bytes", () => { +test.skipIf(process.platform !== "win32")("failed detached launch removes staged executable bytes", () => { const value = fixture(); let captured: WindowsSwapLaunch | undefined;