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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 1.0.5 — 2026-09-13

Stable OpenClaw 2026.9 compatibility release, validated against default profiles, named profiles, multiple agents, live policy enforcement, local dashboard rendering, and end-to-end ingestion.

### Fixes

- Restore OpenClaw 2026.9.2+ transcript ingestion from per-agent SQLite databases while preserving legacy JSONL support, including dynamically discovered agents under configured extra profile paths and stable per-profile agent namespacing (#796).
- Restore complete OpenClaw sessions in the local dashboard, including live WAL-backed SQLite rows on every supported Node version, project grouping by agent and channel, session viewing, and JSONL downloads (#796).
- Deliver OpenClaw `PreToolUse` `instruct()` decisions as model-visible guidance with a retry gate, while retaining ordinary deny behavior and leaving event canonicalization and transcript ingestion unchanged (#796).
- Install the FailproofAI plugin into every valid default and named OpenClaw profile, resolve the correct agent workspace for policy evaluation, and prune collector cursors after OpenClaw removes retained session rows (#796).

## 1.0.5-beta.1 — 2026-09-12

### Fixes

- Restore OpenClaw 2026.9.2+ observability after live transcripts moved from per-session JSONL files into per-agent SQLite databases. `failproofaid` now discovers every agent profile and configured extra path, tails each SQLite transcript incrementally, handles transcript rewrites without duplicating delivery, and retains legacy JSONL compatibility.

- Restore OpenClaw sessions in the local dashboard's Projects view and session viewer. SQLite and legacy sessions are merged per agent, live SQLite copies win over archived duplicates, missing channels group under `local`, and downloads export the original `event_json` records as JSONL.

- Deliver OpenClaw `PreToolUse` instructions to the agent through its model-visible tool rejection reason. The first matching `instruct()` temporarily interrupts the tool call, while a session-and-policy-scoped retry window lets the agent proceed after following the guidance. Policy source, tool canonicalization, and transcript ingestion remain unchanged.

- Install the FailproofAI plugin into every valid default and named OpenClaw profile, and preserve each agent's resolved workspace across agent and tool hooks so workspace-scoped policies evaluate consistently.

## 1.0.5-beta.0 — 2026-09-12

### Fixes
Expand Down
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,11 @@ maps in `types.ts` (single source of truth).
`stopHookActive`, ≈ Claude's Stop payload), so the 5 `require-*-before-stop`
builtins **enforce** on OpenClaw — a deny becomes a `{action:"revise"}` that
re-runs the turn (unlike Hermes, which has no Stop event at all). **Instruct**
degrades to allow + stderr note on non-Stop events (no additional-context
channel); on Stop it emits the MANDATORY-ACTION deny so the revise loop carries
the directive. **Omitted hooks:** `agent_end` (would double-fire Stop) and
on `PreToolUse` uses a model-visible `blockReason` to interrupt the first
matching tool attempt, then permits retries from that session/policy for five
minutes; other non-Stop events still degrade to allow + stderr note. On Stop it
emits the MANDATORY-ACTION deny so the revise loop carries the directive.
**Omitted hooks:** `agent_end` (would double-fire Stop) and
`message_sending` (outbound-message cancel gate — an OpenClaw-only capability,
deferred).

Expand Down Expand Up @@ -1290,9 +1292,10 @@ Each entry should be a single line: a short description followed by the PR numbe

## Version bumps

When bumping the version, update **only** `package.json` (root). The CI version-consistency
check compares `packages/*/package.json` against root — that directory does not currently
exist, so no other files need updating.
When bumping the version, update both root `package.json` and the
`[workspace.package]` version in root `Cargo.toml`, then refresh `Cargo.lock`. The CLI and
native daemon must report the same version. The CI version-consistency check also compares
any `packages/*/package.json` files against root; that directory does not currently exist.

That is the **npm** version, and it governs the CLI, the daemon and the Cargo workspace.
The two Python packages version **independently of it and of each other** — `fp-cloud-cli` and
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "1.0.5-beta.0"
version = "1.0.5"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
85 changes: 84 additions & 1 deletion __tests__/hooks/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,90 @@ describe("OpenClaw integration", () => {
expect(openclaw.getSettingsPath("project", "/some/where")).toBe(p);
});

it("returns every valid named profile settings file", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousHome = process.env.OPENCLAW_HOME;
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const root = join(tempDir, ".openclaw");
try {
process.env.OPENCLAW_STATE_DIR = root;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_CONFIG_PATH;
for (const name of ["research", "operations"]) {
const profile = join(tempDir, `.openclaw-${name}`);
mkdirSync(profile);
writeFileSync(join(profile, "openclaw.json"), "{}\n");
}

expect(settingsPathsFor(openclaw, "user")).toEqual([
join(root, "openclaw.json"),
join(tempDir, ".openclaw-operations", "openclaw.json"),
join(tempDir, ".openclaw-research", "openclaw.json"),
]);
} finally {
if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
else process.env.OPENCLAW_STATE_DIR = previousStateDir;
if (previousHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = previousHome;
if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("reports hooks installed only when every discovered profile is enabled", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousHome = process.env.OPENCLAW_HOME;
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const root = join(tempDir, ".openclaw");
const named = join(tempDir, ".openclaw-operations");
const configured = (enabled: boolean) => JSON.stringify({
plugins: {
load: { paths: ["/opt/failproofai/openclaw-plugin"] },
entries: { failproofai: { enabled } },
},
});
try {
process.env.OPENCLAW_STATE_DIR = root;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_CONFIG_PATH;
mkdirSync(root);
mkdirSync(named);
writeFileSync(join(root, "openclaw.json"), configured(true));
writeFileSync(join(named, "openclaw.json"), configured(true));

expect(openclaw.hooksInstalledInSettings("user")).toBe(true);

writeFileSync(join(named, "openclaw.json"), configured(false));
expect(openclaw.hooksInstalledInSettings("user")).toBe(false);
} finally {
if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
else process.env.OPENCLAW_STATE_DIR = previousStateDir;
if (previousHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = previousHome;
if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
}
});

it("reports hooks missing when a returned settings file does not exist", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousHome = process.env.OPENCLAW_HOME;
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
try {
process.env.OPENCLAW_STATE_DIR = join(tempDir, ".openclaw");
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_CONFIG_PATH;
expect(openclaw.hooksInstalledInSettings("user")).toBe(false);
} finally {
if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
else process.env.OPENCLAW_STATE_DIR = previousStateDir;
if (previousHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = previousHome;
if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
}
});

it("writeHookEntries registers the plugin path + enables the entry with allowConversationAccess", () => {
const settings: Record<string, unknown> = {};
openclaw.writeHookEntries(settings, "");
Expand Down Expand Up @@ -1798,4 +1882,3 @@ describe("claudeCode — WorktreeCreate is never registered", () => {
expect(hooks.WorktreeCreate[0].hooks[0].command).toBe("echo /tmp/wt");
});
});

81 changes: 81 additions & 0 deletions __tests__/hooks/openclaw-instruct-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from "vitest";
import { evaluatePolicies } from "../../src/hooks/policy-evaluator";
import { clearPolicies, registerPolicy } from "../../src/hooks/policy-registry";

const OPERATIONS_WORKSPACE_RE = /(?:^|\/)operations(?:\/|$)/;
const REVIEW_CHECKPOINT_RE = /\brequires_manual_review\b/;
const NOTIFICATION_SEND_RE = /\bopenclaw\s+message\s+send\b[\s\S]*?(?:--channel\s+slack|-c\s+slack)[\s\S]*?(?:-t\s+C0123456789|--target\s+C0123456789)/i;
const FAILURE_STATUS_RE = /(?:needs\s+review|couldn['’]?t\s+process)/i;

describe("OpenClaw instruct policy", () => {
beforeEach(() => {
clearPolicies();
registerPolicy("retry-before-action", "generic recovery", (ctx) => {
if (ctx.cli !== "openclaw") return { decision: "allow" };
if (!OPERATIONS_WORKSPACE_RE.test(String(ctx.session?.cwd ?? ""))) return { decision: "allow" };

const toolInput = JSON.stringify(ctx.toolInput ?? {});
const isReviewCheckpoint = REVIEW_CHECKPOINT_RE.test(toolInput);
const isNotification =
ctx.toolName === "Bash" &&
NOTIFICATION_SEND_RE.test(toolInput) &&
FAILURE_STATUS_RE.test(toolInput);

if (!isReviewCheckpoint && !isNotification) return { decision: "allow" };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return { decision: "instruct", reason: "make one more evidence-backed recovery pass" };
}, { events: ["PreToolUse"] });
});

it("emits an OpenClaw instruct verdict for a matching escalation", async () => {
const result = await evaluatePolicies("PreToolUse", {
tool_name: "Bash",
tool_input: {
command: "openclaw message send --channel slack -t C0123456789 --message 'Needs review: couldn’t process record'",
},
cwd: "/Users/tester/.openclaw/workspace/operations",
}, {
cli: "openclaw",
cwd: "/Users/tester/.openclaw/workspace/operations",
});

expect(result.decision).toBe("instruct");
expect(JSON.parse(result.stdout)).toMatchObject({
permission: "instruct",
policyName: "failproofai/retry-before-action",
});
});

it("instructs on a review checkpoint without requiring a notification command", async () => {
const result = await evaluatePolicies("PreToolUse", {
tool_name: "Write",
tool_input: { path: "status.json", content: "requires_manual_review" },
cwd: "/Users/tester/.openclaw/workspace/operations",
}, {
cli: "openclaw",
cwd: "/Users/tester/.openclaw/workspace/operations",
});

expect(result.decision).toBe("instruct");
expect(JSON.parse(result.stdout)).toMatchObject({
permission: "instruct",
policyName: "failproofai/retry-before-action",
});
});

it("does not affect unrelated OpenClaw Slack sends", async () => {
const result = await evaluatePolicies("PreToolUse", {
tool_name: "Bash",
tool_input: {
command: "openclaw message send --channel slack -t C0123456789 --message 'Record processed successfully'",
},
cwd: "/Users/tester/.openclaw/workspace/operations",
}, {
cli: "openclaw",
cwd: "/Users/tester/.openclaw/workspace/operations",
});

expect(result.decision).toBe("allow");
expect(result.stdout).toBe("");
});
});
90 changes: 90 additions & 0 deletions __tests__/hooks/openclaw-instruct-retry-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
createInstructRetryGate,
mapBeforeToolVerdict,
} from "../../openclaw-plugin/instruct-retry-gate.js";

describe("OpenClaw instruct retry gate", () => {
it("interrupts the first instruction and permits retries during the window", () => {
let now = 1_000;
const gate = createInstructRetryGate({ windowMs: 300_000, now: () => now });
const verdict = {
permission: "instruct",
reason: "recover once",
policyName: "failproofai/retry-before-escalation",
};
const ctx = { sessionKey: "recovery-session" };

expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(true);
expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(false);

now += 300_001;
expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(true);
});

it("keeps sessions and policies independent", () => {
const gate = createInstructRetryGate();
const recovery = { permission: "instruct", reason: "recover", policyName: "recovery" };
const security = { permission: "instruct", reason: "review", policyName: "security" };

expect(gate.shouldInterrupt(recovery, {}, { sessionKey: "one" })).toBe(true);
expect(gate.shouldInterrupt(recovery, {}, { sessionKey: "one" })).toBe(false);
expect(gate.shouldInterrupt(security, {}, { sessionKey: "one" })).toBe(true);
expect(gate.shouldInterrupt(recovery, {}, { sessionKey: "two" })).toBe(true);
});

it("keeps separate OpenClaw runs independent within one session", () => {
const gate = createInstructRetryGate();
const verdict = { permission: "instruct", reason: "recover", policyName: "recovery" };

expect(gate.shouldInterrupt(verdict, {}, { sessionKey: "one", runId: "run-a" })).toBe(true);
expect(gate.shouldInterrupt(verdict, {}, { sessionKey: "one", runId: "run-a" })).toBe(false);
expect(gate.shouldInterrupt(verdict, {}, { sessionKey: "one", runId: "run-b" })).toBe(true);
});

it("does not share a retry window between anonymous invocations", () => {
const gate = createInstructRetryGate();
const verdict = { permission: "instruct", reason: "recover", policyName: "recovery" };

expect(gate.shouldInterrupt(verdict, {}, {})).toBe(true);
expect(gate.shouldInterrupt(verdict, {}, {})).toBe(true);
});

it("clears the retry window when the session ends", () => {
const gate = createInstructRetryGate();
const verdict = { permission: "instruct", reason: "recover", policyName: "recovery" };
const ctx = { sessionKey: "recovery-session", runId: "recovery-run" };

expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(true);
expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(false);
gate.clear({}, { sessionKey: "recovery-session" });
expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(true);
});

it("maps deny permanently and instruct to a one-shot model-visible rejection", () => {
const gate = createInstructRetryGate();
const ctx = { sessionKey: "recovery-session" };
const deny = { permission: "deny", reason: "never send this" };
const instruct = {
permission: "instruct",
reason: "perform one more recovery pass",
policyName: "recovery",
};

expect(mapBeforeToolVerdict(deny, {}, ctx, gate)).toEqual({
block: true,
blockReason: "never send this",
});
expect(mapBeforeToolVerdict(deny, {}, ctx, gate)).toEqual({
block: true,
blockReason: "never send this",
});
expect(mapBeforeToolVerdict(instruct, {}, ctx, gate)).toEqual({
block: true,
blockReason: "perform one more recovery pass",
});
expect(mapBeforeToolVerdict(instruct, {}, ctx, gate)).toBeUndefined();
expect(mapBeforeToolVerdict({ permission: "allow" }, {}, ctx, gate)).toBeUndefined();
});
});
Loading