Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 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
47 changes: 47 additions & 0 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 @@ -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 @@ -710,6 +733,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 +777,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
14 changes: 14 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,26 @@ describe('resolveConfig', () => {
maxMessages: undefined,
maxCostMicrocents: undefined,
onExceed: undefined,
allowedCommands: undefined,
allowedCommandGlobs: undefined,
},
variables: {},
mcpServers: [],
});
});

it('resolves the [chat] `!`-shell allowlist per field (project REPLACES workspace, no merge — ADR-0061)', () => {
const workspace: ProjectConfig = {
chat: { allowed_commands: ['ls', 'pwd'], allowed_command_globs: ['git *'] },
};
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).toEqual(['git *']); // absent on project ⇒ falls through to workspace
// 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
9 changes: 9 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 @@ -111,6 +116,10 @@ function resolveChat(
maxMessages: p?.max_messages ?? w?.max_messages,
maxCostMicrocents: p?.max_cost_microcents ?? w?.max_cost_microcents,
onExceed: p?.on_exceed ?? w?.on_exceed,
// Per-field last-writer-wins (project over workspace), REPLACE not merge — a project that sets an allowlist
// fully overrides the workspace's, so a narrower project can't inherit a broader workspace entry (ADR-0061).
allowedCommands: p?.allowed_commands ?? w?.allowed_commands,
allowedCommandGlobs: p?.allowed_command_globs ?? w?.allowed_command_globs,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};
}

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
43 changes: 41 additions & 2 deletions apps/cli/src/engine/tool-host/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,8 +711,17 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto
expect((await sandboxed().readFile('config', {})).content).toBe('plain-config');
});

it('refuses every credential dotfile (.gitconfig / .git-credentials / .netrc / .npmrc / .pypirc / .pgpass) and `.relavium/`', async () => {
for (const f of ['.gitconfig', '.git-credentials', '.netrc', '.npmrc', '.pypirc', '.pgpass']) {
it('refuses every credential dotfile (.gitconfig / .git-credentials / .netrc / .npmrc / .pypirc / .pgpass / .envrc / .dockercfg) and `.relavium/`', async () => {
for (const f of [
'.gitconfig',
'.git-credentials',
'.netrc',
'.npmrc',
'.pypirc',
'.pgpass',
'.envrc', // direnv — holds `export AWS_SECRET_ACCESS_KEY=…` (2.5.D / ADR-0061 step-4 review)
'.dockercfg', // legacy Docker registry auth (pre-config.json)
]) {
await writeFile(join(workspace, f), 'token=secret');
await expect(sandboxed().readFile(f, {})).rejects.toBeInstanceOf(FsScopeDeniedError);
}
Expand All @@ -723,6 +732,36 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto
);
});

it('refuses .env / .env.* + .aws/ + .docker/config.json (2.5.D / ADR-0061 read-floor expansion)', async () => {
for (const f of ['.env', '.env.local', '.env.production']) {
await writeFile(join(workspace, f), 'API_KEY=secret');
await expect(sandboxed().readFile(f, {})).rejects.toBeInstanceOf(FsScopeDeniedError);
}
await mkdir(join(workspace, '.aws'), { recursive: true });
await writeFile(join(workspace, '.aws', 'credentials'), '[default]\naws_secret_access_key=x');
await expect(sandboxed().readFile('.aws/credentials', {})).rejects.toBeInstanceOf(
FsScopeDeniedError,
);
await mkdir(join(workspace, '.docker'), { recursive: true });
await writeFile(join(workspace, '.docker', 'config.json'), '{"auths":{}}');
await expect(sandboxed().readFile('.docker/config.json', {})).rejects.toBeInstanceOf(
FsScopeDeniedError,
);
// Non-secret lookalikes are NOT floored: '.environment' (starts with '.env' but not '.env.') + a plain config.json.
await writeFile(join(workspace, '.environment'), 'not-a-secret');
expect((await sandboxed().readFile('.environment', {})).content).toBe('not-a-secret');
await writeFile(join(workspace, 'config.json'), '{"ok":true}');
expect((await sandboxed().readFile('config.json', {})).content).toBe('{"ok":true}');
});

it('refuses `.env` as a DIRECTORY segment (per-environment secret store), not only a `.env` file (2.5.D step-4 review)', async () => {
await mkdir(join(workspace, '.env'), { recursive: true });
await writeFile(join(workspace, '.env', 'production'), 'API_KEY=secret');
await expect(sandboxed().readFile('.env/production', {})).rejects.toBeInstanceOf(
FsScopeDeniedError,
);
});

it('folds the read floor like the write floor — a case variant (`.SSH/`) is still refused', async () => {
await mkdir(join(workspace, '.SSH'), { recursive: true });
await writeFile(join(workspace, '.SSH', 'id_rsa'), 'KEY');
Expand Down
Loading
Loading