mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-26 21:03:38 -04:00
Compare commits
209 Commits
claw-code-
...
41b769fc5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b769fc5a | ||
|
|
7426ede2eb | ||
|
|
8f7eaffcef | ||
|
|
d2b5f5d498 | ||
|
|
607f071ca8 | ||
|
|
d3f8ff9916 | ||
|
|
204af77596 | ||
|
|
5c40d4e778 | ||
|
|
5625ba597b | ||
|
|
4f60cf70f1 | ||
|
|
6a37442ee1 | ||
|
|
0bca524c8c | ||
|
|
2ad56860df | ||
|
|
1fbde9f47f | ||
|
|
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 | ||
|
|
8f55870dad | ||
|
|
7244a82b36 | ||
|
|
5ab969e7ae | ||
|
|
5a4cc506d5 | ||
|
|
9e1eafd02d | ||
|
|
b2048856f3 | ||
|
|
19aaf9d05e | ||
|
|
8499599b70 | ||
|
|
86ff83c233 | ||
|
|
bd126905db | ||
|
|
f4a9674086 | ||
|
|
d3a982dda9 | ||
|
|
8cf628a53c | ||
|
|
b8f989b605 | ||
|
|
e29010ed48 | ||
|
|
0e5f695844 | ||
|
|
ce39d5c598 | ||
|
|
fad53e2df9 | ||
|
|
328fd114ff | ||
|
|
075c214439 | ||
|
|
ec882f4c88 | ||
|
|
7204844982 | ||
|
|
1fecdf096b | ||
|
|
3730b459a2 | ||
|
|
d7dbe951ce | ||
|
|
6c0c305a4b | ||
|
|
3c563fa1dc | ||
|
|
6aa4b85c95 | ||
|
|
b98b9a712e | ||
|
|
357629dbd9 | ||
|
|
12b65f9807 | ||
|
|
75c08bc982 | ||
|
|
553d25ee50 | ||
|
|
5be173edf6 | ||
|
|
28998422e2 | ||
|
|
b4733b67a6 | ||
|
|
ab44985916 | ||
|
|
d074d1c046 | ||
|
|
caeac828b5 | ||
|
|
85435ad4b5 | ||
|
|
5eb4b8a944 | ||
|
|
65aa559733 | ||
|
|
ac8a24b30b | ||
|
|
94b80a05d3 | ||
|
|
9b97c4d832 | ||
|
|
1206f4131d | ||
|
|
c99330372c | ||
|
|
9a512633a5 | ||
|
|
6ac13ffdad | ||
|
|
482681cdfe | ||
|
|
8e45f1850c | ||
|
|
57096b0a1a | ||
|
|
51b9e6b37f | ||
|
|
e939777f92 | ||
|
|
1093e26792 | ||
|
|
44cca2054d | ||
|
|
6dc7b26d82 | ||
|
|
a0bd406c8f | ||
|
|
b62646edfe | ||
|
|
d95b230cae | ||
|
|
f48f156754 | ||
|
|
52a909cebe | ||
|
|
c4c618e476 | ||
|
|
74338dc635 | ||
|
|
c092cf7fef | ||
|
|
8e24f3049e | ||
|
|
71d8e7b925 | ||
|
|
19947545e2 | ||
|
|
f7b2d8d6fe | ||
|
|
6f92e54dc0 | ||
|
|
31d9198a02 | ||
|
|
5eb1d7d824 | ||
|
|
3b03375e69 | ||
|
|
0f9e8915be | ||
|
|
ab95b75fcd | ||
|
|
ee44ff984d | ||
|
|
2ab26df4bd | ||
|
|
a2a38df9b8 | ||
|
|
fd90c9fe67 | ||
|
|
cca6f6829c | ||
|
|
c77d1a87e1 | ||
|
|
ee41b266d3 | ||
|
|
ca92c695f4 | ||
|
|
c6c01beaca | ||
|
|
970cdc925e | ||
|
|
b2f7a3354f | ||
|
|
2a08b7a35c | ||
|
|
a510f73422 | ||
|
|
1283c6d532 | ||
|
|
a1bfcd4110 | ||
|
|
c49839bb1f | ||
|
|
f65b2b4f0e | ||
|
|
f4b74e89dd | ||
|
|
5856913104 | ||
|
|
d45a0d2f5b | ||
|
|
dc47482e40 | ||
|
|
9537c97231 | ||
|
|
f56a5afcf7 | ||
|
|
3efaf551ed | ||
|
|
30c9b438ef | ||
|
|
587bb18572 | ||
|
|
24ccb59bd2 | ||
|
|
0e8e75ef75 | ||
|
|
0f7578c064 | ||
|
|
213d406cbf | ||
|
|
ee85fed6ca | ||
|
|
3a34d83749 | ||
|
|
981aff7c8b | ||
|
|
c94940effa | ||
|
|
b90875fa8e | ||
|
|
2567cbcc78 | ||
|
|
d607ff3674 | ||
|
|
cdf6282965 | ||
|
|
e7074f47ee | ||
|
|
9468383b67 | ||
|
|
1da2781816 | ||
|
|
9037430d52 | ||
|
|
8e22f757d8 | ||
|
|
7676b376ae | ||
|
|
1011a83823 | ||
|
|
1376d92064 | ||
|
|
be53e04671 | ||
|
|
cb56dc12ab | ||
|
|
71686a20fc | ||
|
|
07992b8a1b | ||
|
|
74ea754d29 | ||
|
|
77afde768c | ||
|
|
6db68a2baa | ||
|
|
5b910356a2 |
14886
.omx/cc2/board.json
Normal file
14886
.omx/cc2/board.json
Normal file
File diff suppressed because one or more lines are too long
842
.omx/cc2/board.md
Normal file
842
.omx/cc2/board.md
Normal file
File diff suppressed because one or more lines are too long
429
.omx/cc2/issue-parity-intake.json
Normal file
429
.omx/cc2/issue-parity-intake.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
.omx/cc2/issue-parity-intake.md
Normal file
47
.omx/cc2/issue-parity-intake.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
250
.omx/cc2/render_board_md.py
Executable file
250
.omx/cc2/render_board_md.py
Executable file
@@ -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())
|
||||||
58
.omx/cc2/validate_issue_parity_intake.py
Executable file
58
.omx/cc2/validate_issue_parity_intake.py
Executable file
@@ -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()
|
||||||
8
.port_sessions/b035f648d5b549aa836ea01f6727ec62.json
Normal file
8
.port_sessions/b035f648d5b549aa836ea01f6727ec62.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"session_id": "b035f648d5b549aa836ea01f6727ec62",
|
||||||
|
"messages": [
|
||||||
|
"review MCP tool"
|
||||||
|
],
|
||||||
|
"input_tokens": 3,
|
||||||
|
"output_tokens": 13
|
||||||
|
}
|
||||||
9
.port_sessions/b234acb1eb8c486e80544ddc7e13e6d8.json
Normal file
9
.port_sessions/b234acb1eb8c486e80544ddc7e13e6d8.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"session_id": "b234acb1eb8c486e80544ddc7e13e6d8",
|
||||||
|
"messages": [
|
||||||
|
"review MCP tool",
|
||||||
|
"review MCP tool"
|
||||||
|
],
|
||||||
|
"input_tokens": 6,
|
||||||
|
"output_tokens": 32
|
||||||
|
}
|
||||||
9
.port_sessions/b67e062748f04e10ac5770df9285e4bd.json
Normal file
9
.port_sessions/b67e062748f04e10ac5770df9285e4bd.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"session_id": "b67e062748f04e10ac5770df9285e4bd",
|
||||||
|
"messages": [
|
||||||
|
"review MCP tool",
|
||||||
|
"review MCP tool"
|
||||||
|
],
|
||||||
|
"input_tokens": 6,
|
||||||
|
"output_tokens": 32
|
||||||
|
}
|
||||||
9
.port_sessions/bb88fd20433840a8b19237e3f306c6e3.json
Normal file
9
.port_sessions/bb88fd20433840a8b19237e3f306c6e3.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"session_id": "bb88fd20433840a8b19237e3f306c6e3",
|
||||||
|
"messages": [
|
||||||
|
"review MCP tool",
|
||||||
|
"review MCP tool"
|
||||||
|
],
|
||||||
|
"input_tokens": 6,
|
||||||
|
"output_tokens": 32
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- Frameworks: none detected from the supported starter markers.
|
- Frameworks: none detected from the supported starter markers.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
- Run Rust verification from repo root: `scripts/fmt.sh --check`; for formatting use `scripts/fmt.sh`. Run Rust clippy/tests from `rust/`: `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||||
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
|
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
|
||||||
|
|
||||||
## Repository shape
|
## Repository shape
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ cargo test --workspace
|
|||||||
- [`PARITY.md`](./PARITY.md) — parity status for the Rust port
|
- [`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
|
- [`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
|
- [`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
|
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why the project exists and how it is operated
|
||||||
|
|
||||||
## Ecosystem
|
## Ecosystem
|
||||||
|
|||||||
281
ROADMAP.md
281
ROADMAP.md
File diff suppressed because one or more lines are too long
185
docs/g002-security-verification-map.md
Normal file
185
docs/g002-security-verification-map.md
Normal file
@@ -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.
|
||||||
96
docs/g003-boot-session-verification-map.md
Normal file
96
docs/g003-boot-session-verification-map.md
Normal file
@@ -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.
|
||||||
67
docs/g004-events-reports-contract.md
Normal file
67
docs/g004-events-reports-contract.md
Normal file
@@ -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.
|
||||||
57
docs/g004-events-reports-verification-map.md
Normal file
57
docs/g004-events-reports-verification-map.md
Normal file
@@ -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.
|
||||||
40
docs/g005-branch-recovery-verification-map.md
Normal file
40
docs/g005-branch-recovery-verification-map.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# G005 Branch Recovery Verification Map
|
||||||
|
|
||||||
|
Scope: worker-1 follow-up map for G005 branch/test awareness and recovery. This file intentionally does not mutate leader-owned `.omx/ultragoal` state.
|
||||||
|
|
||||||
|
## Covered ROADMAP / PRD pinpoints
|
||||||
|
|
||||||
|
- `ROADMAP.md:912-921` — Phase 3 §7 stale-branch detection before broad verification: broad workspace test commands are preflighted before execution, stale/diverged branches emit `branch.stale_against_main`, and targeted tests bypass the broad-test gate.
|
||||||
|
- `ROADMAP.md:922-933` — Phase 3 §8 recovery recipes: stale-branch recovery remains represented by the `stale_branch` recipe, with one automatic attempt before escalation.
|
||||||
|
- `ROADMAP.md:935-949` — Phase 3 §8.5 recovery attempt ledger: `RecoveryContext` now exposes ledger entries with recipe id, attempt count, state, started/finished markers, last failure summary, and escalation reason.
|
||||||
|
- `ROADMAP.md:951-970` — Phase 3 §9 green-ness / hung-test reporting: timed-out test commands now classify as `test.hung` with structured provenance instead of generic timeout.
|
||||||
|
- `prd.json:37-44` — US-003 stale-branch detection before broad verification: verified through the `workspace_test_branch_preflight` broad-test block and targeted-test bypass tests.
|
||||||
|
- `prd.json:50-57` — US-004 recovery recipes with ledger: verified through recovery ledger unit coverage and serialization-compatible recovery structs.
|
||||||
|
|
||||||
|
## Implementation anchors
|
||||||
|
|
||||||
|
- `rust/crates/runtime/src/stale_branch.rs` — existing branch freshness model and policy actions for fresh, stale, and diverged branches.
|
||||||
|
- `rust/crates/tools/src/lib.rs` — `workspace_test_branch_preflight`, `branch_divergence_output`, Bash/PowerShell broad-test gating, and `test.hung` structured timeout provenance on tool-shell timeouts.
|
||||||
|
- `rust/crates/runtime/src/recovery_recipes.rs` — recovery recipes plus `RecoveryLedgerEntry` / `RecoveryAttemptState` ledger surface.
|
||||||
|
- `rust/crates/runtime/src/bash.rs` — runtime Bash timeout classification and structured provenance for hung test commands.
|
||||||
|
- `rust/crates/runtime/src/lib.rs` — public exports for the recovery ledger types.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- `cargo test -p runtime` → PASS: 538 unit tests, 2 G004 conformance tests, 12 integration tests, and doctests passed.
|
||||||
|
- `cargo test -p tools bash_tool_classifies_test_timeout_as_hung_with_provenance -- --nocapture` → PASS.
|
||||||
|
- `cargo test -p tools bash_workspace_tests_are_blocked_when_branch_is_behind_main -- --nocapture` → PASS.
|
||||||
|
- `cargo test -p tools bash_targeted_tests_skip_branch_preflight -- --nocapture` → PASS.
|
||||||
|
- `cargo check -p runtime -p tools` → PASS.
|
||||||
|
- `cargo clippy -p runtime --all-targets -- -D warnings` → PASS.
|
||||||
|
- `cargo clippy -p tools --lib --no-deps -- -D warnings` → PASS.
|
||||||
|
|
||||||
|
## Known unresolved / out-of-scope items
|
||||||
|
|
||||||
|
- Full `cargo test -p tools` is still red on six permission-enforcer expectation tests unrelated to G005 branch freshness, recovery ledger, or hung-test classification. The failing tests assert old permission wording/read-only behavior and pre-existed this follow-up scope.
|
||||||
|
- ROADMAP stale-base JSON/doctor/status pinpoints remain broader CLI diagnostic-surface work, especially `ROADMAP.md:2425-2489`, `ROADMAP.md:4346-4431`, and `ROADMAP.md:5061-5086`. They are related to branch freshness, but task 1 only required the broad-test freshness gate and narrow reporting surfaces.
|
||||||
|
- No `.omx/ultragoal` files were changed; leader-owned Ultragoal checkpointing remains outside worker scope.
|
||||||
|
|
||||||
|
## Delegation evidence
|
||||||
|
|
||||||
|
Subagent spawn evidence: 1, Repository map probe `019e25d5-9be9-7193-8a33-f21450beb62c`; spawned before further serial task-2 mapping per contract, but errored with 429 Too Many Requests, so direct repo evidence was integrated instead.
|
||||||
42
docs/pr-issue-resolution-gate.md
Normal file
42
docs/pr-issue-resolution-gate.md
Normal file
@@ -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.
|
||||||
58
docs/roadmap-pr-goals.md
Normal file
58
docs/roadmap-pr-goals.md
Normal file
@@ -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.
|
||||||
|
|
||||||
245
progress.txt
245
progress.txt
@@ -74,6 +74,18 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
|
|||||||
- DegradedMode behavior
|
- DegradedMode behavior
|
||||||
- Tests: 11 unit tests passing
|
- Tests: 11 unit tests passing
|
||||||
|
|
||||||
|
|
||||||
|
Iteration 2026-04-27 - ROADMAP #200 COMPLETED
|
||||||
|
------------------------------------------------
|
||||||
|
- Selected next actionable backlog item because no active task was in progress.
|
||||||
|
- ROADMAP #200: Interactive MCP/tool permission prompts are invisible blockers.
|
||||||
|
- Files: rust/crates/runtime/src/worker_boot.rs, rust/crates/runtime/src/recovery_recipes.rs, ROADMAP.md, progress.txt.
|
||||||
|
- Added tool_permission_required worker status and event classification for interactive MCP/tool permission gates.
|
||||||
|
- Added structured ToolPermissionPrompt payload with server/tool identity and prompt preview.
|
||||||
|
- Startup evidence now records tool_permission_prompt_detected and classifies timeout evidence as tool_permission_required.
|
||||||
|
- Readiness snapshots now mark tool-permission-gated workers as blocked, not ready/idle.
|
||||||
|
- Tests: targeted tool_permission regressions, full runtime test/clippy/fmt pending in Ralph verification loop.
|
||||||
|
|
||||||
VERIFICATION STATUS:
|
VERIFICATION STATUS:
|
||||||
------------------
|
------------------
|
||||||
- cargo build --workspace: PASSED
|
- cargo build --workspace: PASSED
|
||||||
@@ -108,6 +120,29 @@ US-010 COMPLETED (Add model compatibility documentation)
|
|||||||
- Cross-referenced with existing code comments in openai_compat.rs
|
- Cross-referenced with existing code comments in openai_compat.rs
|
||||||
- cargo clippy passes
|
- cargo clippy passes
|
||||||
|
|
||||||
|
Iteration 3: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-012 COMPLETED (Trust prompt resolver with allowlist auto-trust)
|
||||||
|
- Files: rust/crates/runtime/src/trust_resolver.rs
|
||||||
|
- Enhanced TrustConfig with pattern matching and serde support:
|
||||||
|
- TrustAllowlistEntry struct with pattern, worktree_pattern, description
|
||||||
|
- TrustResolution enum (AutoAllowlisted, ManualApproval)
|
||||||
|
- Enhanced TrustEvent variants with serde tags and metadata
|
||||||
|
- Glob pattern matching with * and ? wildcards
|
||||||
|
- Support for path prefix matching and worktree patterns
|
||||||
|
- Updated TrustResolver with new resolve() signature:
|
||||||
|
- Added worktree parameter for worktree pattern matching
|
||||||
|
- Proper event emission with TrustResolution
|
||||||
|
- Manual approval detection from screen text
|
||||||
|
- Added helper functions:
|
||||||
|
- extract_repo_name() - extracts repo name from path
|
||||||
|
- detect_manual_approval() - detects manual trust from screen text
|
||||||
|
- glob_matches() - recursive backtracking glob matcher
|
||||||
|
- Tests: 25 new tests for pattern matching, serialization, and resolver behavior
|
||||||
|
- All 483 runtime tests pass
|
||||||
|
- cargo clippy passes with no warnings
|
||||||
|
|
||||||
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
|
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
|
||||||
- Files:
|
- Files:
|
||||||
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
|
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
|
||||||
@@ -131,3 +166,213 @@ US-011 COMPLETED (Performance optimization: reduce API request serialization ove
|
|||||||
- is_reasoning_model detection: ~26-42ns depending on model
|
- is_reasoning_model detection: ~26-42ns depending on model
|
||||||
- All tests pass (119 unit tests + 29 integration tests)
|
- All tests pass (119 unit tests + 29 integration tests)
|
||||||
- cargo clippy passes
|
- cargo clippy passes
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 3):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
All 12 stories from prd.json now have passes: true
|
||||||
|
- US-001 through US-007: Pre-existing implementations
|
||||||
|
- US-008: kimi-k2.5 model API compatibility fix
|
||||||
|
- US-009: Unit tests for kimi model compatibility
|
||||||
|
- US-010: Model compatibility documentation
|
||||||
|
- US-011: Performance optimization with criterion benchmarks
|
||||||
|
- US-012: Trust prompt resolver with allowlist auto-trust
|
||||||
|
|
||||||
|
Iteration 4: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-013 COMPLETED (Phase 2 - Session event ordering + terminal-state reconciliation)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added EventTerminality enum (Terminal, Advisory, Uncertainty)
|
||||||
|
- Added classify_event_terminality() function for event classification
|
||||||
|
- Added reconcile_terminal_events() function for deterministic event ordering:
|
||||||
|
- Sorts events by monotonic sequence number
|
||||||
|
- Deduplicates terminal events by fingerprint
|
||||||
|
- Detects transport death uncertainty (terminal + transport death)
|
||||||
|
- Handles out-of-order event bursts
|
||||||
|
- Added events_materially_differ() for detecting meaningful differences
|
||||||
|
- Added 8 comprehensive tests for reconciliation logic:
|
||||||
|
- reconcile_terminal_events_sorts_by_monotonic_sequence
|
||||||
|
- reconcile_terminal_events_deduplicates_same_fingerprint
|
||||||
|
- reconcile_terminal_events_detects_transport_death_uncertainty
|
||||||
|
- reconcile_terminal_events_handles_completed_idle_error_completed_noise
|
||||||
|
- reconcile_terminal_events_returns_none_for_empty_input
|
||||||
|
- reconcile_terminal_events_preserves_advisory_events
|
||||||
|
- events_materially_differ_detects_real_differences
|
||||||
|
- classify_event_terminality_correctly_classifies
|
||||||
|
- Fixed test compilation issues with LaneEventBuilder API
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 4):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
US-013 marked passes: true in prd.json
|
||||||
|
|
||||||
|
US-014 COMPLETED (Phase 2 - Event provenance / environment labeling)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added ConfidenceLevel enum (High, Medium, Low, Unknown)
|
||||||
|
- Added fields to LaneEventMetadata:
|
||||||
|
- environment_label: Option<String> - environment/channel (production, staging, dev)
|
||||||
|
- emitter_identity: Option<String> - emitter (clawd, plugin-name, operator-id)
|
||||||
|
- confidence_level: Option<ConfidenceLevel> - trust level for automation
|
||||||
|
- Added builder methods: with_environment(), with_emitter(), with_confidence()
|
||||||
|
- Added filtering functions:
|
||||||
|
- filter_by_provenance() - select events by source
|
||||||
|
- filter_by_environment() - select events by environment label
|
||||||
|
- filter_by_confidence() - select events above confidence threshold
|
||||||
|
- is_test_event() - check if synthetic source (test, healthcheck, replay)
|
||||||
|
- is_live_lane_event() - check if production event
|
||||||
|
- Added 7 comprehensive tests for US-014:
|
||||||
|
- confidence_level_round_trips_through_serialization
|
||||||
|
- filter_by_provenance_selects_only_matching_events
|
||||||
|
- filter_by_environment_selects_only_matching_environment
|
||||||
|
- filter_by_confidence_selects_events_above_threshold
|
||||||
|
- is_test_event_detects_synthetic_sources
|
||||||
|
- is_live_lane_event_detects_production_events
|
||||||
|
- lane_event_metadata_includes_us014_fields
|
||||||
|
|
||||||
|
US-016 COMPLETED (Phase 2 - Duplicate terminal-event suppression)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Event fingerprinting already implemented via compute_event_fingerprint()
|
||||||
|
- Fingerprint attached via LaneEventMetadata.event_fingerprint
|
||||||
|
- Deduplication via dedupe_terminal_events() - returns first occurrence of each fingerprint
|
||||||
|
- Raw event history preserved separately from deduplicated actionable events
|
||||||
|
- Material difference detection via events_materially_differ():
|
||||||
|
- Different event type (Finished vs Failed) is material
|
||||||
|
- Different status is material
|
||||||
|
- Different failure class is material
|
||||||
|
- Different data payload is material
|
||||||
|
- Reconcile function surfaces latest terminal event when materially different
|
||||||
|
- Added 5 comprehensive tests for US-016:
|
||||||
|
- canonical_terminal_event_fingerprint_attached_to_metadata
|
||||||
|
- dedupe_terminal_events_suppresses_repeated_fingerprints
|
||||||
|
- dedupe_preserves_raw_event_history_separately
|
||||||
|
- events_materially_differ_detects_payload_differences
|
||||||
|
- reconcile_terminal_events_surfaces_latest_when_different
|
||||||
|
|
||||||
|
US-017 COMPLETED (Phase 2 - Lane ownership / scope binding)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- LaneOwnership struct already existed with:
|
||||||
|
- owner: String - owner/assignee identity
|
||||||
|
- workflow_scope: String - workflow scope (claw-code-dogfood, etc.)
|
||||||
|
- watcher_action: WatcherAction - Act, Observe, Ignore
|
||||||
|
- Ownership preserved through lifecycle via with_ownership() builder method
|
||||||
|
- All lifecycle events (Started -> Ready -> Finished) preserve ownership
|
||||||
|
- Added 3 comprehensive tests for US-017:
|
||||||
|
- lane_ownership_attached_to_metadata
|
||||||
|
- lane_ownership_preserved_through_lifecycle_events
|
||||||
|
- lane_ownership_watcher_action_variants
|
||||||
|
|
||||||
|
US-015 COMPLETED (Phase 2 - Session identity completeness at creation time)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- SessionIdentity struct already existed with:
|
||||||
|
- title: String - stable title for the session
|
||||||
|
- workspace: String - workspace/worktree path
|
||||||
|
- purpose: String - lane/session purpose
|
||||||
|
- placeholder_reason: Option<String> - reason for placeholder values
|
||||||
|
- Added reconcile_enriched() method for updating session identity:
|
||||||
|
- Updates title/workspace/purpose with newly available data
|
||||||
|
- Clears placeholder_reason when real values are provided
|
||||||
|
- Preserves existing values for fields not being updated
|
||||||
|
- Allows incremental enrichment without ambiguity
|
||||||
|
- Added 2 comprehensive tests:
|
||||||
|
- session_identity_reconcile_enriched_updates_fields
|
||||||
|
- session_identity_reconcile_preserves_placeholder_if_no_new_data
|
||||||
|
|
||||||
|
US-018 COMPLETED (Phase 2 - Nudge acknowledgment / dedupe contract)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added NudgeTracking struct:
|
||||||
|
- nudge_id: String - unique nudge identifier
|
||||||
|
- delivered_at: String - timestamp of delivery
|
||||||
|
- acknowledged: bool - whether acknowledged
|
||||||
|
- acknowledged_at: Option<String> - when acknowledged
|
||||||
|
- is_retry: bool - whether this is a retry
|
||||||
|
- original_nudge_id: Option<String> - original ID if retry
|
||||||
|
- Added NudgeClassification enum (New, Retry, StaleDuplicate)
|
||||||
|
- Added classify_nudge() function for deduplication logic
|
||||||
|
- Added 6 comprehensive tests for US-018
|
||||||
|
|
||||||
|
US-019 COMPLETED (Phase 2 - Stable roadmap-id assignment)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added RoadmapId struct:
|
||||||
|
- id: String - canonical unique identifier
|
||||||
|
- filed_at: String - timestamp when filed
|
||||||
|
- is_new_filing: bool - new vs update
|
||||||
|
- supersedes: Option<String> - lineage for supersedes
|
||||||
|
- Added builder methods: new_filing(), update(), supersedes()
|
||||||
|
- Added 3 comprehensive tests for US-019
|
||||||
|
|
||||||
|
US-020 COMPLETED (Phase 2 - Roadmap item lifecycle state contract)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added RoadmapLifecycleState enum (Filed, Acknowledged, InProgress, Blocked, Done, Superseded)
|
||||||
|
- Added RoadmapLifecycle struct:
|
||||||
|
- state: RoadmapLifecycleState - current state
|
||||||
|
- state_changed_at: String - last transition timestamp
|
||||||
|
- filed_at: String - original filing timestamp
|
||||||
|
- lineage: Vec<String> - supersession chain
|
||||||
|
- Added methods: new_filed(), transition(), superseded_by(), is_terminal(), is_active()
|
||||||
|
- Added 5 comprehensive tests for US-020
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 7):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
US-013 through US-015 and US-018 through US-020 now marked passes: true
|
||||||
|
|
||||||
|
FINAL VERIFICATION (All 20 Stories Complete):
|
||||||
|
------------------------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (119+ API tests, 39 runtime tests, 12 integration tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
ALL 20 STORIES FROM PRD COMPLETE:
|
||||||
|
- US-001 through US-012: Pre-existing implementations (verified working)
|
||||||
|
- US-013: Session event ordering + terminal-state reconciliation
|
||||||
|
- US-014: Event provenance / environment labeling
|
||||||
|
- US-015: Session identity completeness at creation time
|
||||||
|
- US-016: Duplicate terminal-event suppression
|
||||||
|
- US-017: Lane ownership / scope binding
|
||||||
|
- US-018: Nudge acknowledgment / dedupe contract
|
||||||
|
- US-019: Stable roadmap-id assignment
|
||||||
|
- US-020: Roadmap item lifecycle state contract
|
||||||
|
|
||||||
|
Iteration 8: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
|
||||||
|
- Files:
|
||||||
|
- rust/crates/api/src/error.rs (new error variant)
|
||||||
|
- rust/crates/api/src/providers/openai_compat.rs
|
||||||
|
- Added RequestBodySizeExceeded error variant with actionable message
|
||||||
|
- Added max_request_body_bytes to OpenAiCompatConfig:
|
||||||
|
- DashScope: 6MB (6_291_456 bytes) - from dogfood with kimi-k2.5
|
||||||
|
- OpenAI: 100MB (104_857_600 bytes)
|
||||||
|
- xAI: 50MB (52_428_800 bytes)
|
||||||
|
- Added estimate_request_body_size() for pre-flight checks
|
||||||
|
- Added check_request_body_size() for validation
|
||||||
|
- Pre-flight check integrated in send_raw_request()
|
||||||
|
- Tests: 5 new tests for size estimation and limit checking
|
||||||
|
|
||||||
|
PROJECT STATUS: COMPLETE (21/21 stories)
|
||||||
|
|
||||||
|
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
|
||||||
|
------------------------------------------------
|
||||||
|
- Pulled origin/main: already up to date.
|
||||||
|
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
|
||||||
|
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
|
||||||
|
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
|
||||||
|
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
|
||||||
|
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
|
||||||
|
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
|
||||||
|
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ This file provides guidance to Claw Code (clawcode.dev) when working with code i
|
|||||||
- Frameworks: none detected from the supported starter markers.
|
- Frameworks: none detected from the supported starter markers.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
|
||||||
|
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
|
||||||
|
|
||||||
## Working agreement
|
## Working agreement
|
||||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
|||||||
"too many tokens",
|
"too many tokens",
|
||||||
"prompt is too long",
|
"prompt is too long",
|
||||||
"input is too long",
|
"input is too long",
|
||||||
|
"input tokens exceed",
|
||||||
|
"configured limit",
|
||||||
|
"messages resulted in",
|
||||||
|
"completion tokens",
|
||||||
|
"prompt tokens",
|
||||||
"request is too large",
|
"request is too large",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -542,6 +547,26 @@ mod tests {
|
|||||||
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||||
|
let error = ApiError::Api {
|
||||||
|
status: reqwest::StatusCode::BAD_REQUEST,
|
||||||
|
error_type: Some("invalid_request_error".to_string()),
|
||||||
|
message: Some(
|
||||||
|
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
request_id: Some("req_ctx_openai_123".to_string()),
|
||||||
|
body: String::new(),
|
||||||
|
retryable: false,
|
||||||
|
suggested_action: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(error.is_context_window_failure());
|
||||||
|
assert_eq!(error.safe_failure_class(), "context_window");
|
||||||
|
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ pub use prompt_cache::{
|
|||||||
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
|
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
|
||||||
pub use providers::openai_compat::{
|
pub use providers::openai_compat::{
|
||||||
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
|
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
|
||||||
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
|
model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message,
|
||||||
|
OpenAiCompatClient, OpenAiCompatConfig,
|
||||||
};
|
};
|
||||||
pub use providers::{
|
pub use providers::{
|
||||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||||
resolve_model_alias, ProviderKind,
|
model_family_identity_for, model_family_identity_for_kind, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
|
|||||||
@@ -250,19 +250,31 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||||||
ProviderKind::Anthropic
|
ProviderKind::Anthropic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity {
|
||||||
|
match kind {
|
||||||
|
ProviderKind::Anthropic => runtime::ModelFamilyIdentity::Claude,
|
||||||
|
ProviderKind::Xai | ProviderKind::OpenAi => runtime::ModelFamilyIdentity::Generic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_family_identity_for(model: &str) -> runtime::ModelFamilyIdentity {
|
||||||
|
model_family_identity_for_kind(detect_provider_kind(model))
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||||
model_token_limit(model).map_or_else(
|
let canonical = resolve_model_alias(model);
|
||||||
|| {
|
let heuristic = if canonical.contains("opus") {
|
||||||
let canonical = resolve_model_alias(model);
|
32_000
|
||||||
if canonical.contains("opus") {
|
} else {
|
||||||
32_000
|
64_000
|
||||||
} else {
|
};
|
||||||
64_000
|
|
||||||
}
|
model_token_limit(model)
|
||||||
},
|
.map(|limit| heuristic.min(limit.max_output_tokens))
|
||||||
|limit| limit.max_output_tokens,
|
.unwrap_or(heuristic)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||||
@@ -276,7 +288,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||||
let canonical = resolve_model_alias(model);
|
let canonical = resolve_model_alias(model);
|
||||||
match canonical.as_str() {
|
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||||
|
match base_model {
|
||||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||||
max_output_tokens: 32_000,
|
max_output_tokens: 32_000,
|
||||||
context_window_tokens: 200_000,
|
context_window_tokens: 200_000,
|
||||||
@@ -289,6 +302,20 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||||||
max_output_tokens: 64_000,
|
max_output_tokens: 64_000,
|
||||||
context_window_tokens: 131_072,
|
context_window_tokens: 131_072,
|
||||||
}),
|
}),
|
||||||
|
// GPT-4.1 family via the OpenAI API.
|
||||||
|
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 32_768,
|
||||||
|
context_window_tokens: 1_047_576,
|
||||||
|
}),
|
||||||
|
// GPT-5.4 family via the OpenAI API.
|
||||||
|
"gpt-5.4" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 128_000,
|
||||||
|
context_window_tokens: 1_000_000,
|
||||||
|
}),
|
||||||
|
"gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 128_000,
|
||||||
|
context_window_tokens: 400_000,
|
||||||
|
}),
|
||||||
// Kimi models via DashScope (Moonshot AI)
|
// Kimi models via DashScope (Moonshot AI)
|
||||||
// Source: https://platform.moonshot.cn/docs/intro
|
// Source: https://platform.moonshot.cn/docs/intro
|
||||||
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
|
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
|
||||||
@@ -470,8 +497,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
||||||
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||||
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
|
model_family_identity_for, model_family_identity_for_kind, model_token_limit, parse_dotenv,
|
||||||
ProviderKind,
|
preflight_message_request, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Serializes every test in this module that mutates process-wide
|
/// Serializes every test in this module that mutates process-wide
|
||||||
@@ -530,6 +557,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_provider_kind_to_model_family_identity() {
|
||||||
|
// given: each supported provider kind
|
||||||
|
let anthropic = ProviderKind::Anthropic;
|
||||||
|
let openai = ProviderKind::OpenAi;
|
||||||
|
let xai = ProviderKind::Xai;
|
||||||
|
|
||||||
|
// when: converting provider kinds to prompt model family identities
|
||||||
|
let anthropic_identity = model_family_identity_for_kind(anthropic);
|
||||||
|
let openai_identity = model_family_identity_for_kind(openai);
|
||||||
|
let xai_identity = model_family_identity_for_kind(xai);
|
||||||
|
|
||||||
|
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||||
|
assert_eq!(anthropic_identity, runtime::ModelFamilyIdentity::Claude);
|
||||||
|
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_model_name_to_model_family_identity() {
|
||||||
|
// given: Anthropic, OpenAI-compatible, and xAI model names
|
||||||
|
let claude_model = "claude-opus-4-6";
|
||||||
|
let openai_model = "openai/gpt-4.1-mini";
|
||||||
|
let xai_model = "grok-3";
|
||||||
|
|
||||||
|
// when: detecting prompt model family identities from model names
|
||||||
|
let claude_identity = model_family_identity_for(claude_model);
|
||||||
|
let openai_identity = model_family_identity_for(openai_model);
|
||||||
|
let xai_identity = model_family_identity_for(xai_model);
|
||||||
|
|
||||||
|
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||||
|
assert_eq!(claude_identity, runtime::ModelFamilyIdentity::Claude);
|
||||||
|
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
||||||
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
||||||
@@ -614,6 +677,15 @@ mod tests {
|
|||||||
fn keeps_existing_max_token_heuristic() {
|
fn keeps_existing_max_token_heuristic() {
|
||||||
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
||||||
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caps_default_max_tokens_to_openai_model_limits() {
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768);
|
||||||
|
assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768);
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||||
|
assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -680,6 +752,18 @@ mod tests {
|
|||||||
.context_window_tokens,
|
.context_window_tokens,
|
||||||
131_072
|
131_072
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("openai/gpt-4.1-mini")
|
||||||
|
.expect("openai/gpt-4.1-mini should be registered")
|
||||||
|
.context_window_tokens,
|
||||||
|
1_047_576
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("gpt-5.4")
|
||||||
|
.expect("gpt-5.4 should be registered")
|
||||||
|
.context_window_tokens,
|
||||||
|
1_000_000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -728,6 +812,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_blocks_oversized_requests_for_gpt_5_4() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "gpt-5.4".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(3_900_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: Some("Keep the answer short.".to_string()),
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = preflight_message_request(&request)
|
||||||
|
.expect_err("oversized gpt-5.4 request should be rejected before the provider call");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ApiError::ContextWindowExceeded {
|
||||||
|
model,
|
||||||
|
requested_output_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(model, "gpt-5.4");
|
||||||
|
assert_eq!(requested_output_tokens, 64_000);
|
||||||
|
assert_eq!(context_window_tokens, 1_000_000);
|
||||||
|
}
|
||||||
|
other => panic!("expected context-window preflight failure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preflight_skips_unknown_models() {
|
fn preflight_skips_unknown_models() {
|
||||||
let request = MessageRequest {
|
let request = MessageRequest {
|
||||||
@@ -753,14 +873,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn returns_context_window_metadata_for_kimi_models() {
|
fn returns_context_window_metadata_for_kimi_models() {
|
||||||
// kimi-k2.5
|
// kimi-k2.5
|
||||||
let k25_limit = model_token_limit("kimi-k2.5")
|
let k25_limit =
|
||||||
.expect("kimi-k2.5 should have token limit metadata");
|
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
|
||||||
assert_eq!(k25_limit.max_output_tokens, 16_384);
|
assert_eq!(k25_limit.max_output_tokens, 16_384);
|
||||||
assert_eq!(k25_limit.context_window_tokens, 256_000);
|
assert_eq!(k25_limit.context_window_tokens, 256_000);
|
||||||
|
|
||||||
// kimi-k1.5
|
// kimi-k1.5
|
||||||
let k15_limit = model_token_limit("kimi-k1.5")
|
let k15_limit =
|
||||||
.expect("kimi-k1.5 should have token limit metadata");
|
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
|
||||||
assert_eq!(k15_limit.max_output_tokens, 16_384);
|
assert_eq!(k15_limit.max_output_tokens, 16_384);
|
||||||
assert_eq!(k15_limit.context_window_tokens, 256_000);
|
assert_eq!(k15_limit.context_window_tokens, 256_000);
|
||||||
}
|
}
|
||||||
@@ -768,11 +888,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
|
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
|
||||||
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
|
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
|
||||||
let alias_limit = model_token_limit("kimi")
|
let alias_limit =
|
||||||
.expect("kimi alias should resolve to kimi-k2.5 limits");
|
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
|
||||||
let direct_limit = model_token_limit("kimi-k2.5")
|
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
|
||||||
.expect("kimi-k2.5 should have limits");
|
assert_eq!(
|
||||||
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
|
alias_limit.max_output_tokens,
|
||||||
|
direct_limit.max_output_tokens
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alias_limit.context_window_tokens,
|
alias_limit.context_window_tokens,
|
||||||
direct_limit.context_window_tokens
|
direct_limit.context_window_tokens
|
||||||
|
|||||||
@@ -443,6 +443,8 @@ struct StreamState {
|
|||||||
stop_reason: Option<String>,
|
stop_reason: Option<String>,
|
||||||
usage: Option<Usage>,
|
usage: Option<Usage>,
|
||||||
tool_calls: BTreeMap<u32, ToolCallState>,
|
tool_calls: BTreeMap<u32, ToolCallState>,
|
||||||
|
thinking_started: bool,
|
||||||
|
thinking_finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -456,6 +458,8 @@ impl StreamState {
|
|||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
tool_calls: BTreeMap::new(),
|
tool_calls: BTreeMap::new(),
|
||||||
|
thinking_started: false,
|
||||||
|
thinking_finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,35 +497,61 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for choice in chunk.choices {
|
for choice in chunk.choices {
|
||||||
|
if let Some(reasoning) = choice
|
||||||
|
.delta
|
||||||
|
.reasoning_content
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
if !self.thinking_started {
|
||||||
|
self.thinking_started = true;
|
||||||
|
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 0,
|
||||||
|
content_block: OutputContentBlock::Thinking {
|
||||||
|
thinking: String::new(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 0,
|
||||||
|
delta: ContentBlockDelta::ThinkingDelta {
|
||||||
|
thinking: reasoning,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
||||||
|
self.close_thinking(&mut events);
|
||||||
if !self.text_started {
|
if !self.text_started {
|
||||||
self.text_started = true;
|
self.text_started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
content_block: OutputContentBlock::Text {
|
content_block: OutputContentBlock::Text {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
delta: ContentBlockDelta::TextDelta { text: content },
|
delta: ContentBlockDelta::TextDelta { text: content },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
for tool_call in choice.delta.tool_calls {
|
for tool_call in choice.delta.tool_calls {
|
||||||
|
self.close_thinking(&mut events);
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
let state = self.tool_calls.entry(tool_call.index).or_default();
|
let state = self.tool_calls.entry(tool_call.index).or_default();
|
||||||
state.apply(tool_call);
|
state.apply(tool_call);
|
||||||
let block_index = state.block_index();
|
let block_index = state.block_index(tool_index_offset);
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(delta_event) = state.delta_event() {
|
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||||
}
|
}
|
||||||
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
|
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
|
||||||
@@ -535,11 +565,12 @@ impl StreamState {
|
|||||||
if let Some(finish_reason) = choice.finish_reason {
|
if let Some(finish_reason) = choice.finish_reason {
|
||||||
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
|
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
|
||||||
if finish_reason == "tool_calls" {
|
if finish_reason == "tool_calls" {
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
for state in self.tool_calls.values_mut() {
|
for state in self.tool_calls.values_mut() {
|
||||||
if state.started && !state.stopped {
|
if state.started && !state.stopped {
|
||||||
state.stopped = true;
|
state.stopped = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: state.block_index(),
|
index: state.block_index(tool_index_offset),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,19 +588,21 @@ impl StreamState {
|
|||||||
self.finished = true;
|
self.finished = true;
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
self.close_thinking(&mut events);
|
||||||
if self.text_started && !self.text_finished {
|
if self.text_started && !self.text_finished {
|
||||||
self.text_finished = true;
|
self.text_finished = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
for state in self.tool_calls.values_mut() {
|
for state in self.tool_calls.values_mut() {
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
if let Some(delta_event) = state.delta_event() {
|
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +610,7 @@ impl StreamState {
|
|||||||
if state.started && !state.stopped {
|
if state.started && !state.stopped {
|
||||||
state.stopped = true;
|
state.stopped = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: state.block_index(),
|
index: state.block_index(tool_index_offset),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +636,31 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn close_thinking(&mut self, events: &mut Vec<StreamEvent>) {
|
||||||
|
if self.thinking_started && !self.thinking_finished {
|
||||||
|
self.thinking_finished = true;
|
||||||
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
|
index: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn text_block_index(&self) -> u32 {
|
||||||
|
if self.thinking_started {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn tool_index_offset(&self) -> u32 {
|
||||||
|
if self.thinking_started {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -630,12 +688,12 @@ impl ToolCallState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn block_index(&self) -> u32 {
|
const fn block_index(&self, offset: u32) -> u32 {
|
||||||
self.openai_index + 1
|
self.openai_index + offset
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
fn start_event(&self, offset: u32) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
||||||
let Some(name) = self.name.clone() else {
|
let Some(name) = self.name.clone() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -644,7 +702,7 @@ impl ToolCallState {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
||||||
Ok(Some(ContentBlockStartEvent {
|
Ok(Some(ContentBlockStartEvent {
|
||||||
index: self.block_index(),
|
index: self.block_index(offset),
|
||||||
content_block: OutputContentBlock::ToolUse {
|
content_block: OutputContentBlock::ToolUse {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -653,14 +711,14 @@ impl ToolCallState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
fn delta_event(&mut self, offset: u32) -> Option<ContentBlockDeltaEvent> {
|
||||||
if self.emitted_len >= self.arguments.len() {
|
if self.emitted_len >= self.arguments.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let delta = self.arguments[self.emitted_len..].to_string();
|
let delta = self.arguments[self.emitted_len..].to_string();
|
||||||
self.emitted_len = self.arguments.len();
|
self.emitted_len = self.arguments.len();
|
||||||
Some(ContentBlockDeltaEvent {
|
Some(ContentBlockDeltaEvent {
|
||||||
index: self.block_index(),
|
index: self.block_index(offset),
|
||||||
delta: ContentBlockDelta::InputJsonDelta {
|
delta: ContentBlockDelta::InputJsonDelta {
|
||||||
partial_json: delta,
|
partial_json: delta,
|
||||||
},
|
},
|
||||||
@@ -690,6 +748,8 @@ struct ChatMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
tool_calls: Vec<ResponseToolCall>,
|
tool_calls: Vec<ResponseToolCall>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,6 +795,8 @@ struct ChunkChoice {
|
|||||||
struct ChunkDelta {
|
struct ChunkDelta {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||||
tool_calls: Vec<DeltaToolCall>,
|
tool_calls: Vec<DeltaToolCall>,
|
||||||
}
|
}
|
||||||
@@ -793,6 +855,15 @@ pub fn is_reasoning_model(model: &str) -> bool {
|
|||||||
|| canonical.contains("thinking")
|
|| canonical.contains("thinking")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true for OpenAI-compatible DeepSeek V4 models that require prior
|
||||||
|
/// assistant reasoning to be echoed back as `reasoning_content` in history.
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
||||||
|
let lowered = model.to_ascii_lowercase();
|
||||||
|
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
|
||||||
|
canonical.starts_with("deepseek-v4")
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||||
/// The prefix is used only to select transport; the backend expects the
|
/// The prefix is used only to select transport; the backend expects the
|
||||||
/// bare model id.
|
/// bare model id.
|
||||||
@@ -948,10 +1019,14 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
match message.role.as_str() {
|
match message.role.as_str() {
|
||||||
"assistant" => {
|
"assistant" => {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
let mut reasoning = String::new();
|
||||||
let mut tool_calls = Vec::new();
|
let mut tool_calls = Vec::new();
|
||||||
for block in &message.content {
|
for block in &message.content {
|
||||||
match block {
|
match block {
|
||||||
InputContentBlock::Text { text: value } => text.push_str(value),
|
InputContentBlock::Text { text: value } => text.push_str(value),
|
||||||
|
InputContentBlock::Thinking {
|
||||||
|
thinking: value, ..
|
||||||
|
} => reasoning.push_str(value),
|
||||||
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
|
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -963,13 +1038,18 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
InputContentBlock::ToolResult { .. } => {}
|
InputContentBlock::ToolResult { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if text.is_empty() && tool_calls.is_empty() {
|
let include_reasoning =
|
||||||
|
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
|
||||||
|
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
let mut msg = serde_json::json!({
|
let mut msg = serde_json::json!({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": (!text.is_empty()).then_some(text),
|
"content": (!text.is_empty()).then_some(text),
|
||||||
});
|
});
|
||||||
|
if include_reasoning {
|
||||||
|
msg["reasoning_content"] = json!(reasoning);
|
||||||
|
}
|
||||||
// Only include tool_calls when non-empty: some providers reject
|
// Only include tool_calls when non-empty: some providers reject
|
||||||
// assistant messages with an explicit empty tool_calls array.
|
// assistant messages with an explicit empty tool_calls array.
|
||||||
if !tool_calls.is_empty() {
|
if !tool_calls.is_empty() {
|
||||||
@@ -1003,6 +1083,7 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
Some(msg)
|
Some(msg)
|
||||||
}
|
}
|
||||||
|
InputContentBlock::Thinking { .. } => None,
|
||||||
InputContentBlock::ToolUse { .. } => None,
|
InputContentBlock::ToolUse { .. } => None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -1182,6 +1263,16 @@ fn normalize_response(
|
|||||||
"chat completion response missing choices",
|
"chat completion response missing choices",
|
||||||
))?;
|
))?;
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
|
if let Some(thinking) = choice
|
||||||
|
.message
|
||||||
|
.reasoning_content
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
content.push(OutputContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
|
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
|
||||||
content.push(OutputContentBlock::Text { text });
|
content.push(OutputContentBlock::Text { text });
|
||||||
}
|
}
|
||||||
@@ -1413,13 +1504,15 @@ impl StringExt for String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||||
normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient,
|
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||||
OpenAiCompatConfig,
|
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||||
|
StreamState,
|
||||||
};
|
};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
|
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||||
ToolResultContentBlock,
|
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
|
||||||
|
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
@@ -1465,6 +1558,188 @@ mod tests {
|
|||||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_requires_reasoning_content_in_history_detects_deepseek_v4_models() {
|
||||||
|
// Given DeepSeek V4 and non-V4 model names.
|
||||||
|
let positive = [
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
"openai/deepseek-v4-pro",
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
];
|
||||||
|
let negative = [
|
||||||
|
"deepseek-reasoner",
|
||||||
|
"deepseek-chat",
|
||||||
|
"gpt-4o",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
];
|
||||||
|
|
||||||
|
// When checking whether history reasoning_content is required.
|
||||||
|
// Then only DeepSeek V4 variants require it.
|
||||||
|
for model in positive {
|
||||||
|
assert!(model_requires_reasoning_content_in_history(model));
|
||||||
|
}
|
||||||
|
for model in negative {
|
||||||
|
assert!(!model_requires_reasoning_content_in_history(model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_deepseek_reasoner_request_omits_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("deepseek-reasoner");
|
||||||
|
|
||||||
|
// When serializing for legacy deepseek-reasoner.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is omitted.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["role"], json!("assistant"));
|
||||||
|
assert!(assistant.get("reasoning_content").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_pro_request_includes_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("openai/deepseek-v4-pro");
|
||||||
|
|
||||||
|
// When serializing for DeepSeek V4 Pro.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is included on the assistant message.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||||
|
assert_eq!(assistant["content"], json!("answer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("deepseek-v4-flash");
|
||||||
|
|
||||||
|
// When serializing for DeepSeek V4 Flash.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is included on the assistant message.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_streaming_response_with_reasoning_content_emits_thinking_block_first() {
|
||||||
|
// Given a non-streaming OpenAI-compatible response with reasoning_content.
|
||||||
|
let response = super::ChatCompletionResponse {
|
||||||
|
id: "chatcmpl_reasoning".to_string(),
|
||||||
|
model: "deepseek-v4-pro".to_string(),
|
||||||
|
choices: vec![super::ChatChoice {
|
||||||
|
message: super::ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: Some("final answer".to_string()),
|
||||||
|
reasoning_content: Some("hidden thought".to_string()),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When normalizing the provider response.
|
||||||
|
let normalized = normalize_response("deepseek-v4-pro", response).expect("normalized");
|
||||||
|
|
||||||
|
// Then Thinking is the first content block, before text.
|
||||||
|
assert_eq!(
|
||||||
|
normalized.content,
|
||||||
|
vec![
|
||||||
|
OutputContentBlock::Thinking {
|
||||||
|
thinking: "hidden thought".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
OutputContentBlock::Text {
|
||||||
|
text: "final answer".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() {
|
||||||
|
// Given streaming chunks with reasoning_content followed by text.
|
||||||
|
let mut state = StreamState::new("deepseek-v4-pro".to_string());
|
||||||
|
let mut events = state
|
||||||
|
.ingest_chunk(super::ChatCompletionChunk {
|
||||||
|
id: "chatcmpl_stream_reasoning".to_string(),
|
||||||
|
model: Some("deepseek-v4-pro".to_string()),
|
||||||
|
choices: vec![super::ChunkChoice {
|
||||||
|
delta: super::ChunkDelta {
|
||||||
|
content: None,
|
||||||
|
reasoning_content: Some("think".to_string()),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: None,
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
.expect("reasoning chunk");
|
||||||
|
events.extend(
|
||||||
|
state
|
||||||
|
.ingest_chunk(super::ChatCompletionChunk {
|
||||||
|
id: "chatcmpl_stream_reasoning".to_string(),
|
||||||
|
model: None,
|
||||||
|
choices: vec![super::ChunkChoice {
|
||||||
|
delta: super::ChunkDelta {
|
||||||
|
content: Some(" answer".to_string()),
|
||||||
|
reasoning_content: None,
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
.expect("text chunk"),
|
||||||
|
);
|
||||||
|
events.extend(state.finish().expect("finish"));
|
||||||
|
|
||||||
|
// When reading normalized stream events.
|
||||||
|
// Then Thinking starts at index 0, text is offset to index 1.
|
||||||
|
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
||||||
|
assert!(matches!(
|
||||||
|
events[1],
|
||||||
|
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 0,
|
||||||
|
content_block: OutputContentBlock::Thinking { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[2],
|
||||||
|
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 0,
|
||||||
|
delta: ContentBlockDelta::ThinkingDelta { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[3],
|
||||||
|
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[4],
|
||||||
|
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 1,
|
||||||
|
content_block: OutputContentBlock::Text { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[5],
|
||||||
|
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 1,
|
||||||
|
delta: ContentBlockDelta::TextDelta { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[6],
|
||||||
|
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
||||||
// OpenAI /responses endpoint rejects object schemas missing
|
// OpenAI /responses endpoint rejects object schemas missing
|
||||||
@@ -1624,6 +1899,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assistant_history_with_thinking_request(model: &str) -> MessageRequest {
|
||||||
|
MessageRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: vec![
|
||||||
|
InputContentBlock::Thinking {
|
||||||
|
thinking: "prior reasoning".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
InputContentBlock::Text {
|
||||||
|
text: "answer".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
@@ -2195,9 +2491,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_specific_size_limits_are_correct() {
|
fn provider_specific_size_limits_are_correct() {
|
||||||
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
|
assert_eq!(
|
||||||
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
|
OpenAiCompatConfig::dashscope().max_request_body_bytes,
|
||||||
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
|
6_291_456
|
||||||
|
); // 6MB
|
||||||
|
assert_eq!(
|
||||||
|
OpenAiCompatConfig::openai().max_request_body_bytes,
|
||||||
|
104_857_600
|
||||||
|
); // 100MB
|
||||||
|
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
|
||||||
|
// 50MB
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ pub enum InputContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -268,8 +273,9 @@ pub enum StreamEvent {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use runtime::format_usd;
|
use runtime::format_usd;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{MessageResponse, Usage};
|
use super::{InputContentBlock, MessageResponse, Usage};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_total_tokens_includes_cache_tokens() {
|
fn usage_total_tokens_includes_cache_tokens() {
|
||||||
@@ -307,4 +313,33 @@ mod tests {
|
|||||||
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
|
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
|
||||||
assert_eq!(response.total_tokens(), 1_800_000);
|
assert_eq!(response.total_tokens(), 1_800_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_content_block_thinking_serializes_with_snake_case_type() {
|
||||||
|
// given
|
||||||
|
let block = InputContentBlock::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig_123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// when
|
||||||
|
let serialized = serde_json::to_value(&block).unwrap();
|
||||||
|
let deserialized: InputContentBlock = serde_json::from_value(json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "pondering",
|
||||||
|
"signature": "sig_123"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
serialized,
|
||||||
|
json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "pondering",
|
||||||
|
"signature": "sig_123"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(deserialized, block);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,50 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
|
|||||||
assert_eq!(body["tools"][0]["type"], json!("function"));
|
assert_eq!(body["tools"][0]["type"], json!("function"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||||
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
let body = concat!(
|
||||||
|
"{",
|
||||||
|
"\"id\":\"chatcmpl_deepseek_reasoning\",",
|
||||||
|
"\"model\":\"deepseek-v4-pro\",",
|
||||||
|
"\"choices\":[{",
|
||||||
|
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
|
||||||
|
"\"finish_reason\":\"stop\"",
|
||||||
|
"}],",
|
||||||
|
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
let server = spawn_server(
|
||||||
|
state.clone(),
|
||||||
|
vec![http_response("200 OK", "application/json", body)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url(server.base_url());
|
||||||
|
let response = client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
model: "openai/deepseek-v4-pro".to_string(),
|
||||||
|
..sample_request(false)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.content,
|
||||||
|
vec![
|
||||||
|
OutputContentBlock::Thinking {
|
||||||
|
thinking: "Think first".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
OutputContentBlock::Text {
|
||||||
|
text: "Answer second".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
|||||||
@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
let skills = load_skills_from_roots(&roots)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report(&skills))
|
Ok(render_skills_report(&skills))
|
||||||
}
|
}
|
||||||
|
Some(args) if args.starts_with("list ") => {
|
||||||
|
let filter = args["list ".len()..].trim().to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let filtered: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report(&filtered))
|
||||||
|
}
|
||||||
|
Some("show" | "info" | "describe") => {
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report(&skills))
|
||||||
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
let name = args
|
||||||
|
.splitn(2, ' ')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let matched: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase() == name)
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report(&matched))
|
||||||
|
}
|
||||||
Some("install") => Ok(render_skills_usage(Some("install"))),
|
Some("install") => Ok(render_skills_usage(Some("install"))),
|
||||||
Some(args) if args.starts_with("install ") => {
|
Some(args) if args.starts_with("install ") => {
|
||||||
let target = args["install ".len()..].trim();
|
let target = args["install ".len()..].trim();
|
||||||
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
let skills = load_skills_from_roots(&roots)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report_json(&skills))
|
Ok(render_skills_report_json(&skills))
|
||||||
}
|
}
|
||||||
|
Some(args) if args.starts_with("list ") => {
|
||||||
|
let filter = args["list ".len()..].trim().to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let filtered: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report_json(&filtered))
|
||||||
|
}
|
||||||
|
Some("show" | "info" | "describe") => {
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report_json(&skills))
|
||||||
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
let name = args
|
||||||
|
.splitn(2, ' ')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let matched: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase() == name)
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report_json(&matched))
|
||||||
|
}
|
||||||
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
||||||
Some(args) if args.starts_with("install ") => {
|
Some(args) if args.starts_with("install ") => {
|
||||||
let target = args["install ".len()..].trim();
|
let target = args["install ".len()..].trim();
|
||||||
@@ -2419,10 +2487,27 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
||||||
match normalize_optional_args(args) {
|
match normalize_optional_args(args) {
|
||||||
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
|
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
|
||||||
|
SkillSlashDispatch::Local
|
||||||
|
}
|
||||||
|
Some(args)
|
||||||
|
if args
|
||||||
|
.split_whitespace()
|
||||||
|
.any(|part| matches!(part, "-h" | "--help")) =>
|
||||||
|
{
|
||||||
|
SkillSlashDispatch::Local
|
||||||
|
}
|
||||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||||
SkillSlashDispatch::Local
|
SkillSlashDispatch::Local
|
||||||
}
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("list ")
|
||||||
|
|| args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
SkillSlashDispatch::Local
|
||||||
|
}
|
||||||
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2596,10 +2681,44 @@ fn render_mcp_report_for(
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||||
|
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
|
||||||
|
Ok(render_mcp_unsupported_action_text(
|
||||||
|
args,
|
||||||
|
"list accepts no filter argument; use `claw mcp list`",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||||
|
Ok(render_mcp_unsupported_action_text(
|
||||||
|
args,
|
||||||
|
"use `claw mcp show <server>` to inspect a server",
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(args) => Ok(render_mcp_usage(Some(args))),
|
Some(args) => Ok(render_mcp_usage(Some(args))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"kind": "mcp",
|
||||||
|
"action": "error",
|
||||||
|
"ok": false,
|
||||||
|
"error_kind": "unsupported_action",
|
||||||
|
"requested_action": action,
|
||||||
|
"hint": hint,
|
||||||
|
"usage": {
|
||||||
|
"slash_command": "/mcp [list|show <server>|help]",
|
||||||
|
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn render_mcp_report_json_for(
|
fn render_mcp_report_json_for(
|
||||||
loader: &ConfigLoader,
|
loader: &ConfigLoader,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
@@ -2623,10 +2742,8 @@ fn render_mcp_report_json_for(
|
|||||||
// runs, the existing serializer adds `status: "ok"` below.
|
// runs, the existing serializer adds `status: "ok"` below.
|
||||||
match loader.load() {
|
match loader.load() {
|
||||||
Ok(runtime_config) => {
|
Ok(runtime_config) => {
|
||||||
let mut value = render_mcp_summary_report_json(
|
let mut value =
|
||||||
cwd,
|
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||||
runtime_config.mcp().servers(),
|
|
||||||
);
|
|
||||||
if let Some(map) = value.as_object_mut() {
|
if let Some(map) = value.as_object_mut() {
|
||||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||||
map.insert("config_load_error".to_string(), Value::Null);
|
map.insert("config_load_error".to_string(), Value::Null);
|
||||||
@@ -2682,6 +2799,18 @@ fn render_mcp_report_json_for(
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||||
|
Ok(render_mcp_unsupported_action_json(
|
||||||
|
args,
|
||||||
|
"list accepts no filter argument; use `claw mcp list`",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||||
|
Ok(render_mcp_unsupported_action_json(
|
||||||
|
args,
|
||||||
|
"use `claw mcp show <server>` to inspect a server",
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4621,6 +4750,32 @@ mod tests {
|
|||||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_show_and_list_filter_do_not_invoke_model() {
|
||||||
|
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
|
||||||
|
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
|
||||||
|
for token in &["show", "info", "describe"] {
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some(token)),
|
||||||
|
SkillSlashDispatch::Local,
|
||||||
|
"`skills {token}` alone must be Local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for prefix in &["show ", "info ", "list ", "describe "] {
|
||||||
|
let arg = format!("{prefix}plan");
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some(&arg)),
|
||||||
|
SkillSlashDispatch::Local,
|
||||||
|
"`skills {arg}` must be Local, not Invoke"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Bare invocable tokens still dispatch to Invoke.
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some("plan")),
|
||||||
|
SkillSlashDispatch::Invoke("$plan".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -4643,6 +4798,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
|
||||||
|
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
|
||||||
|
// Regression for #504: these previously fell through to render_mcp_usage with
|
||||||
|
// unexpected=arg, giving no machine-readable error_kind.
|
||||||
|
use crate::handle_mcp_slash_command_json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
let cwd = PathBuf::from("/tmp");
|
||||||
|
|
||||||
|
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
|
||||||
|
.expect("info nonexistent should not error at IO level");
|
||||||
|
assert_eq!(info_json["kind"], "mcp");
|
||||||
|
assert_eq!(info_json["ok"], false);
|
||||||
|
assert_eq!(info_json["error_kind"], "unsupported_action");
|
||||||
|
assert!(info_json["hint"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("show"));
|
||||||
|
|
||||||
|
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
|
||||||
|
.expect("list nonexistent should not error at IO level");
|
||||||
|
assert_eq!(list_filter_json["kind"], "mcp");
|
||||||
|
assert_eq!(list_filter_json["ok"], false);
|
||||||
|
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
|
||||||
|
|
||||||
|
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
|
||||||
|
.expect("describe myserver should not error at IO level");
|
||||||
|
assert_eq!(describe_json["kind"], "mcp");
|
||||||
|
assert_eq!(describe_json["ok"], false);
|
||||||
|
assert_eq!(describe_json["error_kind"], "unsupported_action");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_mcp_arguments() {
|
fn rejects_invalid_mcp_arguments() {
|
||||||
let show_error = parse_error_message("/mcp show alpha beta");
|
let show_error = parse_error_message("/mcp show alpha beta");
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ fn detect_scenario(request: &MessageRequest) -> Option<Scenario> {
|
|||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
|
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
|
||||||
.and_then(Scenario::parse),
|
.and_then(Scenario::parse),
|
||||||
|
InputContentBlock::Thinking { .. } => None,
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
502
rust/crates/runtime/src/approval_tokens.rs
Normal file
502
rust/crates/runtime/src/approval_tokens.rs
Normal file
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use std::process::{Command, Stdio};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
use tokio::process::Command as TokioCommand;
|
use tokio::process::Command as TokioCommand;
|
||||||
use tokio::runtime::Builder;
|
use tokio::runtime::Builder;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
@@ -122,7 +123,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
|
|||||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||||
pr_number: None,
|
pr_number: None,
|
||||||
};
|
};
|
||||||
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
|
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
|
||||||
// Log to stderr as interim routing before event stream integration
|
// Log to stderr as interim routing before event stream integration
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
||||||
@@ -172,31 +173,14 @@ async fn execute_bash_async(
|
|||||||
) -> io::Result<BashCommandOutput> {
|
) -> io::Result<BashCommandOutput> {
|
||||||
// Detect and emit ship provenance for git push operations
|
// Detect and emit ship provenance for git push operations
|
||||||
detect_and_emit_ship_prepared(&input.command);
|
detect_and_emit_ship_prepared(&input.command);
|
||||||
|
|
||||||
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
|
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
|
||||||
|
|
||||||
let output_result = if let Some(timeout_ms) = input.timeout {
|
let output_result = if let Some(timeout_ms) = input.timeout {
|
||||||
match timeout(Duration::from_millis(timeout_ms), command.output()).await {
|
if let Ok(result) = timeout(Duration::from_millis(timeout_ms), command.output()).await {
|
||||||
Ok(result) => (result?, false),
|
(result?, false)
|
||||||
Err(_) => {
|
} else {
|
||||||
return Ok(BashCommandOutput {
|
return Ok(timeout_output(&input, timeout_ms, sandbox_status));
|
||||||
stdout: String::new(),
|
|
||||||
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
|
|
||||||
raw_output_path: None,
|
|
||||||
interrupted: true,
|
|
||||||
is_image: None,
|
|
||||||
background_task_id: None,
|
|
||||||
backgrounded_by_user: None,
|
|
||||||
assistant_auto_backgrounded: None,
|
|
||||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
|
||||||
return_code_interpretation: Some(String::from("timeout")),
|
|
||||||
no_output_expected: Some(true),
|
|
||||||
structured_content: None,
|
|
||||||
persisted_output_path: None,
|
|
||||||
persisted_output_size: None,
|
|
||||||
sandbox_status: Some(sandbox_status),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(command.output().await?, false)
|
(command.output().await?, false)
|
||||||
@@ -233,6 +217,67 @@ async fn execute_bash_async(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn timeout_output(
|
||||||
|
input: &BashCommandInput,
|
||||||
|
timeout_ms: u64,
|
||||||
|
sandbox_status: SandboxStatus,
|
||||||
|
) -> BashCommandOutput {
|
||||||
|
let is_test = is_test_command(&input.command);
|
||||||
|
let return_code_interpretation = if is_test { "test.hung" } else { "timeout" };
|
||||||
|
BashCommandOutput {
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
|
||||||
|
raw_output_path: None,
|
||||||
|
interrupted: true,
|
||||||
|
is_image: None,
|
||||||
|
background_task_id: None,
|
||||||
|
backgrounded_by_user: None,
|
||||||
|
assistant_auto_backgrounded: None,
|
||||||
|
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||||
|
return_code_interpretation: Some(String::from(return_code_interpretation)),
|
||||||
|
no_output_expected: Some(true),
|
||||||
|
structured_content: Some(vec![test_timeout_provenance(
|
||||||
|
&input.command,
|
||||||
|
timeout_ms,
|
||||||
|
is_test,
|
||||||
|
)]),
|
||||||
|
persisted_output_path: None,
|
||||||
|
persisted_output_size: None,
|
||||||
|
sandbox_status: Some(sandbox_status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_test_command(command: &str) -> bool {
|
||||||
|
let normalized = command
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
normalized.contains("cargo test")
|
||||||
|
|| normalized.contains("cargo nextest")
|
||||||
|
|| normalized.contains("npm test")
|
||||||
|
|| normalized.contains("pnpm test")
|
||||||
|
|| normalized.contains("yarn test")
|
||||||
|
|| normalized.contains("pytest")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_timeout_provenance(
|
||||||
|
command: &str,
|
||||||
|
timeout_ms: u64,
|
||||||
|
classified_as_test_hang: bool,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"event": if classified_as_test_hang { "test.hung" } else { "command.timeout" },
|
||||||
|
"failureClass": if classified_as_test_hang { "test_hang" } else { "timeout" },
|
||||||
|
"data": {
|
||||||
|
"command": command,
|
||||||
|
"timeoutMs": timeout_ms,
|
||||||
|
"provenance": "bash.timeout",
|
||||||
|
"classification": if classified_as_test_hang { "test.hung" } else { "timeout" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
|
fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
|
||||||
let config = ConfigLoader::default_for(cwd).load().map_or_else(
|
let config = ConfigLoader::default_for(cwd).load().map_or_else(
|
||||||
|_| SandboxConfig::default(),
|
|_| SandboxConfig::default(),
|
||||||
@@ -349,6 +394,31 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!output.sandbox_status.expect("sandbox status").enabled);
|
assert!(!output.sandbox_status.expect("sandbox status").enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timed_out_test_command_is_classified_as_hung_test_with_provenance() {
|
||||||
|
let output = execute_bash(BashCommandInput {
|
||||||
|
command: String::from("sleep 1 # cargo test slow_case"),
|
||||||
|
timeout: Some(1),
|
||||||
|
description: None,
|
||||||
|
run_in_background: Some(false),
|
||||||
|
dangerously_disable_sandbox: Some(false),
|
||||||
|
namespace_restrictions: Some(false),
|
||||||
|
isolate_network: Some(false),
|
||||||
|
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
|
||||||
|
allowed_mounts: None,
|
||||||
|
})
|
||||||
|
.expect("bash command should return structured timeout");
|
||||||
|
|
||||||
|
assert!(output.interrupted);
|
||||||
|
assert_eq!(
|
||||||
|
output.return_code_interpretation.as_deref(),
|
||||||
|
Some("test.hung")
|
||||||
|
);
|
||||||
|
let structured = output.structured_content.expect("structured content");
|
||||||
|
assert_eq!(structured[0]["event"], "test.hung");
|
||||||
|
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
.filter_map(|block| match block {
|
.filter_map(|block| match block {
|
||||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||||
ContentBlock::Text { .. } => None,
|
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
tool_names.sort_unstable();
|
tool_names.sort_unstable();
|
||||||
@@ -317,6 +317,9 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) ->
|
|||||||
fn summarize_block(block: &ContentBlock) -> String {
|
fn summarize_block(block: &ContentBlock) -> String {
|
||||||
let raw = match block {
|
let raw = match block {
|
||||||
ContentBlock::Text { text } => text.clone(),
|
ContentBlock::Text { text } => text.clone(),
|
||||||
|
ContentBlock::Thinking { thinking, .. } => {
|
||||||
|
format!("thinking ({} chars)", thinking.chars().count())
|
||||||
|
}
|
||||||
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name,
|
tool_name,
|
||||||
@@ -378,6 +381,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
|||||||
ContentBlock::Text { text } => text.as_str(),
|
ContentBlock::Text { text } => text.as_str(),
|
||||||
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||||
|
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
|
||||||
})
|
})
|
||||||
.flat_map(extract_file_candidates)
|
.flat_map(extract_file_candidates)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -400,6 +404,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
|||||||
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||||
ContentBlock::ToolUse { .. }
|
ContentBlock::ToolUse { .. }
|
||||||
| ContentBlock::ToolResult { .. }
|
| ContentBlock::ToolResult { .. }
|
||||||
|
| ContentBlock::Thinking { .. }
|
||||||
| ContentBlock::Text { .. } => None,
|
| ContentBlock::Text { .. } => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -450,6 +455,10 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
|||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name, output, ..
|
tool_name, output, ..
|
||||||
} => (tool_name.len() + output.len()) / 4 + 1,
|
} => (tool_name.len() + output.len()) / 4 + 1,
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => thinking.len() / 4 + signature.as_ref().map_or(0, |value| value.len() / 4 + 1),
|
||||||
})
|
})
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,6 +414,17 @@ impl RuntimeConfig {
|
|||||||
pub fn trusted_roots(&self) -> &[String] {
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
&self.feature_config.trusted_roots
|
&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 {
|
impl RuntimeFeatureConfig {
|
||||||
@@ -483,6 +494,22 @@ impl RuntimeFeatureConfig {
|
|||||||
pub fn trusted_roots(&self) -> &[String] {
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
&self.trusted_roots
|
&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 {
|
impl ProviderFallbackConfig {
|
||||||
@@ -1245,8 +1272,8 @@ fn push_unique(target: &mut Vec<String>, value: String) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
||||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
|
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
||||||
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
@@ -1502,6 +1529,51 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
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]
|
#[test]
|
||||||
fn trusted_roots_default_is_empty_when_unset() {
|
fn trusted_roots_default_is_empty_when_unset() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub struct ApiRequest {
|
|||||||
/// Streamed events emitted while processing a single assistant turn.
|
/// Streamed events emitted while processing a single assistant turn.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AssistantEvent {
|
pub enum AssistantEvent {
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
TextDelta(String),
|
TextDelta(String),
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -721,6 +725,16 @@ fn build_assistant_message(
|
|||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
|
AssistantEvent::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
flush_text_block(&mut text, &mut blocks);
|
||||||
|
blocks.push(ContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
}
|
||||||
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
||||||
AssistantEvent::ToolUse { id, name, input } => {
|
AssistantEvent::ToolUse { id, name, input } => {
|
||||||
flush_text_block(&mut text, &mut blocks);
|
flush_text_block(&mut text, &mut blocks);
|
||||||
@@ -1723,6 +1737,47 @@ mod tests {
|
|||||||
.contains("assistant stream produced no content"));
|
.contains("assistant stream produced no content"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_assistant_message_places_thinking_block_before_text_and_tool_use() {
|
||||||
|
// given
|
||||||
|
let events = vec![
|
||||||
|
AssistantEvent::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig".to_string()),
|
||||||
|
},
|
||||||
|
AssistantEvent::TextDelta("hello".to_string()),
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "echo".to_string(),
|
||||||
|
input: "payload".to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
];
|
||||||
|
|
||||||
|
// when
|
||||||
|
let (message, _, _) = build_assistant_message(events)
|
||||||
|
.expect("assistant message should preserve thinking, text, and tool blocks");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
message.blocks,
|
||||||
|
vec![
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig".to_string()),
|
||||||
|
},
|
||||||
|
ContentBlock::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
|
ContentBlock::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "echo".to_string(),
|
||||||
|
input: "payload".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn static_tool_executor_rejects_unknown_tools() {
|
fn static_tool_executor_rejects_unknown_tools() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -7,7 +8,7 @@ use std::time::Instant;
|
|||||||
use glob::Pattern;
|
use glob::Pattern;
|
||||||
use regex::RegexBuilder;
|
use regex::RegexBuilder;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use walkdir::WalkDir;
|
use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
/// Maximum file size that can be read (10 MB).
|
/// Maximum file size that can be read (10 MB).
|
||||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
@@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
|||||||
/// Maximum file size that can be written (10 MB).
|
/// Maximum file size that can be written (10 MB).
|
||||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
".build",
|
||||||
|
"target",
|
||||||
|
"dist",
|
||||||
|
"coverage",
|
||||||
|
];
|
||||||
|
|
||||||
/// Check whether a file appears to contain binary content by examining
|
/// Check whether a file appears to contain binary content by examining
|
||||||
/// the first chunk for NUL bytes.
|
/// the first chunk for NUL bytes.
|
||||||
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||||
@@ -297,11 +307,23 @@ pub fn edit_file(
|
|||||||
|
|
||||||
/// Expands a glob pattern and returns matching filenames.
|
/// Expands a glob pattern and returns matching filenames.
|
||||||
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
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 started = Instant::now();
|
||||||
let base_dir = path
|
let base_dir = path
|
||||||
.map(normalize_path)
|
.map(normalize_path)
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or(std::env::current_dir()?);
|
.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() {
|
let search_pattern = if Path::new(pattern).is_absolute() {
|
||||||
pattern.to_owned()
|
pattern.to_owned()
|
||||||
} else {
|
} else {
|
||||||
@@ -313,14 +335,32 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
|||||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
||||||
let expanded = expand_braces(&search_pattern);
|
let expanded = expand_braces(&search_pattern);
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
for pat in &expanded {
|
for pat in &expanded {
|
||||||
let entries = glob::glob(pat)
|
let compiled = Pattern::new(pat)
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
.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));
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.is_file() && seen.insert(entry.clone()) {
|
let candidate = entry.path();
|
||||||
matches.push(entry);
|
if entry.file_type().is_file()
|
||||||
|
&& 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,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.
|
/// Runs a regex search over workspace files with optional context lines.
|
||||||
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
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
|
let base_path = input
|
||||||
.path
|
.path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(normalize_path)
|
.map(normalize_path)
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or(std::env::current_dir()?);
|
.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)
|
let regex = RegexBuilder::new(&input.pattern)
|
||||||
.case_insensitive(input.case_insensitive.unwrap_or(false))
|
.case_insensitive(input.case_insensitive.unwrap_or(false))
|
||||||
@@ -380,6 +431,10 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
let mut total_matches = 0usize;
|
let mut total_matches = 0usize;
|
||||||
|
|
||||||
for file_path in collect_search_files(&base_path)? {
|
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) {
|
if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -429,27 +484,21 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
|
|
||||||
let (filenames, applied_limit, applied_offset) =
|
let (filenames, applied_limit, applied_offset) =
|
||||||
apply_limit(filenames, input.head_limit, input.offset);
|
apply_limit(filenames, input.head_limit, input.offset);
|
||||||
let content_output = if output_mode == "content" {
|
if output_mode == "content" {
|
||||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
return Ok(build_grep_content_output(
|
||||||
return Ok(GrepSearchOutput {
|
output_mode,
|
||||||
mode: Some(output_mode),
|
|
||||||
num_files: filenames.len(),
|
|
||||||
filenames,
|
filenames,
|
||||||
num_lines: Some(lines.len()),
|
content_lines,
|
||||||
content: Some(lines.join("\n")),
|
input.head_limit,
|
||||||
num_matches: None,
|
input.offset,
|
||||||
applied_limit: limit,
|
));
|
||||||
applied_offset: offset,
|
}
|
||||||
});
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(GrepSearchOutput {
|
Ok(GrepSearchOutput {
|
||||||
mode: Some(output_mode.clone()),
|
mode: Some(output_mode.clone()),
|
||||||
num_files: filenames.len(),
|
num_files: filenames.len(),
|
||||||
filenames,
|
filenames,
|
||||||
content: content_output,
|
content: None,
|
||||||
num_lines: None,
|
num_lines: None,
|
||||||
num_matches: (output_mode == "count").then_some(total_matches),
|
num_matches: (output_mode == "count").then_some(total_matches),
|
||||||
applied_limit,
|
applied_limit,
|
||||||
@@ -457,6 +506,65 @@ 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
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_glob_walk_root(pattern: &str) -> PathBuf {
|
||||||
|
let path = Path::new(pattern);
|
||||||
|
let mut prefix = PathBuf::new();
|
||||||
|
let mut saw_component = false;
|
||||||
|
|
||||||
|
for component in path.components() {
|
||||||
|
let text = component.as_os_str().to_string_lossy();
|
||||||
|
if component_contains_glob(&text) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prefix.push(component.as_os_str());
|
||||||
|
saw_component = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if saw_component {
|
||||||
|
prefix
|
||||||
|
} else {
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_contains_glob(component: &str) -> bool {
|
||||||
|
component.contains('*') || component.contains('?') || component.contains('[')
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||||
if base_path.is_file() {
|
if base_path.is_file() {
|
||||||
return Ok(vec![base_path.to_path_buf()]);
|
return Ok(vec![base_path.to_path_buf()]);
|
||||||
@@ -574,9 +682,7 @@ pub fn read_file_in_workspace(
|
|||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
) -> io::Result<ReadFileOutput> {
|
) -> io::Result<ReadFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
let canonical_root = workspace_root
|
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||||
read_file(path, offset, limit)
|
read_file(path, offset, limit)
|
||||||
}
|
}
|
||||||
@@ -589,9 +695,7 @@ pub fn write_file_in_workspace(
|
|||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
) -> io::Result<WriteFileOutput> {
|
) -> io::Result<WriteFileOutput> {
|
||||||
let absolute_path = normalize_path_allow_missing(path)?;
|
let absolute_path = normalize_path_allow_missing(path)?;
|
||||||
let canonical_root = workspace_root
|
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||||
write_file(path, content)
|
write_file(path, content)
|
||||||
}
|
}
|
||||||
@@ -606,13 +710,30 @@ pub fn edit_file_in_workspace(
|
|||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
) -> io::Result<EditFileOutput> {
|
) -> io::Result<EditFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
let canonical_root = workspace_root
|
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||||
edit_file(path, old_string, new_string, replace_all)
|
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.
|
/// Check whether a path is a symlink that resolves outside the workspace.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
|
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
|
||||||
@@ -651,11 +772,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
|
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
|
||||||
|
write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||||
@@ -755,6 +878,68 @@ mod tests {
|
|||||||
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
|
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]
|
#[test]
|
||||||
fn globs_and_greps_directory() {
|
fn globs_and_greps_directory() {
|
||||||
let dir = temp_path("search-dir");
|
let dir = temp_path("search-dir");
|
||||||
@@ -836,4 +1021,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_search_skips_common_heavy_directories() {
|
||||||
|
let dir = temp_path("glob-ignored-dirs");
|
||||||
|
std::fs::create_dir_all(dir.join("src")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("docs")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap();
|
||||||
|
std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap();
|
||||||
|
std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap();
|
||||||
|
std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap();
|
||||||
|
std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap();
|
||||||
|
|
||||||
|
let result =
|
||||||
|
glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.num_files, 2, "ignored dirs should be pruned");
|
||||||
|
assert!(result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.ends_with("src/AGENTS.md")));
|
||||||
|
assert!(result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.ends_with("docs/AGENTS.md")));
|
||||||
|
assert!(!result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.contains("node_modules")
|
||||||
|
|| path.contains(".build")
|
||||||
|
|| path.contains("/target/")));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_glob_walk_root_stops_at_first_glob_component() {
|
||||||
|
let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md");
|
||||||
|
assert_eq!(root, PathBuf::from("/tmp/demo"));
|
||||||
|
assert!(component_contains_glob("**"));
|
||||||
|
assert!(component_contains_glob("*.rs"));
|
||||||
|
assert!(!component_contains_glob("src"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
399
rust/crates/runtime/src/g004_conformance.rs
Normal file
399
rust/crates/runtime/src/g004_conformance.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -27,19 +27,38 @@ impl std::fmt::Display for GreenLevel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct GreenContract {
|
pub struct GreenContract {
|
||||||
pub required_level: GreenLevel,
|
pub required_level: GreenLevel,
|
||||||
|
pub requirements: Vec<GreenContractRequirement>,
|
||||||
|
pub block_known_flakes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GreenContract {
|
impl GreenContract {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(required_level: GreenLevel) -> Self {
|
pub fn new(required_level: GreenLevel) -> Self {
|
||||||
Self { required_level }
|
Self {
|
||||||
|
required_level,
|
||||||
|
requirements: Vec::new(),
|
||||||
|
block_known_flakes: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
|
pub fn merge_ready(required_level: GreenLevel) -> Self {
|
||||||
|
Self {
|
||||||
|
required_level,
|
||||||
|
requirements: vec![
|
||||||
|
GreenContractRequirement::TestCommandProvenance,
|
||||||
|
GreenContractRequirement::BaseBranchFreshness,
|
||||||
|
GreenContractRequirement::RecoveryAttemptContext,
|
||||||
|
],
|
||||||
|
block_known_flakes: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate(&self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
|
||||||
match observed_level {
|
match observed_level {
|
||||||
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
|
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
|
||||||
required_level: self.required_level,
|
required_level: self.required_level,
|
||||||
@@ -53,11 +72,170 @@ impl GreenContract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
|
pub fn evaluate_evidence(&self, evidence: &GreenEvidence) -> GreenEvidenceOutcome {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
let mut blocking_flakes = Vec::new();
|
||||||
|
|
||||||
|
if evidence.observed_level < self.required_level {
|
||||||
|
missing.push(GreenContractRequirement::RequiredLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for requirement in &self.requirements {
|
||||||
|
match requirement {
|
||||||
|
GreenContractRequirement::TestCommandProvenance
|
||||||
|
if !evidence.has_passing_test_command() =>
|
||||||
|
{
|
||||||
|
missing.push(*requirement);
|
||||||
|
}
|
||||||
|
GreenContractRequirement::BaseBranchFreshness if !evidence.base_branch_fresh => {
|
||||||
|
missing.push(*requirement);
|
||||||
|
}
|
||||||
|
GreenContractRequirement::RecoveryAttemptContext
|
||||||
|
if !evidence.recovery_attempt_context_recorded =>
|
||||||
|
{
|
||||||
|
missing.push(*requirement);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.block_known_flakes {
|
||||||
|
blocking_flakes = evidence
|
||||||
|
.known_flakes
|
||||||
|
.iter()
|
||||||
|
.filter(|flake| flake.blocks_green)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing.is_empty() && blocking_flakes.is_empty() {
|
||||||
|
GreenEvidenceOutcome::Satisfied {
|
||||||
|
required_level: self.required_level,
|
||||||
|
observed_level: evidence.observed_level,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GreenEvidenceOutcome::Unsatisfied {
|
||||||
|
required_level: self.required_level,
|
||||||
|
observed_level: evidence.observed_level,
|
||||||
|
missing,
|
||||||
|
blocking_flakes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_satisfied_by(&self, observed_level: GreenLevel) -> bool {
|
||||||
observed_level >= self.required_level
|
observed_level >= self.required_level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GreenEvidence {
|
||||||
|
pub observed_level: GreenLevel,
|
||||||
|
pub test_commands: Vec<TestCommandProvenance>,
|
||||||
|
pub base_branch_fresh: bool,
|
||||||
|
pub known_flakes: Vec<KnownFlake>,
|
||||||
|
pub recovery_attempt_context_recorded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GreenEvidence {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(observed_level: GreenLevel) -> Self {
|
||||||
|
Self {
|
||||||
|
observed_level,
|
||||||
|
test_commands: Vec::new(),
|
||||||
|
base_branch_fresh: false,
|
||||||
|
known_flakes: Vec::new(),
|
||||||
|
recovery_attempt_context_recorded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_test_command(mut self, command: impl Into<String>, exit_code: i32) -> Self {
|
||||||
|
self.test_commands.push(TestCommandProvenance {
|
||||||
|
command: command.into(),
|
||||||
|
exit_code,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_base_branch_fresh(mut self, is_fresh: bool) -> Self {
|
||||||
|
self.base_branch_fresh = is_fresh;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_known_flake(mut self, test_name: impl Into<String>, blocks_green: bool) -> Self {
|
||||||
|
self.known_flakes.push(KnownFlake {
|
||||||
|
test_name: test_name.into(),
|
||||||
|
blocks_green,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_recovery_attempt_context(mut self, recorded: bool) -> Self {
|
||||||
|
self.recovery_attempt_context_recorded = recorded;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_passing_test_command(&self) -> bool {
|
||||||
|
self.test_commands.iter().any(TestCommandProvenance::passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TestCommandProvenance {
|
||||||
|
pub command: String,
|
||||||
|
pub exit_code: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestCommandProvenance {
|
||||||
|
#[must_use]
|
||||||
|
pub fn passed(&self) -> bool {
|
||||||
|
self.exit_code == 0 && !self.command.trim().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KnownFlake {
|
||||||
|
pub test_name: String,
|
||||||
|
pub blocks_green: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum GreenContractRequirement {
|
||||||
|
RequiredLevel,
|
||||||
|
TestCommandProvenance,
|
||||||
|
BaseBranchFreshness,
|
||||||
|
RecoveryAttemptContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||||
|
pub enum GreenEvidenceOutcome {
|
||||||
|
Satisfied {
|
||||||
|
required_level: GreenLevel,
|
||||||
|
observed_level: GreenLevel,
|
||||||
|
},
|
||||||
|
Unsatisfied {
|
||||||
|
required_level: GreenLevel,
|
||||||
|
observed_level: GreenLevel,
|
||||||
|
missing: Vec<GreenContractRequirement>,
|
||||||
|
blocking_flakes: Vec<KnownFlake>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GreenEvidenceOutcome {
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_satisfied(&self) -> bool {
|
||||||
|
matches!(self, Self::Satisfied { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||||
pub enum GreenContractOutcome {
|
pub enum GreenContractOutcome {
|
||||||
@@ -149,4 +327,83 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn merge_ready_contract_requires_provenance_beyond_test_level() {
|
||||||
|
// given
|
||||||
|
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||||
|
let evidence = GreenEvidence::new(GreenLevel::Workspace)
|
||||||
|
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let outcome = contract.evaluate_evidence(&evidence);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
GreenEvidenceOutcome::Unsatisfied {
|
||||||
|
required_level: GreenLevel::Workspace,
|
||||||
|
observed_level: GreenLevel::Workspace,
|
||||||
|
missing: vec![
|
||||||
|
GreenContractRequirement::BaseBranchFreshness,
|
||||||
|
GreenContractRequirement::RecoveryAttemptContext,
|
||||||
|
],
|
||||||
|
blocking_flakes: vec![],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!outcome.is_satisfied());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_ready_contract_accepts_complete_test_provenance_context() {
|
||||||
|
// given
|
||||||
|
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||||
|
let evidence = GreenEvidence::new(GreenLevel::MergeReady)
|
||||||
|
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0)
|
||||||
|
.with_base_branch_fresh(true)
|
||||||
|
.with_recovery_attempt_context(true);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let outcome = contract.evaluate_evidence(&evidence);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
GreenEvidenceOutcome::Satisfied {
|
||||||
|
required_level: GreenLevel::Workspace,
|
||||||
|
observed_level: GreenLevel::MergeReady,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_blocking_flake_prevents_green_contract_satisfaction() {
|
||||||
|
// given
|
||||||
|
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||||
|
let evidence = GreenEvidence::new(GreenLevel::MergeReady)
|
||||||
|
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0)
|
||||||
|
.with_base_branch_fresh(true)
|
||||||
|
.with_recovery_attempt_context(true)
|
||||||
|
.with_known_flake(
|
||||||
|
"session_lifecycle_prefers_running_process_over_idle_shell",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let outcome = contract.evaluate_evidence(&evidence);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
GreenEvidenceOutcome::Unsatisfied {
|
||||||
|
required_level: GreenLevel::Workspace,
|
||||||
|
observed_level: GreenLevel::MergeReady,
|
||||||
|
missing: vec![],
|
||||||
|
blocking_flakes: vec![KnownFlake {
|
||||||
|
test_name: "session_lifecycle_prefers_running_process_over_idle_shell"
|
||||||
|
.to_string(),
|
||||||
|
blocks_green: true,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
//! MCP plumbing, tool-facing file operations, and the core conversation loop
|
//! MCP plumbing, tool-facing file operations, and the core conversation loop
|
||||||
//! that drives interactive and one-shot turns.
|
//! that drives interactive and one-shot turns.
|
||||||
|
|
||||||
|
mod approval_tokens;
|
||||||
mod bash;
|
mod bash;
|
||||||
pub mod bash_validation;
|
pub mod bash_validation;
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
@@ -13,6 +14,7 @@ mod config;
|
|||||||
pub mod config_validate;
|
pub mod config_validate;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
mod file_ops;
|
mod file_ops;
|
||||||
|
pub mod g004_conformance;
|
||||||
mod git_context;
|
mod git_context;
|
||||||
pub mod green_contract;
|
pub mod green_contract;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
@@ -33,6 +35,7 @@ mod policy_engine;
|
|||||||
mod prompt;
|
mod prompt;
|
||||||
pub mod recovery_recipes;
|
pub mod recovery_recipes;
|
||||||
mod remote;
|
mod remote;
|
||||||
|
mod report_schema;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
mod session;
|
mod session;
|
||||||
pub mod session_control;
|
pub mod session_control;
|
||||||
@@ -49,6 +52,10 @@ mod trust_resolver;
|
|||||||
mod usage;
|
mod usage;
|
||||||
pub mod worker_boot;
|
pub mod worker_boot;
|
||||||
|
|
||||||
|
pub use approval_tokens::{
|
||||||
|
ApprovalDelegationHop, ApprovalScope, ApprovalTokenAudit, ApprovalTokenError,
|
||||||
|
ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus,
|
||||||
|
};
|
||||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||||
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
|
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
|
||||||
@@ -74,9 +81,10 @@ pub use conversation::{
|
|||||||
ToolExecutor, TurnSummary,
|
ToolExecutor, TurnSummary,
|
||||||
};
|
};
|
||||||
pub use file_ops::{
|
pub use file_ops::{
|
||||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||||
WriteFileOutput,
|
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||||
|
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||||
};
|
};
|
||||||
pub use git_context::{GitCommitEntry, GitContext};
|
pub use git_context::{GitCommitEntry, GitContext};
|
||||||
pub use hooks::{
|
pub use hooks::{
|
||||||
@@ -131,18 +139,26 @@ pub use policy_engine::{
|
|||||||
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
||||||
};
|
};
|
||||||
pub use prompt::{
|
pub use prompt::{
|
||||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||||
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
};
|
};
|
||||||
pub use recovery_recipes::{
|
pub use recovery_recipes::{
|
||||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
|
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
||||||
RecoveryEvent, RecoveryRecipe, RecoveryResult, RecoveryStep,
|
RecoveryAttemptType, RecoveryCommandResult, RecoveryContext, RecoveryEvent,
|
||||||
|
RecoveryLedgerEntry, RecoveryRecipe, RecoveryResult, RecoveryStatusReport, RecoveryStep,
|
||||||
};
|
};
|
||||||
pub use remote::{
|
pub use remote::{
|
||||||
inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
|
inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
|
||||||
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
||||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
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::{
|
pub use sandbox::{
|
||||||
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
|
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
|
||||||
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
|
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
pub type GreenLevel = u8;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PolicyRule {
|
pub struct PolicyRule {
|
||||||
@@ -58,7 +58,9 @@ impl PolicyCondition {
|
|||||||
Self::Or(conditions) => conditions
|
Self::Or(conditions) => conditions
|
||||||
.iter()
|
.iter()
|
||||||
.any(|condition| condition.matches(context)),
|
.any(|condition| condition.matches(context)),
|
||||||
Self::GreenAt { level } => context.green_level >= *level,
|
Self::GreenAt { level } => {
|
||||||
|
context.green_contract_satisfied && context.green_level >= *level
|
||||||
|
}
|
||||||
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
||||||
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
||||||
Self::LaneCompleted => context.completed,
|
Self::LaneCompleted => context.completed,
|
||||||
@@ -134,6 +136,7 @@ pub enum DiffScope {
|
|||||||
pub struct LaneContext {
|
pub struct LaneContext {
|
||||||
pub lane_id: String,
|
pub lane_id: String,
|
||||||
pub green_level: GreenLevel,
|
pub green_level: GreenLevel,
|
||||||
|
pub green_contract_satisfied: bool,
|
||||||
pub branch_freshness: Duration,
|
pub branch_freshness: Duration,
|
||||||
pub blocker: LaneBlocker,
|
pub blocker: LaneBlocker,
|
||||||
pub review_status: ReviewStatus,
|
pub review_status: ReviewStatus,
|
||||||
@@ -156,6 +159,7 @@ impl LaneContext {
|
|||||||
Self {
|
Self {
|
||||||
lane_id: lane_id.into(),
|
lane_id: lane_id.into(),
|
||||||
green_level,
|
green_level,
|
||||||
|
green_contract_satisfied: false,
|
||||||
branch_freshness,
|
branch_freshness,
|
||||||
blocker,
|
blocker,
|
||||||
review_status,
|
review_status,
|
||||||
@@ -171,6 +175,7 @@ impl LaneContext {
|
|||||||
Self {
|
Self {
|
||||||
lane_id: lane_id.into(),
|
lane_id: lane_id.into(),
|
||||||
green_level: 0,
|
green_level: 0,
|
||||||
|
green_contract_satisfied: false,
|
||||||
branch_freshness: Duration::from_secs(0),
|
branch_freshness: Duration::from_secs(0),
|
||||||
blocker: LaneBlocker::None,
|
blocker: LaneBlocker::None,
|
||||||
review_status: ReviewStatus::Pending,
|
review_status: ReviewStatus::Pending,
|
||||||
@@ -179,6 +184,12 @@ impl LaneContext {
|
|||||||
reconciled: true,
|
reconciled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_green_contract_satisfied(mut self, satisfied: bool) -> Self {
|
||||||
|
self.green_contract_satisfied = satisfied;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -257,7 +268,8 @@ mod tests {
|
|||||||
ReviewStatus::Approved,
|
ReviewStatus::Approved,
|
||||||
DiffScope::Scoped,
|
DiffScope::Scoped,
|
||||||
false,
|
false,
|
||||||
);
|
)
|
||||||
|
.with_green_contract_satisfied(true);
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let actions = engine.evaluate(&context);
|
let actions = engine.evaluate(&context);
|
||||||
@@ -266,6 +278,36 @@ mod tests {
|
|||||||
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
|
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_rule_blocks_when_green_tests_lack_contract_provenance() {
|
||||||
|
// given
|
||||||
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
|
"merge-to-dev",
|
||||||
|
PolicyCondition::And(vec![
|
||||||
|
PolicyCondition::GreenAt { level: 2 },
|
||||||
|
PolicyCondition::ScopedDiff,
|
||||||
|
PolicyCondition::ReviewPassed,
|
||||||
|
]),
|
||||||
|
PolicyAction::MergeToDev,
|
||||||
|
20,
|
||||||
|
)]);
|
||||||
|
let context = LaneContext::new(
|
||||||
|
"lane-7",
|
||||||
|
3,
|
||||||
|
Duration::from_secs(5),
|
||||||
|
LaneBlocker::None,
|
||||||
|
ReviewStatus::Approved,
|
||||||
|
DiffScope::Scoped,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let actions = engine.evaluate(&context);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_branch_rule_fires_at_threshold() {
|
fn stale_branch_rule_fires_at_threshold() {
|
||||||
// given
|
// given
|
||||||
@@ -468,7 +510,8 @@ mod tests {
|
|||||||
ReviewStatus::Pending,
|
ReviewStatus::Pending,
|
||||||
DiffScope::Full,
|
DiffScope::Full,
|
||||||
false,
|
false,
|
||||||
);
|
)
|
||||||
|
.with_green_contract_satisfied(true);
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let actions = engine.evaluate(&context);
|
let actions = engine.evaluate(&context);
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
|||||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||||
|
|
||||||
|
/// Neutral identity for the model family line in generated prompts.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum ModelFamilyIdentity {
|
||||||
|
#[default]
|
||||||
|
Claude,
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelFamilyIdentity {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn family_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Claude => FRONTIER_MODEL_NAME,
|
||||||
|
Self::Generic => "an AI assistant",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Contents of an instruction file included in prompt construction.
|
/// Contents of an instruction file included in prompt construction.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ContextFile {
|
pub struct ContextFile {
|
||||||
@@ -97,6 +115,7 @@ pub struct SystemPromptBuilder {
|
|||||||
output_style_prompt: Option<String>,
|
output_style_prompt: Option<String>,
|
||||||
os_name: Option<String>,
|
os_name: Option<String>,
|
||||||
os_version: Option<String>,
|
os_version: Option<String>,
|
||||||
|
model_family: Option<ModelFamilyIdentity>,
|
||||||
append_sections: Vec<String>,
|
append_sections: Vec<String>,
|
||||||
project_context: Option<ProjectContext>,
|
project_context: Option<ProjectContext>,
|
||||||
config: Option<RuntimeConfig>,
|
config: Option<RuntimeConfig>,
|
||||||
@@ -122,6 +141,12 @@ impl SystemPromptBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_model_family(mut self, model_family: ModelFamilyIdentity) -> Self {
|
||||||
|
self.model_family = Some(model_family);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
||||||
self.project_context = Some(project_context);
|
self.project_context = Some(project_context);
|
||||||
@@ -179,9 +204,10 @@ impl SystemPromptBuilder {
|
|||||||
|| "unknown".to_string(),
|
|| "unknown".to_string(),
|
||||||
|context| context.current_date.clone(),
|
|context| context.current_date.clone(),
|
||||||
);
|
);
|
||||||
|
let identity = self.model_family.unwrap_or_default();
|
||||||
let mut lines = vec!["# Environment context".to_string()];
|
let mut lines = vec!["# Environment context".to_string()];
|
||||||
lines.extend(prepend_bullets(vec![
|
lines.extend(prepend_bullets(vec![
|
||||||
format!("Model family: {FRONTIER_MODEL_NAME}"),
|
format!("Model family: {}", identity.family_label()),
|
||||||
format!("Working directory: {cwd}"),
|
format!("Working directory: {cwd}"),
|
||||||
format!("Date: {date}"),
|
format!("Date: {date}"),
|
||||||
format!(
|
format!(
|
||||||
@@ -434,12 +460,14 @@ pub fn load_system_prompt(
|
|||||||
current_date: impl Into<String>,
|
current_date: impl Into<String>,
|
||||||
os_name: impl Into<String>,
|
os_name: impl Into<String>,
|
||||||
os_version: impl Into<String>,
|
os_version: impl Into<String>,
|
||||||
|
model_family: ModelFamilyIdentity,
|
||||||
) -> Result<Vec<String>, PromptBuildError> {
|
) -> Result<Vec<String>, PromptBuildError> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||||
Ok(SystemPromptBuilder::new()
|
Ok(SystemPromptBuilder::new()
|
||||||
.with_os(os_name, os_version)
|
.with_os(os_name, os_version)
|
||||||
|
.with_model_family(model_family)
|
||||||
.with_project_context(project_context)
|
.with_project_context(project_context)
|
||||||
.with_runtime_config(config)
|
.with_runtime_config(config)
|
||||||
.build())
|
.build())
|
||||||
@@ -522,7 +550,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||||
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
||||||
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder,
|
||||||
|
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
};
|
};
|
||||||
use crate::config::ConfigLoader;
|
use crate::config::ConfigLoader;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -804,13 +833,19 @@ mod tests {
|
|||||||
std::env::set_var("HOME", &root);
|
std::env::set_var("HOME", &root);
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||||
std::env::set_current_dir(&root).expect("change cwd");
|
std::env::set_current_dir(&root).expect("change cwd");
|
||||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
let prompt = super::load_system_prompt(
|
||||||
.expect("system prompt should load")
|
&root,
|
||||||
.join(
|
"2026-03-31",
|
||||||
"
|
"linux",
|
||||||
|
"6.8",
|
||||||
|
ModelFamilyIdentity::Claude,
|
||||||
|
)
|
||||||
|
.expect("system prompt should load")
|
||||||
|
.join(
|
||||||
|
"
|
||||||
|
|
||||||
",
|
",
|
||||||
);
|
);
|
||||||
std::env::set_current_dir(previous).expect("restore cwd");
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
if let Some(value) = original_home {
|
if let Some(value) = original_home {
|
||||||
std::env::set_var("HOME", value);
|
std::env::set_var("HOME", value);
|
||||||
@@ -828,6 +863,50 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_default_claude_model_family_identity() {
|
||||||
|
// given: a prompt builder without an explicit model family override
|
||||||
|
let project_context = ProjectContext {
|
||||||
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
|
current_date: "2026-03-31".to_string(),
|
||||||
|
..ProjectContext::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// when: rendering the system prompt environment section
|
||||||
|
let prompt = SystemPromptBuilder::new()
|
||||||
|
.with_os("linux", "6.8")
|
||||||
|
.with_project_context(project_context)
|
||||||
|
.render();
|
||||||
|
|
||||||
|
// then: the Claude model family label is preserved by default
|
||||||
|
assert!(prompt.contains("Model family: Claude Opus 4.6"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_generic_model_family_identity_without_claude_label() {
|
||||||
|
// given: a prompt builder with generic model family identity
|
||||||
|
let project_context = ProjectContext {
|
||||||
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
|
current_date: "2026-03-31".to_string(),
|
||||||
|
..ProjectContext::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// when: rendering the system prompt environment section
|
||||||
|
let prompt = SystemPromptBuilder::new()
|
||||||
|
.with_os("linux", "6.8")
|
||||||
|
.with_model_family(ModelFamilyIdentity::Generic)
|
||||||
|
.with_project_context(project_context)
|
||||||
|
.render();
|
||||||
|
let model_family_line = prompt
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.contains("Model family:"))
|
||||||
|
.expect("model family line should render");
|
||||||
|
|
||||||
|
// then: the model family line is neutral and excludes Claude Opus 4.6
|
||||||
|
assert_eq!(model_family_line, " - Model family: an AI assistant");
|
||||||
|
assert!(!model_family_line.contains("Claude Opus 4.6"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_claude_code_style_sections_with_project_context() {
|
fn renders_claude_code_style_sections_with_project_context() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ impl FailureScenario {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
||||||
match kind {
|
match kind {
|
||||||
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
|
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => {
|
||||||
|
Self::TrustPromptUnresolved
|
||||||
|
}
|
||||||
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
||||||
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
||||||
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
||||||
@@ -119,6 +121,21 @@ pub enum RecoveryResult {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Type of recovery execution represented in the ledger.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RecoveryAttemptType {
|
||||||
|
Automatic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result for one executable recovery command/step.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RecoveryCommandResult {
|
||||||
|
pub command: RecoveryStep,
|
||||||
|
pub status: RecoveryAttemptState,
|
||||||
|
pub result: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Structured event emitted during recovery.
|
/// Structured event emitted during recovery.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -133,14 +150,59 @@ pub enum RecoveryEvent {
|
|||||||
Escalated,
|
Escalated,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Machine-readable recovery progress for one failure scenario.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RecoveryLedgerEntry {
|
||||||
|
pub recipe_id: String,
|
||||||
|
pub attempt_type: RecoveryAttemptType,
|
||||||
|
pub trigger: FailureScenario,
|
||||||
|
pub attempt_count: u32,
|
||||||
|
pub retry_limit: u32,
|
||||||
|
pub attempts_remaining: u32,
|
||||||
|
pub state: RecoveryAttemptState,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
pub command_results: Vec<RecoveryCommandResult>,
|
||||||
|
pub result: Option<RecoveryResult>,
|
||||||
|
pub last_failure_summary: Option<String>,
|
||||||
|
pub escalation_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state of a recovery recipe attempt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RecoveryAttemptState {
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Exhausted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable status projection for callers that need to
|
||||||
|
/// distinguish an untouched scenario from an exhausted recovery.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RecoveryStatusReport {
|
||||||
|
pub scenario: FailureScenario,
|
||||||
|
pub attempted: bool,
|
||||||
|
pub state: Option<RecoveryAttemptState>,
|
||||||
|
pub attempt_count: u32,
|
||||||
|
pub retry_limit: Option<u32>,
|
||||||
|
pub attempts_remaining: Option<u32>,
|
||||||
|
pub escalation_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal context for tracking recovery state and emitting events.
|
/// Minimal context for tracking recovery state and emitting events.
|
||||||
///
|
///
|
||||||
/// Holds per-scenario attempt counts, a structured event log, and an
|
/// Holds per-scenario attempt counts, a structured event log, a recovery
|
||||||
/// optional simulation knob for controlling step outcomes during tests.
|
/// attempt ledger, and an optional simulation knob for controlling step
|
||||||
|
/// outcomes during tests.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct RecoveryContext {
|
pub struct RecoveryContext {
|
||||||
attempts: HashMap<FailureScenario, u32>,
|
attempts: HashMap<FailureScenario, u32>,
|
||||||
events: Vec<RecoveryEvent>,
|
events: Vec<RecoveryEvent>,
|
||||||
|
ledger: HashMap<FailureScenario, RecoveryLedgerEntry>,
|
||||||
|
clock_tick: u64,
|
||||||
/// Optional step index at which simulated execution fails.
|
/// Optional step index at which simulated execution fails.
|
||||||
/// `None` means all steps succeed.
|
/// `None` means all steps succeed.
|
||||||
fail_at_step: Option<usize>,
|
fail_at_step: Option<usize>,
|
||||||
@@ -170,6 +232,51 @@ impl RecoveryContext {
|
|||||||
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
|
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
|
||||||
self.attempts.get(scenario).copied().unwrap_or(0)
|
self.attempts.get(scenario).copied().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the machine-readable recovery ledger entry for a scenario.
|
||||||
|
#[must_use]
|
||||||
|
pub fn ledger_entry(&self, scenario: &FailureScenario) -> Option<&RecoveryLedgerEntry> {
|
||||||
|
self.ledger.get(scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all recovery ledger entries currently tracked by this context.
|
||||||
|
#[must_use]
|
||||||
|
pub fn ledger_entries(&self) -> Vec<&RecoveryLedgerEntry> {
|
||||||
|
let mut entries: Vec<_> = self.ledger.values().collect();
|
||||||
|
entries.sort_by(|left, right| left.recipe_id.cmp(&right.recipe_id));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a compact machine-readable recovery status for a scenario,
|
||||||
|
/// including `attempted = false` when no ledger entry exists yet.
|
||||||
|
#[must_use]
|
||||||
|
pub fn status_report(&self, scenario: &FailureScenario) -> RecoveryStatusReport {
|
||||||
|
self.ledger_entry(scenario).map_or(
|
||||||
|
RecoveryStatusReport {
|
||||||
|
scenario: *scenario,
|
||||||
|
attempted: false,
|
||||||
|
state: None,
|
||||||
|
attempt_count: 0,
|
||||||
|
retry_limit: None,
|
||||||
|
attempts_remaining: None,
|
||||||
|
escalation_reason: None,
|
||||||
|
},
|
||||||
|
|entry| RecoveryStatusReport {
|
||||||
|
scenario: *scenario,
|
||||||
|
attempted: entry.attempt_count > 0,
|
||||||
|
state: Some(entry.state),
|
||||||
|
attempt_count: entry.attempt_count,
|
||||||
|
retry_limit: Some(entry.retry_limit),
|
||||||
|
attempts_remaining: Some(entry.attempts_remaining),
|
||||||
|
escalation_reason: entry.escalation_reason.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_timestamp(&mut self) -> String {
|
||||||
|
self.clock_tick += 1;
|
||||||
|
format!("recovery-ledger-tick-{}", self.clock_tick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the known recovery recipe for the given failure scenario.
|
/// Returns the known recovery recipe for the given failure scenario.
|
||||||
@@ -231,18 +338,51 @@ pub fn recipe_for(scenario: &FailureScenario) -> RecoveryRecipe {
|
|||||||
/// Looks up the recipe, enforces the one-attempt-before-escalation
|
/// Looks up the recipe, enforces the one-attempt-before-escalation
|
||||||
/// policy, simulates step execution (controlled by the context), and
|
/// policy, simulates step execution (controlled by the context), and
|
||||||
/// emits structured [`RecoveryEvent`]s for every attempt.
|
/// emits structured [`RecoveryEvent`]s for every attempt.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
|
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
|
||||||
let recipe = recipe_for(scenario);
|
let recipe = recipe_for(scenario);
|
||||||
let attempt_count = ctx.attempts.entry(*scenario).or_insert(0);
|
let recipe_id = scenario.to_string();
|
||||||
|
ctx.ledger
|
||||||
|
.entry(*scenario)
|
||||||
|
.or_insert_with(|| RecoveryLedgerEntry {
|
||||||
|
recipe_id: recipe_id.clone(),
|
||||||
|
attempt_type: RecoveryAttemptType::Automatic,
|
||||||
|
trigger: *scenario,
|
||||||
|
attempt_count: 0,
|
||||||
|
retry_limit: recipe.max_attempts,
|
||||||
|
attempts_remaining: recipe.max_attempts,
|
||||||
|
state: RecoveryAttemptState::Queued,
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
command_results: Vec::new(),
|
||||||
|
result: None,
|
||||||
|
last_failure_summary: None,
|
||||||
|
escalation_reason: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let current_attempts = ctx.attempt_count(scenario);
|
||||||
|
|
||||||
// Enforce one automatic recovery attempt before escalation.
|
// Enforce one automatic recovery attempt before escalation.
|
||||||
if *attempt_count >= recipe.max_attempts {
|
if current_attempts >= recipe.max_attempts {
|
||||||
let result = RecoveryResult::EscalationRequired {
|
let result = RecoveryResult::EscalationRequired {
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"max recovery attempts ({}) exceeded for {}",
|
"max recovery attempts ({}) exceeded for {}",
|
||||||
recipe.max_attempts, scenario
|
recipe.max_attempts, scenario
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
let finished_at = ctx.next_timestamp();
|
||||||
|
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||||
|
entry.attempt_count = current_attempts;
|
||||||
|
entry.attempts_remaining = 0;
|
||||||
|
entry.state = RecoveryAttemptState::Exhausted;
|
||||||
|
entry.finished_at = Some(finished_at);
|
||||||
|
entry.result = Some(result.clone());
|
||||||
|
let RecoveryResult::EscalationRequired { reason } = &result else {
|
||||||
|
unreachable!("exhaustion always produces escalation");
|
||||||
|
};
|
||||||
|
entry.last_failure_summary = Some(reason.clone());
|
||||||
|
entry.escalation_reason = Some(reason.clone());
|
||||||
|
}
|
||||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
||||||
scenario: *scenario,
|
scenario: *scenario,
|
||||||
recipe,
|
recipe,
|
||||||
@@ -252,19 +392,44 @@ pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
*attempt_count += 1;
|
let updated_attempts = ctx.attempts.entry(*scenario).or_insert(0);
|
||||||
|
*updated_attempts += 1;
|
||||||
|
let updated_attempts = *updated_attempts;
|
||||||
|
let started_at = ctx.next_timestamp();
|
||||||
|
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||||
|
entry.attempt_count = updated_attempts;
|
||||||
|
entry.attempts_remaining = recipe.max_attempts.saturating_sub(updated_attempts);
|
||||||
|
entry.state = RecoveryAttemptState::Running;
|
||||||
|
entry.started_at = Some(started_at);
|
||||||
|
entry.finished_at = None;
|
||||||
|
entry.command_results.clear();
|
||||||
|
entry.result = None;
|
||||||
|
entry.last_failure_summary = None;
|
||||||
|
entry.escalation_reason = None;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute steps, honoring the optional fail_at_step simulation.
|
// Execute steps, honoring the optional fail_at_step simulation.
|
||||||
let fail_index = ctx.fail_at_step;
|
let fail_index = ctx.fail_at_step;
|
||||||
let mut executed = Vec::new();
|
let mut executed = Vec::new();
|
||||||
|
let mut command_results = Vec::new();
|
||||||
let mut failed = false;
|
let mut failed = false;
|
||||||
|
|
||||||
for (i, step) in recipe.steps.iter().enumerate() {
|
for (i, step) in recipe.steps.iter().enumerate() {
|
||||||
if fail_index == Some(i) {
|
if fail_index == Some(i) {
|
||||||
|
command_results.push(RecoveryCommandResult {
|
||||||
|
command: step.clone(),
|
||||||
|
status: RecoveryAttemptState::Failed,
|
||||||
|
result: format!("step {i} failed for {scenario}"),
|
||||||
|
});
|
||||||
failed = true;
|
failed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
executed.push(step.clone());
|
executed.push(step.clone());
|
||||||
|
command_results.push(RecoveryCommandResult {
|
||||||
|
command: step.clone(),
|
||||||
|
status: RecoveryAttemptState::Succeeded,
|
||||||
|
result: format!("step {i} succeeded for {scenario}"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = if failed {
|
let result = if failed {
|
||||||
@@ -286,6 +451,29 @@ pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Emit the attempt as structured event data.
|
// Emit the attempt as structured event data.
|
||||||
|
let finished_at = ctx.next_timestamp();
|
||||||
|
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||||
|
entry.finished_at = Some(finished_at);
|
||||||
|
entry.command_results = command_results;
|
||||||
|
entry.result = Some(result.clone());
|
||||||
|
match &result {
|
||||||
|
RecoveryResult::Recovered { .. } => {
|
||||||
|
entry.state = RecoveryAttemptState::Succeeded;
|
||||||
|
}
|
||||||
|
RecoveryResult::PartialRecovery { remaining, .. } => {
|
||||||
|
entry.state = RecoveryAttemptState::Failed;
|
||||||
|
entry.last_failure_summary = Some(format!(
|
||||||
|
"{} step(s) remaining after partial recovery",
|
||||||
|
remaining.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
RecoveryResult::EscalationRequired { reason } => {
|
||||||
|
entry.state = RecoveryAttemptState::Exhausted;
|
||||||
|
entry.last_failure_summary = Some(reason.clone());
|
||||||
|
entry.escalation_reason = Some(reason.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
||||||
scenario: *scenario,
|
scenario: *scenario,
|
||||||
recipe,
|
recipe,
|
||||||
@@ -497,6 +685,126 @@ mod tests {
|
|||||||
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
|
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_context_exposes_machine_readable_ledger() {
|
||||||
|
// given
|
||||||
|
let mut ctx = RecoveryContext::new();
|
||||||
|
|
||||||
|
// when
|
||||||
|
let result = attempt_recovery(&FailureScenario::StaleBranch, &mut ctx);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 2 });
|
||||||
|
let entry = ctx
|
||||||
|
.ledger_entry(&FailureScenario::StaleBranch)
|
||||||
|
.expect("stale branch ledger entry");
|
||||||
|
assert_eq!(entry.recipe_id, "stale_branch");
|
||||||
|
assert_eq!(entry.attempt_type, RecoveryAttemptType::Automatic);
|
||||||
|
assert_eq!(entry.trigger, FailureScenario::StaleBranch);
|
||||||
|
assert_eq!(entry.attempt_count, 1);
|
||||||
|
assert_eq!(entry.retry_limit, 1);
|
||||||
|
assert_eq!(entry.attempts_remaining, 0);
|
||||||
|
assert_eq!(entry.state, RecoveryAttemptState::Succeeded);
|
||||||
|
assert!(entry.started_at.is_some());
|
||||||
|
assert!(entry.finished_at.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
entry.result,
|
||||||
|
Some(RecoveryResult::Recovered { steps_taken: 2 })
|
||||||
|
);
|
||||||
|
assert_eq!(entry.command_results.len(), 2);
|
||||||
|
assert_eq!(entry.command_results[0].command, RecoveryStep::RebaseBranch);
|
||||||
|
assert_eq!(
|
||||||
|
entry.command_results[0].status,
|
||||||
|
RecoveryAttemptState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(entry.last_failure_summary, None);
|
||||||
|
assert_eq!(entry.escalation_reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_ledger_records_exhausted_escalation_reason() {
|
||||||
|
// given
|
||||||
|
let mut ctx = RecoveryContext::new();
|
||||||
|
let scenario = FailureScenario::PromptMisdelivery;
|
||||||
|
|
||||||
|
// when
|
||||||
|
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||||
|
let result = attempt_recovery(&scenario, &mut ctx);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(matches!(result, RecoveryResult::EscalationRequired { .. }));
|
||||||
|
let entry = ctx.ledger_entry(&scenario).expect("ledger entry");
|
||||||
|
assert_eq!(entry.state, RecoveryAttemptState::Exhausted);
|
||||||
|
assert_eq!(entry.attempt_count, 1);
|
||||||
|
assert_eq!(entry.attempts_remaining, 0);
|
||||||
|
assert!(matches!(
|
||||||
|
entry.result,
|
||||||
|
Some(RecoveryResult::EscalationRequired { .. })
|
||||||
|
));
|
||||||
|
assert!(entry
|
||||||
|
.escalation_reason
|
||||||
|
.as_deref()
|
||||||
|
.expect("escalation reason")
|
||||||
|
.contains("max recovery attempts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_status_report_distinguishes_not_attempted_from_exhausted() {
|
||||||
|
// given
|
||||||
|
let mut ctx = RecoveryContext::new();
|
||||||
|
let scenario = FailureScenario::PromptMisdelivery;
|
||||||
|
|
||||||
|
// then — no ledger entry is not the same as exhausted.
|
||||||
|
let not_attempted = ctx.status_report(&scenario);
|
||||||
|
assert!(!not_attempted.attempted);
|
||||||
|
assert_eq!(not_attempted.state, None);
|
||||||
|
assert_eq!(not_attempted.attempt_count, 0);
|
||||||
|
assert_eq!(not_attempted.retry_limit, None);
|
||||||
|
|
||||||
|
// when — one allowed attempt then one extra attempt.
|
||||||
|
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||||
|
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||||
|
|
||||||
|
// then
|
||||||
|
let exhausted = ctx.status_report(&scenario);
|
||||||
|
assert!(exhausted.attempted);
|
||||||
|
assert_eq!(exhausted.state, Some(RecoveryAttemptState::Exhausted));
|
||||||
|
assert_eq!(exhausted.attempt_count, 1);
|
||||||
|
assert_eq!(exhausted.retry_limit, Some(1));
|
||||||
|
assert_eq!(exhausted.attempts_remaining, Some(0));
|
||||||
|
assert!(exhausted
|
||||||
|
.escalation_reason
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|reason| reason.contains("max recovery attempts")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_ledger_records_failed_command_result() {
|
||||||
|
// given
|
||||||
|
let mut ctx = RecoveryContext::new().with_fail_at_step(1);
|
||||||
|
let scenario = FailureScenario::PartialPluginStartup;
|
||||||
|
|
||||||
|
// when
|
||||||
|
let result = attempt_recovery(&scenario, &mut ctx);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(matches!(result, RecoveryResult::PartialRecovery { .. }));
|
||||||
|
let entry = ctx.ledger_entry(&scenario).expect("ledger entry");
|
||||||
|
assert_eq!(entry.state, RecoveryAttemptState::Failed);
|
||||||
|
assert_eq!(entry.command_results.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
entry.command_results[0].status,
|
||||||
|
RecoveryAttemptState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entry.command_results[1].status,
|
||||||
|
RecoveryAttemptState::Failed
|
||||||
|
);
|
||||||
|
assert!(entry.command_results[1]
|
||||||
|
.result
|
||||||
|
.contains("partial_plugin_startup"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_branch_recipe_has_rebase_then_clean_build() {
|
fn stale_branch_recipe_has_rebase_then_clean_build() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
552
rust/crates/runtime/src/report_schema.rs
Normal file
552
rust/crates/runtime/src/report_schema.rs
Normal file
@@ -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())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.is_ok_and(|status| status.success())
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ pub enum ContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -737,6 +741,22 @@ impl ContentBlock {
|
|||||||
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
||||||
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
||||||
}
|
}
|
||||||
|
Self::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
object.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
JsonValue::String("thinking".to_string()),
|
||||||
|
);
|
||||||
|
object.insert("thinking".to_string(), JsonValue::String(thinking.clone()));
|
||||||
|
if let Some(signature) = signature {
|
||||||
|
object.insert(
|
||||||
|
"signature".to_string(),
|
||||||
|
JsonValue::String(signature.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Self::ToolUse { id, name, input } => {
|
Self::ToolUse { id, name, input } => {
|
||||||
object.insert(
|
object.insert(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
@@ -783,6 +803,13 @@ impl ContentBlock {
|
|||||||
"text" => Ok(Self::Text {
|
"text" => Ok(Self::Text {
|
||||||
text: required_string(object, "text")?,
|
text: required_string(object, "text")?,
|
||||||
}),
|
}),
|
||||||
|
"thinking" => Ok(Self::Thinking {
|
||||||
|
thinking: required_string(object, "thinking")?,
|
||||||
|
signature: object
|
||||||
|
.get("signature")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(String::from),
|
||||||
|
}),
|
||||||
"tool_use" => Ok(Self::ToolUse {
|
"tool_use" => Ok(Self::ToolUse {
|
||||||
id: required_string(object, "id")?,
|
id: required_string(object, "id")?,
|
||||||
name: required_string(object, "name")?,
|
name: required_string(object, "name")?,
|
||||||
@@ -1208,6 +1235,36 @@ mod tests {
|
|||||||
assert_eq!(restored.session_id, session.session_id);
|
assert_eq!(restored.session_id, session.session_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persists_assistant_thinking_block_round_trip_through_jsonl() {
|
||||||
|
// given
|
||||||
|
let mut session = Session::new();
|
||||||
|
session
|
||||||
|
.push_message(ConversationMessage::assistant(vec![
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "trace the path through session persistence".to_string(),
|
||||||
|
signature: Some("sig-123".to_string()),
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
.expect("thinking block should append");
|
||||||
|
let path = temp_session_path("thinking-jsonl");
|
||||||
|
|
||||||
|
// when
|
||||||
|
session.save_to_path(&path).expect("session should save");
|
||||||
|
let restored = Session::load_from_path(&path).expect("session should load");
|
||||||
|
fs::remove_file(&path).expect("temp file should be removable");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(restored, session);
|
||||||
|
assert_eq!(
|
||||||
|
restored.messages[0].blocks[0],
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "trace the path through session persistence".to_string(),
|
||||||
|
signature: Some("sig-123".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_legacy_session_json_object() {
|
fn loads_legacy_session_json_object() {
|
||||||
let path = temp_session_path("legacy");
|
let path = temp_session_path("legacy");
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ impl SessionStore {
|
|||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
// #151: canonicalize workspace_root for consistent fingerprinting
|
// #151: canonicalize workspace_root for consistent fingerprinting
|
||||||
// across equivalent path representations.
|
// across equivalent path representations.
|
||||||
let canonical_workspace = fs::canonicalize(workspace_root)
|
let canonical_workspace =
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||||
let sessions_root = data_dir
|
let sessions_root = data_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.join("sessions")
|
.join("sessions")
|
||||||
@@ -158,10 +158,9 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
self.list_sessions()?
|
self.list_sessions()?.into_iter().next().ok_or_else(|| {
|
||||||
.into_iter()
|
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
|
||||||
.next()
|
})
|
||||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_session(
|
pub fn load_session(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const TRUST_PROMPT_CUES: &[&str] = &[
|
const TRUST_PROMPT_CUES: &[&str] = &[
|
||||||
"do you trust the files in this folder",
|
"do you trust the files in this folder",
|
||||||
"trust the files in this folder",
|
"trust the files in this folder",
|
||||||
@@ -8,24 +10,121 @@ const TRUST_PROMPT_CUES: &[&str] = &[
|
|||||||
"yes, proceed",
|
"yes, proceed",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Resolution method for trust decisions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TrustPolicy {
|
pub enum TrustPolicy {
|
||||||
|
/// Automatically trust this path (allowlisted)
|
||||||
AutoTrust,
|
AutoTrust,
|
||||||
|
/// Require manual approval
|
||||||
RequireApproval,
|
RequireApproval,
|
||||||
|
/// Deny trust for this path
|
||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// Events emitted during trust resolution lifecycle.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum TrustEvent {
|
pub enum TrustEvent {
|
||||||
TrustRequired { cwd: String },
|
/// Trust prompt was detected and is required
|
||||||
TrustResolved { cwd: String, policy: TrustPolicy },
|
TrustRequired {
|
||||||
TrustDenied { cwd: String, reason: String },
|
/// Current working directory where trust is needed
|
||||||
|
cwd: String,
|
||||||
|
/// Optional repo identifier
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// Optional worktree path
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
worktree: Option<String>,
|
||||||
|
},
|
||||||
|
/// Trust was resolved (granted)
|
||||||
|
TrustResolved {
|
||||||
|
/// Current working directory
|
||||||
|
cwd: String,
|
||||||
|
/// The policy that was applied
|
||||||
|
policy: TrustPolicy,
|
||||||
|
/// How the trust was resolved
|
||||||
|
resolution: TrustResolution,
|
||||||
|
},
|
||||||
|
/// Trust was denied
|
||||||
|
TrustDenied {
|
||||||
|
/// Current working directory
|
||||||
|
cwd: String,
|
||||||
|
/// Reason for denial
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
/// How trust was resolved.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TrustResolution {
|
||||||
|
/// Automatically granted due to allowlist
|
||||||
|
AutoAllowlisted,
|
||||||
|
/// Manually approved by user
|
||||||
|
ManualApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry in the trust allowlist with pattern matching support.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TrustAllowlistEntry {
|
||||||
|
/// Repository path or glob pattern to match
|
||||||
|
pub pattern: String,
|
||||||
|
/// Optional worktree subpath pattern
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worktree_pattern: Option<String>,
|
||||||
|
/// Human-readable description of why this is allowlisted
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustAllowlistEntry {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pattern: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
pattern: pattern.into(),
|
||||||
|
worktree_pattern: None,
|
||||||
|
description: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
|
||||||
|
self.worktree_pattern = Some(pattern.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||||
|
self.description = Some(desc.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for trust resolution with allowlist/denylist support.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TrustConfig {
|
pub struct TrustConfig {
|
||||||
allowlisted: Vec<PathBuf>,
|
/// Allowlisted paths with pattern matching
|
||||||
denied: Vec<PathBuf>,
|
pub allowlisted: Vec<TrustAllowlistEntry>,
|
||||||
|
/// Denied paths (exact or prefix matches)
|
||||||
|
pub denied: Vec<PathBuf>,
|
||||||
|
/// Whether to emit events for trust decisions
|
||||||
|
#[serde(default = "default_emit_events")]
|
||||||
|
pub emit_events: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_emit_events() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TrustConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allowlisted: Vec::new(),
|
||||||
|
denied: Vec::new(),
|
||||||
|
emit_events: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrustConfig {
|
impl TrustConfig {
|
||||||
@@ -35,8 +134,14 @@ impl TrustConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self {
|
||||||
self.allowlisted.push(path.into());
|
self.allowlisted.push(TrustAllowlistEntry::new(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
|
||||||
|
self.allowlisted.push(entry);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +150,147 @@ impl TrustConfig {
|
|||||||
self.denied.push(path.into());
|
self.denied.push(path.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a path matches an allowlisted entry using glob patterns.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_allowlisted(
|
||||||
|
&self,
|
||||||
|
cwd: &str,
|
||||||
|
worktree: Option<&str>,
|
||||||
|
) -> Option<&TrustAllowlistEntry> {
|
||||||
|
self.allowlisted.iter().find(|entry| {
|
||||||
|
let path_matches = Self::pattern_matches(&entry.pattern, cwd);
|
||||||
|
if !path_matches {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (&entry.worktree_pattern, worktree) {
|
||||||
|
(Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
(None, _) => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match a pattern against a path string.
|
||||||
|
/// Supports exact matching and glob patterns (* and ?).
|
||||||
|
fn pattern_matches(pattern: &str, path: &str) -> bool {
|
||||||
|
let pattern = pattern.trim();
|
||||||
|
let path = path.trim();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if pattern == path {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize paths for comparison
|
||||||
|
let pattern_normalized = pattern.replace("//", "/");
|
||||||
|
let path_normalized = path.replace("//", "/");
|
||||||
|
|
||||||
|
// Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a")
|
||||||
|
// This handles the common case of directory containment
|
||||||
|
if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') {
|
||||||
|
// Prefix match: pattern is a directory that contains path
|
||||||
|
if path_normalized.starts_with(&pattern_normalized) {
|
||||||
|
let rest = &path_normalized[pattern_normalized.len()..];
|
||||||
|
// Must be exact match or continue with /
|
||||||
|
return rest.is_empty() || rest.starts_with('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern ends with wildcard (prefix match)
|
||||||
|
if pattern_normalized.ends_with("/*") {
|
||||||
|
let prefix = pattern_normalized.trim_end_matches("/*");
|
||||||
|
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||||
|
// Must either be exact match or continue with /
|
||||||
|
return rest.is_empty() || rest.starts_with('/');
|
||||||
|
}
|
||||||
|
} else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") {
|
||||||
|
// Simple trailing * (not a path component wildcard)
|
||||||
|
let prefix = pattern_normalized.trim_end_matches('*');
|
||||||
|
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||||
|
return rest.is_empty() || !rest.starts_with('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern is a path component match (bounded by /)
|
||||||
|
if path_normalized
|
||||||
|
.split('/')
|
||||||
|
.any(|component| component == pattern_normalized)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern appears as a substring within a path component
|
||||||
|
// (e.g., "repo" matches "/tmp/worktrees/repo-a")
|
||||||
|
if path_normalized
|
||||||
|
.split('/')
|
||||||
|
.any(|component| component.contains(&pattern_normalized))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glob matching for patterns with ? or * in the middle
|
||||||
|
if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") {
|
||||||
|
return Self::glob_matches(&pattern_normalized, &path_normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple glob pattern matching (? matches single char, * matches any sequence).
|
||||||
|
/// Handles patterns like /tmp/*/repo-* where * matches path components.
|
||||||
|
fn glob_matches(pattern: &str, path: &str) -> bool {
|
||||||
|
// Use recursive backtracking for proper glob matching
|
||||||
|
Self::glob_match_recursive(pattern, path, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool {
|
||||||
|
let p_chars: Vec<char> = pattern.chars().collect();
|
||||||
|
let s_chars: Vec<char> = path.chars().collect();
|
||||||
|
|
||||||
|
let mut p = p_idx;
|
||||||
|
let mut s = s_idx;
|
||||||
|
|
||||||
|
while p < p_chars.len() {
|
||||||
|
match p_chars[p] {
|
||||||
|
'*' => {
|
||||||
|
// Try all possible matches for *
|
||||||
|
p += 1;
|
||||||
|
if p >= p_chars.len() {
|
||||||
|
// * at end matches everything remaining
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Try matching 0 or more characters
|
||||||
|
for skip in 0..=(s_chars.len() - s) {
|
||||||
|
if Self::glob_match_recursive(pattern, path, p, s + skip) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
'?' => {
|
||||||
|
// ? matches exactly one character
|
||||||
|
if s >= s_chars.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p += 1;
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
c => {
|
||||||
|
// Exact character match
|
||||||
|
if s >= s_chars.len() || s_chars[s] != c {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p += 1;
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern exhausted - path must also be exhausted
|
||||||
|
s >= s_chars.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -86,15 +332,19 @@ impl TrustResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
|
pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision {
|
||||||
if !detect_trust_prompt(screen_text) {
|
if !detect_trust_prompt(screen_text) {
|
||||||
return TrustDecision::NotRequired;
|
return TrustDecision::NotRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let repo = extract_repo_name(cwd);
|
||||||
let mut events = vec![TrustEvent::TrustRequired {
|
let mut events = vec![TrustEvent::TrustRequired {
|
||||||
cwd: cwd.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
|
repo: repo.clone(),
|
||||||
|
worktree: worktree.map(String::from),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
// Check denylist first
|
||||||
if let Some(matched_root) = self
|
if let Some(matched_root) = self
|
||||||
.config
|
.config
|
||||||
.denied
|
.denied
|
||||||
@@ -112,15 +362,12 @@ impl TrustResolver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if self
|
// Check allowlist with pattern matching
|
||||||
.config
|
if self.config.is_allowlisted(cwd, worktree).is_some() {
|
||||||
.allowlisted
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
{
|
|
||||||
events.push(TrustEvent::TrustResolved {
|
events.push(TrustEvent::TrustResolved {
|
||||||
cwd: cwd.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
});
|
});
|
||||||
return TrustDecision::Required {
|
return TrustDecision::Required {
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
@@ -128,6 +375,19 @@ impl TrustResolver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for manual trust resolution via screen text analysis
|
||||||
|
if detect_manual_approval(screen_text) {
|
||||||
|
events.push(TrustEvent::TrustResolved {
|
||||||
|
cwd: cwd.to_owned(),
|
||||||
|
policy: TrustPolicy::RequireApproval,
|
||||||
|
resolution: TrustResolution::ManualApproval,
|
||||||
|
});
|
||||||
|
return TrustDecision::Required {
|
||||||
|
policy: TrustPolicy::RequireApproval,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
TrustDecision::Required {
|
TrustDecision::Required {
|
||||||
policy: TrustPolicy::RequireApproval,
|
policy: TrustPolicy::RequireApproval,
|
||||||
events,
|
events,
|
||||||
@@ -135,17 +395,20 @@ impl TrustResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn trusts(&self, cwd: &str) -> bool {
|
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool {
|
||||||
!self
|
// Check denylist first
|
||||||
|
let denied = self
|
||||||
.config
|
.config
|
||||||
.denied
|
.denied
|
||||||
.iter()
|
.iter()
|
||||||
.any(|root| path_matches(cwd, root))
|
.any(|root| path_matches(cwd, root));
|
||||||
&& self
|
|
||||||
.config
|
if denied {
|
||||||
.allowlisted
|
return false;
|
||||||
.iter()
|
}
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
|
// Check allowlist using pattern matching
|
||||||
|
self.config.is_allowlisted(cwd, worktree).is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +435,240 @@ fn normalize_path(path: &Path) -> PathBuf {
|
|||||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract repository name from a path for event context.
|
||||||
|
fn extract_repo_name(cwd: &str) -> Option<String> {
|
||||||
|
let path = Path::new(cwd);
|
||||||
|
// Try to find a .git directory to identify repo root
|
||||||
|
let mut current = Some(path);
|
||||||
|
while let Some(p) = current {
|
||||||
|
if p.join(".git").is_dir() {
|
||||||
|
return p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
current = p.parent();
|
||||||
|
}
|
||||||
|
// Fallback: use the last component of the path
|
||||||
|
path.file_name().map(|n| n.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if the screen text indicates manual approval was granted.
|
||||||
|
fn detect_manual_approval(screen_text: &str) -> bool {
|
||||||
|
let lowered = screen_text.to_ascii_lowercase();
|
||||||
|
// Look for indicators that user manually approved
|
||||||
|
MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANUAL_APPROVAL_CUES: &[&str] = &[
|
||||||
|
"yes, i trust",
|
||||||
|
"i trust this",
|
||||||
|
"trusted manually",
|
||||||
|
"approval granted",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod path_matching_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_pattern_star_matches_any_sequence() {
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo"));
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_pattern_question_matches_single_char() {
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1"));
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_exact() {
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees",
|
||||||
|
"/tmp/worktrees"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees",
|
||||||
|
"/tmp/worktrees-other"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_prefix_with_wildcard() {
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/worktrees/repo-a/subdir"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/other/repo"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_contains() {
|
||||||
|
// Pattern contained within path
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"worktrees",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"repo",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlist_entry_with_worktree_pattern() {
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||||
|
.with_worktree_pattern("*/.git")
|
||||||
|
.with_description("Git worktrees"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should match when both patterns match
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"))
|
||||||
|
.is_some());
|
||||||
|
|
||||||
|
// Should not match when worktree pattern doesn't match
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path"))
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Should not match when a worktree pattern is required but no worktree is supplied
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Should match when no worktree pattern required and path matches
|
||||||
|
let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*");
|
||||||
|
assert!(config_no_worktree
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlist_entry_returns_matched_entry() {
|
||||||
|
let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees");
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(entry.clone());
|
||||||
|
|
||||||
|
let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None);
|
||||||
|
assert!(matched.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
matched.unwrap().description,
|
||||||
|
Some("Test worktrees".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complex_glob_patterns() {
|
||||||
|
// Multiple wildcards
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/worktrees/repo-123"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/other/repo-abc"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/worktrees/other"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Mixed ? and *
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/test?/*.txt",
|
||||||
|
"/tmp/test1/file.txt"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/test?/*.txt",
|
||||||
|
"/tmp/testA/subdir/file.txt"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_serialization_roundtrip() {
|
||||||
|
let config = TrustConfig::new()
|
||||||
|
.with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||||
|
.with_worktree_pattern("*/.git")
|
||||||
|
.with_description("Git worktrees"),
|
||||||
|
)
|
||||||
|
.with_denied("/tmp/malicious");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).expect("serialization failed");
|
||||||
|
let deserialized: TrustConfig =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
|
||||||
|
assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len());
|
||||||
|
assert_eq!(config.denied.len(), deserialized.denied.len());
|
||||||
|
assert_eq!(config.emit_events, deserialized.emit_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_event_serialization() {
|
||||||
|
let event = TrustEvent::TrustRequired {
|
||||||
|
cwd: "/tmp/test".to_string(),
|
||||||
|
repo: Some("test-repo".to_string()),
|
||||||
|
worktree: Some("/tmp/test/.git".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||||
|
assert!(json.contains("trust_required"));
|
||||||
|
assert!(json.contains("/tmp/test"));
|
||||||
|
assert!(json.contains("test-repo"));
|
||||||
|
|
||||||
|
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
match deserialized {
|
||||||
|
TrustEvent::TrustRequired {
|
||||||
|
cwd,
|
||||||
|
repo,
|
||||||
|
worktree,
|
||||||
|
} => {
|
||||||
|
assert_eq!(cwd, "/tmp/test");
|
||||||
|
assert_eq!(repo, Some("test-repo".to_string()));
|
||||||
|
assert_eq!(worktree, Some("/tmp/test/.git".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("wrong event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_event_resolved_serialization() {
|
||||||
|
let event = TrustEvent::TrustResolved {
|
||||||
|
cwd: "/tmp/test".to_string(),
|
||||||
|
policy: TrustPolicy::AutoTrust,
|
||||||
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||||
|
assert!(json.contains("trust_resolved"));
|
||||||
|
assert!(json.contains("auto_allowlisted"));
|
||||||
|
|
||||||
|
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
match deserialized {
|
||||||
|
TrustEvent::TrustResolved { resolution, .. } => {
|
||||||
|
assert_eq!(resolution, TrustResolution::AutoAllowlisted);
|
||||||
|
}
|
||||||
|
_ => panic!("wrong event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root,
|
||||||
TrustPolicy, TrustResolver,
|
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution,
|
||||||
|
TrustResolver,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -197,7 +689,7 @@ mod tests {
|
|||||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
|
let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>");
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(decision, TrustDecision::NotRequired);
|
assert_eq!(decision, TrustDecision::NotRequired);
|
||||||
@@ -213,23 +705,23 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/worktrees/repo-a",
|
"/tmp/worktrees/repo-a",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
assert_eq!(
|
let events = decision.events();
|
||||||
decision.events(),
|
assert_eq!(events.len(), 2);
|
||||||
&[
|
assert!(matches!(events[0], TrustEvent::TrustRequired { .. }));
|
||||||
TrustEvent::TrustRequired {
|
assert!(matches!(
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
events[1],
|
||||||
},
|
TrustEvent::TrustResolved {
|
||||||
TrustEvent::TrustResolved {
|
policy: TrustPolicy::AutoTrust,
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
policy: TrustPolicy::AutoTrust,
|
..
|
||||||
},
|
}
|
||||||
]
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -240,6 +732,7 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/other/repo-b",
|
"/tmp/other/repo-b",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,6 +742,8 @@ mod tests {
|
|||||||
decision.events(),
|
decision.events(),
|
||||||
&[TrustEvent::TrustRequired {
|
&[TrustEvent::TrustRequired {
|
||||||
cwd: "/tmp/other/repo-b".to_string(),
|
cwd: "/tmp/other/repo-b".to_string(),
|
||||||
|
repo: Some("repo-b".to_string()),
|
||||||
|
worktree: None,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,6 +760,7 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/worktrees/repo-c",
|
"/tmp/worktrees/repo-c",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,6 +771,8 @@ mod tests {
|
|||||||
&[
|
&[
|
||||||
TrustEvent::TrustRequired {
|
TrustEvent::TrustRequired {
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||||
|
repo: Some("repo-c".to_string()),
|
||||||
|
worktree: None,
|
||||||
},
|
},
|
||||||
TrustEvent::TrustDenied {
|
TrustEvent::TrustDenied {
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||||
@@ -284,6 +782,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_trusts_with_glob_pattern_allowlist() {
|
||||||
|
// given
|
||||||
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*"));
|
||||||
|
|
||||||
|
// when - any repo under /tmp/worktrees should auto-trust
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/worktrees/repo-a",
|
||||||
|
None,
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_with_worktree_pattern_matching() {
|
||||||
|
// given
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"),
|
||||||
|
);
|
||||||
|
let resolver = TrustResolver::new(config);
|
||||||
|
|
||||||
|
// when - with worktree that matches the pattern
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/worktrees/repo-a",
|
||||||
|
Some("/tmp/worktrees/repo-a/.git"),
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then - should auto-trust because both patterns match
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_approval_detected_from_screen_text() {
|
||||||
|
// given
|
||||||
|
let resolver = TrustResolver::new(TrustConfig::new());
|
||||||
|
|
||||||
|
// when - screen text indicates manual approval
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/some/repo",
|
||||||
|
None,
|
||||||
|
"Do you trust the files in this folder?\nUser selected: Yes, I trust this folder",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then - should detect manual approval
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
|
||||||
|
let events = decision.events();
|
||||||
|
assert!(events.len() >= 2);
|
||||||
|
assert!(matches!(
|
||||||
|
events[events.len() - 1],
|
||||||
|
TrustEvent::TrustResolved {
|
||||||
|
resolution: TrustResolution::ManualApproval,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sibling_prefix_does_not_match_trusted_root() {
|
fn sibling_prefix_does_not_match_trusted_root() {
|
||||||
// given
|
// given
|
||||||
@@ -296,4 +854,70 @@ mod tests {
|
|||||||
// then
|
// then
|
||||||
assert!(!matched);
|
assert!(!matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_manual_approval_cues() {
|
||||||
|
assert!(detect_manual_approval(
|
||||||
|
"User selected: Yes, I trust this folder"
|
||||||
|
));
|
||||||
|
assert!(detect_manual_approval(
|
||||||
|
"I trust this repository and its contents"
|
||||||
|
));
|
||||||
|
assert!(detect_manual_approval("Approval granted by user"));
|
||||||
|
assert!(!detect_manual_approval(
|
||||||
|
"Do you trust the files in this folder?"
|
||||||
|
));
|
||||||
|
assert!(!detect_manual_approval("Some unrelated text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_config_default_emit_events() {
|
||||||
|
let config = TrustConfig::default();
|
||||||
|
assert!(config.emit_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_resolver_trusts_method() {
|
||||||
|
let resolver = TrustResolver::new(
|
||||||
|
TrustConfig::new()
|
||||||
|
.with_allowlisted("/tmp/worktrees/*")
|
||||||
|
.with_denied("/tmp/worktrees/bad-repo"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should trust allowlisted paths
|
||||||
|
assert!(resolver.trusts("/tmp/worktrees/good-repo", None));
|
||||||
|
|
||||||
|
// Should not trust denied paths
|
||||||
|
assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None));
|
||||||
|
|
||||||
|
// Should not trust unknown paths
|
||||||
|
assert!(!resolver.trusts("/tmp/other/repo", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_policy_serde_roundtrip() {
|
||||||
|
for policy in [
|
||||||
|
TrustPolicy::AutoTrust,
|
||||||
|
TrustPolicy::RequireApproval,
|
||||||
|
TrustPolicy::Deny,
|
||||||
|
] {
|
||||||
|
let json = serde_json::to_string(&policy).expect("serialization failed");
|
||||||
|
let deserialized: TrustPolicy =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
assert_eq!(policy, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_resolution_serde_roundtrip() {
|
||||||
|
for resolution in [
|
||||||
|
TrustResolution::AutoAllowlisted,
|
||||||
|
TrustResolution::ManualApproval,
|
||||||
|
] {
|
||||||
|
let json = serde_json::to_string(&resolution).expect("serialization failed");
|
||||||
|
let deserialized: TrustResolution =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
assert_eq!(resolution, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ fn now_secs() -> u64 {
|
|||||||
pub enum WorkerStatus {
|
pub enum WorkerStatus {
|
||||||
Spawning,
|
Spawning,
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
ToolPermissionRequired,
|
||||||
ReadyForPrompt,
|
ReadyForPrompt,
|
||||||
Running,
|
Running,
|
||||||
Finished,
|
Finished,
|
||||||
@@ -41,6 +42,7 @@ impl std::fmt::Display for WorkerStatus {
|
|||||||
match self {
|
match self {
|
||||||
Self::Spawning => write!(f, "spawning"),
|
Self::Spawning => write!(f, "spawning"),
|
||||||
Self::TrustRequired => write!(f, "trust_required"),
|
Self::TrustRequired => write!(f, "trust_required"),
|
||||||
|
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||||
Self::Running => write!(f, "running"),
|
Self::Running => write!(f, "running"),
|
||||||
Self::Finished => write!(f, "finished"),
|
Self::Finished => write!(f, "finished"),
|
||||||
@@ -53,6 +55,7 @@ impl std::fmt::Display for WorkerStatus {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerFailureKind {
|
pub enum WorkerFailureKind {
|
||||||
TrustGate,
|
TrustGate,
|
||||||
|
ToolPermissionGate,
|
||||||
PromptDelivery,
|
PromptDelivery,
|
||||||
Protocol,
|
Protocol,
|
||||||
Provider,
|
Provider,
|
||||||
@@ -71,6 +74,7 @@ pub struct WorkerFailure {
|
|||||||
pub enum WorkerEventKind {
|
pub enum WorkerEventKind {
|
||||||
Spawning,
|
Spawning,
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
ToolPermissionRequired,
|
||||||
TrustResolved,
|
TrustResolved,
|
||||||
ReadyForPrompt,
|
ReadyForPrompt,
|
||||||
PromptMisdelivery,
|
PromptMisdelivery,
|
||||||
@@ -104,6 +108,8 @@ pub enum WorkerPromptTarget {
|
|||||||
pub enum StartupFailureClassification {
|
pub enum StartupFailureClassification {
|
||||||
/// Trust prompt is required but not detected/resolved
|
/// Trust prompt is required but not detected/resolved
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
/// Tool permission prompt is required before startup can continue
|
||||||
|
ToolPermissionRequired,
|
||||||
/// Prompt was delivered to wrong target (shell misdelivery)
|
/// Prompt was delivered to wrong target (shell misdelivery)
|
||||||
PromptMisdelivery,
|
PromptMisdelivery,
|
||||||
/// Prompt was sent but acceptance timed out
|
/// Prompt was sent but acceptance timed out
|
||||||
@@ -116,13 +122,37 @@ pub enum StartupFailureClassification {
|
|||||||
Unknown,
|
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.
|
/// Evidence bundle collected when worker startup times out without clear evidence.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct StartupEvidenceBundle {
|
pub struct StartupEvidenceBundle {
|
||||||
/// Last known worker lifecycle state before timeout
|
/// Last known worker lifecycle state before timeout
|
||||||
pub last_lifecycle_state: WorkerStatus,
|
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
|
/// The pane/command that was being executed
|
||||||
pub pane_command: String,
|
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
|
/// Timestamp when prompt was sent (if any), unix epoch seconds
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_sent_at: Option<u64>,
|
pub prompt_sent_at: Option<u64>,
|
||||||
@@ -130,10 +160,22 @@ pub struct StartupEvidenceBundle {
|
|||||||
pub prompt_acceptance_state: bool,
|
pub prompt_acceptance_state: bool,
|
||||||
/// Result of trust prompt detection at timeout
|
/// Result of trust prompt detection at timeout
|
||||||
pub trust_prompt_detected: bool,
|
pub trust_prompt_detected: bool,
|
||||||
|
/// Result of tool permission prompt detection at timeout
|
||||||
|
pub tool_permission_prompt_detected: bool,
|
||||||
|
/// Age in seconds of the latest tool permission prompt, when observed
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_permission_prompt_age_seconds: Option<u64>,
|
||||||
|
/// Whether the prompt surface exposed only a session allow path or also an always-allow path
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
|
||||||
/// Transport health summary (true = healthy/responsive)
|
/// Transport health summary (true = healthy/responsive)
|
||||||
pub transport_healthy: bool,
|
pub transport_healthy: bool,
|
||||||
|
/// Typed transport health placeholder for future concrete probes
|
||||||
|
pub transport_health: StartupHealthSummary,
|
||||||
/// MCP health summary (true = all servers healthy)
|
/// MCP health summary (true = all servers healthy)
|
||||||
pub mcp_healthy: bool,
|
pub mcp_healthy: bool,
|
||||||
|
/// Typed MCP health placeholder for future concrete probes
|
||||||
|
pub mcp_health: StartupHealthSummary,
|
||||||
/// Seconds since worker creation
|
/// Seconds since worker creation
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
}
|
}
|
||||||
@@ -146,6 +188,15 @@ pub enum WorkerEventPayload {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
resolution: Option<WorkerTrustResolution>,
|
resolution: Option<WorkerTrustResolution>,
|
||||||
},
|
},
|
||||||
|
ToolPermissionPrompt {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
server_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_name: Option<String>,
|
||||||
|
prompt_age_seconds: u64,
|
||||||
|
allow_scope: ToolPermissionAllowScope,
|
||||||
|
prompt_preview: String,
|
||||||
|
},
|
||||||
PromptDelivery {
|
PromptDelivery {
|
||||||
prompt_preview: String,
|
prompt_preview: String,
|
||||||
observed_target: WorkerPromptTarget,
|
observed_target: WorkerPromptTarget,
|
||||||
@@ -163,6 +214,14 @@ pub enum WorkerEventPayload {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolPermissionAllowScope {
|
||||||
|
SessionOnly,
|
||||||
|
SessionOrAlways,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerTaskReceipt {
|
pub struct WorkerTaskReceipt {
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
@@ -194,6 +253,7 @@ pub struct Worker {
|
|||||||
pub auto_recover_prompt_misdelivery: bool,
|
pub auto_recover_prompt_misdelivery: bool,
|
||||||
pub prompt_delivery_attempts: u32,
|
pub prompt_delivery_attempts: u32,
|
||||||
pub prompt_in_flight: bool,
|
pub prompt_in_flight: bool,
|
||||||
|
pub prompt_sent_at: Option<u64>,
|
||||||
pub last_prompt: Option<String>,
|
pub last_prompt: Option<String>,
|
||||||
pub expected_receipt: Option<WorkerTaskReceipt>,
|
pub expected_receipt: Option<WorkerTaskReceipt>,
|
||||||
pub replay_prompt: Option<String>,
|
pub replay_prompt: Option<String>,
|
||||||
@@ -243,6 +303,7 @@ impl WorkerRegistry {
|
|||||||
auto_recover_prompt_misdelivery,
|
auto_recover_prompt_misdelivery,
|
||||||
prompt_delivery_attempts: 0,
|
prompt_delivery_attempts: 0,
|
||||||
prompt_in_flight: false,
|
prompt_in_flight: false,
|
||||||
|
prompt_sent_at: None,
|
||||||
last_prompt: None,
|
last_prompt: None,
|
||||||
expected_receipt: None,
|
expected_receipt: None,
|
||||||
replay_prompt: None,
|
replay_prompt: None,
|
||||||
@@ -276,6 +337,29 @@ impl WorkerRegistry {
|
|||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
||||||
let lowered = screen_text.to_ascii_lowercase();
|
let lowered = screen_text.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if let Some(tool_prompt) = detect_tool_permission_prompt(screen_text, &lowered) {
|
||||||
|
worker.status = WorkerStatus::ToolPermissionRequired;
|
||||||
|
worker.last_error = Some(WorkerFailure {
|
||||||
|
kind: WorkerFailureKind::ToolPermissionGate,
|
||||||
|
message: tool_prompt.message(),
|
||||||
|
created_at: now_secs(),
|
||||||
|
});
|
||||||
|
push_event(
|
||||||
|
worker,
|
||||||
|
WorkerEventKind::ToolPermissionRequired,
|
||||||
|
WorkerStatus::ToolPermissionRequired,
|
||||||
|
Some("tool permission prompt detected".to_string()),
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||||
|
server_name: tool_prompt.server_name,
|
||||||
|
tool_name: tool_prompt.tool_name,
|
||||||
|
prompt_age_seconds: 0,
|
||||||
|
allow_scope: tool_prompt.allow_scope,
|
||||||
|
prompt_preview: tool_prompt.prompt_preview,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Ok(worker.clone());
|
||||||
|
}
|
||||||
|
|
||||||
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
||||||
worker.status = WorkerStatus::TrustRequired;
|
worker.status = WorkerStatus::TrustRequired;
|
||||||
worker.last_error = Some(WorkerFailure {
|
worker.last_error = Some(WorkerFailure {
|
||||||
@@ -474,6 +558,7 @@ impl WorkerRegistry {
|
|||||||
|
|
||||||
worker.prompt_delivery_attempts += 1;
|
worker.prompt_delivery_attempts += 1;
|
||||||
worker.prompt_in_flight = true;
|
worker.prompt_in_flight = true;
|
||||||
|
worker.prompt_sent_at = Some(now_secs());
|
||||||
worker.last_prompt = Some(next_prompt.clone());
|
worker.last_prompt = Some(next_prompt.clone());
|
||||||
worker.expected_receipt = task_receipt;
|
worker.expected_receipt = task_receipt;
|
||||||
worker.replay_prompt = None;
|
worker.replay_prompt = None;
|
||||||
@@ -503,7 +588,9 @@ impl WorkerRegistry {
|
|||||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||||
blocked: matches!(
|
blocked: matches!(
|
||||||
worker.status,
|
worker.status,
|
||||||
WorkerStatus::TrustRequired | WorkerStatus::Failed
|
WorkerStatus::TrustRequired
|
||||||
|
| WorkerStatus::ToolPermissionRequired
|
||||||
|
| WorkerStatus::Failed
|
||||||
),
|
),
|
||||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
replay_prompt_ready: worker.replay_prompt.is_some(),
|
||||||
last_error: worker.last_error.clone(),
|
last_error: worker.last_error.clone(),
|
||||||
@@ -523,6 +610,7 @@ impl WorkerRegistry {
|
|||||||
worker.last_error = None;
|
worker.last_error = None;
|
||||||
worker.prompt_delivery_attempts = 0;
|
worker.prompt_delivery_attempts = 0;
|
||||||
worker.prompt_in_flight = false;
|
worker.prompt_in_flight = false;
|
||||||
|
worker.prompt_sent_at = None;
|
||||||
push_event(
|
push_event(
|
||||||
worker,
|
worker,
|
||||||
WorkerEventKind::Restarted,
|
WorkerEventKind::Restarted,
|
||||||
@@ -624,24 +712,44 @@ impl WorkerRegistry {
|
|||||||
|
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let elapsed = now.saturating_sub(worker.created_at);
|
let elapsed = now.saturating_sub(worker.created_at);
|
||||||
|
let latest_tool_permission_event = worker
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired);
|
||||||
|
let tool_permission_allow_scope =
|
||||||
|
latest_tool_permission_event.and_then(|event| match &event.payload {
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt { allow_scope, .. }) => {
|
||||||
|
Some(*allow_scope)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
// Build evidence bundle
|
// Build evidence bundle
|
||||||
let evidence = StartupEvidenceBundle {
|
let evidence = StartupEvidenceBundle {
|
||||||
last_lifecycle_state: worker.status,
|
last_lifecycle_state: worker.status,
|
||||||
|
last_lifecycle_at: worker.updated_at,
|
||||||
pane_command: pane_command.to_string(),
|
pane_command: pane_command.to_string(),
|
||||||
prompt_sent_at: if worker.prompt_delivery_attempts > 0 {
|
pane_observed_at: now,
|
||||||
Some(worker.updated_at)
|
command_started_at: worker.created_at,
|
||||||
} else {
|
prompt_sent_at: worker.prompt_sent_at,
|
||||||
None
|
|
||||||
},
|
|
||||||
prompt_acceptance_state: worker.status == WorkerStatus::Running
|
prompt_acceptance_state: worker.status == WorkerStatus::Running
|
||||||
&& !worker.prompt_in_flight,
|
&& !worker.prompt_in_flight,
|
||||||
trust_prompt_detected: worker
|
trust_prompt_detected: worker
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| e.kind == WorkerEventKind::TrustRequired),
|
.any(|e| e.kind == WorkerEventKind::TrustRequired),
|
||||||
|
tool_permission_prompt_detected: worker
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.kind == WorkerEventKind::ToolPermissionRequired),
|
||||||
|
tool_permission_prompt_age_seconds: latest_tool_permission_event
|
||||||
|
.map(|event| now.saturating_sub(event.timestamp)),
|
||||||
|
tool_permission_allow_scope,
|
||||||
transport_healthy,
|
transport_healthy,
|
||||||
|
transport_health: StartupHealthSummary::observed("transport", transport_healthy),
|
||||||
mcp_healthy,
|
mcp_healthy,
|
||||||
|
mcp_health: StartupHealthSummary::observed("mcp", mcp_healthy),
|
||||||
elapsed_seconds: elapsed,
|
elapsed_seconds: elapsed,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -694,6 +802,13 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
|
|||||||
return StartupFailureClassification::TrustRequired;
|
return StartupFailureClassification::TrustRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for tool permission prompts that were not resolved
|
||||||
|
if evidence.tool_permission_prompt_detected
|
||||||
|
&& evidence.last_lifecycle_state == WorkerStatus::ToolPermissionRequired
|
||||||
|
{
|
||||||
|
return StartupFailureClassification::ToolPermissionRequired;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for prompt acceptance timeout
|
// Check for prompt acceptance timeout
|
||||||
if evidence.prompt_sent_at.is_some()
|
if evidence.prompt_sent_at.is_some()
|
||||||
&& !evidence.prompt_acceptance_state
|
&& !evidence.prompt_acceptance_state
|
||||||
@@ -815,6 +930,140 @@ fn normalize_path(path: &str) -> PathBuf {
|
|||||||
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ToolPermissionPromptObservation {
|
||||||
|
server_name: Option<String>,
|
||||||
|
tool_name: Option<String>,
|
||||||
|
allow_scope: ToolPermissionAllowScope,
|
||||||
|
prompt_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolPermissionPromptObservation {
|
||||||
|
fn message(&self) -> String {
|
||||||
|
match (&self.server_name, &self.tool_name) {
|
||||||
|
(Some(server), Some(tool)) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {server}.{tool}")
|
||||||
|
}
|
||||||
|
(Some(server), None) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {server}")
|
||||||
|
}
|
||||||
|
(None, Some(tool)) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {tool}")
|
||||||
|
}
|
||||||
|
(None, None) => "worker boot blocked on tool permission prompt".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_tool_permission_prompt(
|
||||||
|
screen_text: &str,
|
||||||
|
lowered: &str,
|
||||||
|
) -> Option<ToolPermissionPromptObservation> {
|
||||||
|
let looks_like_prompt = lowered.contains("allow the")
|
||||||
|
&& lowered.contains("server")
|
||||||
|
&& lowered.contains("tool")
|
||||||
|
&& lowered.contains("run");
|
||||||
|
let looks_like_tool_gate = lowered.contains("allow tool") && lowered.contains("run");
|
||||||
|
if !looks_like_prompt && !looks_like_tool_gate {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt_line = screen_text
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|line| {
|
||||||
|
let lowered_line = line.to_ascii_lowercase();
|
||||||
|
lowered_line.contains("allow")
|
||||||
|
&& lowered_line.contains("tool")
|
||||||
|
&& (lowered_line.contains("run") || lowered_line.contains("server"))
|
||||||
|
})
|
||||||
|
.unwrap_or(screen_text)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
let tool_name = extract_quoted_value(prompt_line)
|
||||||
|
.or_else(|| extract_after(prompt_line, "tool ").map(|token| normalize_tool_token(&token)));
|
||||||
|
let server_name = extract_between(prompt_line, "the ", " server")
|
||||||
|
.map(|server| server.trim_end_matches(" MCP").to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
tool_name
|
||||||
|
.as_deref()
|
||||||
|
.and_then(extract_server_from_qualified_tool)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(ToolPermissionPromptObservation {
|
||||||
|
server_name,
|
||||||
|
tool_name,
|
||||||
|
allow_scope: detect_tool_permission_allow_scope(lowered),
|
||||||
|
prompt_preview: prompt_preview(prompt_line),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_tool_permission_allow_scope(lowered: &str) -> ToolPermissionAllowScope {
|
||||||
|
let always_allow_capable = [
|
||||||
|
"always allow",
|
||||||
|
"allow always",
|
||||||
|
"allow this tool always",
|
||||||
|
"allow for all sessions",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| lowered.contains(needle));
|
||||||
|
|
||||||
|
if always_allow_capable {
|
||||||
|
return ToolPermissionAllowScope::SessionOrAlways;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_allow_capable = [
|
||||||
|
"allow once",
|
||||||
|
"allow for this session",
|
||||||
|
"allow this session",
|
||||||
|
"yes, allow",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| lowered.contains(needle));
|
||||||
|
|
||||||
|
if session_allow_capable {
|
||||||
|
ToolPermissionAllowScope::SessionOnly
|
||||||
|
} else {
|
||||||
|
ToolPermissionAllowScope::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_quoted_value(text: &str) -> Option<String> {
|
||||||
|
let start = text.find('"')? + 1;
|
||||||
|
let rest = &text[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_between(text: &str, prefix: &str, suffix: &str) -> Option<String> {
|
||||||
|
let start = text.find(prefix)? + prefix.len();
|
||||||
|
let rest = &text[start..];
|
||||||
|
let end = rest.find(suffix)?;
|
||||||
|
let value = rest[..end].trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_after(text: &str, prefix: &str) -> Option<String> {
|
||||||
|
let start = text.to_ascii_lowercase().find(prefix)? + prefix.len();
|
||||||
|
let value = text[start..]
|
||||||
|
.split_whitespace()
|
||||||
|
.next()?
|
||||||
|
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'');
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tool_token(token: &str) -> String {
|
||||||
|
token
|
||||||
|
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
|
||||||
|
let rest = tool.strip_prefix("mcp__")?;
|
||||||
|
let (server, _) = rest.split_once("__")?;
|
||||||
|
(!server.is_empty()).then(|| server.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||||
[
|
[
|
||||||
"do you trust the files in this folder",
|
"do you trust the files in this folder",
|
||||||
@@ -1134,6 +1383,96 @@ mod tests {
|
|||||||
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_permission_prompt_blocks_worker_with_structured_event() {
|
||||||
|
let registry = WorkerRegistry::new();
|
||||||
|
let worker = registry.create("/tmp/repo-mcp", &[], true);
|
||||||
|
|
||||||
|
let blocked = registry
|
||||||
|
.observe(
|
||||||
|
&worker.worker_id,
|
||||||
|
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?\n\
|
||||||
|
1. Yes, allow once\n\
|
||||||
|
2. Always allow this tool",
|
||||||
|
)
|
||||||
|
.expect("tool permission observe should succeed");
|
||||||
|
|
||||||
|
assert_eq!(blocked.status, WorkerStatus::ToolPermissionRequired);
|
||||||
|
assert_eq!(
|
||||||
|
blocked
|
||||||
|
.last_error
|
||||||
|
.as_ref()
|
||||||
|
.expect("tool permission error should exist")
|
||||||
|
.kind,
|
||||||
|
WorkerFailureKind::ToolPermissionGate
|
||||||
|
);
|
||||||
|
let event = blocked
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired)
|
||||||
|
.expect("tool permission event should exist");
|
||||||
|
assert_eq!(
|
||||||
|
event.payload,
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||||
|
server_name: Some("omx_memory".to_string()),
|
||||||
|
tool_name: Some("project_memory_read".to_string()),
|
||||||
|
prompt_age_seconds: 0,
|
||||||
|
allow_scope: ToolPermissionAllowScope::SessionOrAlways,
|
||||||
|
prompt_preview: prompt_preview(
|
||||||
|
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let readiness = registry
|
||||||
|
.await_ready(&worker.worker_id)
|
||||||
|
.expect("ready snapshot should load");
|
||||||
|
assert!(readiness.blocked);
|
||||||
|
assert!(!readiness.ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_timeout_classifies_tool_permission_prompt() {
|
||||||
|
let registry = WorkerRegistry::new();
|
||||||
|
let worker = registry.create("/tmp/repo-mcp-timeout", &[], true);
|
||||||
|
|
||||||
|
registry
|
||||||
|
.observe(
|
||||||
|
&worker.worker_id,
|
||||||
|
"Allow the omx_memory MCP server to run tool \"notepad_read\"?\n\
|
||||||
|
1. Yes, allow once",
|
||||||
|
)
|
||||||
|
.expect("tool permission observe should succeed");
|
||||||
|
|
||||||
|
let timed_out = registry
|
||||||
|
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
|
||||||
|
.expect("startup timeout observe should succeed");
|
||||||
|
let event = timed_out
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::StartupNoEvidence)
|
||||||
|
.expect("startup no evidence event should exist");
|
||||||
|
|
||||||
|
match event.payload.as_ref() {
|
||||||
|
Some(WorkerEventPayload::StartupNoEvidence {
|
||||||
|
classification,
|
||||||
|
evidence,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(
|
||||||
|
*classification,
|
||||||
|
StartupFailureClassification::ToolPermissionRequired
|
||||||
|
);
|
||||||
|
assert!(evidence.tool_permission_prompt_detected);
|
||||||
|
assert_eq!(
|
||||||
|
evidence.tool_permission_allow_scope,
|
||||||
|
Some(ToolPermissionAllowScope::SessionOnly)
|
||||||
|
);
|
||||||
|
assert!(evidence.tool_permission_prompt_age_seconds.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("expected StartupNoEvidence payload"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
||||||
let registry = WorkerRegistry::new();
|
let registry = WorkerRegistry::new();
|
||||||
@@ -1534,8 +1873,16 @@ mod tests {
|
|||||||
"last state should be spawning"
|
"last state should be spawning"
|
||||||
);
|
);
|
||||||
assert_eq!(evidence.pane_command, "cargo test");
|
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_healthy);
|
||||||
|
assert!(!evidence.transport_health.healthy);
|
||||||
|
assert!(evidence
|
||||||
|
.transport_health
|
||||||
|
.summary
|
||||||
|
.contains("transport_unhealthy"));
|
||||||
assert!(evidence.mcp_healthy);
|
assert!(evidence.mcp_healthy);
|
||||||
|
assert!(evidence.mcp_health.healthy);
|
||||||
assert_eq!(*classification, StartupFailureClassification::TransportDead);
|
assert_eq!(*classification, StartupFailureClassification::TransportDead);
|
||||||
}
|
}
|
||||||
_ => panic!(
|
_ => panic!(
|
||||||
@@ -1626,16 +1973,63 @@ 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]
|
#[test]
|
||||||
fn startup_evidence_bundle_serializes_correctly() {
|
fn startup_evidence_bundle_serializes_correctly() {
|
||||||
let bundle = StartupEvidenceBundle {
|
let bundle = StartupEvidenceBundle {
|
||||||
last_lifecycle_state: WorkerStatus::Running,
|
last_lifecycle_state: WorkerStatus::Running,
|
||||||
|
last_lifecycle_at: 1_234_567_889,
|
||||||
pane_command: "test command".to_string(),
|
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_sent_at: Some(1_234_567_890),
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: true,
|
trust_prompt_detected: true,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: true,
|
transport_healthy: true,
|
||||||
|
transport_health: StartupHealthSummary::observed("transport", true),
|
||||||
mcp_healthy: false,
|
mcp_healthy: false,
|
||||||
|
mcp_health: StartupHealthSummary::observed("mcp", false),
|
||||||
elapsed_seconds: 60,
|
elapsed_seconds: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1644,8 +2038,13 @@ mod tests {
|
|||||||
assert!(json.contains("\"pane_command\""));
|
assert!(json.contains("\"pane_command\""));
|
||||||
assert!(json.contains("\"prompt_sent_at\":1234567890"));
|
assert!(json.contains("\"prompt_sent_at\":1234567890"));
|
||||||
assert!(json.contains("\"trust_prompt_detected\":true"));
|
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_healthy\":true"));
|
||||||
|
assert!(json.contains("\"transport_health\""));
|
||||||
assert!(json.contains("\"mcp_healthy\":false"));
|
assert!(json.contains("\"mcp_healthy\":false"));
|
||||||
|
assert!(json.contains("\"mcp_health\""));
|
||||||
|
|
||||||
let deserialized: StartupEvidenceBundle =
|
let deserialized: StartupEvidenceBundle =
|
||||||
serde_json::from_str(&json).expect("should deserialize");
|
serde_json::from_str(&json).expect("should deserialize");
|
||||||
@@ -1657,12 +2056,20 @@ mod tests {
|
|||||||
fn classify_startup_failure_detects_transport_dead() {
|
fn classify_startup_failure_detects_transport_dead() {
|
||||||
let evidence = StartupEvidenceBundle {
|
let evidence = StartupEvidenceBundle {
|
||||||
last_lifecycle_state: WorkerStatus::Spawning,
|
last_lifecycle_state: WorkerStatus::Spawning,
|
||||||
|
last_lifecycle_at: 10,
|
||||||
pane_command: "test".to_string(),
|
pane_command: "test".to_string(),
|
||||||
|
pane_observed_at: 40,
|
||||||
|
command_started_at: 1,
|
||||||
prompt_sent_at: None,
|
prompt_sent_at: None,
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: false,
|
trust_prompt_detected: false,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: false,
|
transport_healthy: false,
|
||||||
|
transport_health: StartupHealthSummary::observed("transport", false),
|
||||||
mcp_healthy: true,
|
mcp_healthy: true,
|
||||||
|
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||||
elapsed_seconds: 30,
|
elapsed_seconds: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1674,12 +2081,20 @@ mod tests {
|
|||||||
fn classify_startup_failure_defaults_to_unknown() {
|
fn classify_startup_failure_defaults_to_unknown() {
|
||||||
let evidence = StartupEvidenceBundle {
|
let evidence = StartupEvidenceBundle {
|
||||||
last_lifecycle_state: WorkerStatus::Spawning,
|
last_lifecycle_state: WorkerStatus::Spawning,
|
||||||
|
last_lifecycle_at: 10,
|
||||||
pane_command: "test".to_string(),
|
pane_command: "test".to_string(),
|
||||||
|
pane_observed_at: 40,
|
||||||
|
command_started_at: 1,
|
||||||
prompt_sent_at: None,
|
prompt_sent_at: None,
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: 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_healthy: true,
|
||||||
|
transport_health: StartupHealthSummary::observed("transport", true),
|
||||||
mcp_healthy: true,
|
mcp_healthy: true,
|
||||||
|
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||||
elapsed_seconds: 10,
|
elapsed_seconds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1687,18 +2102,54 @@ mod tests {
|
|||||||
assert_eq!(classification, StartupFailureClassification::Unknown);
|
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]
|
#[test]
|
||||||
fn classify_startup_failure_detects_worker_crashed() {
|
fn classify_startup_failure_detects_worker_crashed() {
|
||||||
// Worker crashed scenario: transport healthy but MCP unhealthy
|
// Worker crashed scenario: transport healthy but MCP unhealthy
|
||||||
// Don't have prompt in flight (no prompt_sent_at) to avoid matching PromptAcceptanceTimeout
|
// Don't have prompt in flight (no prompt_sent_at) to avoid matching PromptAcceptanceTimeout
|
||||||
let evidence = StartupEvidenceBundle {
|
let evidence = StartupEvidenceBundle {
|
||||||
last_lifecycle_state: WorkerStatus::Spawning,
|
last_lifecycle_state: WorkerStatus::Spawning,
|
||||||
|
last_lifecycle_at: 10,
|
||||||
pane_command: "test".to_string(),
|
pane_command: "test".to_string(),
|
||||||
|
pane_observed_at: 40,
|
||||||
|
command_started_at: 1,
|
||||||
prompt_sent_at: None, // No prompt sent yet
|
prompt_sent_at: None, // No prompt sent yet
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: 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_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,
|
elapsed_seconds: 45,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
81
rust/crates/runtime/tests/fixtures/g004_contract_bundle.valid.json
vendored
Normal file
81
rust/crates/runtime/tests/fixtures/g004_contract_bundle.valid.json
vendored
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
rust/crates/runtime/tests/fixtures/report_schema_v1/README.md
vendored
Normal file
11
rust/crates/runtime/tests/fixtures/report_schema_v1/README.md
vendored
Normal file
@@ -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
|
||||||
80
rust/crates/runtime/tests/g004_conformance.rs
Normal file
80
rust/crates/runtime/tests/g004_conformance.rs
Normal file
@@ -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(
|
let stale_context = LaneContext::new(
|
||||||
"stale-lane",
|
"stale-lane",
|
||||||
0,
|
0,
|
||||||
Duration::from_secs(2 * 60 * 60), // 2 hours stale
|
Duration::from_hours(2), // 2 hours stale
|
||||||
LaneBlocker::None,
|
LaneBlocker::None,
|
||||||
ReviewStatus::Pending,
|
ReviewStatus::Pending,
|
||||||
DiffScope::Full,
|
DiffScope::Full,
|
||||||
@@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
|
|||||||
let fresh_context = LaneContext::new(
|
let fresh_context = LaneContext::new(
|
||||||
"fresh-lane",
|
"fresh-lane",
|
||||||
0,
|
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,
|
LaneBlocker::None,
|
||||||
ReviewStatus::Pending,
|
ReviewStatus::Pending,
|
||||||
DiffScope::Full,
|
DiffScope::Full,
|
||||||
@@ -96,9 +96,7 @@ fn green_contract_unsatisfied_blocks_merge() {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is a conceptual test — we need a way to express "requires workspace green"
|
// The context has a test level but lacks the full green contract, so merge stays blocked.
|
||||||
// Currently LaneContext has raw green_level: u8, not a contract
|
|
||||||
// For now we just verify the policy condition works
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
"workspace-green-required",
|
"workspace-green-required",
|
||||||
PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
|
PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
|
||||||
@@ -212,8 +210,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
|||||||
// when: build context and evaluate policy
|
// when: build context and evaluate policy
|
||||||
let context = LaneContext::new(
|
let context = LaneContext::new(
|
||||||
"lane-9411",
|
"lane-9411",
|
||||||
3, // Workspace green
|
3, // Workspace green
|
||||||
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
|
Duration::from_hours(5), // 5 hours stale, definitely over threshold
|
||||||
LaneBlocker::None,
|
LaneBlocker::None,
|
||||||
ReviewStatus::Approved,
|
ReviewStatus::Approved,
|
||||||
DiffScope::Scoped,
|
DiffScope::Scoped,
|
||||||
@@ -261,13 +259,14 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
|||||||
fn fresh_approved_lane_gets_merge_action() {
|
fn fresh_approved_lane_gets_merge_action() {
|
||||||
let context = LaneContext::new(
|
let context = LaneContext::new(
|
||||||
"fresh-approved-lane",
|
"fresh-approved-lane",
|
||||||
3, // Workspace green
|
3, // Workspace green
|
||||||
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
|
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh
|
||||||
LaneBlocker::None,
|
LaneBlocker::None,
|
||||||
ReviewStatus::Approved,
|
ReviewStatus::Approved,
|
||||||
DiffScope::Scoped,
|
DiffScope::Scoped,
|
||||||
false,
|
false,
|
||||||
);
|
)
|
||||||
|
.with_green_contract_satisfied(true);
|
||||||
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||||
"merge-if-green-approved-not-stale",
|
"merge-if-green-approved-not-stale",
|
||||||
@@ -347,7 +346,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
|||||||
// (Simulating the policy check that would happen after successful recovery)
|
// (Simulating the policy check that would happen after successful recovery)
|
||||||
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
|
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
|
||||||
let green_level = 3; // Workspace green
|
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(
|
let post_recovery_context = LaneContext::new(
|
||||||
"recovered-lane",
|
"recovered-lane",
|
||||||
@@ -357,7 +356,8 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
|||||||
ReviewStatus::Approved,
|
ReviewStatus::Approved,
|
||||||
DiffScope::Scoped,
|
DiffScope::Scoped,
|
||||||
false,
|
false,
|
||||||
);
|
)
|
||||||
|
.with_green_contract_satisfied(true);
|
||||||
|
|
||||||
let policy_engine = PolicyEngine::new(vec![
|
let policy_engine = PolicyEngine::new(vec![
|
||||||
// Rule: if recovered from failure + green + approved -> merge
|
// Rule: if recovered from failure + green + approved -> merge
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -126,6 +126,66 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
|
|||||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||||
|
// given a workspace pointed at the mock Anthropic service running the
|
||||||
|
// streaming_text scenario which only emits a single assistant text block
|
||||||
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
|
let server = runtime
|
||||||
|
.block_on(MockAnthropicService::spawn())
|
||||||
|
.expect("mock service should start");
|
||||||
|
let base_url = server.base_url();
|
||||||
|
|
||||||
|
let workspace = unique_temp_dir("text-prompt-mode");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
// when we invoke claw in normal text prompt mode for the streaming text scenario
|
||||||
|
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||||
|
let output = run_claw(
|
||||||
|
&workspace,
|
||||||
|
&config_home,
|
||||||
|
&home,
|
||||||
|
&base_url,
|
||||||
|
&[
|
||||||
|
"--model",
|
||||||
|
"sonnet",
|
||||||
|
"--permission-mode",
|
||||||
|
"read-only",
|
||||||
|
&prompt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// then stdout should contain the final assistant text, not just spinner output
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
|
let plain_stdout = strip_ansi_codes(&stdout);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("Mock streaming says hello from the parity harness."),
|
||||||
|
"text prompt stdout should include the assistant text ({stdout:?})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("✔ ✨ Done"),
|
||||||
|
"text prompt stdout should still include spinner completion ({stdout:?})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain_stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line == "Mock streaming says hello from the parity harness."),
|
||||||
|
"text prompt stdout should print the assistant text as its own line ({stdout:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_flag_with_json_output_emits_structured_json() {
|
fn compact_flag_with_json_output_emits_structured_json() {
|
||||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
@@ -172,7 +232,10 @@ stderr:
|
|||||||
);
|
);
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
||||||
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness.");
|
assert_eq!(
|
||||||
|
parsed["message"],
|
||||||
|
"Mock streaming says hello from the parity harness."
|
||||||
|
);
|
||||||
assert_eq!(parsed["compact"], true);
|
assert_eq!(parsed["compact"], true);
|
||||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||||
assert!(parsed["usage"].is_object());
|
assert!(parsed["usage"].is_object());
|
||||||
@@ -212,3 +275,21 @@ fn unique_temp_dir(label: &str) -> PathBuf {
|
|||||||
std::process::id()
|
std::process::id()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strip_ansi_codes(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||||
|
chars.next();
|
||||||
|
while let Some(next) = chars.next() {
|
||||||
|
if ('@'..='~').contains(&next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|||||||
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Output, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_slash_command_in_repl_does_not_start_nested_tokio_runtime() {
|
||||||
|
// given
|
||||||
|
let workspace = unique_temp_dir("compact-repl-panic");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let output = run_claw_repl(&workspace, &config_home, &home, "/compact\n/exit\n");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"compact repl run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("Cannot start a runtime"),
|
||||||
|
"stderr must not contain nested runtime panic: {stderr:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("panicked at"),
|
||||||
|
"stderr must not contain panic output: {stderr:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
|
let plain_stdout = strip_ansi_codes(&stdout);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("Compaction skipped")
|
||||||
|
|| plain_stdout.contains("Result skipped")
|
||||||
|
|| plain_stdout.contains("Result compacted"),
|
||||||
|
"stdout should contain compact report output ({stdout:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_claw_repl(
|
||||||
|
cwd: &std::path::Path,
|
||||||
|
config_home: &std::path::Path,
|
||||||
|
home: &std::path::Path,
|
||||||
|
stdin: &str,
|
||||||
|
) -> Output {
|
||||||
|
let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw"));
|
||||||
|
let mut child = command
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env_clear()
|
||||||
|
.env("ANTHROPIC_API_KEY", "test-compact-repl-key")
|
||||||
|
.env("CLAW_CONFIG_HOME", config_home)
|
||||||
|
.env("HOME", home)
|
||||||
|
.env("NO_COLOR", "1")
|
||||||
|
.env("PATH", "/usr/bin:/bin")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("claw should launch");
|
||||||
|
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.expect("stdin should be piped")
|
||||||
|
.write_all(stdin.as_bytes())
|
||||||
|
.expect("stdin should write");
|
||||||
|
|
||||||
|
child.wait_with_output().expect("claw should finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn python_pty_command(claw: &str) -> Command {
|
||||||
|
let mut command = Command::new("python3");
|
||||||
|
command.args([
|
||||||
|
"-c",
|
||||||
|
r#"
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
claw = sys.argv[1]
|
||||||
|
payload = sys.stdin.buffer.read()
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
os.close(slave)
|
||||||
|
os.write(master, payload)
|
||||||
|
stdout, stderr = child.communicate(timeout=30)
|
||||||
|
os.close(master)
|
||||||
|
sys.stdout.buffer.write(stdout)
|
||||||
|
sys.stderr.buffer.write(stderr)
|
||||||
|
raise SystemExit(child.returncode)
|
||||||
|
"#,
|
||||||
|
claw,
|
||||||
|
]);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock should be after epoch")
|
||||||
|
.as_millis();
|
||||||
|
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"claw-{label}-{}-{millis}-{counter}",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_ansi_codes(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||||
|
chars.next();
|
||||||
|
for next in chars.by_ref() {
|
||||||
|
if ('@'..='~').contains(&next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
@@ -22,6 +22,42 @@ fn help_emits_json_when_requested() {
|
|||||||
.contains("Usage:"));
|
.contains("Usage:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_help_emits_bounded_json_when_requested_384() {
|
||||||
|
let root = unique_temp_dir("export-help-json");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
|
||||||
|
assert_eq!(parsed["kind"], "help");
|
||||||
|
assert_eq!(parsed["topic"], "export");
|
||||||
|
assert_eq!(parsed["command"], "export");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["usage"],
|
||||||
|
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed["defaults"]["session"], "latest");
|
||||||
|
assert!(parsed["options"].as_array().expect("options").len() >= 4);
|
||||||
|
assert!(parsed.get("message").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_help_preserves_plaintext_in_text_mode_384() {
|
||||||
|
let root = unique_temp_dir("export-help-text");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
let output = run_claw(&root, &["export", "--help"], &[]);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"stdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||||
|
assert!(stdout.starts_with("Export\n"));
|
||||||
|
assert!(stdout.contains("Usage claw export"));
|
||||||
|
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_emits_json_when_requested() {
|
fn version_emits_json_when_requested() {
|
||||||
let root = unique_temp_dir("version-json");
|
let root = unique_temp_dir("version-json");
|
||||||
@@ -30,6 +66,15 @@ fn version_emits_json_when_requested() {
|
|||||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||||
assert_eq!(parsed["kind"], "version");
|
assert_eq!(parsed["kind"], "version");
|
||||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||||
|
// Provenance fields must be present for binary identification (#507).
|
||||||
|
assert!(
|
||||||
|
parsed["build_date"].is_string(),
|
||||||
|
"build_date must be a string in version JSON"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed["executable_path"].is_string(),
|
||||||
|
"executable_path must be a string in version JSON so callers can identify which binary is running"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -46,6 +91,32 @@ fn status_and_sandbox_emit_json_when_requested() {
|
|||||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
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]
|
#[test]
|
||||||
fn acp_guidance_emits_json_when_requested() {
|
fn acp_guidance_emits_json_when_requested() {
|
||||||
let root = unique_temp_dir("acp-json");
|
let root = unique_temp_dir("acp-json");
|
||||||
@@ -105,6 +176,18 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||||
assert_eq!(skills["kind"], "skills");
|
assert_eq!(skills["kind"], "skills");
|
||||||
assert_eq!(skills["action"], "list");
|
assert_eq!(skills["action"], "list");
|
||||||
|
|
||||||
|
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
|
||||||
|
assert_eq!(plugins["kind"], "plugin");
|
||||||
|
assert_eq!(plugins["action"], "list");
|
||||||
|
assert!(
|
||||||
|
plugins["reload_runtime"].is_boolean(),
|
||||||
|
"plugins reload_runtime should be a boolean"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["target"].is_null(),
|
||||||
|
"plugins target should be null when no plugin is targeted"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -227,7 +310,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(summary["failures"].as_u64().is_some());
|
assert!(summary["failures"].as_u64().is_some());
|
||||||
|
|
||||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||||
assert_eq!(checks.len(), 6);
|
assert_eq!(checks.len(), 7);
|
||||||
let check_names = checks
|
let check_names = checks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|check| {
|
.map(|check| {
|
||||||
@@ -244,6 +327,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
"config",
|
"config",
|
||||||
"install source",
|
"install source",
|
||||||
"workspace",
|
"workspace",
|
||||||
|
"boot preflight",
|
||||||
"sandbox",
|
"sandbox",
|
||||||
"system"
|
"system"
|
||||||
]
|
]
|
||||||
@@ -269,6 +353,14 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(workspace["cwd"].as_str().is_some());
|
assert!(workspace["cwd"].as_str().is_some());
|
||||||
assert!(workspace["in_git_repo"].is_boolean());
|
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
|
let sandbox = checks
|
||||||
.iter()
|
.iter()
|
||||||
.find(|check| check["name"] == "sandbox")
|
.find(|check| check["name"] == "sandbox")
|
||||||
@@ -348,6 +440,62 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(skills["action"], "list");
|
assert_eq!(skills["action"], "list");
|
||||||
assert!(skills["summary"]["total"].is_number());
|
assert!(skills["summary"]["total"].is_number());
|
||||||
assert!(skills["skills"].is_array());
|
assert!(skills["skills"].is_array());
|
||||||
|
|
||||||
|
let agents = assert_json_command_with_env(
|
||||||
|
&root,
|
||||||
|
&[
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--resume",
|
||||||
|
session_path.to_str().expect("utf8 session path"),
|
||||||
|
"/agents",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(agents["kind"], "agents");
|
||||||
|
assert_eq!(agents["action"], "list");
|
||||||
|
assert!(
|
||||||
|
agents["agents"].is_array(),
|
||||||
|
"agents field must be a JSON array"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
agents["count"].is_number(),
|
||||||
|
"count must be a number, not a text render"
|
||||||
|
);
|
||||||
|
|
||||||
|
let plugins = assert_json_command_with_env(
|
||||||
|
&root,
|
||||||
|
&[
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--resume",
|
||||||
|
session_path.to_str().expect("utf8 session path"),
|
||||||
|
"/plugins",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(plugins["kind"], "plugin");
|
||||||
|
assert_eq!(plugins["action"], "list");
|
||||||
|
assert!(
|
||||||
|
plugins["reload_runtime"].is_boolean(),
|
||||||
|
"plugins reload_runtime should be a boolean"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["target"].is_null(),
|
||||||
|
"plugins target should be null when no plugin is targeted"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -384,6 +532,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
|||||||
assert!(root.join("CLAUDE.md").exists());
|
assert!(root.join("CLAUDE.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_section_json_emits_section_and_value() {
|
||||||
|
let root = unique_temp_dir("config-section-json");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
// Without a section: should return base envelope (no section field).
|
||||||
|
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
|
||||||
|
assert_eq!(base["kind"], "config");
|
||||||
|
assert!(base["loaded_files"].is_number());
|
||||||
|
assert!(base["merged_keys"].is_number());
|
||||||
|
assert!(
|
||||||
|
base.get("section").is_none(),
|
||||||
|
"no section field without section arg"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With a known section: should add section + section_value fields.
|
||||||
|
for section in &["model", "env", "hooks", "plugins"] {
|
||||||
|
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
|
||||||
|
assert_eq!(result["kind"], "config", "section={section}");
|
||||||
|
assert_eq!(
|
||||||
|
result["section"].as_str(),
|
||||||
|
Some(*section),
|
||||||
|
"section field must match requested section, got {result:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.get("section_value").is_some(),
|
||||||
|
"section_value field must be present for section={section}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With an unsupported section: should return ok:false + error field.
|
||||||
|
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
|
||||||
|
assert_eq!(bad["kind"], "config");
|
||||||
|
assert_eq!(bad["ok"], false);
|
||||||
|
assert!(bad["error"].as_str().is_some());
|
||||||
|
assert!(bad["section"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||||
assert_json_command_with_env(current_dir, args, &[])
|
assert_json_command_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
// given
|
// given
|
||||||
let temp_dir = unique_temp_dir("resume-status-json");
|
let temp_dir = unique_temp_dir("resume-status-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let config_home = temp_dir.join("config-home");
|
||||||
|
fs::create_dir_all(&config_home).expect("isolated config home should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
let mut session = workspace_session(&temp_dir);
|
||||||
@@ -238,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let output = run_claw(
|
// Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded,
|
||||||
|
// which would cause loaded_config_files to be non-zero (#65).
|
||||||
|
let output = run_claw_with_env(
|
||||||
&temp_dir,
|
&temp_dir,
|
||||||
&[
|
&[
|
||||||
"--output-format",
|
"--output-format",
|
||||||
@@ -247,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
session_path.to_str().expect("utf8 path"),
|
session_path.to_str().expect("utf8 path"),
|
||||||
"/status",
|
"/status",
|
||||||
],
|
],
|
||||||
|
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub(crate) fn detect_lane_completion(
|
|||||||
Some(LaneContext {
|
Some(LaneContext {
|
||||||
lane_id: output.agent_id.clone(),
|
lane_id: output.agent_id.clone(),
|
||||||
green_level: 3, // Workspace green
|
green_level: 3, // Workspace green
|
||||||
|
green_contract_satisfied: true,
|
||||||
branch_freshness: std::time::Duration::from_secs(0),
|
branch_freshness: std::time::Duration::from_secs(0),
|
||||||
blocker: LaneBlocker::None,
|
blocker: LaneBlocker::None,
|
||||||
review_status: ReviewStatus::Approved,
|
review_status: ReviewStatus::Approved,
|
||||||
@@ -165,6 +166,7 @@ mod tests {
|
|||||||
let context = LaneContext {
|
let context = LaneContext {
|
||||||
lane_id: "completed-lane".to_string(),
|
lane_id: "completed-lane".to_string(),
|
||||||
green_level: 3,
|
green_level: 3,
|
||||||
|
green_contract_satisfied: true,
|
||||||
branch_freshness: std::time::Duration::from_secs(0),
|
branch_freshness: std::time::Duration::from_secs(0),
|
||||||
blocker: LaneBlocker::None,
|
blocker: LaneBlocker::None,
|
||||||
review_status: ReviewStatus::Approved,
|
review_status: ReviewStatus::Approved,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
205
rust/crates/tools/tests/path_scope_enforcement.rs
Normal file
205
rust/crates/tools/tests/path_scope_enforcement.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
scripts/cc2_board.py
Executable file
54
scripts/cc2_board.py
Executable file
@@ -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())
|
||||||
68
scripts/dogfood-build.sh
Executable file
68
scripts/dogfood-build.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# dogfood-build.sh — Build claw from current checkout and verify provenance.
|
||||||
|
#
|
||||||
|
# Injects GIT_SHA at build time so version JSON is non-null.
|
||||||
|
# Suppresses Cargo compile noise on stderr.
|
||||||
|
# Prints the verified binary path on success. Use as:
|
||||||
|
#
|
||||||
|
# CLAW=$(bash scripts/dogfood-build.sh)
|
||||||
|
#
|
||||||
|
# Then dogfood with config isolation (avoids real user config bleeding in):
|
||||||
|
#
|
||||||
|
# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
RUST_DIR="$REPO_ROOT/rust"
|
||||||
|
BINARY="$RUST_DIR/target/debug/claw"
|
||||||
|
EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
echo "▶ Building claw from $REPO_ROOT" >&2
|
||||||
|
echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2
|
||||||
|
|
||||||
|
# Inject GIT_SHA so version JSON returns a non-null sha.
|
||||||
|
# Redirect cargo stderr to /dev/null to suppress compile noise;
|
||||||
|
# on build failure cargo exits non-zero and set -e aborts.
|
||||||
|
if ! GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||||
|
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||||
|
-p rusty-claude-cli -q 2>/dev/null; then
|
||||||
|
# Re-run with visible output so the user sees the error
|
||||||
|
echo "✗ Build failed — rerunning with output:" >&2
|
||||||
|
GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||||
|
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||||
|
-p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$BINARY" ]]; then
|
||||||
|
echo "✗ Binary not found at $BINARY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \
|
||||||
|
|| echo "null")
|
||||||
|
|
||||||
|
if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then
|
||||||
|
echo "✗ Provenance check failed: binary reports git_sha: null" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then
|
||||||
|
echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " export CLAW=$BINARY" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Dogfood with isolated config (no real user config on stderr):" >&2
|
||||||
|
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
|
||||||
|
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
|
||||||
|
echo " rm -rf \$CLAW_ISOLATED" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
|
||||||
|
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
|
||||||
|
echo "$BINARY"
|
||||||
7
scripts/fmt.sh
Executable file
7
scripts/fmt.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT/rust"
|
||||||
|
exec cargo fmt "$@"
|
||||||
525
scripts/generate_cc2_board.py
Executable file
525
scripts/generate_cc2_board.py
Executable file
@@ -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())
|
||||||
87
scripts/validate_cc2_board.py
Executable file
87
scripts/validate_cc2_board.py
Executable file
@@ -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:
|
class PermissionDenial:
|
||||||
tool_name: str
|
tool_name: str
|
||||||
reason: str
|
reason: str
|
||||||
|
status: str = 'blocked'
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
150
src/path_scope.py
Normal file
150
src/path_scope.py
Normal file
@@ -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
|
||||||
@@ -1,20 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .path_scope import PathScopeDecision, WorkspacePathScope
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ToolPermissionContext:
|
class ToolPermissionContext:
|
||||||
deny_names: frozenset[str] = field(default_factory=frozenset)
|
deny_names: frozenset[str] = field(default_factory=frozenset)
|
||||||
deny_prefixes: tuple[str, ...] = ()
|
deny_prefixes: tuple[str, ...] = ()
|
||||||
|
workspace_scope: WorkspacePathScope | None = None
|
||||||
|
cwd: Path | None = None
|
||||||
|
|
||||||
@classmethod
|
@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(
|
return cls(
|
||||||
deny_names=frozenset(name.lower() for name in (deny_names or [])),
|
deny_names=frozenset(name.lower() for name in (deny_names or [])),
|
||||||
deny_prefixes=tuple(prefix.lower() for prefix in (deny_prefixes 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:
|
def blocks(self, tool_name: str) -> bool:
|
||||||
lowered = tool_name.lower()
|
lowered = tool_name.lower()
|
||||||
return lowered in self.deny_names or any(lowered.startswith(prefix) for prefix in self.deny_prefixes)
|
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'))
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class QueryEnginePort:
|
|||||||
f'Matched commands: {", ".join(matched_commands) if matched_commands else "none"}',
|
f'Matched commands: {", ".join(matched_commands) if matched_commands else "none"}',
|
||||||
f'Matched tools: {", ".join(matched_tools) if matched_tools else "none"}',
|
f'Matched tools: {", ".join(matched_tools) if matched_tools else "none"}',
|
||||||
f'Permission denials: {len(denied_tools)}',
|
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)
|
output = self._format_output(summary_lines)
|
||||||
projected_usage = self.total_usage.add_turn(prompt, output)
|
projected_usage = self.total_usage.add_turn(prompt, output)
|
||||||
@@ -116,7 +117,13 @@ class QueryEnginePort:
|
|||||||
if matched_tools:
|
if matched_tools:
|
||||||
yield {'type': 'tool_match', 'tools': matched_tools}
|
yield {'type': 'tool_match', 'tools': matched_tools}
|
||||||
if denied_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)
|
result = self.submit_message(prompt, matched_commands, matched_tools, denied_tools)
|
||||||
yield {'type': 'message_delta', 'text': result.output}
|
yield {'type': 'message_delta', 'text': result.output}
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
17
src/tools.py
17
src/tools.py
@@ -78,10 +78,25 @@ def find_tools(query: str, limit: int = 20) -> list[PortingModule]:
|
|||||||
return matches[:limit]
|
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)
|
module = get_tool(name)
|
||||||
if module is None:
|
if module is None:
|
||||||
return ToolExecution(name=name, source_hint='', payload=payload, handled=False, message=f'Unknown mirrored tool: {name}')
|
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}."
|
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)
|
return ToolExecution(name=module.name, source_hint=module.source_hint, payload=payload, handled=True, message=action)
|
||||||
|
|
||||||
|
|||||||
135
tests/test_security_scope.py
Normal file
135
tests/test_security_scope.py
Normal file
@@ -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