diff --git a/.changeset/bash-parser-service.md b/.changeset/bash-parser-service.md new file mode 100644 index 00000000000..05e5f342084 --- /dev/null +++ b/.changeset/bash-parser-service.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an internal bash parsing capability that turns shell command strings into syntax trees, in preparation for per-command permission analysis. No user-facing behavior change yet. diff --git a/AGENTS.md b/AGENTS.md index 31b25124b44..0ba8ee0b65d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. +- `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). ## Environment Requirements diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 07ed0703b01..b7c0b67de55 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -8,7 +8,9 @@ * / State tabs), the chat column, and the right dock (`RightPanel`) merging * the transcript audit and the agent inspector under Audit / Agent tabs; * the `models` view is the full-width model catalog; the `services` view is - * the full-width app-scope Service reflection (`AppServicesView`). + * the full-width app-scope Service reflection (`AppServicesView`); the + * `bash` view is the full-width `IBashParserService` playground + * (`BashParserView`). */ import { useEffect, useState } from 'react'; @@ -17,6 +19,7 @@ import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/session import type { AuditTrail } from './audit/trail'; import { AppServicesView } from './components/AppServicesView'; +import { BashParserView } from './components/BashParserView'; import { ChatView } from './components/ChatView'; import { ModelCatalogView } from './components/ModelCatalogView'; import { NavRail, type AppView } from './components/NavRail'; @@ -82,6 +85,8 @@ export function App() { {view === 'services' ? ( + ) : view === 'bash' ? ( + ) : view === 'models' ? ( { diff --git a/apps/kimi-inspect/src/components/BashParserView.tsx b/apps/kimi-inspect/src/components/BashParserView.tsx new file mode 100644 index 00000000000..1532705722b --- /dev/null +++ b/apps/kimi-inspect/src/components/BashParserView.tsx @@ -0,0 +1,279 @@ +/** + * Bash Parser view — a playground for the App-scope `IBashParserService` + * (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`). + * + * left: the bash source textarea plus the parse budget (timeoutMs / + * maxNodes, empty = package default); the `examples…` dropdown + * fills the textarea with curated snippets from the parser's own + * differential fixtures; + * right: the parse result — status badges (hasError / aborted / node + * count) and the syntax tree, one row per node with its type, + * UTF-16 range and (for leaves) the source text. Anonymous tokens + * are dimmed; rows expand/collapse. + * + * Parsing is debounced off the textarea and rides the same `/api/v1/debug` + * channel as every other panel (`klient.core(IBashParserService).parse`) — + * the budgeted parse never throws, `{ ok: false }` means budget exhaustion. + */ + +import { useEffect, useState } from 'react'; + +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser'; + +import { useConnection } from '../connection'; +import { Badge, errorMessage } from '../ui'; + +const DEFAULT_SOURCE = `if [ -f config.sh ]; then + source config.sh && echo "loaded" | tee -a setup.log +else + echo "missing" >&2; exit 1 +fi +`; + +const PARSE_DEBOUNCE_MS = 300; + +/** + * Quick-fill examples, adapted from the parser's own differential fixtures + * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one + * exercises a distinct area of the grammar. The last three probe the + * non-happy paths: deep nesting (a left-associative arithmetic chain, the + * case that once overflowed the DTO conversion) and the error-recovery + * paths that set `hasError`. + */ +const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [ + { + name: 'deep arithmetic (1000 operands)', + // A thousand left-nested binary_expression levels. Deeper chains parse + // fine in-process, but past ~2500 levels the JSON RPC transport itself + // cannot serialize the tree (V8 call-stack limit in JSON.stringify). + source: `echo $((${'1+'.repeat(1000)}1))`, + }, + { + name: 'pipeline & redirects', + source: `git log --oneline | head -20 | tee /tmp/log.txt +find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO +cmd <<< "$input" >out.txt 2>&1 +`, + }, + { + name: 'case statement', + source: `case $x in + a) echo A ;; + b|c) echo BC ;& + foo*|bar) echo match ;; + [a-z]) echo lower ;; + *) echo other ;; +esac +`, + }, + { + name: 'heredoc', + source: `foo() { cat < sum + countNodes(child), 0); +} + +export function BashParserView() { + const { klient } = useConnection(); + const [source, setSource] = useState(DEFAULT_SOURCE); + const [timeoutMs, setTimeoutMs] = useState(''); + const [maxNodes, setMaxNodes] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handle = setTimeout(() => { + klient + .core(IBashParserService) + .parse(source, { + timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs), + maxNodes: maxNodes === '' ? undefined : Number(maxNodes), + }) + .then(setResult, (e: unknown) => { + setResult(null); + setError(errorMessage(e)); + }); + }, PARSE_DEBOUNCE_MS); + return () => { + clearTimeout(handle); + }; + }, [klient, source, timeoutMs, maxNodes]); + + const nodeCount = result !== null && result.ok ? countNodes(result.root) : null; + + return ( +
+
+
+ bash source + +
+ + +
+