sableai 2.0.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.
Files changed (78) hide show
  1. sable/__init__.py +3 -0
  2. sable/__main__.py +4 -0
  3. sable/_version.py +3 -0
  4. sable/automation.py +147 -0
  5. sable/capabilities.py +501 -0
  6. sable/cli.py +611 -0
  7. sable/cli_app.py +109 -0
  8. sable/cli_args.py +194 -0
  9. sable/cli_settings.py +215 -0
  10. sable/cli_workspace.py +262 -0
  11. sable/config.py +252 -0
  12. sable/context/__init__.py +4 -0
  13. sable/context/engine.py +602 -0
  14. sable/context/models.py +126 -0
  15. sable/doctor.py +475 -0
  16. sable/evals/__init__.py +68 -0
  17. sable/evals/__main__.py +3 -0
  18. sable/evals/assertions.py +210 -0
  19. sable/evals/baseline.py +157 -0
  20. sable/evals/cli.py +148 -0
  21. sable/evals/fixtures.py +75 -0
  22. sable/evals/metrics.py +112 -0
  23. sable/evals/models.py +534 -0
  24. sable/evals/provider.py +131 -0
  25. sable/evals/reporting.py +169 -0
  26. sable/evals/runner.py +148 -0
  27. sable/evals/system.py +953 -0
  28. sable/execution/__init__.py +78 -0
  29. sable/execution/base.py +136 -0
  30. sable/execution/native.py +261 -0
  31. sable/execution/proot.py +177 -0
  32. sable/groq_client.py +269 -0
  33. sable/main_agent.py +372 -0
  34. sable/orchestrator.py +900 -0
  35. sable/patches.py +197 -0
  36. sable/presentation.py +458 -0
  37. sable/project.py +168 -0
  38. sable/providers/__init__.py +25 -0
  39. sable/providers/anthropic.py +309 -0
  40. sable/providers/base.py +177 -0
  41. sable/providers/deepseek.py +11 -0
  42. sable/providers/factory.py +22 -0
  43. sable/providers/groq.py +7 -0
  44. sable/providers/openai.py +11 -0
  45. sable/providers/openai_compat.py +203 -0
  46. sable/providers/router.py +121 -0
  47. sable/runtime.py +349 -0
  48. sable/security.py +389 -0
  49. sable/sessions/__init__.py +4 -0
  50. sable/sessions/manager.py +365 -0
  51. sable/sessions/models.py +72 -0
  52. sable/tool_schemas.py +202 -0
  53. sable/tools/__init__.py +6 -0
  54. sable/tools/base.py +409 -0
  55. sable/tools/commands.py +76 -0
  56. sable/tools/context.py +100 -0
  57. sable/tools/executor.py +252 -0
  58. sable/tools/files_read.py +194 -0
  59. sable/tools/files_write.py +403 -0
  60. sable/tools/git.py +340 -0
  61. sable/transactions.py +984 -0
  62. sable/ui.py +93 -0
  63. sable/verification/__init__.py +72 -0
  64. sable/verification/adapters.py +682 -0
  65. sable/verification/affected.py +235 -0
  66. sable/verification/classifiers.py +105 -0
  67. sable/verification/discovery.py +166 -0
  68. sable/verification/integrity.py +345 -0
  69. sable/verification/models.py +472 -0
  70. sable/verification/planner.py +222 -0
  71. sable/verification/runner.py +274 -0
  72. sable/verifier.py +117 -0
  73. sableai-2.0.0.dist-info/METADATA +454 -0
  74. sableai-2.0.0.dist-info/RECORD +78 -0
  75. sableai-2.0.0.dist-info/WHEEL +5 -0
  76. sableai-2.0.0.dist-info/entry_points.txt +2 -0
  77. sableai-2.0.0.dist-info/licenses/LICENSE +21 -0
  78. sableai-2.0.0.dist-info/top_level.txt +1 -0
sable/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sable — a bounded, workspace-scoped coding agent."""
2
+
3
+ from ._version import __version__ as __version__
sable/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli_app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
sable/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single version source for package metadata and the public sable.__version__."""
2
+
3
+ __version__ = "2.0.0"
sable/automation.py ADDED
@@ -0,0 +1,147 @@
1
+ """Stable, bounded machine-output contracts for Sable one-shot runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .cli_args import exit_code_for_result
8
+ from .config import redact_secrets
9
+
10
+ SCHEMA_VERSION = 1
11
+
12
+
13
+ def _safe(value: Any, limit: int = 1000) -> str:
14
+ return redact_secrets("" if value is None else str(value))[:limit]
15
+
16
+
17
+ def _items(value: Any) -> list[Any]:
18
+ return list(value) if isinstance(value, (list, tuple)) else []
19
+
20
+
21
+ def _count(value: Any) -> int:
22
+ try:
23
+ return max(0, int(value or 0))
24
+ except (TypeError, ValueError):
25
+ return 0
26
+
27
+
28
+ def _verification_status(result: dict[str, Any], runtime: dict[str, Any]) -> str:
29
+ loops = result.get("verification_loops", [])
30
+ if isinstance(loops, list) and loops:
31
+ latest = loops[-1]
32
+ if isinstance(latest, dict):
33
+ explicit = latest.get("overall_status") or latest.get("status")
34
+ if explicit:
35
+ return _safe(explicit, 60).upper()
36
+ verification = runtime.get("verification", {})
37
+ if isinstance(verification, dict):
38
+ explicit = verification.get("overall_status") or verification.get("status")
39
+ if explicit:
40
+ return _safe(explicit, 60).upper()
41
+ return "SKIPPED"
42
+
43
+
44
+ def _public_checks(result: dict[str, Any]) -> list[dict[str, Any]]:
45
+ loops = result.get("verification_loops", [])
46
+ if not isinstance(loops, list) or not loops or not isinstance(loops[-1], dict):
47
+ return []
48
+ checks: list[dict[str, Any]] = []
49
+ for item in _items(loops[-1].get("checks", []))[:100]:
50
+ if isinstance(item, dict):
51
+ check = item.get("check", {}) if isinstance(item.get("check"), dict) else {}
52
+ checks.append(
53
+ {
54
+ "name": _safe(check.get("name", item.get("name", "check")), 160),
55
+ "status": _safe(item.get("status", "UNKNOWN"), 60).upper(),
56
+ "classification": _safe(item.get("classification", "NONE"), 80).upper(),
57
+ }
58
+ )
59
+ continue
60
+ status = getattr(item, "status", "UNKNOWN")
61
+ classification = getattr(item, "classification", "NONE")
62
+ checks.append(
63
+ {
64
+ "name": _safe(getattr(item, "name", "check"), 160),
65
+ "status": _safe(getattr(status, "value", status), 60).upper(),
66
+ "classification": _safe(
67
+ getattr(classification, "value", classification), 80
68
+ ).upper(),
69
+ }
70
+ )
71
+ return checks
72
+
73
+
74
+ def build_json_result(
75
+ result: dict[str, Any],
76
+ *,
77
+ verification_enabled: bool = True,
78
+ ) -> dict[str, Any]:
79
+ """Build schema v1 from structured task data without raw tool output."""
80
+ runtime = result.get("runtime_task", {})
81
+ if not isinstance(runtime, dict):
82
+ runtime = {}
83
+ final_status = _safe(result.get("final_status", "unknown"), 80).lower()
84
+ if final_status in {"pass", "built", "plan"}:
85
+ status = "completed"
86
+ elif final_status == "cancelled":
87
+ status = "cancelled"
88
+ elif final_status in {
89
+ "blocked",
90
+ "verification_incomplete",
91
+ "verification_blocked",
92
+ "verification_integrity_blocked",
93
+ }:
94
+ status = "blocked"
95
+ else:
96
+ status = "failed"
97
+ verification_status = _verification_status(result, runtime)
98
+ purposes = _items(runtime.get("routing_purposes", []))
99
+ fast_calls = sum(1 for purpose in purposes if str(purpose).upper() == "FAST_CONTEXT_SUMMARY")
100
+ total_calls = _count(runtime.get("model_turn_count", result.get("agent_steps", 0)))
101
+ public: dict[str, Any] = {
102
+ "schema_version": SCHEMA_VERSION,
103
+ "status": status,
104
+ "result": final_status,
105
+ "verified": bool(
106
+ verification_enabled and verification_status in {"PASS", "PASS_WITH_OPTIONAL_SKIPS"}
107
+ ),
108
+ "verification_enabled": bool(verification_enabled),
109
+ "verification_status": verification_status,
110
+ "task_id": _safe(result.get("task_id") or runtime.get("task_id"), 160) or None,
111
+ "session_id": _safe(runtime.get("session_id"), 160) or None,
112
+ "transaction_id": _safe(result.get("transaction_id") or runtime.get("transaction_id"), 160)
113
+ or None,
114
+ "changed_files": [
115
+ _safe(path, 500) for path in _items(result.get("changed_files", []))[:500]
116
+ ],
117
+ "commit": _safe(result.get("commit_sha"), 160) or None,
118
+ "usage": {
119
+ "main_model_calls": max(0, total_calls - fast_calls),
120
+ "fast_model_calls": fast_calls,
121
+ "input_tokens": _count(runtime.get("input_tokens", 0)),
122
+ "output_tokens": _count(runtime.get("output_tokens", 0)),
123
+ "total_tokens": _count(runtime.get("total_tokens", 0)),
124
+ },
125
+ "duration_ms": _count(runtime.get("duration_ms", 0)),
126
+ "exit_reason": _safe(runtime.get("termination_reason"), 100) or None,
127
+ "exit_code": int(exit_code_for_result(result)),
128
+ "summary": _safe(result.get("chat_reply"), 4000),
129
+ "verification_checks": _public_checks(result),
130
+ }
131
+ if result.get("undo_available"):
132
+ public["undo_available"] = True
133
+ if isinstance(result.get("rollback"), dict):
134
+ public["rollback"] = {
135
+ "success": bool(result["rollback"].get("success")),
136
+ "changed_files": [
137
+ _safe(path, 500)
138
+ for path in _items(result["rollback"].get("changed_files", []))[:500]
139
+ ],
140
+ }
141
+ warnings = [_safe(item, 500) for item in _items(result.get("trace_errors", []))[:20]]
142
+ if warnings:
143
+ public["warnings"] = warnings
144
+ return public
145
+
146
+
147
+ __all__ = ["SCHEMA_VERSION", "build_json_result"]
sable/capabilities.py ADDED
@@ -0,0 +1,501 @@
1
+ """Runtime-owned capability classification and scoped human approvals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from typing import Any, Callable
13
+
14
+ from .config import redact_secrets
15
+
16
+
17
+ class Capability(str, Enum):
18
+ READ_WORKSPACE = "READ_WORKSPACE"
19
+ WRITE_WORKSPACE = "WRITE_WORKSPACE"
20
+ DELETE_WORKSPACE = "DELETE_WORKSPACE"
21
+ EXECUTE_PROCESS = "EXECUTE_PROCESS"
22
+ EXECUTE_SHELL = "EXECUTE_SHELL"
23
+ NETWORK_ACCESS = "NETWORK_ACCESS"
24
+ PACKAGE_INSTALL = "PACKAGE_INSTALL"
25
+ GIT_REMOTE_READ = "GIT_REMOTE_READ"
26
+ GIT_PUBLISH = "GIT_PUBLISH"
27
+ GIT_HISTORY_MUTATION = "GIT_HISTORY_MUTATION"
28
+ EXTERNAL_FILESYSTEM = "EXTERNAL_FILESYSTEM"
29
+
30
+
31
+ class ActionSource(str, Enum):
32
+ MODEL = "MODEL"
33
+ USER = "USER"
34
+ VERIFIER = "VERIFIER"
35
+ RUNTIME = "RUNTIME"
36
+
37
+
38
+ class ApprovalDecision(str, Enum):
39
+ ALLOW_ONCE = "ALLOW_ONCE"
40
+ ALLOW_SESSION = "ALLOW_SESSION"
41
+ DENY = "DENY"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class CapabilityRequirement:
46
+ capability: Capability
47
+ action: str
48
+ risk: str
49
+ reason: str
50
+ target: str | None = None
51
+
52
+
53
+ @dataclass
54
+ class CapabilityRequest:
55
+ request_id: str
56
+ capability: Capability
57
+ action: str
58
+ source: ActionSource
59
+ task_id: str | None
60
+ session_id: str
61
+ tool: str
62
+ risk: str
63
+ reason: str
64
+ target: str | None
65
+ created_at: str
66
+ scope: str
67
+ decision: ApprovalDecision | None = None
68
+ consumed: bool = False
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "request_id": self.request_id,
73
+ "capability": self.capability.value,
74
+ "action": self.action,
75
+ "source": self.source.value,
76
+ "task_id": self.task_id,
77
+ "session_id": self.session_id,
78
+ "tool": self.tool,
79
+ "risk": self.risk,
80
+ "reason": self.reason,
81
+ "target": self.target,
82
+ "created_at": self.created_at,
83
+ "scope": self.scope,
84
+ "decision": self.decision.value if self.decision else None,
85
+ "consumed": self.consumed,
86
+ }
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class AuthorizationOutcome:
91
+ allowed: bool
92
+ capability: Capability
93
+ reason: str
94
+ allowed_by: str
95
+ approval_required: bool = False
96
+ request: CapabilityRequest | None = None
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return {
100
+ "allowed": self.allowed,
101
+ "capability": self.capability.value,
102
+ "reason": self.reason,
103
+ "allowed_by": self.allowed_by,
104
+ "approval_required": self.approval_required,
105
+ "request": self.request.to_dict() if self.request else None,
106
+ }
107
+
108
+
109
+ class ApprovalError(RuntimeError):
110
+ pass
111
+
112
+
113
+ ApprovalHandler = Callable[[CapabilityRequest], ApprovalDecision | str]
114
+
115
+
116
+ class ApprovalEngine:
117
+ """In-memory approvals. Session grants intentionally never persist to disk."""
118
+
119
+ def __init__(self, *, session_id: str | None = None, handler: ApprovalHandler | None = None):
120
+ self.session_id = session_id or f"volatile-{uuid.uuid4().hex}"
121
+ self.handler = handler
122
+ self._pending: dict[str, CapabilityRequest] = {}
123
+ self._history: list[CapabilityRequest] = []
124
+ self._session_grants: set[tuple[str, Capability, str]] = set()
125
+
126
+ def bind_session(self, session_id: str | None) -> None:
127
+ new_id = session_id or f"volatile-{uuid.uuid4().hex}"
128
+ if new_id != self.session_id:
129
+ self._pending.clear()
130
+ self._session_grants.clear()
131
+ self.session_id = new_id
132
+
133
+ def set_handler(self, handler: ApprovalHandler | None) -> None:
134
+ self.handler = handler
135
+
136
+ def create_request(
137
+ self,
138
+ requirement: CapabilityRequirement,
139
+ *,
140
+ source: ActionSource,
141
+ tool: str,
142
+ task_id: str | None,
143
+ scope_material: Any,
144
+ ) -> CapabilityRequest:
145
+ canonical = json.dumps(scope_material, sort_keys=True, separators=(",", ":"), default=str)
146
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20]
147
+ scope = f"{tool}:{digest}"
148
+ request = CapabilityRequest(
149
+ request_id=f"cap-{uuid.uuid4().hex}",
150
+ capability=requirement.capability,
151
+ action=redact_secrets(requirement.action)[:500],
152
+ source=source,
153
+ task_id=str(task_id)[:120] if task_id else None,
154
+ session_id=self.session_id,
155
+ tool=str(tool)[:80],
156
+ risk=str(requirement.risk)[:20],
157
+ reason=redact_secrets(requirement.reason)[:500],
158
+ target=redact_secrets(requirement.target)[:300] if requirement.target else None,
159
+ created_at=datetime.now(timezone.utc).isoformat(timespec="microseconds"),
160
+ scope=scope,
161
+ )
162
+ self._pending[request.request_id] = request
163
+ return request
164
+
165
+ def decide(self, request_id: str, decision: ApprovalDecision | str) -> CapabilityRequest:
166
+ request = self._pending.get(str(request_id))
167
+ if request is None or request.decision is not None or request.session_id != self.session_id:
168
+ raise ApprovalError(
169
+ "Approval request is invalid, stale, already consumed, or belongs to another session."
170
+ )
171
+ try:
172
+ normalized = (
173
+ decision
174
+ if isinstance(decision, ApprovalDecision)
175
+ else ApprovalDecision(str(decision).upper())
176
+ )
177
+ except ValueError as exc:
178
+ raise ApprovalError(f"Invalid approval decision: {decision}") from exc
179
+ request.decision = normalized
180
+ request.consumed = normalized == ApprovalDecision.ALLOW_ONCE
181
+ if normalized == ApprovalDecision.ALLOW_SESSION:
182
+ self._session_grants.add((request.session_id, request.capability, request.scope))
183
+ self._pending.pop(request.request_id, None)
184
+ self._history.append(request)
185
+ self._history = self._history[-200:]
186
+ return request
187
+
188
+ def authorize(self, request: CapabilityRequest) -> AuthorizationOutcome:
189
+ grant_key = (self.session_id, request.capability, request.scope)
190
+ if request.session_id != self.session_id:
191
+ return AuthorizationOutcome(
192
+ False, request.capability, "Approval session changed.", "stale", request=request
193
+ )
194
+ if grant_key in self._session_grants:
195
+ self._pending.pop(request.request_id, None)
196
+ request.decision = ApprovalDecision.ALLOW_SESSION
197
+ self._history.append(request)
198
+ self._history = self._history[-200:]
199
+ return AuthorizationOutcome(
200
+ True,
201
+ request.capability,
202
+ "Matching session-scoped approval reused.",
203
+ "session",
204
+ request=request,
205
+ )
206
+ if self.handler is None:
207
+ return AuthorizationOutcome(
208
+ False,
209
+ request.capability,
210
+ "Human approval is required, but no approval handler is available.",
211
+ "pending",
212
+ approval_required=True,
213
+ request=request,
214
+ )
215
+ try:
216
+ decision = self.handler(request)
217
+ decided = self.decide(request.request_id, decision)
218
+ except (ApprovalError, ValueError, TypeError) as exc:
219
+ self._pending.pop(request.request_id, None)
220
+ return AuthorizationOutcome(
221
+ False, request.capability, redact_secrets(str(exc))[:300], "denied", request=request
222
+ )
223
+ if decided.decision in {ApprovalDecision.ALLOW_ONCE, ApprovalDecision.ALLOW_SESSION}:
224
+ return AuthorizationOutcome(
225
+ True,
226
+ request.capability,
227
+ "Human approval granted.",
228
+ "once" if decided.decision == ApprovalDecision.ALLOW_ONCE else "session",
229
+ request=decided,
230
+ )
231
+ return AuthorizationOutcome(
232
+ False, request.capability, "Human approval denied.", "denied", request=decided
233
+ )
234
+
235
+ def pending(self) -> list[dict[str, Any]]:
236
+ return [request.to_dict() for request in self._pending.values()]
237
+
238
+
239
+ class CapabilityPolicy:
240
+ BASELINE = {
241
+ Capability.READ_WORKSPACE,
242
+ Capability.WRITE_WORKSPACE,
243
+ Capability.EXECUTE_PROCESS,
244
+ }
245
+ HARD_DENIED = {Capability.EXTERNAL_FILESYSTEM}
246
+
247
+ def evaluate(
248
+ self, capability: Capability, *, mode: str, source: ActionSource
249
+ ) -> AuthorizationOutcome:
250
+ normalized_mode = mode if mode in {"plan", "build", "yolo"} else "build"
251
+ if capability in self.HARD_DENIED:
252
+ return AuthorizationOutcome(
253
+ False, capability, "External filesystem access is a hard boundary.", "hard_boundary"
254
+ )
255
+ if normalized_mode == "plan" and capability != Capability.READ_WORKSPACE:
256
+ return AuthorizationOutcome(
257
+ False, capability, "Plan mode is a hard read-only ceiling.", "plan_ceiling"
258
+ )
259
+ if source == ActionSource.USER:
260
+ return AuthorizationOutcome(True, capability, "Direct user action.", "direct_user")
261
+ if capability in self.BASELINE:
262
+ return AuthorizationOutcome(
263
+ True, capability, "Allowed by the mode baseline.", "baseline"
264
+ )
265
+ if normalized_mode != "yolo":
266
+ return AuthorizationOutcome(
267
+ False,
268
+ capability,
269
+ f"{capability.value} is not requestable in {normalized_mode} mode; use /mode yolo.",
270
+ "mode_ceiling",
271
+ )
272
+ return AuthorizationOutcome(
273
+ False, capability, "Human approval is required.", "approval", approval_required=True
274
+ )
275
+
276
+
277
+ READ_TOOLS = {
278
+ "read_file",
279
+ "read_file_lines",
280
+ "list_files",
281
+ "search_files",
282
+ "grep_files",
283
+ "file_info",
284
+ "project_profile",
285
+ "repo_map",
286
+ "list_symbols",
287
+ "find_symbol",
288
+ "find_references",
289
+ "read_symbol",
290
+ "find_tests_for_file",
291
+ "recent_changes",
292
+ "git_status",
293
+ "git_diff",
294
+ "git_log",
295
+ }
296
+ WRITE_TOOLS = {
297
+ "write_file",
298
+ "append_file",
299
+ "patch_file",
300
+ "apply_patch",
301
+ "make_dir",
302
+ "copy_file",
303
+ "move_file",
304
+ }
305
+ PACKAGE_MANAGERS = {
306
+ "pip",
307
+ "pip3",
308
+ "npm",
309
+ "pnpm",
310
+ "yarn",
311
+ "bun",
312
+ "pkg",
313
+ "apt",
314
+ "apt-get",
315
+ "cargo",
316
+ "go",
317
+ }
318
+ NETWORK_EXECUTABLES = {"curl", "wget", "ssh", "scp", "sftp", "nc", "ncat", "netcat"}
319
+ PACKAGE_ACTIONS = {
320
+ "install",
321
+ "add",
322
+ "remove",
323
+ "uninstall",
324
+ "update",
325
+ "upgrade",
326
+ "i",
327
+ "exec",
328
+ "dlx",
329
+ "get",
330
+ "fetch",
331
+ "download",
332
+ "publish",
333
+ "login",
334
+ "logout",
335
+ "ci",
336
+ }
337
+
338
+
339
+ def _command_requirements(argv: list[str]) -> list[CapabilityRequirement]:
340
+ action = " ".join(str(item) for item in argv)[:500]
341
+ requirements = [
342
+ CapabilityRequirement(
343
+ Capability.EXECUTE_PROCESS,
344
+ action,
345
+ "normal",
346
+ "Launch a direct project subprocess.",
347
+ )
348
+ ]
349
+ if not argv:
350
+ return requirements
351
+ executable = os.path.basename(argv[0]).lower()
352
+ lowered = [str(item).lower() for item in argv[1:]]
353
+ package_install = executable in PACKAGE_MANAGERS and any(
354
+ item in PACKAGE_ACTIONS for item in lowered
355
+ )
356
+ if executable.startswith("python") and "-m" in lowered:
357
+ try:
358
+ package_install = package_install or lowered[lowered.index("-m") + 1] in {
359
+ "pip",
360
+ "ensurepip",
361
+ }
362
+ except IndexError:
363
+ pass
364
+ git_remote = executable == "git" and any(
365
+ item in {"clone", "fetch", "pull", "push"} for item in lowered
366
+ )
367
+ if executable in NETWORK_EXECUTABLES or package_install or git_remote:
368
+ requirements.append(
369
+ CapabilityRequirement(
370
+ Capability.NETWORK_ACCESS,
371
+ action,
372
+ "high",
373
+ "The command is a known direct network-capable action. This policy does not isolate arbitrary process networking.",
374
+ )
375
+ )
376
+ if package_install:
377
+ requirements.append(
378
+ CapabilityRequirement(
379
+ Capability.PACKAGE_INSTALL,
380
+ action,
381
+ "high",
382
+ "The command appears to mutate package dependencies or registries.",
383
+ )
384
+ )
385
+ if executable == "git" and "push" in lowered:
386
+ requirements.append(
387
+ CapabilityRequirement(
388
+ Capability.GIT_PUBLISH, action, "high", "The command publishes Git refs."
389
+ )
390
+ )
391
+ elif git_remote:
392
+ requirements.append(
393
+ CapabilityRequirement(
394
+ Capability.GIT_REMOTE_READ, action, "high", "The command accesses a Git remote."
395
+ )
396
+ )
397
+ return requirements
398
+
399
+
400
+ def requirements_for_tool(tool: str, args: dict[str, Any]) -> list[CapabilityRequirement]:
401
+ """Classify an action without trusting authorization-shaped tool arguments."""
402
+
403
+ name = str(tool)
404
+ target = str(args.get("path") or args.get("file") or args.get("branch") or "")[:300] or None
405
+ if name in READ_TOOLS or (name == "git_branch" and not args.get("name")):
406
+ return [
407
+ CapabilityRequirement(
408
+ Capability.READ_WORKSPACE,
409
+ name,
410
+ "normal",
411
+ "Read workspace or local Git metadata.",
412
+ target,
413
+ )
414
+ ]
415
+ if name in WRITE_TOOLS:
416
+ return [
417
+ CapabilityRequirement(
418
+ Capability.WRITE_WORKSPACE,
419
+ name,
420
+ "normal",
421
+ "Mutate a workspace path through a transaction-aware file tool.",
422
+ target,
423
+ )
424
+ ]
425
+ if name == "delete_file":
426
+ return [
427
+ CapabilityRequirement(
428
+ Capability.DELETE_WORKSPACE,
429
+ f"delete {target or '?'}",
430
+ "high",
431
+ "Delete a workspace path through the transaction-aware file tool.",
432
+ target,
433
+ )
434
+ ]
435
+ if name == "run_command":
436
+ return _command_requirements(list(args.get("argv") or []))
437
+ if name == "run_shell":
438
+ command = str(args.get("command") or "")[:500]
439
+ return [
440
+ CapabilityRequirement(
441
+ Capability.EXECUTE_SHELL,
442
+ command,
443
+ "high",
444
+ "Execute a raw shell command whose effects cannot be fully classified.",
445
+ )
446
+ ]
447
+ if name in {"git_clone", "git_fetch", "git_pull"}:
448
+ action = f"{name} {target or ''}".strip()
449
+ return [
450
+ CapabilityRequirement(
451
+ Capability.NETWORK_ACCESS, action, "high", "Access a Git remote."
452
+ ),
453
+ CapabilityRequirement(
454
+ Capability.GIT_REMOTE_READ, action, "high", "Read or merge data from a Git remote."
455
+ ),
456
+ ]
457
+ if name == "git_push":
458
+ action = f"git push {target or ''}".strip()
459
+ return [
460
+ CapabilityRequirement(
461
+ Capability.NETWORK_ACCESS, action, "high", "Access a Git remote."
462
+ ),
463
+ CapabilityRequirement(
464
+ Capability.GIT_PUBLISH, action, "high", "Publish local Git refs to a remote."
465
+ ),
466
+ ]
467
+ if name in {"git_commit", "git_stash", "git_set_remote"} or (
468
+ name == "git_branch" and args.get("name")
469
+ ):
470
+ return [
471
+ CapabilityRequirement(
472
+ Capability.GIT_HISTORY_MUTATION,
473
+ name,
474
+ "high",
475
+ "Mutate local Git state or configuration.",
476
+ target,
477
+ )
478
+ ]
479
+ return []
480
+
481
+
482
+ def approval_scope_material(
483
+ tool: str, args: dict[str, Any], capability: Capability
484
+ ) -> dict[str, Any]:
485
+ """Build an exact runtime scope; approval-looking model arguments are ignored."""
486
+
487
+ allowed_keys = {
488
+ "delete_file": ("path",),
489
+ "run_shell": ("command", "cwd"),
490
+ "run_command": ("argv", "cwd"),
491
+ "git_push": ("branch",),
492
+ "git_pull": ("branch",),
493
+ "git_clone": ("url", "dest"),
494
+ "git_branch": ("name",),
495
+ "git_commit": ("message",),
496
+ }.get(tool, tuple())
497
+ return {
498
+ "tool": tool,
499
+ "capability": capability.value,
500
+ "args": {key: args.get(key) for key in allowed_keys},
501
+ }