mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-21 16:58:32 +02:00
Compare commits
66 Commits
8f55870dad
...
879962b826
| Author | SHA1 | Date | |
|---|---|---|---|
| 879962b826 | |||
| 0b0d55d7ec | |||
| 7214573f35 | |||
| dcf11f8190 | |||
| f79ca989ba | |||
| e1641aa010 | |||
| 5cebdd999d | |||
| bf533d77a7 | |||
| e34209ff7f | |||
| ff37d395bb | |||
| f8d744bb37 | |||
| c8c936ede1 | |||
| 57b3e3258b | |||
| 06e545325d | |||
| ed3ccae844 | |||
| f4e08d0ecf | |||
| 030f2ef20f | |||
| 16d6525de4 | |||
| 42c79218c9 | |||
| 4e0211d36c | |||
| aec291caab | |||
| 43b182882a | |||
| 307b23d27f | |||
| 8c11dd16f4 | |||
| 2012718749 | |||
| 79d3b809f9 | |||
| 9ec4d8398e | |||
| 5f45740408 | |||
| 675d9ddc78 | |||
| 087e31d190 | |||
| a6ee51baab | |||
| 6df60a4683 | |||
| 3cf0db8f79 | |||
| 964458ad4a | |||
| d87c3e6400 | |||
| ac888623a8 | |||
| 3a8ce83234 | |||
| 37b2b75287 | |||
| f2dc615a8a | |||
| 9bc55f9946 | |||
| 180ebb3b02 | |||
| 534442b8da | |||
| 9c2ebb4f39 | |||
| 2c48400293 | |||
| 713ca7aee4 | |||
| 02b591ac64 | |||
| f789525839 | |||
| b1d8a66515 | |||
| ad9e0234a9 | |||
| 145413d624 | |||
| 17da2964d7 | |||
| 9ab569e626 | |||
| 4af5664ff8 | |||
| 1864ce38ad | |||
| 74cc590407 | |||
| a4b20ea34d | |||
| 8d0cee46d5 | |||
| 45b43b5a96 | |||
| d15268e2cc | |||
| 424825f8cb | |||
| 07dad88e8c | |||
| 5c77896dec | |||
| 74bbf4b36f | |||
| 481585f865 | |||
| c6e2a7dee4 | |||
| 83116555ff |
+14886
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
|
||||
{
|
||||
"schema_version": "cc2.issue_parity_intake.v1",
|
||||
"generated_at": "2026-05-14T08:02:00Z",
|
||||
"task_id": "3",
|
||||
"owner": "worker-2",
|
||||
"goal": "G001-stream0-board",
|
||||
"notes": [
|
||||
"Leader owns Ultragoal; this artifact does not mutate .omx/ultragoal.",
|
||||
"Rows are scoped intake/classification evidence for Worker 1/Task 2 board integration."
|
||||
],
|
||||
"source_manifest": {
|
||||
"claw_open_latest": {
|
||||
"path": ".omx/research/claw-open-latest.json",
|
||||
"sha256_prefix_from_plan": "89e3e027fa735f38",
|
||||
"covered_issue_numbers": [3028, 3029, 3030, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038]
|
||||
},
|
||||
"claw_issues": {
|
||||
"path": ".omx/research/claw-issues.json",
|
||||
"sha256_prefix_from_plan": "e64fdba7df3b78ed",
|
||||
"covered_issue_numbers": [2997, 3003, 3004, 3005, 3006, 3007, 3020, 3023]
|
||||
},
|
||||
"opencode": {
|
||||
"repo_path": ".omx/research/repos/opencode",
|
||||
"metadata_path": ".omx/research/opencode-repo.json",
|
||||
"issues_path": ".omx/research/opencode-issues.json",
|
||||
"head_from_plan": "27ac53aaacc677b1401c4e75ca7a7dadf8b2c349"
|
||||
},
|
||||
"codex": {
|
||||
"repo_path": ".omx/research/repos/codex",
|
||||
"metadata_path": ".omx/research/codex-repo.json",
|
||||
"issues_path": ".omx/research/codex-issues.json",
|
||||
"head_from_plan": "6a225e4005209f2325ab3c681c7c6beba2907d4d"
|
||||
}
|
||||
},
|
||||
"issue_clusters": [
|
||||
{
|
||||
"id": "CC2-ISSUE-3007",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3007",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3007,
|
||||
"title": "Permission modes do not enforce path scope on file tools or shell expansion in bash",
|
||||
"theme": "security/path-scope",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"lifecycle_status": "active",
|
||||
"roadmap_anchor": "ROADMAP.md#11-policy-engine-for-autonomous-coding; ROADMAP.md#9-green-ness-contract",
|
||||
"dependencies": ["permission path canonicalization", "file tool target validation", "bash command/path validation reachability", "policy regression fixtures"],
|
||||
"verification_required": ["workspace-write cannot read/write/delete outside workspace", "shell expansion and symlink traversal are rejected or policy-blocked", "file tools and bash use the same target-scope decision record"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Security/sandbox escape class; plan names #3007 as alpha blocker."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3020",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3020",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3020,
|
||||
"title": "OpenAI-compatible model IDs with slashes are stripped before request",
|
||||
"theme": "provider/model-routing",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#provider-routing-model-name-prefix-must-win-over-env-var-presence-fixed-2026-04-08-0530c50",
|
||||
"dependencies": ["provider profile contract", "wire model-id preservation option", "routing-prefix source reporting"],
|
||||
"verification_required": ["OpenAI-compatible endpoint receives exact model id when preservation is enabled", "status JSON reports raw model input, route, and wire model id"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Core provider correctness but below alpha state/security contracts unless it blocks the selected alpha model path."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3006",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3006",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3006,
|
||||
"title": "Not Working in windows",
|
||||
"theme": "windows/install",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["Windows support policy", "PowerShell install path", "dependency/version matrix", "diagnostic setup output"],
|
||||
"verification_required": ["fresh Windows/PowerShell setup smoke documented", "unsupported native paths fail with actionable WSL2/native guidance"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Real adoption blocker; plan places Windows/install in beta adoption overlay."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3005",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3005",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3005,
|
||||
"title": "DeepSeek V4-flash/pro fails with 400 Bad Request (missing reasoning_content) while deepseek-reasoner works",
|
||||
"theme": "provider/response-shape",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#5-failure-taxonomy; ROADMAP.md#provider-routing-model-name-prefix-must-win-over-env-var-presence-fixed-2026-04-08-0530c50",
|
||||
"dependencies": ["OpenAI-compatible diagnostics playbook", "provider error taxonomy", "reasoning/thinking field compatibility tests"],
|
||||
"verification_required": ["provider 400 response classified with actionable remediation", "DeepSeek-compatible response-shape fixture does not hide assistant output"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Provider compatibility issue that shares the #3032 diagnostics lane."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3004",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3004",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3004,
|
||||
"title": "When can we adapt to zed?",
|
||||
"theme": "ide/acp",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#phase-5-plugin-and-mcp-lifecycle-maturity",
|
||||
"dependencies": ["stable session/control API", "plugin/MCP lifecycle", "engine API or ACP bridge decision"],
|
||||
"verification_required": ["Zed/ACP smoke once core state/control contracts exist"],
|
||||
"deferral_rationale": "IDE integration is valuable but should wait until boot/session/event/control truth surfaces are stable.",
|
||||
"classification_rationale": "Matches plan's GA ecosystem lane for Zed/ACP."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3003",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3003",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3003,
|
||||
"title": ".claude/sessions should not be submitted to repo",
|
||||
"theme": "session-hygiene/gitignore",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#9-green-ness-contract; ROADMAP.md#8-recovery-recipes-for-common-failures",
|
||||
"dependencies": ["artifact ignore policy", "session storage boundary docs", "repo hygiene check"],
|
||||
"verification_required": ["session directories are ignored", "status/doctor warns about tracked session artifacts"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Small but user-visible session hygiene and data-leak prevention item."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-2997",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/2997",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 2997,
|
||||
"title": "License?",
|
||||
"theme": "docs/license",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["maintainer license decision", "LICENSE file", "README/USAGE attribution wording"],
|
||||
"verification_required": ["repository license file exists", "package metadata and docs reference the same license"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Adoption/readiness documentation gap; requires maintainer decision before implementation."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3023",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3023",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3023,
|
||||
"title": "Protect claw-code from AI slop PRs",
|
||||
"theme": "repo-hygiene/anti-slop",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["contributor policy", "PR quality gate selection", "false-positive review escape hatch"],
|
||||
"verification_required": ["selected PR quality gate runs on sample good/bad PR fixtures", "maintainers can override false positives"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Protects project throughput but should not precede alpha core safety contracts."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3028",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3028",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3028,
|
||||
"title": "docs: add navigation and file-context usage guide",
|
||||
"theme": "docs/navigation-context",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#7-human-ux-still-leaks-into-claw-workflows",
|
||||
"dependencies": ["current TUI/shell key behavior inventory", "file context syntax docs", "secret-handling guidance"],
|
||||
"verification_required": ["docs include terminal history, scrollback, @file context, attach/external file caveats", "examples work against current CLI"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Documentation support item from latest open issue refresh."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3029",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3029",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3029,
|
||||
"title": "build: add cross-platform installer path and release artifact quickstart",
|
||||
"theme": "install/distribution",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["release artifact policy", "install.sh/install.ps1 contract", "PATH/update/uninstall instructions"],
|
||||
"verification_required": ["install quickstart smoke on supported OS/arch", "failed install prints actionable diagnostics"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Distribution friction belongs in adoption overlay."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3030",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3030",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3030,
|
||||
"title": "feat: make provider/model setup less env-var-driven",
|
||||
"theme": "provider/setup-profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#3-structured-session-control-api; ROADMAP.md#145-boot-preflight-doctor-contract",
|
||||
"dependencies": ["provider profiles", "setup wizard or dry-run", "secret redaction", "base-url/model smoke test"],
|
||||
"verification_required": ["setup validates provider route without echoing keys", "session-only versus persisted profile behavior is explicit"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Directly reduces current provider setup support churn."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3031",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3031",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3031,
|
||||
"title": "feat: auto-compact or clearly recover from context-window provider errors",
|
||||
"theme": "session-recovery/context-window",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#8-recovery-recipes-for-common-failures; ROADMAP.md#158-compact_messages_if_needed-drops-turns-silently-no-structured-compaction-event-emitted",
|
||||
"dependencies": ["provider error classifier", "safe compact retry policy", "compaction event/audit trail", "retry loop cap"],
|
||||
"verification_required": ["context-window error either compacts+retries once safely or emits exact recovery command", "compaction event is machine-visible"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Recovery reliability item; promoted only if selected alpha provider path hits it."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3032",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3032",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3032,
|
||||
"title": "docs: add OpenAI-compatible/local provider diagnostics playbook",
|
||||
"theme": "provider/diagnostics-docs",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#5-failure-taxonomy",
|
||||
"dependencies": ["raw chat-completions smoke tests", "tool-call response-shape examples", "provider failure taxonomy"],
|
||||
"verification_required": ["playbook distinguishes Claw bugs from wrapper/tool-call-shape bugs", "curl examples cover non-streaming and streaming tool calls"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Shared diagnostic lane for #3005/#3020/local model reports."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3033",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3033",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3033,
|
||||
"title": "feat: add minimal claw serve JSON-RPC engine API",
|
||||
"theme": "engine-api/control-plane",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#3-structured-session-control-api; ROADMAP.md#phase-4-claws-first-task-execution",
|
||||
"dependencies": ["stable session state API", "event schema v1", "permission policy contract", "cancel/prompt stream semantics"],
|
||||
"verification_required": ["protocol conformance fixtures for session/create prompt/stream cancel error", "capability negotiation backwards compatibility"],
|
||||
"deferral_rationale": "Engine API should expose, not invent, stable core control-plane semantics after alpha contracts land.",
|
||||
"classification_rationale": "Useful integration surface but too broad for alpha unless narrowed to existing session control API."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3034",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3034",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3034,
|
||||
"title": "docs: define evidence-gated Hermes handoff loop for Claw Code execution",
|
||||
"theme": "sdlc/evidence-handoff",
|
||||
"release_bucket": "post_2_0_research",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#4-canonical-lane-event-schema; ROADMAP.md#10-typed-task-packet-format",
|
||||
"dependencies": ["typed task packet", "evidence bundle schema", "report gate status vocabulary"],
|
||||
"verification_required": ["handoff packet fixture validates scope/success/test evidence fields", "post-flight gate consumes evidence instead of free-text summary"],
|
||||
"deferral_rationale": "Can inform event/report/task contracts, but Hermes-specific loop should stay research/docs until core schemas are stable.",
|
||||
"classification_rationale": "Only the generic evidence-gated contract is Claw 2.0; Hermes branding is not core."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3035",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3035",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3035,
|
||||
"title": "fix: improve compacted session resume discoverability",
|
||||
"theme": "session-resume/discoverability",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#8-recovery-recipes-for-common-failures; ROADMAP.md#160-session_store-has-no-list_sessions-delete_session-or-session_exists",
|
||||
"dependencies": ["session enumeration", "latest-session workspace search boundary", "compacted session marker"],
|
||||
"verification_required": ["/resume latest finds newest eligible compacted session", "/session or status lists resumable compacted sessions with path/id"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Session recovery/adoption item."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3036",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3036",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3036,
|
||||
"title": "docs: add official Ollama/llama.cpp/vLLM local model examples",
|
||||
"theme": "provider/local-docs",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#145-boot-preflight-doctor-contract; ROADMAP.md#5-failure-taxonomy",
|
||||
"dependencies": ["known-good local provider examples", "raw /v1 smoke test", "tool-call limitation warning"],
|
||||
"verification_required": ["docs include Ollama/llama.cpp/vLLM examples and HELLO smoke", "tool-call caveats are explicit"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Local provider adoption support."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3037",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3037",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3037,
|
||||
"title": "docs: clarify Claw Code positioning as multi-provider Claude-Code-shaped runtime",
|
||||
"theme": "docs/product-positioning",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#goal; ROADMAP.md#definition-of-clawable",
|
||||
"dependencies": ["README positioning copy", "provider support truth table", "identity leak bug policy"],
|
||||
"verification_required": ["README/docs answer Claude-only question directly", "provider support wording matches implemented routes"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Clarifies product identity for adoption without broad implementation."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3038",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3038",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3038,
|
||||
"title": "roadmap: track skills/plugins/marketplace ecosystem gap after core UX stabilizes",
|
||||
"theme": "plugin-marketplace/ecosystem",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#13-first-class-pluginmcp-lifecycle-contract; ROADMAP.md#14-mcp-end-to-end-lifecycle-parity",
|
||||
"dependencies": ["plugin/MCP lifecycle contract", "extension point inventory", "discovery/install/update flow design"],
|
||||
"verification_required": ["extension point inventory exists", "marketplace work explicitly depends on core UX stabilization"],
|
||||
"deferral_rationale": "Marketplace breadth should wait until core setup/auth/provider/session UX and plugin lifecycle are reliable.",
|
||||
"classification_rationale": "Matches plan's ga_ecosystem/post-2.0 caution for marketplace parity."
|
||||
}
|
||||
],
|
||||
"parity_rows": [
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-PLUGIN-ECOSYSTEM",
|
||||
"source_anchor": "anomalyco/opencode@27ac53aa packages/app/web/desktop/plugin/sdk/extensions/zed/slack/containers plus issue #3038",
|
||||
"source_type": "repo_clone_and_local_issue",
|
||||
"title": "Plugin/skills/marketplace ecosystem inventory",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["Claw plugin/MCP lifecycle contract", "current extension-point inventory"],
|
||||
"verification_required": ["inventory maps current Claw plugin/skill/MCP extension points before marketplace implementation"],
|
||||
"deferral_rationale": "Adapt ecosystem discovery only after core setup/provider/session reliability is stable."
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-PERMISSION-PRESETS",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27464 and ROADMAP.md#11-policy-engine-for-autonomous-coding",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "Quick permission preset switching mapped onto Claw policy profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["policy profile model", "approval-token audit trail"],
|
||||
"verification_required": ["preset switch is visible in status/report output and cannot bypass path-scope enforcement"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-CUSTOM-PROVIDER-PARAMS",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27462 and #3030/#3032",
|
||||
"source_type": "external_issue_and_local_issue",
|
||||
"title": "Custom API parameter passthrough for provider profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["provider profile schema", "secret redaction", "request audit surface"],
|
||||
"verification_required": ["custom params are schema-validated, redacted, and visible as provenance without leaking secrets"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-TODOWRITE-AUTOCOMPLETE",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27453 and ROADMAP.md#10-typed-task-packet-format",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "Task/Todo completion assistance via typed task lifecycle",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["typed task packet", "task lifecycle events", "evidence-gated completion"],
|
||||
"verification_required": ["auto-complete suggestions cannot mark work complete without evidence bundle or explicit user approval"],
|
||||
"deferral_rationale": "Useful UX should follow, not precede, typed task lifecycle and evidence contract."
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-WINDOWS-DISTRIBUTION",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27476 https://github.com/anomalyco/opencode/issues/27459 https://github.com/anomalyco/opencode/issues/27470 and #3006/#3029",
|
||||
"source_type": "external_issues_and_local_issues",
|
||||
"title": "Windows/GLIBC/distribution reliability parity lessons",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["install artifact matrix", "Windows encoding guidance", "minimum Linux/GLIBC support statement"],
|
||||
"verification_required": ["release quickstart documents supported OS matrix and known terminal/encoding caveats"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-GRANULAR-PERMISSIONS",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22595 and Codex docs permissions/app/plugin concepts",
|
||||
"source_type": "external_issue_and_docs",
|
||||
"title": "Granular app/plugin permissions adapted to Claw policy engine",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"lifecycle_status": "active",
|
||||
"dependencies": ["permission enforcer path-scope fix", "plugin/MCP capability model", "approval-token replay protection"],
|
||||
"verification_required": ["granular permission grants do not widen workspace path scope implicitly"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-SESSION-RECOVERY",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22619 https://github.com/openai/codex/issues/22597 https://github.com/openai/codex/issues/22593 and #3035",
|
||||
"source_type": "external_issues_and_local_issue",
|
||||
"title": "Safe local session/thread recovery without storage amplification",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["session enumeration", "resume latest boundary", "JSONL/storage compaction policy"],
|
||||
"verification_required": ["recoverable sessions are discoverable and session forks avoid unbounded duplicate history"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-PROXY-NETWORK",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22623 and #3032",
|
||||
"source_type": "external_issue_and_local_issue",
|
||||
"title": "Provider/network diagnostics include proxy behavior",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["HTTP client proxy detection", "provider diagnostics playbook"],
|
||||
"verification_required": ["diagnostics report whether proxy env/config is honored for provider calls"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-CLI-AGENT-FLAG",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22615 and ROADMAP.md#10-typed-task-packet-format",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "CLI flag for agent/subagent mode mapped to Claw typed task packets",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["typed task packet", "session control API", "policy-scoped worker launch"],
|
||||
"verification_required": ["CLI agent mode cannot bypass task policy or evidence requirements"],
|
||||
"deferral_rationale": "Implement only after core task/session control contracts are stable."
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"required_latest_open_range_3028_3038": [3028, 3029, 3030, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038],
|
||||
"required_existing_issue_numbers": [3007, 3006, 3020, 3005, 3003, 2997, 3023, 3004],
|
||||
"issue_rows_expected": 19,
|
||||
"parity_rows_expected_minimum": 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# CC2 Issue / Parity Intake Mapping
|
||||
|
||||
Generated by `worker-2` for team task 3 (`G001 issue/parity intake mapping`). This is a board-integration fragment for Stream 0; it intentionally does **not** mutate `.omx/ultragoal`.
|
||||
|
||||
## Covered local issue clusters
|
||||
|
||||
| Issue | Theme | Bucket | Lifecycle | Board anchor |
|
||||
|---:|---|---|---|---|
|
||||
| #3007 | security/path-scope | `alpha_blocker` | `active` | Policy engine + green-ness contract |
|
||||
| #3020 | provider/model-routing | `beta_adoption` | `open` | Provider routing/model source status |
|
||||
| #3006 | windows/install | `beta_adoption` | `open` | Immediate backlog / install readiness |
|
||||
| #3005 | provider/response-shape | `beta_adoption` | `open` | Failure taxonomy / provider diagnostics |
|
||||
| #3004 | ide/acp | `ga_ecosystem` | `deferred_with_rationale` | Plugin/MCP lifecycle maturity |
|
||||
| #3003 | session-hygiene/gitignore | `beta_adoption` | `open` | Green-ness / recovery hygiene |
|
||||
| #2997 | docs/license | `beta_adoption` | `open` | Adoption docs/license readiness |
|
||||
| #3023 | repo-hygiene/anti-slop | `beta_adoption` | `open` | Immediate backlog / PR quality gate |
|
||||
| #3028 | docs/navigation-context | `beta_adoption` | `open` | Human UX leaks into claw workflows |
|
||||
| #3029 | install/distribution | `beta_adoption` | `open` | Cross-platform release quickstart |
|
||||
| #3030 | provider/setup-profiles | `beta_adoption` | `open` | Boot preflight / structured session control |
|
||||
| #3031 | session-recovery/context-window | `beta_adoption` | `open` | Recovery recipes / compaction event |
|
||||
| #3032 | provider/diagnostics-docs | `beta_adoption` | `open` | Failure taxonomy |
|
||||
| #3033 | engine-api/control-plane | `ga_ecosystem` | `deferred_with_rationale` | Structured session control API |
|
||||
| #3034 | sdlc/evidence-handoff | `post_2_0_research` | `deferred_with_rationale` | Event/report/task contract input |
|
||||
| #3035 | session-resume/discoverability | `beta_adoption` | `open` | Recovery recipes / session enumeration |
|
||||
| #3036 | provider/local-docs | `beta_adoption` | `open` | Provider setup and diagnostics docs |
|
||||
| #3037 | docs/product-positioning | `beta_adoption` | `open` | Goal / definition of clawable |
|
||||
| #3038 | plugin-marketplace/ecosystem | `ga_ecosystem` | `deferred_with_rationale` | Plugin/MCP lifecycle maturity |
|
||||
|
||||
## Parity intake rows
|
||||
|
||||
| Row | Source | Bucket | Lifecycle | Adaptation rule |
|
||||
|---|---|---|---|---|
|
||||
| `CC2-PARITY-OPENCODE-PLUGIN-ECOSYSTEM` | opencode repo + #3038 | `ga_ecosystem` | `deferred_with_rationale` | Inventory Claw extension points before marketplace work. |
|
||||
| `CC2-PARITY-OPENCODE-PERMISSION-PRESETS` | opencode #27464 | `beta_adoption` | `open` | Permission preset UX must not bypass Claw path-scope policy. |
|
||||
| `CC2-PARITY-OPENCODE-CUSTOM-PROVIDER-PARAMS` | opencode #27462 + #3030/#3032 | `beta_adoption` | `open` | Custom provider params need schema validation, redaction, and provenance. |
|
||||
| `CC2-PARITY-OPENCODE-TODOWRITE-AUTOCOMPLETE` | opencode #27453 | `ga_ecosystem` | `deferred_with_rationale` | Auto-complete task UX follows typed task lifecycle/evidence gates. |
|
||||
| `CC2-PARITY-OPENCODE-WINDOWS-DISTRIBUTION` | opencode #27476/#27459/#27470 + #3006/#3029 | `beta_adoption` | `open` | Use external pain as release-matrix and diagnostics evidence. |
|
||||
| `CC2-PARITY-CODEX-GRANULAR-PERMISSIONS` | Codex #22595 + docs | `alpha_blocker` | `active` | Adapt granular permissions only through Claw policy engine and approval tokens. |
|
||||
| `CC2-PARITY-CODEX-SESSION-RECOVERY` | Codex #22619/#22597/#22593 + #3035 | `beta_adoption` | `open` | Session discovery/recovery must avoid storage amplification. |
|
||||
| `CC2-PARITY-CODEX-PROXY-NETWORK` | Codex #22623 + #3032 | `beta_adoption` | `open` | Provider diagnostics should expose proxy behavior. |
|
||||
| `CC2-PARITY-CODEX-CLI-AGENT-FLAG` | Codex #22615 | `ga_ecosystem` | `deferred_with_rationale` | CLI agent mode waits for typed task/session control contracts. |
|
||||
|
||||
Validation command:
|
||||
|
||||
```bash
|
||||
python3 .omx/cc2/validate_issue_parity_intake.py
|
||||
```
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the Claw Code 2.0 canonical board JSON as a human-readable Markdown board."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
STATUS_DESCRIPTIONS = {
|
||||
"context": "Context-only heading or evidence anchor; not an implementation work item.",
|
||||
"active": "Current Claw Code 2.0 implementation surface that should remain visible on the board.",
|
||||
"open": "Actionable unresolved work that needs implementation or acceptance evidence.",
|
||||
"done_verify": "Marked as done upstream but retained for verification against current CC2 behavior.",
|
||||
"stale_done": "Historically completed or merged work that may be stale and needs freshness checks before relying on it.",
|
||||
"superseded": "Replaced by a newer item; keep as traceability context only.",
|
||||
"deferred_with_rationale": "Intentionally deferred; rationale must be present in the board item.",
|
||||
"rejected_not_claw": "Excluded because it is not Claw Code product work.",
|
||||
}
|
||||
|
||||
BUCKET_DESCRIPTIONS = {
|
||||
"alpha_blocker": "Must be resolved before alpha-quality autonomous coding lanes are dependable.",
|
||||
"beta_adoption": "Important for broader dogfood/adoption once alpha blockers are controlled.",
|
||||
"ga_ecosystem": "Required for mature plugin/MCP/provider ecosystem behavior.",
|
||||
"2.x_intake": "Post-2.0 intake or follow-up candidate retained for sequencing.",
|
||||
"post_2_0_research": "Research-oriented item not required for the CC2 board cut.",
|
||||
"context": "Non-actionable roadmap context.",
|
||||
"rejected_not_claw": "Explicit non-Claw rejection bucket.",
|
||||
}
|
||||
|
||||
LANE_TITLES = {
|
||||
"stream_0_governance": "Stream 0 — Governance, intake, and cross-cutting roadmap triage",
|
||||
"stream_1_worker_boot_session_control": "Stream 1 — Worker boot and session control",
|
||||
"stream_2_event_reporting_contracts": "Stream 2 — Event/reporting contracts",
|
||||
"stream_3_branch_test_recovery": "Stream 3 — Branch/test recovery",
|
||||
"stream_4_claws_first_execution": "Stream 4 — Claws-first task execution",
|
||||
"stream_5_plugin_mcp_lifecycle": "Stream 5 — Plugin/MCP lifecycle",
|
||||
"adoption_overlay": "Adoption overlay — user-visible parity and release polish",
|
||||
"parity_overlay": "Parity overlay — opencode/codex comparison context",
|
||||
}
|
||||
|
||||
REQUIRED_ITEM_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"lifecycle_status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
]
|
||||
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
with path.open() as f:
|
||||
board = json.load(f)
|
||||
if not isinstance(board, dict):
|
||||
raise ValueError("board JSON root must be an object")
|
||||
items = board.get("items")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("board JSON must contain an items array")
|
||||
return board
|
||||
|
||||
|
||||
def validate_board(board: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("unmapped_roadmap_heading_lines"):
|
||||
errors.append(f"unmapped roadmap heading lines: {coverage['unmapped_roadmap_heading_lines']}")
|
||||
if coverage.get("roadmap_headings_mapped") != coverage.get("roadmap_headings_total"):
|
||||
errors.append("roadmap heading coverage is incomplete")
|
||||
if coverage.get("roadmap_actions_mapped") != coverage.get("roadmap_actions_total"):
|
||||
errors.append("roadmap ordered-action coverage is incomplete")
|
||||
|
||||
allowed_status = set(board.get("generation_policy", {}).get("status_values", []))
|
||||
allowed_buckets = set(board.get("generation_policy", {}).get("release_buckets", []))
|
||||
seen_ids: set[str] = set()
|
||||
for index, item in enumerate(board["items"], 1):
|
||||
for field in REQUIRED_ITEM_FIELDS:
|
||||
if field not in item:
|
||||
errors.append(f"item {index} missing required field {field}")
|
||||
item_id = item.get("id")
|
||||
if item_id in seen_ids:
|
||||
errors.append(f"duplicate item id {item_id}")
|
||||
seen_ids.add(item_id)
|
||||
status = item.get("lifecycle_status")
|
||||
bucket = item.get("release_bucket")
|
||||
if allowed_status and status not in allowed_status:
|
||||
errors.append(f"{item_id} has unknown lifecycle_status {status!r}")
|
||||
if allowed_buckets and bucket not in allowed_buckets:
|
||||
errors.append(f"{item_id} has unknown release_bucket {bucket!r}")
|
||||
if status == "deferred_with_rationale" and not str(item.get("deferral_rationale", "")).strip():
|
||||
errors.append(f"{item_id} is deferred without deferral_rationale")
|
||||
return errors
|
||||
|
||||
|
||||
def table(headers: list[str], rows: list[list[Any]]) -> list[str]:
|
||||
out = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
|
||||
for row in rows:
|
||||
out.append("| " + " | ".join(str(cell) for cell in row) + " |")
|
||||
return out
|
||||
|
||||
|
||||
def fmt_list(value: Any) -> str:
|
||||
if not value:
|
||||
return "none"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(f"`{v}`" for v in value) if value else "none"
|
||||
return f"`{value}`"
|
||||
|
||||
|
||||
def render(board: dict[str, Any]) -> str:
|
||||
items: list[dict[str, Any]] = board["items"]
|
||||
summary = board.get("summary", {})
|
||||
coverage = board.get("coverage", {})
|
||||
sources = board.get("sources", {})
|
||||
policy = board.get("generation_policy", {})
|
||||
by_lane = Counter(item.get("owner_lane", "unassigned") for item in items)
|
||||
by_status = Counter(item.get("lifecycle_status", "unknown") for item in items)
|
||||
by_bucket = Counter(item.get("release_bucket", "unknown") for item in items)
|
||||
by_source = Counter(item.get("source_type", "unknown") for item in items)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Claw Code 2.0 Canonical Board")
|
||||
lines.append("")
|
||||
lines.append(f"Generated from board schema: `{board.get('generated_at', 'unknown')}`")
|
||||
lines.append(f"Schema version: `{board.get('schema_version', 'unknown')}`")
|
||||
lines.append("Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified by this rendering task.")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Evidence Freeze")
|
||||
lines.append("")
|
||||
roadmap = sources.get("roadmap", {})
|
||||
research = sources.get("research", {})
|
||||
plan = sources.get("approved_plan", {})
|
||||
lines.extend(table(["Source", "Frozen evidence"], [
|
||||
["Roadmap", f"`{roadmap.get('path', 'ROADMAP.md')}` sha256 prefix `{roadmap.get('sha256_prefix', 'unknown')}`; {roadmap.get('heading_count', '?')} headings; {roadmap.get('ordered_action_count', '?')} ordered actions"],
|
||||
["Approved plan", f"`{plan.get('path', '.omx/plans/claw-code-2-0-adaptive-plan.md')}` sha256 prefix `{plan.get('sha256_prefix', 'unknown')}`"],
|
||||
["Research bundle", f"root `{research.get('root', '.omx/research')}`; latest open issues {research.get('claw_open_latest_count', '?')}; issue corpus {research.get('claw_issues_count', '?')}; codex/opencode clone metadata included"],
|
||||
]))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Roadmap Coverage Summary")
|
||||
lines.append("")
|
||||
heading_total = coverage.get("roadmap_headings_total", 0)
|
||||
heading_mapped = coverage.get("roadmap_headings_mapped", 0)
|
||||
action_total = coverage.get("roadmap_actions_total", 0)
|
||||
action_mapped = coverage.get("roadmap_actions_mapped", 0)
|
||||
lines.extend(table(["Coverage gate", "Mapped", "Total", "Status"], [
|
||||
["ROADMAP headings", heading_mapped, heading_total, "PASS" if heading_mapped == heading_total and not coverage.get("unmapped_roadmap_heading_lines") else "FAIL"],
|
||||
["ROADMAP ordered actions", action_mapped, action_total, "PASS" if action_mapped == action_total else "FAIL"],
|
||||
["Duplicate heading lines", len(coverage.get("duplicate_roadmap_heading_lines", [])), 0, "PASS" if not coverage.get("duplicate_roadmap_heading_lines") else "WARN"],
|
||||
]))
|
||||
lines.append("")
|
||||
lines.append(f"Total canonical board items: **{len(items)}**")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Lifecycle Enum Reference")
|
||||
lines.append("")
|
||||
status_rows = []
|
||||
for status in policy.get("status_values", sorted(by_status)):
|
||||
status_rows.append([f"`{status}`", by_status.get(status, 0), STATUS_DESCRIPTIONS.get(status, "Board-defined lifecycle status.")])
|
||||
lines.extend(table(["Lifecycle", "Count", "Meaning"], status_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Release Bucket Reference")
|
||||
lines.append("")
|
||||
bucket_rows = []
|
||||
for bucket in policy.get("release_buckets", sorted(by_bucket)):
|
||||
bucket_rows.append([f"`{bucket}`", by_bucket.get(bucket, 0), BUCKET_DESCRIPTIONS.get(bucket, "Board-defined release bucket.")])
|
||||
lines.extend(table(["Bucket", "Count", "Meaning"], bucket_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Stream Summaries")
|
||||
lines.append("")
|
||||
lane_rows = []
|
||||
for lane, count in sorted(by_lane.items()):
|
||||
lane_items = [item for item in items if item.get("owner_lane") == lane]
|
||||
lane_status = Counter(item.get("lifecycle_status") for item in lane_items)
|
||||
open_like = lane_status.get("active", 0) + lane_status.get("open", 0) + lane_status.get("done_verify", 0)
|
||||
lane_rows.append([
|
||||
LANE_TITLES.get(lane, lane),
|
||||
count,
|
||||
open_like,
|
||||
", ".join(f"`{k}` {v}" for k, v in sorted(lane_status.items())),
|
||||
])
|
||||
lines.extend(table(["Stream / lane", "Items", "Active+open+verify", "Lifecycle mix"], lane_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Source-Type Mix")
|
||||
lines.append("")
|
||||
lines.extend(table(["Source type", "Items"], [[f"`{k}`", v] for k, v in sorted(by_source.items())]))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Board Items by Stream")
|
||||
lines.append("")
|
||||
for lane in sorted(by_lane):
|
||||
lane_items = [item for item in items if item.get("owner_lane") == lane]
|
||||
lines.append(f"### {LANE_TITLES.get(lane, lane)}")
|
||||
lines.append("")
|
||||
lines.extend(table(
|
||||
["ID", "Title", "Source", "Bucket", "Lifecycle", "Verification", "Dependencies", "Deferral"],
|
||||
[[
|
||||
f"`{item.get('id')}`",
|
||||
str(item.get("title", "")).replace("|", "\\|"),
|
||||
f"`{item.get('source_anchor')}` / `{item.get('source_type')}`",
|
||||
f"`{item.get('release_bucket')}`",
|
||||
f"`{item.get('lifecycle_status')}`",
|
||||
f"`{item.get('verification_required')}`",
|
||||
fmt_list(item.get("dependencies")),
|
||||
str(item.get("deferral_rationale") or "—").replace("|", "\\|"),
|
||||
] for item in lane_items]
|
||||
))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("board_json", type=Path)
|
||||
parser.add_argument("board_md", type=Path)
|
||||
parser.add_argument("--check", action="store_true", help="fail if board_md is not up to date")
|
||||
args = parser.parse_args()
|
||||
|
||||
board = load_board(args.board_json)
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
rendered = render(board)
|
||||
if args.check:
|
||||
existing = args.board_md.read_text() if args.board_md.exists() else ""
|
||||
if existing != rendered:
|
||||
print(f"ERROR: {args.board_md} is not up to date", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS: {args.board_md} is up to date and roadmap coverage is complete")
|
||||
return 0
|
||||
args.board_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.board_md.write_text(rendered)
|
||||
print(f"wrote {args.board_md}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the worker-2 CC2 issue/parity intake fragment."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INTAKE = ROOT / ".omx" / "cc2" / "issue-parity-intake.json"
|
||||
REQUIRED_ISSUES = set(range(3028, 3039)) | {3007, 3006, 3020, 3005, 3003, 2997, 3023, 3004}
|
||||
ALLOWED_STATUS = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
ALLOWED_BUCKETS = {"alpha_blocker", "beta_adoption", "ga_ecosystem", "post_2_0_research"}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise SystemExit(f"FAIL: {message}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = json.loads(INTAKE.read_text())
|
||||
issue_rows = data.get("issue_clusters", [])
|
||||
parity_rows = data.get("parity_rows", [])
|
||||
|
||||
seen = {row.get("source_number") for row in issue_rows}
|
||||
missing = sorted(REQUIRED_ISSUES - seen)
|
||||
extra = sorted(seen - REQUIRED_ISSUES)
|
||||
require(not missing, f"missing required issue rows: {missing}")
|
||||
require(not extra, f"unexpected issue rows in scoped intake: {extra}")
|
||||
require(len(issue_rows) == len(REQUIRED_ISSUES), "duplicate or missing issue row count")
|
||||
|
||||
ids = [row.get("id") for row in issue_rows + parity_rows]
|
||||
require(len(ids) == len(set(ids)), "duplicate ids present")
|
||||
|
||||
for row in issue_rows + parity_rows:
|
||||
row_id = row.get("id")
|
||||
for field in ["source_anchor", "source_type", "release_bucket", "lifecycle_status", "dependencies", "verification_required"]:
|
||||
require(row.get(field) not in (None, "", []), f"{row_id} missing {field}")
|
||||
require(row["release_bucket"] in ALLOWED_BUCKETS, f"{row_id} invalid release_bucket {row['release_bucket']}")
|
||||
require(row["lifecycle_status"] in ALLOWED_STATUS, f"{row_id} invalid lifecycle_status {row['lifecycle_status']}")
|
||||
if row["lifecycle_status"] == "deferred_with_rationale":
|
||||
require(row.get("deferral_rationale"), f"{row_id} deferred without rationale")
|
||||
|
||||
require(len(parity_rows) >= data["coverage"]["parity_rows_expected_minimum"], "not enough parity rows")
|
||||
print(f"PASS issue/parity intake: {len(issue_rows)} issue rows, {len(parity_rows)} parity rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "b035f648d5b549aa836ea01f6727ec62",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "b234acb1eb8c486e80544ddc7e13e6d8",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "b67e062748f04e10ac5770df9285e4bd",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "bb88fd20433840a8b19237e3f306c6e3",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -192,6 +192,7 @@ cargo test --workspace
|
||||
- [`PARITY.md`](./PARITY.md) — parity status for the Rust port
|
||||
- [`rust/MOCK_PARITY_HARNESS.md`](./rust/MOCK_PARITY_HARNESS.md) — deterministic mock-service harness details
|
||||
- [`ROADMAP.md`](./ROADMAP.md) — active roadmap and open cleanup work
|
||||
- [`docs/g004-events-reports-contract.md`](./docs/g004-events-reports-contract.md) — Stream 2 lane event/report contract guidance for consumers
|
||||
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why the project exists and how it is operated
|
||||
|
||||
## Ecosystem
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# G002 alpha security map and verification plan
|
||||
|
||||
Generated by `worker-4` for OMX team task 5 on 2026-05-14.
|
||||
|
||||
## Scope and coordination
|
||||
|
||||
- Active goal context: `G002-alpha-security` / Stream 6 day-one security and permissions gate.
|
||||
- Worker ownership: `worker-1` owns minimal implementation changes for workspace/path enforcement. `worker-4` owns this repository map, integration verification plan, changed-file/commit report, and exact verification evidence.
|
||||
- Boundary: this report does not mutate `.omx/ultragoal` and does not edit shared security/path tests.
|
||||
- Parallel probe status: three native subagents were spawned for repository map, test probe, and change-slice probe, but all failed before returning findings with `429 Too Many Requests`; local mapping below is based on direct repository inspection.
|
||||
|
||||
## Current permission and path enforcement map
|
||||
|
||||
### Runtime permission policy and enforcer
|
||||
|
||||
- `rust/crates/runtime/src/permissions.rs`
|
||||
- Owns the `PermissionMode` ordering and `PermissionPolicy` authorization contract.
|
||||
- Existing tests cover read-only denial, workspace-write escalation, prompt approvals/denials, danger-full-access allowance, override recording, and required-mode reporting.
|
||||
- Integration risk: any new dynamic file/path rule must preserve the existing `PermissionPolicy::authorize` semantics so prompt/override audit events remain stable.
|
||||
|
||||
- `rust/crates/runtime/src/permission_enforcer.rs`
|
||||
- `PermissionEnforcer::check`, `check_with_required_mode`, `check_file_write`, and `check_bash` convert policy outcomes into structured `EnforcementResult` payloads.
|
||||
- `check_file_write` currently has the direct write gate for workspace-write mode.
|
||||
- `is_within_workspace` is a string-prefix boundary check after simple relative-path joining; it does not canonicalize symlinks, `..`, Windows drive prefixes, or case variants.
|
||||
- Existing tests cover read-only denial, workspace-write inside/outside paths, trailing slashes, root equality, bash read-only heuristics, prompt-mode denial payloads, and structured denied fields.
|
||||
|
||||
### File tool path handling
|
||||
|
||||
- `rust/crates/runtime/src/file_ops.rs`
|
||||
- `read_file`, `write_file`, and `edit_file` normalize paths before filesystem operations but do not themselves require a workspace root.
|
||||
- `read_file_in_workspace`, `write_file_in_workspace`, and `edit_file_in_workspace` exist as boundary-enforced wrappers.
|
||||
- `validate_workspace_boundary` canonicalizes through the caller-provided resolved path and checks `starts_with(workspace_root)`.
|
||||
- `is_symlink_escape` detects direct symlink escapes by comparing canonical target to canonical workspace root.
|
||||
- Search tools (`glob_search`, `grep_search`) derive walk roots and prune heavy directories, but they are separate from the write enforcement path.
|
||||
- Existing tests cover oversized/binary reads, workspace-boundary read rejection, symlink escape detection, glob brace expansion, ignored directories, and grep/glob behavior.
|
||||
|
||||
### Bash command validation
|
||||
|
||||
- `rust/crates/runtime/src/bash_validation.rs`
|
||||
- `validate_command` runs mode validation, sed validation, destructive warning checks, then path validation.
|
||||
- `validate_read_only` blocks write-like commands, state-modifying commands, write redirects, and mutating git subcommands in read-only mode.
|
||||
- `validate_mode` warns when workspace-write commands appear to target hard-coded system paths.
|
||||
- `validate_paths` warns for `../`, `~/`, and `$HOME` references; it is intentionally heuristic and does not resolve shell expansion or canonical targets.
|
||||
- Existing tests cover read-only blockers, destructive warnings, sed in-place blocking, path traversal/home warnings, command classification, and full pipeline allow/block/warn outcomes.
|
||||
|
||||
### Sandbox and diagnostics surfaces
|
||||
|
||||
- `rust/crates/runtime/src/sandbox.rs`
|
||||
- Owns container/sandbox status detection and workspace-only sandbox command construction.
|
||||
- Relevant for day-one security because sandbox status must not overstate filesystem isolation.
|
||||
|
||||
- `rust/crates/rusty-claude-cli/src/main.rs`
|
||||
- Owns CLI permission-mode parsing, direct JSON/text diagnostic output, `/permissions`, `/status`, `/doctor`, and command dispatch paths.
|
||||
- Existing CLI integration tests under `rust/crates/rusty-claude-cli/tests/` cover permission prompt scenarios and output-format contracts.
|
||||
|
||||
- `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`
|
||||
- End-to-end harness includes `bash_permission_prompt_approved`, `bash_permission_prompt_denied`, read/write file allow/deny, and plugin workspace-write scenarios.
|
||||
|
||||
## Existing G002-adjacent coverage
|
||||
|
||||
- Unit-level permission coverage:
|
||||
- `cargo test -p runtime permissions::tests`
|
||||
- `cargo test -p runtime permission_enforcer::tests`
|
||||
- `cargo test -p runtime bash_validation::tests`
|
||||
- `cargo test -p runtime file_ops::tests`
|
||||
|
||||
- CLI and integration coverage:
|
||||
- `cargo test -p rusty-claude-cli --test mock_parity_harness`
|
||||
- `cargo test -p rusty-claude-cli --test output_format_contract`
|
||||
- `cargo test -p rusty-claude-cli --test cli_flags_and_config_defaults`
|
||||
|
||||
- Board/report validation coverage:
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json`
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json`
|
||||
|
||||
## Recommended safe work slices
|
||||
|
||||
### Implementation lane (owned by worker-1 unless re-scoped)
|
||||
|
||||
1. Replace string-prefix workspace boundary checks with canonical path comparison in the runtime enforcement path.
|
||||
- Primary files: `rust/crates/runtime/src/permission_enforcer.rs`, possibly shared helper extraction from `rust/crates/runtime/src/file_ops.rs`.
|
||||
- Regression cases: `../` traversal, symlink escape, root prefix collision (`/workspace` vs `/workspacex`), relative paths, trailing slash root equality.
|
||||
|
||||
2. Ensure direct file tools call workspace-aware wrappers when active permission mode is `workspace-write`.
|
||||
- Primary files: likely `rust/crates/runtime/src/mcp_tool_bridge.rs` and/or the runtime tool execution bridge that calls `file_ops`.
|
||||
- Regression cases: direct read/write paths, missing parent creation, symlink parent escape, and error payload stability.
|
||||
|
||||
3. Keep bash validation as a warning/classification layer unless a real shell-expansion resolver is introduced.
|
||||
- Primary files: `rust/crates/runtime/src/bash_validation.rs`, `rust/crates/runtime/src/bash.rs`.
|
||||
- Risk: heuristic parsing cannot faithfully resolve shell expansion, globs, aliases, or platform-specific path rules; avoid claiming hard enforcement unless execution sandbox or command resolver proves it.
|
||||
|
||||
### Test lane (coordinate with worker-3/worker-1 before editing)
|
||||
|
||||
1. Add unit regressions close to each enforcement function before changing behavior.
|
||||
- `permission_enforcer.rs`: canonical path boundary and Windows-shaped path cases.
|
||||
- `file_ops.rs`: write/edit workspace wrappers with symlink parent escapes and missing file parent canonicalization.
|
||||
- `bash_validation.rs`: shell expansion/glob/path warnings remain warnings unless a resolver is introduced.
|
||||
|
||||
2. Add at least one integration test proving the runtime bridge actually routes file tools through workspace enforcement, not only helper functions.
|
||||
- Candidate: `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs` for direct write denial and no file created outside workspace.
|
||||
|
||||
3. Preserve existing prompt/event visibility tests.
|
||||
- Candidate surfaces: permission prompt scenarios in `mock_parity_harness.rs`, status/doctor JSON in `output_format_contract.rs`.
|
||||
|
||||
### Docs/reporting lane (owned by worker-4)
|
||||
|
||||
1. Keep this file as the integration handoff artifact for G002 mapping and verification.
|
||||
2. Report changed files and commits relative to `origin/main` so the leader can integrate worker branches deterministically.
|
||||
3. Include exact command evidence in the task lifecycle result.
|
||||
|
||||
## Changed files relative to `origin/main` at map time
|
||||
|
||||
The worktree currently contains these files added relative to `origin/main` before this task report:
|
||||
|
||||
- `.omx/cc2/board.json`
|
||||
- `.omx/cc2/board.md`
|
||||
- `.omx/cc2/issue-parity-intake.json`
|
||||
- `.omx/cc2/issue-parity-intake.md`
|
||||
- `.omx/cc2/render_board_md.py`
|
||||
- `.omx/cc2/validate_issue_parity_intake.py`
|
||||
- `scripts/cc2_board.py`
|
||||
- `scripts/generate_cc2_board.py`
|
||||
- `scripts/validate_cc2_board.py`
|
||||
|
||||
This task adds:
|
||||
|
||||
- `docs/g002-security-verification-map.md`
|
||||
|
||||
## Commits relative to `origin/main` at map time
|
||||
|
||||
- `8311655` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `c6e2a7d` — `omx(team): merge worker-1`
|
||||
- `481585f` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `74bbf4b` — `omx(team): auto-checkpoint worker-4 [unknown]`
|
||||
- `5c77896` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `07dad88` — `Classify issue and parity intake for CC2 board integration`
|
||||
- `424825f` — `task: G001 human board and docs rendering`
|
||||
- `d15268e` — `Create a canonical CC2 board so every frozen ROADMAP heading is verifiably mapped`
|
||||
- `45b43b5` — `Make the CC2 board schema executable for G001`
|
||||
|
||||
## Verification checklist for leader integration
|
||||
|
||||
Run these from the repository root unless noted:
|
||||
|
||||
1. Python board/schema validation:
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json`
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json`
|
||||
|
||||
2. Rust formatting and lint/type checks:
|
||||
- `scripts/fmt.sh --check`
|
||||
- `(cd rust && cargo check --workspace)`
|
||||
- `(cd rust && cargo clippy --workspace --all-targets -- -D warnings)`
|
||||
|
||||
3. Targeted G002 security tests:
|
||||
- `(cd rust && cargo test -p runtime permissions::tests permission_enforcer::tests bash_validation::tests file_ops::tests)`
|
||||
- `(cd rust && cargo test -p rusty-claude-cli --test mock_parity_harness)`
|
||||
|
||||
4. Full regression:
|
||||
- `(cd rust && cargo test --workspace)`
|
||||
|
||||
|
||||
## Worker-4 verification evidence (2026-05-14)
|
||||
|
||||
PASS:
|
||||
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json` → `PASS cc2 board validation`; 729 items; ROADMAP headings `124/124`; ROADMAP actions `542/542`.
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json` → `PASS issue/parity intake: 19 issue rows, 9 parity rows`.
|
||||
- `scripts/fmt.sh --check` → no output and zero exit before Rust checks continued.
|
||||
- `(cd rust && cargo check --workspace)` → `Finished dev profile` successfully.
|
||||
- `(cd rust && cargo test -p runtime permissions::tests)` → 9 passed.
|
||||
- `(cd rust && cargo test -p runtime permission_enforcer::tests)` → 21 passed.
|
||||
- `(cd rust && cargo test -p runtime bash_validation::tests)` → 32 passed.
|
||||
- `(cd rust && cargo test -p runtime file_ops::tests)` → 14 passed.
|
||||
- `(cd rust && cargo test -p rusty-claude-cli --test mock_parity_harness)` → 1 passed.
|
||||
|
||||
FAIL / integration blockers observed on this worktree:
|
||||
|
||||
- `(cd rust && cargo clippy --workspace --all-targets -- -D warnings)` failed in existing runtime code, not this docs-only task:
|
||||
- `rust/crates/runtime/src/compact.rs:215` / `:216`: `clippy::match_same_arms`.
|
||||
- `rust/crates/runtime/src/policy_engine.rs:5`: `clippy::duration-suboptimal-units`.
|
||||
- `rust/crates/runtime/src/sandbox.rs:295-302`: `clippy::map_unwrap_or`.
|
||||
- `(cd rust && cargo test --workspace)` failed after broad success in API/commands/plugins/runtime tests because `rusty-claude-cli` unit test `tests::session_lifecycle_prefers_running_process_over_idle_shell` asserted `RunningProcess` but observed `IdleShell`.
|
||||
- Rerun of the specific failing test confirmed deterministic failure: `(cd rust && cargo test -p rusty-claude-cli --bin claw tests::session_lifecycle_prefers_running_process_over_idle_shell -- --exact --nocapture)` → 0 passed, 1 failed with the same `IdleShell` vs `RunningProcess` assertion.
|
||||
|
||||
Recommended owner for failures: not `worker-4` unless re-scoped. These failures are outside the docs/report artifact and touch shared runtime/CLI implementation files.
|
||||
@@ -0,0 +1,96 @@
|
||||
# G003 boot/session/preflight verification map
|
||||
|
||||
Generated by `worker-1` for OMX team task 2 on 2026-05-14.
|
||||
|
||||
## Scope and coordination
|
||||
|
||||
- Active goal context: `G003-boot-session` / Stream 1 reliable worker boot and session control.
|
||||
- Boundary: this artifact is an audit/integration map only. It does not mutate `.omx/ultragoal` and it does not change shared implementation or tests.
|
||||
- Current worker split from leader mailbox:
|
||||
- `worker-1`: task 1 worker boot / prompt SLA plus this task 2 audit map.
|
||||
- `worker-2`: default trusted roots / trust resolver.
|
||||
- `worker-3`: startup-no-evidence classifier.
|
||||
- `worker-4`: session control plus preflight/doctor JSON surfaces.
|
||||
- Native subagent probes were attempted for Task 2 (`test probe` and `debug/root-cause probe`) but both failed before returning findings with `429 Too Many Requests`; the map below is based on direct repository inspection.
|
||||
|
||||
## Implementation surface map
|
||||
|
||||
### Worker boot lifecycle and prompt SLA
|
||||
|
||||
- `rust/crates/runtime/src/worker_boot.rs`
|
||||
- Core state types: `WorkerStatus`, `WorkerFailureKind`, `WorkerEventKind`, `WorkerEventPayload`, `StartupFailureClassification`, `StartupEvidenceBundle`, `WorkerTaskReceipt`, and `WorkerReadySnapshot`.
|
||||
- Control plane: `WorkerRegistry::{create,get,observe,resolve_trust,send_prompt,await_ready,restart,terminate,observe_completion,observe_startup_timeout}`.
|
||||
- Lifecycle states currently covered in code: `spawning`, `trust_required`, `tool_permission_required`, `ready_for_prompt`, `running`, `finished`, and `failed`.
|
||||
- Prompt delivery semantics currently use `Running` events and fields `prompt_in_flight`, `last_prompt`, `expected_receipt`, `replay_prompt`, and `prompt_delivery_attempts`.
|
||||
- Startup-no-evidence surface: `observe_startup_timeout` builds `StartupEvidenceBundle` and classifies trust, tool permission, prompt acceptance timeout, prompt misdelivery, transport death, worker crash, or unknown.
|
||||
- File observability surface: `emit_state_file` writes `.claw/worker-state.json` with status, readiness, trust state, prompt-in-flight flag, last event, and update age.
|
||||
|
||||
- `rust/crates/tools/src/lib.rs`
|
||||
- Tool APIs expose the worker control plane through `WorkerCreate`, `WorkerGet`, `WorkerObserve`, `WorkerResolveTrust`, `WorkerAwaitReady`, `WorkerSendPrompt`, `WorkerRestart`, `WorkerTerminate`, and `WorkerObserveCompletion`.
|
||||
- `WorkerCreate` merges `ConfigLoader::trusted_roots()` with per-call `trusted_roots` before calling `WorkerRegistry::create`.
|
||||
- Tool-level tests exercise worker create/observe/send/restart/terminate/completion and state-file transitions.
|
||||
|
||||
### Trust resolver and default trusted roots
|
||||
|
||||
- `rust/crates/runtime/src/trust_resolver.rs`
|
||||
- `TrustConfig`, `TrustAllowlistEntry`, and `TrustResolver` model trust prompts, allowlist/denylist policy, auto-trust, manual approval, and emitted trust events.
|
||||
- `path_matches_trusted_root` and internal `path_matches` canonicalize paths when possible.
|
||||
- Hazard: prefix matching must avoid accidental sibling matches such as `/tmp/work` matching `/tmp/work-evil`; worker-2 owns any changes here.
|
||||
|
||||
- `rust/crates/runtime/src/config.rs`
|
||||
- `trustedRoots` is parsed by `parse_optional_trusted_roots` and exposed through `RuntimeConfig::trusted_roots()` / feature config accessors.
|
||||
- Current default is empty when unset; any project default roots work belongs to worker-2.
|
||||
|
||||
### Session control
|
||||
|
||||
- `rust/crates/runtime/src/session_control.rs`
|
||||
- `SessionStore` namespaces sessions by canonical workspace fingerprint.
|
||||
- Key API: `from_cwd`, `from_data_dir`, `create_handle`, `resolve_reference`, `resolve_managed_path`, `list_sessions`, `latest_session`, `load_session`, and `fork_session`.
|
||||
- Guardrail: `validate_loaded_session` rejects cross-workspace sessions and allows legacy sessions only when their path remains inside the current workspace.
|
||||
- Worker-4 owns changes to this lane.
|
||||
|
||||
### CLI doctor/status/preflight and bootstrap-adjacent surfaces
|
||||
|
||||
- `rust/crates/commands/src/lib.rs`
|
||||
- Slash command definitions include `/status`, `/sandbox`, and `/doctor`.
|
||||
- JSON rendering for command surfaces exists through handler functions and tests in the same module.
|
||||
|
||||
- `rust/crates/tools/src/lib.rs`
|
||||
- Bash and PowerShell tool runners include `workspace_test_branch_preflight`, which returns structured output with `return_code_interpretation: preflight_blocked:branch_divergence` for broad workspace tests on stale branches.
|
||||
- Tests around `bash_workspace_tests_are_blocked_when_branch_is_behind_main` and targeted-test skipping protect this preflight behavior.
|
||||
|
||||
## Existing focused verification commands
|
||||
|
||||
Run from `rust/` unless noted.
|
||||
|
||||
- Worker boot runtime contract:
|
||||
- `cargo test -p runtime worker_boot -- --nocapture`
|
||||
- Worker tool API contract:
|
||||
- `cargo test -p tools worker_ -- --nocapture`
|
||||
- Session control contract:
|
||||
- `cargo test -p runtime session_control -- --nocapture`
|
||||
- Trust resolver/config trusted roots:
|
||||
- `cargo test -p runtime trust_resolver -- --nocapture`
|
||||
- `cargo test -p runtime config::tests::parses_trusted_roots_from_settings config::tests::trusted_roots_default_is_empty_when_unset -- --nocapture`
|
||||
- Preflight/tool branch guardrails:
|
||||
- `cargo test -p tools bash_workspace_tests_are_blocked_when_branch_is_behind_main bash_targeted_tests_skip_branch_preflight -- --nocapture`
|
||||
- Formatting/type/lint baseline:
|
||||
- `../scripts/fmt.sh --check`
|
||||
- `cargo check -p runtime -p tools -p commands`
|
||||
- `cargo clippy -p runtime -p tools -p commands --all-targets --no-deps -- -D warnings`
|
||||
|
||||
## Gaps and hazards for leader integration
|
||||
|
||||
- Prompt SLA event naming is partially implicit: `send_prompt` emits `WorkerEventKind::Running`; it does not expose separate `prompt.sent`, `prompt.accepted`, `prompt.acceptance_delayed`, or `prompt.acceptance_timeout` event names. The current equivalent evidence is `prompt_in_flight`, `Running`, `observe_completion`, and startup-timeout classification.
|
||||
- `StartupFailureClassification::PromptAcceptanceTimeout` is covered in `worker_boot` tests; full terminal/transport integration should still be verified by the leader or worker-3 if a real pane watcher exists outside the in-memory registry.
|
||||
- Default trusted roots are parsed and merged into `WorkerCreate`, but unset config currently means no default roots. Worker-2 owns any change to default root selection.
|
||||
- Session control protects workspace fingerprints at load/fork time; worker-4 owns CLI/doctor/preflight JSON contract changes.
|
||||
- Full-workspace clippy currently has known unrelated runtime findings observed during task 1 verification; do not block this docs-only map on those unless leader re-scopes cleanup.
|
||||
|
||||
## Recommended safe integration order
|
||||
|
||||
1. Integrate worker boot / prompt SLA changes first and run `cargo test -p runtime worker_boot -- --nocapture` plus `cargo test -p tools worker_ -- --nocapture`.
|
||||
2. Integrate trust-root changes and rerun trust/config tests plus the worker create config merge test.
|
||||
3. Integrate startup-no-evidence classifier changes and rerun `cargo test -p runtime worker_boot -- --nocapture`.
|
||||
4. Integrate session control / preflight / doctor JSON changes and rerun session-control, commands JSON, and preflight tests.
|
||||
5. Run final formatting, targeted cargo check/clippy, then broader workspace tests with known full-workspace failures documented separately.
|
||||
@@ -0,0 +1,67 @@
|
||||
# G004 event and report contract guidance
|
||||
|
||||
Captured: 2026-05-14 during the Stream 2 `G004-events-reports` team run.
|
||||
|
||||
Purpose: keep the user/developer-facing contract guidance for ROADMAP Phase 2 in one tracked source that points back to the code and roadmap anchors. This document is intentionally not the implementation map for task 5; it describes the interoperability contract consumers should rely on as the lane-event, report-schema, approval-token, and capability-negotiation lanes land.
|
||||
|
||||
## Source-of-truth anchors
|
||||
|
||||
| Contract family | Roadmap anchor | Current implementation / owner-facing anchor | Consumer guidance |
|
||||
| --- | --- | --- | --- |
|
||||
| Canonical lane events | `ROADMAP.md` Phase 2 §4, §4.5, §4.6, §4.7 | `rust/crates/runtime/src/lane_events.rs` (`LaneEventName`, `LaneEventStatus`, `LaneEventMetadata`, terminal reconciliation helpers) | Consume `event`, `status`, `emittedAt`, and `metadata` fields as the canonical state stream; do not infer lane state from terminal text when a structured event is present. |
|
||||
| Report schema v1 and projections | `ROADMAP.md` §4.25-§4.34 | Stream 2 report-schema lane / fixtures as they land | Treat a report as a versioned canonical payload plus derived projections. A projection may omit or transform fields only with explicit provenance: compatibility downgrade, redaction policy, truncation, or source absence. |
|
||||
| Policy-blocked handoff and approval-token chain | `ROADMAP.md` §4.37-§4.39 | Stream 2 approval-token lane as it lands | Treat policy blocks and owner approvals as typed artifacts, not prose. Execute an exception only when the approval token matches actor, policy, action, repo/branch/commit scope, expiry, and one-time-use state. |
|
||||
| Capability negotiation | `ROADMAP.md` §4.25, §4.26, §4.32, §4.34 | Report-schema/projection fixtures and consumer conformance cases as they land | Consumers must advertise supported schema versions, optional field families, projection views, redaction semantics, and downgrade handling before relying on reduced payloads. |
|
||||
|
||||
## Lane event contract
|
||||
|
||||
The lane-event stream is the first machine-trustworthy surface for Stream 2. Consumers should expect these invariants when reading `LaneEvent` payloads:
|
||||
|
||||
- `event` is a typed event name, currently including the core lane lifecycle (`lane.started`, `lane.ready`, `lane.blocked`, `lane.red`, `lane.green`, `lane.finished`, `lane.failed`), branch health (`branch.stale_against_main`, `branch.workspace_mismatch`), reconciliation (`lane.reconciled`, `lane.superseded`, `lane.closed`), and ship provenance (`ship.prepared`, `ship.commits_selected`, `ship.merged`, `ship.pushed_main`).
|
||||
- `status` is the normalized state for the event; consumers should prefer it over freeform `detail` text for automation.
|
||||
- `metadata.seq`, `metadata.timestamp_ms`, and terminal fingerprints are the ordering/deduplication hooks. Consumers should use terminal reconciliation output rather than double-reporting contradictory terminal bursts.
|
||||
- `metadata.provenance`, `metadata.environment_label`, `metadata.emitter_identity`, and `metadata.confidence_level` tell consumers whether an event is live lane truth, test traffic, healthcheck/replay output, or transport-layer evidence.
|
||||
- `metadata.session_identity` and `metadata.ownership` bind a lane event to the session, workspace, workflow scope, owner, and watcher action. A watcher should not act on events whose ownership says `observe` or `ignore`.
|
||||
|
||||
Minimal consumer rule: if a structured event exists, pane text is supporting evidence only. Pane scraping must not override a higher-confidence typed event with matching session/workflow ownership.
|
||||
|
||||
## Report schema v1 contract
|
||||
|
||||
A Stream 2 report should be treated as a canonical fact record with optional projections. Consumers should preserve these semantics even when they receive only a downgraded view:
|
||||
|
||||
- Every report payload declares a schema version and a stable report identity/content hash for the full-fidelity canonical payload.
|
||||
- Assertions are labeled as `fact`, `hypothesis`, or another declared evidence class, with confidence and source references. Negative evidence is first-class: `not observed`, `checked and absent`, and `redacted` are distinct states.
|
||||
- Field deltas name the field, previous value/state, new value/state, attribution, and whether the delta came from source content, projection, downgrade, or redaction policy.
|
||||
- Projections carry lineage back to the canonical report id/content hash and name the projection view, capability set, schema version, redaction policy, and deterministic rendering inputs.
|
||||
- Redaction provenance is explicit. A missing field without a redaction/downgrade/source-absence reason is not enough evidence for an automated consumer to conclude the underlying fact is absent.
|
||||
|
||||
Minimal consumer rule: store the canonical identity and projection metadata together. Do not compare two projections as state changes unless their canonical content hash or declared projection inputs differ.
|
||||
|
||||
## Approval-token and policy-blocked contract
|
||||
|
||||
Policy-blocked actions and owner-approved exceptions belong in the same structured event/report family:
|
||||
|
||||
- A policy block names the typed reason, policy source, actor scope, blocked action, and safe fallback path.
|
||||
- An approval token names the approving actor, policy exception, action, repository/worktree/branch/commit scope, expiry, and allowed use count.
|
||||
- Token consumption records the exact action and scope that spent the token. Replays, scope expansion, expired tokens, and revoked tokens should surface typed policy errors.
|
||||
- Delegation traceability stays attached when another worker/lane executes the approved action; the executor must be able to prove which approval artifact authorized the exception.
|
||||
|
||||
Minimal consumer rule: prose such as "approved" is not an executable approval. Require the structured token and verify that it is unconsumed and scoped to the exact action before proceeding.
|
||||
|
||||
## Capability negotiation and conformance
|
||||
|
||||
Mixed-version consumers are expected during Stream 2 rollout. Producers and consumers should negotiate instead of silently dropping fields:
|
||||
|
||||
- Consumers advertise supported report schema versions, field families, projection views, redaction states, downgrade semantics, and fixture/conformance suite version.
|
||||
- Producers preserve one canonical full-fidelity report and emit downgraded projections only with `downgraded_for_compatibility` metadata.
|
||||
- Deterministic projection inputs include schema version, consumer capability set, projection policy version, redaction policy version, and canonical content hash.
|
||||
- Consumer conformance should distinguish syntax acceptance from semantic correctness, especially for `redacted` vs `missing`, stale vs current projections, negative evidence, and approval-token replay states.
|
||||
|
||||
Minimal consumer rule: an older consumer may accept a downgraded projection, but it must surface the downgrade as a capability limitation rather than treating omitted fields as canonical absence.
|
||||
|
||||
## Documentation maintenance rules
|
||||
|
||||
- Keep ROADMAP Phase 2 as the product requirement source and this file as the contract-reading guide.
|
||||
- Keep Rust type names and event names aligned with `rust/crates/runtime/src/lane_events.rs`; update this document in the same change when public event names or metadata semantics change.
|
||||
- Keep report-schema examples/fixtures aligned with this guide once the schema lane lands; fixture updates should explain intentional schema or projection changes.
|
||||
- Do not mutate `.omx/ultragoal` from worker lanes. Leader-owned Ultragoal checkpointing consumes commits and verification evidence from task results.
|
||||
@@ -0,0 +1,57 @@
|
||||
# G004 events/reports verification map
|
||||
|
||||
Scope source: OMX team `g004-events-reports-u-e61d2271`, worker-1 tasks 1, 2, 4, 5. Workers must not mutate `.omx/ultragoal`; leader owns aggregate checkpoints.
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **Lane events / event identity / terminal reconciliation** — `rust/crates/runtime/src/lane_events.rs`, exported through `rust/crates/runtime/src/lib.rs`; tool-manifest consumers in `rust/crates/tools/src/lib.rs` write `LaneEvent` vectors.
|
||||
- **Report schema v1 / projection / redaction / capability negotiation** — `rust/crates/runtime/src/report_schema.rs`, exported through `rust/crates/runtime/src/lib.rs`; fixture note at `rust/crates/runtime/tests/fixtures/report_schema_v1/README.md`.
|
||||
- **Approval-token chain** — ROADMAP §§4.38-4.40; owned by worker-2 for this team split. Worker-1 did not edit it.
|
||||
- **Pinpoint closure batch** — runtime hygiene across compact/search-parser/policy/sandbox/integration-test surfaces: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/file_ops.rs`, `rust/crates/runtime/src/policy_engine.rs`, `rust/crates/runtime/src/sandbox.rs`, `rust/crates/runtime/tests/integration_tests.rs`.
|
||||
- **Regression harness / docs alignment** — worker-3/worker-4 lanes per leader split. Coordinate before editing shared docs/tests.
|
||||
|
||||
## Relevant symbols and files
|
||||
|
||||
- `LaneEventName`, `LaneEventStatus`, `LaneEventMetadata`, `LaneEventBuilder`, `compute_event_fingerprint`, `dedupe_terminal_events`, `reconcile_terminal_events` in `runtime/src/lane_events.rs`.
|
||||
- `CanonicalReportV1`, `ReportClaim`, `NegativeEvidence`, `FieldDelta`, `ConsumerCapabilities`, `ReportProjectionV1`, `canonicalize_report`, `project_report`, `report_schema_v1_registry` in `runtime/src/report_schema.rs`.
|
||||
- `AgentOutput.lane_events`, `persist_agent_terminal_state`, `write_agent_manifest`, `maybe_commit_provenance` in `tools/src/lib.rs`.
|
||||
- Search/parser closure helpers: `summarize_messages` in `compact.rs`, `grep_search_impl` / `build_grep_content_output` in `file_ops.rs`.
|
||||
|
||||
## Completed worker-1 commits
|
||||
|
||||
- `f45f05e` / task 1 auto-checkpoint — terminal event fingerprints use stable SHA-256-derived canonical JSON, and production convenience terminal events attach/refresh fingerprints after payload changes.
|
||||
- `3989fc0` — report schema v1 contract, deterministic projection/redaction provenance, capability negotiation, and fixture note.
|
||||
- `7fff4c4` / task 4 auto-checkpoint — strict runtime clippy closure batch across compact/file_ops/policy/sandbox/integration tests.
|
||||
|
||||
## Current verification evidence
|
||||
|
||||
Run from `rust/` unless noted:
|
||||
|
||||
- `cargo test -p runtime lane_events -- --nocapture` — PASS, 46 lane-event tests.
|
||||
- `cargo test -p runtime report_schema -- --nocapture` — PASS, 4 report-schema tests.
|
||||
- `cargo check -p runtime` — PASS.
|
||||
- `cargo clippy -p runtime --all-targets -- -D warnings` — PASS after task 4 closure batch.
|
||||
- `cargo test -p runtime -- --nocapture` — PASS, 531 unit tests, 12 integration tests, doc-tests pass.
|
||||
- `cargo test -p tools lane_event_schema_serializes_to_canonical_names -- --nocapture` — PASS, 1 targeted tools contract test.
|
||||
|
||||
## Leader integration verification plan
|
||||
|
||||
1. Inspect worker commits: `git log --oneline --decorate --max-count=8`.
|
||||
2. Re-run focused contracts:
|
||||
- `cd rust && cargo test -p runtime lane_events -- --nocapture`
|
||||
- `cd rust && cargo test -p runtime report_schema -- --nocapture`
|
||||
- `cd rust && cargo test -p tools lane_event_schema_serializes_to_canonical_names -- --nocapture`
|
||||
3. Re-run runtime quality gate:
|
||||
- `cd rust && cargo check -p runtime`
|
||||
- `cd rust && cargo clippy -p runtime --all-targets -- -D warnings`
|
||||
- `cd rust && cargo test -p runtime -- --nocapture`
|
||||
4. If merging with worker-2 approval-token work, additionally run the worker-2 focused approval-token tests and check for export conflicts in `runtime/src/lib.rs`.
|
||||
5. If merging with worker-3/4 docs or harness work, re-run their named regression harnesses plus `git diff --check`.
|
||||
|
||||
## Integration hazards
|
||||
|
||||
- `runtime/src/lib.rs` export blocks are shared; resolve conflicts by keeping both lane-event and report-schema exports sorted enough to remain readable.
|
||||
- `tools/src/lib.rs` serializes lane events into agent manifests; terminal fingerprint changes intentionally affect `metadata.event_fingerprint` for finished/failed/superseded/merged/closed events with payloads.
|
||||
- `report_schema.rs` currently defines the reusable contract and in-code deterministic fixtures; it does not yet wire report emission into CLI/status surfaces.
|
||||
- ROADMAP approval-token §§4.38-4.40 remain a separate lane; do not treat worker-1 report schema as an approval artifact.
|
||||
- Full workspace checks may include unrelated slow/provider-dependent tests; the verified local gate for this stream is runtime + targeted tools tests above.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Claw Code 2.0 PR and Issue Resolution Gate
|
||||
|
||||
This gate was added to the Claw Code 2.0 Ultragoal after the explicit requirement:
|
||||
|
||||
> all PRs should be merged and all issues should be resolved if resolvable and correct.
|
||||
|
||||
## Scope
|
||||
|
||||
Before the Claw Code 2.0 Ultragoal can be marked complete:
|
||||
|
||||
1. Every open GitHub PR at the current final-gate snapshot must be triaged.
|
||||
2. PRs that are correct, compatible with Claw Code 2.0 direction, and pass required verification must be merged.
|
||||
3. PRs that are stale, incorrect, duplicative, unsafe, spam, or outside Claw Code scope must not be merged; each needs a recorded rationale.
|
||||
4. Every open GitHub issue at the current final-gate snapshot must be triaged.
|
||||
5. Issues that are resolvable and correct must be fixed or explicitly linked to a merged fix.
|
||||
6. Issues that are spam, duplicates, incorrect, unactionable, externally blocked, or not Claw Code work must be closed or labeled/commented with rationale when repository policy allows.
|
||||
7. The final completion audit must use a fresh GitHub snapshot, not only the planning snapshot.
|
||||
|
||||
## Current live snapshot
|
||||
|
||||
A live snapshot was captured locally during G002 execution:
|
||||
|
||||
- PR snapshot: `.omx/research/github-live/open-prs.json`
|
||||
- Issue snapshot: `.omx/research/github-live/open-issues.json`
|
||||
- Captured on: 2026-05-14 during the active Ultragoal run.
|
||||
- Observed counts: 50 open PR records and 1000 open issue records from GitHub CLI list calls.
|
||||
|
||||
These local `.omx/research/github-live/*` files are evidence inputs, not final proof. The final gate must refresh them and compare deltas.
|
||||
|
||||
## Required final evidence
|
||||
|
||||
The final report must include:
|
||||
|
||||
- Fresh `gh pr list --state open` and `gh issue list --state open` snapshots.
|
||||
- A PR ledger with one row per PR: merge / reject / defer, reason, verification, commit/merge reference.
|
||||
- An issue ledger with one row per issue: fixed / duplicate / spam / invalid / deferred-with-rationale / externally-blocked, reason, and linked evidence.
|
||||
- Verification that no correct, mergeable PR remains unmerged without rationale.
|
||||
- Verification that no resolvable, correct issue remains open without a fix or rationale.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This gate does not require merging unsafe, unverified, incompatible, spam, or incorrect contributions. It requires explicit evidence-backed triage and action for everything that is correct and resolvable.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Roadmap PR goal intake
|
||||
|
||||
Captured: 2026-05-14 (Asia/Seoul) during the Claw Code 2.0 Ultragoal run.
|
||||
|
||||
Purpose: make the user's follow-up requirement durable: all roadmap PRs should be merged when correct/resolvable, and unresolved roadmap deltas should become Ultragoal work rather than being lost. This file is a tracked companion to the leader-owned `.omx/ultragoal/goals.json` and `.omx/ultragoal/ledger.jsonl` artifacts.
|
||||
|
||||
## Merge policy
|
||||
|
||||
- Merge only PRs that are still relevant to Claw Code 2.0, are non-draft, target `main`, and are conflict-free after a fresh mergeability refresh.
|
||||
- Prefer squash merges with a Lore-style body when GitHub allows a direct PR merge.
|
||||
- If a PR is documentation-only but adds a real roadmap gap, merging it is acceptable once checks/conflicts are clean.
|
||||
- If a PR is stale, duplicated by already-landed work, or not product-aligned, do not force-merge; record the rationale and map any still-correct requirement into G011/G012.
|
||||
- After merging roadmap PRs, refresh generated board artifacts (`.omx/cc2/board.json`, `.omx/cc2/board.md`) so Stream 0 coverage stays current.
|
||||
|
||||
## Open roadmap PRs with green historical checks
|
||||
|
||||
These are first-pass merge candidates, pending fresh mergeability and conflict checks against current `main`.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2848 | docs(roadmap): add #333 — no in-session settings inspect command | `docs/roadmap-333-no-settings-inspect-command` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2848 |
|
||||
| #2846 | docs(roadmap): add #331 — export silently overwrites on repeated invocations | `docs/roadmap-331-export-filename-collision` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2846 |
|
||||
| #2869 | docs(roadmap): add #358 — history entries missing role field, no pagination | `docs/roadmap-348-history-entries-missing-role` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2869 |
|
||||
| #2850 | docs(roadmap): add #335 — session list omits created_at_ms field | `docs/roadmap-335-session-list-no-created-at` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2850 |
|
||||
| #2868 | docs(roadmap): add #356 — session list title always null; no rename command | `docs/roadmap-347-session-list-title-always-null` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2868 |
|
||||
| #2865 | docs(roadmap): add #362 — doctor auth false-positive: misses CLI session tokens | `docs/roadmap-345-doctor-auth-check-incomplete` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2865 |
|
||||
| #2864 | docs(roadmap): add #364 — /cost returns no cost_usd; identical to /stats | `docs/roadmap-344-cost-command-no-dollar-amount` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2864 |
|
||||
| #2867 | docs(roadmap): add #368 — export always appends .txt; response.file reflects mangled path | `docs/roadmap-346-export-forces-txt-extension` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2867 |
|
||||
| #2862 | docs(roadmap): add #342 — status json omits active session ID, workspace counters ambiguous | `docs/roadmap-342-v2` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2862 |
|
||||
| #2876 | docs(roadmap): add #354 — /cwd suggests itself in did-you-mean; self-referential loop | `docs/roadmap-354-cwd-self-referential-suggestion` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2876 |
|
||||
| #2872 | docs(roadmap): add #360 — /tokens, /stats, /cost identical output; no context-window or cost_usd | `docs/roadmap-349-tokens-stats-cost-identical` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2872 |
|
||||
|
||||
## Open roadmap PRs needing local validation or CI refresh
|
||||
|
||||
These have no check rollup in the live snapshot; validate locally or refresh CI before merging.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2858 | docs(roadmap): add #343 — session subcommand resume-safety inconsistently enforced | `docs/roadmap-340-session-resume-safe-inconsistent` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2858 |
|
||||
| #2839 | docs(roadmap): add #330 — resume mode stats/cost always zero | `docs/roadmap-324-resume-stats-zero` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2839 |
|
||||
| #2841 | docs(roadmap): add #332 — doctor json missing top-level status field | `docs/roadmap-325-doctor-no-status-field` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2841 |
|
||||
| #2844 | docs(roadmap): add #336 — session subcommand resume inconsistency and type/kind error mismatch | `docs/roadmap-329-session-subcommand-resume-inconsistency` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2844 |
|
||||
| #2842 | docs(roadmap): add #334 — version json omits build_date and uses short sha only | `docs/roadmap-328-version-json-incomplete` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2842 |
|
||||
|
||||
## Product-fit review before merge
|
||||
|
||||
These may be broader than the Claw Code 2.0 roadmap scope and need a product-fit decision before merge.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2824 | docs: personal assistant roadmap | `pr/docs-personal-assistant-roadmap` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2824 |
|
||||
|
||||
## Ultragoal mapping
|
||||
|
||||
- G003-G010: close implementation gaps that overlap a roadmap PR title if the requirement belongs to the active stream.
|
||||
- G011: reconcile ecosystem/ops/UX roadmap PRs and unresolved correct issues that do not fit earlier streams.
|
||||
- G012: final release gate must prove that every open roadmap PR was merged, closed as duplicate/obsolete, or converted into an explicit remaining goal with evidence.
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Machine-readable policy exception scope that an approval token may override.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalScope {
|
||||
pub policy: String,
|
||||
pub action: String,
|
||||
pub repository: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
impl ApprovalScope {
|
||||
#[must_use]
|
||||
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
|
||||
Self {
|
||||
policy: policy.into(),
|
||||
action: action.into(),
|
||||
repository: None,
|
||||
branch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_repository(mut self, repository: impl Into<String>) -> Self {
|
||||
self.repository = Some(repository.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
|
||||
self.branch = Some(branch.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor/session hop recorded when an approval is delegated or consumed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalDelegationHop {
|
||||
pub actor: String,
|
||||
pub session_id: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl ApprovalDelegationHop {
|
||||
#[must_use]
|
||||
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
actor: actor.into(),
|
||||
session_id: None,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Current lifecycle state for a policy-exception approval token.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalTokenStatus {
|
||||
Pending,
|
||||
Granted,
|
||||
Consumed,
|
||||
Expired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl ApprovalTokenStatus {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "approval_pending",
|
||||
Self::Granted => "approval_granted",
|
||||
Self::Consumed => "approval_consumed",
|
||||
Self::Expired => "approval_expired",
|
||||
Self::Revoked => "approval_revoked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed policy errors returned when a token cannot authorize a blocked action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ApprovalTokenError {
|
||||
NoApproval,
|
||||
ApprovalPending,
|
||||
ApprovalExpired,
|
||||
ApprovalRevoked,
|
||||
ApprovalAlreadyConsumed,
|
||||
ScopeMismatch {
|
||||
expected: Box<ApprovalScope>,
|
||||
actual: Box<ApprovalScope>,
|
||||
},
|
||||
UnauthorizedDelegate {
|
||||
expected: String,
|
||||
actual: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApprovalTokenError {
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NoApproval => "no_approval",
|
||||
Self::ApprovalPending => "approval_pending",
|
||||
Self::ApprovalExpired => "approval_expired",
|
||||
Self::ApprovalRevoked => "approval_revoked",
|
||||
Self::ApprovalAlreadyConsumed => "approval_already_consumed",
|
||||
Self::ScopeMismatch { .. } => "approval_scope_mismatch",
|
||||
Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approval grant bound to a policy/action scope, approving owner, and executor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalTokenGrant {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub approved_executor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub expires_at_epoch_seconds: Option<u64>,
|
||||
pub max_uses: u32,
|
||||
pub uses: u32,
|
||||
delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenGrant {
|
||||
#[must_use]
|
||||
pub fn pending(
|
||||
token: impl Into<String>,
|
||||
scope: ApprovalScope,
|
||||
approving_actor: impl Into<String>,
|
||||
approved_executor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
token: token.into(),
|
||||
scope,
|
||||
approving_actor: approving_actor.into(),
|
||||
approved_executor: approved_executor.into(),
|
||||
status: ApprovalTokenStatus::Pending,
|
||||
expires_at_epoch_seconds: None,
|
||||
max_uses: 1,
|
||||
uses: 0,
|
||||
delegation_chain: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(
|
||||
token: impl Into<String>,
|
||||
scope: ApprovalScope,
|
||||
approving_actor: impl Into<String>,
|
||||
approved_executor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::pending(token, scope, approving_actor, approved_executor).approve()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approve(mut self) -> Self {
|
||||
self.status = ApprovalTokenStatus::Granted;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expires_at(mut self, epoch_seconds: u64) -> Self {
|
||||
self.expires_at_epoch_seconds = Some(epoch_seconds);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_uses(mut self, max_uses: u32) -> Self {
|
||||
self.max_uses = max_uses.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self {
|
||||
self.delegation_chain.push(hop);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] {
|
||||
&self.delegation_chain
|
||||
}
|
||||
}
|
||||
|
||||
/// Auditable result of verifying or consuming an approval token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalTokenAudit {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub executing_actor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub delegated_execution: bool,
|
||||
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
pub uses: u32,
|
||||
pub max_uses: u32,
|
||||
}
|
||||
|
||||
/// In-memory approval-token ledger with one-time-use and replay protection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ApprovalTokenLedger {
|
||||
grants: BTreeMap<String, ApprovalTokenGrant>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenLedger {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, grant: ApprovalTokenGrant) {
|
||||
self.grants.insert(grant.token.clone(), grant);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> {
|
||||
self.grants.get(token)
|
||||
}
|
||||
|
||||
pub fn revoke(&mut self, token: &str) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
grant.status = ApprovalTokenStatus::Revoked;
|
||||
Ok(Self::audit_for(grant, &grant.approved_executor))
|
||||
}
|
||||
|
||||
pub fn verify(
|
||||
&self,
|
||||
token: &str,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
||||
Ok(Self::audit_for(grant, executing_actor))
|
||||
}
|
||||
|
||||
pub fn consume(
|
||||
&mut self,
|
||||
token: &str,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
||||
grant.uses += 1;
|
||||
if grant.uses >= grant.max_uses {
|
||||
grant.status = ApprovalTokenStatus::Consumed;
|
||||
}
|
||||
Ok(Self::audit_for(grant, executing_actor))
|
||||
}
|
||||
|
||||
fn validate_grant(
|
||||
grant: &ApprovalTokenGrant,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), ApprovalTokenError> {
|
||||
match grant.status {
|
||||
ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending),
|
||||
ApprovalTokenStatus::Consumed => {
|
||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
||||
}
|
||||
ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired),
|
||||
ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked),
|
||||
ApprovalTokenStatus::Granted => {}
|
||||
}
|
||||
|
||||
if grant
|
||||
.expires_at_epoch_seconds
|
||||
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
|
||||
{
|
||||
return Err(ApprovalTokenError::ApprovalExpired);
|
||||
}
|
||||
|
||||
if grant.uses >= grant.max_uses {
|
||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed);
|
||||
}
|
||||
|
||||
if grant.scope != *scope {
|
||||
return Err(ApprovalTokenError::ScopeMismatch {
|
||||
expected: Box::new(grant.scope.clone()),
|
||||
actual: Box::new(scope.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
if grant.approved_executor != executing_actor {
|
||||
return Err(ApprovalTokenError::UnauthorizedDelegate {
|
||||
expected: grant.approved_executor.clone(),
|
||||
actual: executing_actor.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit {
|
||||
let mut delegation_chain = grant.delegation_chain.clone();
|
||||
if delegation_chain.is_empty() {
|
||||
delegation_chain.push(ApprovalDelegationHop::new(
|
||||
grant.approving_actor.clone(),
|
||||
"approval granted",
|
||||
));
|
||||
}
|
||||
if grant.approving_actor != executing_actor
|
||||
&& !delegation_chain
|
||||
.iter()
|
||||
.any(|hop| hop.actor == executing_actor)
|
||||
{
|
||||
delegation_chain.push(ApprovalDelegationHop::new(
|
||||
executing_actor.to_string(),
|
||||
"delegated execution",
|
||||
));
|
||||
}
|
||||
|
||||
ApprovalTokenAudit {
|
||||
token: grant.token.clone(),
|
||||
scope: grant.scope.clone(),
|
||||
approving_actor: grant.approving_actor.clone(),
|
||||
executing_actor: executing_actor.to_string(),
|
||||
status: grant.status,
|
||||
delegated_execution: grant.approving_actor != executing_actor,
|
||||
delegation_chain,
|
||||
uses: grant.uses,
|
||||
max_uses: grant.max_uses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant,
|
||||
ApprovalTokenLedger, ApprovalTokenStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn approval_token_blocks_until_owner_grants_policy_exception() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("main");
|
||||
ledger.insert(ApprovalTokenGrant::pending(
|
||||
"tok-pending",
|
||||
scope.clone(),
|
||||
"repo-owner",
|
||||
"release-bot",
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-missing", &scope, "release-bot", 10),
|
||||
Err(ApprovalTokenError::NoApproval)
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-pending", &scope, "release-bot", 10),
|
||||
Err(ApprovalTokenError::ApprovalPending)
|
||||
));
|
||||
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-granted",
|
||||
scope.clone(),
|
||||
"repo-owner",
|
||||
"release-bot",
|
||||
));
|
||||
let audit = ledger
|
||||
.verify("tok-granted", &scope, "release-bot", 10)
|
||||
.expect("owner approval should verify");
|
||||
|
||||
assert_eq!(audit.status, ApprovalTokenStatus::Granted);
|
||||
assert_eq!(audit.approving_actor, "repo-owner");
|
||||
assert_eq!(audit.executing_actor, "release-bot");
|
||||
assert!(audit.delegated_execution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_is_one_time_use_and_rejects_replay() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("release_requires_owner", "release publish")
|
||||
.with_repository("sisyphus/claw-code");
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-once",
|
||||
scope.clone(),
|
||||
"owner",
|
||||
"release-bot",
|
||||
));
|
||||
|
||||
let first = ledger
|
||||
.consume("tok-once", &scope, "release-bot", 10)
|
||||
.expect("first use should consume token");
|
||||
assert_eq!(first.status, ApprovalTokenStatus::Consumed);
|
||||
assert_eq!(first.uses, 1);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.consume("tok-once", &scope, "release-bot", 11),
|
||||
Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
||||
));
|
||||
assert_eq!(
|
||||
ledger.get("tok-once").map(|grant| grant.status),
|
||||
Some(ApprovalTokenStatus::Consumed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_rejects_scope_expansion_expiry_and_revocation() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("main");
|
||||
let dev_scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("dev");
|
||||
|
||||
ledger.insert(
|
||||
ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot")
|
||||
.expires_at(20),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-expiring", &dev_scope, "bot", 10),
|
||||
Err(ApprovalTokenError::ScopeMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-expiring", &scope, "bot", 21),
|
||||
Err(ApprovalTokenError::ApprovalExpired)
|
||||
));
|
||||
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-revoked",
|
||||
scope.clone(),
|
||||
"owner",
|
||||
"bot",
|
||||
));
|
||||
let revoked = ledger
|
||||
.revoke("tok-revoked")
|
||||
.expect("revocation should be audited");
|
||||
assert_eq!(revoked.status, ApprovalTokenStatus::Revoked);
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-revoked", &scope, "bot", 10),
|
||||
Err(ApprovalTokenError::ApprovalRevoked)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_preserves_delegation_traceability() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod");
|
||||
ledger.insert(
|
||||
ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot")
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("owner", "owner approval")
|
||||
.with_session_id("session-owner"),
|
||||
)
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot")
|
||||
.with_session_id("session-lead"),
|
||||
),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-delegated", &scope, "unexpected-bot", 10),
|
||||
Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual })
|
||||
if expected == "deploy-bot" && actual == "unexpected-bot"
|
||||
));
|
||||
|
||||
let audit = ledger
|
||||
.consume("tok-delegated", &scope, "deploy-bot", 10)
|
||||
.expect("approved delegate should consume token");
|
||||
let actors = audit
|
||||
.delegation_chain
|
||||
.iter()
|
||||
.map(|hop| hop.actor.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(audit.delegated_execution);
|
||||
assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[0].session_id.as_deref(),
|
||||
Some("session-owner")
|
||||
);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[1].session_id.as_deref(),
|
||||
Some("session-lead")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,8 +212,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||
ContentBlock::Text { .. } => None,
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tool_names.sort_unstable();
|
||||
|
||||
@@ -414,6 +414,17 @@ impl RuntimeConfig {
|
||||
pub fn trusted_roots(&self) -> &[String] {
|
||||
&self.feature_config.trusted_roots
|
||||
}
|
||||
|
||||
/// Merge config-level default trusted roots with per-call roots.
|
||||
///
|
||||
/// Config roots are defaults and are kept first; per-call roots extend the
|
||||
/// allowlist for a specific worker/session creation request. Duplicates are
|
||||
/// removed without reordering the first occurrence so evidence remains
|
||||
/// deterministic while avoiding repeated trust checks.
|
||||
#[must_use]
|
||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
@@ -483,6 +494,22 @@ impl RuntimeFeatureConfig {
|
||||
pub fn trusted_roots(&self) -> &[String] {
|
||||
&self.trusted_roots
|
||||
}
|
||||
|
||||
/// Merge this config's default trusted roots with per-call roots.
|
||||
#[must_use]
|
||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec<String> {
|
||||
let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len());
|
||||
for root in config_roots.iter().chain(per_call_roots.iter()) {
|
||||
if !merged.contains(root) {
|
||||
merged.push(root.clone());
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
impl ProviderFallbackConfig {
|
||||
@@ -1245,8 +1272,8 @@ fn push_unique(target: &mut Vec<String>, value: String) {
|
||||
mod tests {
|
||||
use super::{
|
||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
|
||||
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
||||
RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::sandbox::FilesystemIsolationMode;
|
||||
@@ -1502,6 +1529,51 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_roots_with_overrides_preserves_config_defaults_and_adds_per_call_roots() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"trustedRoots": ["/tmp/config-default", "/tmp/shared"]}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
// when
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
let merged = loaded.trusted_roots_with_overrides(&[
|
||||
"/tmp/per-call".to_string(),
|
||||
"/tmp/shared".to_string(),
|
||||
]);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
merged,
|
||||
["/tmp/config-default", "/tmp/shared", "/tmp/per-call"]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_feature_trusted_roots_with_overrides_matches_runtime_config_merge() {
|
||||
let config = RuntimeFeatureConfig {
|
||||
trusted_roots: vec!["/tmp/config".to_string()],
|
||||
..RuntimeFeatureConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.trusted_roots_with_overrides(&["/tmp/per-call".to_string()]),
|
||||
["/tmp/config", "/tmp/per-call"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_roots_default_is_empty_when_unset() {
|
||||
// given
|
||||
|
||||
@@ -307,11 +307,23 @@ pub fn edit_file(
|
||||
|
||||
/// Expands a glob pattern and returns matching filenames.
|
||||
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
||||
glob_search_impl(pattern, path, None)
|
||||
}
|
||||
|
||||
fn glob_search_impl(
|
||||
pattern: &str,
|
||||
path: Option<&str>,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> io::Result<GlobSearchOutput> {
|
||||
let started = Instant::now();
|
||||
let base_dir = path
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let canonical_root = workspace_root.map(canonicalize_workspace_root);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
validate_workspace_boundary(&base_dir, root)?;
|
||||
}
|
||||
let search_pattern = if Path::new(pattern).is_absolute() {
|
||||
pattern.to_owned()
|
||||
} else {
|
||||
@@ -329,6 +341,12 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
let compiled = Pattern::new(pat)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
let walk_root = derive_glob_walk_root(pat);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_walk_root = walk_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| walk_root.clone());
|
||||
validate_workspace_boundary(&canonical_walk_root, root)?;
|
||||
}
|
||||
let entries = WalkDir::new(&walk_root)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| !should_skip_glob_dir(entry));
|
||||
@@ -338,6 +356,10 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
&& compiled.matches_path(candidate)
|
||||
&& seen.insert(candidate.to_path_buf())
|
||||
{
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_candidate = candidate.canonicalize()?;
|
||||
validate_workspace_boundary(&canonical_candidate, root)?;
|
||||
}
|
||||
matches.push(candidate.to_path_buf());
|
||||
}
|
||||
}
|
||||
@@ -367,12 +389,23 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
|
||||
/// Runs a regex search over workspace files with optional context lines.
|
||||
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
grep_search_impl(input, None)
|
||||
}
|
||||
|
||||
fn grep_search_impl(
|
||||
input: &GrepSearchInput,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> io::Result<GrepSearchOutput> {
|
||||
let base_path = input
|
||||
.path
|
||||
.as_deref()
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let canonical_root = workspace_root.map(canonicalize_workspace_root);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
validate_workspace_boundary(&base_path, root)?;
|
||||
}
|
||||
|
||||
let regex = RegexBuilder::new(&input.pattern)
|
||||
.case_insensitive(input.case_insensitive.unwrap_or(false))
|
||||
@@ -398,6 +431,10 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
let mut total_matches = 0usize;
|
||||
|
||||
for file_path in collect_search_files(&base_path)? {
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_file = file_path.canonicalize()?;
|
||||
validate_workspace_boundary(&canonical_file, root)?;
|
||||
}
|
||||
if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
|
||||
continue;
|
||||
}
|
||||
@@ -447,27 +484,21 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
|
||||
let (filenames, applied_limit, applied_offset) =
|
||||
apply_limit(filenames, input.head_limit, input.offset);
|
||||
let content_output = if output_mode == "content" {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
||||
return Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
num_files: filenames.len(),
|
||||
if output_mode == "content" {
|
||||
return Ok(build_grep_content_output(
|
||||
output_mode,
|
||||
filenames,
|
||||
num_lines: Some(lines.len()),
|
||||
content: Some(lines.join("\n")),
|
||||
num_matches: None,
|
||||
applied_limit: limit,
|
||||
applied_offset: offset,
|
||||
});
|
||||
} else {
|
||||
None
|
||||
};
|
||||
content_lines,
|
||||
input.head_limit,
|
||||
input.offset,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode.clone()),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
content: content_output,
|
||||
content: None,
|
||||
num_lines: None,
|
||||
num_matches: (output_mode == "count").then_some(total_matches),
|
||||
applied_limit,
|
||||
@@ -475,6 +506,32 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_grep_content_output(
|
||||
output_mode: String,
|
||||
filenames: Vec<String>,
|
||||
content_lines: Vec<String>,
|
||||
head_limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
) -> GrepSearchOutput {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, head_limit, offset);
|
||||
GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
num_lines: Some(lines.len()),
|
||||
content: Some(lines.join("\n")),
|
||||
num_matches: None,
|
||||
applied_limit: limit,
|
||||
applied_offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_workspace_root(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf())
|
||||
}
|
||||
|
||||
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
|
||||
entry.file_type().is_dir()
|
||||
&& entry
|
||||
@@ -625,9 +682,7 @@ pub fn read_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
read_file(path, offset, limit)
|
||||
}
|
||||
@@ -640,9 +695,7 @@ pub fn write_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<WriteFileOutput> {
|
||||
let absolute_path = normalize_path_allow_missing(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
write_file(path, content)
|
||||
}
|
||||
@@ -657,13 +710,30 @@ pub fn edit_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<EditFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
edit_file(path, old_string, new_string, replace_all)
|
||||
}
|
||||
|
||||
/// Expand a glob pattern with workspace boundary enforcement.
|
||||
#[allow(dead_code)]
|
||||
pub fn glob_search_in_workspace(
|
||||
pattern: &str,
|
||||
path: Option<&str>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<GlobSearchOutput> {
|
||||
glob_search_impl(pattern, path, Some(workspace_root))
|
||||
}
|
||||
|
||||
/// Search file contents with workspace boundary enforcement.
|
||||
#[allow(dead_code)]
|
||||
pub fn grep_search_in_workspace(
|
||||
input: &GrepSearchInput,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<GrepSearchOutput> {
|
||||
grep_search_impl(input, Some(workspace_root))
|
||||
}
|
||||
|
||||
/// Check whether a path is a symlink that resolves outside the workspace.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
|
||||
@@ -708,7 +778,7 @@ 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,
|
||||
GrepSearchInput, MAX_WRITE_SIZE,
|
||||
write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -808,6 +878,68 @@ mod tests {
|
||||
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_read_rejects_symlink_escape_regression_3007_class() {
|
||||
let workspace = temp_path("workspace-read-symlink-escape");
|
||||
let outside = temp_path("workspace-read-symlink-target");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
||||
std::fs::create_dir_all(&outside).expect("outside dir should be created");
|
||||
let outside_file = outside.join("secret.txt");
|
||||
std::fs::write(&outside_file, "outside secret").expect("outside file should write");
|
||||
|
||||
let link_path = workspace.join("linked-secret.txt");
|
||||
std::os::unix::fs::symlink(&outside_file, &link_path).expect("symlink should create");
|
||||
|
||||
let result =
|
||||
read_file_in_workspace(link_path.to_string_lossy().as_ref(), None, None, &workspace);
|
||||
|
||||
assert!(result.is_err(), "symlink escape must be rejected");
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
error.to_string().contains("escapes workspace"),
|
||||
"error should explain workspace escape: {error}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() {
|
||||
let workspace = temp_path("workspace-write-symlink-escape");
|
||||
let outside = temp_path("workspace-write-symlink-target");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
||||
std::fs::create_dir_all(&outside).expect("outside dir should be created");
|
||||
|
||||
let link_dir = workspace.join("linked-outside");
|
||||
std::os::unix::fs::symlink(&outside, &link_dir).expect("symlink dir should create");
|
||||
let escaped_child = link_dir.join("created.txt");
|
||||
|
||||
let result = write_file_in_workspace(
|
||||
escaped_child.to_string_lossy().as_ref(),
|
||||
"must not escape",
|
||||
&workspace,
|
||||
);
|
||||
|
||||
assert!(result.is_err(), "parent symlink escape must be rejected");
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
error.to_string().contains("escapes workspace"),
|
||||
"error should explain workspace escape: {error}"
|
||||
);
|
||||
assert!(
|
||||
!outside.join("created.txt").exists(),
|
||||
"write should not create through an escaping symlink"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_and_greps_directory() {
|
||||
let dir = temp_path("search-dir");
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Machine-checkable conformance helpers for G004 event/report contract bundles.
|
||||
//!
|
||||
//! The harness intentionally validates JSON-shaped artifacts instead of owning the
|
||||
//! lane-event, report, or approval-token implementations. This keeps it usable by
|
||||
//! independent implementation lanes and by golden fixtures produced outside the
|
||||
//! runtime crate.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BUNDLE_SCHEMA_VERSION: &str = "g004.contract.bundle.v1";
|
||||
const REPORT_SCHEMA_VERSION: &str = "g004.report.v1";
|
||||
|
||||
/// A single conformance validation failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct G004ConformanceError {
|
||||
/// JSON pointer-ish path to the invalid field.
|
||||
pub path: String,
|
||||
/// Human-readable reason the field failed validation.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl G004ConformanceError {
|
||||
fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a G004 golden contract bundle.
|
||||
///
|
||||
/// The bundle shape is deliberately small and cross-lane:
|
||||
/// - `laneEvents[]` must expose stable event identity, ordering/provenance, and
|
||||
/// terminal dedupe fingerprints.
|
||||
/// - `reports[]` must expose schema identity, content hash, projection/redaction
|
||||
/// provenance, capability negotiation, fact/hypothesis/negative-evidence
|
||||
/// labels, confidence, and field-level delta attribution.
|
||||
/// - `approvalTokens[]` must expose owner/scope, delegation chain, one-time-use,
|
||||
/// and replay-prevention fields.
|
||||
#[must_use]
|
||||
pub fn validate_g004_contract_bundle(bundle: &Value) -> Vec<G004ConformanceError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
require_string_eq(bundle, "/schemaVersion", BUNDLE_SCHEMA_VERSION, &mut errors);
|
||||
validate_lane_events(bundle.get("laneEvents"), "/laneEvents", &mut errors);
|
||||
validate_reports(bundle.get("reports"), "/reports", &mut errors);
|
||||
validate_approval_tokens(bundle.get("approvalTokens"), "/approvalTokens", &mut errors);
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_g004_contract_bundle_valid(bundle: &Value) -> bool {
|
||||
validate_g004_contract_bundle(bundle).is_empty()
|
||||
}
|
||||
|
||||
fn validate_lane_events(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(events) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut previous_seq = None;
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(event, "/event", &format!("{base}/event"), errors);
|
||||
require_non_empty_string_at(event, "/status", &format!("{base}/status"), errors);
|
||||
require_non_empty_string_at(event, "/emittedAt", &format!("{base}/emittedAt"), errors);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/provenance",
|
||||
&format!("{base}/metadata/provenance"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/emitterIdentity",
|
||||
&format!("{base}/metadata/emitterIdentity"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/environmentLabel",
|
||||
&format!("{base}/metadata/environmentLabel"),
|
||||
errors,
|
||||
);
|
||||
|
||||
match get_path(event, "/metadata/seq").and_then(Value::as_u64) {
|
||||
Some(seq) => {
|
||||
if let Some(previous) = previous_seq {
|
||||
if seq <= previous {
|
||||
errors.push(G004ConformanceError::new(
|
||||
format!("{base}/metadata/seq"),
|
||||
"sequence must be strictly increasing",
|
||||
));
|
||||
}
|
||||
}
|
||||
previous_seq = Some(seq);
|
||||
}
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
format!("{base}/metadata/seq"),
|
||||
"required u64 field missing",
|
||||
)),
|
||||
}
|
||||
|
||||
if is_terminal_event_value(event.get("event")) {
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/eventFingerprint",
|
||||
&format!("{base}/metadata/eventFingerprint"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_reports(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(reports) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, report) in reports.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_string_eq_at(
|
||||
report,
|
||||
"/schemaVersion",
|
||||
&format!("{base}/schemaVersion"),
|
||||
REPORT_SCHEMA_VERSION,
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(report, "/reportId", &format!("{base}/reportId"), errors);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/identity/contentHash",
|
||||
&format!("{base}/identity/contentHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/projection/provenance",
|
||||
&format!("{base}/projection/provenance"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/redaction/provenance",
|
||||
&format!("{base}/redaction/provenance"),
|
||||
errors,
|
||||
);
|
||||
non_empty_array(
|
||||
get_path(report, "/consumerCapabilities"),
|
||||
&format!("{base}/consumerCapabilities"),
|
||||
errors,
|
||||
);
|
||||
validate_findings(
|
||||
get_path(report, "/findings"),
|
||||
&format!("{base}/findings"),
|
||||
errors,
|
||||
);
|
||||
validate_field_deltas(
|
||||
get_path(report, "/fieldDeltas"),
|
||||
&format!("{base}/fieldDeltas"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_findings(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(findings) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, finding) in findings.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_one_of_at(
|
||||
finding,
|
||||
"/kind",
|
||||
&format!("{base}/kind"),
|
||||
&["fact", "hypothesis", "negative_evidence"],
|
||||
errors,
|
||||
);
|
||||
require_one_of_at(
|
||||
finding,
|
||||
"/confidence",
|
||||
&format!("{base}/confidence"),
|
||||
&["low", "medium", "high"],
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(finding, "/statement", &format!("{base}/statement"), errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_field_deltas(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(deltas) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, delta) in deltas.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(delta, "/field", &format!("{base}/field"), errors);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/previousHash",
|
||||
&format!("{base}/previousHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/currentHash",
|
||||
&format!("{base}/currentHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/attribution",
|
||||
&format!("{base}/attribution"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_approval_tokens(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(tokens) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(token, "/tokenId", &format!("{base}/tokenId"), errors);
|
||||
require_non_empty_string_at(token, "/owner", &format!("{base}/owner"), errors);
|
||||
require_non_empty_string_at(token, "/scope", &format!("{base}/scope"), errors);
|
||||
require_non_empty_string_at(token, "/issuedAt", &format!("{base}/issuedAt"), errors);
|
||||
require_bool_true_at(token, "/oneTimeUse", &format!("{base}/oneTimeUse"), errors);
|
||||
require_non_empty_string_at(
|
||||
token,
|
||||
"/replayPreventionNonce",
|
||||
&format!("{base}/replayPreventionNonce"),
|
||||
errors,
|
||||
);
|
||||
validate_delegation_chain(
|
||||
get_path(token, "/delegationChain"),
|
||||
&format!("{base}/delegationChain"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_delegation_chain(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(chain) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, hop) in chain.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(hop, "/from", &format!("{base}/from"), errors);
|
||||
require_non_empty_string_at(hop, "/to", &format!("{base}/to"), errors);
|
||||
require_non_empty_string_at(hop, "/action", &format!("{base}/action"), errors);
|
||||
require_non_empty_string_at(hop, "/at", &format!("{base}/at"), errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_array<'a>(
|
||||
value: Option<&'a Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) -> Option<&'a Vec<Value>> {
|
||||
match value.and_then(Value::as_array) {
|
||||
Some(array) if !array.is_empty() => Some(array),
|
||||
Some(_) => {
|
||||
errors.push(G004ConformanceError::new(path, "array must not be empty"));
|
||||
None
|
||||
}
|
||||
None => {
|
||||
errors.push(G004ConformanceError::new(
|
||||
path,
|
||||
"required array field missing",
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_string_eq(
|
||||
root: &Value,
|
||||
path: &str,
|
||||
expected: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
require_string_eq_at(root, path, path, expected, errors);
|
||||
}
|
||||
|
||||
fn require_string_eq_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
expected: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(actual) if actual == expected => {}
|
||||
Some(actual) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
format!("expected '{expected}', got '{actual}'"),
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_non_empty_string_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(value) if !value.trim().is_empty() => {}
|
||||
Some(_) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"string must not be empty",
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_one_of_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
allowed: &[&str],
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(value) if allowed.contains(&value) => {}
|
||||
Some(value) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
format!("'{value}' is not one of {}", allowed.join(", ")),
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_bool_true_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_bool) {
|
||||
Some(true) => {}
|
||||
Some(false) => errors.push(G004ConformanceError::new(error_path, "must be true")),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required boolean field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal_event_value(value: Option<&Value>) -> bool {
|
||||
matches!(
|
||||
value.and_then(Value::as_str),
|
||||
Some("lane.finished" | "lane.failed" | "lane.merged" | "lane.superseded" | "lane.closed")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
if let Some(value) = root.pointer(path) {
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
let segments = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
|
||||
for index in 1..segments.len() {
|
||||
let relative = format!("/{}", segments[index..].join("/"));
|
||||
if let Some(value) = root.pointer(&relative) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -449,18 +449,21 @@ pub fn compute_event_fingerprint(
|
||||
status: &LaneEventStatus,
|
||||
data: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
format!("{event:?}").hash(&mut hasher);
|
||||
format!("{status:?}").hash(&mut hasher);
|
||||
if let Some(d) = data {
|
||||
serde_json::to_string(d)
|
||||
.unwrap_or_default()
|
||||
.hash(&mut hasher);
|
||||
let payload = serde_json::json!({
|
||||
"event": event,
|
||||
"status": status,
|
||||
"data": data,
|
||||
});
|
||||
let canonical = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
let digest = Sha256::digest(canonical);
|
||||
let mut fingerprint = String::with_capacity(16);
|
||||
for byte in &digest[..8] {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut fingerprint, "{byte:02x}").expect("writing to String should not fail");
|
||||
}
|
||||
format!("{:016x}", hasher.finish())
|
||||
fingerprint
|
||||
}
|
||||
|
||||
/// Classification of event terminality for reconciliation.
|
||||
@@ -1045,6 +1048,7 @@ impl LaneEvent {
|
||||
emitted_at,
|
||||
)
|
||||
.with_optional_detail(detail)
|
||||
.with_terminal_fingerprint()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1098,7 +1102,7 @@ impl LaneEvent {
|
||||
event =
|
||||
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||
}
|
||||
event
|
||||
event.with_terminal_fingerprint()
|
||||
}
|
||||
|
||||
/// Ship prepared — §4.44.5
|
||||
@@ -1170,6 +1174,21 @@ impl LaneEvent {
|
||||
#[must_use]
|
||||
pub fn with_data(mut self, data: Value) -> Self {
|
||||
self.data = Some(data);
|
||||
if is_terminal_event(self.event) {
|
||||
self = self.with_terminal_fingerprint();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn with_terminal_fingerprint(mut self) -> Self {
|
||||
if is_terminal_event(self.event) {
|
||||
self.metadata.event_fingerprint = Some(compute_event_fingerprint(
|
||||
&self.event,
|
||||
&self.status,
|
||||
self.data.as_ref(),
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1375,6 +1394,39 @@ mod tests {
|
||||
assert_eq!(round_trip.event, LaneEventName::ShipPushedMain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convenience_terminal_events_attach_and_refresh_fingerprints() {
|
||||
let finished = LaneEvent::finished("2026-04-04T00:00:00Z", Some("done".to_string()));
|
||||
let initial_fingerprint = finished
|
||||
.metadata
|
||||
.event_fingerprint
|
||||
.clone()
|
||||
.expect("finished events should carry terminal fingerprint");
|
||||
|
||||
let with_payload = finished.with_data(json!({"result": "ok", "attempt": 1}));
|
||||
assert!(with_payload.metadata.event_fingerprint.is_some());
|
||||
assert_ne!(
|
||||
Some(initial_fingerprint),
|
||||
with_payload.metadata.event_fingerprint,
|
||||
"payload changes must refresh the actionable terminal fingerprint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_style_finished_events_dedupe_after_payload_is_added() {
|
||||
let first = LaneEvent::finished("2026-04-04T00:00:00Z", Some("done".to_string()))
|
||||
.with_data(json!({"result": "ok"}));
|
||||
let duplicate = LaneEvent::finished("2026-04-04T00:00:01Z", Some("done again".to_string()))
|
||||
.with_data(json!({"result": "ok"}));
|
||||
|
||||
assert_eq!(
|
||||
first.metadata.event_fingerprint,
|
||||
duplicate.metadata.event_fingerprint
|
||||
);
|
||||
let deduped = dedupe_terminal_events(&[first, duplicate]);
|
||||
assert_eq!(deduped.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
||||
let event = LaneEvent::commit_created(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! MCP plumbing, tool-facing file operations, and the core conversation loop
|
||||
//! that drives interactive and one-shot turns.
|
||||
|
||||
mod approval_tokens;
|
||||
mod bash;
|
||||
pub mod bash_validation;
|
||||
mod bootstrap;
|
||||
@@ -13,6 +14,7 @@ mod config;
|
||||
pub mod config_validate;
|
||||
mod conversation;
|
||||
mod file_ops;
|
||||
pub mod g004_conformance;
|
||||
mod git_context;
|
||||
pub mod green_contract;
|
||||
mod hooks;
|
||||
@@ -33,6 +35,7 @@ mod policy_engine;
|
||||
mod prompt;
|
||||
pub mod recovery_recipes;
|
||||
mod remote;
|
||||
mod report_schema;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
@@ -49,6 +52,10 @@ mod trust_resolver;
|
||||
mod usage;
|
||||
pub mod worker_boot;
|
||||
|
||||
pub use approval_tokens::{
|
||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenAudit, ApprovalTokenError,
|
||||
ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus,
|
||||
};
|
||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
|
||||
@@ -74,9 +81,10 @@ pub use conversation::{
|
||||
ToolExecutor, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
||||
WriteFileOutput,
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||
};
|
||||
pub use git_context::{GitCommitEntry, GitContext};
|
||||
pub use hooks::{
|
||||
@@ -143,6 +151,13 @@ pub use remote::{
|
||||
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
||||
};
|
||||
pub use report_schema::{
|
||||
canonicalize_report, project_report, report_content_hash, report_schema_v1_registry,
|
||||
CanonicalReportV1, ClaimKind, ConsumerCapabilities, FieldDelta, FieldDeltaState,
|
||||
NegativeEvidence, NegativeFindingStatus, ProjectionProvenance, RedactionProvenance,
|
||||
ReportClaim, ReportConfidence, ReportIdentity, ReportProjectionV1, ReportSchemaField,
|
||||
ReportSchemaRegistry, SensitivityClass, DEFAULT_PROJECTION_POLICY_V1, REPORT_SCHEMA_V1,
|
||||
};
|
||||
pub use sandbox::{
|
||||
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
|
||||
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
pub type GreenLevel = u8;
|
||||
|
||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
|
||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_hours(1);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PolicyRule {
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const REPORT_SCHEMA_V1: &str = "claw.report.v1";
|
||||
pub const DEFAULT_PROJECTION_POLICY_V1: &str = "claw.report.projection.v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClaimKind {
|
||||
ObservedFact,
|
||||
Inference,
|
||||
Hypothesis,
|
||||
Recommendation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReportConfidence {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensitivityClass {
|
||||
Public,
|
||||
Internal,
|
||||
OperatorOnly,
|
||||
Secret,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FieldDeltaState {
|
||||
Changed,
|
||||
Unchanged,
|
||||
Cleared,
|
||||
CarriedForward,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NegativeFindingStatus {
|
||||
NotObservedInCheckedScope,
|
||||
UnknownNotChecked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportClaim {
|
||||
pub id: String,
|
||||
pub kind: ClaimKind,
|
||||
pub text: String,
|
||||
pub confidence: ReportConfidence,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub evidence: Vec<String>,
|
||||
pub sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NegativeEvidence {
|
||||
pub id: String,
|
||||
pub status: NegativeFindingStatus,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub checked_surfaces: Vec<String>,
|
||||
pub query: String,
|
||||
pub window: String,
|
||||
pub sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FieldDelta {
|
||||
pub field: String,
|
||||
pub state: FieldDeltaState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_hash: Option<String>,
|
||||
pub attribution: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportIdentity {
|
||||
pub report_id: String,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CanonicalReportV1 {
|
||||
pub schema_version: String,
|
||||
pub identity: ReportIdentity,
|
||||
pub generated_at: String,
|
||||
pub producer: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub claims: Vec<ReportClaim>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub negative_evidence: Vec<NegativeEvidence>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub field_deltas: Vec<FieldDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConsumerCapabilities {
|
||||
pub consumer: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub schema_versions: BTreeSet<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub field_families: BTreeSet<String>,
|
||||
pub max_sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RedactionProvenance {
|
||||
pub field_path: String,
|
||||
pub reason: String,
|
||||
pub policy_id: String,
|
||||
pub original_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectionProvenance {
|
||||
pub policy_id: String,
|
||||
pub source_schema_version: String,
|
||||
pub source_report_id: String,
|
||||
pub source_content_hash: String,
|
||||
pub consumer: String,
|
||||
pub downgraded: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub omitted_field_families: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub redactions: Vec<RedactionProvenance>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportProjectionV1 {
|
||||
pub schema_version: String,
|
||||
pub projection_id: String,
|
||||
pub view: String,
|
||||
pub provenance: ProjectionProvenance,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportSchemaField {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
pub field_family: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportSchemaRegistry {
|
||||
pub schema_version: String,
|
||||
pub compatibility: String,
|
||||
pub fields: Vec<ReportSchemaField>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn report_schema_v1_registry() -> ReportSchemaRegistry {
|
||||
ReportSchemaRegistry {
|
||||
schema_version: REPORT_SCHEMA_V1.to_string(),
|
||||
compatibility: "additive fields are compatible; missing required fields are breaking"
|
||||
.to_string(),
|
||||
fields: vec![
|
||||
field(
|
||||
"identity.report_id",
|
||||
"stable canonical report identity",
|
||||
true,
|
||||
"identity",
|
||||
),
|
||||
field(
|
||||
"identity.content_hash",
|
||||
"hash of canonical payload excluding identity",
|
||||
true,
|
||||
"identity",
|
||||
),
|
||||
field(
|
||||
"claims[].kind",
|
||||
"fact/inference/hypothesis/recommendation label",
|
||||
true,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"claims[].confidence",
|
||||
"confidence bucket for the claim",
|
||||
true,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"claims[].evidence",
|
||||
"evidence ids supporting a claim",
|
||||
false,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"negative_evidence[]",
|
||||
"searched-and-not-found findings with checked scope",
|
||||
false,
|
||||
"negative_evidence",
|
||||
),
|
||||
field(
|
||||
"field_deltas[]",
|
||||
"field-level changed/unchanged/cleared/carried-forward attribution",
|
||||
false,
|
||||
"field_deltas",
|
||||
),
|
||||
field(
|
||||
"projection.provenance.redactions[]",
|
||||
"redaction policy provenance for projected fields",
|
||||
false,
|
||||
"projection",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn canonicalize_report(mut report: CanonicalReportV1) -> CanonicalReportV1 {
|
||||
report.schema_version = REPORT_SCHEMA_V1.to_string();
|
||||
report.claims.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
report.negative_evidence.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
report.field_deltas.sort_by(|a, b| a.field.cmp(&b.field));
|
||||
let content_hash = report_content_hash(&report);
|
||||
if report.identity.report_id.is_empty() {
|
||||
report.identity.report_id = format!("report-{content_hash}");
|
||||
}
|
||||
report.identity.content_hash = content_hash;
|
||||
report
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn report_content_hash(report: &CanonicalReportV1) -> String {
|
||||
let mut hashable = report.clone();
|
||||
hashable.identity.report_id.clear();
|
||||
hashable.identity.content_hash.clear();
|
||||
stable_json_hash(&serde_json::to_value(hashable).expect("report should serialize"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_report(
|
||||
report: &CanonicalReportV1,
|
||||
capabilities: &ConsumerCapabilities,
|
||||
view: impl Into<String>,
|
||||
) -> ReportProjectionV1 {
|
||||
let view = view.into();
|
||||
let supports_schema = capabilities.schema_versions.contains(REPORT_SCHEMA_V1);
|
||||
let mut omitted_field_families = Vec::new();
|
||||
let mut redactions = Vec::new();
|
||||
let mut payload = serde_json::Map::new();
|
||||
|
||||
payload.insert(
|
||||
"identity".to_string(),
|
||||
serde_json::to_value(&report.identity).expect("identity serializes"),
|
||||
);
|
||||
payload.insert(
|
||||
"generated_at".to_string(),
|
||||
Value::String(report.generated_at.clone()),
|
||||
);
|
||||
payload.insert(
|
||||
"producer".to_string(),
|
||||
Value::String(report.producer.clone()),
|
||||
);
|
||||
|
||||
if supports_family(capabilities, "claims") {
|
||||
let claims = report
|
||||
.claims
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, claim)| redact_claim(index, claim, capabilities, &mut redactions))
|
||||
.collect::<Vec<_>>();
|
||||
payload.insert("claims".to_string(), Value::Array(claims));
|
||||
} else {
|
||||
omitted_field_families.push("claims".to_string());
|
||||
}
|
||||
|
||||
if supports_family(capabilities, "negative_evidence") {
|
||||
payload.insert(
|
||||
"negative_evidence".to_string(),
|
||||
serde_json::to_value(&report.negative_evidence).expect("negative evidence serializes"),
|
||||
);
|
||||
} else {
|
||||
omitted_field_families.push("negative_evidence".to_string());
|
||||
}
|
||||
|
||||
if supports_family(capabilities, "field_deltas") {
|
||||
payload.insert(
|
||||
"field_deltas".to_string(),
|
||||
serde_json::to_value(&report.field_deltas).expect("field deltas serialize"),
|
||||
);
|
||||
} else {
|
||||
omitted_field_families.push("field_deltas".to_string());
|
||||
}
|
||||
|
||||
let downgraded =
|
||||
!supports_schema || !omitted_field_families.is_empty() || !redactions.is_empty();
|
||||
let provenance = ProjectionProvenance {
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
source_schema_version: report.schema_version.clone(),
|
||||
source_report_id: report.identity.report_id.clone(),
|
||||
source_content_hash: report.identity.content_hash.clone(),
|
||||
consumer: capabilities.consumer.clone(),
|
||||
downgraded,
|
||||
omitted_field_families,
|
||||
redactions,
|
||||
};
|
||||
let mut projection = ReportProjectionV1 {
|
||||
schema_version: REPORT_SCHEMA_V1.to_string(),
|
||||
projection_id: String::new(),
|
||||
view,
|
||||
provenance,
|
||||
payload: Value::Object(payload),
|
||||
};
|
||||
projection.projection_id = stable_json_hash(&serde_json::json!({
|
||||
"view": projection.view,
|
||||
"provenance": projection.provenance,
|
||||
"payload": projection.payload,
|
||||
}));
|
||||
projection
|
||||
}
|
||||
|
||||
fn field(id: &str, description: &str, required: bool, field_family: &str) -> ReportSchemaField {
|
||||
ReportSchemaField {
|
||||
id: id.to_string(),
|
||||
description: description.to_string(),
|
||||
required,
|
||||
field_family: field_family.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_family(capabilities: &ConsumerCapabilities, family: &str) -> bool {
|
||||
capabilities.field_families.is_empty() || capabilities.field_families.contains(family)
|
||||
}
|
||||
|
||||
fn redact_claim(
|
||||
index: usize,
|
||||
claim: &ReportClaim,
|
||||
capabilities: &ConsumerCapabilities,
|
||||
redactions: &mut Vec<RedactionProvenance>,
|
||||
) -> Option<Value> {
|
||||
if claim.sensitivity <= capabilities.max_sensitivity {
|
||||
return Some(serde_json::to_value(claim).expect("claim serializes"));
|
||||
}
|
||||
if claim.sensitivity == SensitivityClass::Secret {
|
||||
redactions.push(RedactionProvenance {
|
||||
field_path: format!("claims[{index}]"),
|
||||
reason: "omitted: sensitivity exceeds consumer policy".to_string(),
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
original_hash: stable_json_hash(
|
||||
&serde_json::to_value(claim).expect("claim serializes"),
|
||||
),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut redacted = claim.clone();
|
||||
let original_hash = stable_json_hash(&serde_json::to_value(claim).expect("claim serializes"));
|
||||
redacted.text = "<redacted>".to_string();
|
||||
redacted.evidence.clear();
|
||||
redactions.push(RedactionProvenance {
|
||||
field_path: format!("claims[{index}].text"),
|
||||
reason: "transformed: sensitivity exceeds consumer policy".to_string(),
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
original_hash,
|
||||
});
|
||||
Some(serde_json::to_value(redacted).expect("redacted claim serializes"))
|
||||
}
|
||||
|
||||
fn stable_json_hash(value: &Value) -> String {
|
||||
let normalized = normalize_json(value);
|
||||
let bytes = serde_json::to_vec(&normalized).expect("normalized json should serialize");
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut hash = String::with_capacity(16);
|
||||
for byte in &digest[..8] {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut hash, "{byte:02x}").expect("writing to String should not fail");
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn normalize_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(values) => Value::Array(values.iter().map(normalize_json).collect()),
|
||||
Value::Object(map) => {
|
||||
let sorted = map
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), normalize_json(value)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
serde_json::to_value(sorted).expect("sorted map should serialize")
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
canonicalize_report, project_report, report_schema_v1_registry, CanonicalReportV1,
|
||||
ClaimKind, ConsumerCapabilities, FieldDelta, FieldDeltaState, NegativeEvidence,
|
||||
NegativeFindingStatus, ReportClaim, ReportConfidence, ReportIdentity, SensitivityClass,
|
||||
REPORT_SCHEMA_V1,
|
||||
};
|
||||
|
||||
fn fixture_report() -> CanonicalReportV1 {
|
||||
canonicalize_report(CanonicalReportV1 {
|
||||
schema_version: String::new(),
|
||||
identity: ReportIdentity {
|
||||
report_id: String::new(),
|
||||
content_hash: String::new(),
|
||||
},
|
||||
generated_at: "2026-05-14T00:00:00Z".to_string(),
|
||||
producer: "worker-1".to_string(),
|
||||
claims: vec![
|
||||
ReportClaim {
|
||||
id: "claim-secret".to_string(),
|
||||
kind: ClaimKind::ObservedFact,
|
||||
text: "secret token appeared in logs".to_string(),
|
||||
confidence: ReportConfidence::High,
|
||||
evidence: vec!["log:secret".to_string()],
|
||||
sensitivity: SensitivityClass::Secret,
|
||||
},
|
||||
ReportClaim {
|
||||
id: "claim-hypothesis".to_string(),
|
||||
kind: ClaimKind::Hypothesis,
|
||||
text: "transport restart likely caused the retry".to_string(),
|
||||
confidence: ReportConfidence::Medium,
|
||||
evidence: vec!["event:transport".to_string()],
|
||||
sensitivity: SensitivityClass::Internal,
|
||||
},
|
||||
ReportClaim {
|
||||
id: "claim-fact".to_string(),
|
||||
kind: ClaimKind::ObservedFact,
|
||||
text: "lane finished once".to_string(),
|
||||
confidence: ReportConfidence::High,
|
||||
evidence: vec!["event:lane.finished".to_string()],
|
||||
sensitivity: SensitivityClass::Public,
|
||||
},
|
||||
],
|
||||
negative_evidence: vec![NegativeEvidence {
|
||||
id: "neg-blocker".to_string(),
|
||||
status: NegativeFindingStatus::NotObservedInCheckedScope,
|
||||
checked_surfaces: vec!["lane_events".to_string(), "worker_status".to_string()],
|
||||
query: "current blocker".to_string(),
|
||||
window: "2026-05-14T00:00:00Z/2026-05-14T00:05:00Z".to_string(),
|
||||
sensitivity: SensitivityClass::Public,
|
||||
}],
|
||||
field_deltas: vec![FieldDelta {
|
||||
field: "blocker".to_string(),
|
||||
state: FieldDeltaState::Cleared,
|
||||
previous_hash: Some("prev123".to_string()),
|
||||
current_hash: None,
|
||||
attribution: "lane.failed reconciled to lane.finished".to_string(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(families: &[&str], max_sensitivity: SensitivityClass) -> ConsumerCapabilities {
|
||||
ConsumerCapabilities {
|
||||
consumer: "clawhip".to_string(),
|
||||
schema_versions: [REPORT_SCHEMA_V1.to_string()].into_iter().collect(),
|
||||
field_families: families
|
||||
.iter()
|
||||
.map(|family| (*family).to_string())
|
||||
.collect(),
|
||||
max_sensitivity,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_schema_registry_is_self_describing() {
|
||||
let registry = report_schema_v1_registry();
|
||||
assert_eq!(registry.schema_version, REPORT_SCHEMA_V1);
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "claims[].kind"));
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "negative_evidence[]"));
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "projection.provenance.redactions[]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_report_labels_claims_negative_evidence_and_deltas() {
|
||||
let report = fixture_report();
|
||||
assert_eq!(report.schema_version, REPORT_SCHEMA_V1);
|
||||
assert!(report.identity.report_id.starts_with("report-"));
|
||||
assert_eq!(report.identity.content_hash.len(), 16);
|
||||
assert_eq!(report.claims[0].id, "claim-fact");
|
||||
assert_eq!(report.claims[1].kind, ClaimKind::Hypothesis);
|
||||
assert_eq!(report.claims[1].confidence, ReportConfidence::Medium);
|
||||
assert_eq!(
|
||||
report.negative_evidence[0].status,
|
||||
NegativeFindingStatus::NotObservedInCheckedScope
|
||||
);
|
||||
assert_eq!(report.field_deltas[0].state, FieldDeltaState::Cleared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projections_are_deterministic_and_record_redaction_provenance() {
|
||||
let report = fixture_report();
|
||||
let capabilities = capabilities(
|
||||
&["claims", "negative_evidence", "field_deltas"],
|
||||
SensitivityClass::Public,
|
||||
);
|
||||
|
||||
let first = project_report(&report, &capabilities, "delta_brief");
|
||||
let second = project_report(&report, &capabilities, "delta_brief");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.provenance.source_report_id, report.identity.report_id);
|
||||
assert_eq!(
|
||||
first.provenance.source_content_hash,
|
||||
report.identity.content_hash
|
||||
);
|
||||
assert!(first.provenance.downgraded);
|
||||
assert_eq!(first.provenance.redactions.len(), 2);
|
||||
assert!(first
|
||||
.provenance
|
||||
.redactions
|
||||
.iter()
|
||||
.any(|redaction| redaction.field_path == "claims[1].text"));
|
||||
assert!(first
|
||||
.provenance
|
||||
.redactions
|
||||
.iter()
|
||||
.any(|redaction| redaction.field_path == "claims[2]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_negotiation_omits_unsupported_field_families() {
|
||||
let report = fixture_report();
|
||||
let capabilities = capabilities(&["claims"], SensitivityClass::Internal);
|
||||
let projection = project_report(&report, &capabilities, "legacy_clawhip");
|
||||
|
||||
assert!(projection.provenance.downgraded);
|
||||
assert_eq!(
|
||||
projection.provenance.omitted_field_families,
|
||||
vec!["negative_evidence".to_string(), "field_deltas".to_string()]
|
||||
);
|
||||
assert!(projection.payload.get("claims").is_some());
|
||||
assert!(projection.payload.get("negative_evidence").is_none());
|
||||
assert!(projection.payload.get("field_deltas").is_none());
|
||||
}
|
||||
}
|
||||
@@ -298,8 +298,7 @@ fn unshare_user_namespace_works() -> bool {
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
.is_ok_and(|status| status.success())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -122,13 +122,37 @@ pub enum StartupFailureClassification {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StartupHealthSummary {
|
||||
/// Whether this subsystem appeared healthy at timeout.
|
||||
pub healthy: bool,
|
||||
/// Stable placeholder/source string until deeper transport and MCP probes are wired in.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl StartupHealthSummary {
|
||||
fn observed(name: &str, healthy: bool) -> Self {
|
||||
let status = if healthy { "healthy" } else { "unhealthy" };
|
||||
Self {
|
||||
healthy,
|
||||
summary: format!("{name}_{status}_placeholder"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evidence bundle collected when worker startup times out without clear evidence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StartupEvidenceBundle {
|
||||
/// Last known worker lifecycle state before timeout
|
||||
pub last_lifecycle_state: WorkerStatus,
|
||||
/// Timestamp of the last lifecycle state transition, unix epoch seconds
|
||||
pub last_lifecycle_at: u64,
|
||||
/// The pane/command that was being executed
|
||||
pub pane_command: String,
|
||||
/// Timestamp when the pane/command snapshot was observed, unix epoch seconds
|
||||
pub pane_observed_at: u64,
|
||||
/// Timestamp when the worker command was started, unix epoch seconds
|
||||
pub command_started_at: u64,
|
||||
/// Timestamp when prompt was sent (if any), unix epoch seconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_sent_at: Option<u64>,
|
||||
@@ -146,8 +170,12 @@ pub struct StartupEvidenceBundle {
|
||||
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
|
||||
/// Transport health summary (true = healthy/responsive)
|
||||
pub transport_healthy: bool,
|
||||
/// Typed transport health placeholder for future concrete probes
|
||||
pub transport_health: StartupHealthSummary,
|
||||
/// MCP health summary (true = all servers healthy)
|
||||
pub mcp_healthy: bool,
|
||||
/// Typed MCP health placeholder for future concrete probes
|
||||
pub mcp_health: StartupHealthSummary,
|
||||
/// Seconds since worker creation
|
||||
pub elapsed_seconds: u64,
|
||||
}
|
||||
@@ -225,6 +253,7 @@ pub struct Worker {
|
||||
pub auto_recover_prompt_misdelivery: bool,
|
||||
pub prompt_delivery_attempts: u32,
|
||||
pub prompt_in_flight: bool,
|
||||
pub prompt_sent_at: Option<u64>,
|
||||
pub last_prompt: Option<String>,
|
||||
pub expected_receipt: Option<WorkerTaskReceipt>,
|
||||
pub replay_prompt: Option<String>,
|
||||
@@ -274,6 +303,7 @@ impl WorkerRegistry {
|
||||
auto_recover_prompt_misdelivery,
|
||||
prompt_delivery_attempts: 0,
|
||||
prompt_in_flight: false,
|
||||
prompt_sent_at: None,
|
||||
last_prompt: None,
|
||||
expected_receipt: None,
|
||||
replay_prompt: None,
|
||||
@@ -528,6 +558,7 @@ impl WorkerRegistry {
|
||||
|
||||
worker.prompt_delivery_attempts += 1;
|
||||
worker.prompt_in_flight = true;
|
||||
worker.prompt_sent_at = Some(now_secs());
|
||||
worker.last_prompt = Some(next_prompt.clone());
|
||||
worker.expected_receipt = task_receipt;
|
||||
worker.replay_prompt = None;
|
||||
@@ -579,6 +610,7 @@ impl WorkerRegistry {
|
||||
worker.last_error = None;
|
||||
worker.prompt_delivery_attempts = 0;
|
||||
worker.prompt_in_flight = false;
|
||||
worker.prompt_sent_at = None;
|
||||
push_event(
|
||||
worker,
|
||||
WorkerEventKind::Restarted,
|
||||
@@ -696,12 +728,11 @@ impl WorkerRegistry {
|
||||
// Build evidence bundle
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: worker.status,
|
||||
last_lifecycle_at: worker.updated_at,
|
||||
pane_command: pane_command.to_string(),
|
||||
prompt_sent_at: if worker.prompt_delivery_attempts > 0 {
|
||||
Some(worker.updated_at)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
pane_observed_at: now,
|
||||
command_started_at: worker.created_at,
|
||||
prompt_sent_at: worker.prompt_sent_at,
|
||||
prompt_acceptance_state: worker.status == WorkerStatus::Running
|
||||
&& !worker.prompt_in_flight,
|
||||
trust_prompt_detected: worker
|
||||
@@ -716,7 +747,9 @@ impl WorkerRegistry {
|
||||
.map(|event| now.saturating_sub(event.timestamp)),
|
||||
tool_permission_allow_scope,
|
||||
transport_healthy,
|
||||
transport_health: StartupHealthSummary::observed("transport", transport_healthy),
|
||||
mcp_healthy,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", mcp_healthy),
|
||||
elapsed_seconds: elapsed,
|
||||
};
|
||||
|
||||
@@ -1840,8 +1873,16 @@ mod tests {
|
||||
"last state should be spawning"
|
||||
);
|
||||
assert_eq!(evidence.pane_command, "cargo test");
|
||||
assert!(evidence.command_started_at <= evidence.pane_observed_at);
|
||||
assert!(evidence.last_lifecycle_at <= evidence.pane_observed_at);
|
||||
assert!(!evidence.transport_healthy);
|
||||
assert!(!evidence.transport_health.healthy);
|
||||
assert!(evidence
|
||||
.transport_health
|
||||
.summary
|
||||
.contains("transport_unhealthy"));
|
||||
assert!(evidence.mcp_healthy);
|
||||
assert!(evidence.mcp_health.healthy);
|
||||
assert_eq!(*classification, StartupFailureClassification::TransportDead);
|
||||
}
|
||||
_ => panic!(
|
||||
@@ -1932,11 +1973,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_preserves_original_prompt_sent_timestamp() {
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create("/tmp/repo-prompt-timestamp", &[], true);
|
||||
|
||||
registry
|
||||
.observe(&worker.worker_id, "Ready for input\n>")
|
||||
.expect("ready observe should succeed");
|
||||
let prompted = registry
|
||||
.send_prompt(
|
||||
&worker.worker_id,
|
||||
Some("Run timestamp-sensitive work"),
|
||||
None,
|
||||
)
|
||||
.expect("prompt send should succeed");
|
||||
let sent_at = prompted
|
||||
.prompt_sent_at
|
||||
.expect("prompt send should record a prompt timestamp");
|
||||
|
||||
let timed_out = registry
|
||||
.observe_startup_timeout(&worker.worker_id, "claw worker", true, true)
|
||||
.expect("startup timeout observe should succeed");
|
||||
|
||||
let event = timed_out
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| e.kind == WorkerEventKind::StartupNoEvidence)
|
||||
.expect("startup no evidence event should exist");
|
||||
|
||||
match event.payload.as_ref() {
|
||||
Some(WorkerEventPayload::StartupNoEvidence { evidence, .. }) => {
|
||||
assert_eq!(evidence.prompt_sent_at, Some(sent_at));
|
||||
assert!(evidence.last_lifecycle_at <= evidence.pane_observed_at);
|
||||
assert!(evidence.command_started_at <= sent_at);
|
||||
}
|
||||
_ => panic!("expected StartupNoEvidence payload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_evidence_bundle_serializes_correctly() {
|
||||
let bundle = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Running,
|
||||
last_lifecycle_at: 1_234_567_889,
|
||||
pane_command: "test command".to_string(),
|
||||
pane_observed_at: 1_234_567_891,
|
||||
command_started_at: 1_234_567_800,
|
||||
prompt_sent_at: Some(1_234_567_890),
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: true,
|
||||
@@ -1944,7 +2027,9 @@ mod tests {
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: false,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", false),
|
||||
elapsed_seconds: 60,
|
||||
};
|
||||
|
||||
@@ -1953,8 +2038,13 @@ mod tests {
|
||||
assert!(json.contains("\"pane_command\""));
|
||||
assert!(json.contains("\"prompt_sent_at\":1234567890"));
|
||||
assert!(json.contains("\"trust_prompt_detected\":true"));
|
||||
assert!(json.contains("\"last_lifecycle_at\":1234567889"));
|
||||
assert!(json.contains("\"pane_observed_at\":1234567891"));
|
||||
assert!(json.contains("\"command_started_at\":1234567800"));
|
||||
assert!(json.contains("\"transport_healthy\":true"));
|
||||
assert!(json.contains("\"transport_health\""));
|
||||
assert!(json.contains("\"mcp_healthy\":false"));
|
||||
assert!(json.contains("\"mcp_health\""));
|
||||
|
||||
let deserialized: StartupEvidenceBundle =
|
||||
serde_json::from_str(&json).expect("should deserialize");
|
||||
@@ -1966,7 +2056,10 @@ mod tests {
|
||||
fn classify_startup_failure_detects_transport_dead() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
@@ -1974,7 +2067,9 @@ mod tests {
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: false,
|
||||
transport_health: StartupHealthSummary::observed("transport", false),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 30,
|
||||
};
|
||||
|
||||
@@ -1986,7 +2081,10 @@ mod tests {
|
||||
fn classify_startup_failure_defaults_to_unknown() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
@@ -1994,7 +2092,9 @@ mod tests {
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 10,
|
||||
};
|
||||
|
||||
@@ -2002,13 +2102,44 @@ mod tests {
|
||||
assert_eq!(classification, StartupFailureClassification::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_startup_failure_detects_prompt_misdelivery_after_timeout() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::ReadyForPrompt,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 45,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: Some(10),
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 31,
|
||||
};
|
||||
|
||||
let classification = classify_startup_failure(&evidence);
|
||||
assert_eq!(
|
||||
classification,
|
||||
StartupFailureClassification::PromptMisdelivery
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_startup_failure_detects_worker_crashed() {
|
||||
// Worker crashed scenario: transport healthy but MCP unhealthy
|
||||
// Don't have prompt in flight (no prompt_sent_at) to avoid matching PromptAcceptanceTimeout
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None, // No prompt sent yet
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
@@ -2016,7 +2147,9 @@ mod tests {
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: false,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", false), // MCP unhealthy but transport healthy suggests crash
|
||||
elapsed_seconds: 45,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schemaVersion": "g004.contract.bundle.v1",
|
||||
"laneEvents": [
|
||||
{
|
||||
"event": "lane.started",
|
||||
"status": "running",
|
||||
"emittedAt": "2026-05-14T00:00:00Z",
|
||||
"metadata": {
|
||||
"seq": 1,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004"
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "lane.finished",
|
||||
"status": "completed",
|
||||
"emittedAt": "2026-05-14T00:00:10Z",
|
||||
"metadata": {
|
||||
"seq": 2,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004",
|
||||
"eventFingerprint": "terminal-fp-001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reports": [
|
||||
{
|
||||
"schemaVersion": "g004.report.v1",
|
||||
"reportId": "report-g004-fixture",
|
||||
"identity": { "contentHash": "sha256:report-content" },
|
||||
"projection": { "provenance": "runtime.event_projection.v1" },
|
||||
"redaction": { "provenance": "runtime.redaction_policy.v1" },
|
||||
"consumerCapabilities": ["facts", "field_deltas", "redaction_provenance"],
|
||||
"findings": [
|
||||
{
|
||||
"kind": "fact",
|
||||
"confidence": "high",
|
||||
"statement": "lane event reached terminal state"
|
||||
},
|
||||
{
|
||||
"kind": "hypothesis",
|
||||
"confidence": "medium",
|
||||
"statement": "consumer can reconcile the terminal fingerprint"
|
||||
},
|
||||
{
|
||||
"kind": "negative_evidence",
|
||||
"confidence": "high",
|
||||
"statement": "no duplicate terminal event appears in this fixture"
|
||||
}
|
||||
],
|
||||
"fieldDeltas": [
|
||||
{
|
||||
"field": "/laneEvents/1/status",
|
||||
"previousHash": "sha256:running",
|
||||
"currentHash": "sha256:completed",
|
||||
"attribution": "worker-1 terminal reconciliation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"approvalTokens": [
|
||||
{
|
||||
"tokenId": "approval-token-fixture",
|
||||
"owner": "leader-fixed",
|
||||
"scope": "g004.contract.bundle.fixture",
|
||||
"issuedAt": "2026-05-14T00:00:01Z",
|
||||
"oneTimeUse": true,
|
||||
"replayPreventionNonce": "nonce-fixture-001",
|
||||
"delegationChain": [
|
||||
{
|
||||
"from": "leader-fixed",
|
||||
"to": "worker-3",
|
||||
"action": "validate-g004-contract-fixture",
|
||||
"at": "2026-05-14T00:00:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Report schema v1 fixture set
|
||||
|
||||
Validated by `cargo test -p runtime report_schema -- --nocapture`.
|
||||
|
||||
The in-code fixture in `runtime::report_schema::tests::fixture_report` covers:
|
||||
- fact / hypothesis / confidence labels
|
||||
- negative evidence with checked surfaces and query window
|
||||
- field-level delta attribution
|
||||
- canonical report id plus content hash
|
||||
- deterministic projection/redaction provenance
|
||||
- consumer capability negotiation and downgraded projections
|
||||
@@ -0,0 +1,80 @@
|
||||
use runtime::g004_conformance::{is_g004_contract_bundle_valid, validate_g004_contract_bundle};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn valid_bundle() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/g004_contract_bundle.valid.json"))
|
||||
.expect("valid fixture JSON should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_g004_contract_bundle_fixture_passes_conformance() {
|
||||
let fixture = valid_bundle();
|
||||
|
||||
let errors = validate_g004_contract_bundle(&fixture);
|
||||
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"unexpected conformance errors: {errors:?}"
|
||||
);
|
||||
assert!(is_g004_contract_bundle_valid(&fixture));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g004_conformance_reports_machine_readable_paths_for_contract_gaps() {
|
||||
let invalid = json!({
|
||||
"schemaVersion": "g004.contract.bundle.v1",
|
||||
"laneEvents": [
|
||||
{
|
||||
"event": "lane.finished",
|
||||
"status": "completed",
|
||||
"emittedAt": "2026-05-14T00:00:10Z",
|
||||
"metadata": {
|
||||
"seq": 1,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reports": [
|
||||
{
|
||||
"schemaVersion": "g004.report.v1",
|
||||
"reportId": "report-with-gaps",
|
||||
"identity": { "contentHash": "sha256:report-content" },
|
||||
"projection": { "provenance": "runtime.event_projection.v1" },
|
||||
"redaction": { "provenance": "runtime.redaction_policy.v1" },
|
||||
"consumerCapabilities": [],
|
||||
"findings": [
|
||||
{
|
||||
"kind": "guess",
|
||||
"confidence": "certain",
|
||||
"statement": "bad labels should be rejected"
|
||||
}
|
||||
],
|
||||
"fieldDeltas": []
|
||||
}
|
||||
],
|
||||
"approvalTokens": [
|
||||
{
|
||||
"tokenId": "approval-token-fixture",
|
||||
"owner": "leader-fixed",
|
||||
"scope": "g004.contract.bundle.fixture",
|
||||
"issuedAt": "2026-05-14T00:00:01Z",
|
||||
"oneTimeUse": false,
|
||||
"replayPreventionNonce": "nonce-fixture-001",
|
||||
"delegationChain": []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let errors = validate_g004_contract_bundle(&invalid);
|
||||
let paths: Vec<&str> = errors.iter().map(|error| error.path.as_str()).collect();
|
||||
|
||||
assert!(paths.contains(&"/laneEvents/0/metadata/eventFingerprint"));
|
||||
assert!(paths.contains(&"/reports/0/consumerCapabilities"));
|
||||
assert!(paths.contains(&"/reports/0/findings/0/kind"));
|
||||
assert!(paths.contains(&"/reports/0/findings/0/confidence"));
|
||||
assert!(paths.contains(&"/reports/0/fieldDeltas"));
|
||||
assert!(paths.contains(&"/approvalTokens/0/oneTimeUse"));
|
||||
assert!(paths.contains(&"/approvalTokens/0/delegationChain"));
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn stale_branch_detection_flows_into_policy_engine() {
|
||||
let stale_context = LaneContext::new(
|
||||
"stale-lane",
|
||||
0,
|
||||
Duration::from_secs(2 * 60 * 60), // 2 hours stale
|
||||
Duration::from_hours(2), // 2 hours stale
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
@@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
|
||||
let fresh_context = LaneContext::new(
|
||||
"fresh-lane",
|
||||
0,
|
||||
Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
|
||||
Duration::from_mins(30), // 30 min stale — under 1 hour threshold
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
@@ -212,8 +212,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
||||
// when: build context and evaluate policy
|
||||
let context = LaneContext::new(
|
||||
"lane-9411",
|
||||
3, // Workspace green
|
||||
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
|
||||
3, // Workspace green
|
||||
Duration::from_hours(5), // 5 hours stale, definitely over threshold
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
@@ -261,8 +261,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
||||
fn fresh_approved_lane_gets_merge_action() {
|
||||
let context = LaneContext::new(
|
||||
"fresh-approved-lane",
|
||||
3, // Workspace green
|
||||
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
|
||||
3, // Workspace green
|
||||
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
@@ -347,7 +347,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
||||
// (Simulating the policy check that would happen after successful recovery)
|
||||
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
|
||||
let green_level = 3; // Workspace green
|
||||
let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
|
||||
let not_stale = Duration::from_mins(30); // 30 min — fresh
|
||||
|
||||
let post_recovery_context = LaneContext::new(
|
||||
"recovered-lane",
|
||||
|
||||
@@ -1972,8 +1972,16 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
let (project_root, git_branch) =
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref());
|
||||
let empty_config = runtime::RuntimeConfig::empty();
|
||||
let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config);
|
||||
let boot_preflight = build_boot_preflight_snapshot(
|
||||
&cwd,
|
||||
project_root.as_deref(),
|
||||
project_context.git_status.as_deref(),
|
||||
config.as_ref().ok(),
|
||||
config.as_ref().err().map(ToString::to_string).as_deref(),
|
||||
);
|
||||
let context = StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
session_path: None,
|
||||
@@ -1986,7 +1994,9 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
branch_freshness,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
boot_preflight,
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
// Doctor path has its own config check; StatusContext here is only
|
||||
// fed into health renderers that don't read config_load_error.
|
||||
@@ -1998,6 +2008,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
check_config_health(&config_loader, config.as_ref()),
|
||||
check_install_source_health(),
|
||||
check_workspace_health(&context),
|
||||
check_boot_preflight_health(&context),
|
||||
check_sandbox_health(&context.sandbox_status),
|
||||
check_system_health(&cwd, config.as_ref().ok()),
|
||||
],
|
||||
@@ -2393,6 +2404,73 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
let preflight = &context.boot_preflight;
|
||||
let missing_binaries = preflight
|
||||
.required_binaries
|
||||
.iter()
|
||||
.filter(|binary| !binary.available)
|
||||
.map(|binary| binary.name)
|
||||
.collect::<Vec<_>>();
|
||||
let socket_details = preflight
|
||||
.control_sockets
|
||||
.iter()
|
||||
.map(|socket| {
|
||||
format!(
|
||||
"Control socket {} configured={} exists={} path={}",
|
||||
socket.name,
|
||||
socket.configured,
|
||||
socket.exists,
|
||||
socket.path.as_deref().unwrap_or("<none>")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut details = vec![
|
||||
format!("Repo exists {}", preflight.repo_exists),
|
||||
format!("Worktree exists {}", preflight.worktree_exists),
|
||||
format!("Git dir exists {}", preflight.git_dir_exists),
|
||||
format!("Branch behind {}", preflight.branch_freshness.behind),
|
||||
format!("Trust allowlist {:?}", preflight.trust_gate_allowed),
|
||||
format!("Trusted roots {}", preflight.trusted_roots_count),
|
||||
format!(
|
||||
"MCP eligible {} · servers {}",
|
||||
preflight.mcp_startup_eligible, preflight.mcp_servers_configured
|
||||
),
|
||||
format!(
|
||||
"Plugin eligible {} · configured {}",
|
||||
preflight.plugin_startup_eligible, preflight.plugins_configured
|
||||
),
|
||||
format!(
|
||||
"Last failed boot {}",
|
||||
preflight
|
||||
.last_failed_boot_reason
|
||||
.as_deref()
|
||||
.unwrap_or("<none>")
|
||||
),
|
||||
];
|
||||
details.extend(preflight.required_binaries.iter().map(|binary| {
|
||||
format!(
|
||||
"Required binary {} available={}",
|
||||
binary.name, binary.available
|
||||
)
|
||||
}));
|
||||
details.extend(socket_details);
|
||||
DiagnosticCheck::new(
|
||||
"Boot preflight",
|
||||
if preflight.repo_exists && preflight.worktree_exists && missing_binaries.is_empty() {
|
||||
DiagnosticLevel::Ok
|
||||
} else {
|
||||
DiagnosticLevel::Warn
|
||||
},
|
||||
preflight.summary(),
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([(
|
||||
"boot_preflight".to_string(),
|
||||
preflight.json_value(),
|
||||
)]))
|
||||
}
|
||||
|
||||
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
||||
let degraded = status.enabled && !status.active;
|
||||
let mut details = vec![
|
||||
@@ -2841,7 +2919,9 @@ struct StatusContext {
|
||||
project_root: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_summary: GitWorkspaceSummary,
|
||||
branch_freshness: BranchFreshness,
|
||||
session_lifecycle: SessionLifecycleSummary,
|
||||
boot_preflight: BootPreflightSnapshot,
|
||||
sandbox_status: runtime::SandboxStatus,
|
||||
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
|
||||
/// we capture the parse error here and still populate every field that
|
||||
@@ -2852,6 +2932,162 @@ struct StatusContext {
|
||||
config_load_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BranchFreshness {
|
||||
upstream: Option<String>,
|
||||
ahead: u32,
|
||||
behind: u32,
|
||||
fresh: Option<bool>,
|
||||
}
|
||||
|
||||
impl BranchFreshness {
|
||||
fn from_git_status(status: Option<&str>) -> Self {
|
||||
let first_line = status
|
||||
.and_then(|status| status.lines().next())
|
||||
.unwrap_or_default();
|
||||
let upstream = first_line
|
||||
.split_once("...")
|
||||
.and_then(|(_, rest)| rest.split([' ', '[']).next())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let mut ahead = 0;
|
||||
let mut behind = 0;
|
||||
if let Some((_, bracketed)) = first_line.split_once('[') {
|
||||
let bracketed = bracketed.trim_end_matches(']');
|
||||
for part in bracketed.split(',').map(str::trim) {
|
||||
if let Some(value) = part.strip_prefix("ahead ") {
|
||||
ahead = value.parse().unwrap_or(0);
|
||||
} else if let Some(value) = part.strip_prefix("behind ") {
|
||||
behind = value.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fresh = upstream.as_ref().map(|_| behind == 0);
|
||||
Self {
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
fresh,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"upstream": self.upstream,
|
||||
"ahead": self.ahead,
|
||||
"behind": self.behind,
|
||||
"fresh": self.fresh,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BinaryPreflight {
|
||||
name: &'static str,
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl BinaryPreflight {
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"name": self.name,
|
||||
"available": self.available,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ControlSocketPreflight {
|
||||
name: &'static str,
|
||||
configured: bool,
|
||||
exists: bool,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
impl ControlSocketPreflight {
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"name": self.name,
|
||||
"configured": self.configured,
|
||||
"exists": self.exists,
|
||||
"path": self.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BootPreflightSnapshot {
|
||||
repo_exists: bool,
|
||||
worktree_exists: bool,
|
||||
git_dir_exists: bool,
|
||||
branch_freshness: BranchFreshness,
|
||||
trust_gate_allowed: Option<bool>,
|
||||
trusted_roots_count: usize,
|
||||
required_binaries: Vec<BinaryPreflight>,
|
||||
control_sockets: Vec<ControlSocketPreflight>,
|
||||
mcp_startup_eligible: bool,
|
||||
mcp_servers_configured: usize,
|
||||
plugin_startup_eligible: bool,
|
||||
plugins_configured: usize,
|
||||
last_failed_boot_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl BootPreflightSnapshot {
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"repo": {
|
||||
"exists": self.repo_exists,
|
||||
"worktree_exists": self.worktree_exists,
|
||||
"git_dir_exists": self.git_dir_exists,
|
||||
},
|
||||
"branch_freshness": self.branch_freshness.json_value(),
|
||||
"trust_gate": {
|
||||
"allowlisted": self.trust_gate_allowed,
|
||||
"trusted_roots_count": self.trusted_roots_count,
|
||||
},
|
||||
"required_binaries": self.required_binaries.iter().map(BinaryPreflight::json_value).collect::<Vec<_>>(),
|
||||
"control_sockets": self.control_sockets.iter().map(ControlSocketPreflight::json_value).collect::<Vec<_>>(),
|
||||
"mcp_startup": {
|
||||
"eligible": self.mcp_startup_eligible,
|
||||
"servers_configured": self.mcp_servers_configured,
|
||||
},
|
||||
"plugin_startup": {
|
||||
"eligible": self.plugin_startup_eligible,
|
||||
"plugins_configured": self.plugins_configured,
|
||||
},
|
||||
"last_failed_boot_reason": self.last_failed_boot_reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn summary(&self) -> String {
|
||||
let trust = self
|
||||
.trust_gate_allowed
|
||||
.map(|value| {
|
||||
if value {
|
||||
"allowlisted"
|
||||
} else {
|
||||
"not allowlisted"
|
||||
}
|
||||
})
|
||||
.unwrap_or("unknown");
|
||||
let freshness = self
|
||||
.branch_freshness
|
||||
.fresh
|
||||
.map(|fresh| if fresh { "fresh" } else { "behind" })
|
||||
.unwrap_or("no upstream");
|
||||
format!(
|
||||
"repo={} worktree={} branch={} trust={} mcp={} plugins={} last_failed={}",
|
||||
self.repo_exists,
|
||||
self.worktree_exists,
|
||||
freshness,
|
||||
trust,
|
||||
self.mcp_startup_eligible,
|
||||
self.plugin_startup_eligible,
|
||||
self.last_failed_boot_reason.as_deref().unwrap_or("none")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct StatusUsage {
|
||||
message_count: usize,
|
||||
@@ -3294,6 +3530,123 @@ fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
|
||||
summary
|
||||
}
|
||||
|
||||
fn build_boot_preflight_snapshot(
|
||||
cwd: &Path,
|
||||
project_root: Option<&Path>,
|
||||
git_status: Option<&str>,
|
||||
runtime_config: Option<&runtime::RuntimeConfig>,
|
||||
config_load_error: Option<&str>,
|
||||
) -> BootPreflightSnapshot {
|
||||
let branch_freshness = BranchFreshness::from_git_status(git_status);
|
||||
let worktree_exists = run_git_bool(cwd, &["rev-parse", "--is-inside-work-tree"]);
|
||||
let git_dir_exists = run_git_capture_in(cwd, &["rev-parse", "--git-dir"])
|
||||
.map(|path| {
|
||||
let path = PathBuf::from(path.trim());
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
})
|
||||
.is_some_and(|path| path.exists());
|
||||
let trusted_roots = runtime_config
|
||||
.map(runtime::RuntimeConfig::trusted_roots)
|
||||
.unwrap_or(&[]);
|
||||
let trust_gate_allowed = runtime_config.map(|_| {
|
||||
trusted_roots
|
||||
.iter()
|
||||
.any(|root| path_matches_trusted_root_local(cwd, root))
|
||||
});
|
||||
let plugin_configured = runtime_config
|
||||
.map(|config| config.plugins().enabled_plugins().len())
|
||||
.unwrap_or_default();
|
||||
let mcp_configured = runtime_config
|
||||
.map(|config| config.mcp().servers().len())
|
||||
.unwrap_or_default();
|
||||
let config_ok = config_load_error.is_none();
|
||||
BootPreflightSnapshot {
|
||||
repo_exists: project_root.is_some_and(Path::exists),
|
||||
worktree_exists,
|
||||
git_dir_exists,
|
||||
branch_freshness,
|
||||
trust_gate_allowed,
|
||||
trusted_roots_count: trusted_roots.len(),
|
||||
required_binaries: vec![
|
||||
BinaryPreflight {
|
||||
name: "claw",
|
||||
available: env::current_exe().is_ok_and(|path| path.exists()),
|
||||
},
|
||||
BinaryPreflight {
|
||||
name: "git",
|
||||
available: command_available("git"),
|
||||
},
|
||||
BinaryPreflight {
|
||||
name: "tmux",
|
||||
available: command_available("tmux"),
|
||||
},
|
||||
],
|
||||
control_sockets: vec![tmux_control_socket_preflight()],
|
||||
mcp_startup_eligible: config_ok,
|
||||
mcp_servers_configured: mcp_configured,
|
||||
plugin_startup_eligible: config_ok,
|
||||
plugins_configured: plugin_configured,
|
||||
last_failed_boot_reason: last_failed_boot_reason(cwd),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git_bool(cwd: &Path, args: &[&str]) -> bool {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn command_available(command: &str) -> bool {
|
||||
Command::new(command)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn tmux_control_socket_preflight() -> ControlSocketPreflight {
|
||||
let path = env::var("TMUX")
|
||||
.ok()
|
||||
.and_then(|value| value.split(',').next().map(str::to_string))
|
||||
.filter(|value| !value.is_empty());
|
||||
let exists = path.as_ref().is_some_and(|path| Path::new(path).exists());
|
||||
ControlSocketPreflight {
|
||||
name: "tmux",
|
||||
configured: path.is_some(),
|
||||
exists,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
fn last_failed_boot_reason(cwd: &Path) -> Option<String> {
|
||||
env::var("CLAW_LAST_FAILED_BOOT_REASON")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
fs::read_to_string(cwd.join(".claw").join("last-failed-boot.txt"))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn path_matches_trusted_root_local(cwd: &Path, trusted_root: &str) -> bool {
|
||||
let cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
||||
let trusted_root = Path::new(trusted_root);
|
||||
let trusted_root = if trusted_root.is_absolute() {
|
||||
trusted_root.to_path_buf()
|
||||
} else {
|
||||
cwd.join(trusted_root)
|
||||
};
|
||||
let trusted_root = fs::canonicalize(&trusted_root).unwrap_or(trusted_root);
|
||||
cwd == trusted_root || cwd.starts_with(trusted_root)
|
||||
}
|
||||
|
||||
fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
|
||||
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
|
||||
let branch = branch.trim();
|
||||
@@ -5810,6 +6163,8 @@ fn status_json_value(
|
||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||
}),
|
||||
"session_lifecycle": context.session_lifecycle.json_value(),
|
||||
"branch_freshness": context.branch_freshness.json_value(),
|
||||
"boot_preflight": context.boot_preflight.json_value(),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
@@ -5843,7 +6198,8 @@ fn status_context(
|
||||
// so that one malformed `mcpServers.*` entry doesn't take down the whole
|
||||
// health surface (workspace, git, model, permission, sandbox can still be
|
||||
// reported independently).
|
||||
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
|
||||
let runtime_config = loader.load();
|
||||
let (loaded_config_files, sandbox_status, config_load_error) = match runtime_config.as_ref() {
|
||||
Ok(runtime_config) => (
|
||||
runtime_config.loaded_entries().len(),
|
||||
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
|
||||
@@ -5864,6 +6220,14 @@ fn status_context(
|
||||
let (project_root, git_branch) =
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref());
|
||||
let boot_preflight = build_boot_preflight_snapshot(
|
||||
&cwd,
|
||||
project_root.as_deref(),
|
||||
project_context.git_status.as_deref(),
|
||||
runtime_config.as_ref().ok(),
|
||||
config_load_error.as_deref(),
|
||||
);
|
||||
Ok(StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
@@ -5873,7 +6237,9 @@ fn status_context(
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
branch_freshness,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
boot_preflight,
|
||||
sandbox_status,
|
||||
config_load_error,
|
||||
})
|
||||
@@ -5948,6 +6314,8 @@ fn format_status_report(
|
||||
Untracked {}
|
||||
Session {}
|
||||
Lifecycle {}
|
||||
Branch fresh {}
|
||||
Boot preflight {}
|
||||
Config files loaded {}/{}
|
||||
Memory files {}
|
||||
Suggested flow /status → /diff → /commit",
|
||||
@@ -5967,6 +6335,12 @@ fn format_status_report(
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.session_lifecycle.signal(),
|
||||
context
|
||||
.branch_freshness
|
||||
.fresh
|
||||
.map(|fresh| if fresh { "yes" } else { "behind" })
|
||||
.unwrap_or("no upstream"),
|
||||
context.boot_preflight.summary(),
|
||||
context.loaded_config_files,
|
||||
context.discovered_config_files,
|
||||
context.memory_file_count,
|
||||
@@ -12119,6 +12493,33 @@ mod tests {
|
||||
assert!(report.contains("Switch models with /model <name>"));
|
||||
}
|
||||
|
||||
fn test_branch_freshness() -> super::BranchFreshness {
|
||||
super::BranchFreshness {
|
||||
upstream: Some("origin/main".to_string()),
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fresh: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_boot_preflight() -> super::BootPreflightSnapshot {
|
||||
super::BootPreflightSnapshot {
|
||||
repo_exists: true,
|
||||
worktree_exists: true,
|
||||
git_dir_exists: true,
|
||||
branch_freshness: test_branch_freshness(),
|
||||
trust_gate_allowed: Some(false),
|
||||
trusted_roots_count: 0,
|
||||
required_binaries: Vec::new(),
|
||||
control_sockets: Vec::new(),
|
||||
mcp_startup_eligible: true,
|
||||
mcp_servers_configured: 0,
|
||||
plugin_startup_eligible: true,
|
||||
plugins_configured: 0,
|
||||
last_failed_boot_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_switch_report_preserves_context_summary() {
|
||||
let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
|
||||
@@ -12165,6 +12566,7 @@ mod tests {
|
||||
untracked_files: 1,
|
||||
conflicted_files: 0,
|
||||
},
|
||||
branch_freshness: test_branch_freshness(),
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some("%7".to_string()),
|
||||
@@ -12173,6 +12575,7 @@ mod tests {
|
||||
workspace_dirty: true,
|
||||
abandoned: true,
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
},
|
||||
@@ -12300,6 +12703,7 @@ mod tests {
|
||||
project_root: Some(PathBuf::from("/tmp/project")),
|
||||
git_branch: Some("feature/session-lifecycle".to_string()),
|
||||
git_summary: GitWorkspaceSummary::default(),
|
||||
branch_freshness: test_branch_freshness(),
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some("%9".to_string()),
|
||||
@@ -12308,6 +12712,7 @@ mod tests {
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
};
|
||||
@@ -12336,6 +12741,67 @@ mod tests {
|
||||
"claw"
|
||||
);
|
||||
assert_eq!(value["workspace"]["session_lifecycle"]["abandoned"], false);
|
||||
assert_eq!(value["workspace"]["branch_freshness"]["fresh"], true);
|
||||
assert_eq!(
|
||||
value["workspace"]["boot_preflight"]["repo"]["worktree_exists"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
value["workspace"]["boot_preflight"]["mcp_startup"]["eligible"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
value["workspace"]["boot_preflight"]["last_failed_boot_reason"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_freshness_parses_ahead_behind_status_header() {
|
||||
let freshness = super::BranchFreshness::from_git_status(Some(
|
||||
"## feature/boot...origin/feature/boot [ahead 2, behind 3]\n M src/main.rs",
|
||||
));
|
||||
|
||||
assert_eq!(freshness.upstream.as_deref(), Some("origin/feature/boot"));
|
||||
assert_eq!(freshness.ahead, 2);
|
||||
assert_eq!(freshness.behind, 3);
|
||||
assert_eq!(freshness.fresh, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_preflight_snapshot_reports_machine_readable_contract_fields() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_workspace("boot-preflight-json");
|
||||
fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
git(&["init", "--quiet"], &workspace);
|
||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||
fs::write(workspace.join(".claw.json"), r#"{"trustedRoots": ["."]}"#)
|
||||
.expect("write config");
|
||||
git(&["add", "tracked.txt"], &workspace);
|
||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||
|
||||
let loader = ConfigLoader::default_for(&workspace);
|
||||
let config = loader.load().expect("config should load");
|
||||
let status = super::run_git_capture_in(&workspace, &["status", "--short", "--branch"]);
|
||||
let snapshot = super::build_boot_preflight_snapshot(
|
||||
&workspace,
|
||||
Some(&workspace),
|
||||
status.as_deref(),
|
||||
Some(&config),
|
||||
None,
|
||||
);
|
||||
let json = snapshot.json_value();
|
||||
|
||||
assert_eq!(json["repo"]["exists"], true);
|
||||
assert_eq!(json["repo"]["worktree_exists"], true);
|
||||
assert_eq!(json["trust_gate"]["allowlisted"], true);
|
||||
assert_eq!(json["mcp_startup"]["eligible"], true);
|
||||
assert!(json["required_binaries"]
|
||||
.as_array()
|
||||
.is_some_and(|items| { items.iter().any(|item| item["name"] == "git") }));
|
||||
fs::remove_dir_all(workspace).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,6 +91,32 @@ fn status_and_sandbox_emit_json_when_requested() {
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_surfaces_permission_mode_override_for_security_audit() {
|
||||
let root = unique_temp_dir("status-json-permission-mode");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--output-format",
|
||||
"json",
|
||||
"status",
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(parsed["permission_mode"], "read-only");
|
||||
assert!(
|
||||
parsed["workspace"]["cwd"].as_str().is_some(),
|
||||
"status JSON should retain workspace context with permission mode"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_guidance_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("acp-json");
|
||||
@@ -284,7 +310,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 6);
|
||||
assert_eq!(checks.len(), 7);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
@@ -301,6 +327,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
"config",
|
||||
"install source",
|
||||
"workspace",
|
||||
"boot preflight",
|
||||
"sandbox",
|
||||
"system"
|
||||
]
|
||||
@@ -326,6 +353,14 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(workspace["cwd"].as_str().is_some());
|
||||
assert!(workspace["in_git_repo"].is_boolean());
|
||||
|
||||
let boot_preflight = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "boot preflight")
|
||||
.expect("boot preflight check");
|
||||
assert!(boot_preflight["boot_preflight"]["repo"]["exists"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["mcp_startup"]["eligible"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["required_binaries"].is_array());
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "sandbox")
|
||||
|
||||
+374
-52
@@ -12,22 +12,22 @@ use api::{
|
||||
use plugins::PluginTool;
|
||||
use reqwest::blocking::Client;
|
||||
use runtime::{
|
||||
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
||||
grep_search, load_system_prompt,
|
||||
check_freshness, dedupe_superseded_commit_events, edit_file_in_workspace, execute_bash,
|
||||
glob_search_in_workspace, grep_search_in_workspace, load_system_prompt,
|
||||
lsp_client::LspRegistry,
|
||||
mcp_tool_bridge::McpToolRegistry,
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
read_file,
|
||||
read_file_in_workspace,
|
||||
summary_compression::compress_summary_text,
|
||||
task_registry::TaskRegistry,
|
||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||
worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt},
|
||||
write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput,
|
||||
BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage, ConversationRuntime,
|
||||
GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName,
|
||||
LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode,
|
||||
PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, Session, TaskPacket,
|
||||
ToolError, ToolExecutor,
|
||||
write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput,
|
||||
BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage,
|
||||
ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||
LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole,
|
||||
PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError,
|
||||
Session, TaskPacket, ToolError, ToolExecutor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -1198,6 +1198,7 @@ pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
||||
execute_tool_with_enforcer(None, name, input)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn execute_tool_with_enforcer(
|
||||
enforcer: Option<&PermissionEnforcer>,
|
||||
name: &str,
|
||||
@@ -1212,24 +1213,34 @@ fn execute_tool_with_enforcer(
|
||||
run_bash(bash_input)
|
||||
}
|
||||
"read_file" => {
|
||||
maybe_enforce_permission_check(enforcer, name, input)?;
|
||||
from_value::<ReadFileInput>(input).and_then(run_read_file)
|
||||
let file_input: ReadFileInput = from_value(input)?;
|
||||
let required_mode = classify_file_path_permission(&file_input.path, false);
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_read_file(file_input)
|
||||
}
|
||||
"write_file" => {
|
||||
maybe_enforce_permission_check(enforcer, name, input)?;
|
||||
from_value::<WriteFileInput>(input).and_then(run_write_file)
|
||||
let file_input: WriteFileInput = from_value(input)?;
|
||||
let required_mode = classify_file_path_permission(&file_input.path, true);
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_write_file(file_input)
|
||||
}
|
||||
"edit_file" => {
|
||||
maybe_enforce_permission_check(enforcer, name, input)?;
|
||||
from_value::<EditFileInput>(input).and_then(run_edit_file)
|
||||
let file_input: EditFileInput = from_value(input)?;
|
||||
let required_mode = classify_file_path_permission(&file_input.path, false);
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_edit_file(file_input)
|
||||
}
|
||||
"glob_search" => {
|
||||
maybe_enforce_permission_check(enforcer, name, input)?;
|
||||
from_value::<GlobSearchInputValue>(input).and_then(run_glob_search)
|
||||
let glob_input: GlobSearchInputValue = from_value(input)?;
|
||||
let required_mode = classify_glob_permission(&glob_input);
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_glob_search(glob_input)
|
||||
}
|
||||
"grep_search" => {
|
||||
maybe_enforce_permission_check(enforcer, name, input)?;
|
||||
from_value::<GrepSearchInput>(input).and_then(run_grep_search)
|
||||
let grep_input: GrepSearchInput = from_value(input)?;
|
||||
let required_mode = classify_grep_permission(&grep_input);
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_grep_search(grep_input)
|
||||
}
|
||||
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
|
||||
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
|
||||
@@ -1298,17 +1309,6 @@ fn execute_tool_with_enforcer(
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_enforce_permission_check(
|
||||
enforcer: Option<&PermissionEnforcer>,
|
||||
tool_name: &str,
|
||||
input: &Value,
|
||||
) -> Result<(), String> {
|
||||
if let Some(enforcer) = enforcer {
|
||||
enforce_permission_check(enforcer, tool_name, input)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enforce permission check with a dynamically classified permission mode.
|
||||
/// Used for tools like bash and `PowerShell` where the required permission
|
||||
/// depends on the actual command being executed.
|
||||
@@ -1500,15 +1500,11 @@ fn run_task_output(input: TaskIdInput) -> Result<String, String> {
|
||||
fn run_worker_create(input: WorkerCreateInput) -> Result<String, String> {
|
||||
// Merge config-level trusted_roots with per-call overrides.
|
||||
// Config provides the default allowlist; per-call roots add on top.
|
||||
let config_roots: Vec<String> = ConfigLoader::default_for(&input.cwd)
|
||||
let merged_roots: Vec<String> = ConfigLoader::default_for(&input.cwd)
|
||||
.load()
|
||||
.ok()
|
||||
.map(|c| c.trusted_roots().to_vec())
|
||||
.unwrap_or_default();
|
||||
let merged_roots: Vec<String> = config_roots
|
||||
.into_iter()
|
||||
.chain(input.trusted_roots.iter().cloned())
|
||||
.collect();
|
||||
.map(|config| config.trusted_roots_with_overrides(&input.trusted_roots))
|
||||
.unwrap_or_else(|| input.trusted_roots.clone());
|
||||
let worker = global_worker_registry().create(
|
||||
&input.cwd,
|
||||
&merged_roots,
|
||||
@@ -1885,20 +1881,38 @@ fn classify_bash_permission(command: &str) -> PermissionMode {
|
||||
fn has_dangerous_paths(command: &str) -> bool {
|
||||
// Look for absolute paths
|
||||
let tokens: Vec<&str> = command.split_whitespace().collect();
|
||||
let cwd = std::env::current_dir()
|
||||
.ok()
|
||||
.map(|cwd| cwd.canonicalize().unwrap_or(cwd));
|
||||
|
||||
for token in tokens {
|
||||
let token = token.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'"' | '\'' | '`' | ',' | ';' | ')' | '(' | '[' | ']' | '{' | '}'
|
||||
)
|
||||
});
|
||||
// Skip flags/options
|
||||
if token.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if token.contains('$') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if looks_like_windows_absolute_path(token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for absolute paths
|
||||
if token.starts_with('/') || token.starts_with("~/") {
|
||||
// Check if it's within CWD
|
||||
let path =
|
||||
PathBuf::from(token.replace('~', &std::env::var("HOME").unwrap_or_default()));
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
if !path.starts_with(&cwd) {
|
||||
if let Some(cwd) = cwd.as_ref() {
|
||||
let resolved = path.canonicalize().unwrap_or(path);
|
||||
if !resolved.starts_with(cwd) {
|
||||
return true; // Path outside workspace
|
||||
}
|
||||
}
|
||||
@@ -1908,11 +1922,35 @@ fn has_dangerous_paths(command: &str) -> bool {
|
||||
if token.contains("../..") || token.starts_with("../") && !token.starts_with("./") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(cwd) = cwd.as_ref() {
|
||||
if token.starts_with('.') || token.contains('/') || Path::new(token).exists() {
|
||||
let candidate = if Path::new(token).is_absolute() {
|
||||
PathBuf::from(token)
|
||||
} else {
|
||||
cwd.join(token)
|
||||
};
|
||||
if let Ok(canonical) = candidate.canonicalize() {
|
||||
if !canonical.starts_with(cwd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn looks_like_windows_absolute_path(token: &str) -> bool {
|
||||
let bytes = token.as_bytes();
|
||||
(bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'/' | b'\\'))
|
||||
|| token.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn run_bash(input: BashCommandInput) -> Result<String, String> {
|
||||
if let Some(output) = workspace_test_branch_preflight(&input.command) {
|
||||
return serde_json::to_string_pretty(&output).map_err(|error| error.to_string());
|
||||
@@ -1996,8 +2034,7 @@ fn git_ref_exists(reference: &str) -> bool {
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "--verify", "--quiet", reference])
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn git_stdout(args: &[&str]) -> Option<String> {
|
||||
@@ -2069,22 +2106,31 @@ fn branch_divergence_output(
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||
to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
|
||||
.map_err(io_to_string)?,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_write_file(input: WriteFileInput) -> Result<String, String> {
|
||||
to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
write_file_in_workspace(&input.path, &input.content, &workspace).map_err(io_to_string)?,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
edit_file(
|
||||
edit_file_in_workspace(
|
||||
&input.path,
|
||||
&input.old_string,
|
||||
&input.new_string,
|
||||
input.replace_all.unwrap_or(false),
|
||||
&workspace,
|
||||
)
|
||||
.map_err(io_to_string)?,
|
||||
)
|
||||
@@ -2092,12 +2138,17 @@ fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
|
||||
to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
glob_search_in_workspace(&input.pattern, input.path.as_deref(), &workspace)
|
||||
.map_err(io_to_string)?,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
|
||||
to_pretty_json(grep_search(&input).map_err(io_to_string)?)
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(grep_search_in_workspace(&input, &workspace).map_err(io_to_string)?)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
@@ -2158,6 +2209,77 @@ fn run_repl(input: ReplInput) -> Result<String, String> {
|
||||
to_pretty_json(execute_repl(input)?)
|
||||
}
|
||||
|
||||
fn classify_file_path_permission(path: &str, allow_missing: bool) -> PermissionMode {
|
||||
if path_within_current_workspace(path, allow_missing) {
|
||||
PermissionMode::WorkspaceWrite
|
||||
} else {
|
||||
PermissionMode::DangerFullAccess
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_glob_permission(input: &GlobSearchInputValue) -> PermissionMode {
|
||||
let base_allowed = input
|
||||
.path
|
||||
.as_deref()
|
||||
.is_none_or(|path| path_within_current_workspace(path, false));
|
||||
let pattern_allowed = path_within_current_workspace(&input.pattern, true);
|
||||
if base_allowed && pattern_allowed {
|
||||
PermissionMode::WorkspaceWrite
|
||||
} else {
|
||||
PermissionMode::DangerFullAccess
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_grep_permission(input: &GrepSearchInput) -> PermissionMode {
|
||||
if input
|
||||
.path
|
||||
.as_deref()
|
||||
.is_none_or(|path| path_within_current_workspace(path, false))
|
||||
{
|
||||
PermissionMode::WorkspaceWrite
|
||||
} else {
|
||||
PermissionMode::DangerFullAccess
|
||||
}
|
||||
}
|
||||
|
||||
fn path_within_current_workspace(path: &str, allow_missing: bool) -> bool {
|
||||
let trimmed = path.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'"' | '\'' | '`' | ',' | ';' | ')' | '(' | '[' | ']' | '{' | '}'
|
||||
)
|
||||
});
|
||||
if looks_like_windows_absolute_path(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return false;
|
||||
};
|
||||
let cwd = cwd.canonicalize().unwrap_or(cwd);
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
let absolute = if candidate.is_absolute() {
|
||||
candidate
|
||||
} else {
|
||||
cwd.join(candidate)
|
||||
};
|
||||
|
||||
let resolved = if allow_missing {
|
||||
absolute
|
||||
.parent()
|
||||
.and_then(|parent| parent.canonicalize().ok())
|
||||
.map(|parent| parent.join(absolute.file_name().unwrap_or_default()))
|
||||
.unwrap_or(absolute)
|
||||
} else {
|
||||
match absolute.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(_) => absolute,
|
||||
}
|
||||
};
|
||||
|
||||
resolved.starts_with(cwd)
|
||||
}
|
||||
|
||||
/// Classify `PowerShell` command permission based on command type and path.
|
||||
/// ROADMAP #50: Read-only commands targeting CWD paths get `WorkspaceWrite`,
|
||||
/// all others remain `DangerFullAccess`.
|
||||
@@ -2217,12 +2339,24 @@ fn extract_powershell_path(command: &str) -> Option<String> {
|
||||
|
||||
/// Check if a path is within the current workspace.
|
||||
fn is_within_workspace(path: &str) -> bool {
|
||||
let path = PathBuf::from(path);
|
||||
let trimmed = path.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'"' | '\'' | '`' | ',' | ';' | ')' | '(' | '[' | ']' | '{' | '}'
|
||||
)
|
||||
});
|
||||
if looks_like_windows_absolute_path(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = PathBuf::from(trimmed);
|
||||
|
||||
// If path is absolute, check if it starts with CWD
|
||||
if path.is_absolute() {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
return path.starts_with(&cwd);
|
||||
let cwd = cwd.canonicalize().unwrap_or(cwd);
|
||||
let resolved = path.canonicalize().unwrap_or(path);
|
||||
return resolved.starts_with(&cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5989,8 +6123,7 @@ fn command_exists(command: &str) -> bool {
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command} >/dev/null 2>&1"))
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -6435,6 +6568,45 @@ mod tests {
|
||||
fs::remove_dir_all(&worktree).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_create_merges_config_trusted_roots_with_per_call_roots() {
|
||||
use std::fs;
|
||||
|
||||
let worktree = temp_path("config-and-call-trust-worktree");
|
||||
let claw_dir = worktree.join(".claw");
|
||||
fs::create_dir_all(&claw_dir).expect("create .claw dir");
|
||||
fs::write(
|
||||
claw_dir.join("settings.json"),
|
||||
r#"{"trustedRoots": ["/definitely/not/this/worktree"]}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let cwd = worktree.to_str().expect("valid utf-8").to_string();
|
||||
let parent = worktree
|
||||
.parent()
|
||||
.expect("temp path has parent")
|
||||
.to_str()
|
||||
.expect("valid parent utf-8")
|
||||
.to_string();
|
||||
|
||||
let created = execute_tool(
|
||||
"WorkerCreate",
|
||||
&json!({
|
||||
"cwd": cwd,
|
||||
"trusted_roots": [parent]
|
||||
}),
|
||||
)
|
||||
.expect("WorkerCreate should succeed");
|
||||
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||
|
||||
assert_eq!(
|
||||
output["trust_auto_resolve"], true,
|
||||
"per-call trusted_roots must extend config defaults for this create request"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&worktree).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_terminate_sets_finished_status() {
|
||||
// Create a worker in running state
|
||||
@@ -8630,8 +8802,12 @@ mod tests {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let path = temp_path("subagent-input.txt");
|
||||
let root = temp_path("subagent-runtime");
|
||||
std::fs::create_dir_all(&root).expect("create root");
|
||||
let path = root.join("subagent-input.txt");
|
||||
std::fs::write(&path, "hello from child").expect("write input file");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
@@ -8663,7 +8839,8 @@ mod tests {
|
||||
if output.contains("hello from child")
|
||||
)));
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9117,6 +9294,78 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tools_reject_paths_outside_current_workspace() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("workspace-scope");
|
||||
let outside = temp_path("workspace-scope-outside");
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::create_dir_all(&outside).expect("create outside");
|
||||
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let read_error = execute_tool(
|
||||
"read_file",
|
||||
&json!({ "path": outside.join("secret.txt").display().to_string() }),
|
||||
)
|
||||
.expect_err("read outside workspace should fail");
|
||||
assert!(read_error.contains("escapes workspace"));
|
||||
|
||||
let write_error = execute_tool(
|
||||
"write_file",
|
||||
&json!({ "path": outside.join("created.txt").display().to_string(), "content": "nope" }),
|
||||
)
|
||||
.expect_err("write outside workspace should fail");
|
||||
assert!(write_error.contains("escapes workspace"));
|
||||
assert!(!outside.join("created.txt").exists());
|
||||
|
||||
let glob_error = execute_tool(
|
||||
"glob_search",
|
||||
&json!({ "pattern": outside.join("*.txt").display().to_string() }),
|
||||
)
|
||||
.expect_err("absolute glob outside workspace should fail");
|
||||
assert!(glob_error.contains("escapes workspace"));
|
||||
|
||||
let grep_error = execute_tool(
|
||||
"grep_search",
|
||||
&json!({ "pattern": "secret", "path": outside.display().to_string() }),
|
||||
)
|
||||
.expect_err("grep outside workspace should fail");
|
||||
assert!(grep_error.contains("escapes workspace"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_dir_all(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn file_tools_reject_symlink_escape_from_current_workspace() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("workspace-symlink-scope");
|
||||
let outside = temp_path("workspace-symlink-outside");
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::create_dir_all(&outside).expect("create outside");
|
||||
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt"))
|
||||
.expect("create symlink");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let error = execute_tool("read_file", &json!({ "path": "link.txt" }))
|
||||
.expect_err("symlink outside workspace should fail");
|
||||
assert!(error.contains("escapes workspace"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_dir_all(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_waits_and_reports_duration() {
|
||||
let started = std::time::Instant::now();
|
||||
@@ -9530,6 +9779,19 @@ printf 'pwsh:%s' "$1"
|
||||
registry
|
||||
}
|
||||
|
||||
fn workspace_write_registry() -> super::GlobalToolRegistry {
|
||||
use runtime::permission_enforcer::PermissionEnforcer;
|
||||
use runtime::PermissionPolicy;
|
||||
|
||||
let policy = mvp_tool_specs().into_iter().fold(
|
||||
PermissionPolicy::new(runtime::PermissionMode::WorkspaceWrite),
|
||||
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|
||||
);
|
||||
let mut registry = super::GlobalToolRegistry::builtin();
|
||||
registry.set_enforcer(PermissionEnforcer::new(policy));
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_bash_then_denied() {
|
||||
let registry = read_only_registry();
|
||||
@@ -9543,6 +9805,63 @@ printf 'pwsh:%s' "$1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
|
||||
let registry = workspace_write_registry();
|
||||
let err = registry
|
||||
.execute("bash", &json!({ "command": "cat $HOME/.ssh/config" }))
|
||||
.expect_err("shell-expanded path should require elevated permission");
|
||||
assert!(
|
||||
err.contains("requires 'danger-full-access'"),
|
||||
"should require elevated mode: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_workspace_write_enforcer_when_bash_uses_windows_absolute_path_then_denied() {
|
||||
let registry = workspace_write_registry();
|
||||
let err = registry
|
||||
.execute(
|
||||
"bash",
|
||||
&json!({ "command": r"cat C:\\Users\\alice\\.ssh\\config" }),
|
||||
)
|
||||
.expect_err("Windows absolute path should require elevated permission");
|
||||
assert!(
|
||||
err.contains("requires 'danger-full-access'"),
|
||||
"should require elevated mode: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn given_workspace_write_enforcer_when_bash_reads_symlink_escape_then_denied() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("bash-symlink-scope");
|
||||
let outside = temp_path("bash-symlink-outside");
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::create_dir_all(&outside).expect("create outside");
|
||||
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt"))
|
||||
.expect("create symlink");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let registry = workspace_write_registry();
|
||||
let err = registry
|
||||
.execute("bash", &json!({ "command": "cat link.txt" }))
|
||||
.expect_err("symlink escape should require elevated permission");
|
||||
assert!(
|
||||
err.contains("requires 'danger-full-access'"),
|
||||
"should require elevated mode: {err}"
|
||||
);
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_dir_all(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_write_file_then_denied() {
|
||||
let registry = read_only_registry();
|
||||
@@ -9582,11 +9901,14 @@ printf 'pwsh:%s' "$1"
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
let file = root.join("readable.txt");
|
||||
fs::write(&file, "content\n").expect("write test file");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let registry = read_only_registry();
|
||||
let result = registry.execute("read_file", &json!({ "path": file.display().to_string() }));
|
||||
assert!(result.is_ok(), "read_file should be allowed: {result:?}");
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
use runtime::{permission_enforcer::PermissionEnforcer, PermissionMode, PermissionPolicy};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tools::{mvp_tool_specs, GlobalToolRegistry};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("claw-path-scope-{unique}-{name}"))
|
||||
}
|
||||
|
||||
fn workspace_write_registry() -> GlobalToolRegistry {
|
||||
let policy = mvp_tool_specs().into_iter().fold(
|
||||
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|
||||
);
|
||||
GlobalToolRegistry::builtin().with_enforcer(PermissionEnforcer::new(policy))
|
||||
}
|
||||
|
||||
fn run_bash(command: &str) -> Result<String, String> {
|
||||
workspace_write_registry().execute("bash", &json!({ "command": command }))
|
||||
}
|
||||
|
||||
fn run_powershell(command: &str) -> Result<String, String> {
|
||||
workspace_write_registry().execute("PowerShell", &json!({ "command": command }))
|
||||
}
|
||||
|
||||
fn run_read_file(path: &Path) -> Result<String, String> {
|
||||
workspace_write_registry().execute("read_file", &json!({ "path": path.display().to_string() }))
|
||||
}
|
||||
|
||||
fn assert_permission_denied(result: Result<String, String>, case_name: &str) {
|
||||
let err = result
|
||||
.unwrap_err_or_else(|ok| panic!("{case_name} should be denied before execution, got {ok}"));
|
||||
assert!(
|
||||
(err.contains("requires danger-full-access permission")
|
||||
|| err.contains("requires \'danger-full-access\' permission"))
|
||||
|| err.contains("current mode is workspace-write")
|
||||
|| err.contains("escapes workspace"),
|
||||
"{case_name} should fail in permission enforcement, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
trait UnwrapErrOrElse<T, E> {
|
||||
fn unwrap_err_or_else<F: FnOnce(T) -> E>(self, op: F) -> E;
|
||||
}
|
||||
|
||||
impl<T, E> UnwrapErrOrElse<T, E> for Result<T, E> {
|
||||
fn unwrap_err_or_else<F: FnOnce(T) -> E>(self, op: F) -> E {
|
||||
match self {
|
||||
Ok(value) => op(value),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_cwd<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
||||
let previous = std::env::current_dir().expect("current dir");
|
||||
std::env::set_current_dir(cwd).expect("set cwd");
|
||||
let result = f();
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_paths_allow_workspace_file_and_deny_absolute_outside_file() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("direct");
|
||||
fs::create_dir_all(root.join("src")).expect("create workspace");
|
||||
fs::write(root.join("src/lib.rs"), "workspace\n").expect("write workspace file");
|
||||
let outside = temp_path("direct-outside.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
|
||||
with_cwd(&root, || {
|
||||
let allowed = run_bash("cat src/lib.rs").expect("workspace-relative read should execute");
|
||||
assert!(allowed.contains("workspace"));
|
||||
assert_permission_denied(
|
||||
run_bash(&format!("cat {}", outside.display())),
|
||||
"absolute outside file",
|
||||
);
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tool_direct_outside_path_is_denied_before_reading() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("file-tool-direct");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
let outside = temp_path("file-tool-secret.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
|
||||
with_cwd(&root, || {
|
||||
assert_permission_denied(run_read_file(&outside), "read_file outside workspace");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_resolving_outside_workspace_is_denied_before_execution() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("symlink");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
let outside = temp_path("symlink-secret.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
std::os::unix::fs::symlink(&outside, root.join("secret-link")).expect("create symlink");
|
||||
|
||||
with_cwd(&root, || {
|
||||
assert_permission_denied(run_bash("cat secret-link"), "outside symlink");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_expansion_and_glob_parent_traversal_are_denied_before_execution() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("expansion");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
|
||||
with_cwd(&root, || {
|
||||
for (name, command) in [
|
||||
("parent glob", "ls ../*"),
|
||||
("PWD parent expansion", "cat $PWD/../secret.txt"),
|
||||
("braced PWD parent expansion", "cat ${PWD}/../secret.txt"),
|
||||
(
|
||||
"command substitution parent expansion",
|
||||
"cat $(pwd)/../secret.txt",
|
||||
),
|
||||
] {
|
||||
assert_permission_denied(run_bash(command), name);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_worktree_paths_are_allowed_but_parent_escape_is_denied() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("worktree");
|
||||
let worktree = root.join("main").join("linked-worktree");
|
||||
fs::create_dir_all(worktree.join("src")).expect("create worktree");
|
||||
fs::write(worktree.join("src/lib.rs"), "worktree\n").expect("write worktree file");
|
||||
|
||||
with_cwd(&worktree, || {
|
||||
let allowed =
|
||||
run_bash("cat src/lib.rs").expect("nested worktree-relative read should execute");
|
||||
assert!(allowed.contains("worktree"));
|
||||
assert_permission_denied(run_bash("cat ../../outside.txt"), "worktree parent escape");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_style_absolute_paths_are_denied_before_execution() {
|
||||
for (name, command) in [
|
||||
(
|
||||
"windows drive backslash",
|
||||
r"cat C:\Users\attacker\secret.txt",
|
||||
),
|
||||
("windows drive slash", r"cat C:/Users/attacker/secret.txt"),
|
||||
] {
|
||||
assert_permission_denied(run_bash(command), name);
|
||||
}
|
||||
|
||||
for (name, command) in [
|
||||
(
|
||||
"powershell windows drive backslash",
|
||||
r"Get-Content -Path C:\Users\attacker\secret.txt",
|
||||
),
|
||||
(
|
||||
"powershell windows drive slash",
|
||||
r"Get-Content -Path C:/Users/attacker/secret.txt",
|
||||
),
|
||||
] {
|
||||
assert_permission_denied(run_powershell(command), name);
|
||||
}
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical CC2 board command wrapper.
|
||||
|
||||
This script intentionally delegates to the richer G001 board generator,
|
||||
validator, and Markdown renderer so all entrypoints enforce the same schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path) -> int:
|
||||
return subprocess.run(cmd, cwd=str(cwd)).returncode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["generate", "validate"])
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="repository root containing ROADMAP.md")
|
||||
parser.add_argument("--context-root", type=Path, default=None, help="accepted for compatibility; source .omx is auto-detected by the generator")
|
||||
parser.add_argument("--board-json", default=".omx/cc2/board.json")
|
||||
parser.add_argument("--board-md", default=".omx/cc2/board.md")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_json = repo_root / args.board_json
|
||||
board_md = repo_root / args.board_md
|
||||
generator = repo_root / "scripts" / "generate_cc2_board.py"
|
||||
validator = repo_root / "scripts" / "validate_cc2_board.py"
|
||||
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
|
||||
if args.command == "generate":
|
||||
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
return run([sys.executable, str(renderer), str(board_json), str(board_md)], repo_root)
|
||||
|
||||
checks = [
|
||||
[sys.executable, str(validator), "--repo-root", str(repo_root), "--board", str(board_json)],
|
||||
[sys.executable, str(renderer), str(board_json), str(board_md), "--check"],
|
||||
]
|
||||
for cmd in checks:
|
||||
rc = run(cmd, repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
print(f"CC2 board validation PASS: {board_json} and {board_md} are canonical and in sync")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+525
@@ -0,0 +1,525 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the canonical Claw Code 2.0 execution board from frozen roadmap evidence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REQUIRED_ITEM_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
]
|
||||
STATUSES = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
RELEASE_BUCKETS = {
|
||||
"alpha_blocker",
|
||||
"beta_adoption",
|
||||
"ga_ecosystem",
|
||||
"post_2_0_research",
|
||||
"rejected_not_claw",
|
||||
"context",
|
||||
"2.x_intake",
|
||||
}
|
||||
|
||||
STRUCTURAL_HEADINGS = {
|
||||
"Clawable Coding Harness Roadmap",
|
||||
"Goal",
|
||||
'Definition of "clawable"',
|
||||
"Current Pain Points",
|
||||
"Product Principles",
|
||||
"Roadmap",
|
||||
"Immediate Backlog (from current real pain)",
|
||||
"Deployment Architecture Gap (filed from dogfood 2026-04-08)",
|
||||
"Startup Friction Gap: No Default trusted_roots in Settings (filed 2026-04-08)",
|
||||
"Observability Transport Decision (filed 2026-04-08)",
|
||||
"Provider Routing: Model-Name Prefix Must Win Over Env-Var Presence (fixed 2026-04-08, `0530c50`)",
|
||||
}
|
||||
|
||||
CATEGORY_KEYWORDS = [
|
||||
("security", ["security", "sandbox", "permission", "trust", "approval-token", "denied"]),
|
||||
("windows_install", ["windows", "install", "path", "release", "binary", "container"]),
|
||||
("provider", ["provider", "model", "openai", "anthropic", "ollama", "llama", "vllm", "credential"]),
|
||||
("sessions", ["session", "resume", "compact", "context-window", "thread"]),
|
||||
("docs_license", ["docs", "readme", "usage", "license", "help", "onboarding"]),
|
||||
("ide_acp", ["zed", "acp", "editor", "daemon"]),
|
||||
("plugin_mcp", ["plugin", "mcp", "marketplace", "server"]),
|
||||
("event_report", ["event", "report", "schema", "projection", "redaction", "clawhip", "lane"]),
|
||||
("branch_recovery", ["branch", "stale", "recovery", "green", "flake"]),
|
||||
("boot", ["boot", "worker", "startup", "ready", "prompt"]),
|
||||
("task_policy", ["task", "policy", "claw-native", "dashboard", "lane board"]),
|
||||
("ux_tui", ["tui", "statusline", "keymap", "clickable", "copy", "paste"]),
|
||||
("anti_slop", ["spam", "slop", "issue hygiene", "bot"]),
|
||||
]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoadmapRecord:
|
||||
line: int
|
||||
level: int
|
||||
title: str
|
||||
path: str
|
||||
source_type: str
|
||||
ordinal: int | None = None
|
||||
|
||||
|
||||
def sha256_prefix(path: Path, length: int = 16) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:length]
|
||||
|
||||
|
||||
def slugify(text: str, limit: int = 54) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
return slug[:limit].strip("-") or "item"
|
||||
|
||||
|
||||
def find_source_omx(repo_root: Path) -> Path:
|
||||
candidates = []
|
||||
env = None
|
||||
try:
|
||||
import os
|
||||
env = os.environ.get("CC2_SOURCE_OMX")
|
||||
except Exception:
|
||||
env = None
|
||||
if env:
|
||||
candidates.append(Path(env).expanduser())
|
||||
candidates.append(repo_root / ".omx")
|
||||
candidates.extend(parent / ".omx" for parent in repo_root.parents)
|
||||
for candidate in candidates:
|
||||
if (candidate / "plans" / "claw-code-2-0-adaptive-plan.md").exists() and (candidate / "research").exists():
|
||||
return candidate
|
||||
raise FileNotFoundError("could not locate source .omx with plans/claw-code-2-0-adaptive-plan.md and research/")
|
||||
|
||||
|
||||
def parse_roadmap(path: Path) -> tuple[list[RoadmapRecord], list[RoadmapRecord]]:
|
||||
headings: list[RoadmapRecord] = []
|
||||
actions: list[RoadmapRecord] = []
|
||||
stack: list[tuple[str, int, int]] = []
|
||||
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
heading = re.match(r"^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$", line)
|
||||
if heading:
|
||||
level = len(heading.group(1))
|
||||
title = heading.group(2).strip()
|
||||
stack = [entry for entry in stack if entry[1] < level] + [(title, level, line_no)]
|
||||
headings.append(RoadmapRecord(line_no, level, title, " > ".join(entry[0] for entry in stack), "roadmap_heading"))
|
||||
continue
|
||||
ordered = re.match(r"^(\s*)(\d+)\.\s+(.+?)\s*$", line)
|
||||
if ordered and len(ordered.group(1)) <= 4:
|
||||
title = ordered.group(3).strip()
|
||||
if len(title) > 10:
|
||||
actions.append(
|
||||
RoadmapRecord(
|
||||
line_no,
|
||||
len(stack[-1][0]) if stack else 0,
|
||||
title,
|
||||
" > ".join(entry[0] for entry in stack),
|
||||
"roadmap_action",
|
||||
int(ordered.group(2)),
|
||||
)
|
||||
)
|
||||
return headings, actions
|
||||
|
||||
|
||||
def category_for(text: str) -> str:
|
||||
lower = text.lower()
|
||||
for category, needles in CATEGORY_KEYWORDS:
|
||||
if any(needle in lower for needle in needles):
|
||||
return category
|
||||
return "governance"
|
||||
|
||||
|
||||
def stream_for(record: RoadmapRecord) -> str:
|
||||
title = record.title.lower()
|
||||
path = record.path.lower()
|
||||
combined = f"{path} {title}"
|
||||
if "phase 1" in combined or category_for(combined) == "boot":
|
||||
return "stream_1_worker_boot_session_control"
|
||||
if "phase 2" in combined or category_for(combined) == "event_report":
|
||||
return "stream_2_event_reporting_contracts"
|
||||
if "phase 3" in combined or category_for(combined) == "branch_recovery":
|
||||
return "stream_3_branch_test_recovery"
|
||||
if "phase 4" in combined or category_for(combined) == "task_policy":
|
||||
return "stream_4_claws_first_execution"
|
||||
if "phase 5" in combined or category_for(combined) == "plugin_mcp":
|
||||
return "stream_5_plugin_mcp_lifecycle"
|
||||
if any(k in combined for k in ["windows", "install", "provider", "docs", "license", "session hygiene", "compact"]):
|
||||
return "adoption_overlay"
|
||||
if any(k in combined for k in ["zed", "acp", "desktop", "marketplace", "package"]):
|
||||
return "parity_overlay"
|
||||
return "stream_0_governance"
|
||||
|
||||
|
||||
def release_bucket_for(record: RoadmapRecord, status: str) -> str:
|
||||
combined = f"{record.path} {record.title}".lower()
|
||||
category = category_for(combined)
|
||||
if status == "context":
|
||||
return "context"
|
||||
if status == "rejected_not_claw":
|
||||
return "rejected_not_claw"
|
||||
if any(k in combined for k in ["phase 1", "phase 2", "phase 3", "phase 4", "p0", "p1", "security", "sandbox", "trust", "worker", "event", "branch freshness"]):
|
||||
return "alpha_blocker"
|
||||
if category in {"windows_install", "provider", "sessions", "docs_license", "anti_slop"}:
|
||||
return "beta_adoption"
|
||||
if category in {"plugin_mcp", "ide_acp", "ux_tui"}:
|
||||
return "ga_ecosystem"
|
||||
if any(k in combined for k in ["desktop", "share", "cloud", "research", "post-2.0", "future"]):
|
||||
return "post_2_0_research"
|
||||
if "pinpoint" in combined:
|
||||
return "alpha_blocker"
|
||||
return "beta_adoption"
|
||||
|
||||
|
||||
def status_for(record: RoadmapRecord) -> str:
|
||||
title = record.title
|
||||
combined = f"{record.path} {title}".lower()
|
||||
if record.source_type == "roadmap_heading" and (record.level <= 2 or title in STRUCTURAL_HEADINGS):
|
||||
# Phase headings are active work containers; other h1/h2 prose headings are context unless fixed/deferred wording says otherwise.
|
||||
if title.startswith("Phase "):
|
||||
return "active"
|
||||
if "pinpoint" not in title.lower() and not any(word in combined for word in ["gap", "routing"]):
|
||||
return "context"
|
||||
if any(word in combined for word in ["rejected_not_claw", "not claw", "outside claw"]):
|
||||
return "rejected_not_claw"
|
||||
if "superseded" in combined:
|
||||
return "superseded"
|
||||
if "deferred" in combined or "post-2.0" in combined or "post_2_0" in combined:
|
||||
return "deferred_with_rationale"
|
||||
if any(word in combined for word in ["done", "implemented", "fixed", "verified", "re-verified", "landed", "green"]):
|
||||
if any(word in combined for word in ["stale", "old filing", "original filing below", "no longer reproduces"]):
|
||||
return "stale_done"
|
||||
return "done_verify"
|
||||
if title.lower().startswith(("evidence for", "trace path", "actual root cause", "meta-lesson")):
|
||||
return "context"
|
||||
return "open" if "pinpoint" in combined or record.source_type == "roadmap_action" else "active"
|
||||
|
||||
|
||||
def deferral_for(record: RoadmapRecord, status: str) -> str:
|
||||
if status == "deferred_with_rationale":
|
||||
return "Deferred by roadmap/approved plan until prerequisite contracts or post-2.0 research admission gates are satisfied."
|
||||
if status == "rejected_not_claw":
|
||||
return "Rejected because the source describes clone-only breadth or behavior outside Claw's machine-truth/clawable-harness identity."
|
||||
if status == "superseded":
|
||||
return "Superseded by a newer roadmap entry or canonical Rust/control-plane contract; keep only for audit traceability."
|
||||
if status == "stale_done":
|
||||
return "Marked done in roadmap but needs freshness re-verification before being used as release evidence."
|
||||
return ""
|
||||
|
||||
|
||||
def verification_for(record: RoadmapRecord, status: str) -> str:
|
||||
if status == "context":
|
||||
return "none_context_only"
|
||||
if status in {"done_verify", "stale_done"}:
|
||||
return "verify_existing_evidence_and_regression_guard"
|
||||
cat = category_for(f"{record.path} {record.title}")
|
||||
if cat == "docs_license":
|
||||
return "docs_snapshot_or_help_output_check"
|
||||
if cat == "windows_install":
|
||||
return "install_matrix_or_cross_platform_smoke"
|
||||
if cat == "provider":
|
||||
return "provider_routing_contract_test"
|
||||
if cat == "plugin_mcp":
|
||||
return "plugin_mcp_lifecycle_contract_test"
|
||||
if cat == "event_report":
|
||||
return "schema_golden_fixture_or_consumer_contract_test"
|
||||
if cat == "branch_recovery":
|
||||
return "git_fixture_or_recovery_recipe_test"
|
||||
if cat == "boot":
|
||||
return "worker_boot_state_machine_or_cli_json_contract_test"
|
||||
return "targeted_regression_or_acceptance_test_required"
|
||||
|
||||
|
||||
def dependencies_for(record: RoadmapRecord, status: str) -> list[str]:
|
||||
combined = f"{record.path} {record.title}".lower()
|
||||
deps: list[str] = []
|
||||
if status == "context":
|
||||
return deps
|
||||
if "phase 2" in combined or category_for(combined) == "event_report":
|
||||
deps.append("stream_1_worker_boot_session_control")
|
||||
if "phase 3" in combined or category_for(combined) == "branch_recovery":
|
||||
deps.append("stream_2_event_reporting_contracts")
|
||||
if "phase 4" in combined or category_for(combined) == "task_policy":
|
||||
deps.append("stream_2_event_reporting_contracts")
|
||||
if "phase 5" in combined or category_for(combined) == "plugin_mcp":
|
||||
deps.append("stream_1_worker_boot_session_control")
|
||||
if any(k in combined for k in ["zed", "acp", "desktop", "marketplace"]):
|
||||
deps.append("stable_alpha_contracts")
|
||||
if any(k in combined for k in ["provider", "install", "windows", "docs", "license"]):
|
||||
deps.append("adoption_overlay_triage")
|
||||
return sorted(set(deps))
|
||||
|
||||
|
||||
def roadmap_item(record: RoadmapRecord, index: int) -> dict[str, Any]:
|
||||
status = status_for(record)
|
||||
item_id = f"CC2-RM-{'H' if record.source_type == 'roadmap_heading' else 'A'}{index:04d}-{slugify(record.title, 40)}"
|
||||
bucket = release_bucket_for(record, status)
|
||||
return {
|
||||
"id": item_id,
|
||||
"title": record.title,
|
||||
"source_anchor": f"ROADMAP.md:L{record.line}",
|
||||
"source_type": record.source_type,
|
||||
"source_path": "ROADMAP.md",
|
||||
"source_context": record.path,
|
||||
"source_line": record.line,
|
||||
"source_level": record.level if record.source_type == "roadmap_heading" else None,
|
||||
"source_ordinal": record.ordinal,
|
||||
"release_bucket": bucket,
|
||||
"lifecycle_status": status,
|
||||
"status": status,
|
||||
"category": category_for(f"{record.path} {record.title}"),
|
||||
"owner_lane": stream_for(record),
|
||||
"dependencies": dependencies_for(record, status),
|
||||
"verification_required": verification_for(record, status),
|
||||
"deferral_rationale": deferral_for(record, status),
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def issue_item(issue: dict[str, Any], source_name: str, source_type: str, bucket: str) -> dict[str, Any]:
|
||||
title = issue.get("title") or f"Issue #{issue.get('number')}"
|
||||
number = issue.get("number")
|
||||
body = f"{title} {issue.get('body') or ''}"
|
||||
status = "open" if issue.get("state", "OPEN").lower() != "closed" else "done_verify"
|
||||
return {
|
||||
"id": f"CC2-ISSUE-{source_name.upper()}-{number}",
|
||||
"title": title,
|
||||
"source_anchor": f".omx/research/{source_name}.json#issue-{number}",
|
||||
"source_type": source_type,
|
||||
"source_path": f".omx/research/{source_name}.json",
|
||||
"issue_number": number,
|
||||
"issue_url": issue.get("url"),
|
||||
"release_bucket": bucket,
|
||||
"lifecycle_status": status,
|
||||
"status": status,
|
||||
"category": category_for(body),
|
||||
"owner_lane": stream_for(RoadmapRecord(0, 0, title, title, source_type)),
|
||||
"dependencies": ["roadmap_board_triage"],
|
||||
"verification_required": "issue_acceptance_repro_or_triage_decision",
|
||||
"deferral_rationale": "Latest issue intake is admitted only when it matches freeze/admission rules; otherwise remains 2.x_intake." if bucket == "2.x_intake" else "",
|
||||
}
|
||||
|
||||
|
||||
def repo_context_item(meta: dict[str, Any], source_name: str) -> dict[str, Any]:
|
||||
owner = meta.get("nameWithOwner", source_name)
|
||||
return {
|
||||
"id": f"CC2-PARITY-{source_name.upper()}-REPO-CONTEXT",
|
||||
"title": f"Parity source metadata: {owner}",
|
||||
"source_anchor": f".omx/research/{source_name}-repo.json",
|
||||
"source_type": "parity_repo_context",
|
||||
"source_path": f".omx/research/{source_name}-repo.json",
|
||||
"release_bucket": "context",
|
||||
"lifecycle_status": "context",
|
||||
"status": "context",
|
||||
"category": "governance",
|
||||
"owner_lane": "parity_overlay",
|
||||
"dependencies": [],
|
||||
"verification_required": "none_context_only",
|
||||
"deferral_rationale": "",
|
||||
"repo": {
|
||||
"nameWithOwner": owner,
|
||||
"url": meta.get("url"),
|
||||
"pushedAt": meta.get("pushedAt"),
|
||||
"latestRelease": meta.get("latestRelease"),
|
||||
"licenseInfo": meta.get("licenseInfo"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def summarize_counts(items: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for item in items:
|
||||
out[item[key]] = out.get(item[key], 0) + 1
|
||||
return dict(sorted(out.items()))
|
||||
|
||||
|
||||
def render_markdown(board: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"# Claw Code 2.0 Canonical Board",
|
||||
"",
|
||||
f"Generated: `{board['generated_at']}`",
|
||||
f"Roadmap SHA-256 prefix: `{board['sources']['roadmap']['sha256_prefix']}`",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total items: **{len(board['items'])}**",
|
||||
f"- Roadmap headings covered: **{board['coverage']['roadmap_headings_total']} / {board['coverage']['roadmap_headings_mapped']}**",
|
||||
f"- Roadmap ordered actions covered: **{board['coverage']['roadmap_actions_total']} / {board['coverage']['roadmap_actions_mapped']}**",
|
||||
"",
|
||||
"### By lifecycle status",
|
||||
"",
|
||||
]
|
||||
for status, count in board["summary"]["by_status"].items():
|
||||
lines.append(f"- `{status}`: {count}")
|
||||
lines.extend(["", "### By release bucket", ""])
|
||||
for bucket, count in board["summary"]["by_release_bucket"].items():
|
||||
lines.append(f"- `{bucket}`: {count}")
|
||||
lines.extend(["", "## Board Items", ""])
|
||||
for item in board["items"]:
|
||||
deps = ", ".join(item.get("dependencies") or []) or "none"
|
||||
rationale = item.get("deferral_rationale") or ""
|
||||
lines.extend([
|
||||
f"### {item['id']}",
|
||||
f"- Title: {item['title']}",
|
||||
f"- Source: `{item['source_anchor']}` (`{item['source_type']}`)",
|
||||
f"- Bucket/status: `{item['release_bucket']}` / `{item['status']}`",
|
||||
f"- Category/lane: `{item.get('category')}` / `{item.get('owner_lane')}`",
|
||||
f"- Dependencies: {deps}",
|
||||
f"- Verification: `{item['verification_required']}`",
|
||||
f"- Deferral rationale: {rationale}",
|
||||
"",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_board(board: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
seen = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
missing = [field for field in REQUIRED_ITEM_FIELDS if field not in item]
|
||||
if missing:
|
||||
errors.append(f"item {index} missing fields: {missing}")
|
||||
if item.get("id") in seen:
|
||||
errors.append(f"duplicate id: {item.get('id')}")
|
||||
seen.add(item.get("id"))
|
||||
if item.get("status") not in STATUSES:
|
||||
errors.append(f"{item.get('id')} invalid status {item.get('status')}")
|
||||
if item.get("release_bucket") not in RELEASE_BUCKETS:
|
||||
errors.append(f"{item.get('id')} invalid release_bucket {item.get('release_bucket')}")
|
||||
if not isinstance(item.get("dependencies"), list):
|
||||
errors.append(f"{item.get('id')} dependencies must be list")
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("unmapped_roadmap_heading_lines"):
|
||||
errors.append(f"unmapped heading lines: {coverage['unmapped_roadmap_heading_lines']}")
|
||||
if coverage.get("duplicate_roadmap_heading_lines"):
|
||||
errors.append(f"duplicate heading lines: {coverage['duplicate_roadmap_heading_lines']}")
|
||||
if coverage.get("roadmap_headings_total") != coverage.get("roadmap_headings_mapped"):
|
||||
errors.append("roadmap heading total/mapped mismatch")
|
||||
return errors
|
||||
|
||||
|
||||
def build_board(repo_root: Path) -> dict[str, Any]:
|
||||
roadmap_path = repo_root / "ROADMAP.md"
|
||||
source_omx = find_source_omx(repo_root)
|
||||
research = source_omx / "research"
|
||||
plan_path = source_omx / "plans" / "claw-code-2-0-adaptive-plan.md"
|
||||
headings, actions = parse_roadmap(roadmap_path)
|
||||
items = [roadmap_item(record, i) for i, record in enumerate(headings, 1)]
|
||||
items.extend(roadmap_item(record, i) for i, record in enumerate(actions, 1))
|
||||
|
||||
latest_issues = load_json(research / "claw-open-latest.json")
|
||||
all_issues = load_json(research / "claw-issues.json")
|
||||
items.extend(issue_item(issue, "claw-open-latest", "latest_open_issue", "2.x_intake") for issue in latest_issues)
|
||||
# Include a small real-issue sample from the full freeze to keep the board tied to the larger issue manifest without exploding scope.
|
||||
for issue in all_issues[:50]:
|
||||
title_body = f"{issue.get('title','')} {issue.get('body','')}".lower()
|
||||
if any(k in title_body for k in ["security", "windows", "install", "provider", "model", "session", "license", "zed", "spam", "plugin"]):
|
||||
items.append(issue_item(issue, "claw-issues", "issue_theme", "beta_adoption"))
|
||||
for source_name in ["opencode", "codex"]:
|
||||
repo_meta = load_json(research / f"{source_name}-repo.json")
|
||||
items.append(repo_context_item(repo_meta, source_name))
|
||||
|
||||
heading_lines = [record.line for record in headings]
|
||||
mapped_heading_lines = [item["source_line"] for item in items if item.get("source_type") == "roadmap_heading"]
|
||||
duplicate_heading_lines = sorted(line for line in set(mapped_heading_lines) if mapped_heading_lines.count(line) != 1)
|
||||
unmapped_heading_lines = sorted(set(heading_lines) - set(mapped_heading_lines))
|
||||
|
||||
board = {
|
||||
"schema_version": "cc2.board.v1",
|
||||
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"generation_policy": {
|
||||
"ultragoal_mutation": "forbidden",
|
||||
"roadmap_coverage": "all markdown headings plus top-level ordered roadmap actions",
|
||||
"status_values": sorted(STATUSES),
|
||||
"release_buckets": sorted(RELEASE_BUCKETS),
|
||||
},
|
||||
"sources": {
|
||||
"roadmap": {
|
||||
"path": "ROADMAP.md",
|
||||
"sha256_prefix": sha256_prefix(roadmap_path),
|
||||
"heading_count": len(headings),
|
||||
"ordered_action_count": len(actions),
|
||||
},
|
||||
"approved_plan": {
|
||||
"path": ".omx/plans/claw-code-2-0-adaptive-plan.md",
|
||||
"sha256_prefix": sha256_prefix(plan_path),
|
||||
},
|
||||
"research": {
|
||||
"root": str(source_omx / "research"),
|
||||
"claw_open_latest_count": len(latest_issues),
|
||||
"claw_issues_count": len(all_issues),
|
||||
"opencode_repo": ".omx/research/opencode-repo.json",
|
||||
"codex_repo": ".omx/research/codex-repo.json",
|
||||
},
|
||||
},
|
||||
"coverage": {
|
||||
"roadmap_headings_total": len(headings),
|
||||
"roadmap_headings_mapped": len(mapped_heading_lines),
|
||||
"unmapped_roadmap_heading_lines": unmapped_heading_lines,
|
||||
"duplicate_roadmap_heading_lines": duplicate_heading_lines,
|
||||
"roadmap_actions_total": len(actions),
|
||||
"roadmap_actions_mapped": len([item for item in items if item.get("source_type") == "roadmap_action"]),
|
||||
},
|
||||
"summary": {},
|
||||
"items": items,
|
||||
}
|
||||
board["summary"] = {
|
||||
"by_status": summarize_counts(items, "status"),
|
||||
"by_release_bucket": summarize_counts(items, "release_bucket"),
|
||||
"by_source_type": summarize_counts(items, "source_type"),
|
||||
"by_owner_lane": summarize_counts(items, "owner_lane"),
|
||||
}
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
raise SystemExit("board validation failed:\n" + "\n".join(errors))
|
||||
return board
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--out-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
out_dir = args.out_dir or (repo_root / ".omx" / "cc2")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
board = build_board(repo_root)
|
||||
board_json = out_dir / "board.json"
|
||||
board_md = out_dir / "board.md"
|
||||
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
if renderer.exists():
|
||||
subprocess.run([sys.executable, str(renderer), str(board_json), str(board_md)], check=True, cwd=str(repo_root))
|
||||
else:
|
||||
board_md.write_text(render_markdown(board) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"wrote {board_json}")
|
||||
print(f"wrote {board_md}")
|
||||
print(f"roadmap headings mapped: {board['coverage']['roadmap_headings_mapped']}/{board['coverage']['roadmap_headings_total']}")
|
||||
print(f"roadmap actions mapped: {board['coverage']['roadmap_actions_mapped']}/{board['coverage']['roadmap_actions_total']}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the generated Claw Code 2.0 board coverage and schema."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED = {
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
}
|
||||
STATUSES = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
|
||||
def roadmap_heading_lines(path: Path) -> list[int]:
|
||||
lines = []
|
||||
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
lines.append(line_no)
|
||||
return lines
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--board", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
|
||||
board = json.loads(board_path.read_text(encoding="utf-8"))
|
||||
errors: list[str] = []
|
||||
ids = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
missing = REQUIRED - set(item)
|
||||
if missing:
|
||||
errors.append(f"item {index} missing required fields: {sorted(missing)}")
|
||||
if item.get("id") in ids:
|
||||
errors.append(f"duplicate id: {item.get('id')}")
|
||||
ids.add(item.get("id"))
|
||||
if item.get("status") not in STATUSES:
|
||||
errors.append(f"{item.get('id')} invalid status {item.get('status')}")
|
||||
if not isinstance(item.get("dependencies"), list):
|
||||
errors.append(f"{item.get('id')} dependencies must be list")
|
||||
expected = roadmap_heading_lines(repo_root / "ROADMAP.md")
|
||||
mapped = [item.get("source_line") for item in board.get("items", []) if item.get("source_type") == "roadmap_heading"]
|
||||
unmapped = sorted(set(expected) - set(mapped))
|
||||
duplicates = sorted(line for line in set(mapped) if mapped.count(line) != 1)
|
||||
if unmapped:
|
||||
errors.append(f"unmapped ROADMAP headings: {unmapped}")
|
||||
if duplicates:
|
||||
errors.append(f"duplicate ROADMAP heading mappings: {duplicates}")
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("roadmap_headings_total") != len(expected):
|
||||
errors.append("coverage roadmap_headings_total does not match ROADMAP.md")
|
||||
if coverage.get("roadmap_headings_mapped") != len(mapped):
|
||||
errors.append("coverage roadmap_headings_mapped does not match board items")
|
||||
if errors:
|
||||
print("FAIL cc2 board validation")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
print("PASS cc2 board validation")
|
||||
print(f"- board: {board_path}")
|
||||
print(f"- items: {len(board.get('items', []))}")
|
||||
print(f"- ROADMAP headings mapped: {len(mapped)}/{len(expected)}")
|
||||
print(f"- ROADMAP actions mapped: {coverage.get('roadmap_actions_mapped')}/{coverage.get('roadmap_actions_total')}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -23,6 +23,7 @@ class PortingModule:
|
||||
class PermissionDenial:
|
||||
tool_name: str
|
||||
reason: str
|
||||
status: str = 'blocked'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
_GLOB_META = set('*?[')
|
||||
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
|
||||
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
|
||||
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathScopeDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
candidate: str | None = None
|
||||
resolved: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePathScope:
|
||||
"""Validate tool/shell path operands against explicit workspace roots.
|
||||
|
||||
The policy is intentionally conservative for the Python port: any candidate
|
||||
path that resolves outside the configured roots is denied, including paths
|
||||
reached through symlinks or glob expansion. Windows drive/UNC paths are
|
||||
treated as out-of-scope on POSIX roots unless an allowed root is also a
|
||||
Windows-style root with the same prefix.
|
||||
"""
|
||||
|
||||
roots: tuple[Path, ...]
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: str | Path) -> 'WorkspacePathScope':
|
||||
return cls.from_roots((root,))
|
||||
|
||||
@classmethod
|
||||
def from_roots(cls, roots: tuple[str | Path, ...] | list[str | Path]) -> 'WorkspacePathScope':
|
||||
resolved_roots = tuple(Path(root).expanduser().resolve(strict=False) for root in roots)
|
||||
if not resolved_roots:
|
||||
raise ValueError('at least one workspace root is required')
|
||||
return cls(resolved_roots)
|
||||
|
||||
def validate_payload(self, payload: str, cwd: str | Path | None = None) -> PathScopeDecision:
|
||||
cwd_path = Path(cwd).expanduser().resolve(strict=False) if cwd else self.roots[0]
|
||||
cwd_decision = self.validate_path(cwd_path)
|
||||
if not cwd_decision.allowed:
|
||||
return PathScopeDecision(False, f'cwd outside workspace scope: {cwd_path}', str(cwd_path), cwd_decision.resolved)
|
||||
for candidate in extract_path_candidates(payload):
|
||||
decision = self.validate_path(candidate, cwd_path)
|
||||
if not decision.allowed:
|
||||
return decision
|
||||
return PathScopeDecision(True, 'all path candidates are inside workspace scope')
|
||||
|
||||
def validate_path(self, candidate: str | Path, cwd: str | Path | None = None) -> PathScopeDecision:
|
||||
raw = os.path.expandvars(os.path.expanduser(str(candidate)))
|
||||
if _is_windows_absolute(raw):
|
||||
return self._validate_windows_path(raw)
|
||||
base = Path(cwd).expanduser().resolve(strict=False) if cwd else self.roots[0]
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
expanded = self._expand_glob(path)
|
||||
for expanded_path in expanded:
|
||||
resolved = expanded_path.resolve(strict=False)
|
||||
if not any(_is_relative_to(resolved, root) for root in self.roots):
|
||||
return PathScopeDecision(
|
||||
False,
|
||||
'path resolves outside workspace scope',
|
||||
str(candidate),
|
||||
str(resolved),
|
||||
)
|
||||
return PathScopeDecision(True, 'path is inside workspace scope', str(candidate), str(expanded[0].resolve(strict=False)))
|
||||
|
||||
def _expand_glob(self, path: Path) -> tuple[Path, ...]:
|
||||
path_text = str(path)
|
||||
if any(char in path_text for char in _GLOB_META):
|
||||
matches = tuple(Path(match) for match in glob.glob(path_text, recursive=True))
|
||||
if matches:
|
||||
return matches
|
||||
# For unmatched globs, validate the stable non-glob parent prefix.
|
||||
stable_parts: list[str] = []
|
||||
for part in path.parts:
|
||||
if any(char in part for char in _GLOB_META):
|
||||
break
|
||||
stable_parts.append(part)
|
||||
if stable_parts:
|
||||
return (Path(*stable_parts),)
|
||||
return (path,)
|
||||
|
||||
def _validate_windows_path(self, raw: str) -> PathScopeDecision:
|
||||
candidate = PureWindowsPath(raw)
|
||||
for root in self.roots:
|
||||
root_text = str(root)
|
||||
if not _is_windows_absolute(root_text):
|
||||
continue
|
||||
try:
|
||||
candidate.relative_to(PureWindowsPath(root_text))
|
||||
return PathScopeDecision(True, 'windows path is inside workspace scope', raw, str(candidate))
|
||||
except ValueError:
|
||||
continue
|
||||
return PathScopeDecision(False, 'windows absolute path is outside workspace scope', raw, str(candidate))
|
||||
|
||||
|
||||
def extract_path_candidates(payload: str) -> tuple[str, ...]:
|
||||
"""Return conservative path-like operands from a shell/tool payload."""
|
||||
|
||||
try:
|
||||
tokens = shlex.split(payload, posix=True)
|
||||
except ValueError:
|
||||
tokens = payload.split()
|
||||
raw_tokens = payload.split()
|
||||
candidates: list[str] = []
|
||||
for token in (*tokens, *raw_tokens):
|
||||
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
|
||||
continue
|
||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
||||
if _looks_like_path(token) or _looks_like_path(expanded):
|
||||
candidate = expanded if _looks_like_path(expanded) else token
|
||||
if candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _looks_like_path(token: str) -> bool:
|
||||
return (
|
||||
token in {'.', '..'}
|
||||
or token.startswith(('./', '../', '/', '~/', '~/'))
|
||||
or '..' in token.split('/')
|
||||
or '/' in token
|
||||
or '\\' in token
|
||||
or any(char in token for char in _GLOB_META)
|
||||
or _is_windows_absolute(token)
|
||||
)
|
||||
|
||||
|
||||
def _is_windows_absolute(value: str) -> bool:
|
||||
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
+30
-1
@@ -1,20 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .path_scope import PathScopeDecision, WorkspacePathScope
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPermissionContext:
|
||||
deny_names: frozenset[str] = field(default_factory=frozenset)
|
||||
deny_prefixes: tuple[str, ...] = ()
|
||||
workspace_scope: WorkspacePathScope | None = None
|
||||
cwd: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_iterables(cls, deny_names: list[str] | None = None, deny_prefixes: list[str] | None = None) -> 'ToolPermissionContext':
|
||||
def from_iterables(
|
||||
cls,
|
||||
deny_names: list[str] | None = None,
|
||||
deny_prefixes: list[str] | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
workspace_roots: list[str | Path] | tuple[str | Path, ...] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
) -> 'ToolPermissionContext':
|
||||
roots: list[str | Path] = []
|
||||
if workspace_roots:
|
||||
roots.extend(workspace_roots)
|
||||
if workspace_root is not None:
|
||||
roots.append(workspace_root)
|
||||
return cls(
|
||||
deny_names=frozenset(name.lower() for name in (deny_names or [])),
|
||||
deny_prefixes=tuple(prefix.lower() for prefix in (deny_prefixes or [])),
|
||||
workspace_scope=WorkspacePathScope.from_roots(roots) if roots else None,
|
||||
cwd=Path(cwd).expanduser().resolve(strict=False) if cwd is not None else None,
|
||||
)
|
||||
|
||||
def blocks(self, tool_name: str) -> bool:
|
||||
lowered = tool_name.lower()
|
||||
return lowered in self.deny_names or any(lowered.startswith(prefix) for prefix in self.deny_prefixes)
|
||||
|
||||
def validate_payload_scope(self, tool_name: str, payload: str) -> PathScopeDecision:
|
||||
if self.workspace_scope is None or not _scope_checked_tool(tool_name):
|
||||
return PathScopeDecision(True, 'workspace path scope not required for this tool')
|
||||
return self.workspace_scope.validate_payload(payload, cwd=self.cwd)
|
||||
|
||||
|
||||
def _scope_checked_tool(tool_name: str) -> bool:
|
||||
lowered = tool_name.lower()
|
||||
return any(marker in lowered for marker in ('bash', 'shell', 'powershell', 'fileread', 'filewrite', 'fileedit'))
|
||||
|
||||
+8
-1
@@ -82,6 +82,7 @@ class QueryEnginePort:
|
||||
f'Matched commands: {", ".join(matched_commands) if matched_commands else "none"}',
|
||||
f'Matched tools: {", ".join(matched_tools) if matched_tools else "none"}',
|
||||
f'Permission denials: {len(denied_tools)}',
|
||||
*(f'Permission denial: {denial.tool_name} status={denial.status} reason={denial.reason}' for denial in denied_tools),
|
||||
]
|
||||
output = self._format_output(summary_lines)
|
||||
projected_usage = self.total_usage.add_turn(prompt, output)
|
||||
@@ -116,7 +117,13 @@ class QueryEnginePort:
|
||||
if matched_tools:
|
||||
yield {'type': 'tool_match', 'tools': matched_tools}
|
||||
if denied_tools:
|
||||
yield {'type': 'permission_denial', 'denials': [denial.tool_name for denial in denied_tools]}
|
||||
yield {
|
||||
'type': 'permission_denial',
|
||||
'denials': [
|
||||
{'tool_name': denial.tool_name, 'reason': denial.reason, 'status': denial.status}
|
||||
for denial in denied_tools
|
||||
],
|
||||
}
|
||||
result = self.submit_message(prompt, matched_commands, matched_tools, denied_tools)
|
||||
yield {'type': 'message_delta', 'text': result.output}
|
||||
yield {
|
||||
|
||||
+16
-1
@@ -78,10 +78,25 @@ def find_tools(query: str, limit: int = 20) -> list[PortingModule]:
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def execute_tool(name: str, payload: str = '') -> ToolExecution:
|
||||
def execute_tool(name: str, payload: str = '', permission_context: ToolPermissionContext | None = None) -> ToolExecution:
|
||||
module = get_tool(name)
|
||||
if module is None:
|
||||
return ToolExecution(name=name, source_hint='', payload=payload, handled=False, message=f'Unknown mirrored tool: {name}')
|
||||
if permission_context and permission_context.blocks(module.name):
|
||||
return ToolExecution(name=module.name, source_hint=module.source_hint, payload=payload, handled=False, message=f"Permission denied for mirrored tool '{module.name}'.")
|
||||
if permission_context:
|
||||
scope_decision = permission_context.validate_payload_scope(module.name, payload)
|
||||
if not scope_decision.allowed:
|
||||
return ToolExecution(
|
||||
name=module.name,
|
||||
source_hint=module.source_hint,
|
||||
payload=payload,
|
||||
handled=False,
|
||||
message=(
|
||||
f"Permission denied for mirrored tool '{module.name}': {scope_decision.reason}"
|
||||
f" (candidate={scope_decision.candidate!r}, resolved={scope_decision.resolved!r})."
|
||||
),
|
||||
)
|
||||
action = f"Mirrored tool '{module.name}' from {module.source_hint} would handle payload {payload!r}."
|
||||
return ToolExecution(name=module.name, source_hint=module.source_hint, payload=payload, handled=True, message=action)
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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.tools import execute_tool
|
||||
|
||||
|
||||
class WorkspacePathScopeTests(unittest.TestCase):
|
||||
def test_direct_parent_escape_is_denied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp) / 'workspace'
|
||||
workspace.mkdir()
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat ../secret.txt')
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn('outside workspace scope', decision.reason)
|
||||
|
||||
def test_issue_3007_symlink_escape_is_denied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
(outside / 'secret.txt').write_text('secret')
|
||||
link = workspace / 'linked-outside'
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat linked-outside/secret.txt')
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_glob_expansion_must_stay_inside_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
(outside / 'secret.txt').write_text('secret')
|
||||
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload(f'cat {outside}/*.txt')
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(str((outside / 'secret.txt').resolve()), decision.resolved)
|
||||
|
||||
def test_shell_environment_expansion_is_validated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
previous = os.environ.get('CLAW_SCOPE_OUTSIDE')
|
||||
os.environ['CLAW_SCOPE_OUTSIDE'] = str(outside)
|
||||
try:
|
||||
self.assertEqual((f'{outside}/secret.txt',), extract_path_candidates('cat $CLAW_SCOPE_OUTSIDE/secret.txt'))
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat $CLAW_SCOPE_OUTSIDE/secret.txt')
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop('CLAW_SCOPE_OUTSIDE', None)
|
||||
else:
|
||||
os.environ['CLAW_SCOPE_OUTSIDE'] = previous
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_explicit_worktree_roots_are_allowed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
worktree = root / 'worktree'
|
||||
workspace.mkdir()
|
||||
worktree.mkdir()
|
||||
(worktree / 'file.txt').write_text('ok')
|
||||
|
||||
decision = WorkspacePathScope.from_roots((workspace, worktree)).validate_payload(f'cat {worktree}/file.txt')
|
||||
|
||||
self.assertTrue(decision.allowed, decision.reason)
|
||||
|
||||
def test_windows_absolute_paths_are_denied_for_posix_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp) / 'workspace'
|
||||
workspace.mkdir()
|
||||
|
||||
drive_decision = WorkspacePathScope.from_root(workspace).validate_payload(r'type C:\Users\other\secret.txt')
|
||||
unc_decision = WorkspacePathScope.from_root(workspace).validate_payload(r'type \\server\share\secret.txt')
|
||||
|
||||
self.assertFalse(drive_decision.allowed)
|
||||
self.assertIn('windows absolute path', drive_decision.reason)
|
||||
self.assertFalse(unc_decision.allowed)
|
||||
self.assertIn('windows absolute path', unc_decision.reason)
|
||||
|
||||
def test_file_and_shell_tools_use_workspace_scope_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
context = ToolPermissionContext.from_iterables(workspace_root=workspace, cwd=workspace)
|
||||
|
||||
file_result = execute_tool('FileReadTool', f'{outside}/secret.txt', permission_context=context)
|
||||
shell_result = execute_tool('BashTool', f'cat {outside}/secret.txt', permission_context=context)
|
||||
inside_result = execute_tool('FileReadTool', './allowed.txt', permission_context=context)
|
||||
|
||||
self.assertFalse(file_result.handled)
|
||||
self.assertIn('Permission denied', file_result.message)
|
||||
self.assertFalse(shell_result.handled)
|
||||
self.assertIn('Permission denied', shell_result.message)
|
||||
self.assertTrue(inside_result.handled)
|
||||
|
||||
def test_permission_denial_stream_events_expose_status_and_reason(self) -> None:
|
||||
engine = QueryEnginePort.from_workspace()
|
||||
denial = PermissionDenial('BashTool', 'path resolves outside workspace scope')
|
||||
|
||||
events = list(engine.stream_submit_message('cat ../secret.txt', matched_tools=('BashTool',), denied_tools=(denial,)))
|
||||
permission_event = next(event for event in events if event['type'] == 'permission_denial')
|
||||
result = engine.submit_message('cat ../secret.txt', matched_tools=('BashTool',), denied_tools=(denial,))
|
||||
|
||||
self.assertEqual('blocked', permission_event['denials'][0]['status'])
|
||||
self.assertEqual('path resolves outside workspace scope', permission_event['denials'][0]['reason'])
|
||||
self.assertIn('status=blocked', result.output)
|
||||
self.assertIn('path resolves outside workspace scope', result.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user