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
5 changes: 5 additions & 0 deletions .changeset/bash-parser-service.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `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

Expand Down
4 changes: 3 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
./packages/protocol
./packages/telemetry
./packages/transcript
./packages/tree-sitter-bash
./apps/kimi-code
./apps/vscode
./apps/kimi-inspect
Expand All @@ -103,6 +104,7 @@
"@moonshot-ai/protocol"
"@moonshot-ai/kimi-telemetry"
"@moonshot-ai/transcript"
"@moonshot-ai/tree-sitter-bash"
"@moonshot-ai/kimi-code"
"kimi-code"
"@moonshot-ai/kimi-inspect"
Expand Down Expand Up @@ -160,7 +162,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-+pzJfoWJwVXIUU8oc56LVpfNjSY6MABID5g11Cm92xw=";
hash = "sha256-niK61WYaqCJLUUlp4AGgq2/DOF3sqst6N9Kpk9noXyE=";
};

nativeBuildInputs = [
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/minidb": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"@moonshot-ai/tree-sitter-bash": "workspace:^",
"@mozilla/readability": "^0.6.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/scripts/check-domain-layers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const DOMAIN_LAYER = new Map([
// Depends only on `_base`; sits in L1 beside the other program-control
// layer substrates.
['task', 1],
// `bashParser` is the App-scope adapter over the pure
// `@moonshot-ai/tree-sitter-bash` package (bash source → syntax tree DTO).
// It injects no services, so it sits in L1 beside the other pure
// capabilities.
['bashParser', 1],
// persistence/ and os/ — the two-level scopes. `interface` holds contracts
// (same layer as the old domains they replace); `backends` holds
// implementations that may depend on cross-domain services at various layers.
Expand Down
42 changes: 42 additions & 0 deletions packages/agent-core-v2/src/app/bashParser/bashParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* `bashParser` domain (L1) — bash source parsing capability.
*
* Defines the `IBashParserService` that parses a bash source string into a
* syntax tree through the pure `@moonshot-ai/tree-sitter-bash` package, plus
* the wire-safe DTO types it returns: `BashSyntaxNode` drops the cyclic
* `parent` link so results can cross the RPC boundary, and offsets are
* UTF-16 code units (`text` always equals `source.slice(start, end)`). The
* parse runs under a deterministic budget — budget exhaustion yields
* `{ ok: false, reason: 'aborted' }` and malformed input yields
* `hasError: true`, never a throw; callers that cannot analyze a command
* must degrade on either signal. Bound at App scope.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export interface BashSyntaxNode {
readonly type: string;
readonly text: string;
readonly startIndex: number;
readonly endIndex: number;
readonly isNamed: boolean;
readonly children: readonly BashSyntaxNode[];
}

export interface BashParseOptions {
readonly timeoutMs?: number;
readonly maxNodes?: number;
}

export type BashParseResult =
| { readonly ok: true; readonly hasError: boolean; readonly root: BashSyntaxNode }
| { readonly ok: false; readonly reason: 'aborted' };

export interface IBashParserService {
readonly _serviceBrand: undefined;

parse(source: string, options?: BashParseOptions): BashParseResult;
}

export const IBashParserService: ServiceIdentifier<IBashParserService> =
createDecorator<IBashParserService>('bashParserService');
49 changes: 49 additions & 0 deletions packages/agent-core-v2/src/app/bashParser/bashParserService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* `bashParser` domain (L1) — `IBashParserService` implementation.
*
* Thin adapter over the pure `@moonshot-ai/tree-sitter-bash` package: runs
* its budgeted `parse` and snapshots the returned tree into the wire-safe
* `BashSyntaxNode` DTO (source-ordered children including anonymous tokens,
* `parent` links dropped). Owns no state and injects no services. Bound at
* App scope.
*/

import { parse } from '@moonshot-ai/tree-sitter-bash';
import type { SyntaxNode } from '@moonshot-ai/tree-sitter-bash';

import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';

import type { BashParseOptions, BashParseResult, BashSyntaxNode } from './bashParser';
import { IBashParserService } from './bashParser';

function snapshot(node: SyntaxNode): BashSyntaxNode {
return {
type: node.type,
text: node.text,
startIndex: node.startIndex,
endIndex: node.endIndex,
isNamed: node.isNamed,
children: node.children.map(snapshot),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snapshot deep syntax trees iteratively

When callers parse a long but budget-acceptable arithmetic expression, e.g. echo $((1+1+...)) with a few thousand operands and a larger timeoutMs, the parser returns a deeply left-nested binary_expression tree under the default maxNodes; this recursive map(snapshot) then overflows the JS call stack and makes IBashParserService.parse throw RangeError instead of returning hasError/aborted. Since this service is meant to be the wire-safe RPC adapter, the DTO conversion needs the same iterative approach as materialize.

Useful? React with 👍 / 👎.

};
}

export class BashParserService implements IBashParserService {
declare readonly _serviceBrand: undefined;

parse(source: string, options: BashParseOptions = {}): BashParseResult {
const result = parse(source, options);
if (!result.ok) {
return { ok: false, reason: result.reason };
}
return { ok: true, hasError: result.hasError, root: snapshot(result.rootNode) };
}
}

registerScopedService(
LifecycleScope.App,
IBashParserService,
BashParserService,
InstantiationType.Delayed,
'bashParser',
);
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ export * from '#/app/workspaceRegistry/workspacePersistence';
export * from '#/app/workspaceRegistry/fileWorkspacePersistence';
import '#/app/workspaceRegistry/workspaceQueryService';
import '#/app/git/gitService';
export * from '#/app/bashParser/bashParser';
import '#/app/bashParser/bashParserService';
export * from '#/session/process/processRunner';
export * from '#/session/process/processRunnerService';
export * from '#/session/sessionFs/errors';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import type { BashSyntaxNode } from '#/app/bashParser/bashParser';
import { IBashParserService } from '#/app/bashParser/bashParser';
import { BashParserService } from '#/app/bashParser/bashParserService';

describe('BashParserService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let service: IBashParserService;

beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.define(IBashParserService, BashParserService);
},
});
service = ix.get(IBashParserService);
});

afterEach(() => {
disposables.dispose();
});

it('splits a compound command into per-command nodes', () => {
const result = service.parse('git status && rm -rf /');
if (!result.ok) {
throw new Error('expected ok');
}
expect(result.hasError).toBe(false);
expect(result.root.type).toBe('program');
const commands: string[] = [];
const walk = (node: BashSyntaxNode): void => {
if (node.type === 'command') {
commands.push(node.text);
}
node.children.forEach(walk);
};
walk(result.root);
expect(commands).toEqual(['git status', 'rm -rf /']);
});

it('flags malformed input with hasError instead of throwing', () => {
const result = service.parse('echo "unterminated');
if (!result.ok) {
throw new Error('expected ok');
}
expect(result.hasError).toBe(true);
});

it('reports budget exhaustion as aborted', () => {
const result = service.parse('ls; '.repeat(20000), { maxNodes: 100 });
expect(result).toEqual({ ok: false, reason: 'aborted' });
});

it('returns a JSON-serializable tree with text/range fidelity', () => {
const source = 'echo "你好 🎉" | grep 你';
const result = service.parse(source);
if (!result.ok) {
throw new Error('expected ok');
}
const roundTripped = JSON.parse(JSON.stringify(result.root)) as BashSyntaxNode;
const check = (node: BashSyntaxNode): void => {
expect(node.text).toBe(source.slice(node.startIndex, node.endIndex));
node.children.forEach(check);
};
check(roundTripped);
});
});
Loading
Loading