Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
23 changes: 17 additions & 6 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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.
22 changes: 6 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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

<!-- Replace the URL and thumbnail once the video is public -->
[![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

Expand Down
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/shell-shim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/shell-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
22 changes: 20 additions & 2 deletions src/ingest/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,30 @@ 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;
}
}

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) {
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions src/situational/context.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import type { Event, Guard } from "../types";

/**
* Live runtime-fact collectors. These shell out, so they are only ever called
* 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;
Expand Down Expand Up @@ -62,15 +62,15 @@ 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();
return {
...event,
...(needs.cwd ? { cwd } : {}),
...(needs.processes ? { runningProcesses: runningProcessNames() } : {}),
...(needs.unstaged ? { hasUnstagedChanges: gitHasUnstagedChanges(cwd) } : {}),
...(needs.unstaged ? { hasUnstagedChanges: gitHasUnstagedChanges(cwd, gitExecutable) } : {}),
};
}

Expand Down
11 changes: 6 additions & 5 deletions tests/guard-home.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
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;
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"));
});
45 changes: 38 additions & 7 deletions tests/shell-shim.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 });
}
});
2 changes: 1 addition & 1 deletion tests/updater-windows-swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading