umbra-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
umbra_core/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """umbra-core — an agent-agnostic change-control plane for coding agents."""
2
+ from .executors.base import ExecutionResult, Executor
3
+ from .executors.claude_code import ClaudeCodeExecutor
4
+ from .executors.codex import CodexExecutor
5
+ from .executors.null import NullExecutor
6
+ from .executors.registry import (
7
+ available_executors,
8
+ get_executor,
9
+ resolve_available,
10
+ )
11
+ from .pipeline import (
12
+ AUTHORITY,
13
+ AUTHORITY_LABEL,
14
+ AdmissionReport,
15
+ ChecksReport,
16
+ Contract,
17
+ ContractResult,
18
+ InMemoryLogStore,
19
+ InMemoryPassportStore,
20
+ JsonFileLogStore,
21
+ JsonFilePassportStore,
22
+ PassportError,
23
+ PassportStatus,
24
+ TransparencyLog,
25
+ TrustBoundaryResult,
26
+ VerifierReport,
27
+ build_receipt,
28
+ default_contract,
29
+ evaluate_contract,
30
+ evaluate_passport,
31
+ gate_pr,
32
+ issue_passport,
33
+ load_contract,
34
+ merkle_root,
35
+ public_key_b64,
36
+ revoke,
37
+ run_admission,
38
+ run_required_checks,
39
+ sanitize_checkout,
40
+ scan_repository_text,
41
+ sign,
42
+ to_slsa_provenance,
43
+ verify_change,
44
+ verify_inclusion,
45
+ verify_receipt,
46
+ verify_signature,
47
+ )
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [
52
+ # executors
53
+ "Executor",
54
+ "ExecutionResult",
55
+ "CodexExecutor",
56
+ "ClaudeCodeExecutor",
57
+ "NullExecutor",
58
+ "available_executors",
59
+ "get_executor",
60
+ "resolve_available",
61
+ # pipeline
62
+ "run_admission",
63
+ "AdmissionReport",
64
+ "AUTHORITY",
65
+ "AUTHORITY_LABEL",
66
+ "Contract",
67
+ "ContractResult",
68
+ "default_contract",
69
+ "evaluate_contract",
70
+ "load_contract",
71
+ "ChecksReport",
72
+ "run_required_checks",
73
+ "TrustBoundaryResult",
74
+ "scan_repository_text",
75
+ "sanitize_checkout",
76
+ "VerifierReport",
77
+ "verify_change",
78
+ "build_receipt",
79
+ "verify_receipt",
80
+ "verify_signature",
81
+ "sign",
82
+ "public_key_b64",
83
+ # passport / emergency brake
84
+ "issue_passport",
85
+ "gate_pr",
86
+ "revoke",
87
+ "evaluate_passport",
88
+ "PassportStatus",
89
+ "PassportError",
90
+ "InMemoryPassportStore",
91
+ "JsonFilePassportStore",
92
+ # SLSA / in-toto provenance
93
+ "to_slsa_provenance",
94
+ # transparency log
95
+ "TransparencyLog",
96
+ "InMemoryLogStore",
97
+ "JsonFileLogStore",
98
+ "merkle_root",
99
+ "verify_inclusion",
100
+ "__version__",
101
+ ]
umbra_core/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """The ``umbra`` command-line interface.
2
+
3
+ One entry point over the agent-agnostic core, so the same governance runs on a
4
+ developer's machine, in a git hook, and in CI:
5
+
6
+ umbra admit <repo> --agent claude-code --mission "..." # govern an agent's change
7
+ umbra verify <receipt.json> # verify a signed receipt
8
+ umbra brake <owner> <repo> --store passports.json # Emergency Brake -> L0
9
+ umbra provenance <receipt.json> # emit SLSA/in-toto statement
10
+
11
+ ``admit`` exits non-zero unless the run earns branch-PR authority (L2), so it
12
+ gates a pre-push hook or a CI required check. ``--min-authority`` tunes the bar.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import (
23
+ JsonFilePassportStore,
24
+ build_receipt,
25
+ get_executor,
26
+ issue_passport,
27
+ resolve_available,
28
+ revoke,
29
+ run_admission,
30
+ to_slsa_provenance,
31
+ verify_receipt,
32
+ )
33
+
34
+
35
+ def _print(obj: Any, as_json: bool) -> None:
36
+ if as_json:
37
+ print(json.dumps(obj, indent=2, default=str))
38
+
39
+
40
+ def _receipt_from_report(report) -> dict[str, Any]:
41
+ return build_receipt(
42
+ repo=report.repo, base_commit=report.base_commit, contract=report.contract,
43
+ contract_result=report.contract_result, verifier=report.verifier,
44
+ trust_boundary=report.trust_boundary, proposed_change=report.proposed_change,
45
+ providers=report.providers, authority_level=report.authority_level,
46
+ authority=report.authority, executor=report.executor, diff=report.diff,
47
+ checks=report.checks, baseline_checks=report.baseline_checks,
48
+ check_diagnosis=report.check_diagnosis, model_identity=report.model_identity,
49
+ context_manifest=report.context_manifest, outcome=report.outcome,
50
+ )
51
+
52
+
53
+ def cmd_admit(args: argparse.Namespace) -> int:
54
+ repo_path = Path(args.repo).resolve()
55
+ if not repo_path.is_dir():
56
+ print(f"error: {repo_path} is not a directory", file=sys.stderr)
57
+ return 2
58
+
59
+ # Resolve the executor: explicit --agent, else first available, else fail clearly.
60
+ if args.agent:
61
+ try:
62
+ executor = get_executor(args.agent)
63
+ except ValueError as exc:
64
+ print(f"error: {exc}", file=sys.stderr)
65
+ return 2
66
+ if not executor.available():
67
+ print(
68
+ f"error: agent {args.agent!r} is not available. Enable + authenticate it "
69
+ f"(e.g. UMBRA_ENABLE_CLAUDE_CODE=true / UMBRA_ENABLE_CODEX_CLI=true).",
70
+ file=sys.stderr,
71
+ )
72
+ return 2
73
+ else:
74
+ executor = resolve_available(args.prefer.split(",") if args.prefer else None)
75
+ if executor is None:
76
+ print(
77
+ "error: no coding agent is available. Enable one with "
78
+ "UMBRA_ENABLE_CLAUDE_CODE=true or UMBRA_ENABLE_CODEX_CLI=true, "
79
+ "or pass --agent.",
80
+ file=sys.stderr,
81
+ )
82
+ return 2
83
+
84
+ report = run_admission(repo_path, args.label or repo_path.name, args.mission, executor)
85
+ envelope = _receipt_from_report(report)
86
+
87
+ # Persist the earned-authority passport when a store is given.
88
+ if args.store:
89
+ store = JsonFilePassportStore(args.store)
90
+ store.save(args.owner, report.repo, issue_passport(report, receipt_hash=envelope["canonical_hash"]))
91
+
92
+ if args.receipt_out:
93
+ Path(args.receipt_out).write_text(json.dumps(envelope, indent=2, default=str))
94
+
95
+ payload = {"report": report.to_public(), "receipt": envelope}
96
+ if args.json:
97
+ _print(payload, True)
98
+ else:
99
+ print(f"repo : {report.repo}")
100
+ print(f"agent : {report.executor}")
101
+ print(f"changed : {', '.join(report.changed_files) or '(none)'}")
102
+ print(f"contract : {'PASS' if report.contract_result['passed'] else 'VIOLATED'}")
103
+ tb = report.trust_boundary
104
+ print(f"trust bound.: {'clean' if tb['clean'] else str(tb['quarantined_count']) + ' line(s) quarantined'}")
105
+ if report.verifier:
106
+ print(f"verifier : {'BLOCKED' if report.verifier['blocked'] else 'reviewable'}")
107
+ checks = report.checks or {}
108
+ print(f"checks : ran={checks.get('ran')} all_passed={checks.get('all_passed')} enforcement={checks.get('enforcement')}")
109
+ print(f"authority : L{report.authority_level} ({report.authority})")
110
+ print(f"outcome : {report.outcome}")
111
+ print(f"receipt : {envelope['canonical_hash']} (key_ephemeral={envelope['key_ephemeral']})")
112
+
113
+ # Exit code gates hooks/CI: non-zero unless the run met the authority bar.
114
+ return 0 if report.authority_level >= args.min_authority else 1
115
+
116
+
117
+ def cmd_verify(args: argparse.Namespace) -> int:
118
+ try:
119
+ envelope = json.loads(Path(args.receipt).read_text())
120
+ except (OSError, json.JSONDecodeError) as exc:
121
+ print(f"error: cannot read receipt: {exc}", file=sys.stderr)
122
+ return 2
123
+ result = verify_receipt(envelope, expected_public_key=args.public_key)
124
+ if args.json:
125
+ _print(result, True)
126
+ else:
127
+ ok = result["verified"]
128
+ print(("VERIFIED" if ok else "NOT VERIFIED") + f" (issued_by_umbra={result['issued_by_umbra']}, hash_matches={result['hash_matches']})")
129
+ return 0 if result["verified"] else 1
130
+
131
+
132
+ def cmd_brake(args: argparse.Namespace) -> int:
133
+ store = JsonFilePassportStore(args.store)
134
+ rec = revoke(store, args.owner, args.repo, reason=args.reason)
135
+ if args.json:
136
+ _print(rec, True)
137
+ else:
138
+ print(f"Emergency Brake applied: {args.owner}/{args.repo} -> L{rec['authority_level']} ({rec['authority']})")
139
+ return 0
140
+
141
+
142
+ def cmd_provenance(args: argparse.Namespace) -> int:
143
+ try:
144
+ envelope = json.loads(Path(args.receipt).read_text())
145
+ except (OSError, json.JSONDecodeError) as exc:
146
+ print(f"error: cannot read receipt: {exc}", file=sys.stderr)
147
+ return 2
148
+ stmt = to_slsa_provenance(envelope)
149
+ print(json.dumps(stmt, indent=2, default=str))
150
+ return 0
151
+
152
+
153
+ def build_parser() -> argparse.ArgumentParser:
154
+ parser = argparse.ArgumentParser(prog="umbra", description="Agent-agnostic change-control plane for coding agents.")
155
+ parser.add_argument("--json", action="store_true", help="Machine-readable JSON output.")
156
+ sub = parser.add_subparsers(dest="command", required=True)
157
+
158
+ p_admit = sub.add_parser("admit", help="Run the admission pipeline: govern an agent's change to a repo.")
159
+ p_admit.add_argument("repo", help="Path to a git checkout to run the agent in.")
160
+ p_admit.add_argument("--mission", required=True, help="The bounded task handed to the agent.")
161
+ p_admit.add_argument("--agent", help="Force a specific agent (e.g. codex-cli, claude-code, or any registered executor).")
162
+ p_admit.add_argument("--prefer", help="Comma-separated preference order when auto-selecting (e.g. 'claude-code,codex-cli').")
163
+ p_admit.add_argument("--label", help="Repo label for the receipt (defaults to the directory name).")
164
+ p_admit.add_argument("--owner", default="local", help="Owner key for the passport store.")
165
+ p_admit.add_argument("--store", help="Path to a JSON passport store to persist the earned authority.")
166
+ p_admit.add_argument("--receipt-out", help="Write the signed receipt envelope to this file.")
167
+ p_admit.add_argument("--min-authority", type=int, default=2, help="Exit non-zero unless the run earns at least this level (default 2 = branch-PR).")
168
+ p_admit.set_defaults(func=cmd_admit)
169
+
170
+ p_verify = sub.add_parser("verify", help="Verify a signed receipt against a pinned public key.")
171
+ p_verify.add_argument("receipt", help="Path to a receipt envelope JSON file.")
172
+ p_verify.add_argument("--public-key", help="Base64 Ed25519 public key to verify against (defaults to this instance's key).")
173
+ p_verify.set_defaults(func=cmd_verify)
174
+
175
+ p_brake = sub.add_parser("brake", help="Emergency Brake: revoke a repo's earned authority to Level 0.")
176
+ p_brake.add_argument("owner", help="Owner key.")
177
+ p_brake.add_argument("repo", help="Repo label.")
178
+ p_brake.add_argument("--store", required=True, help="Path to the JSON passport store.")
179
+ p_brake.add_argument("--reason", help="Reason recorded with the revocation.")
180
+ p_brake.set_defaults(func=cmd_brake)
181
+
182
+ p_prov = sub.add_parser("provenance", help="Emit an in-toto/SLSA provenance statement for a receipt.")
183
+ p_prov.add_argument("receipt", help="Path to a receipt envelope JSON file.")
184
+ p_prov.set_defaults(func=cmd_provenance)
185
+
186
+ return parser
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ parser = build_parser()
191
+ args = parser.parse_args(argv)
192
+ # Thread the top-level --json down to subcommands.
193
+ if not hasattr(args, "json"):
194
+ args.json = False
195
+ return args.func(args)
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ from .base import ExecutionResult, Executor
2
+ from .claude_code import ClaudeCodeExecutor
3
+ from .codex import CodexExecutor
4
+ from .null import NullExecutor
5
+ from .registry import available_executors, get_executor, resolve_available
6
+
7
+ __all__ = [
8
+ "Executor",
9
+ "ExecutionResult",
10
+ "CodexExecutor",
11
+ "ClaudeCodeExecutor",
12
+ "NullExecutor",
13
+ "available_executors",
14
+ "get_executor",
15
+ "resolve_available",
16
+ ]
@@ -0,0 +1,67 @@
1
+ """Shared, side-effect-free helpers for executor adapters."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
10
+
11
+
12
+ def git(repo_path: Path, args: list[str]) -> str:
13
+ """Run a read-only git command in the checkout and return stdout."""
14
+ result = subprocess.run(["git", *args], cwd=repo_path, text=True, capture_output=True, check=False)
15
+ return result.stdout
16
+
17
+
18
+ def changed_files(repo_path: Path) -> list[str]:
19
+ return [line for line in git(repo_path, ["diff", "--name-only"]).splitlines() if line]
20
+
21
+
22
+ def unified_diff(repo_path: Path) -> str:
23
+ return git(repo_path, ["diff", "--binary"])
24
+
25
+
26
+ def sanitize_paths(text: str, repo_path: Path) -> str:
27
+ """Strip the disposable checkout's absolute path out of agent prose.
28
+
29
+ Agents freely embed the absolute temp-dir path in explanations; left raw it
30
+ leaks the host filesystem layout into user-facing reports. The git diff
31
+ already uses repo-relative ``a/…``/``b/…`` prefixes, so only free text needs
32
+ this.
33
+ """
34
+ if not text:
35
+ return text
36
+ prefixes: set[str] = set()
37
+ for base in (str(repo_path), str(repo_path.resolve())):
38
+ prefixes.add(base)
39
+ if base.startswith("/private/"):
40
+ prefixes.add(base[len("/private"):])
41
+ elif base.startswith("/var/"):
42
+ prefixes.add("/private" + base)
43
+ for prefix in sorted(prefixes, key=len, reverse=True):
44
+ text = text.replace(prefix + os.sep, "").replace(prefix, "")
45
+ return text
46
+
47
+
48
+ def bounded_prompt(mission: str, agent_label: str) -> str:
49
+ """The hard no-authority instruction wrapped around every mission."""
50
+ return f"""You are {agent_label} working for Umbra in a disposable local checkout.
51
+ Mission: {mission}
52
+
53
+ Hard rules: never push, commit, create a PR, merge, approve, deploy, force-push,
54
+ or expose a secret. You may inspect and edit only this checkout. Make the minimum
55
+ safe change, run relevant tests, and finish with a concise explanation of changed
56
+ files, exact tests run, and anything that prevented verification."""
57
+
58
+
59
+ def reason_prompt(mission: str, agent_label: str) -> str:
60
+ return f"""You are {agent_label} acting as Umbra's reasoning analyst in a read-only workspace.
61
+ Task:
62
+ {mission}
63
+
64
+ Rules: Do not edit, create, run, push, or inspect any files. Reason only from the
65
+ text supplied above. Never invent files, line numbers, commit SHAs, CVEs, or
66
+ behavior. If context is insufficient, say so plainly. Respond with a concise,
67
+ well-structured analysis and nothing else."""
@@ -0,0 +1,108 @@
1
+ """The agent-agnostic executor interface.
2
+
3
+ The central idea of umbra-core: a coding agent (Codex, Claude Code, Cursor, …)
4
+ is an *untrusted engine*. It proposes a change inside a disposable checkout; it
5
+ never pushes, commits, or merges, and it can never approve its own authority.
6
+
7
+ Every agent is adapted to one interface — :class:`Executor` — so the admission
8
+ pipeline (contract → trust boundary → checks → verifier → earned authority →
9
+ signed receipt) treats them identically. Adding a new agent means writing one
10
+ adapter, not touching the governance core.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any, Protocol, runtime_checkable
18
+
19
+
20
+ @dataclass
21
+ class ExecutionResult:
22
+ """The auditable, replayable record of a single agent invocation.
23
+
24
+ Fields are deliberately provider-neutral so a receipt can bind any agent's
25
+ output identically. Honesty rule: ``provider`` describes *produced* output,
26
+ never a mere launch attempt — a failed run is recorded as such, never green.
27
+ """
28
+
29
+ prompt: str
30
+ summary: str
31
+ diff: str
32
+ tests_passed: bool | None
33
+ files: list[str]
34
+ # Which agent produced this — e.g. "codex-cli", "claude-code", "cursor",
35
+ # "deterministic". Never fabricated; a failed run reports "unavailable".
36
+ executor: str
37
+ created_at: str
38
+ command: list[str] | None = None
39
+ stdout: str = ""
40
+ error: str | None = None
41
+ # Provider-attested model provenance for the signed receipt. Kept honest:
42
+ # "configured" is what we requested, "resolved" stays unavailable unless the
43
+ # agent explicitly reports the model that ran.
44
+ model_identity: dict[str, Any] = field(default_factory=dict)
45
+
46
+ def as_dict(self) -> dict[str, object]:
47
+ return asdict(self)
48
+
49
+ @classmethod
50
+ def failed(cls, prompt: str, executor: str, error: str, command: list[str] | None = None) -> "ExecutionResult":
51
+ return cls(
52
+ prompt=prompt,
53
+ summary=f"{executor} did not produce a change: {error}",
54
+ diff="",
55
+ tests_passed=False,
56
+ files=[],
57
+ executor="unavailable",
58
+ created_at=datetime.now(UTC).isoformat(),
59
+ command=command,
60
+ error=error,
61
+ )
62
+
63
+ @classmethod
64
+ def disabled(cls, prompt: str, executor: str, reason: str) -> "ExecutionResult":
65
+ return cls(
66
+ prompt=prompt,
67
+ summary=reason,
68
+ diff="",
69
+ tests_passed=None,
70
+ files=[],
71
+ executor=f"{executor}-disabled",
72
+ created_at=datetime.now(UTC).isoformat(),
73
+ )
74
+
75
+
76
+ @runtime_checkable
77
+ class Executor(Protocol):
78
+ """Any coding agent Umbra can govern.
79
+
80
+ Implementations wrap a CLI or API (Codex, Claude Code, Cursor, …) behind
81
+ this single contract. The governance core depends only on this protocol, so
82
+ it is fully agent-agnostic.
83
+ """
84
+
85
+ #: Stable identifier used in receipts and the provider ledger.
86
+ name: str
87
+
88
+ def available(self) -> bool:
89
+ """Whether this agent can actually run here (binary present, auth ok).
90
+
91
+ Must never raise; a probe failure returns ``False`` so the pipeline can
92
+ fall back or cap authority rather than crash.
93
+ """
94
+ ...
95
+
96
+ def propose(self, prompt: str, repo_path: Path, *, read_only: bool = False) -> ExecutionResult:
97
+ """Run the agent in ``repo_path`` (a disposable checkout) and return the
98
+ produced change as an :class:`ExecutionResult`.
99
+
100
+ The agent MUST be invoked with no push/commit/merge authority and no
101
+ write credentials. ``read_only`` requests a pure-reasoning pass with no
102
+ filesystem side effects.
103
+ """
104
+ ...
105
+
106
+ def model_identity(self) -> dict[str, Any]:
107
+ """Truthful model provenance for the receipt (never fabricated)."""
108
+ ...
@@ -0,0 +1,179 @@
1
+ """Claude Code executor — adapts the ``claude`` CLI to the Executor protocol.
2
+
3
+ This is the proof that umbra-core is agent-agnostic: Claude Code is governed by
4
+ the *same* admission pipeline as Codex. It runs headless (``-p``) inside a
5
+ disposable checkout with:
6
+
7
+ - ``--bare`` — skips CLAUDE.md auto-discovery, so the agent does not silently
8
+ ingest untrusted repository instruction files. This reinforces Umbra's trust
9
+ boundary: the agent can't read manipulation Umbra hasn't vetted.
10
+ - ``--disallowed-tools`` — git push/commit/merge and other authority-bearing
11
+ commands are refused at the tool layer; the agent cannot self-grant authority.
12
+ - the diff is recomputed from ``git`` on the final tree, so the signed change
13
+ reflects only the agent's real edits.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import subprocess
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .base import ExecutionResult
26
+ from ._shared import (
27
+ Runner,
28
+ bounded_prompt,
29
+ changed_files,
30
+ reason_prompt,
31
+ sanitize_paths,
32
+ unified_diff,
33
+ )
34
+
35
+ logger = logging.getLogger("umbra.executor.claude")
36
+
37
+ # Tools that would let the agent push, merge, or otherwise self-grant authority.
38
+ # Refused at the CLI layer so a governed run can only ever propose a change.
39
+ _DISALLOWED_TOOLS = [
40
+ "Bash(git push:*)",
41
+ "Bash(git commit:*)",
42
+ "Bash(git merge:*)",
43
+ "Bash(git rebase:*)",
44
+ "Bash(gh pr *)",
45
+ "Bash(gh pr create:*)",
46
+ "Bash(git remote *)",
47
+ "WebFetch",
48
+ ]
49
+
50
+
51
+ class ClaudeCodeExecutor:
52
+ name = "claude-code"
53
+
54
+ def __init__(
55
+ self,
56
+ runner: Runner = subprocess.run,
57
+ model: str | None = None,
58
+ ) -> None:
59
+ self.runner = runner
60
+ # Free-form alias/full name (e.g. "opus", "sonnet"); passed only via
61
+ # --model. No allowlist coupling to a specific vendor catalog.
62
+ self.model = (model if model is not None else os.getenv("UMBRA_CLAUDE_MODEL") or "").strip() or None
63
+
64
+ # --- capability ---------------------------------------------------------
65
+ def available(self) -> bool:
66
+ if os.getenv("UMBRA_ENABLE_CLAUDE_CODE", "false").lower() != "true":
67
+ return False
68
+ return self._cli_version() is not None
69
+
70
+ def _cli_version(self) -> str | None:
71
+ try:
72
+ r = self.runner(["claude", "--version"], text=True, capture_output=True, timeout=15, check=False)
73
+ except (OSError, subprocess.SubprocessError):
74
+ return None
75
+ if r is None:
76
+ return None
77
+ out = ((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or "")).strip()
78
+ return out.splitlines()[0].strip() if out else None
79
+
80
+ # --- provenance ---------------------------------------------------------
81
+ def model_identity(self) -> dict[str, Any]:
82
+ return {
83
+ "executor": self.name,
84
+ "cli_version": self._cli_version() or "unavailable",
85
+ "model_configured": self.model or "claude-default",
86
+ # Resolved only if the CLI's JSON result reports the model that ran;
87
+ # filled in _run_cli when observed. Never inferred from --model.
88
+ "model_resolved": "unavailable",
89
+ "model_evidence": "cli-argument" if self.model else "claude-default",
90
+ }
91
+
92
+ # --- run ----------------------------------------------------------------
93
+ def propose(self, prompt: str, repo_path: Path, *, read_only: bool = False) -> ExecutionResult:
94
+ if not self.available():
95
+ return ExecutionResult.disabled(
96
+ prompt, self.name,
97
+ "Claude Code is disabled. Set UMBRA_ENABLE_CLAUDE_CODE=true and authenticate the `claude` CLI.",
98
+ )
99
+ if repo_path is None or not repo_path.is_dir():
100
+ raise RuntimeError("A checked-out repository is required for ClaudeCodeExecutor.propose()")
101
+ return self._run_cli(prompt, repo_path, read_only=read_only)
102
+
103
+ def _run_cli(self, prompt: str, repo_path: Path, *, read_only: bool) -> ExecutionResult:
104
+ # acceptEdits lets the agent edit files in the checkout without prompting;
105
+ # plan mode forbids edits entirely for a read-only reasoning pass.
106
+ permission_mode = "plan" if read_only else "acceptEdits"
107
+ model_args = ["--model", self.model] if self.model else []
108
+ cli_prompt = reason_prompt(prompt, "Claude Code") if read_only else bounded_prompt(prompt, "Claude Code")
109
+ command = [
110
+ "claude", "-p", cli_prompt,
111
+ "--output-format", "json",
112
+ "--bare", # do NOT auto-read CLAUDE.md — Umbra's trust boundary owns that
113
+ "--permission-mode", permission_mode,
114
+ "--disallowed-tools", *_DISALLOWED_TOOLS,
115
+ "--add-dir", str(repo_path),
116
+ *model_args,
117
+ ]
118
+ try:
119
+ completed = self.runner(
120
+ command, text=True, capture_output=True, timeout=900, check=False, cwd=str(repo_path),
121
+ )
122
+ except (OSError, subprocess.SubprocessError) as exc:
123
+ return ExecutionResult.failed(prompt, self.name, str(exc)[:300], command=command[:2] + ["<prompt>"])
124
+
125
+ rc = getattr(completed, "returncode", 1)
126
+ raw_stdout = getattr(completed, "stdout", "") or ""
127
+ summary, resolved_model = self._parse_result(raw_stdout)
128
+
129
+ diff = unified_diff(repo_path)
130
+ files = changed_files(repo_path)
131
+ if not summary:
132
+ summary = (
133
+ ("Claude Code completed; see the diff below." if diff else "Claude Code ran and produced no changes.")
134
+ if rc == 0
135
+ else f"Claude Code failed (exit {rc})."
136
+ )
137
+ if rc != 0:
138
+ logger.warning("claude -p failed (rc=%s): %s", rc, (getattr(completed, "stderr", "") or "")[-1000:])
139
+
140
+ identity = self.model_identity()
141
+ if resolved_model:
142
+ identity["model_resolved"] = resolved_model
143
+ identity["model_evidence"] = "provider-attested"
144
+
145
+ return ExecutionResult(
146
+ prompt=prompt,
147
+ summary=sanitize_paths(summary, repo_path),
148
+ diff=diff,
149
+ tests_passed=rc == 0,
150
+ files=files,
151
+ executor=self.name if rc == 0 else "unavailable",
152
+ created_at=datetime.now(UTC).isoformat(),
153
+ command=command[:2] + ["<agent prompt redacted from command replay>"] + command[3:],
154
+ stdout=sanitize_paths(raw_stdout[-12000:], repo_path),
155
+ error=sanitize_paths((getattr(completed, "stderr", "") or "")[-4000:], repo_path) or None,
156
+ model_identity=identity,
157
+ )
158
+
159
+ @staticmethod
160
+ def _parse_result(stdout: str) -> tuple[str, str | None]:
161
+ """Extract the final assistant text and (if reported) the resolved model
162
+ from ``--output-format json``. Falls back to raw stdout on any parse
163
+ failure — we never fabricate a model that ran.
164
+ """
165
+ text = (stdout or "").strip()
166
+ if not text:
167
+ return "", None
168
+ try:
169
+ data = json.loads(text)
170
+ except (json.JSONDecodeError, ValueError):
171
+ return text, None
172
+ summary = ""
173
+ resolved = None
174
+ if isinstance(data, dict):
175
+ summary = str(data.get("result") or data.get("text") or data.get("content") or "").strip()
176
+ model = data.get("model")
177
+ if isinstance(model, str) and model.strip():
178
+ resolved = model.strip()
179
+ return summary or text, resolved