Files
shiro-neko/src/cli.tsx
T
Muhammad Zakir Ramadhan 7fd578e13b Gate tool calls per command and path, not per tool name
Approval was a list of tool names: `bash` needed it, `read_file` did not. That
fails in a specific way. `bash` covers `git status` and `rm -rf` equally, so a
user working through a batch presses `a` — always allow — on the first prompt and
every later command runs unasked, including the one they would have refused. The
gate was strongest when it mattered least and gone by the time it mattered.

Rules now match the *subject* of a call: the command for `bash`, the path for a
file tool, the pattern for a search.

    "permission": {
      "bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
      "edit_file": { "*": "deny", "src/generated/*": "allow" }
    }

Plain last-match-wins, with no special case for deny. An earlier version made
deny win wherever it sat, on the theory that a refusal should be impossible to
undo by accident, and it made default-deny-with-exceptions unexpressible — which
is the shape a careful user actually writes, and the same shape as `*.env` denied
while `*.env.example` is allowed. Refusals that must never be configurable stay
in the guard plugin, which runs ahead of this and which --yolo cannot reach.

Three behaviours fall out of it:

- `always` grants the pattern the tool suggests, not the tool. Approving
  `git status` runs `git log` unprompted and still asks about `npm publish`.
- `.env`, `.env.*`, and `.pem` are denied on read by default. Not gated, refused:
  a secret that reaches the context is on the wire and in the session file, and
  there is no taking it back. `.env.example` stays allowed.
- A call repeated identically three times in one turn asks even when allowed. A
  model repeating itself is not making progress, and `bash: allow` is a statement
  about which commands are safe rather than permission to loop.

The prompt now says which rule matched and what `always` would grant:

    bash wants to run
    git status --porcelain
    y allow once | a always allow bash git * | n deny

A typo in a decision string is dropped at parse time, leaving the tool on its
default. Treating an unparseable value as `allow` would mean one misspelling
silently removing the gate.

Written after surveying Claude Code, Codex, opencode, and phi. Three of the four
had already moved to per-pattern rules; the credential deny and the repeat guard
come from opencode directly. ROADMAP records what was deliberately not taken and
why — OS sandboxing needs three platform implementations and is worse than
nothing if half-built, and opencode's own docs say LSP integration is often not a
net positive.

581 tests, up from 572. The engine is a pure function tested on its own, and the
loop is tested through a real Session: an allowed pattern never prompts, a denied
one never executes, and a `.env` read leaves no secret in the transcript.
2026-09-03 11:19:53 +07:00

525 lines
20 KiB
TypeScript

#!/usr/bin/env bun
import { render } from 'ink';
import React from 'react';
import type { LanguageModel, ModelMessage } from 'ai';
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
import type { FallbackEvent } from './fallback';
import { readStdin, runHeadless } from './headless';
import { INIT_PROMPT, loadInstructions } from './instructions';
import { walk } from './ignore';
import { connectMcp } from './mcp';
import { Memory, KIND_LABEL } from './memory';
import { costOf } from './pricing';
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
import { createHost } from './plugins';
import { fetchModels, presetById } from './providers';
import * as registry from './registry';
import { Session } from './session';
import { loadSkills } from './skills';
import * as store from './store';
import { createTaskTool } from './subagent';
import { VERSION, versionLine } from './version';
import { createAskBridge } from './ui/Ask';
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
import type { RegistryRow as AppRegistryRow } from './ui/Panels';
// SDK warnings go straight to stderr, which tears up the Ink render.
(globalThis as { AI_SDK_LOG_WARNINGS?: boolean }).AI_SDK_LOG_WARNINGS = false;
const HELP = `shiro-neko ${VERSION} - agentic coding CLI
usage: shiro [options]
shiro -p "prompt" headless, prints to stdout
cat file | shiro -p prompt read from stdin
options:
-p, --print [prompt] headless mode; requires --yolo for tool use
--json with -p, emit one JSON event per line
-c, --continue resume the newest session for this directory
-r, --resume <id> resume a session by id or id prefix
--agent <name> ${VARIANTS.map((v) => v.name).join(' | ')}
--think <level> off | low | medium | high | max
--provider <anthropic|openai> wire protocol to use (default anthropic)
--model <id> model id
--base-url <url> OpenAI/Anthropic-compatible endpoint
--no-mcp skip MCP servers from the config file
--no-subagent omit the task tool
--no-instructions ignore AGENTS.md / CLAUDE.md
--no-skills ignore builtin and project skills
--no-plugins disable all plugins, including the guard
--no-memory do not load or write project memory
--yolo skip all tool approval prompts
-v, --version
-h, --help
first run: start shiro with no key and it opens provider setup, or use /provider anytime.
config: ${configPath()}
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
"toolSets": ["edit-plus", "git"], "registryUrl": "https://...",
"permission": { "bash": { "*": "ask", "git *": "allow" } },
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
ANTHROPIC_API_KEY OPENAI_API_KEY
skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
registry: /registry to browse and install external skills and plugins
sessions: ${store.sessionsDir()}
in-session: /help for the command list`;
const argv = process.argv.slice(2);
function flag(...names: string[]): string | undefined {
for (const n of names) {
const i = argv.indexOf(n);
if (i === -1) continue;
const next = argv[i + 1];
return next && !next.startsWith('-') ? next : '';
}
return undefined;
}
const has = (...names: string[]) => names.some((n) => argv.includes(n));
if (has('-h', '--help')) {
console.log(HELP);
process.exit(0);
}
if (has('-v', '--version')) {
console.log(versionLine());
process.exit(0);
}
const providerFlag = flag('--provider');
const modelFlag = flag('--model');
const baseUrlFlag = flag('--base-url');
if (providerFlag) process.env['SHIRO_PROVIDER'] = providerFlag;
if (modelFlag) process.env['SHIRO_MODEL'] = modelFlag;
if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
let cfg = await loadConfig();
const yolo = has('--yolo');
const headless = flag('-p', '--print') !== undefined;
// A missing key is fatal for a pipe, but interactively it just means "not set up yet".
if (!cfg.apiKey && headless) {
console.error(`shiro: ${missingKeyMessage(cfg.provider)}`);
process.exit(1);
}
const needsProvider = !cfg.apiKey;
const notices = createNoticeBus();
const subagents = createSubagentBus();
const askBridge = createAskBridge();
function reportFallback(e: FallbackEvent): void {
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
if (headless) process.stderr.write(`shiro: ${line}\n`);
else notices.emit(line);
}
let languageModel: LanguageModel | undefined;
if (cfg.apiKey) {
try {
languageModel = resolveModel(cfg, reportFallback);
} catch (e) {
console.error(`shiro: ${(e as Error).message}`);
process.exit(1);
}
}
let restored: store.SessionRecord | undefined;
const resumeArg = flag('-r', '--resume');
if (resumeArg) {
const id = await store.resolveId(resumeArg);
restored = id ? await store.load(id) : undefined;
if (!restored) {
console.error(`shiro: no session matching "${resumeArg}"`);
process.exit(1);
}
} else if (has('-c', '--continue')) {
restored = await store.latest(process.cwd());
if (!restored) {
console.error('shiro: no saved session for this directory');
process.exit(1);
}
}
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
const instructions = has('--no-instructions') ? [] : await loadInstructions();
const skills = has('--no-skills') ? [] : await loadSkills();
const promptHistory = await store.loadHistory();
const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins();
/** Installed entries, as `kind:name`, so the registry list can mark what is already here. */
async function installedNames(): Promise<Set<string>> {
const names = new Set<string>();
for (const s of skills) if (s.origin === 'registry') names.add(`skill:${s.name}`);
for (const p of installedPlugins.plugins) names.add(`plugin:${p.name}`);
return names;
}
/**
* One entry by name, from the index.
*
* A name alone is ambiguous when a skill and a plugin share it, so `skill:name`
* disambiguates. Asking rather than guessing would be worse here: the two differ in
* what they can do, and picking one silently is the wrong default.
*/
async function findEntry(name: string): Promise<registry.RegistryEntry> {
const parsed = /^(skill|plugin):(.+)$/.exec(name.trim());
const kind = parsed?.[1] as registry.RegistryKind | undefined;
const bare = (parsed?.[2] ?? name).trim().toLowerCase();
const entries = await registry.fetchIndex(cfg.registryUrl);
const hits = entries.filter((e) => e.name === bare && (kind === undefined || e.kind === kind));
if (hits.length === 0) throw new Error(`no registry entry named "${bare}". Try /registry search ${bare}`);
if (hits.length > 1) {
throw new Error(
`"${bare}" is both a skill and a plugin. Use /registry add skill:${bare} or /registry add plugin:${bare}`,
);
}
return hits[0]!;
}
let agentVariant: AgentVariant;
try {
agentVariant = resolveAgent(flag('--agent') || cfg.agent, flag('--think') || cfg.thinking);
} catch (e) {
console.error(`shiro: ${(e as Error).message}`);
process.exit(1);
}
const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLED);
const pluginErrors = enabledPlugins
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
.map((name) => ({ plugin: name, message: 'no such plugin' }));
const plugins = createHost(
[...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)), ...installedPlugins.plugins],
[...pluginErrors, ...installedPlugins.errors],
);
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
if (memory) await memory.load();
/** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
const unconfiguredModel: LanguageModel = {
specificationVersion: 'v4',
provider: 'unconfigured',
modelId: 'unconfigured',
supportedUrls: {},
doGenerate: () => Promise.reject(new Error('no provider configured - run /provider')),
doStream: () => Promise.reject(new Error('no provider configured - run /provider')),
};
const record: store.SessionRecord = restored ?? {
id: store.newId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
cwd: process.cwd(),
provider: cfg.provider,
model: cfg.model,
title: 'untitled',
inputTokens: 0,
outputTokens: 0,
messages: [],
};
let saveTimer: ReturnType<typeof setTimeout> | undefined;
async function persist(messages: ModelMessage[]): Promise<void> {
record.messages = messages;
record.title = store.titleOf(messages);
record.inputTokens = session.inputTokens;
record.outputTokens = session.outputTokens;
record.notebook = session.notebook.state();
const cost = costOf(record.model, session.inputTokens, session.outputTokens);
if (cost !== undefined) record.costUsd = cost;
await store.save(record);
}
const bridge = createApprovalBridge();
const session = new Session({
model: languageModel ?? unconfiguredModel,
askApproval: bridge.ask,
yolo,
instructions,
skills,
plugins,
agent: agentVariant,
...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}),
...(cfg.permission ? { permissions: cfg.permission } : {}),
// Headless has no one to answer, so the tool is withheld rather than left to hang.
...(headless ? {} : { ask: askBridge.ask }),
...(memory ? { memory } : {}),
...(record.notebook ? { notebook: record.notebook } : {}),
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
extraTools: {
...(mcp?.tools ?? {}),
...(has('--no-subagent')
? {}
: {
task: createTaskTool({
model: languageModel ?? unconfiguredModel,
...(headless ? {} : { report: subagents.emit }),
}),
}),
},
autoApprove: ['task'],
messages: [...record.messages],
onChange: (messages) => {
// Debounced so a long tool loop does not hit the disk on every step.
clearTimeout(saveTimer);
saveTimer = setTimeout(() => void persist(messages), 400);
},
});
async function shutdown(code: number): Promise<never> {
clearTimeout(saveTimer);
if (session.messages.length > 0) await persist(session.messages);
await mcp?.close();
process.exit(code);
}
const printArg = flag('-p', '--print');
if (printArg !== undefined) {
const prompt = printArg || (await readStdin());
if (!prompt) {
console.error('shiro: -p needs a prompt argument or piped stdin');
await shutdown(1);
}
if (!yolo) {
process.stderr.write(
'shiro: headless denies write_file, edit_file, multi_edit, bash and mcp tools unless --yolo is passed\n',
);
}
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
await shutdown(code);
}
function applyConfig(next: Config): void {
cfg = next;
record.provider = next.provider;
record.model = next.model;
session.setModel(resolveModel(next, reportFallback));
}
const hooks: AppHooks = {
sessionId: record.id,
config: () => cfg,
instructionFiles: () => instructions.map((i) => i.path),
listPaths: async () => {
const found: string[] = [];
for await (const rel of walk({ limit: 5000 })) found.push(rel);
return found;
},
registry: {
list: async () => {
const entries = await registry.fetchIndex(cfg.registryUrl);
const installed = await installedNames();
return entries.map((e) => ({
name: e.name,
kind: e.kind,
description: e.description,
...(e.author ? { author: e.author } : {}),
installed: installed.has(`${e.kind}:${e.name}`),
}));
},
installed: async () => {
const rows: AppRegistryRow[] = [];
for (const s of skills) {
if (s.origin === 'registry') rows.push({ name: s.name, kind: 'skill', description: s.description, installed: true });
}
for (const p of installedPlugins.plugins) {
rows.push({ name: p.name, kind: 'plugin', description: p.description, installed: true });
}
return rows.sort((a, b) => a.name.localeCompare(b.name));
},
stage: async (name) => {
const entry = await findEntry(name);
const { preview } = await registry.stage(entry);
return {
row: { name: entry.name, kind: entry.kind, description: entry.description },
url: entry.url,
preview,
};
},
install: async (name) => {
const entry = await findEntry(name);
const { path } = await registry.install(entry);
// Loaded on the next start rather than hot-swapped: a skill joins the system
// prompt and a plugin joins the guard chain, and both are built once at boot.
return `installed ${entry.kind} ${entry.name} to ${path}\nrestart shiro to load it`;
},
remove: async (name) => {
const parsed = /^(skill|plugin):(.+)$/.exec(name);
const kinds: registry.RegistryKind[] = parsed ? [parsed[1] as registry.RegistryKind] : ['skill', 'plugin'];
const bare = parsed ? parsed[2]! : name;
for (const kind of kinds) {
if (await registry.uninstall(kind, bare)) return `removed ${kind} ${bare}\nrestart shiro to unload it`;
}
throw new Error(`nothing installed under the name "${bare}"`);
},
},
initPrompt: INIT_PROMPT,
history: promptHistory,
recordPrompt: (text) => void store.appendHistory(text),
agentName: () => session.agent().name,
thinkingLevel: () => session.agent().thinking,
switchModel: (id) => {
applyConfig({ ...cfg, model: id });
return `model is now ${id}`;
},
switchAgent: (name) => {
const next = resolveAgent(name, session.agent().thinking);
session.setAgent(next);
const scope = next.allowTools ? ` (read-only: ${next.allowTools.length} tools)` : '';
return `agent is now ${next.name}, thinking ${next.thinking}${scope}`;
},
switchThinking: (level) => {
if (!isThinkingLevel(level)) throw new Error(`Unknown thinking level "${level}"`);
session.setAgent({ ...session.agent(), thinking: level });
return `thinking is now ${level}`;
},
listSkills: () => {
if (skills.length === 0) return 'no skills loaded';
return [
...skills.map((s) => `${s.name.padEnd(12)} ${s.origin.padEnd(9)} ${s.description}`),
'',
'`/registry` to browse and install more',
].join('\n');
},
listPlugins: () => {
const active = plugins.plugins.map((p) => `${p.name.padEnd(12)} ${p.description}`);
const failed = plugins.errors.map((e) => `${e.plugin.padEnd(12)} ${e.message}`);
if (active.length === 0 && failed.length === 0) return 'no plugins active';
return [...active, ...failed, '', '`/registry` to browse and install more'].join('\n');
},
listMemory: async () => {
if (!memory) return 'memory is disabled (--no-memory)';
const all = await memory.load();
if (all.length === 0) return 'nothing remembered about this project yet';
return all
.slice()
.reverse()
.map((e) => `(${KIND_LABEL[e.kind]}) ${e.text}${e.hits > 0 ? ` [recalled ${e.hits}x]` : ''}`)
.join('\n');
},
summarizeMemory: async () => {
if (!memory) return 'memory is disabled (--no-memory)';
const { before, after } = await memory.summarize();
return before === after
? `memory left as is: ${before} entries, too few unused ones to merge`
: `memory compacted: ${before} entries into ${after}`;
},
applyProvider: async (result) => {
const next: Config = {
...cfg,
provider: result.provider,
model: result.model,
baseURL: result.baseURL,
apiKey: result.apiKey,
presetId: result.presetId,
};
applyConfig(next);
const path = await writeConfigFile({
provider: next.provider,
model: next.model,
baseURL: next.baseURL,
apiKey: next.apiKey,
presetId: next.presetId,
});
const label = presetById(result.presetId)?.label ?? result.presetId;
return `${label} configured with ${result.model}\nsaved to ${path}`;
},
listModels: async () => {
if (!cfg.apiKey) return { models: [], warning: 'no API key set - run /provider' };
const preset = presetById(cfg.presetId ?? cfg.provider);
const { models, warning } = await fetchModels(
{
kind: cfg.provider,
baseURL: cfg.baseURL ?? '',
...(preset?.fallbackModels ? { fallbackModels: preset.fallbackModels } : {}),
},
cfg.apiKey,
);
return warning ? { models, warning } : { models };
},
listSessions: async () => {
const all = await store.list(15);
if (all.length === 0) return 'no saved sessions';
return all
.map(
(r) =>
`${r.id.slice(0, 8)} ${r.updatedAt.slice(0, 16).replace('T', ' ')} ${r.messages.length}msg ${r.title}`,
)
.join('\n');
},
resumeSession: async (idOrPrefix) => {
const id = await store.resolveId(idOrPrefix);
const rec = id ? await store.load(id) : undefined;
if (!rec) throw new Error(`no session matching "${idOrPrefix}"`);
session.replace(rec.messages);
record.id = rec.id;
record.title = rec.title;
hooks.sessionId = rec.id;
return `resumed ${rec.id.slice(0, 8)} (${rec.messages.length} messages): ${rec.title}`;
},
saveSession: async () => {
await persist(session.messages);
return `saved ${record.id}`;
},
};
const header = [
needsProvider
? `shiro-neko ${VERSION} no provider configured`
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
`cwd: ${process.cwd()}`,
restored ? `resumed ${record.messages.length} messages` : undefined,
instructions.length > 0
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
: 'no AGENTS.md found - /init writes one',
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
yolo
? 'approvals: OFF (--yolo), but deny rules and the guard still apply'
: cfg.permission
? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere`
: 'approvals: ask for write_file, edit_file, multi_edit, bash, mcp__*',
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined,
'/help for commands',
]
.filter(Boolean)
.join('\n');
// ctrl-c has to reach the App: with a command running it kills that command and
// keeps the turn. Ink's own handler would exit the process before we saw the key.
const app = render(
<App
session={session}
bridge={bridge}
header={header}
hooks={hooks}
notices={notices}
askBridge={askBridge}
subagents={subagents}
needsProvider={needsProvider}
/>,
{ exitOnCtrlC: false },
);
await app.waitUntilExit();
await shutdown(0);