c7be3151a3dd7e6b522e7e644c7410d8a469f886

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Strip rtk prefix from commands before checking them

Diff

  1diff --git a/src/deterministic.ts b/src/deterministic.ts
  2index 4651187fd9a567b7d15ad4edb1ccf1ba83d308d2..6ccb8ebee80637d9f0ec4267c84842567e5ac782 100644
  3--- a/src/deterministic.ts
  4+++ b/src/deterministic.ts
  5@@ -6,6 +6,7 @@ import {
  6   SHELL_CONTROL_RE,
  7   STDERR_REDIRECT_RE,
  8 } from "./rules"
  9+import { stripRtkPrefix } from "./normalizer"
 10 
 11 function hasShellControl(cmd: string): boolean {
 12   return SHELL_CONTROL_RE.test(cmd.replace(/"[^"]*"|'[^']*'/g, '""'))
 13@@ -57,7 +58,9 @@ function checkConfigAllow(command: string): Decision | undefined {
 14 function checkBash(command: string): Decision | undefined {
 15   // Trailing `2>&1` / `2>/dev/null` are security-neutral; strip before matching
 16   // so SHELL_CONTROL_RE's `>` check doesn't reject them.
 17-  const cleaned = command.replace(STDERR_REDIRECT_RE, "")
 18+  // Strip optional `rtk ` prefix (transparent output-filter proxy) so policy
 19+  // decisions apply to the proxied command.
 20+  const cleaned = stripRtkPrefix(command.replace(STDERR_REDIRECT_RE, ""))
 21   return checkHardAllow(cleaned) ?? checkConfigAllow(cleaned) ?? checkAskPatterns(cleaned)
 22 }
 23 
 24diff --git a/src/normalizer.ts b/src/normalizer.ts
 25index b36259bec9a41f2a5fb567c6e238a4a5e6d0aeed..5a3eff3627f098e906a5a7f6b83e80b70d38eced 100644
 26--- a/src/normalizer.ts
 27+++ b/src/normalizer.ts
 28@@ -1,11 +1,21 @@
 29 import { createHash } from "crypto"
 30 import { POLICY_VERSION } from "./rules"
 31 
 32+// rtk (https://github.com/rtk-ai/rtk) is a transparent proxy that runs the
 33+// underlying command and reformats output for fewer tokens. Strip the prefix
 34+// so policy decisions match the proxied command.
 35+const RTK_PREFIX_RE = /^rtk\s+/
 36+
 37+export function stripRtkPrefix(command: string): string {
 38+  return command.replace(RTK_PREFIX_RE, "")
 39+}
 40+
 41 function normalizeBashCommand(command: string): string {
 42   let n = command.split(/\s+/).join(" ").trim()
 43   const home = process.env.HOME ?? "~"
 44   n = n.replaceAll("~", home)
 45   n = n.replace(/;+\s*$/, "").trim()
 46+  n = stripRtkPrefix(n)
 47   return n
 48 }
 49 
 50diff --git a/src/rules.ts b/src/rules.ts
 51index 4d714b044fbded167fd2d69e91ad355db432333e..5e4cd8471e7db7ddf8abc28fc639de7680e9cac1 100644
 52--- a/src/rules.ts
 53+++ b/src/rules.ts
 54@@ -1,5 +1,5 @@
 55 // Bump to invalidate all cached decisions when rules change.
 56-export const POLICY_VERSION = 3;
 57+export const POLICY_VERSION = 4;
 58 
 59 // Trailing stderr redirections that are safe to strip before pattern matching.
 60 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
 61diff --git a/test/deterministic.test.ts b/test/deterministic.test.ts
 62index 74a2577cd4b603ecf4c9de37ec8c7c9321f2ce60..5061a8753845b4d3bcf6c75f1bb6dcef54a563fc 100644
 63--- a/test/deterministic.test.ts
 64+++ b/test/deterministic.test.ts
 65@@ -197,3 +197,37 @@ test("webfetch always allowed", () => {
 66 test("unknown tool type returns undefined", () => {
 67   expect(checkDeterministic("mcp__linear__list", { foo: 1 })).toBeUndefined()
 68 })
 69+
 70+describe("rtk prefix is transparent", () => {
 71+  const allowed = [
 72+    "rtk ls -la",
 73+    "rtk ls src",
 74+    "rtk cat package.json",
 75+    "rtk grep -rn 'TODO' src",
 76+    "rtk git status",
 77+    "rtk find src -type f -name '*.ts'",
 78+    "rtk ls -la 2>&1",
 79+  ]
 80+  for (const cmd of allowed) {
 81+    test(`allow: ${cmd}`, () => {
 82+      const d = checkDeterministic("bash", { command: cmd })
 83+      expect(d).toBeDefined()
 84+      expect(d!.decision).toBe("allow")
 85+    })
 86+  }
 87+
 88+  test("rtk-wrapped dangerous command still asks", () => {
 89+    const d = checkDeterministic("bash", { command: "rtk sudo apt install foo" })
 90+    expect(d).toBeDefined()
 91+    expect(d!.decision).toBe("ask")
 92+  })
 93+
 94+  test("rtk alone (no subcommand) falls through to LLM", () => {
 95+    expect(checkDeterministic("bash", { command: "rtk" })).toBeUndefined()
 96+  })
 97+
 98+  test("rtk with non-proxy subcommand falls through to LLM", () => {
 99+    // `rtk smart` -> `smart`, not a known pattern
100+    expect(checkDeterministic("bash", { command: "rtk smart src/index.ts" })).toBeUndefined()
101+  })
102+})
103diff --git a/test/normalizer.test.ts b/test/normalizer.test.ts
104index 8d2246516c30f707073dc20f7e1b14696c324954..b3cf7fe867ffe2021ae4ee60eb42b326d957c01c 100644
105--- a/test/normalizer.test.ts
106+++ b/test/normalizer.test.ts
107@@ -28,6 +28,12 @@ describe("normalizeRequest", () => {
108     const n = normalizeRequest("edit", { path: "/tmp/x" })
109     expect(n).toContain("edit:")
110   })
111+
112+  test("bash — strips rtk prefix so cache key matches proxied command", () => {
113+    const a = normalizeRequest("bash", { command: "rtk ls -la" })
114+    const b = normalizeRequest("bash", { command: "ls -la" })
115+    expect(a).toBe(b)
116+  })
117 })
118 
119 describe("cacheKey", () => {