Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ba9e9a5
docs: mark 2.5.E Done (PR #63 merged) + reconcile the chat tool-failu…
cemililik Jul 2, 2026
c92b58e
docs(decisions): add ADR-0061 — CLI input-layer @-mention + !-shell s…
cemililik Jul 3, 2026
9bd68cd
docs(decisions): revise ADR-0061 per maintainer security review + mar…
cemililik Jul 3, 2026
5de2d44
refactor(cli): cursor-bearing EditorState foundation for the chat pro…
cemililik Jul 3, 2026
cc10236
fix(cli): 2.5.D step 1 — address opus review round (2 confirmed + 1 c…
cemililik Jul 3, 2026
e92ac6a
test(cli): 2.5.D step 1 — address sonnet review round (mutation-prove…
cemililik Jul 3, 2026
8c999e0
test(cli): 2.5.D step 1 — close codepoint review dimension + pin the …
cemililik Jul 3, 2026
7150035
feat(cli): line-editor core — Ctrl+J newline, cursor/word/line motion…
cemililik Jul 3, 2026
bab7ff0
fix(cli): 2.5.D step 2 — address opus review round (2 MEDIUM + 4 LOW,…
cemililik Jul 3, 2026
d85fb8f
fix(cli): 2.5.D step 2 — address sonnet review round (3 MEDIUM + 3 LOW)
cemililik Jul 3, 2026
f1f13f2
feat(cli): history recall (Up/Down) + Ctrl+R reverse-search (2.5.D st…
cemililik Jul 3, 2026
2bde2f8
fix(cli): 2.5.D step 3 — address opus review round (history/search in…
cemililik Jul 3, 2026
f2d2fa1
fix(cli): 2.5.D step 3 — address sonnet review round (1 HIGH regressi…
cemililik Jul 3, 2026
fb7fc62
feat(cli): expand the read-side confidentiality floor to .env / .aws …
cemililik Jul 3, 2026
48dd8ad
feat(cli): @-mention completion model + fs-jailed reader (2.5.D step …
cemililik Jul 3, 2026
a8bec3f
feat(cli): wire the @-mention completion UI into chat + Home (2.5.D s…
cemililik Jul 3, 2026
acb8c34
fix(cli): harden @-mention injection path against control-char frame …
cemililik Jul 3, 2026
0024798
fix(cli): 2.5.D step 4 — address opus review round (6 confirmed findi…
cemililik Jul 3, 2026
a9d0c13
fix(cli): 2.5.D step 4 — address sonnet review round (8 confirmed fin…
cemililik Jul 3, 2026
cb6fbc6
feat(core): add AgentSession.runUserCommand for the !-shell escape (2…
cemililik Jul 3, 2026
5e11cfd
feat(cli): wire the !-shell escape into chat + Home (2.5.D step 5b/5c…
cemililik Jul 3, 2026
9d55033
fix(cli): 2.5.D step 5 — address sonnet review round (1 HIGH + 1 LOW)
cemililik Jul 3, 2026
0e22264
docs: reconcile the 2.5.D input ergonomics + @/! canonical homes (ADR…
cemililik Jul 3, 2026
984f98c
docs: reference PR #64 in the 2.5.D roadmap status
cemililik Jul 3, 2026
83d8d8a
fix(cli): wire the OS keychain into the bare-invocation Home key reso…
cemililik Jul 3, 2026
1f24fe0
fix(llm,cli): classify HTTP 402 as provider_auth + surface provider e…
cemililik Jul 3, 2026
0a5b9fe
feat(llm,cli): add deepseek-v4-flash / deepseek-v4-pro to the pricing…
cemililik Jul 3, 2026
d9e54d9
refactor(cli): address 2.5.D review + SonarCloud findings (complexity…
cemililik Jul 3, 2026
5802127
fix(llm,cli,docs): address 2.5.D review round 2 + SonarCloud (links, …
cemililik Jul 3, 2026
0834598
fix(cli,core): 2.5.D comprehensive-review — 3 confirmed race/correctn…
cemililik Jul 3, 2026
2407456
feat(cli): chip-model @/! input — pending attachments over inline inj…
cemililik Jul 3, 2026
e8007d5
refactor(cli): address PR #64 review nits (ternary, negated condition…
cemililik Jul 3, 2026
5029855
fix(cli,core): PR #64 review — couple the !-shell allowlist, harden t…
cemililik Jul 3, 2026
d62ab96
fix(cli): Backspace now deletes backward (ink reports Unix DEL as key…
cemililik Jul 3, 2026
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: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ fail-closed per-tool `confirmAction` floor (`[y]/[a]/[n]` + a session once/alway
(EA7), and the host arms closing the 2.5.A deferral: a write-capable `fs` tier + **protected paths** refused in
every mode incl. `auto`, the SSRF-hardened `egress` arm shared with media, and the `os` arm as a governed action
class — wired live into `relavium chat`, one-shot `agent run`, and the Home, each activating the regime before its
first turn) is 🟡 **implemented + reviewed on `development`; PR pending merge**, behind [ADR-0057](docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)
(**Accepted** after the mandatory holistic security review). **The next pickup is the 2.5 experience arm (2.5.D / F / G).**
first turn) is ✅ **Done (PR #63, 2026-07-03)**, behind [ADR-0057](docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)
(**Accepted** after the mandatory holistic security review); a same-PR chat-UX follow-up also landed — a host tool
EXECUTION failure on the interactive surface (a file-not-found READ) is fed back to the model to recover
(`recoverToolFailures`, scoped to IDEMPOTENT tools via a stamped `ToolExecutionError.recoverable`; a governed /
side-effecting failure stays fail-fast) plus a static secret-free `tool_failed` chat hint. **With 2.5.E the CLI
Consolidation spine (2.5.A/B/C/E) is complete; the next pickup is the 2.5 experience arm (2.5.D / F / G).**
For live status, per-PR history, milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview.

Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/chat/persister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const EMPTY_CHAT: ResolvedChatConfig = {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
};

const textOf = (content: readonly DurableContentPart[]): string =>
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/chat/session-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ const EMPTY_CHAT: ResolvedChatConfig = {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
};

function deterministicIds() {
Expand Down
21 changes: 18 additions & 3 deletions apps/cli/src/chat/session-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
McpServerRegistration,
SessionContext,
SessionMessage,
ToolPolicy,
} from '@relavium/shared';

import type { ResolvedChatConfig } from '../config/resolve.js';
Expand Down Expand Up @@ -186,6 +187,20 @@ function buildSessionRuntime(
// Conditional spread ⇒ the inbound-MCP arm is a true MERGE onto fs/process, never a replace (the prior bug).
const host: ToolHost = mcp === undefined ? baseHost : { ...baseHost, mcp: mcp.capability };
const registry = createToolRegistry({ tools, host });
// The chat `ToolPolicy` (ADR-0055's single source) extended with the `[chat].allowed_commands` /
// `allowed_command_globs` `!`-shell allowlist (2.5.D, ADR-0061). Absent/empty ⇒ the factory default (`{}`) ⇒
// `run_command` denied (the secure `empty ⇒ disabled` symmetry). Threaded into `SessionDeps.toolPolicy`, it is
// what BOTH a model `run_command` (advertised only if the agent grants it) AND the user `!`-shell
// (`runUserCommand`) enforce — the ONE allowlist, never a chat-specific fork.
const chatToolPolicy: ToolPolicy = {
...factoryEnv.policy,
...(opts.chat.allowedCommands === undefined
? {}
: { allowedCommands: opts.chat.allowedCommands }),
...(opts.chat.allowedCommandGlobs === undefined
? {}
: { allowedCommandGlobs: opts.chat.allowedCommandGlobs }),
};
const governor = buildGovernorWiring(opts.chat, opts.onBudgetWarning);
// The session event sink (1.W): a draft → bus → stamped sequenceNumber/timestamp. Hoisted so a SURFACE
// event (the in-REPL `/export`'s `session:exported`, 2.Q) can ride the same monotonic per-session counter.
Expand All @@ -204,9 +219,9 @@ function buildSessionRuntime(
// The chat-default `ToolPolicy` comes from the factory (ADR-0055's single source), not an implicit engine
// default: today it is `{}` (gated tools deny-all; `run_command` disabled via empty allowedCommands — a
// standalone chat has no workflow allowedCommands to inherit, the secure default per config-spec.md `[chat]`
// "empty/absent ⇒ run_command disabled"). Wiring it now means a 2.5.E/ADR-0057 per-mode allowlist flows
// through automatically rather than being silently dropped by reading only the factory's `host`.
toolPolicy: factoryEnv.policy,
// "empty/absent ⇒ run_command disabled"). Extended with the `[chat].allowed_commands` `!`-shell allowlist
// (ADR-0061) — see `chatToolPolicy` above. A 2.5.E/ADR-0057 per-mode allowlist flows through automatically.
toolPolicy: chatToolPolicy,
// Interactive-surface turn bounds: recover from a host tool EXECUTION failure (a file-not-found read, a
// transient egress error) by feeding it back to the model so it can adapt / explain, instead of ending the
// turn with a bare `tool_failed` (ADR-0057 UX). A WORKFLOW node keeps the default (fail-fast) — this opt-in
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const EMPTY_CHAT: ResolvedChatConfig = {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
};

const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const;
Expand Down
57 changes: 53 additions & 4 deletions apps/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DEFAULT_SESSION_MAX_TURNS,
type SessionHandle,
type SessionStreamHandleEvent,
type UserCommandOutcome,
} from '@relavium/core';
import { exportSession } from '../chat/export.js';
import { formatDoctorReport, runDoctorChecks, type DoctorProbes } from '../chat/doctor.js';
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
type BuiltChatSession,
} from '../chat/session-host.js';
import { loadResolvedConfig } from '../config/load.js';
import { assembleToolEnv } from '../engine/tool-host/assemble.js';
import { surfaceMcpSkipped } from '../engine/mcp-servers.js';
import { createProviderResolver, type ProviderResolver } from '../engine/providers.js';
import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js';
Expand All @@ -47,6 +49,7 @@ import {
stripTerminalControls,
} from '../render/tui/chat-projection.js';
import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js';
import { createMentionReader, type MentionReader } from '../render/tui/mention.js';
import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js';

/**
Expand Down Expand Up @@ -89,7 +92,7 @@ export interface ChatDriveContext {
*/
readonly startSession: () => void;
/** Handle one line of user input (a slash command or a chat message). Awaits the turn for a message. */
readonly processLine: (line: string) => Promise<void>;
readonly processLine: (line: string, display?: string) => Promise<void>;
/** `true` once `/exit` or `/cancel` has run — the driver stops reading input. */
readonly shouldStop: () => boolean;
/** The live session stream (the driver renders it: ink reduces it into the store; plain prints it). */
Expand Down Expand Up @@ -129,6 +132,26 @@ export interface ChatDriveContext {
* (cycle) + the `/mode` command to this. Absent on a driver that has no mode UI (the mode stays the default).
*/
readonly onModeChange?: (mode: ChatMode) => void;
/**
* The `@`-mention completion reader (2.5.D, [ADR-0061](../../../../docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md))
* — a thin wrapper over a READ-ONLY `FsCapability` jailed to the SAME fs-scope tier + workspace as the session's
* tools, so `@`-completion browses + injects files through the identical jail + confidentiality floor + listing-gate
* (a `.ssh`/`.env` entry is never listed nor read). Present on an interactive (TTY, non-`--json`) session; the ink
* driver wires `@` at a word boundary to the dir-navigable completion. Absent ⇒ a plain/`--json` driver treats a
* leading `@` as a literal (no completion). Read-only by construction — the mention path never writes.
*/
readonly mentionReader?: MentionReader;
/**
* Run a USER-invoked `!`-shell command (2.5.D step 5, ADR-0061) through the session's `runUserCommand` — the one
* `run_command` boundary (allowlist BEFORE approval → mode-aware `confirmAction` → hardened process arm). The
* caller pre-tokenizes the line into `command` + `args`. Present on an interactive (TTY, non-`--json`) session;
* the driver injects the classified output as UNTRUSTED context / renders the actionable deny hint. Absent ⇒ a
* plain/`--json` driver treats a leading `!` as a literal message (no shell escape).
*/
readonly runShellCommand?: (
command: string,
args: readonly string[],
) => Promise<UserCommandOutcome>;
}
export type ChatDriver = (ctx: ChatDriveContext) => Promise<void>;

Expand Down Expand Up @@ -470,7 +493,7 @@ export function createChatModeControl(
/** The slash-aware line handler + the session's cancel/stop state + the mode/abort control (ADR-0057). */
export interface ChatLineHandler extends ChatModeControl {
/** Handle one line (a slash command or a message); awaits the turn for a message. */
readonly processLine: (raw: string) => Promise<void>;
readonly processLine: (raw: string, display?: string) => Promise<void>;
/** Emit the session's sole terminal (`session:cancelled`, idempotent) — the teardown caller fires it. */
readonly cancelOnce: () => void;
/** `true` once `/exit` or `/cancel` has run — the driver stops reading input. */
Expand Down Expand Up @@ -680,14 +703,16 @@ export function createChatLineHandler(
await command.run(replCtx, tokens); // may be async (/cost, /doctor); never fire-and-forget
};

const processLine = async (raw: string): Promise<void> => {
const processLine = async (raw: string, display?: string): Promise<void> => {
const line = raw.trim();
if (line.length === 0) return;
if (line.startsWith('/')) {
await handleSlashCommand(line);
return;
}
store.appendUser(line);
// `display` is the COMPACT transcript form (prose + chip note) when a message carried `@`/`!` attachments; the
// model + the durable transcript get the full framed `line` (resume fidelity), the live transcript the compact one.
store.appendUser(display ?? line);
persister.beginUserTurn(line);
await built.session.sendMessage(line);
};
Expand All @@ -710,6 +735,28 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise<Exit
deps,
);

// The `@`-mention completion reader (2.5.D, ADR-0061): a READ-ONLY fs jail at the SAME fs-scope tier + workspace
// as the session's tools, so `@`-completion browses + injects through the identical confidentiality floor +
// listing-gate (a `.ssh`/`.env` entry is never listed nor read). READ-ONLY by construction — the mention path
// never writes. TTY-only: an interactive driver wires the completion; a plain/`--json` driver treats a leading
// `@` as a literal, so it needs no reader. Building it is pure (no I/O).
const mentionReader = ((): MentionReader | undefined => {
if (!chatIsInteractive(deps.io, deps.global)) return undefined;
const fsArm = assembleToolEnv({
profile: 'chat-read-only',
fsScopeTier: built.context.fsScopeTier,
workspaceDir: built.context.workingDir,
}).host.fs;
return fsArm === undefined ? undefined : createMentionReader(fsArm);
})();

// The `!`-shell runner (2.5.D step 5, ADR-0061) — a thin wrapper over the session's `runUserCommand`. TTY-only:
// an interactive driver intercepts a leading `!`; a plain/`--json` driver treats it as a literal message.
const runShellCommand = chatIsInteractive(deps.io, deps.global)
? (command: string, args: readonly string[]): Promise<UserCommandOutcome> =>
built.session.runUserCommand(command, args)
: undefined;

// persister.start() subscribes for the turn events + adopts/inserts the session row; it does NOT consume
// session:started, so it is safe before the driver. The session-open action (fresh start() / resume no-op)
// is deferred to startSession() INSIDE the driver, after the driver has subscribed the view store.
Expand All @@ -732,6 +779,8 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise<Exit
...(intro === undefined ? {} : { intro }),
onAbort,
onModeChange,
...(mentionReader === undefined ? {} : { mentionReader }),
...(runShellCommand === undefined ? {} : { runShellCommand }),
});
} finally {
cancelOnce(); // emit the terminal even on /exit or EOF (idempotent); flips the row to 'ended'
Expand Down
32 changes: 32 additions & 0 deletions apps/cli/src/config/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,44 @@ describe('resolveConfig', () => {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
},
variables: {},
mcpServers: [],
});
});

it('resolves the [chat] `!`-shell allowlist as a COUPLED unit — a project that sets EITHER field owns the whole policy (ADR-0061)', () => {
const workspace: ProjectConfig = {
chat: { allowed_commands: ['ls', 'pwd'], allowed_command_globs: ['git *'] },
};
// The project narrows to `git status` and sets NO globs. It must NOT inherit the workspace's broad `git *`
// glob — else `git push` would still be allowed, defeating the narrowing (a security regression).
const project: ProjectConfig = { chat: { allowed_commands: ['git status'] } };
const resolved = resolveConfig({ workspace, project }).chat;
expect(resolved.allowedCommands).toEqual(['git status']); // project REPLACES (never merges) the workspace list
expect(resolved.allowedCommandGlobs).toBeUndefined(); // NOT inherited — the project owns the whole allowlist
// Symmetric: a project setting ONLY globs drops the workspace's exact commands too.
const globsOnly = resolveConfig({
workspace,
project: { chat: { allowed_command_globs: ['npm run *'] } },
}).chat;
expect(globsOnly.allowedCommandGlobs).toEqual(['npm run *']);
expect(globsOnly.allowedCommands).toBeUndefined();
// A project that sets NEITHER allowlist field DOES fall through to the workspace, per field (both inherited).
const inherited = resolveConfig({ workspace, project: { chat: { max_turns: 5 } } }).chat;
expect(inherited.allowedCommands).toEqual(['ls', 'pwd']);
expect(inherited.allowedCommandGlobs).toEqual(['git *']);
// The `[]` OPT-OUT (ADR-0061): a project setting `allowed_commands: []` explicitly disables exact commands and
// must NOT inherit the workspace's globs either — else `git push` would still run via the inherited `git *`.
const optOut = resolveConfig({ workspace, project: { chat: { allowed_commands: [] } } }).chat;
expect(optOut.allowedCommands).toEqual([]);
expect(optOut.allowedCommandGlobs).toBeUndefined(); // NOT inherited — the opt-out is real
// Absent everywhere ⇒ undefined ⇒ `!`-shell disabled (secure default).
expect(resolveConfig({}).chat.allowedCommands).toBeUndefined();
});

it('resolves the [chat] block last-writer-wins (project > workspace), per field', () => {
const workspace: ProjectConfig = {
chat: { default_model: 'w-model', fs_scope: 'sandboxed', max_turns: 20, max_messages: 100 },
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/config/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface ResolvedChatConfig {
/** `[chat].on_exceed` — action when the cost cap trips (in an interactive REPL, `pause_for_approval`
* degrades to a loud turn-end since the prompt itself is the approval gate). */
readonly onExceed: ChatConfig['on_exceed'];
/** `[chat].allowed_commands` — the `!`-shell exact-match allowlist (→ engine `allowedCommands`; ADR-0061).
* Absent/empty ⇒ `!`-shell disabled (the `empty ⇒ disabled` symmetry; no chat-specific relaxation). */
readonly allowedCommands: ChatConfig['allowed_commands'];
/** `[chat].allowed_command_globs` — the opt-in glob form of the `!`-shell allowlist (→ `allowedCommandGlobs`). */
readonly allowedCommandGlobs: ChatConfig['allowed_command_globs'];
}

export interface ResolvedConfig {
Expand Down Expand Up @@ -104,13 +109,23 @@ function resolveChat(
): ResolvedChatConfig {
const p = project?.chat;
const w = workspace?.chat;
// The command allowlist is a COUPLED security policy: `allowed_commands` (exact) + `allowed_command_globs`
// (patterns) together decide what `!`-shell may run. A project that sets EITHER owns the WHOLE policy and must
// NOT inherit the other field from the workspace — else a project narrowing `allowed_commands` would silently
// keep the workspace's broader globs (e.g. lock to `git status` yet still allow `git push` via an inherited
// `git *`), the exact "narrower project can't inherit a broader workspace entry" the override guarantees
// (ADR-0061). Only when the project sets NEITHER do both fall through to the workspace, per field.
const projectSetsAllowlist =
p?.allowed_commands !== undefined || p?.allowed_command_globs !== undefined;
return {
defaultModel: p?.default_model ?? w?.default_model,
fsScope: p?.fs_scope ?? w?.fs_scope,
maxTurns: p?.max_turns ?? w?.max_turns,
maxMessages: p?.max_messages ?? w?.max_messages,
maxCostMicrocents: p?.max_cost_microcents ?? w?.max_cost_microcents,
onExceed: p?.on_exceed ?? w?.on_exceed,
allowedCommands: projectSetsAllowlist ? p?.allowed_commands : w?.allowed_commands,
allowedCommandGlobs: projectSetsAllowlist ? p?.allowed_command_globs : w?.allowed_command_globs,
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/engine/media-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const EMPTY_CONFIG: ResolvedConfig = {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
},
variables: {},
mcpServers: [],
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/engine/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const KNOWN_PROVIDERS: Record<(typeof KNOWN_PROVIDER_IDS)[number], Provid
deepseek: {
displayName: 'DeepSeek',
baseUrl: 'https://api.deepseek.com',
testModel: 'deepseek-chat',
testModel: 'deepseek-v4-flash',
},
};

Expand Down
Loading
Loading