From 3081b3f8eba5c769529adf6731ccac7291522b00 Mon Sep 17 00:00:00 2001 From: Hulkito Date: Thu, 19 Mar 2026 23:42:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(security):=20SEC-06=20=E2=80=94=20block=20?= =?UTF-8?q?filesystem=20disclosure=20in=20SHOW=5FAD=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SHOW_AD=true (public demo), agents could leak host filesystem information by posting directory listings through AgentChatBus messages. Changes: - New filesystemDisclosureFilter.ts: 6 detection patterns - Unix tree connector output (>=2 lines with ├── / └── / │) - Unix ls -la output (permissions block + total header) - Windows dir/Get-ChildItem output (column header or >=2 mode lines) - Dense path cluster (>=3 consecutive absolute path lines, line-counting) - /etc/passwd content dump (colon-separated UID:GID format) - SSH public key / authorized_keys content - Integrate checkFilesystemDisclosureOrThrow() in memoryStore.postMessage() and editMessage() — active only when SHOW_AD=true - /api/agents/register returns restricted_mode:true + restrictions array when SHOW_AD=true (cooperative signal for well-behaved MCP clients) - 36 new unit + integration tests (all pass) Design: conservative. Single path mention in prose = allowed. Structured bulk output = blocked. createThread system_prompt intentionally not filtered (admin-controlled, not agent-generated). --- .../services/filesystemDisclosureFilter.ts | 187 ++++++++ .../src/core/services/memoryStore.ts | 5 + agentchatbus-ts/src/transports/http/server.ts | 5 +- .../test_filesystem_disclosure_filter.test.ts | 401 ++++++++++++++++++ 4 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 agentchatbus-ts/src/core/services/filesystemDisclosureFilter.ts create mode 100644 agentchatbus-ts/tests/unit/test_filesystem_disclosure_filter.test.ts diff --git a/agentchatbus-ts/src/core/services/filesystemDisclosureFilter.ts b/agentchatbus-ts/src/core/services/filesystemDisclosureFilter.ts new file mode 100644 index 0000000..b8f6ca8 --- /dev/null +++ b/agentchatbus-ts/src/core/services/filesystemDisclosureFilter.ts @@ -0,0 +1,187 @@ +/** + * AgentChatBus Filesystem Disclosure Filter (SEC-06) + * + * Blocks messages containing filesystem directory listings or file dumps + * when SHOW_AD=true (public demo mode). + * + * This filter is SHOW_AD-conditional — it does not run on private/localhost + * deployments. The existing secret-pattern content filter (contentFilter.ts) + * is always-on and handles a separate concern (API keys, tokens). + * + * Design: conservative. False positives (blocking legitimate messages) are + * worse than false negatives in a technical community. Only structured/bulk + * filesystem output is blocked, not casual path mentions. + */ + +/** Result of a filesystem disclosure check. */ +export interface FilesystemDisclosureResult { + blocked: boolean; + reason: string | null; +} + +// ─── Regex patterns ─────────────────────────────────────────────────────────── + +/** + * Unix-style directory tree connector characters (output of `tree`, `eza`, etc.) + * Matches lines like: ├── src/ │ └── main.ts + */ +const TREE_CONNECTOR_RE = /[├└│]/; + +/** + * Unix `ls -la` style lines: permissions block at start. + * Matches: drwxr-xr-x, -rw-r--r--, lrwxrwxrwx, etc. + */ +const LS_LA_LINE_RE = /^[dlrwxtTsS\-]{9,10}\s+\d+\s+\S+/m; + +/** + * `ls -la` summary header: "total NNN" + */ +const LS_TOTAL_RE = /^total\s+\d+$/m; + +/** + * Windows `dir` / PowerShell listing header or entry. + * Matches lines with: Mode LastWriteTime Length Name + * or d---- 03/19/2026 14:00 folder + * or -a--- 03/19/2026 14:00 12345 file.txt + */ +const WINDOWS_DIR_LINE_RE = /^([d\-][a-rhs\-]{4})\s+\d{2}\/\d{2}\/\d{4}/m; + +/** + * Windows PowerShell `dir` column header line. + */ +const WINDOWS_DIR_HEADER_RE = /Mode\s+LastWriteTime\s+(Length\s+)?Name/i; + +/** + * Absolute Unix path: starts with / followed by at least one path segment. + * Intentionally requires at least 2 segments to avoid matching bare `/`. + */ +const UNIX_ABS_PATH_RE = /^\/[a-zA-Z0-9_\-.]+(?:\/[a-zA-Z0-9_\-. ]*)+\s*$/; + +/** + * Absolute Windows path: drive letter + colon + backslash. + */ +const WIN_ABS_PATH_RE = /^[A-Za-z]:\\(?:[^\\\n]+\\)*[^\\\n]*\s*$/; + +/** + * /etc/passwd line format: username:x:uid:gid:... + * Detects dumped passwd file content (6+ colon-separated fields). + */ +const PASSWD_LINE_RE = /^[a-zA-Z_][a-zA-Z0-9_\-]*:[x*]:?\d+:\d+:/m; + +/** + * SSH private/public key headers — already covered by contentFilter for private + * keys, but we also guard public key files and authorized_keys. + */ +const SSH_AUTH_KEYS_RE = /^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp\d+)\s+AAAA/m; + +/** Minimum number of consecutive path-only lines to trigger "dense path cluster" blocking. */ +const DENSE_PATH_THRESHOLD = 3; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Check whether `text` contains filesystem disclosure patterns. + * + * Returns a result object with `blocked: true` and a descriptive `reason` + * when a pattern fires. Returns `{ blocked: false, reason: null }` otherwise. + * + * This function is pure and has no side effects. + */ +export function checkFilesystemDisclosure(text: string): FilesystemDisclosureResult { + // 1. Unix tree connector characters (multi-line structural output) + const lines = text.split("\n"); + const treeLines = lines.filter((l) => TREE_CONNECTOR_RE.test(l)); + if (treeLines.length >= 2) { + return { blocked: true, reason: "Directory tree output (├── / └── characters)" }; + } + + // 2. ls -la style output: permissions block + total header together + if (LS_LA_LINE_RE.test(text) && LS_TOTAL_RE.test(text)) { + return { blocked: true, reason: "Unix directory listing (ls -la output)" }; + } + + // 3. Windows dir listing: column header or multiple dir entry lines + if (WINDOWS_DIR_HEADER_RE.test(text)) { + return { blocked: true, reason: "Windows directory listing header (dir/Get-ChildItem output)" }; + } + const winDirLines = lines.filter((l) => WINDOWS_DIR_LINE_RE.test(l)); + if (winDirLines.length >= 2) { + return { blocked: true, reason: "Windows directory listing entries (dir/Get-ChildItem output)" }; + } + + // 4. Dense path cluster: ≥ DENSE_PATH_THRESHOLD consecutive path-only lines + let consecutivePaths = 0; + let maxConsecutivePaths = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + consecutivePaths = 0; + continue; + } + if (UNIX_ABS_PATH_RE.test(trimmed) || WIN_ABS_PATH_RE.test(trimmed)) { + consecutivePaths++; + maxConsecutivePaths = Math.max(maxConsecutivePaths, consecutivePaths); + } else { + consecutivePaths = 0; + } + } + if (maxConsecutivePaths >= DENSE_PATH_THRESHOLD) { + return { + blocked: true, + reason: `Dense filesystem path cluster (${maxConsecutivePaths} consecutive path lines)`, + }; + } + + // 5. /etc/passwd content dump + if (PASSWD_LINE_RE.test(text)) { + return { blocked: true, reason: "Sensitive file content (/etc/passwd format)" }; + } + + // 6. SSH authorized_keys / public key dump + if (SSH_AUTH_KEYS_RE.test(text)) { + return { blocked: true, reason: "SSH public key or authorized_keys content" }; + } + + return { blocked: false, reason: null }; +} + +/** + * Check whether the filesystem disclosure filter is active. + * Active when AGENTCHATBUS_SHOW_AD=true (public demo mode). + */ +export function isFilesystemDisclosureFilterActive(): boolean { + const showAd = process.env.AGENTCHATBUS_SHOW_AD; + if (!showAd) return false; + return ["1", "true", "yes"].includes(showAd.trim().toLowerCase()); +} + +/** + * Check content and throw FilesystemDisclosureError if blocked. + * No-op when SHOW_AD is not set or false. + * + * @throws FilesystemDisclosureError when disclosure is detected and filter is active. + */ +export function checkFilesystemDisclosureOrThrow(text: string): void { + if (!isFilesystemDisclosureFilterActive()) return; + + const result = checkFilesystemDisclosure(text); + if (result.blocked && result.reason) { + throw new FilesystemDisclosureError(result.reason); + } +} + +/** + * Error thrown when a message is blocked by the filesystem disclosure filter. + */ +export class FilesystemDisclosureError extends Error { + public readonly disclosureReason: string; + + constructor(disclosureReason: string) { + super( + `Content blocked in demo mode: ${disclosureReason}. ` + + "Filesystem listings and path dumps are not allowed on public instances." + ); + this.name = "FilesystemDisclosureError"; + this.disclosureReason = disclosureReason; + } +} diff --git a/agentchatbus-ts/src/core/services/memoryStore.ts b/agentchatbus-ts/src/core/services/memoryStore.ts index 363e54f..6683c80 100644 --- a/agentchatbus-ts/src/core/services/memoryStore.ts +++ b/agentchatbus-ts/src/core/services/memoryStore.ts @@ -17,6 +17,7 @@ import { eventBus } from "../../shared/eventBus.js"; import { generateAgentEmoji } from "../../main.js"; import { registerStore } from "./storeSingleton.js"; import { checkContentOrThrow, ContentFilterError } from "./contentFilter.js"; +import { checkFilesystemDisclosureOrThrow, FilesystemDisclosureError } from "./filesystemDisclosureFilter.js"; import { BUS_VERSION, ENABLE_HANDOFF_TARGET, ENABLE_STOP_REASON, ENABLE_PRIORITY, getConfig } from "../config/env.js"; /** Constant-time string comparison to prevent timing attacks on tokens */ @@ -1600,6 +1601,8 @@ export class MemoryStore { // Content filter check (UP-07) checkContentOrThrow(input.content); + // Filesystem disclosure filter (SEC-06) — active only when SHOW_AD=true + checkFilesystemDisclosureOrThrow(input.content); const latestSeq = this.getLatestSeq(input.threadId); const agent = this.getAgentById(input.author); @@ -1862,6 +1865,8 @@ export class MemoryStore { } // Fix #7: Content filter on edited content checkContentOrThrow(newContent); + // Filesystem disclosure filter (SEC-06) — active only when SHOW_AD=true + checkFilesystemDisclosureOrThrow(newContent); const newVersion = (message.edit_version || 0) + 1; const edits = this.messageEditHistory.get(messageId) || []; edits.push({ diff --git a/agentchatbus-ts/src/transports/http/server.ts b/agentchatbus-ts/src/transports/http/server.ts index 750b8b3..7219cb9 100644 --- a/agentchatbus-ts/src/transports/http/server.ts +++ b/agentchatbus-ts/src/transports/http/server.ts @@ -660,6 +660,8 @@ export function createHttpServer() { skills: Array.isArray(body.skills) ? body.skills : undefined }); reply.code(200); + const showAd = process.env.AGENTCHATBUS_SHOW_AD; + const isShowAd = showAd ? ["1", "true", "yes"].includes(showAd.trim().toLowerCase()) : false; return { ok: true, id: agent.id, @@ -669,7 +671,8 @@ export function createHttpServer() { token: agent.token, capabilities: agent.capabilities, skills: agent.skills, - emoji: (agent as any).emoji || "🤖" + emoji: (agent as any).emoji || "🤖", + ...(isShowAd ? { restricted_mode: true, restrictions: ["no_filesystem_disclosure"] } : {}) }; } catch (err) { console.error("Registration error:", err); diff --git a/agentchatbus-ts/tests/unit/test_filesystem_disclosure_filter.test.ts b/agentchatbus-ts/tests/unit/test_filesystem_disclosure_filter.test.ts new file mode 100644 index 0000000..5ffc488 --- /dev/null +++ b/agentchatbus-ts/tests/unit/test_filesystem_disclosure_filter.test.ts @@ -0,0 +1,401 @@ +/** + * Unit tests for SEC-06: Filesystem Disclosure Filter. + * + * Tests the pure detection logic, SHOW_AD gating, MemoryStore integration, + * and the restricted_mode registration signal. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + checkFilesystemDisclosure, + checkFilesystemDisclosureOrThrow, + isFilesystemDisclosureFilterActive, + FilesystemDisclosureError, +} from "../../src/core/services/filesystemDisclosureFilter.js"; +import { MemoryStore } from "../../src/core/services/memoryStore.js"; + +// ─── Pure detection tests ───────────────────────────────────────────────────── + +describe("checkFilesystemDisclosure", () => { + describe("allowed — non-disclosure content", () => { + it("allows normal chat messages", () => { + const { blocked } = checkFilesystemDisclosure("The refactor looks good, great work!"); + expect(blocked).toBe(false); + }); + + it("allows a single path mention in a sentence", () => { + const { blocked } = checkFilesystemDisclosure( + "Please check the file at C:\\Users\\me\\project\\src\\main.ts before merging." + ); + expect(blocked).toBe(false); + }); + + it("allows a single Unix path mention", () => { + const { blocked } = checkFilesystemDisclosure( + "The config lives at /etc/nginx/nginx.conf — do not change the worker_processes line." + ); + expect(blocked).toBe(false); + }); + + it("allows a casual mention of ~/.ssh/", () => { + const { blocked } = checkFilesystemDisclosure( + "Always protect your ~/.ssh/ directory and restrict permissions to 700." + ); + expect(blocked).toBe(false); + }); + + it("allows code with 1-2 path references", () => { + const { blocked } = checkFilesystemDisclosure( + "```python\nwith open('/tmp/output.txt', 'w') as f:\n f.write(result)\n```" + ); + expect(blocked).toBe(false); + }); + + it("allows technical discussion about ls flags", () => { + const { blocked } = checkFilesystemDisclosure( + "Use `ls -la` to list hidden files. The total line shows disk block usage." + ); + expect(blocked).toBe(false); + }); + + it("allows tree as a word without connector chars", () => { + const { blocked } = checkFilesystemDisclosure( + "The component tree looks clean. No circular dependencies detected." + ); + expect(blocked).toBe(false); + }); + }); + + describe("blocked — Unix tree output", () => { + it("blocks output with ├── connectors (2+ lines)", () => { + const text = [ + "Here is the project structure:", + "src/", + "├── main.ts", + "└── utils.ts", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/tree/i); + }); + + it("blocks deep tree output", () => { + const text = [ + ".", + "├── agentchatbus-ts", + "│ ├── src", + "│ │ └── main.ts", + "└── README.md", + ].join("\n"); + const { blocked } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + }); + }); + + describe("blocked — Unix ls -la output", () => { + it("blocks full ls -la output (total + permissions)", () => { + const text = [ + "total 48", + "drwxr-xr-x 5 user group 4096 Mar 19 10:00 .", + "drwxr-xr-x 12 user group 4096 Mar 19 09:00 ..", + "-rw-r--r-- 1 user group 512 Mar 19 10:00 README.md", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/ls -la/i); + }); + + it("does not block permissions line without total header", () => { + const text = "drwxr-xr-x 5 user group 4096 Mar 19 10:00 src"; + const { blocked } = checkFilesystemDisclosure(text); + expect(blocked).toBe(false); + }); + }); + + describe("blocked — Windows dir output", () => { + it("blocks Windows dir listing with column header", () => { + const text = [ + " Directory of C:\\Users\\user\\project", + "", + "Mode LastWriteTime Length Name", + "---- ------------- ------ ----", + "d---- 19/03/2026 14:00 src", + "-a--- 19/03/2026 14:00 12345 README.md", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/Windows/i); + }); + + it("blocks PowerShell Get-ChildItem with 2+ mode lines", () => { + const text = [ + "d---- 03/19/2026 14:00 src", + "-a--- 03/19/2026 14:00 README.md", + "-a--- 03/19/2026 14:00 package.json", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/Windows/i); + }); + }); + + describe("blocked — dense path cluster", () => { + it("blocks 3+ consecutive Unix absolute path lines", () => { + const text = [ + "Here are all the config files:", + "/etc/nginx/nginx.conf", + "/etc/nginx/sites-enabled/default", + "/etc/ssh/sshd_config", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/cluster/i); + }); + + it("blocks 3+ consecutive Windows absolute path lines", () => { + const text = [ + "C:\\Windows\\System32\\drivers\\etc\\hosts", + "C:\\Windows\\System32\\drivers\\etc\\services", + "C:\\Users\\user\\AppData\\Local\\Temp\\file.tmp", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/cluster/i); + }); + + it("allows 2 consecutive path lines", () => { + const text = [ + "Compare these two:", + "/etc/nginx/nginx.conf", + "/etc/apache2/apache2.conf", + ].join("\n"); + const { blocked } = checkFilesystemDisclosure(text); + expect(blocked).toBe(false); + }); + + it("does not count non-consecutive paths", () => { + const text = [ + "/etc/nginx/nginx.conf", + "This file controls the server config.", + "/etc/ssh/sshd_config", + "This controls SSH.", + "/etc/hosts", + ].join("\n"); + const { blocked } = checkFilesystemDisclosure(text); + expect(blocked).toBe(false); + }); + }); + + describe("blocked — sensitive file content", () => { + it("blocks /etc/passwd dump", () => { + const text = [ + "root:x:0:0:root:/root:/bin/bash", + "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin", + "bin:x:2:2:bin:/bin:/usr/sbin/nologin", + ].join("\n"); + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/passwd/i); + }); + + it("blocks SSH public key dump", () => { + const text = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3... user@host"; + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/SSH/i); + }); + + it("blocks ed25519 public key", () => { + const text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GkZXS user@host"; + const { blocked, reason } = checkFilesystemDisclosure(text); + expect(blocked).toBe(true); + expect(reason).toMatch(/SSH/i); + }); + }); +}); + +// ─── SHOW_AD gating tests ──────────────────────────────────────────────────── + +describe("isFilesystemDisclosureFilterActive", () => { + const originalEnv = process.env.AGENTCHATBUS_SHOW_AD; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTCHATBUS_SHOW_AD; + } else { + process.env.AGENTCHATBUS_SHOW_AD = originalEnv; + } + }); + + it("is inactive when SHOW_AD is not set", () => { + delete process.env.AGENTCHATBUS_SHOW_AD; + expect(isFilesystemDisclosureFilterActive()).toBe(false); + }); + + it("is active when SHOW_AD=true", () => { + process.env.AGENTCHATBUS_SHOW_AD = "true"; + expect(isFilesystemDisclosureFilterActive()).toBe(true); + }); + + it("is active when SHOW_AD=1", () => { + process.env.AGENTCHATBUS_SHOW_AD = "1"; + expect(isFilesystemDisclosureFilterActive()).toBe(true); + }); + + it("is active when SHOW_AD=yes", () => { + process.env.AGENTCHATBUS_SHOW_AD = "yes"; + expect(isFilesystemDisclosureFilterActive()).toBe(true); + }); + + it("is inactive when SHOW_AD=false", () => { + process.env.AGENTCHATBUS_SHOW_AD = "false"; + expect(isFilesystemDisclosureFilterActive()).toBe(false); + }); + + it("is inactive when SHOW_AD=0", () => { + process.env.AGENTCHATBUS_SHOW_AD = "0"; + expect(isFilesystemDisclosureFilterActive()).toBe(false); + }); +}); + +describe("checkFilesystemDisclosureOrThrow", () => { + afterEach(() => { + delete process.env.AGENTCHATBUS_SHOW_AD; + }); + + it("throws FilesystemDisclosureError when SHOW_AD=true and disclosure detected", () => { + process.env.AGENTCHATBUS_SHOW_AD = "true"; + const text = "├── src\n└── README.md"; + expect(() => checkFilesystemDisclosureOrThrow(text)).toThrow(FilesystemDisclosureError); + }); + + it("does not throw when SHOW_AD is not set, even for disclosure content", () => { + delete process.env.AGENTCHATBUS_SHOW_AD; + const text = "├── src\n└── README.md"; + expect(() => checkFilesystemDisclosureOrThrow(text)).not.toThrow(); + }); + + it("does not throw when SHOW_AD=false, even for disclosure content", () => { + process.env.AGENTCHATBUS_SHOW_AD = "false"; + const text = "├── src\n└── README.md"; + expect(() => checkFilesystemDisclosureOrThrow(text)).not.toThrow(); + }); + + it("does not throw for normal content when SHOW_AD=true", () => { + process.env.AGENTCHATBUS_SHOW_AD = "true"; + expect(() => + checkFilesystemDisclosureOrThrow("Great implementation, looks clean!") + ).not.toThrow(); + }); +}); + +describe("FilesystemDisclosureError", () => { + it("has disclosureReason field", () => { + const err = new FilesystemDisclosureError("Directory tree output"); + expect(err.disclosureReason).toBe("Directory tree output"); + expect(err.name).toBe("FilesystemDisclosureError"); + expect(err.message).toContain("demo mode"); + expect(err.message).toContain("Directory tree output"); + }); +}); + +// ─── MemoryStore integration tests ─────────────────────────────────────────── + +describe("MemoryStore filesystem disclosure integration", () => { + let store: MemoryStore; + + beforeEach(() => { + process.env.AGENTCHATBUS_SHOW_AD = "true"; + process.env.AGENTCHATBUS_DB = ":memory:"; + store = new MemoryStore(":memory:"); + }); + + afterEach(() => { + store.close(); + delete process.env.AGENTCHATBUS_SHOW_AD; + delete process.env.AGENTCHATBUS_DB; + }); + + it("postMessage blocks tree output when SHOW_AD=true", () => { + const { thread } = store.createThread("test-thread"); + const sync = store.issueSyncContext(thread.id, "agent", "test"); + + expect(() => + store.postMessage({ + threadId: thread.id, + author: "agent", + content: "src/\n├── main.ts\n└── utils.ts", + expectedLastSeq: sync.current_seq, + replyToken: sync.reply_token, + }) + ).toThrow(FilesystemDisclosureError); + }); + + it("postMessage blocks ls -la output when SHOW_AD=true", () => { + const { thread } = store.createThread("test-thread"); + const sync = store.issueSyncContext(thread.id, "agent", "test"); + + const lsOutput = [ + "total 32", + "drwxr-xr-x 4 user group 4096 Mar 19 10:00 .", + "-rw-r--r-- 1 user group 512 Mar 19 10:00 README.md", + ].join("\n"); + + expect(() => + store.postMessage({ + threadId: thread.id, + author: "agent", + content: lsOutput, + expectedLastSeq: sync.current_seq, + replyToken: sync.reply_token, + }) + ).toThrow(FilesystemDisclosureError); + }); + + it("postMessage allows normal messages when SHOW_AD=true", () => { + const { thread } = store.createThread("test-thread"); + const sync = store.issueSyncContext(thread.id, "agent", "test"); + + const msg = store.postMessage({ + threadId: thread.id, + author: "agent", + content: "The implementation looks correct. Ready for review.", + expectedLastSeq: sync.current_seq, + replyToken: sync.reply_token, + }); + expect(msg.seq).toBeGreaterThan(0); + }); + + it("postMessage allows disclosure content when SHOW_AD=false (private instance)", () => { + process.env.AGENTCHATBUS_SHOW_AD = "false"; + const localStore = new MemoryStore(":memory:"); + const { thread } = localStore.createThread("test-thread"); + const sync = localStore.issueSyncContext(thread.id, "agent", "test"); + + const msg = localStore.postMessage({ + threadId: thread.id, + author: "agent", + content: "src/\n├── main.ts\n└── utils.ts", + expectedLastSeq: sync.current_seq, + replyToken: sync.reply_token, + }); + expect(msg.seq).toBeGreaterThan(0); + localStore.close(); + }); + + it("editMessage blocks tree output when SHOW_AD=true", () => { + const { thread } = store.createThread("test-thread"); + const sync = store.issueSyncContext(thread.id, "agent", "test"); + + const msg = store.postMessage({ + threadId: thread.id, + author: "agent", + content: "Initial message content.", + expectedLastSeq: sync.current_seq, + replyToken: sync.reply_token, + }); + + expect(() => + store.editMessage(msg.id, "src/\n├── main.ts\n└── utils.ts", "agent") + ).toThrow(FilesystemDisclosureError); + }); +});