mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-19 00:00:08 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50dcd06c65 | ||
|
|
86428abce6 | ||
|
|
1b1fba48fc | ||
|
|
794254597c | ||
|
|
cc58238e43 | ||
|
|
16ce599ef0 | ||
|
|
b05aeee652 | ||
|
|
811403286b | ||
|
|
102bc5ef79 | ||
|
|
dc346498fb | ||
|
|
dbf5389729 | ||
|
|
08106b0c37 | ||
|
|
9e7fd601a5 | ||
|
|
b71afddae1 | ||
|
|
525035b51b | ||
|
|
9cbe6d9a8c | ||
|
|
277fdda894 | ||
|
|
1c8338ea66 | ||
|
|
971d350e53 | ||
|
|
1a66da4eb3 | ||
|
|
8613b7065a |
No files matched your search
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
|
||||
"pid": 487360,
|
||||
"host": "127.0.0.1",
|
||||
"port": 37541,
|
||||
"url": "ws://127.0.0.1:37541",
|
||||
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
|
||||
"startedAt": 1791842673967,
|
||||
"updatedAt": 1791842673967,
|
||||
"stale": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -0,0 +1,92 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-08-16
|
||||
**Commit:** b71afdd
|
||||
**Branch:** main
|
||||
|
||||
## OVERVIEW
|
||||
Claw Code: public Rust implementation of the `claw` CLI agent harness (Claude-Code-style). Canonical code lives in `rust/`; the repo is an agent-managed exhibit (harnesses plan/execute/verify per README), not a hand-operated product. `src/` is a companion Python porting/parity workspace, not production code.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
claw-code/
|
||||
├── rust/ # canonical Cargo workspace: 11 crates, `claw` binary
|
||||
├── src/ # Python porting workspace + reference_data/ parity snapshots
|
||||
├── tests/ # Python unittest validation of src/ + scripts/ (stdlib unittest)
|
||||
├── docs/ # g0XX gate verification maps + topic docs
|
||||
├── scripts/ # fmt.sh, dogfood-build.sh, roadmap/board helpers
|
||||
├── assets/ # README images only
|
||||
└── install.sh, Containerfile, docker-compose.yml
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| CLI subcommands | rust/crates/rusty-claude-cli/src/main.rs | hand-rolled parser; CliAction enum ~L1162; dispatch in run() L995-1158 |
|
||||
| Sessions/permissions/MCP | rust/crates/runtime/src/ | 47 flat modules |
|
||||
| Provider clients | rust/crates/api/src/providers/ | anthropic.rs + openai_compat.rs |
|
||||
| Tool definitions | rust/crates/tools/src/lib.rs | 55-tool spec table L484-1348 |
|
||||
| Slash commands | rust/crates/commands/src/lib.rs | 120+ spec table L60-1047 |
|
||||
| Plugins/hooks | rust/crates/plugins/src/ | manifest = .claude-plugin/plugin.json |
|
||||
| Lean agent harness | rust/crates/claw-analog/src/lib.rs | lib+bin; tool loop over api+runtime |
|
||||
| RAG HTTP service | rust/crates/claw-rag-service/src/ | axum; SQLite + optional Qdrant |
|
||||
| Test mock server | rust/crates/mock-anthropic-service/ | SCENARIO_PREFIX scripted responses |
|
||||
| Python porting CLI | src/main.py | argparse: manifest, parity-audit, graphs |
|
||||
| Parity reference DB | src/reference_data/subsystems/ | 29 JSON snapshots of TS archive |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Refs | Role |
|
||||
|--------|------|----------|------|------|
|
||||
| Session | struct | runtime/src/session.rs:117 | 229 | session persistence/lifecycle |
|
||||
| ConfigLoader | struct | runtime/src/config.rs:409 | 83 | config schema/load |
|
||||
| PluginManager | struct | plugins/src/lib.rs | 48 | plugin install/registry |
|
||||
| PermissionEnforcer | struct | runtime/src/permission_enforcer.rs:27 | 35 | pre-dispatch permission gate |
|
||||
| ConversationRuntime | struct | runtime/src/conversation.rs:130 | 32 | conversation loop driver |
|
||||
| McpServerManager | struct | runtime/src/mcp_stdio.rs:488 | 30 | MCP JSON-RPC processes |
|
||||
| HookRunner | struct | runtime/src/hooks.rs:155 | 25 | shell hook execution |
|
||||
| CliAction | enum | rusty-claude-cli/src/main.rs:1162 | — | 25 subcommand variants |
|
||||
| mvp_tool_specs | fn | tools/src/lib.rs:484 | — | static 55-tool table |
|
||||
| SLASH_COMMAND_SPECS | const | commands/src/lib.rs:60 | — | 120+ slash commands |
|
||||
|
||||
(Refs = rg count across rust/crates; rust-analyzer references timed out during mapping.)
|
||||
|
||||
## CONVENTIONS
|
||||
- `unsafe_code = "forbid"` workspace-wide; every crate opts in via `[lints] workspace = true`; clippy all=warn, pedantic=allow
|
||||
- Edition 2021, resolver 2, publish=false; no rust-toolchain pin (CI floats stable); no rustfmt.toml/clippy.toml — stock defaults
|
||||
- Giant flat files by design (main.rs 19.8k, tools/lib.rs 10.9k, commands/lib.rs 7.2k): organization is positional — types → spec table → dispatch → handlers → tests at EOF
|
||||
- Dual output paths everywhere: `render_x` + `render_x_json`; JSON errors to **stdout**, text errors to **stderr**
|
||||
- Tests: inline `#[cfg(test)] mod tests` primary; integration tests spawn `CARGO_BIN_EXE_claw` subprocess against mock-anthropic-service; tempfile everywhere; env-mutating tests serialize via env_lock/test_env_lock
|
||||
- Comments carry issue numbers (#824, #146); gate tests named by roadmap gate (g004_conformance.rs)
|
||||
- Python side: stdlib only, `python -m unittest`; src/ mixes camelCase (QueryEngine.py) and snake_case filenames
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- NEVER `cargo install claw-code` — crates.io stub is deprecated and installs `claw-code-deprecated.exe`; build from source
|
||||
- Forbidden doc strings (CI-enforced by .github/scripts/check_doc_source_of_truth.py): old org links `github.com/Yeachan-Heo/claw-code`, `github.com/code-yeongyu/claw-code`, `discord.gg/6ztZB9jvWq`, `assets/clawd-hero.jpeg`
|
||||
- Deprecated config keys: `permissionMode` → `permissions.defaultMode`; `enabledPlugins` → `plugins.enabled`; env `RUSTY_CLAUDE_PERMISSION_MODE` is dead
|
||||
- Direct push to main is policy-blocked (`main_push_forbidden` approval scope)
|
||||
- Automation lanes must not merge/close remote PRs/issues (docs/anti-slop-triage.md)
|
||||
- `claw init` must not scaffold `dontAsk` permission mode (regression-pinned in output_format_contract.rs)
|
||||
- File-level `#![allow(dead_code)]` blocks (main.rs, session_control.rs) are tolerated legacy — do not extend the pattern
|
||||
|
||||
## UNIQUE STYLES
|
||||
- Dogfood build: scripts/dogfood-build.sh injects GIT_SHA; `claw version` provenance must equal HEAD
|
||||
- Mock parity: rust/mock_parity_scenarios.json drives CLI subprocess vs MockAnthropicService
|
||||
- Dogfooding uses `CLAW_CONFIG_HOME=$(mktemp -d)` for config isolation
|
||||
- Env contracts: GIT_SHA (build), CLAW_CONFIG_HOME (config dir), OLLAMA_HOST (provider override), `*_API_KEY`/`*_BASE_URL` per provider
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
scripts/fmt.sh --check # fmt check (apply: scripts/fmt.sh)
|
||||
cd rust && cargo clippy --workspace --all-targets -- -D warnings
|
||||
cd rust && cargo test --workspace
|
||||
cd rust && cargo build -p rusty-claude-cli # binary: rust/target/debug/claw
|
||||
python -m unittest discover -s tests # Python suite
|
||||
python .github/scripts/check_doc_source_of_truth.py && scripts/roadmap-check-ids.sh # docs/roadmap CI
|
||||
```
|
||||
|
||||
## NOTES
|
||||
- `claw` binary comes from crate `rusty-claude-cli` (package/bin name mismatch)
|
||||
- rust-ci.yml triggers only on rust/**, docs/**, listed meta file changes (path filters)
|
||||
- CI clippy job runs without `-D warnings` — weaker than the documented gate; known pre-existing failures recorded in docs/g002/g003 maps
|
||||
- `claw acp` is a status stub, not a real ACP server
|
||||
- rust/ has committed harness dotdirs (.clawd-agents/, .omc/, .sandbox-home/) — intentional
|
||||
@@ -0,0 +1,80 @@
|
||||
# AGENTS.md — rust/ workspace
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Virtual Cargo workspace (resolver 2, edition 2021) housing 11 crates that compose the `claw` CLI and supporting services.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
| Crate | Kind | Purpose |
|
||||
|---|---|---|
|
||||
| `rusty-claude-cli` | bin (`claw`) | Main CLI binary. Package name ≠ binary name. |
|
||||
| `claw-analog` | lib+bin | Alternate entry point; depends on api + runtime only. |
|
||||
| `claw-rag-service` | bin | RAG service. Only crate with `[features]` (`qdrant-index`). |
|
||||
| `mock-anthropic-service` | lib+bin | Mock Anthropic Messages API. Prints `MOCK_ANTHROPIC_BASE_URL`. Dev-dep for CLI and analog tests. |
|
||||
| `runtime` | lib | Core: sessions, permissions, MCP, conversation loop. ~47 modules. |
|
||||
| `api` | lib | Provider clients: Anthropic, OpenAI-compat (xAI, OpenAI, DashScope, Ollama). |
|
||||
| `tools` | lib | 55-tool surface area. Depends on `commands` (not vice versa). |
|
||||
| `commands` | lib | 120+ slash commands. |
|
||||
| `plugins` | lib | Plugin manifest and lifecycle. |
|
||||
| `telemetry` | lib | Request identity + analytics sinks. |
|
||||
| `compat-harness` | lib | Extracts upstream TS claude-code manifest/commands/tools for parity comparison. |
|
||||
|
||||
Dependency direction: `rusty-claude-cli` → tools/commands/runtime/api/plugins. `tools` → `commands`.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
- **Parity testing**: `mock_parity_scenarios.json` at workspace root, loaded via `CARGO_MANIFEST_DIR/../../mock_parity_scenarios.json`. Scripts in `scripts/` (`run_mock_parity_harness.sh`, `run_mock_parity_diff.py`).
|
||||
- **CI**: `.github/workflows/rust-ci.yml` (fmt, clippy, test, docs, Windows smoke) and `release.yml` (v* tag builds for linux-x64/macos-arm64/windows-x64).
|
||||
- **Committed test fixtures**: `.clawd-agents/`, `.omc/`, `.sandbox-home/` are checked-in harness dotdirs.
|
||||
- **Docs**: `PARITY.md`, `TUI-ENHANCEMENT-PLAN.md`, `README.md` alongside this file.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
Workspace lints (all crates opt in via `[lints] workspace = true`):
|
||||
- `unsafe_code` = **forbid**. No exceptions.
|
||||
- clippy `all` = warn, `pedantic` = allow. Explicitly allowed: `module_name_repetitions`, `missing_panics_doc`, `missing_errors_doc`.
|
||||
|
||||
No `rustfmt.toml` or `clippy.toml`. Stock defaults only.
|
||||
|
||||
TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mix raw ANSI escapes with crossterm.
|
||||
|
||||
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
|
||||
|
||||
Workspace version is `0.1.3`, `publish = false`, MIT license.
|
||||
|
||||
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Don't run `cargo fmt --manifest-path rust/Cargo.toml` from the repo root. Use `../scripts/fmt.sh` instead.
|
||||
- Don't add `unsafe` code. The lint is set to `forbid`, not `deny`. You can't `#[allow]` it.
|
||||
- Don't create dependencies from `commands` → `tools`. The arrow goes `tools` → `commands`.
|
||||
- Don't write TUI output directly to stdout or use raw ANSI escape sequences.
|
||||
- Don't add features to crates other than `claw-rag-service` without good reason; the workspace is feature-lean by design.
|
||||
|
||||
## COMMANDS
|
||||
|
||||
All run from `rust/`:
|
||||
|
||||
```sh
|
||||
# Format (check only)
|
||||
../scripts/fmt.sh --check
|
||||
|
||||
# Format (apply)
|
||||
../scripts/fmt.sh
|
||||
|
||||
# Lint (strict, matches what you should pass before pushing)
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Test
|
||||
cargo test --workspace
|
||||
|
||||
# Build specific binary
|
||||
cargo build -p rusty-claude-cli
|
||||
cargo build -p claw-analog
|
||||
cargo build -p claw-rag-service
|
||||
cargo build -p mock-anthropic-service
|
||||
```
|
||||
|
||||
Note: CI clippy runs without `-D warnings`, so the local check above is stricter than the gate.
|
||||
+1
-1
@@ -221,7 +221,7 @@ rust/
|
||||
## Stats
|
||||
|
||||
- **~20K lines** of Rust
|
||||
- **9 crates** in workspace
|
||||
- **11 crates** in workspace
|
||||
- **Binary name:** `claw`
|
||||
- **Default model:** `claude-opus-4-7`
|
||||
- **Default permissions:** `workspace-write`
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md — api crate
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
LLM provider client layer: dispatches Anthropic, xAI, OpenAI, DashScope, and Ollama behind two wire protocols (Anthropic Messages native, OpenAI Chat Completions compat).
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Module | What lives here |
|
||||
|---|---|
|
||||
| `client.rs` | `ProviderClient` enum (facade). `from_model(model)` resolves alias, picks `ProviderKind`, handles OLLAMA_HOST and DashScope qwen-prefix cases. `send_message`/`stream_message`. |
|
||||
| `providers/mod.rs` | `Provider` trait (generic, dead_code-allowed, not used for dispatch). `ProviderKind`, `resolve_model_alias`, `ProviderMetadata` (auth_env, base_url_env, default_base_url), `max_tokens_for_model[_with_override]`, capability/diagnostic reporting, `preflight_message_request` validation. |
|
||||
| `providers/anthropic.rs` | `AnthropicClient` (re-exported as `ApiClient` at crate root). Dual auth: API key env vs saved OAuth (`AuthSource`, `OAuthTokenSet`, token expiry checks). Base-url resolution. SSE `MessageStream`. Prompt-cache hooks. |
|
||||
| `providers/openai_compat.rs` | `OpenAiCompatClient` parameterized by `OpenAiCompatConfig` (presets: `xai()`, `openai()`, `dashscope()`, `OLLAMA_CONFIG`). Heavy translation layer: `build_chat_completion_request`, `translate_message`, `sanitize_tool_message_pairing`, `flatten_tool_result_content`. Model-quirk predicates (`is_reasoning_model`, etc.). Body-size estimation/guards. |
|
||||
| `types.rs` | Provider-agnostic wire types: `MessageRequest`, `InputMessage`, `ContentBlock`, `StreamEvent`, `Usage`, `ToolDefinition`, `ToolChoice`. |
|
||||
| `sse.rs` | `SseParser`, `parse_frame`. |
|
||||
| `http_client.rs` | reqwest builders, `ProxyConfig` from env proxy vars, `TimeoutConfig`. |
|
||||
| `error.rs` | `ApiError`. |
|
||||
| `prompt_cache.rs` | `PromptCache` + `Stats` (Anthropic-only). |
|
||||
| `lib.rs` | Curated `pub use` lists define the public surface. Also re-exports sibling telemetry crate items. |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- Module-private by default. `lib.rs` `pub use` lists are the sole public API surface.
|
||||
- `#[must_use]` on pure constructors.
|
||||
- Provider config follows an env-var pair pattern: `*_API_KEY` / `*_BASE_URL`, recorded in `ProviderMetadata`.
|
||||
- Leaf files carry targeted `#![allow(clippy::cast_possible_truncation)]` where needed.
|
||||
- Dispatch goes through the `ProviderClient` enum, not trait objects. The `Provider` trait exists but is dead-code-allowed.
|
||||
- Streams unify into `MessageStream` with `next_event()` yielding `StreamEvent`.
|
||||
|
||||
## TESTS
|
||||
|
||||
- Four integration test files under `tests/`:
|
||||
- `client_integration` — core client behavior
|
||||
- `openai_compat_integration` — OpenAI-compat translation paths
|
||||
- `provider_client_integration` — `ProviderClient` dispatch
|
||||
- `proxy_integration` — proxy config
|
||||
- Tests that touch env vars serialize through a shared `env_lock()` mutex. Don't skip this or you'll get flaky parallel failures.
|
||||
- `benches/request_building.rs` is the workspace's only Criterion bench. Targets hot translation functions. This file bulk-opts out of strict lints.
|
||||
@@ -0,0 +1,37 @@
|
||||
# commands crate
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
REPL slash-command surface: parsing, spec registry, help rendering, and a handful of in-crate handlers. Single flat `src/lib.rs` (~7k lines). Deps: `plugins`, `runtime`, `serde_json` only. Note: `tools` depends on `commands`, not the reverse.
|
||||
|
||||
## lib.rs MAP
|
||||
|
||||
| Lines | Landmark |
|
||||
|-------------|----------|
|
||||
| 16–58 | Registry types: `CommandManifestEntry`, `CommandSource` (Builtin / InternalOnly / FeatureGated), `CommandRegistry`, `SlashCommandSpec`, `SkillSlashDispatch` |
|
||||
| 60–1047 | `SLASH_COMMAND_SPECS` static table. 120+ entries (help, status, sandbox, compact, model, permissions, clear, cost, resume, config, mcp, memory, init, diff, version, bughunter, commit, pr, issue, ultraplan, teleport, debug-tool-call, export, session, plugin, agents, skills, doctor, plan, review, tasks, theme, vim, voice, chat, ...) |
|
||||
| 1048–1303 | `SlashCommand` enum (~65 variants + `Unknown(String)`), `SlashCommandParseError`, `SlashCommand::parse` (L1218) |
|
||||
| 1515–1899 | Per-command arg parsers: `parse_mcp_command`, `parse_plugin_command`, `parse_session_command`, etc. |
|
||||
| 1900–2108 | Help/suggestion rendering: `render_slash_command_help*`, `suggest_slash_commands` (Levenshtein), category grouping |
|
||||
| 2109–2682 | Result types + handlers: `handle_plugins_slash_command`, `handle_agents/mcp/skills_slash_command(_json)`, skill dispatch/resolve |
|
||||
| 3160–5293 | Reporting layer: paired text and `_json` renderers for plugins/agents/skills/mcp reports, skill install/uninstall/create-agent logic, frontmatter parsing, root discovery |
|
||||
| 5294 | `handle_slash_command(input, session, compaction)` top dispatch. Only Compact and Help execute here; all other variants return to the REPL caller |
|
||||
| 5403–7183 | `mod tests` (~1780 lines) |
|
||||
|
||||
## ADDING A SLASH COMMAND
|
||||
|
||||
1. **Spec.** Add a `SlashCommandSpec` entry to `SLASH_COMMAND_SPECS`. Set `resume_supported` honestly.
|
||||
2. **Enum + parse.** Add a variant to `SlashCommand`. Wire a match arm in `SlashCommand::parse`. If the command takes arguments, add a dedicated `parse_*_command` function in the arg-parser block.
|
||||
3. **Handler.** Decide where execution lives:
|
||||
- In-crate (like Compact/Help): handle it inside `handle_slash_command`.
|
||||
- Returned to caller: just return the parsed variant. The REPL layer executes it.
|
||||
4. **Help.** Make sure the spec's `summary` and `argument_hint` are set so help rendering and suggestion matching pick it up automatically.
|
||||
5. **Tests.** Cover parsing (valid input, bad input, edge cases) in the inline `mod tests`.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Dual renderers.** Every report surface has a text variant and a `_json` variant: `handle_x` / `handle_x_json`, `render_*` / `render_*_json`. Keep them in sync.
|
||||
- **Error style.** Handlers return `std::io::Result`. Parse failures use `SlashCommandParseError`.
|
||||
- **Manifest registries.** Pattern is `entries: Vec<_Entry>` backed by the static spec table.
|
||||
- **Dependency direction.** This crate knows nothing about `tools`. Don't import it.
|
||||
- **No execution here.** Almost all commands pass through as parsed data. Only Compact and Help run inside this crate. Respect that boundary.
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md — plugins crate
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Plugin subsystem: how third-party, builtin, and bundled tools/commands/hooks enter the runtime.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
- `src/lib.rs` (~3,863 lines): the bulk of the crate. Manifest parsing (`.claude-plugin/plugin.json`), installed-plugin registry, lifecycle model, permission model, install/update management.
|
||||
- `src/hooks.rs`: hook event model (`HookEvent`, `HookRunResult`) and `HookRunner` for shell-hook execution. Re-exported from `lib.rs`. **Caution:** the runtime crate has its own `hooks.rs` with a separate `HookRunner` (execution + abort-signal side, around L155). Know which layer you need before editing.
|
||||
- `src/test_isolation.rs`: test isolation helpers.
|
||||
- `bundled/`: example plugin fixtures. `example-bundled/` and `sample-hooks/` each contain `.claude-plugin/plugin.json` plus `pre.sh`/`post.sh` shell hooks. Treat these as the reference shape when authoring a new plugin.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
**Key public types** (all in `src/lib.rs` unless noted):
|
||||
|
||||
- Kinds/definitions: `PluginKind`, `PluginDefinition`, `BuiltinPlugin`, `BundledPlugin`, `ExternalPlugin`.
|
||||
- Manifests: `PluginManifest`, `PluginToolManifest`, `PluginToolDefinition`, `PluginToolPermission`, `PluginCommandManifest`.
|
||||
- Hooks: `PluginHooks`, `HookEvent`, `HookRunResult` (from `hooks.rs`).
|
||||
- Lifecycle/permissions: `PluginLifecycle`, `PluginPermission`.
|
||||
- Registry: `InstalledPluginRecord`, `InstalledPluginRegistry`, `RegisteredPlugin`, `PluginRegistry` (+ `Report`, `Summary`, `LoadFailure`).
|
||||
- Management: `PluginManager` (+ `Config`), `InstallOutcome`, `UpdateOutcome`.
|
||||
- Trait: `Plugin`.
|
||||
- Errors: `PluginError`.
|
||||
- Entry points: `builtin_plugins()`, `load_plugin_from_directory()`.
|
||||
|
||||
**Lifecycle spans two crates.** Manifest parsing and registry live here. Health checks, degraded-mode, and `PluginState` live in `runtime/src/plugin_lifecycle.rs`. Changes to plugin lifecycle logic often touch both.
|
||||
|
||||
**Plugin shape.** A plugin directory contains `.claude-plugin/plugin.json` at minimum. Shell hooks (`pre.sh`, `post.sh`) sit alongside. See `bundled/` for working examples.
|
||||
|
||||
**Consumers.** `PluginManager` has ~48 references across the workspace. CLI wires plugins via `RuntimePluginStateBuildOutput` in `rusty-claude-cli`. The tools crate exposes plugin tools through `GlobalToolRegistry`.
|
||||
|
||||
## NOTES
|
||||
|
||||
- Don't confuse the two `HookRunner` implementations. This crate's version handles the event model. The runtime crate's version handles execution and abort signals.
|
||||
- `lib.rs` is large. Most searches for plugin behavior start and end there.
|
||||
- Bundled plugin fixtures under `bundled/` are used in tests. Breaking their structure breaks CI.
|
||||
- Permission model is enforced at install time and checked at runtime. Both paths matter when modifying `PluginPermission`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# AGENTS.md — runtime crate
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Core crate of `claw`: session persistence, permissions, prompt assembly, MCP plumbing, tool-facing file ops, conversation loop. 47 flat modules in src/, ~330 pub symbols, ~170 re-exported flat from lib.rs.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Group | Files | Entry points |
|
||||
|---|---|---|
|
||||
| session/conversation | session.rs, session_control.rs, conversation.rs, compact.rs, summary_compression.rs, usage.rs | `Session` (L117), `SessionStore`, `ConversationRuntime` (L130), `ApiClient`/`ToolExecutor` traits |
|
||||
| config | config.rs, config_validate.rs, bootstrap.rs | `ConfigLoader` (L409), type-export heaviest file; MCP server config enums live here |
|
||||
| MCP (6-file split) | mcp.rs, mcp_client.rs, mcp_stdio.rs, mcp_server.rs, mcp_tool_bridge.rs, mcp_lifecycle_hardened.rs | `McpServerManager` (L488 in mcp_stdio.rs), JSON-RPC spawn |
|
||||
| hooks/plugins | hooks.rs, plugin_lifecycle.rs | `HookRunner` (L155), abort signal, healthcheck, degraded mode |
|
||||
| permissions/safety | permissions.rs, permission_enforcer.rs, policy_engine.rs, approval_tokens.rs, sandbox.rs, bash_validation.rs, trust_resolver.rs | `PermissionEnforcer` (L27), `GreenLevel`, lane decisions |
|
||||
| tools/execution | bash.rs, file_ops.rs, lsp_client.rs | `execute_bash`, `*_in_workspace` file op variants |
|
||||
| lane/worker | lane_events.rs, worker_boot.rs, task_packet.rs, task_registry.rs, team_cron_registry.rs, branch_lock.rs, stale_base.rs, stale_branch.rs | `LaneEvent` dedupe/provenance, `LaneBoard` |
|
||||
| prompt | prompt.rs | `SystemPromptBuilder`, `ContextFile`, dynamic boundary marker |
|
||||
| git/remote/auth | git_context.rs, remote.rs, oauth.rs | Upstream proxy, PKCE flow |
|
||||
| misc | json.rs, sse.rs, g004_conformance.rs, green_contract.rs, recovery_recipes.rs, report_schema.rs, trident.rs | Report v1 + redaction |
|
||||
|
||||
Largest files by line count: config.rs (3894), mcp_stdio.rs (2969), lane_events.rs (2561), worker_boot.rs (2441), session.rs (1961), conversation.rs (1878).
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- One file per module, flat layout. No subdirectories.
|
||||
- Most modules are private `mod x` with selective `pub use`. 21 modules are `pub mod`, so consumers use both the re-export and the qualified path.
|
||||
- Deps kept minimal: serde, tokio, glob, regex, sha2, walkdir + internal plugins/telemetry. No reqwest, no async-trait. Remote/SSE done by hand.
|
||||
- Inline `#[cfg(test)]` tests per file. session.rs has two test modules.
|
||||
- `pub(crate) test_env_lock()` mutex in lib.rs serializes env-mutating tests. Use it when touching env vars.
|
||||
- trust_resolver.rs is `#[cfg(test)]`-gated yet pub-used: test-only API surface.
|
||||
|
||||
## INVARIANTS (do not break)
|
||||
|
||||
1. **Compaction pairs**: compact.rs must never split assistant(ToolUse)/ToolResult pairs.
|
||||
2. **No side effects on construction**: SessionStore construction must not create `.claw` directories (session_control.rs:1090).
|
||||
3. **Workspace containment**: file_ops.rs workspace ops must not escape the workspace root.
|
||||
4. **Permission ordering**: a leading read-only permission token must not launder a trailing destructive one (permission_enforcer.rs:450).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Don't extend whole-file `#![allow(...)]` blocks. They exist as legacy tolerance in worker_boot.rs, mcp_tool_bridge.rs, lsp_client.rs, stale_branch.rs, stale_base.rs, recovery_recipes.rs, mcp_lifecycle_hardened.rs, session_control.rs. Adding new ones is not acceptable.
|
||||
- Don't add reqwest or async-trait as deps. Remote calls go through the manual SSE/proxy layer in remote.rs and sse.rs.
|
||||
- Don't create subdirectories under src/. The flat module layout is intentional.
|
||||
- Don't bypass `*_in_workspace` variants for file ops when running inside a workspace context. The unchecked versions exist for bootstrap and out-of-workspace scenarios only.
|
||||
- Don't add new `pub mod` exports without reason. Prefer private mod + selective `pub use` from lib.rs.
|
||||
@@ -466,31 +466,39 @@ impl ConfigLoader {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut all_warnings = Vec::new();
|
||||
let mut all_warnings: Vec<String> = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
crate::config_validate::check_unsupported_format(&entry.path)?;
|
||||
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
||||
continue;
|
||||
};
|
||||
let validation = crate::config_validate::validate_config_file(
|
||||
&parsed.object,
|
||||
&parsed.source,
|
||||
&entry.path,
|
||||
);
|
||||
let ParsedConfigFile { mut object, source } = parsed;
|
||||
if let Some(warning) =
|
||||
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||
{
|
||||
all_warnings.push(warning);
|
||||
}
|
||||
let validation =
|
||||
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||
if !validation.is_ok() {
|
||||
let first_error = &validation.errors[0];
|
||||
return Err(ConfigError::Parse(first_error.to_string()));
|
||||
}
|
||||
all_warnings.extend(validation.warnings);
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
all_warnings.extend(
|
||||
validation
|
||||
.warnings
|
||||
.into_iter()
|
||||
.map(|warning| warning.to_string()),
|
||||
);
|
||||
validate_optional_hooks_config(&object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
for warning in &all_warnings {
|
||||
emit_config_warning_once(&warning.to_string());
|
||||
emit_config_warning_once(warning);
|
||||
}
|
||||
|
||||
build_runtime_config(merged, loaded_entries, mcp)
|
||||
@@ -512,19 +520,22 @@ impl ConfigLoader {
|
||||
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
||||
continue;
|
||||
};
|
||||
let validation = crate::config_validate::validate_config_file(
|
||||
&parsed.object,
|
||||
&parsed.source,
|
||||
&entry.path,
|
||||
);
|
||||
let ParsedConfigFile { mut object, source } = parsed;
|
||||
if let Some(warning) =
|
||||
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||
{
|
||||
all_warnings.push(warning);
|
||||
}
|
||||
let validation =
|
||||
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||
if !validation.is_ok() {
|
||||
let first_error = &validation.errors[0];
|
||||
return Err(ConfigError::Parse(first_error.to_string()));
|
||||
}
|
||||
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
validate_optional_hooks_config(&object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
@@ -589,11 +600,14 @@ impl ConfigLoader {
|
||||
}
|
||||
};
|
||||
|
||||
let validation = crate::config_validate::validate_config_file(
|
||||
&parsed.object,
|
||||
&parsed.source,
|
||||
&entry.path,
|
||||
);
|
||||
let ParsedConfigFile { mut object, source } = parsed;
|
||||
if let Some(warning) =
|
||||
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||
{
|
||||
warnings.push(warning);
|
||||
}
|
||||
let validation =
|
||||
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||
if !validation.is_ok() {
|
||||
let detail = validation.errors[0].to_string();
|
||||
load_error.get_or_insert_with(|| detail.clone());
|
||||
@@ -612,7 +626,7 @@ impl ConfigLoader {
|
||||
.map(|warning| warning.to_string()),
|
||||
);
|
||||
|
||||
if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) {
|
||||
if let Err(error) = validate_optional_hooks_config(&object, &entry.path) {
|
||||
let detail = error.to_string();
|
||||
load_error.get_or_insert_with(|| detail.clone());
|
||||
files.push(ConfigFileReport::load_error(
|
||||
@@ -624,9 +638,7 @@ impl ConfigLoader {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
|
||||
{
|
||||
if let Err(error) = merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path) {
|
||||
let detail = error.to_string();
|
||||
load_error.get_or_insert_with(|| detail.clone());
|
||||
files.push(ConfigFileReport::load_error(
|
||||
@@ -638,8 +650,8 @@ impl ConfigLoader {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key_paths = collect_config_key_paths(&parsed.object);
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
let key_paths = collect_config_key_paths(&object);
|
||||
deep_merge_objects(&mut merged, &object);
|
||||
loaded_entries.push(entry.clone());
|
||||
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
|
||||
}
|
||||
@@ -663,6 +675,23 @@ impl ConfigLoader {
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_untrusted_project_hooks(
|
||||
source: ConfigSource,
|
||||
object: &mut BTreeMap<String, JsonValue>,
|
||||
path: &Path,
|
||||
) -> Option<String> {
|
||||
// Project settings come from the repository and there is no project-hook
|
||||
// trust approval flow yet. User and local settings remain eligible.
|
||||
if source == ConfigSource::Project && object.remove("hooks").is_some() {
|
||||
Some(format!(
|
||||
"Ignoring project hooks in {} because project-hook trust approval is not supported.",
|
||||
path.display()
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigFileReport {
|
||||
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
|
||||
Self {
|
||||
@@ -2590,6 +2619,7 @@ mod tests {
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::sandbox::FilesystemIsolationMode;
|
||||
use crate::HookRunner;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -2695,17 +2725,14 @@ mod tests {
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PreToolUse"));
|
||||
assert!(loaded
|
||||
assert!(!loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PostToolUse"));
|
||||
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
||||
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
|
||||
assert_eq!(
|
||||
loaded.hooks().post_tool_use_failure(),
|
||||
&["project-failure".to_string()]
|
||||
);
|
||||
assert!(loaded.hooks().post_tool_use().is_empty());
|
||||
assert!(loaded.hooks().post_tool_use_failure().is_empty());
|
||||
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
|
||||
assert_eq!(
|
||||
loaded.permission_rules().deny(),
|
||||
@@ -2718,6 +2745,73 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_config_hooks_cannot_execute_commands() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
let project_settings = cwd.join(".claw").join("settings.json");
|
||||
let project_legacy_settings = cwd.join(".claw.json");
|
||||
let marker = root.join("project-hook-executed");
|
||||
fs::create_dir_all(project_settings.parent().expect("project config dir"))
|
||||
.expect("create project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
let command = format!("echo compromised >> {}", marker.display());
|
||||
fs::write(
|
||||
&project_legacy_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
)
|
||||
.expect("write untrusted legacy project hook");
|
||||
fs::write(
|
||||
&project_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
)
|
||||
.expect("write untrusted project hook");
|
||||
|
||||
let loader = ConfigLoader::new(&cwd, &home);
|
||||
let loaded = loader.load().expect("config should load");
|
||||
let _ =
|
||||
HookRunner::from_feature_config(loaded.feature_config()).run_pre_tool_use("Read", "{}");
|
||||
|
||||
let hook_executed = marker.exists();
|
||||
if hook_executed {
|
||||
fs::remove_file(&marker).expect("remove hook marker");
|
||||
}
|
||||
assert!(
|
||||
!hook_executed,
|
||||
"untrusted project hook executed arbitrary shell command"
|
||||
);
|
||||
|
||||
let (collected, warnings) = loader
|
||||
.load_collecting_warnings()
|
||||
.expect("collecting config should load");
|
||||
assert!(collected.hooks().pre_tool_use().is_empty());
|
||||
assert_eq!(
|
||||
warnings
|
||||
.iter()
|
||||
.filter(|warning| warning.contains("Ignoring project hooks"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let inspected = loader.inspect_collecting_warnings();
|
||||
assert_eq!(
|
||||
inspected
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|warning| warning.contains("Ignoring project hooks"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(inspected
|
||||
.runtime_config
|
||||
.expect("inspection should produce runtime config")
|
||||
.hooks()
|
||||
.pre_tool_use()
|
||||
.is_empty());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_object_style_hook_entries_with_matchers() {
|
||||
let root = temp_dir();
|
||||
@@ -3523,7 +3617,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
|
||||
fn ignores_project_hook_entries_and_keeps_user_hooks_441() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
@@ -3547,22 +3641,8 @@ mod tests {
|
||||
.load()
|
||||
.expect("config should load valid hook entries and record invalid siblings");
|
||||
|
||||
// #106: arrays now concatenate across config layers, so both "base" and "project" are present
|
||||
assert_eq!(
|
||||
loaded.hooks().pre_tool_use(),
|
||||
&["base".to_string(), "project".to_string()]
|
||||
);
|
||||
assert_eq!(loaded.hooks().invalid_count(), 1);
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
|
||||
assert_eq!(
|
||||
loaded.hooks().invalid_hooks()[0].kind,
|
||||
"invalid_hooks_config"
|
||||
);
|
||||
// #106: invalid entry at index 2 after array concatenation
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(2));
|
||||
assert!(loaded.hooks().invalid_hooks()[0]
|
||||
.reason
|
||||
.contains("must be a string or hook object"));
|
||||
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
||||
assert!(loaded.hooks().invalid_hooks().is_empty());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::compact::{
|
||||
use crate::config::RuntimeFeatureConfig;
|
||||
use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
|
||||
use crate::permissions::{
|
||||
PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter,
|
||||
PermissionContext, PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPrompter,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
use crate::usage::{TokenUsage, UsageTracker};
|
||||
@@ -176,6 +176,11 @@ where
|
||||
feature_config: &RuntimeFeatureConfig,
|
||||
) -> Self {
|
||||
let usage_tracker = UsageTracker::from_session(&session);
|
||||
let hook_runner = if permission_policy.active_mode() == PermissionMode::ReadOnly {
|
||||
HookRunner::default()
|
||||
} else {
|
||||
HookRunner::from_feature_config(feature_config)
|
||||
};
|
||||
Self {
|
||||
session,
|
||||
api_client,
|
||||
@@ -184,7 +189,7 @@ where
|
||||
system_prompt,
|
||||
max_iterations: usize::MAX,
|
||||
usage_tracker,
|
||||
hook_runner: HookRunner::from_feature_config(feature_config),
|
||||
hook_runner,
|
||||
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
||||
hook_abort_signal: HookAbortSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
@@ -991,6 +996,82 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_mode_does_not_execute_hooks() {
|
||||
struct SingleCallApiClient {
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
impl ApiClient for SingleCallApiClient {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
self.calls += 1;
|
||||
if self.calls == 1 {
|
||||
Ok(vec![
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "Read".to_string(),
|
||||
input: "{}".to_string(),
|
||||
},
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
name: "ReadError".to_string(),
|
||||
input: "{}".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
} else {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_nanos();
|
||||
let marker = std::env::temp_dir().join(format!(
|
||||
"claw-read-only-hook-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
let hook_command = format!("echo compromised >> \"{}\"", marker.display());
|
||||
let features = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
));
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
Session::new(),
|
||||
SingleCallApiClient { calls: 0 },
|
||||
StaticToolExecutor::new()
|
||||
.register("Read", |_input| Ok("read result".to_string()))
|
||||
.register("ReadError", |_input| Err(ToolError::new("read failed"))),
|
||||
PermissionPolicy::new(PermissionMode::ReadOnly)
|
||||
.with_tool_requirement("Read", PermissionMode::ReadOnly)
|
||||
.with_tool_requirement("ReadError", PermissionMode::ReadOnly),
|
||||
vec!["system".to_string()],
|
||||
&features,
|
||||
);
|
||||
|
||||
runtime
|
||||
.run_turn("read a file", None)
|
||||
.expect("read-only tool call should succeed");
|
||||
|
||||
let hook_executed = marker.exists();
|
||||
if hook_executed {
|
||||
fs::remove_file(&marker).expect("remove hook marker");
|
||||
}
|
||||
assert!(
|
||||
!hook_executed,
|
||||
"read-only mode must not execute pre-, post-, or failure hooks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_runtime_session_trace_events() {
|
||||
let sink = Arc::new(MemoryTelemetrySink::default());
|
||||
|
||||
@@ -40,17 +40,32 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||
/// the workspace boundary (e.g. via `../` traversal or symlink).
|
||||
#[allow(dead_code)]
|
||||
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
||||
if !resolved.starts_with(workspace_root) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"path {} escapes workspace boundary {}",
|
||||
resolved.display(),
|
||||
workspace_root.display()
|
||||
),
|
||||
));
|
||||
let workspace_root = normalize_for_comparison(workspace_root);
|
||||
let comparable = normalize_for_comparison(resolved);
|
||||
if comparable.starts_with(&workspace_root) || resolves_within(resolved, &workspace_root) {
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"path {} escapes workspace boundary {}",
|
||||
comparable.display(),
|
||||
workspace_root.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `resolved` names a location inside `workspace_root` once the
|
||||
/// filesystem resolves it. Windows accepts several spellings of one location
|
||||
/// that [`Path::starts_with`] treats as different: letter case (a working
|
||||
/// directory typed as `c:\users\...` for `C:\Users\...`), the `\\.\` device
|
||||
/// namespace, and 8.3 short names. Only consulted after the lexical comparison
|
||||
/// fails, so it can accept such a spelling but never widens what already
|
||||
/// compared inside, and a path whose canonical form is outside stays rejected.
|
||||
fn resolves_within(resolved: &Path, workspace_root: &Path) -> bool {
|
||||
resolved
|
||||
.canonicalize()
|
||||
.is_ok_and(|canonical| normalize_for_comparison(&canonical).starts_with(workspace_root))
|
||||
}
|
||||
|
||||
/// Text payload returned by file-reading operations.
|
||||
@@ -527,9 +542,29 @@ fn build_grep_content_output(
|
||||
}
|
||||
|
||||
fn canonicalize_workspace_root(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root
|
||||
let canonical = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf())
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
normalize_for_comparison(&canonical)
|
||||
}
|
||||
|
||||
/// Normalize Windows' extended-length path prefix before comparing paths.
|
||||
///
|
||||
/// `std::fs::canonicalize` returns paths with a `\\?\` prefix on Windows,
|
||||
/// while a workspace root may still be represented as a regular path when it
|
||||
/// cannot be canonicalized (for example, while a workspace is bootstrapping).
|
||||
/// These paths refer to the same location but do not compare equal with
|
||||
/// [`Path::starts_with`]. Keep the comparison representation stable without
|
||||
/// changing the paths returned to callers.
|
||||
fn normalize_for_comparison(path: &Path) -> PathBuf {
|
||||
let raw = path.to_string_lossy();
|
||||
if let Some(unc_path) = raw.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{unc_path}"));
|
||||
}
|
||||
if let Some(dos_path) = raw.strip_prefix(r"\\?\") {
|
||||
return PathBuf::from(dos_path);
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
|
||||
@@ -777,8 +812,9 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
grep_search, is_symlink_escape, normalize_for_comparison, read_file,
|
||||
read_file_in_workspace, write_file, write_file_in_workspace, GrepSearchInput,
|
||||
MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -906,6 +942,86 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_windows_extended_length_prefixes_for_boundary_checks() {
|
||||
let dos_path = PathBuf::from(r"C:\workspace\file.txt");
|
||||
let extended_dos_path = PathBuf::from(r"\\?\C:\workspace\file.txt");
|
||||
assert_eq!(
|
||||
normalize_for_comparison(&extended_dos_path),
|
||||
dos_path,
|
||||
"extended DOS paths should compare like their regular form"
|
||||
);
|
||||
|
||||
let unc_path = PathBuf::from(r"\\server\share\workspace\file.txt");
|
||||
let extended_unc_path = PathBuf::from(r"\\?\UNC\server\share\workspace\file.txt");
|
||||
assert_eq!(
|
||||
normalize_for_comparison(&extended_unc_path),
|
||||
unc_path,
|
||||
"extended UNC paths should compare like their regular form"
|
||||
);
|
||||
}
|
||||
|
||||
// Backslash-separated paths only parse into path components on Windows.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn accepts_equivalent_extended_path_at_workspace_boundary() {
|
||||
let root = PathBuf::from(r"C:\workspace");
|
||||
let resolved = PathBuf::from(r"\\?\C:\workspace\src\main.rs");
|
||||
super::validate_workspace_boundary(&resolved, &root)
|
||||
.expect("equivalent Windows path representations should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn accepts_a_path_that_resolves_inside_the_workspace() {
|
||||
let workspace = temp_path("boundary-alias-workspace");
|
||||
let aliases = temp_path("boundary-alias-links");
|
||||
std::fs::create_dir_all(workspace.join("src")).expect("workspace dir should be created");
|
||||
std::fs::create_dir_all(&aliases).expect("alias dir should be created");
|
||||
let alias = aliases.join("src");
|
||||
std::os::unix::fs::symlink(workspace.join("src"), &alias).expect("symlink should create");
|
||||
let root = workspace
|
||||
.canonicalize()
|
||||
.expect("workspace should canonicalize");
|
||||
|
||||
super::validate_workspace_boundary(&alias, &root)
|
||||
.expect("a spelling that resolves inside the workspace should be accepted");
|
||||
super::validate_workspace_boundary(&aliases, &root)
|
||||
.expect_err("a path that resolves outside the workspace must stay rejected");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&aliases);
|
||||
}
|
||||
|
||||
// Windows resolves one location from spellings that compare unequal as
|
||||
// paths, letter case and the `\\.\` device namespace among them.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn accepts_other_windows_spellings_of_a_workspace_path() {
|
||||
let workspace = temp_path("Boundary-Case-Workspace");
|
||||
std::fs::create_dir_all(workspace.join("src")).expect("workspace dir should be created");
|
||||
let root = workspace
|
||||
.canonicalize()
|
||||
.expect("workspace should canonicalize");
|
||||
|
||||
let lower = PathBuf::from(workspace.join("src").to_string_lossy().to_lowercase());
|
||||
super::validate_workspace_boundary(&lower, &root)
|
||||
.expect("a lower-cased spelling of a workspace path should be accepted");
|
||||
|
||||
let device = PathBuf::from(format!(r"\\.\{}", workspace.join("src").display()));
|
||||
super::validate_workspace_boundary(&device, &root)
|
||||
.expect("a device-namespace spelling of a workspace path should be accepted");
|
||||
|
||||
let parent = workspace
|
||||
.parent()
|
||||
.expect("workspace has a parent")
|
||||
.to_path_buf();
|
||||
super::validate_workspace_boundary(&parent, &root)
|
||||
.expect_err("the workspace's parent must stay rejected");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() {
|
||||
|
||||
@@ -81,8 +81,10 @@ impl PermissionEnforcer {
|
||||
|
||||
let active_mode = self.policy.active_mode();
|
||||
|
||||
// Check if active mode meets the dynamically determined required mode
|
||||
if active_mode >= required_mode {
|
||||
// Check if active mode meets the dynamically determined required mode.
|
||||
// Ranked explicitly: `PermissionMode` no longer derives `Ord`, so the
|
||||
// ladder is a named function rather than declaration order.
|
||||
if active_mode.privilege_rank() >= required_mode.privilege_rank() {
|
||||
return EnforcementResult::Allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ use serde_json::Value;
|
||||
use crate::config::RuntimePermissionRuleConfig;
|
||||
|
||||
/// Permission level assigned to a tool invocation or runtime session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
///
|
||||
/// Deliberately **not** `PartialOrd`/`Ord`: see [`PermissionMode::privilege_rank`]
|
||||
/// for why ordering is a named function here rather than declaration order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionMode {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
@@ -25,6 +28,53 @@ impl PermissionMode {
|
||||
Self::Allow => "allow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Position on the privilege ladder used by [`PermissionPolicy`].
|
||||
///
|
||||
/// This is the only ranking, and `Ord` is intentionally not derived, so a
|
||||
/// stray `active_mode >= required_mode` no longer compiles. Ranking through
|
||||
/// declaration order meant a variant inserted in the middle of the enum
|
||||
/// silently re-ranked the ladder, and the difference between "this tool may
|
||||
/// run" and "this tool may run after a prompt" was therefore a property of
|
||||
/// where an `enum` line happened to sit. Every variant is named here, so an
|
||||
/// insertion is a build failure instead of a silent re-ranking; the two
|
||||
/// comparisons outside this module that do read a rank — the
|
||||
/// `PermissionEnforcer`'s `check_with_required_mode` and the `claw doctor`
|
||||
/// permission check — now say so at the call site.
|
||||
///
|
||||
/// `Prompt` and `Allow` are *modes of operation*, not privilege tiers. They
|
||||
/// rank above every tier so those two callers keep the answers they had, but
|
||||
/// they are never ranked *against* a tier: see [`Self::covers`].
|
||||
#[must_use]
|
||||
pub const fn privilege_rank(self) -> u8 {
|
||||
match self {
|
||||
Self::ReadOnly => 0,
|
||||
Self::WorkspaceWrite => 1,
|
||||
Self::DangerFullAccess => 2,
|
||||
Self::Prompt => 3,
|
||||
Self::Allow => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session running in `self` may use a tool that requires
|
||||
/// `required` without asking.
|
||||
///
|
||||
/// Only tiers answer this by rank. `Allow` never asks and `Prompt` always
|
||||
/// asks — it is the exact opposite of a silent allow — so neither is a
|
||||
/// statement about how much of the workspace the session may touch, and
|
||||
/// comparing either to `required` is comparing a mode to a tier. That
|
||||
/// comparison is what let `Prompt`, which outranks `DangerFullAccess`, make
|
||||
/// the ladder return `Allow` for every tool and skip the prompter entirely.
|
||||
#[must_use]
|
||||
pub const fn covers(self, required: Self) -> bool {
|
||||
match self {
|
||||
Self::Allow => true,
|
||||
Self::Prompt => false,
|
||||
Self::ReadOnly | Self::WorkspaceWrite | Self::DangerFullAccess => {
|
||||
self.privilege_rank() >= required.privilege_rank()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook-provided override applied before standard permission evaluation.
|
||||
@@ -236,29 +286,12 @@ impl PermissionPolicy {
|
||||
prompter,
|
||||
);
|
||||
}
|
||||
Some(PermissionOverride::Allow) => {
|
||||
if let Some(rule) = ask_rule {
|
||||
let reason = format!(
|
||||
"tool '{tool_name}' requires approval due to ask rule '{}'",
|
||||
rule.raw
|
||||
);
|
||||
return Self::prompt_or_deny(
|
||||
tool_name,
|
||||
input,
|
||||
current_mode,
|
||||
required_mode,
|
||||
Some(reason),
|
||||
prompter,
|
||||
);
|
||||
}
|
||||
if allow_rule.is_some()
|
||||
|| current_mode == PermissionMode::Allow
|
||||
|| current_mode >= required_mode
|
||||
{
|
||||
return PermissionOutcome::Allow;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
// A hook `allow` is an override, not a bypass: the ask-rule check
|
||||
// and the privilege ladder below still run for it. Both used to be
|
||||
// spelled out a second time inside this arm, which is why the ladder
|
||||
// existed in two copies that had to be corrected in step — and the
|
||||
// second copy is exactly what a fix for one of them is easy to miss.
|
||||
Some(PermissionOverride::Allow) | None => {}
|
||||
}
|
||||
|
||||
if let Some(rule) = ask_rule {
|
||||
@@ -276,10 +309,10 @@ impl PermissionPolicy {
|
||||
);
|
||||
}
|
||||
|
||||
if allow_rule.is_some()
|
||||
|| current_mode == PermissionMode::Allow
|
||||
|| current_mode >= required_mode
|
||||
{
|
||||
// The ladder, in one place. An `allow` rule is an explicit grant for
|
||||
// this tool and outranks the mode; otherwise the mode decides through
|
||||
// [`PermissionMode::covers`].
|
||||
if allow_rule.is_some() || current_mode.covers(required_mode) {
|
||||
return PermissionOutcome::Allow;
|
||||
}
|
||||
|
||||
@@ -587,6 +620,132 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_mode_routes_to_prompter_instead_of_auto_allowing() {
|
||||
// Regression: `Prompt` sorts above `DangerFullAccess` in the
|
||||
// `PermissionMode` `Ord` derivation, so the `current_mode >= required_mode`
|
||||
// ladder check used to auto-allow every tool in `Prompt` mode and never
|
||||
// invoke the prompter — defeating the entire purpose of the mode.
|
||||
let policy = PermissionPolicy::new(PermissionMode::Prompt)
|
||||
.with_tool_requirement("read_file", PermissionMode::ReadOnly);
|
||||
let mut prompter = RecordingPrompter {
|
||||
seen: Vec::new(),
|
||||
allow: true,
|
||||
};
|
||||
|
||||
let outcome = policy.authorize("read_file", "{}", Some(&mut prompter));
|
||||
|
||||
assert_eq!(outcome, PermissionOutcome::Allow);
|
||||
assert_eq!(prompter.seen.len(), 1);
|
||||
assert_eq!(prompter.seen[0].tool_name, "read_file");
|
||||
assert_eq!(prompter.seen[0].current_mode, PermissionMode::Prompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_mode_denies_when_no_prompter_is_available() {
|
||||
let policy = PermissionPolicy::new(PermissionMode::Prompt)
|
||||
.with_tool_requirement("read_file", PermissionMode::ReadOnly);
|
||||
|
||||
assert!(matches!(
|
||||
policy.authorize("read_file", "{}", None),
|
||||
PermissionOutcome::Deny { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_mode_prompts_for_every_privilege_tier() {
|
||||
// The ladder used to exist twice — once on the plain path and once inside
|
||||
// the `Some(PermissionOverride::Allow)` arm — so a hook emitting
|
||||
// `permissionDecision: "allow"` (see `hooks.rs`) had its own route to the
|
||||
// auto-allow. Sweeping the tiers against both routes keeps them honest now
|
||||
// that they are one predicate.
|
||||
for required in [
|
||||
PermissionMode::ReadOnly,
|
||||
PermissionMode::WorkspaceWrite,
|
||||
PermissionMode::DangerFullAccess,
|
||||
] {
|
||||
for override_decision in [None, Some(PermissionOverride::Allow)] {
|
||||
let policy = PermissionPolicy::new(PermissionMode::Prompt)
|
||||
.with_tool_requirement("read_file", required);
|
||||
let context = PermissionContext::new(
|
||||
override_decision,
|
||||
override_decision.map(|_| "hook approved".to_string()),
|
||||
);
|
||||
let mut prompter = RecordingPrompter {
|
||||
seen: Vec::new(),
|
||||
allow: true,
|
||||
};
|
||||
|
||||
let outcome =
|
||||
policy.authorize_with_context("read_file", "{}", &context, Some(&mut prompter));
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PermissionOutcome::Allow,
|
||||
"an approving prompter must still pass a {required:?} tool"
|
||||
);
|
||||
assert_eq!(
|
||||
prompter.seen.len(),
|
||||
1,
|
||||
"prompt mode skipped the prompter for a {required:?} tool \
|
||||
(override = {override_decision:?})"
|
||||
);
|
||||
assert_eq!(prompter.seen[0].tool_name, "read_file");
|
||||
assert_eq!(prompter.seen[0].current_mode, PermissionMode::Prompt);
|
||||
assert_eq!(prompter.seen[0].required_mode, required);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_rank_orders_the_privilege_tiers() {
|
||||
assert!(
|
||||
PermissionMode::ReadOnly.privilege_rank()
|
||||
< PermissionMode::WorkspaceWrite.privilege_rank()
|
||||
);
|
||||
assert!(
|
||||
PermissionMode::WorkspaceWrite.privilege_rank()
|
||||
< PermissionMode::DangerFullAccess.privilege_rank()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_rank_places_the_operating_modes_above_every_tier() {
|
||||
// `Prompt` and `Allow` are modes of operation rather than tiers, so
|
||||
// `covers` answers for them by name and never consults this number. They
|
||||
// still have to rank above every tier, because two callers outside this
|
||||
// module — the `PermissionEnforcer`'s `check_with_required_mode` and the
|
||||
// `claw doctor` permission check — compare ranks, and a `Prompt`/`Allow`
|
||||
// session must keep the result it had under the old derived `Ord`.
|
||||
for mode in [PermissionMode::Prompt, PermissionMode::Allow] {
|
||||
assert!(
|
||||
mode.privilege_rank() > PermissionMode::DangerFullAccess.privilege_rank(),
|
||||
"{mode:?} must outrank danger-full-access"
|
||||
);
|
||||
assert!(
|
||||
mode.privilege_rank() > PermissionMode::WorkspaceWrite.privilege_rank(),
|
||||
"{mode:?} must outrank workspace-write"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_never_compares_an_operating_mode_against_a_tier() {
|
||||
for required in [
|
||||
PermissionMode::ReadOnly,
|
||||
PermissionMode::WorkspaceWrite,
|
||||
PermissionMode::DangerFullAccess,
|
||||
] {
|
||||
assert!(
|
||||
!PermissionMode::Prompt.covers(required),
|
||||
"prompt mode must ask before running a {required:?} tool"
|
||||
);
|
||||
}
|
||||
assert!(PermissionMode::Allow.covers(PermissionMode::DangerFullAccess));
|
||||
assert!(PermissionMode::DangerFullAccess.covers(PermissionMode::WorkspaceWrite));
|
||||
assert!(!PermissionMode::WorkspaceWrite.covers(PermissionMode::DangerFullAccess));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_rule_based_denials_and_allows() {
|
||||
let rules = RuntimePermissionRuleConfig::new(
|
||||
|
||||
@@ -220,15 +220,14 @@ pub fn build_linux_sandbox_command(
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"--user".to_string(),
|
||||
"--map-root-user".to_string(),
|
||||
"--mount".to_string(),
|
||||
"--ipc".to_string(),
|
||||
"--pid".to_string(),
|
||||
"--uts".to_string(),
|
||||
"--fork".to_string(),
|
||||
];
|
||||
let mut args: Vec<String> = working_unshare_mapping()
|
||||
.unwrap_or(UNSHARE_MAPPING_CANDIDATES[0])
|
||||
.iter()
|
||||
.map(|arg| arg.to_string())
|
||||
.collect();
|
||||
// The candidates already carry the namespace flags, so the probe and the
|
||||
// launcher share a single argument shape; only the opt-in `--net` is
|
||||
// added here.
|
||||
if status.network_active {
|
||||
args.push("--net".to_string());
|
||||
}
|
||||
@@ -282,6 +281,82 @@ fn command_exists(command: &str) -> bool {
|
||||
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
|
||||
}
|
||||
|
||||
/// Candidate `unshare` user-namespace mapping options, in preference order.
|
||||
///
|
||||
/// Most systems accept `--map-root-user` alone. Some hardened containers and
|
||||
/// seccomp profiles block unprivileged writes to `/proc/self/uid_map`; there,
|
||||
/// util-linux delegates to the setuid `newuidmap`/`newgidmap` helpers when
|
||||
/// `--map-auto` is also present.
|
||||
///
|
||||
/// That fallback therefore depends on the setuid helpers (the `uidmap`
|
||||
/// package on Debian/Ubuntu) and on the current user having a range in
|
||||
/// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto`
|
||||
/// fails and the startup probe rejects the candidate, keeping the plain form.
|
||||
///
|
||||
/// Each candidate is the **complete** static argument shape the launcher
|
||||
/// uses (see `build_linux_sandbox_command`): mapping flags followed by the
|
||||
/// namespace flags `--mount --ipc --pid --uts --fork`. The startup probe
|
||||
/// runs each candidate verbatim (plus a trivial program), so probe success
|
||||
/// implies launch success: on systems where the mapping works but the
|
||||
/// namespace flags are denied (e.g. AppArmor-restricted CI runners that
|
||||
/// block mount propagation in user namespaces), the probe fails and the
|
||||
/// sandbox stays disabled instead of activating a launcher that always
|
||||
/// errors.
|
||||
///
|
||||
/// `--net` is intentionally absent: it is appended only when network
|
||||
/// isolation is active (the non-default path), and probing with it would
|
||||
/// disable the sandbox on hosts that block network-namespace creation (e.g.
|
||||
/// Docker's default seccomp profile) even when network isolation is never
|
||||
/// requested.
|
||||
const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[
|
||||
&[
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--mount",
|
||||
"--ipc",
|
||||
"--pid",
|
||||
"--uts",
|
||||
"--fork",
|
||||
],
|
||||
&[
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--map-auto",
|
||||
"--mount",
|
||||
"--ipc",
|
||||
"--pid",
|
||||
"--uts",
|
||||
"--fork",
|
||||
],
|
||||
];
|
||||
|
||||
/// Probe a candidate `unshare` mapping invocation with a trivial program.
|
||||
fn unshare_probe(args: &[&str]) -> bool {
|
||||
std::process::Command::new("unshare")
|
||||
.args(args)
|
||||
.arg("true")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// The first mapping option set that works on this machine, if any.
|
||||
///
|
||||
/// Probes are cached for the process lifetime; a missing `unshare` binary or a
|
||||
/// kernel that refuses every mapping yields `None`.
|
||||
fn working_unshare_mapping() -> Option<&'static [&'static str]> {
|
||||
use std::sync::OnceLock;
|
||||
static MAPPING: OnceLock<Option<&'static [&'static str]>> = OnceLock::new();
|
||||
*MAPPING.get_or_init(|| {
|
||||
UNSHARE_MAPPING_CANDIDATES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|args| unshare_probe(args))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether `unshare --user` actually works on this system.
|
||||
/// On some CI environments (e.g. GitHub Actions), the binary exists but
|
||||
/// user namespaces are restricted, causing silent failures.
|
||||
@@ -292,13 +367,7 @@ fn unshare_user_namespace_works() -> bool {
|
||||
if !command_exists("unshare") {
|
||||
return false;
|
||||
}
|
||||
std::process::Command::new("unshare")
|
||||
.args(["--user", "--map-root-user", "true"])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
working_unshare_mapping().is_some()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,6 +428,52 @@ mod tests {
|
||||
assert_eq!(request.allowed_mounts, vec!["tmp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_candidates_prefer_plain_root_mapping() {
|
||||
assert!(!super::UNSHARE_MAPPING_CANDIDATES.is_empty());
|
||||
for candidate in super::UNSHARE_MAPPING_CANDIDATES {
|
||||
// Mapping flags.
|
||||
assert!(candidate.contains(&"--user"));
|
||||
assert!(candidate.contains(&"--map-root-user"));
|
||||
// Namespace flags the real launcher appends — the probe must
|
||||
// exercise the full invocation shape, not just mapping flags.
|
||||
assert!(candidate.contains(&"--mount"));
|
||||
assert!(candidate.contains(&"--ipc"));
|
||||
assert!(candidate.contains(&"--pid"));
|
||||
assert!(candidate.contains(&"--uts"));
|
||||
assert!(candidate.contains(&"--fork"));
|
||||
}
|
||||
// The plain form must be tried first; `--map-auto` is only a fallback
|
||||
// for kernels/containers that block unprivileged uid_map writes.
|
||||
assert_eq!(
|
||||
super::UNSHARE_MAPPING_CANDIDATES[0],
|
||||
&[
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--mount",
|
||||
"--ipc",
|
||||
"--pid",
|
||||
"--uts",
|
||||
"--fork",
|
||||
]
|
||||
);
|
||||
// The second candidate inserts `--map-auto` in the position util-linux
|
||||
// expects (after `--map-root-user`, before the namespace flags).
|
||||
assert_eq!(
|
||||
super::UNSHARE_MAPPING_CANDIDATES[1],
|
||||
&[
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--map-auto",
|
||||
"--mount",
|
||||
"--ipc",
|
||||
"--pid",
|
||||
"--uts",
|
||||
"--fork",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_linux_launcher_with_network_flag_when_requested() {
|
||||
let config = SandboxConfig::default();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md — rusty-claude-cli (the `claw` binary)
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Crate `rusty-claude-cli` produces the `claw` binary (~25 subcommands). Wires together api, runtime, tools, commands, plugins; terminal via crossterm/rustyline/syntect. Hand-rolled arg parser (no clap).
|
||||
|
||||
## main.rs MAP
|
||||
|
||||
src/main.rs is ~19,800 lines. Landmark table:
|
||||
|
||||
| Lines | Section |
|
||||
|---|---|
|
||||
| 73–330 | Provenance/model types (`ModelSource`, `ModelProvenance`, `PermissionModeSource`), build constants |
|
||||
| 330–682 | Error taxonomy (`classify_error_kind`, JSON/text error output) |
|
||||
| 774–994 | Global flags, `--cwd`/`-C` stripping, stdin plumbing |
|
||||
| 995–1158 | `run()`: single flat match dispatching `CliAction` variants |
|
||||
| 1162–1280 | `CliAction` enum, 25 struct variants (each carries `output_format`) |
|
||||
| 1312–1477 | Output-format machinery: `OnceLock` statics, duplicate-flag tracking |
|
||||
| 1478–2272 | `parse_args`: ~800-line manual token loop |
|
||||
| 2542–3389 | Per-subcommand sub-parsers inside `parse_args` |
|
||||
| 2902–3389 | Model/permission/allowed-tools resolution |
|
||||
| 3390–4696 | Doctor subsystem (~13 `check_*_health` fns) |
|
||||
| 4697–6062 | Manifests, bootstrap-plan, system-prompt, version, `resume_session` |
|
||||
| 5459–7047 | `StatusContext`, `BinaryProvenance`, broad-cwd policy, stale-base preflight |
|
||||
| 7048–9423 | `run_repl` interactive loop (`LiveCli` struct, streaming, heartbeat, `HookAbortMonitor`) |
|
||||
| 9424–14264 | Snapshot printers: session-list, status, sandbox, models, help topics, acp, `run_init` (L11027), `run_export` (L11715), `print_help` (L14055) |
|
||||
| 14266–19831 | In-file `mod tests` + 3 smaller test modules (one embeds a Python MCP fixture) |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- Every subcommand supports `--output-format text|json`, env-overridable.
|
||||
- `raw_args_request_json_output` pre-scans argv before parsing to suppress stderr config warnings in JSON mode.
|
||||
- Dual renderers for each output path: human text + structured `*_json`.
|
||||
- Unknown flags/typos get levenshtein-distance suggestions.
|
||||
- Comments carry issue numbers (#824, #146, etc.) when tracking known problems.
|
||||
- `main()` (L330) is only the error envelope. All real work happens in `run()`.
|
||||
- Error output always includes `status`, `error_kind`, `action`, `hint`, `exit_code` fields.
|
||||
- `classify_error_kind` maps message prefixes to snake_case kind tokens.
|
||||
- JSON errors go to stdout, text errors to stderr.
|
||||
- Sibling modules: `init.rs`, `input.rs` (rustyline), `render.rs` (MarkdownStreamState, Spinner, syntect), `setup_wizard.rs`.
|
||||
- `build.rs` injects `GIT_SHA`, `RUSTC_VERSION`, `GIT_DIRTY` via `cargo:rustc-env`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- File opens with crate-wide `#![allow(...)]` suppressing 13 lints including `dead_code` and `unused_imports`. Legacy. Do NOT extend this list.
|
||||
- 9+ functions carry `#[allow(clippy::too_many_lines)]`. Tolerated, not license for more.
|
||||
- Package/bin name mismatch: crate is `rusty-claude-cli`, binary is `claw`. Watch for this in paths and test macros.
|
||||
|
||||
## TESTS
|
||||
|
||||
All tests live in `tests/` (6 files), black-box style. They spawn `env!("CARGO_BIN_EXE_claw")` in unique temp dirs (AtomicU64 counter).
|
||||
|
||||
| File | What it covers |
|
||||
|---|---|
|
||||
| `output_format_contract.rs` | ~105 tests pinning `kind`/`status`/`action` JSON contract for EVERY subcommand. Must update when adding/changing any command output. 5,986 lines. |
|
||||
| `resume_slash_commands.rs` | Resume and slash-command behavior |
|
||||
| `cli_flags_and_config_defaults.rs` | Flag parsing, config file defaults |
|
||||
| `compact_output.rs` | Compact output mode |
|
||||
| `compact_repl_panic.rs` | Nested-runtime panic regression |
|
||||
| `mock_parity_harness.rs` | Scenario-driven tests against mock-anthropic-service, driven by `rust/mock_parity_scenarios.json` |
|
||||
@@ -4222,15 +4222,18 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
|
||||
|| source.to_string(),
|
||||
|env_var| format!("{source}:{env_var}"),
|
||||
);
|
||||
// Ranks, not declaration order: `PermissionMode` deliberately does not
|
||||
// derive `Ord`, so the ladder is named at every comparison site.
|
||||
let specs = mvp_tool_specs();
|
||||
let active_rank = permission_mode.mode.privilege_rank();
|
||||
let tools_satisfied = specs
|
||||
.iter()
|
||||
.filter(|spec| permission_mode.mode >= spec.required_permission)
|
||||
.filter(|spec| active_rank >= spec.required_permission.privilege_rank())
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
let tools_gated = specs
|
||||
.iter()
|
||||
.filter(|spec| permission_mode.mode < spec.required_permission)
|
||||
.filter(|spec| active_rank < spec.required_permission.privilege_rank())
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -7763,15 +7766,20 @@ impl LiveCli {
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
// The assistant response was already streamed to the terminal live
|
||||
// during the turn (emit_output = true), so reprinting the full text
|
||||
// here would duplicate the entire message. When text was streamed,
|
||||
// emit a newline first so `spinner.finish`'s line-clear lands on a
|
||||
// fresh line instead of erasing the last streamed line.
|
||||
let final_text = final_assistant_text(&summary);
|
||||
if !final_text.is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
"✨ Done",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
let final_text = final_assistant_text(&summary);
|
||||
if !final_text.is_empty() {
|
||||
println!("{final_text}");
|
||||
}
|
||||
println!();
|
||||
if let Some(event) = summary.auto_compaction {
|
||||
println!(
|
||||
@@ -7903,6 +7911,9 @@ impl LiveCli {
|
||||
match new_runtime.run_turn(input, Some(&mut rp)) {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(new_runtime)?;
|
||||
if !final_assistant_text(&summary).is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
if round == 0 {
|
||||
"✨ Done (after auto-compact)"
|
||||
|
||||
@@ -249,6 +249,16 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render markdown to ANSI without trimming trailing whitespace.
|
||||
///
|
||||
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
|
||||
/// trailing newline separators here. `render_markdown` trims them for
|
||||
/// one-shot output, while streaming keeps them so that successive chunks
|
||||
/// stay on their own lines instead of running together.
|
||||
fn render_markdown_raw(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
@@ -267,7 +277,7 @@ impl TerminalRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
output.trim_end().to_string()
|
||||
output
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -275,6 +285,18 @@ impl TerminalRenderer {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
|
||||
/// block separators (such as a paragraph's closing newlines).
|
||||
///
|
||||
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
|
||||
/// terminal with no separator of its own, so the trailing newlines must
|
||||
/// survive here to keep a paragraph from sticking to the block that follows
|
||||
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -609,7 +631,7 @@ impl MarkdownStreamState {
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
Some(renderer.markdown_to_ansi(&ready))
|
||||
Some(renderer.markdown_to_ansi_stream(&ready))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1052,6 +1074,34 @@ mod tests {
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_rendering_preserves_block_separators() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
|
||||
// The stream variant keeps the paragraph-ending newlines that the
|
||||
// one-shot variant trims, so consecutive streamed chunks do not run
|
||||
// into each other when written back-to-back to the terminal.
|
||||
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
|
||||
assert!(streamed.ends_with('\n'));
|
||||
assert!(!renderer
|
||||
.markdown_to_ansi("Intro paragraph.\n\n")
|
||||
.ends_with('\n'));
|
||||
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let first = state
|
||||
.push(&renderer, "Intro paragraph.\n\n")
|
||||
.expect("blank line closes the paragraph block");
|
||||
let second = state
|
||||
.push(&renderer, "1. first item\n\n")
|
||||
.expect("blank line closes the list block");
|
||||
|
||||
let combined = strip_ansi(&format!("{first}{second}"));
|
||||
assert!(combined.contains("Intro paragraph."));
|
||||
assert!(combined.contains("1. first item"));
|
||||
// The two blocks stay on separate lines instead of sticking together.
|
||||
assert!(!combined.contains("Intro paragraph.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
|
||||
@@ -130,7 +130,7 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||
fn text_prompt_mode_streams_assistant_text_once_before_spinner_completion() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario which only emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
@@ -162,7 +162,7 @@ fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||
],
|
||||
);
|
||||
|
||||
// then stdout should contain the final assistant text, not just spinner output
|
||||
// then the streamed answer should appear once before the spinner completion
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
@@ -171,19 +171,19 @@ fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert!(
|
||||
plain_stdout.contains("Mock streaming says hello from the parity harness."),
|
||||
"text prompt stdout should include the assistant text ({stdout:?})"
|
||||
let assistant_text = "Mock streaming says hello from the parity harness.";
|
||||
assert_eq!(
|
||||
plain_stdout.matches(assistant_text).count(),
|
||||
1,
|
||||
"text prompt stdout should contain the streamed assistant text exactly once ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("✔ ✨ Done"),
|
||||
"text prompt stdout should still include spinner completion ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == "Mock streaming says hello from the parity harness."),
|
||||
"text prompt stdout should print the assistant text as its own line ({stdout:?})"
|
||||
plain_stdout.find(assistant_text) < plain_stdout.find("✔ ✨ Done"),
|
||||
"text prompt stdout should stream the assistant text before spinner completion ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# AGENTS.md — rust/crates/tools
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Single-crate tool surface: registry, 55 tool specs, permission-gated dispatch, all implementations. One flat `src/lib.rs` (10,892 lines, ~37% tests).
|
||||
|
||||
## lib.rs MAP
|
||||
|
||||
| Lines | Landmark |
|
||||
|-------------|----------|
|
||||
| 1–74 | Imports + six `global_*_registry()` OnceLock singletons (Lsp, McpTool, Team, Cron, Task, Worker) |
|
||||
| 75–483 | Registry API: `ToolManifestEntry`, `ToolSource`, `ToolRegistry`, `ToolSpec`, `GlobalToolRegistry`, `RuntimeToolDefinition`, `canonical_allowed_tool_name` |
|
||||
| 484–1348 | `mvp_tool_specs()` static table of 55 tools with inline JSON schemas |
|
||||
| 1349–1524 | `enforce_permission_check` + `execute_tool()` string-match dispatch, permission classification helpers |
|
||||
| 1525–2735 | `run_*` wrappers: deserialize input, call into `execute_*` or runtime fns |
|
||||
| 2736–2763 | `workspace_traversal_guard_tests` mod |
|
||||
| 2764–3354 | ~45 private serde IO structs |
|
||||
| 3355–6824 | Real implementations: web fetch/search, todo store, skill resolution, agent/subagent spawning (`ProviderRuntimeClient` L5182, `SubagentToolExecutor` L5361), notebook edit, sleep, config, plan-mode, structured output, REPL, PowerShell |
|
||||
| 6825–6826 | `pub mod lane_completion; pub mod pdf_extract;` |
|
||||
| 6829–10892 | `mod tests` (~4000 lines) |
|
||||
|
||||
## ADDING A TOOL
|
||||
|
||||
1. Add a `ToolSpec` entry in `mvp_tool_specs()`. Include `name`, `description`, `input_schema` (inline JSON), and `required_permission: PermissionMode`.
|
||||
2. Add a dispatch arm in `execute_tool()` matching the tool name string.
|
||||
3. Write a `run_<tool>()` wrapper. Deserialize input from a dedicated serde struct.
|
||||
4. Implement the actual logic below L3355 (or call into another crate).
|
||||
5. Add inline tests in `mod tests`. Follow BDD naming: `given_x_when_y_then_z`.
|
||||
6. Permission gating is automatic: `GlobalToolRegistry` / `SubagentToolExecutor` hold an optional `PermissionEnforcer` checked pre-dispatch.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Tool boundary signature**: `Result<String, String>`. Always.
|
||||
- **Tool naming**: snake_case for file/shell tools, PascalCase otherwise. `canonical_allowed_tool_name` normalizes aliases.
|
||||
- **State**: OnceLock registries for global singletons. JSON state files under config dirs for persistence.
|
||||
- **Input validation**: reject empty strings for todos, descriptions, prompts, messages, code. ~12 validation sites between L3806–6167. Keep that contract.
|
||||
- **Test naming**: BDD style (`given_x_when_y_then_z`).
|
||||
- **Env-mutating tests**: acquire `env_lock()` mutex first.
|
||||
- **Dependencies**: runtime, api, plugins, commands, reqwest(blocking), aspect-*, tokio.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Don't add more `#[allow(clippy::...)]` suppressions.** ~50 `needless_pass_by_value` and several `too_many_lines` allows exist as legacy debt. Don't extend.
|
||||
- **Don't skip input validation.** Empty-string rejection is a contract across all user-facing text fields.
|
||||
- **Don't scatter implementation across new submodules.** The crate is intentionally flat (one lib.rs + two leaf mods). Only `lane_completion` and `pdf_extract` break out.
|
||||
- **Don't duplicate tool names.** The canonical name mapping already handles aliases.
|
||||
- **Don't bypass `enforce_permission_check`.** Every tool dispatch goes through permission gating. No exceptions.
|
||||
@@ -0,0 +1,69 @@
|
||||
# src/ AGENTS.md
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
This is a **porting workspace**, not production code. Nothing here is imported by,
|
||||
built with, or shipped in the Rust product (`rust/crates/`). The Python tree exists
|
||||
solely to mirror and track parity against the Claude Code TypeScript source.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.py # argparse CLI entry point (summary, parity-audit, manifest, etc.)
|
||||
├── ~38 top-level .py # mirror TS root files one-to-one
|
||||
├── models.py # frozen dataclasses: Subsystem, PortingModule, PermissionDenial, ...
|
||||
├── parity_audit.py # hard-coded TS→Python filename mapping (main.tsx→main.py, etc.)
|
||||
├── port_manifest.py # builds a manifest of the src/ tree itself
|
||||
├── reference_data/ # tracked JSON snapshots extracted from the TS archive
|
||||
│ ├── archive_surface_snapshot.json
|
||||
│ ├── tools_snapshot.json, commands_snapshot.json
|
||||
│ └── subsystems/*.json (29 per-subsystem records)
|
||||
└── ~30 subdirectories/ # PLACEHOLDER PACKAGES (assistant/, bootstrap/, voice/, vim/, ...)
|
||||
└── each contains only __init__.py loading subsystems/<name>.json
|
||||
```
|
||||
|
||||
Placeholder packages follow an identical template: load
|
||||
`reference_data/subsystems/<name>.json` via `_archive_helper.load_archive_metadata()`,
|
||||
re-export `ARCHIVE_NAME`, `MODULE_COUNT`, `SAMPLE_FILES`, `PORTING_NOTE`. There is no
|
||||
voice, vim, or other real functionality behind them.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Goal | Start here |
|
||||
|---|---|
|
||||
| Understand the CLI subcommands | `main.py` |
|
||||
| See which TS files map to which .py | `parity_audit.py` |
|
||||
| Find shared data structures | `models.py` |
|
||||
| Check TS archive metadata | `reference_data/` |
|
||||
| Real logic (permissions, path scoping) | `permissions.py`, `path_scope.py` |
|
||||
| Query engine shim | `query_engine.py` |
|
||||
| Runtime simulation | `runtime.py` |
|
||||
| Tests | repo-root `tests/` (test_porting_workspace.py, test_security_scope.py) |
|
||||
|
||||
Run tests: `python -m unittest discover -s tests` from repo root.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- snake_case filenames, preserving TS names. camelCase exceptions exist where the
|
||||
TS original used it: `QueryEngine.py`, `costHook.py`, `replLauncher.py`.
|
||||
- Every mirrored entry carries a `source_hint` pointing back to its original `.ts`/`.tsx` path.
|
||||
- `from __future__ import annotations` throughout. Stdlib only, no third-party deps.
|
||||
- `models.py` dataclasses are frozen. Renderers are `as_markdown()` / `to_markdown()`.
|
||||
- main.py simulates routing, turn-loops, bootstrap over mirrored inventories.
|
||||
Read-only shims return handled/message results. It never calls an LLM.
|
||||
- Thin shim modules (e.g. `ink.py`) exist as backlog metadata, not working code.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
**Don't add real agent, tool, voice, or vim functionality here.** That belongs in
|
||||
`rust/crates/`. This tree is a scaffold, not an implementation target.
|
||||
|
||||
**Don't commit anything from `archive/`.** The local TS snapshot
|
||||
(`archive/claude_code_ts_snapshot/src`) is gitignored. `reference_data/` holds the
|
||||
tracked extracts; work from those.
|
||||
|
||||
**Don't let parity_audit.py drift.** When you rename a mirrored module, update the
|
||||
hard-coded mapping in `parity_audit.py` to match.
|
||||
|
||||
**Don't add third-party dependencies.** Everything runs on stdlib.
|
||||
+17
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,16 +16,31 @@ class StoredSession:
|
||||
|
||||
DEFAULT_SESSION_DIR = Path('.port_sessions')
|
||||
|
||||
# Session ids become filenames inside the session directory, so they must not be
|
||||
# able to escape it via path separators or traversal. Restrict to the charset
|
||||
# produced by legitimate ids (uuid4 hex, `session-<digits>-<n>`).
|
||||
_SESSION_ID_PATTERN = re.compile(r'\A[A-Za-z0-9_-]+\Z')
|
||||
|
||||
|
||||
def _validate_session_id(session_id: str) -> str:
|
||||
if not _SESSION_ID_PATTERN.match(session_id):
|
||||
raise ValueError(
|
||||
f'invalid session_id {session_id!r}: only [A-Za-z0-9_-] characters are allowed'
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def save_session(session: StoredSession, directory: Path | None = None) -> Path:
|
||||
session_id = _validate_session_id(session.session_id)
|
||||
target_dir = directory or DEFAULT_SESSION_DIR
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = target_dir / f'{session.session_id}.json'
|
||||
path = target_dir / f'{session_id}.json'
|
||||
path.write_text(json.dumps(asdict(session), indent=2))
|
||||
return path
|
||||
|
||||
|
||||
def load_session(session_id: str, directory: Path | None = None) -> StoredSession:
|
||||
session_id = _validate_session_id(session_id)
|
||||
target_dir = directory or DEFAULT_SESSION_DIR
|
||||
data = json.loads((target_dir / f'{session_id}.json').read_text())
|
||||
return StoredSession(
|
||||
|
||||
@@ -9,9 +9,66 @@ from src.models import PermissionDenial
|
||||
from src.path_scope import WorkspacePathScope, extract_path_candidates
|
||||
from src.permissions import ToolPermissionContext
|
||||
from src.query_engine import QueryEnginePort
|
||||
from src.session_store import StoredSession, load_session, save_session
|
||||
from src.tools import execute_tool
|
||||
|
||||
|
||||
class SessionStorePathTraversalTests(unittest.TestCase):
|
||||
def test_traversal_session_id_is_rejected_on_load(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Path(tmp) / 'store'
|
||||
store.mkdir()
|
||||
# A secret living outside the session store that traversal would reach.
|
||||
(Path(tmp) / 'secret.json').write_text('{}')
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
load_session('../secret', directory=store)
|
||||
|
||||
def test_absolute_and_separator_session_ids_are_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Path(tmp) / 'store'
|
||||
store.mkdir()
|
||||
for bad_id in (
|
||||
'/etc/passwd',
|
||||
'nested/child',
|
||||
r'nested\child',
|
||||
r'C:\Windows\win.ini',
|
||||
'..',
|
||||
'a/../../b',
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
load_session(bad_id, directory=store)
|
||||
|
||||
def test_traversal_session_id_is_rejected_on_save(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Path(tmp) / 'store'
|
||||
store.mkdir()
|
||||
session = StoredSession(
|
||||
session_id='../escape',
|
||||
messages=('hello',),
|
||||
input_tokens=1,
|
||||
output_tokens=2,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
save_session(session, directory=store)
|
||||
self.assertFalse((Path(tmp) / 'escape.json').exists())
|
||||
|
||||
def test_legitimate_session_id_round_trips(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Path(tmp) / 'store'
|
||||
session = StoredSession(
|
||||
session_id='session-1775386832313-0',
|
||||
messages=('hi', 'there'),
|
||||
input_tokens=3,
|
||||
output_tokens=4,
|
||||
)
|
||||
path = save_session(session, directory=store)
|
||||
|
||||
self.assertEqual(store, path.parent)
|
||||
loaded = load_session('session-1775386832313-0', directory=store)
|
||||
self.assertEqual(session, loaded)
|
||||
|
||||
|
||||
class WorkspacePathScopeTests(unittest.TestCase):
|
||||
def test_direct_parent_escape_is_denied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
Reference in new issue
Block a user