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 +101 -0
- umbra_core/cli.py +199 -0
- umbra_core/executors/__init__.py +16 -0
- umbra_core/executors/_shared.py +67 -0
- umbra_core/executors/base.py +108 -0
- umbra_core/executors/claude_code.py +179 -0
- umbra_core/executors/codex.py +147 -0
- umbra_core/executors/null.py +46 -0
- umbra_core/executors/registry.py +53 -0
- umbra_core/mcp_server.py +114 -0
- umbra_core/pipeline/__init__.py +104 -0
- umbra_core/pipeline/_helpers.py +52 -0
- umbra_core/pipeline/admission.py +422 -0
- umbra_core/pipeline/checks.py +245 -0
- umbra_core/pipeline/contract.py +403 -0
- umbra_core/pipeline/passport.py +218 -0
- umbra_core/pipeline/provenance.py +132 -0
- umbra_core/pipeline/receipt.py +208 -0
- umbra_core/pipeline/transparency.py +231 -0
- umbra_core/pipeline/trust_boundary.py +261 -0
- umbra_core/pipeline/verifier.py +167 -0
- umbra_core-0.1.0.dist-info/METADATA +272 -0
- umbra_core-0.1.0.dist-info/RECORD +26 -0
- umbra_core-0.1.0.dist-info/WHEEL +4 -0
- umbra_core-0.1.0.dist-info/entry_points.txt +2 -0
- umbra_core-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|