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

## 1.0.4 — 2026-09-12

The stable release of the OpenClaw 2026.9 compatibility work shipped through
`1.0.4-beta.8`.

### 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.

## 1.0.4-beta.8 — 2026-09-11

### 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, 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.

## 1.0.4-beta.7 — 2026-09-11

### Fixes
Expand Down
8 changes: 5 additions & 3 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
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.4-beta.7"
version = "1.0.4"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
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/warn-invoice-self-resolution",
};
const ctx = { sessionKey: "invoice-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 invoice = { permission: "instruct", reason: "recover", policyName: "invoice" };
const security = { permission: "instruct", reason: "review", policyName: "security" };

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

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

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: "invoice" };

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: "invoice" };
const ctx = { sessionKey: "invoice-session", runId: "invoice-run" };

expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(true);
expect(gate.shouldInterrupt(verdict, {}, ctx)).toBe(false);
gate.clear({}, { sessionKey: "invoice-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: "invoice-session" };
const deny = { permission: "deny", reason: "never send this" };
const instruct = {
permission: "instruct",
reason: "perform one more recovery pass",
policyName: "invoice",
};

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();
});
});
64 changes: 64 additions & 0 deletions __tests__/hooks/openclaw-invoice-instruct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @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 INVOICE_WORKSPACE_RE = /(?:^|\/)invoice(?:\/|$)/;
const NEEDS_HUMAN_MAPPING_RE = /\bneeds_human_mapping\b/;
const FINANCE_SEND_RE = /\bopenclaw\s+message\s+send\b[\s\S]*?(?:--channel\s+slack|-c\s+slack)[\s\S]*?(?:-t\s+C0AVC7K2XDM|--target\s+C0AVC7K2XDM)/i;
const ESCALATION_STATUS_RE = /(?:needs\s+review|couldn['’]?t\s+file)/i;

describe("Zaum OpenClaw invoice instruct policy", () => {
beforeEach(() => {
clearPolicies();
registerPolicy("warn-invoice-self-resolution", "invoice recovery", (ctx) => {
if (ctx.cli !== "openclaw") return { decision: "allow" };
if (!INVOICE_WORKSPACE_RE.test(String(ctx.session?.cwd ?? ""))) return { decision: "allow" };

const toolInput = JSON.stringify(ctx.toolInput ?? {});
const isPreQuestionCheckpoint = NEEDS_HUMAN_MAPPING_RE.test(toolInput);
const isFinanceEscalation =
ctx.toolName === "Bash" &&
FINANCE_SEND_RE.test(toolInput) &&
ESCALATION_STATUS_RE.test(toolInput);

if (!isPreQuestionCheckpoint && !isFinanceEscalation) return { decision: "allow" };
return { decision: "instruct", reason: "make one more evidence-backed recovery pass" };
}, { events: ["PreToolUse"] });
});

it("emits an OpenClaw instruct verdict for Zaum's observed Finance escalation shape", async () => {
const result = await evaluatePolicies("PreToolUse", {
tool_name: "Bash",
tool_input: {
command: "openclaw message send --channel slack -t C0AVC7K2XDM --message 'Needs review: couldn’t file invoice'",
},
cwd: "/Users/atlas/.openclaw/workspace/invoice",
}, {
cli: "openclaw",
cwd: "/Users/atlas/.openclaw/workspace/invoice",
});

expect(result.decision).toBe("instruct");
expect(JSON.parse(result.stdout)).toMatchObject({
permission: "instruct",
policyName: "failproofai/warn-invoice-self-resolution",
});
});

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 C0AVC7K2XDM --message 'Invoice filed successfully'",
},
cwd: "/Users/atlas/.openclaw/workspace/invoice",
}, {
cli: "openclaw",
cwd: "/Users/atlas/.openclaw/workspace/invoice",
});

expect(result.decision).toBe("allow");
expect(result.stdout).toBe("");
});
});
8 changes: 5 additions & 3 deletions __tests__/hooks/policy-evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ describe("hooks/policy-evaluator", () => {
expect(String(parsed.reason)).toContain("commit first");
});

it("OpenClaw instruct on Stop emits MANDATORY-ACTION deny (revise); on tool events degrades to allow + note", async () => {
it("OpenClaw instruct uses revise on Stop and a one-shot shim verdict on PreToolUse", async () => {
registerPolicy("advise-stop", "desc", () => ({ decision: "instruct", reason: "run tests" }), {
events: ["Stop"],
});
Expand All @@ -373,9 +373,11 @@ describe("hooks/policy-evaluator", () => {
const pre = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { cli: "openclaw" });
expect(pre.decision).toBe("instruct");
const preParsed = JSON.parse(pre.stdout) as Record<string, unknown>;
expect(preParsed.permission).toBe("allow"); // does NOT block — no context channel on tool events
expect(preParsed.permission).toBe("instruct");
expect(preParsed.reason).toContain("prefer git mv");
expect(pre.stderr).toContain("prefer git mv");
expect(preParsed.policyName).toBe("failproofai/advise-tool");
expect(preParsed.policyNames).toEqual(["failproofai/advise-tool"]);
expect(pre.stderr).toBe("");
});

it("Cursor SubagentStop + instruct emits {followup_message} JSON (parity with Stop branch)", async () => {
Expand Down
Loading