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.
- sable/__init__.py +3 -0
- sable/__main__.py +4 -0
- sable/_version.py +3 -0
- sable/automation.py +147 -0
- sable/capabilities.py +501 -0
- sable/cli.py +611 -0
- sable/cli_app.py +109 -0
- sable/cli_args.py +194 -0
- sable/cli_settings.py +215 -0
- sable/cli_workspace.py +262 -0
- sable/config.py +252 -0
- sable/context/__init__.py +4 -0
- sable/context/engine.py +602 -0
- sable/context/models.py +126 -0
- sable/doctor.py +475 -0
- sable/evals/__init__.py +68 -0
- sable/evals/__main__.py +3 -0
- sable/evals/assertions.py +210 -0
- sable/evals/baseline.py +157 -0
- sable/evals/cli.py +148 -0
- sable/evals/fixtures.py +75 -0
- sable/evals/metrics.py +112 -0
- sable/evals/models.py +534 -0
- sable/evals/provider.py +131 -0
- sable/evals/reporting.py +169 -0
- sable/evals/runner.py +148 -0
- sable/evals/system.py +953 -0
- sable/execution/__init__.py +78 -0
- sable/execution/base.py +136 -0
- sable/execution/native.py +261 -0
- sable/execution/proot.py +177 -0
- sable/groq_client.py +269 -0
- sable/main_agent.py +372 -0
- sable/orchestrator.py +900 -0
- sable/patches.py +197 -0
- sable/presentation.py +458 -0
- sable/project.py +168 -0
- sable/providers/__init__.py +25 -0
- sable/providers/anthropic.py +309 -0
- sable/providers/base.py +177 -0
- sable/providers/deepseek.py +11 -0
- sable/providers/factory.py +22 -0
- sable/providers/groq.py +7 -0
- sable/providers/openai.py +11 -0
- sable/providers/openai_compat.py +203 -0
- sable/providers/router.py +121 -0
- sable/runtime.py +349 -0
- sable/security.py +389 -0
- sable/sessions/__init__.py +4 -0
- sable/sessions/manager.py +365 -0
- sable/sessions/models.py +72 -0
- sable/tool_schemas.py +202 -0
- sable/tools/__init__.py +6 -0
- sable/tools/base.py +409 -0
- sable/tools/commands.py +76 -0
- sable/tools/context.py +100 -0
- sable/tools/executor.py +252 -0
- sable/tools/files_read.py +194 -0
- sable/tools/files_write.py +403 -0
- sable/tools/git.py +340 -0
- sable/transactions.py +984 -0
- sable/ui.py +93 -0
- sable/verification/__init__.py +72 -0
- sable/verification/adapters.py +682 -0
- sable/verification/affected.py +235 -0
- sable/verification/classifiers.py +105 -0
- sable/verification/discovery.py +166 -0
- sable/verification/integrity.py +345 -0
- sable/verification/models.py +472 -0
- sable/verification/planner.py +222 -0
- sable/verification/runner.py +274 -0
- sable/verifier.py +117 -0
- sableai-2.0.0.dist-info/METADATA +454 -0
- sableai-2.0.0.dist-info/RECORD +78 -0
- sableai-2.0.0.dist-info/WHEEL +5 -0
- sableai-2.0.0.dist-info/entry_points.txt +2 -0
- sableai-2.0.0.dist-info/licenses/LICENSE +21 -0
- sableai-2.0.0.dist-info/top_level.txt +1 -0
sable/__init__.py
ADDED
sable/__main__.py
ADDED
sable/_version.py
ADDED
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
|
+
}
|