Skip to content
Open
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
37 changes: 28 additions & 9 deletions src/cli/commands/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import { listChannelNotificationSubscriptions, markChannelNotificationsRead, sub
import { closeDb } from "../../lib/db.js";
import { resolveIdentity } from "../../lib/identity.js";
import { previewText, windowItems } from "../../lib/compact-output.js";
import { assertNoSensitiveContent } from "../../lib/content-safety.js";
import { getCliWindow, pageFromQuery, printCompactFooter, queryLimitFor } from "../compact.js";
import { printMessageEntry } from "../message-output.js";

function failCommand(error: unknown, fallback: string): never {
console.error(chalk.red(error instanceof Error ? error.message : fallback));
closeDb();
process.exit(1);
}

export function registerChannelCommands(program: Command): void {
const channel = program
.command("channel")
Expand Down Expand Up @@ -257,20 +264,32 @@ export function registerChannelCommands(program: Command): void {
process.exit(1);
}

try {
assertNoSensitiveContent(channelArg, "Message channel");
} catch (error) {
return failCommand(error, "Failed to send channel message.");
}

const sp = getChannel(channelArg);
if (!sp) {
console.error(chalk.red(`Channel #${channelArg} not found.`));
console.error(chalk.red("Channel not found."));
process.exit(1);
}

const msg = sendMessage({
from,
to: channelArg,
content,
channel: channelArg,
session_id: `channel:${channelArg}`,
priority: opts.priority,
});
const msg = (() => {
try {
return sendMessage({
from,
to: channelArg,
content,
channel: channelArg,
session_id: `channel:${channelArg}`,
priority: opts.priority,
});
} catch (error) {
return failCommand(error, "Failed to send channel message.");
}
})();

if (opts.json) {
console.log(JSON.stringify(msg, null, 2));
Expand Down
68 changes: 46 additions & 22 deletions src/cli/commands/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export function formatDigestContinuationCommand(result: Pick<DigestResult, "chan
return parts.join(" ");
}

function failCommand(error: unknown, fallback: string): never {
console.error(chalk.red(error instanceof Error ? error.message : fallback));
closeDb();
process.exit(1);
}

export function registerMessagingCommands(program: Command): void {
// ---- send ----
program
Expand Down Expand Up @@ -77,19 +83,25 @@ export function registerMessagingCommands(program: Command): void {
}
}

const msg = sendMessage({
from,
to: to || from,
channel: channel || undefined,
content,
session_id: session,
priority: opts.priority,
working_dir: opts.workingDir,
repository: opts.repository,
branch: opts.branch,
metadata,
blocking: opts.blocking,
});
const msg = (() => {
try {
return sendMessage({
from,
to: to || from,
channel: channel || undefined,
content,
session_id: session,
priority: opts.priority,
working_dir: opts.workingDir,
repository: opts.repository,
branch: opts.branch,
metadata,
blocking: opts.blocking,
});
} catch (error) {
return failCommand(error, "Failed to send message.");
}
})();

if (opts.json) {
console.log(JSON.stringify(msg, null, 2));
Expand Down Expand Up @@ -401,14 +413,20 @@ export function registerMessagingCommands(program: Command): void {
const to = channel
? channel
: (original.from_agent === from ? original.to_agent : original.from_agent);
const msg = sendMessage({
from,
to,
content,
session_id: original.session_id,
priority: opts.priority,
channel,
});
const msg = (() => {
try {
return sendMessage({
from,
to,
content,
session_id: original.session_id,
priority: opts.priority,
channel,
});
} catch (error) {
return failCommand(error, "Failed to send reply.");
}
})();

if (opts.json) {
console.log(JSON.stringify(msg, null, 2));
Expand Down Expand Up @@ -497,7 +515,13 @@ export function registerMessagingCommands(program: Command): void {
process.exit(1);
}

const msg = editMessage(id, agent, content);
const msg = (() => {
try {
return editMessage(id, agent, content);
} catch (error) {
return failCommand(error, "Failed to edit message.");
}
})();

if (opts.json) {
console.log(JSON.stringify(msg, null, 2));
Expand Down
53 changes: 53 additions & 0 deletions src/cli/compact-output.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ function runCli(args: string[], agent: string) {
};
}

function syntheticDatabaseUrl(): string {
return ["postgres", "://", "cli_user:synthetic-password", "@db.example.invalid/app"].join("");
}

describe("compact CLI output", () => {
afterAll(() => {
try { unlinkSync(TEST_DB); } catch {}
Expand Down Expand Up @@ -67,4 +71,53 @@ describe("compact CLI output", () => {
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe("second page message");
});

test("send exits nonzero for sensitive content without echoing the value", () => {
const blocked = syntheticDatabaseUrl();
const send = runCli(["send", `blocked ${blocked}`, "--to", "blocked-target"], "alice");

expect(send.exitCode).not.toBe(0);
expect(send.stderr).toContain("sensitive content detected");
expect(send.stderr).not.toContain(blocked);

const read = runCli(["read", "--to", "blocked-target", "--json"], "blocked-target");
expect(read.exitCode).toBe(0);
expect(JSON.parse(read.stdout)).toHaveLength(0);
});

test("send exits nonzero for sensitive metadata without echoing the value", () => {
const blocked = syntheticDatabaseUrl();
const send = runCli([
"send",
"metadata should be checked",
"--to",
"metadata-blocked",
"--metadata",
JSON.stringify({ dsn: blocked }),
], "alice");

expect(send.exitCode).not.toBe(0);
expect(send.stderr).toContain("sensitive content detected");
expect(send.stderr).not.toContain(blocked);

const read = runCli(["read", "--to", "metadata-blocked", "--json"], "metadata-blocked");
expect(read.exitCode).toBe(0);
expect(JSON.parse(read.stdout)).toHaveLength(0);
});

test("channel send exits nonzero for sensitive channel input without echoing the value", () => {
const blocked = syntheticDatabaseUrl();
const send = runCli(["channel", "send", blocked, "channel should be checked"], "alice");

expect(send.exitCode).not.toBe(0);
expect(send.stderr).toContain("sensitive content detected");
expect(send.stderr).not.toContain(blocked);
});

test("send exits nonzero for truncated metadata JSON", () => {
const send = runCli(["send", "metadata parse check", "--to", "metadata-target", "--metadata", "{\"broken\":"], "alice");

expect(send.exitCode).not.toBe(0);
expect(send.stderr).toContain("Invalid --metadata JSON.");
});
});
55 changes: 55 additions & 0 deletions src/cli/components/ChatView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { readMessages } from "../../lib/messages.js";
import { closeDb } from "../../lib/db.js";
import { submitChatViewMessage } from "./ChatView.js";

const TEST_DB = join(tmpdir(), `conversations-chat-view-${Date.now()}.db`);

function syntheticDatabaseUrl(): string {
return ["postgres", "://", "tui_user:synthetic-password", "@db.example.invalid/app"].join("");
}

beforeEach(() => {
process.env.CONVERSATIONS_DB_PATH = TEST_DB;
closeDb();
});

afterEach(() => {
closeDb();
try { unlinkSync(TEST_DB); } catch {}
try { unlinkSync(`${TEST_DB}-wal`); } catch {}
try { unlinkSync(`${TEST_DB}-shm`); } catch {}
});

describe("submitChatViewMessage", () => {
test("blocks sensitive content without throwing, echoing, or persisting", () => {
const blocked = syntheticDatabaseUrl();
const result = submitChatViewMessage(
{ agent: "tui-sender", recipient: "tui-recipient" },
`blocked ${blocked}`
);

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("blocked");
expect(result.error).not.toContain(blocked);
}
expect(readMessages({ to: "tui-recipient" })).toHaveLength(0);
});

test("sends safe content", () => {
const result = submitChatViewMessage(
{ agent: "tui-sender", recipient: "tui-recipient" },
"safe chat message"
);

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.message.content).toBe("safe chat message");
}
expect(readMessages({ to: "tui-recipient" })).toHaveLength(1);
});
});
Loading