pulse-coding-agent 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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pulse.telemetry.cost_tracker import (
|
|
2
|
+
BudgetExceededError,
|
|
3
|
+
CostTracker,
|
|
4
|
+
ModelPricing,
|
|
5
|
+
UsageRecord,
|
|
6
|
+
)
|
|
7
|
+
from pulse.telemetry.logger import (
|
|
8
|
+
MetricEvent,
|
|
9
|
+
TelemetryLogger,
|
|
10
|
+
correlation_scope,
|
|
11
|
+
get_correlation_id,
|
|
12
|
+
set_correlation_id,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BudgetExceededError",
|
|
17
|
+
"CostTracker",
|
|
18
|
+
"MetricEvent",
|
|
19
|
+
"ModelPricing",
|
|
20
|
+
"TelemetryLogger",
|
|
21
|
+
"UsageRecord",
|
|
22
|
+
"correlation_scope",
|
|
23
|
+
"get_correlation_id",
|
|
24
|
+
"set_correlation_id",
|
|
25
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BudgetExceededError(Exception):
|
|
8
|
+
"""Raised when token or cost budget limit is exceeded."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ModelPricing:
|
|
13
|
+
prompt_price_per_1k: float
|
|
14
|
+
completion_price_per_1k: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class UsageRecord:
|
|
19
|
+
prompt_tokens: int
|
|
20
|
+
completion_tokens: int
|
|
21
|
+
total_tokens: int
|
|
22
|
+
estimated_cost: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CostTracker:
|
|
26
|
+
"""Tracks prompt/completion token usage across models (OpenAI, Gemini, OpenRouter).
|
|
27
|
+
|
|
28
|
+
Enforces maximum daily/session token and cost budgets.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
DEFAULT_PRICING: ClassVar[dict[str, ModelPricing]] = {
|
|
32
|
+
"gpt-4o": ModelPricing(0.0025, 0.0100),
|
|
33
|
+
"gpt-4o-mini": ModelPricing(0.00015, 0.0006),
|
|
34
|
+
"gemini-2.0-flash": ModelPricing(0.0001, 0.0004),
|
|
35
|
+
"openrouter/auto": ModelPricing(0.0010, 0.0030),
|
|
36
|
+
"default": ModelPricing(0.0015, 0.0050),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
max_session_tokens: int | None = None,
|
|
42
|
+
max_session_cost: float | None = None,
|
|
43
|
+
pricing: dict[str, ModelPricing] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.max_session_tokens = max_session_tokens
|
|
46
|
+
self.max_session_cost = max_session_cost
|
|
47
|
+
self.pricing = pricing or self.DEFAULT_PRICING
|
|
48
|
+
|
|
49
|
+
self._total_prompt_tokens: int = 0
|
|
50
|
+
self._total_completion_tokens: int = 0
|
|
51
|
+
self._total_cost: float = 0.0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def total_tokens(self) -> int:
|
|
55
|
+
return self._total_prompt_tokens + self._total_completion_tokens
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def total_cost(self) -> float:
|
|
59
|
+
return round(self._total_cost, 6)
|
|
60
|
+
|
|
61
|
+
def record_usage(self, model: str, prompt_tokens: int, completion_tokens: int) -> UsageRecord:
|
|
62
|
+
added_tokens = prompt_tokens + completion_tokens
|
|
63
|
+
|
|
64
|
+
if self.max_session_tokens is not None and (self.total_tokens + added_tokens) > self.max_session_tokens:
|
|
65
|
+
raise BudgetExceededError(
|
|
66
|
+
f"Session token budget exceeded: {self.total_tokens + added_tokens} > {self.max_session_tokens}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
model_key = model.lower()
|
|
70
|
+
pricing = self.pricing.get(model_key) or self.pricing.get("default", ModelPricing(0.0015, 0.0050))
|
|
71
|
+
|
|
72
|
+
prompt_cost = (prompt_tokens / 1000.0) * pricing.prompt_price_per_1k
|
|
73
|
+
completion_cost = (completion_tokens / 1000.0) * pricing.completion_price_per_1k
|
|
74
|
+
cost = prompt_cost + completion_cost
|
|
75
|
+
|
|
76
|
+
if self.max_session_cost is not None and (self._total_cost + cost) > self.max_session_cost:
|
|
77
|
+
raise BudgetExceededError(
|
|
78
|
+
f"Session cost budget exceeded: ${self._total_cost + cost:.4f} > ${self.max_session_cost:.4f}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._total_prompt_tokens += prompt_tokens
|
|
82
|
+
self._total_completion_tokens += completion_tokens
|
|
83
|
+
self._total_cost += cost
|
|
84
|
+
|
|
85
|
+
return UsageRecord(
|
|
86
|
+
prompt_tokens=prompt_tokens,
|
|
87
|
+
completion_tokens=completion_tokens,
|
|
88
|
+
total_tokens=added_tokens,
|
|
89
|
+
estimated_cost=round(cost, 6),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def reset(self) -> None:
|
|
93
|
+
self._total_prompt_tokens = 0
|
|
94
|
+
self._total_completion_tokens = 0
|
|
95
|
+
self._total_cost = 0.0
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pulse.sandbox.secrets import SecretScrubber
|
|
14
|
+
|
|
15
|
+
_CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
16
|
+
"pulse_correlation_id", default=None
|
|
17
|
+
)
|
|
18
|
+
_SAFE_CORRELATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_correlation_id(value: object | None = None) -> str:
|
|
22
|
+
candidate = str(value).strip() if value is not None else ""
|
|
23
|
+
correlation_id = (
|
|
24
|
+
candidate if _SAFE_CORRELATION_ID.fullmatch(candidate) else uuid.uuid4().hex
|
|
25
|
+
)
|
|
26
|
+
_CORRELATION_ID.set(correlation_id)
|
|
27
|
+
return correlation_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_correlation_id() -> str:
|
|
31
|
+
return _CORRELATION_ID.get() or set_correlation_id()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def correlation_scope(value: object | None = None):
|
|
36
|
+
correlation_id = str(value).strip() if value is not None else ""
|
|
37
|
+
if not _SAFE_CORRELATION_ID.fullmatch(correlation_id):
|
|
38
|
+
correlation_id = uuid.uuid4().hex
|
|
39
|
+
token = _CORRELATION_ID.set(correlation_id)
|
|
40
|
+
try:
|
|
41
|
+
yield correlation_id
|
|
42
|
+
finally:
|
|
43
|
+
_CORRELATION_ID.reset(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MetricEvent:
|
|
48
|
+
schema_version: int = field(default=1, init=False)
|
|
49
|
+
timestamp: str
|
|
50
|
+
correlation_id: str
|
|
51
|
+
event_type: str
|
|
52
|
+
step: int | None
|
|
53
|
+
duration_ms: float | None
|
|
54
|
+
metadata: dict[str, Any]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TelemetryLogger:
|
|
58
|
+
"""Lightweight structured logger for agent step telemetry and execution metrics."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, log_path: Path | None = None) -> None:
|
|
61
|
+
self.log_path = log_path
|
|
62
|
+
self._scrubber = SecretScrubber()
|
|
63
|
+
|
|
64
|
+
def add_secret(self, secret: str | None) -> None:
|
|
65
|
+
if secret:
|
|
66
|
+
self._scrubber.add_secret(secret)
|
|
67
|
+
|
|
68
|
+
def _sanitize(self, value: Any) -> Any:
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
return self._scrubber.redact(value)
|
|
71
|
+
if isinstance(value, dict):
|
|
72
|
+
return {str(key): self._sanitize(item) for key, item in value.items()}
|
|
73
|
+
if isinstance(value, (list, tuple)):
|
|
74
|
+
return [self._sanitize(item) for item in value]
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
def log_event(
|
|
78
|
+
self,
|
|
79
|
+
event_type: str,
|
|
80
|
+
step: int | None = None,
|
|
81
|
+
duration_ms: float | None = None,
|
|
82
|
+
**metadata: Any,
|
|
83
|
+
) -> MetricEvent:
|
|
84
|
+
event = MetricEvent(
|
|
85
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
86
|
+
correlation_id=get_correlation_id(),
|
|
87
|
+
event_type=event_type,
|
|
88
|
+
step=step,
|
|
89
|
+
duration_ms=duration_ms,
|
|
90
|
+
metadata=self._sanitize(metadata),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if self.log_path:
|
|
94
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with self.log_path.open("a", encoding="utf-8") as handle:
|
|
96
|
+
handle.write(json.dumps(asdict(event), separators=(",", ":")) + "\n")
|
|
97
|
+
|
|
98
|
+
return event
|
|
99
|
+
|
|
100
|
+
def log_step_execution(
|
|
101
|
+
self, step: int, tool_name: str | None, duration_ms: float, success: bool, **kwargs: Any
|
|
102
|
+
) -> MetricEvent:
|
|
103
|
+
return self.log_event(
|
|
104
|
+
event_type="step_execution",
|
|
105
|
+
step=step,
|
|
106
|
+
duration_ms=duration_ms,
|
|
107
|
+
tool_name=tool_name,
|
|
108
|
+
success=success,
|
|
109
|
+
**kwargs,
|
|
110
|
+
)
|
pulse/tool_policy.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Typed contracts and centralized authorization for model tool calls.
|
|
2
|
+
|
|
3
|
+
Tool invocations are untrusted model output. This module keeps validation and
|
|
4
|
+
authorization ahead of execution, with an explicit decision that can be
|
|
5
|
+
audited without recording sensitive argument values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolRisk(str, Enum):
|
|
18
|
+
"""Impact level used to decide whether human approval is required."""
|
|
19
|
+
|
|
20
|
+
LOW = "low"
|
|
21
|
+
MEDIUM = "medium"
|
|
22
|
+
HIGH = "high"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthorizationDecision(str, Enum):
|
|
26
|
+
"""The only authorization outcomes available to the registry."""
|
|
27
|
+
|
|
28
|
+
ALLOW = "allow"
|
|
29
|
+
ASK = "ask"
|
|
30
|
+
DENY = "deny"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArgumentKind(str, Enum):
|
|
34
|
+
"""Supported wire-level argument types for a tool schema."""
|
|
35
|
+
|
|
36
|
+
BOOLEAN = "boolean"
|
|
37
|
+
INTEGER = "integer"
|
|
38
|
+
NUMBER = "number"
|
|
39
|
+
STRING = "string"
|
|
40
|
+
OBJECT = "object"
|
|
41
|
+
ARRAY = "array"
|
|
42
|
+
CALLABLE = "callable"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ToolArgument:
|
|
47
|
+
"""A named, typed argument accepted by one tool."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
kind: ArgumentKind
|
|
51
|
+
required: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ToolSchema:
|
|
56
|
+
"""Strict schema for untrusted tool arguments.
|
|
57
|
+
|
|
58
|
+
Unknown arguments are rejected by default. A tool may opt out only where
|
|
59
|
+
it intentionally implements a command-style sub-protocol itself.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
arguments: tuple[ToolArgument, ...] = ()
|
|
63
|
+
allow_extra: bool = False
|
|
64
|
+
|
|
65
|
+
def validate(self, arguments: Mapping[str, Any]) -> str | None:
|
|
66
|
+
declared = {argument.name: argument for argument in self.arguments}
|
|
67
|
+
for argument in self.arguments:
|
|
68
|
+
if argument.required and argument.name not in arguments:
|
|
69
|
+
return f"Missing required argument '{argument.name}'."
|
|
70
|
+
|
|
71
|
+
if not self.allow_extra:
|
|
72
|
+
extras = sorted(set(arguments) - set(declared))
|
|
73
|
+
if extras:
|
|
74
|
+
return f"Unexpected argument(s): {', '.join(extras)}."
|
|
75
|
+
|
|
76
|
+
for name, value in arguments.items():
|
|
77
|
+
schema_argument = declared.get(name)
|
|
78
|
+
if schema_argument and not _matches_kind(value, schema_argument.kind):
|
|
79
|
+
return f"Argument '{name}' must be a {schema_argument.kind.value}."
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _matches_kind(value: Any, kind: ArgumentKind) -> bool:
|
|
84
|
+
if kind == ArgumentKind.BOOLEAN:
|
|
85
|
+
return isinstance(value, bool)
|
|
86
|
+
if kind == ArgumentKind.INTEGER:
|
|
87
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
88
|
+
if kind == ArgumentKind.NUMBER:
|
|
89
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
90
|
+
if kind == ArgumentKind.STRING:
|
|
91
|
+
return isinstance(value, str)
|
|
92
|
+
if kind == ArgumentKind.OBJECT:
|
|
93
|
+
return isinstance(value, Mapping)
|
|
94
|
+
if kind == ArgumentKind.ARRAY:
|
|
95
|
+
return isinstance(value, list)
|
|
96
|
+
return callable(value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class ToolAuthorization:
|
|
101
|
+
"""An inspectable authorization result, safe to put in an audit log."""
|
|
102
|
+
|
|
103
|
+
decision: AuthorizationDecision
|
|
104
|
+
reason: str
|
|
105
|
+
subject_id: str
|
|
106
|
+
capability: str
|
|
107
|
+
risk: ToolRisk
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AuditRecorder(Protocol):
|
|
111
|
+
def record(self, action: str, file: str, detail: str) -> None: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class ToolPolicyEngine:
|
|
116
|
+
"""Evaluate a capability allowlist, workspace scope, and approval policy."""
|
|
117
|
+
|
|
118
|
+
workspace: Path
|
|
119
|
+
subject_id: str = "local-user"
|
|
120
|
+
allowed_capabilities: frozenset[str] = field(default_factory=frozenset)
|
|
121
|
+
approval_risks: frozenset[ToolRisk] = field(
|
|
122
|
+
default_factory=lambda: frozenset({ToolRisk.MEDIUM, ToolRisk.HIGH})
|
|
123
|
+
)
|
|
124
|
+
audit_log: AuditRecorder | None = None
|
|
125
|
+
|
|
126
|
+
def authorize(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
tool_name: str,
|
|
130
|
+
capability: str,
|
|
131
|
+
risk: ToolRisk,
|
|
132
|
+
arguments: Mapping[str, Any],
|
|
133
|
+
) -> ToolAuthorization:
|
|
134
|
+
"""Return a deterministic decision before an executor sees arguments."""
|
|
135
|
+
if capability not in self.allowed_capabilities:
|
|
136
|
+
return self._decision(
|
|
137
|
+
AuthorizationDecision.DENY,
|
|
138
|
+
f"Capability '{capability}' is not allowlisted for this runtime.",
|
|
139
|
+
capability,
|
|
140
|
+
risk,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
target_error = self._validate_workspace_scope(arguments)
|
|
144
|
+
if target_error:
|
|
145
|
+
return self._decision(AuthorizationDecision.DENY, target_error, capability, risk)
|
|
146
|
+
|
|
147
|
+
if risk in self.approval_risks:
|
|
148
|
+
return self._decision(
|
|
149
|
+
AuthorizationDecision.ASK,
|
|
150
|
+
f"{risk.value.capitalize()}-risk capability '{tool_name}' requires approval.",
|
|
151
|
+
capability,
|
|
152
|
+
risk,
|
|
153
|
+
)
|
|
154
|
+
return self._decision(
|
|
155
|
+
AuthorizationDecision.ALLOW,
|
|
156
|
+
f"Capability '{tool_name}' is allowlisted for {self.subject_id}.",
|
|
157
|
+
capability,
|
|
158
|
+
risk,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def record(self, authorization: ToolAuthorization) -> None:
|
|
162
|
+
"""Record a decision without persisting untrusted tool argument values."""
|
|
163
|
+
if self.audit_log:
|
|
164
|
+
self.audit_log.record(
|
|
165
|
+
f"tool-policy-{authorization.decision.value}",
|
|
166
|
+
authorization.capability,
|
|
167
|
+
(
|
|
168
|
+
f"subject={authorization.subject_id} risk={authorization.risk.value}; "
|
|
169
|
+
f"{authorization.reason}"
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _decision(
|
|
174
|
+
self,
|
|
175
|
+
decision: AuthorizationDecision,
|
|
176
|
+
reason: str,
|
|
177
|
+
capability: str,
|
|
178
|
+
risk: ToolRisk,
|
|
179
|
+
) -> ToolAuthorization:
|
|
180
|
+
return ToolAuthorization(decision, reason, self.subject_id, capability, risk)
|
|
181
|
+
|
|
182
|
+
def _validate_workspace_scope(self, arguments: Mapping[str, Any]) -> str | None:
|
|
183
|
+
"""Reject paths outside the configured workspace before execution."""
|
|
184
|
+
root = self.workspace.resolve()
|
|
185
|
+
for key in ("file", "path", "source", "destination", "cwd", "working_directory"):
|
|
186
|
+
value = arguments.get(key)
|
|
187
|
+
if value is None:
|
|
188
|
+
continue
|
|
189
|
+
if not isinstance(value, str):
|
|
190
|
+
return f"Workspace argument '{key}' must be a string path."
|
|
191
|
+
candidate = Path(value)
|
|
192
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
193
|
+
try:
|
|
194
|
+
resolved.relative_to(root)
|
|
195
|
+
except ValueError:
|
|
196
|
+
return f"Workspace argument '{key}' escapes the authorized workspace."
|
|
197
|
+
return None
|
pulse/tool_registry.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Async, UI-independent registration and execution of Pulse capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from pulse.telemetry import get_correlation_id, set_correlation_id
|
|
12
|
+
from pulse.tool_policy import (
|
|
13
|
+
AuthorizationDecision,
|
|
14
|
+
ToolPolicyEngine,
|
|
15
|
+
ToolRisk,
|
|
16
|
+
ToolSchema,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ToolInvocation:
|
|
22
|
+
name: str | None = None
|
|
23
|
+
arguments: Mapping[str, Any] = field(default_factory=dict)
|
|
24
|
+
message: str = ""
|
|
25
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ToolResult:
|
|
30
|
+
content: str
|
|
31
|
+
terminal: bool = True
|
|
32
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Tool(Protocol):
|
|
36
|
+
"""Implement this small contract, then register an instance."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
requires_permission: bool
|
|
41
|
+
capability: str
|
|
42
|
+
risk: ToolRisk
|
|
43
|
+
schema: ToolSchema | None
|
|
44
|
+
|
|
45
|
+
def matches(self, invocation: ToolInvocation) -> bool: ...
|
|
46
|
+
|
|
47
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
PermissionChecker = Callable[[ToolInvocation, Tool], Awaitable[bool]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ToolRegistry:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
tools: Iterable[Tool] = (),
|
|
57
|
+
*,
|
|
58
|
+
permission_checker: PermissionChecker | None = None,
|
|
59
|
+
policy_engine: ToolPolicyEngine | None = None,
|
|
60
|
+
telemetry: Any | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._tools: dict[str, Tool] = {}
|
|
63
|
+
self._permission_checker = permission_checker
|
|
64
|
+
self._policy_engine = policy_engine
|
|
65
|
+
self._telemetry = telemetry
|
|
66
|
+
for tool in tools:
|
|
67
|
+
self.register(tool)
|
|
68
|
+
|
|
69
|
+
def register(self, tool: Tool) -> None:
|
|
70
|
+
if tool.name in self._tools:
|
|
71
|
+
raise ValueError(f"Tool already registered: {tool.name}")
|
|
72
|
+
self._tools[tool.name] = tool
|
|
73
|
+
|
|
74
|
+
def discover(self) -> tuple[Tool, ...]:
|
|
75
|
+
return tuple(self._tools.values())
|
|
76
|
+
|
|
77
|
+
def get(self, name: str) -> Tool | None:
|
|
78
|
+
return self._tools.get(name)
|
|
79
|
+
|
|
80
|
+
def match(self, invocation: ToolInvocation) -> Tool | None:
|
|
81
|
+
if invocation.name:
|
|
82
|
+
return self.get(invocation.name)
|
|
83
|
+
return next((tool for tool in self._tools.values() if tool.matches(invocation)), None)
|
|
84
|
+
|
|
85
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult | None:
|
|
86
|
+
tool = self.match(invocation)
|
|
87
|
+
if tool is None:
|
|
88
|
+
return None
|
|
89
|
+
correlation_id = set_correlation_id(
|
|
90
|
+
invocation.metadata.get("correlation_id") or get_correlation_id()
|
|
91
|
+
)
|
|
92
|
+
started = time.perf_counter()
|
|
93
|
+
if self._telemetry:
|
|
94
|
+
self._telemetry.log_event(
|
|
95
|
+
"tool_started", tool_name=tool.name, correlation_id=correlation_id
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def finish(result: ToolResult) -> ToolResult:
|
|
99
|
+
result.metadata.setdefault("correlation_id", correlation_id)
|
|
100
|
+
if self._telemetry:
|
|
101
|
+
self._telemetry.log_event(
|
|
102
|
+
"tool_completed",
|
|
103
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
104
|
+
tool_name=tool.name,
|
|
105
|
+
error_code=result.metadata.get("error_code"),
|
|
106
|
+
)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
schema = getattr(tool, "schema", None)
|
|
110
|
+
if schema:
|
|
111
|
+
validation_error = schema.validate(invocation.arguments)
|
|
112
|
+
if validation_error:
|
|
113
|
+
return finish(ToolResult(
|
|
114
|
+
f"Invalid arguments for {tool.name}: {validation_error}",
|
|
115
|
+
metadata={"error_code": "invalid_tool_arguments", "validation_error": validation_error},
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
requires_approval = bool(getattr(tool, "requires_permission", False))
|
|
119
|
+
policy_requires_approval = False
|
|
120
|
+
if self._policy_engine:
|
|
121
|
+
risk = getattr(tool, "risk", ToolRisk.LOW)
|
|
122
|
+
capability = getattr(tool, "capability", None) or tool.name
|
|
123
|
+
authorization = self._policy_engine.authorize(
|
|
124
|
+
tool_name=tool.name,
|
|
125
|
+
capability=capability,
|
|
126
|
+
risk=risk,
|
|
127
|
+
arguments=invocation.arguments,
|
|
128
|
+
)
|
|
129
|
+
self._policy_engine.record(authorization)
|
|
130
|
+
if authorization.decision == AuthorizationDecision.DENY:
|
|
131
|
+
return finish(ToolResult(
|
|
132
|
+
f"Tool denied for {tool.name}: {authorization.reason}",
|
|
133
|
+
metadata={"error_code": "tool_policy_denied", "policy_reason": authorization.reason},
|
|
134
|
+
))
|
|
135
|
+
policy_requires_approval = authorization.decision == AuthorizationDecision.ASK
|
|
136
|
+
requires_approval = requires_approval or policy_requires_approval
|
|
137
|
+
|
|
138
|
+
approval_denied = (
|
|
139
|
+
policy_requires_approval and self._permission_checker is None
|
|
140
|
+
) or (
|
|
141
|
+
requires_approval
|
|
142
|
+
and self._permission_checker is not None
|
|
143
|
+
and not await self._permission_checker(invocation, tool)
|
|
144
|
+
)
|
|
145
|
+
if approval_denied:
|
|
146
|
+
return finish(ToolResult(
|
|
147
|
+
f"Permission denied for {tool.name}.",
|
|
148
|
+
metadata={"permission_denied": True, "error_code": "tool_approval_denied"},
|
|
149
|
+
))
|
|
150
|
+
try:
|
|
151
|
+
return finish(await tool.execute(invocation))
|
|
152
|
+
except Exception:
|
|
153
|
+
if self._telemetry:
|
|
154
|
+
self._telemetry.log_event(
|
|
155
|
+
"tool_failed",
|
|
156
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
157
|
+
tool_name=tool.name,
|
|
158
|
+
)
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
async def execute_many(self, invocations: Iterable[ToolInvocation]) -> list[ToolResult | None]:
|
|
162
|
+
"""Independent tools may run concurrently; callers retain input ordering."""
|
|
163
|
+
return list(await asyncio.gather(*(self.execute(invocation) for invocation in invocations)))
|