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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ omp plugin uninstall @rblaine95/omp-plugins

## Included extensions

| Extension | What it does |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rules-guard` | Enforces the Claude `permissions` allow/deny policy across every omp tool (read/write/edit/find/search/bash/eval/browser), not just `bash`. |
| `usage-status` | Color-coded row above the editor showing remaining usage with reset countdowns for every subscription `/usage` reports (Claude, Codex, Gemini, Grok, OpenCode, Cursor, …), so you don't have to run `/usage`. |
| Extension | What it does |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rules-guard` | Enforces the Claude `permissions` allow/deny policy across every omp tool (read/write/edit/find/search/bash/eval/browser), not just `bash`. Also redacts secret-shaped text (API tokens, private keys, credential URLs) out of what the model sees — including output from `!` / `$` commands, which bypass tool interception entirely. Your terminal and the saved transcript still show the real values. |
| `usage-status` | Color-coded row above the editor showing remaining usage with reset countdowns for every subscription `/usage` reports (Claude, Codex, Gemini, Grok, OpenCode, Cursor, …), so you don't have to run `/usage`. |

## Local development

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
Expand Down
74 changes: 44 additions & 30 deletions bun.lock

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions extensions/rules-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ Denied bash command patterns. `Bash(...)` rules are matched against each command
of a shell-command field (`command`, `cmd`, `script`), so `rm -rf *` or `git push --force`
is blocked in `bash` and any other tool that carries such a field.

Secret-shaped output redaction on `tool_result`, as defense in depth. Substrings that
look like credentials are replaced with `[REDACTED]`:
Secret-shaped text redaction, as defense in depth. It runs in two passes — `tool_result`
for tool output (patched at record time) and `context` for messages on their way to the
model (transient; your terminal and the saved transcript keep the real bytes). Substrings
that look like credentials are replaced with `[REDACTED]`:

- Anthropic (`sk-ant-...`), OpenAI (`sk-...`, `pk-...`)
- Stripe (`sk_live_...`, `rk_test_...`)
Expand All @@ -82,6 +84,25 @@ look like credentials are replaced with `[REDACTED]`:
Bare high-entropy strings, git SHAs, and UUIDs are deliberately not redacted, to keep
false positives out of normal tool output.

### The `!` / `$` escape hatch

User-initiated `!cmd` and `$code` run through `AgentSession.executeBash()` and the Python
kernel, which by design skip tool interception entirely — neither `tool_call` nor
`tool_result` fires. Deny rules therefore do **not** apply: `!cat .env` runs, and that is
intentional, since it is your own shell escape and you are not the threat model. What the
`context` pass guarantees is narrower: credential-shaped text in that command and its
output is redacted before the model sees it.

Coverage is a typed carrier list, not a blind walk over the message tree — user,
developer, custom and tool-result text; `bashExecution` / `pythonExecution` command and
output; `fileMention.files[].content` (`@path` auto-reads); and branch/compaction
summaries. **Adding a message type means adding it to that list.** Two categories are
excluded because rewriting them breaks the provider rather than because they are safe:
signed blocks (assistant text bound to `textSignature`, thinking bound to
`thinkingSignature`) and opaque server-validated replay data (`providerPayload`,
`details`, `redactedThinking`). A secret the model itself echoed into its own reply or
reasoning is therefore not scrubbed.

When a call is blocked the model gets a specific reason instead of a silent failure. The
reason names the matched rule, for example `Blocked by deny policy: "..." matches
Read(**/.env*)`, and tells the model to ask the user for the file instead of reading,
Expand Down
213 changes: 212 additions & 1 deletion extensions/rules-guard/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import rulesGuard, {
type Policy,
parseRule,
pathTokens,
redactMessages,
redactText,
} from "./index";

Expand Down Expand Up @@ -610,13 +611,14 @@ describe("claudeFiles (default settings file locations)", () => {
});

describe("rulesGuard wiring — registration & session_start", () => {
test("sets a label of the expected shape and registers three hooks", () => {
test("sets a label of the expected shape and registers four hooks", () => {
const { pi, handlers, state } = makeMockPi();
rulesGuard(pi);
expect(state.label).toMatch(/^RULES guard \(\d+ deny \/ \d+ allow\)$/);
expect(handlers.has("session_start")).toBe(true);
expect(handlers.has("tool_call")).toBe(true);
expect(handlers.has("tool_result")).toBe(true);
expect(handlers.has("context")).toBe(true);
});
test("session_start notifies only when a UI is present", async () => {
const { pi, handlers } = makeMockPi();
Expand Down Expand Up @@ -753,3 +755,212 @@ describe("fuzzy — redaction idempotence & deny coverage", () => {
}
});
});

// ── context-pass redaction (the `!cat .env` bang hole) ────────────────────────
// Bang output never reaches tool_call/tool_result: `AgentSession.executeBash()`
// skips tool interception by design. It lands as a `bashExecution` message with
// a top-level `output` string — no `content` array — so a content-only walk
// misses it entirely. These fixtures mirror the real message shapes.
const DSN = "postgres://u:p@h:5432/d";

describe("redactMessages — operator-originated carriers", () => {
test("redacts bashExecution output and command (the reported bug)", () => {
const msgs = [
{
role: "bashExecution",
command: `psql ${DSN} -c 'select 1'`,
output: `SECRET=x\nPOSTGRES=${DSN}\n`,
exitCode: 0,
cancelled: false,
truncated: false,
timestamp: 1,
},
];
const out = must(redactMessages(msgs)) as typeof msgs;
expect(out[0]?.output).toBe("SECRET=x\nPOSTGRES=[REDACTED]\n");
expect(out[0]?.command).toBe("psql [REDACTED] -c 'select 1'");
expect(out[0]?.output).not.toContain("postgres://");
expect(out[0]?.command).not.toContain("postgres://");
expect(msgs[0]?.output).toContain(DSN); // input untouched (copy-on-write)
expect(msgs[0]?.command).toContain(DSN);
});

test("redacts pythonExecution output/code and fileMention contents", () => {
const py = must(
redactMessages([
{ role: "pythonExecution", code: `u="${DSN}"`, output: DSN },
]),
) as Array<{ code: string; output: string }>;
expect(py[0]?.output).toBe("[REDACTED]");
expect(py[0]?.code).toBe('u="[REDACTED]"');

const fm = must(
redactMessages([
{ role: "fileMention", files: [{ path: ".env", content: DSN }] },
]),
) as Array<{ files: Array<{ path: string; content: string }> }>;
expect(fm[0]?.files[0]?.content).toBe("[REDACTED]");
expect(fm[0]?.files[0]?.path).toBe(".env");
});

test("redacts user string content, block content, and toolResult text", () => {
const out = must(
redactMessages([
{ role: "user", content: `see ${DSN}` },
{ role: "developer", content: [{ type: "text", text: DSN }] },
{
role: "toolResult",
content: [{ type: "image" }, { type: "text", text: DSN }],
},
]),
) as Array<{ content: string | Array<{ text?: string }> }>;
expect(out[0]?.content).toBe("see [REDACTED]");
const dev = must(out[1]).content as Array<{ text?: string }>;
expect(dev[0]?.text).toBe("[REDACTED]");
const tr = must(out[2]).content as Array<{ type?: string; text?: string }>;
expect(tr[0]?.type).toBe("image"); // non-text block passed through
expect(tr[1]?.text).toBe("[REDACTED]");
});
});

describe("redactMessages — hookMessage content role", () => {
test("redacts hookMessage content", () => {
const out = must(
redactMessages([
{
role: "hookMessage",
customType: "note",
content: [{ type: "text", text: DSN }],
},
]),
) as Array<{ content: Array<{ text?: string }> }>;
expect(must(out[0]).content[0]?.text).toBe("[REDACTED]");
});
});

describe("redactMessages — compaction & branch summaries", () => {
test("redacts branch/compaction summaries including snapcompact blocks", () => {
const out = must(
redactMessages([
{ role: "branchSummary", summary: DSN },
{
role: "compactionSummary",
summary: DSN,
shortSummary: DSN,
blocks: [{ type: "text", text: DSN }],
},
]),
) as Array<Record<string, unknown>>;
expect(out[0]?.["summary"]).toBe("[REDACTED]");
const cs = must(out[1]);
expect(cs["summary"]).toBe("[REDACTED]");
expect(cs["shortSummary"]).toBe("[REDACTED]");
const blocks = cs["blocks"] as Array<{ text?: string }>;
expect(blocks[0]?.text).toBe("[REDACTED]");
});
});

describe("redactMessages — replay-safety invariants", () => {
test("never touches assistant messages (signed text 400s on replay)", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: DSN, textSignature: "sig" },
{ type: "thinking", thinking: DSN, thinkingSignature: "sig" },
],
},
];
expect(redactMessages(msgs)).toBeUndefined();
});

test("skips signed text blocks even outside assistant messages", () => {
const msgs = [
{
role: "user",
content: [{ type: "text", text: DSN, textSignature: "sig" }],
},
];
expect(redactMessages(msgs)).toBeUndefined();
});
});

describe("redactMessages — opaque payloads are never walked", () => {
test("never descends into providerPayload or details", () => {
// The message MUST be rewritten (its text carries a secret) so this proves
// the opaque payloads survive a copy, not merely that nothing happened.
const details = { nested: DSN };
const providerPayload = { items: [{ encrypted_content: DSN }] };
const out = must(
redactMessages([
{
role: "toolResult",
content: [{ type: "text", text: DSN }],
details,
providerPayload,
},
]),
) as Array<Record<string, unknown>>;
const msg = must(out[0]);
expect((msg["content"] as Array<{ text?: string }>)[0]?.text).toBe(
"[REDACTED]",
);
expect(msg["details"]).toBe(details); // same reference, never walked
expect(msg["providerPayload"]).toBe(providerPayload);
expect(details.nested).toBe(DSN); // and byte-identical inside
expect(providerPayload.items[0]?.encrypted_content).toBe(DSN);
});
});

describe("redactMessages — sharing & malformed input", () => {
test("returns undefined when clean, and shares untouched messages", () => {
const clean = { role: "user", content: "nothing here" };
const dirty = { role: "bashExecution", output: DSN };
expect(redactMessages([clean])).toBeUndefined();
const out = must(redactMessages([clean, dirty]));
expect(out[0]).toBe(clean); // identity preserved — no needless allocation
expect(out[1]).not.toBe(dirty);
});

test("tolerates malformed / unknown messages without throwing", () => {
expect(() =>
redactMessages([
null,
42,
{},
{ role: 7 },
{ role: "user" },
{ role: "user", content: 5 },
{ role: "fileMention", files: "nope" },
{ role: "fileMention", files: [null, { content: 1 }] },
{ role: "bashExecution" },
{ role: "brandNewFutureType", output: DSN },
// Inherited `Object.prototype` member: not nullish, not iterable.
{ role: "toString", content: DSN },
]),
).not.toThrow();
// ...and stays inert rather than counting as a content role.
expect(
redactMessages([{ role: "toString", content: DSN }]),
).toBeUndefined();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("rulesGuard wiring — context", () => {
test("handler redacts a bashExecution message; passes clean history", async () => {
const { pi, handlers } = makeMockPi();
rulesGuard(pi);
const onContext = must(handlers.get("context"));
const hit = (await onContext(
{
messages: [{ role: "bashExecution", command: "cat .env", output: DSN }],
},
{},
)) as { messages?: Array<{ output?: string }> } | undefined;
expect(hit?.messages?.[0]?.output).toBe("[REDACTED]");
expect(
await onContext({ messages: [{ role: "user", content: "hi" }] }, {}),
).toBeUndefined();
expect(await onContext({ messages: "nope" }, {})).toBeUndefined();
});
});
Loading