openmuse-agent 0.3.0a0__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.
- openmuse/__init__.py +3 -0
- openmuse/approval_ui.py +14 -0
- openmuse/approvals.py +60 -0
- openmuse/audit.py +64 -0
- openmuse/broker.py +77 -0
- openmuse/browser_worker.py +24 -0
- openmuse/channels/__init__.py +4 -0
- openmuse/channels/base.py +17 -0
- openmuse/channels/web.py +21 -0
- openmuse/cli.py +27 -0
- openmuse/connectors.py +130 -0
- openmuse/core.py +65 -0
- openmuse/effects.py +39 -0
- openmuse/ephemeral.py +32 -0
- openmuse/evidence.py +31 -0
- openmuse/google_calendar_connector.py +112 -0
- openmuse/google_mail_connector.py +116 -0
- openmuse/google_oauth.py +166 -0
- openmuse/host_events.py +57 -0
- openmuse/isolated_worker.py +73 -0
- openmuse/keyring_keys.py +54 -0
- openmuse/memory.py +192 -0
- openmuse/models.py +32 -0
- openmuse/policy.py +33 -0
- openmuse/providers/__init__.py +3 -0
- openmuse/providers/openai_compatible.py +35 -0
- openmuse/registry.py +21 -0
- openmuse/scheduler.py +133 -0
- openmuse/secrets.py +72 -0
- openmuse/simulated_connectors.py +69 -0
- openmuse/subagents.py +105 -0
- openmuse/task_ui.py +16 -0
- openmuse/tasks.py +98 -0
- openmuse/tools.py +159 -0
- openmuse/validation.py +59 -0
- openmuse_agent-0.3.0a0.dist-info/METADATA +179 -0
- openmuse_agent-0.3.0a0.dist-info/RECORD +40 -0
- openmuse_agent-0.3.0a0.dist-info/WHEEL +4 -0
- openmuse_agent-0.3.0a0.dist-info/entry_points.txt +2 -0
- openmuse_agent-0.3.0a0.dist-info/licenses/LICENSE +21 -0
openmuse/__init__.py
ADDED
openmuse/approval_ui.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Render a human-readable approval diff outside planner-controlled text."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
|
|
5
|
+
from .models import Action
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_approval(action: Action, identity: str, destination: str, cost: str | None = None) -> str:
|
|
9
|
+
rows = "".join(
|
|
10
|
+
f"<tr><th>{html.escape(str(k))}</th><td>{html.escape(str(v))}</td></tr>"
|
|
11
|
+
for k, v in sorted(action.arguments.items())
|
|
12
|
+
)
|
|
13
|
+
cost_line = f"<p><strong>Cost:</strong> {html.escape(cost)}</p>" if cost else ""
|
|
14
|
+
return f"<main><h1>Approve action</h1><p><strong>Identity:</strong> {html.escape(identity)}</p><p><strong>Destination:</strong> {html.escape(destination)}</p>{cost_line}<p><strong>Tool:</strong> {html.escape(action.tool)}</p><table>{rows}</table><p>Approval expires and can be used once.</p></main>"
|
openmuse/approvals.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Host-issued, action-bound, expiring, one-time approvals."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from .models import Action
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ApprovalAuthority:
|
|
15
|
+
"""Issues and verifies one-time approval tokens.
|
|
16
|
+
|
|
17
|
+
Expiry contract: a token is expired when ``now >= exp``; the exact
|
|
18
|
+
boundary second is already invalid. Consumption is atomic: no
|
|
19
|
+
interleaving of concurrent ``verify`` calls can consume one token twice.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
secret: bytes
|
|
23
|
+
used: set[str] = field(default_factory=set)
|
|
24
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
|
25
|
+
|
|
26
|
+
def issue(self, action: Action, ttl_seconds: int = 300) -> str:
|
|
27
|
+
payload = {
|
|
28
|
+
"id": action.id,
|
|
29
|
+
"tool": action.tool,
|
|
30
|
+
"arguments": action.arguments,
|
|
31
|
+
"exp": int(time.time()) + ttl_seconds,
|
|
32
|
+
}
|
|
33
|
+
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
34
|
+
return raw.hex() + "." + hmac.new(self.secret, raw, hashlib.sha256).hexdigest()
|
|
35
|
+
|
|
36
|
+
def verify(self, action: Action, token: str | None) -> bool:
|
|
37
|
+
if not token:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
raw_hex, sig = token.split(".", 1)
|
|
41
|
+
raw = bytes.fromhex(raw_hex)
|
|
42
|
+
p = json.loads(raw)
|
|
43
|
+
except (ValueError, TypeError, json.JSONDecodeError):
|
|
44
|
+
return False
|
|
45
|
+
if not isinstance(p, dict):
|
|
46
|
+
return False
|
|
47
|
+
expected = {"id": action.id, "tool": action.tool, "arguments": action.arguments, "exp": p.get("exp")}
|
|
48
|
+
try:
|
|
49
|
+
expired = int(p["exp"]) <= int(time.time())
|
|
50
|
+
except (KeyError, TypeError, ValueError):
|
|
51
|
+
return False
|
|
52
|
+
if expired or p != expected:
|
|
53
|
+
return False
|
|
54
|
+
if not hmac.compare_digest(sig, hmac.new(self.secret, raw, hashlib.sha256).hexdigest()):
|
|
55
|
+
return False
|
|
56
|
+
with self._lock:
|
|
57
|
+
if token in self.used:
|
|
58
|
+
return False
|
|
59
|
+
self.used.add(token)
|
|
60
|
+
return True
|
openmuse/audit.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Redacted, hash-chained JSONL audit trail."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
SENSITIVE = {"password", "token", "secret", "authorization", "api_key", "cookie"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact(v: Any) -> Any:
|
|
14
|
+
if isinstance(v, Mapping):
|
|
15
|
+
return {k: ("[REDACTED]" if k.lower() in SENSITIVE else redact(x)) for k, x in v.items()}
|
|
16
|
+
if isinstance(v, list):
|
|
17
|
+
return [redact(x) for x in v]
|
|
18
|
+
return v
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuditLog:
|
|
22
|
+
def __init__(self, path: Path):
|
|
23
|
+
self.path = path
|
|
24
|
+
|
|
25
|
+
def append(self, record: dict[str, Any]) -> None:
|
|
26
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
previous = "0" * 64
|
|
28
|
+
if self.path.exists():
|
|
29
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
30
|
+
previous = json.loads(lines[-1]).get("hash", previous) if lines else previous
|
|
31
|
+
clean = redact(record)
|
|
32
|
+
clean["previous_hash"] = previous
|
|
33
|
+
raw = json.dumps(clean, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
34
|
+
clean["hash"] = hashlib.sha256(raw.encode()).hexdigest()
|
|
35
|
+
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
36
|
+
with os.fdopen(fd, "a", encoding="utf-8") as h:
|
|
37
|
+
h.write(json.dumps(clean, ensure_ascii=False) + "\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_chain(path: Path) -> tuple[bool, int, str | None]:
|
|
41
|
+
"""Independently recompute every link in an audit JSONL chain."""
|
|
42
|
+
previous = "0" * 64
|
|
43
|
+
count = 0
|
|
44
|
+
try:
|
|
45
|
+
for count, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
46
|
+
record = json.loads(line)
|
|
47
|
+
claimed = record.pop("hash")
|
|
48
|
+
if record.get("previous_hash") != previous:
|
|
49
|
+
return False, count, "previous hash does not match"
|
|
50
|
+
raw = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
51
|
+
actual = hashlib.sha256(raw.encode()).hexdigest()
|
|
52
|
+
if not hmac_compare(actual, claimed):
|
|
53
|
+
return False, count, "record hash does not match"
|
|
54
|
+
previous = claimed
|
|
55
|
+
except (OSError, KeyError, json.JSONDecodeError, TypeError) as exc:
|
|
56
|
+
return False, count, str(exc)
|
|
57
|
+
return True, count, None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def hmac_compare(left: str, right: str) -> bool:
|
|
61
|
+
"""Constant-time compare without expanding the public API surface."""
|
|
62
|
+
import hmac
|
|
63
|
+
|
|
64
|
+
return hmac.compare_digest(left, right)
|
openmuse/broker.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Secrets broker: tools receive secrets through a side channel.
|
|
2
|
+
|
|
3
|
+
The planner proposes actions with ordinary arguments only. A tool that needs a
|
|
4
|
+
secret declares `secret_name`; the broker resolves it from the vault at
|
|
5
|
+
execution time and injects the plaintext into the tool's `run_with_secret`
|
|
6
|
+
callback, which the vault wipes after use. Secret values therefore never appear
|
|
7
|
+
in action arguments, tool manifests, planner context, or the audit log - only
|
|
8
|
+
the access itself is audited, by name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from .audit import AuditLog
|
|
15
|
+
from .policy import Risk
|
|
16
|
+
from .secrets import SecretVault
|
|
17
|
+
from .tools import ManifestMixin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecretConsumingTool(Protocol):
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
risk: Risk
|
|
24
|
+
secret_name: str
|
|
25
|
+
|
|
26
|
+
def manifest(self) -> dict[str, Any]: ...
|
|
27
|
+
def run_with_secret(self, secret: str, **kwargs: Any) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BrokerError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _BrokeredTool(ManifestMixin):
|
|
35
|
+
def __init__(self, inner: SecretConsumingTool, vault: SecretVault, audit: AuditLog | None) -> None:
|
|
36
|
+
schema = dict(inner.manifest().get("schema") or {})
|
|
37
|
+
properties = schema.get("properties") or {}
|
|
38
|
+
if "secret" in properties or inner.secret_name in properties:
|
|
39
|
+
raise BrokerError("secret arguments are forbidden in tool schemas")
|
|
40
|
+
self.inner = inner
|
|
41
|
+
self.vault = vault
|
|
42
|
+
self.audit = audit
|
|
43
|
+
self.name = inner.name
|
|
44
|
+
self.description = inner.description
|
|
45
|
+
self.risk = inner.risk
|
|
46
|
+
self.schema = schema
|
|
47
|
+
|
|
48
|
+
def run(self, **kwargs: Any) -> str:
|
|
49
|
+
if "secret" in kwargs or self.inner.secret_name in kwargs:
|
|
50
|
+
raise BrokerError("secrets never arrive as action arguments")
|
|
51
|
+
box: dict[str, str] = {}
|
|
52
|
+
|
|
53
|
+
def consume(plaintext: str) -> None:
|
|
54
|
+
box["output"] = self.inner.run_with_secret(plaintext, **kwargs)
|
|
55
|
+
|
|
56
|
+
self.vault.use(self.inner.secret_name, consume)
|
|
57
|
+
if self.audit is not None:
|
|
58
|
+
self.audit.append(
|
|
59
|
+
{
|
|
60
|
+
"event": "secret_access",
|
|
61
|
+
"tool": self.name,
|
|
62
|
+
"secret_name": self.inner.secret_name,
|
|
63
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return box["output"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SecretBroker:
|
|
70
|
+
"""Wraps secret-consuming tools so the planner never sees raw secrets."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, vault: SecretVault, audit: AuditLog | None = None) -> None:
|
|
73
|
+
self.vault = vault
|
|
74
|
+
self.audit = audit
|
|
75
|
+
|
|
76
|
+
def tool(self, inner: SecretConsumingTool) -> _BrokeredTool:
|
|
77
|
+
return _BrokeredTool(inner, self.vault, self.audit)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Policy envelope for a separately deployed browser worker."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class BrowserJob:
|
|
10
|
+
url: str
|
|
11
|
+
action: str
|
|
12
|
+
allowed_domains: frozenset[str]
|
|
13
|
+
read_only: bool = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BrowserWorkerPolicy:
|
|
17
|
+
MUTATIONS: ClassVar[set[str]] = {"click", "fill", "submit", "upload", "download"}
|
|
18
|
+
|
|
19
|
+
def validate(self, job: BrowserJob) -> None:
|
|
20
|
+
host = urlsplit(job.url).hostname
|
|
21
|
+
if not host or host not in job.allowed_domains:
|
|
22
|
+
raise ValueError("domain outside worker allowlist")
|
|
23
|
+
if job.read_only and job.action in self.MUTATIONS:
|
|
24
|
+
raise ValueError("mutation blocked in read-only worker")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Portable user-channel interface."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class IncomingMessage:
|
|
9
|
+
channel: str
|
|
10
|
+
conversation_id: str
|
|
11
|
+
sender_id: str
|
|
12
|
+
text: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Channel(Protocol):
|
|
16
|
+
def receive(self) -> IncomingMessage | None: ...
|
|
17
|
+
def send(self, conversation_id: str, text: str) -> str: ...
|
openmuse/channels/web.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Small in-process web-channel adapter for demos and tests."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
from .base import IncomingMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebChannel:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.inbox: deque[IncomingMessage] = deque()
|
|
11
|
+
self.outbox: list[tuple[str, str]] = []
|
|
12
|
+
|
|
13
|
+
def submit(self, conversation_id: str, sender_id: str, text: str) -> None:
|
|
14
|
+
self.inbox.append(IncomingMessage("web", conversation_id, sender_id, text))
|
|
15
|
+
|
|
16
|
+
def receive(self) -> IncomingMessage | None:
|
|
17
|
+
return self.inbox.popleft() if self.inbox else None
|
|
18
|
+
|
|
19
|
+
def send(self, conversation_id: str, text: str) -> str:
|
|
20
|
+
self.outbox.append((conversation_id, text))
|
|
21
|
+
return f"web:{len(self.outbox)}"
|
openmuse/cli.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .core import Agent
|
|
6
|
+
from .models import Action
|
|
7
|
+
from .policy import Policy
|
|
8
|
+
from .tools import FetchURL, ReadFile, WriteFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
p = argparse.ArgumentParser(prog="openmuse")
|
|
13
|
+
p.add_argument("tool", choices=["read_file", "write_file", "fetch_url"])
|
|
14
|
+
p.add_argument("--args", default="{}")
|
|
15
|
+
p.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
16
|
+
p.add_argument("--allow-writes", action="store_true")
|
|
17
|
+
a = p.parse_args()
|
|
18
|
+
w = a.workspace.resolve()
|
|
19
|
+
r = Agent([ReadFile(w), WriteFile(w), FetchURL()], Policy(a.allow_writes), w / ".openmuse/audit.jsonl").execute(
|
|
20
|
+
Action(a.tool, json.loads(a.args))
|
|
21
|
+
)
|
|
22
|
+
print(r.output)
|
|
23
|
+
raise SystemExit(0 if r.status.value == "completed" else 2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
openmuse/connectors.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Scoped connectors: least-privilege declarations enforced at the boundary.
|
|
2
|
+
|
|
3
|
+
A connector declares capabilities; each capability carries a scope string and a
|
|
4
|
+
risk tier. The runtime only routes a call when the host has granted that exact
|
|
5
|
+
scope to that connector, the grant is unexpired and unrevoked, and the action
|
|
6
|
+
passes the policy layer like any other exact action. Grants can never exceed
|
|
7
|
+
the connector's declared scopes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from .policy import Risk
|
|
16
|
+
from .tools import ManifestMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Capability:
|
|
21
|
+
name: str
|
|
22
|
+
scope: str
|
|
23
|
+
risk: Risk
|
|
24
|
+
schema: dict[str, Any] = field(default_factory=lambda: {"type": "object"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Connector(Protocol):
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
def capabilities(self) -> list[Capability]: ...
|
|
31
|
+
def invoke(self, capability: str, arguments: Mapping[str, Any]) -> str: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConnectorLifecycle(Protocol):
|
|
35
|
+
"""Optional lifecycle hook for connectors that retain local cached data."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
|
|
39
|
+
def delete_cached_data(self) -> None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Grant:
|
|
44
|
+
scopes: frozenset[str]
|
|
45
|
+
expires_at: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GrantError(ValueError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScopeGrants:
|
|
53
|
+
"""Host-owned grant ledger; the only writer of scope permissions."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._grants: dict[str, Grant] = {}
|
|
57
|
+
|
|
58
|
+
def grant(self, connector: Connector, scopes: set[str], ttl_seconds: int | None = None) -> Grant:
|
|
59
|
+
declared = {capability.scope for capability in connector.capabilities()}
|
|
60
|
+
if not scopes or not set(scopes) <= declared:
|
|
61
|
+
raise GrantError("grant exceeds the connector's declared scopes")
|
|
62
|
+
grant = Grant(
|
|
63
|
+
frozenset(scopes),
|
|
64
|
+
None if ttl_seconds is None else time.time() + ttl_seconds,
|
|
65
|
+
)
|
|
66
|
+
self._grants[connector.name] = grant
|
|
67
|
+
return grant
|
|
68
|
+
|
|
69
|
+
def revoke(self, connector_name: str) -> None:
|
|
70
|
+
self._grants.pop(connector_name, None)
|
|
71
|
+
|
|
72
|
+
def revoke_and_delete(self, connector: ConnectorLifecycle) -> None:
|
|
73
|
+
"""Revoke authority before deleting connector-owned cached data.
|
|
74
|
+
|
|
75
|
+
If deletion fails, access stays revoked and the error is surfaced so the
|
|
76
|
+
host can retry cleanup without reopening the connector boundary.
|
|
77
|
+
"""
|
|
78
|
+
self.revoke(connector.name)
|
|
79
|
+
connector.delete_cached_data()
|
|
80
|
+
|
|
81
|
+
def allows(self, connector_name: str, scope: str) -> bool:
|
|
82
|
+
grant = self._grants.get(connector_name)
|
|
83
|
+
if grant is None or scope not in grant.scopes:
|
|
84
|
+
return False
|
|
85
|
+
return grant.expires_at is None or time.time() < grant.expires_at
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ScopeDenied(ValueError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConnectorTool(ManifestMixin):
|
|
93
|
+
"""Expose one connector capability as a policy-checked tool."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, connector: Connector, capability: Capability, grants: ScopeGrants) -> None:
|
|
96
|
+
self.connector = connector
|
|
97
|
+
self.capability = capability
|
|
98
|
+
self.grants = grants
|
|
99
|
+
self.name = f"{connector.name}:{capability.name}"
|
|
100
|
+
self.description = f"Connector {connector.name} capability {capability.name} (scope {capability.scope})"
|
|
101
|
+
self.risk = capability.risk
|
|
102
|
+
self.schema = capability.schema
|
|
103
|
+
|
|
104
|
+
def run(self, **kwargs: Any) -> str:
|
|
105
|
+
if not self.grants.allows(self.connector.name, self.capability.scope):
|
|
106
|
+
raise ScopeDenied(f"scope not granted: {self.connector.name}/{self.capability.scope}")
|
|
107
|
+
return self.connector.invoke(self.capability.name, kwargs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def connector_tools(connector: Connector, grants: ScopeGrants) -> list[ConnectorTool]:
|
|
111
|
+
return [ConnectorTool(connector, capability, grants) for capability in connector.capabilities()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ReadOnlyConnector:
|
|
115
|
+
"""Base for read-only connectors: write and representation are undeclarable."""
|
|
116
|
+
|
|
117
|
+
name = "readonly"
|
|
118
|
+
|
|
119
|
+
def capabilities(self) -> list[Capability]:
|
|
120
|
+
declared = self.read_capabilities()
|
|
121
|
+
for capability in declared:
|
|
122
|
+
if capability.risk is not Risk.READ or not capability.scope.endswith(".read"):
|
|
123
|
+
raise GrantError("read-only connectors may only declare read capabilities")
|
|
124
|
+
return declared
|
|
125
|
+
|
|
126
|
+
def read_capabilities(self) -> list[Capability]:
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
def invoke(self, capability: str, arguments: Mapping[str, Any]) -> str:
|
|
130
|
+
raise NotImplementedError
|
openmuse/core.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .audit import AuditLog
|
|
7
|
+
from .models import Action, ActionStatus, ToolResult
|
|
8
|
+
from .policy import Policy
|
|
9
|
+
from .registry import ToolRegistry
|
|
10
|
+
from .validation import SchemaValidationError, validate_arguments
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Planner(Protocol):
|
|
14
|
+
def plan(self, goal: str, tools: list[dict], history: list[ToolResult]) -> Action | None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Agent:
|
|
18
|
+
def __init__(self, tools, policy: Policy, audit_log: Path) -> None:
|
|
19
|
+
self.registry = ToolRegistry(tools)
|
|
20
|
+
self.policy = policy
|
|
21
|
+
self.audit = AuditLog(audit_log)
|
|
22
|
+
|
|
23
|
+
def execute(self, action: Action) -> ToolResult:
|
|
24
|
+
tool = self.registry.get(action.tool)
|
|
25
|
+
if not tool:
|
|
26
|
+
return ToolResult(action.id, ActionStatus.FAILED, error_code="unknown_tool")
|
|
27
|
+
try:
|
|
28
|
+
validate_arguments(action.arguments, tool.manifest().get("schema"))
|
|
29
|
+
except SchemaValidationError as exc:
|
|
30
|
+
r = ToolResult(action.id, ActionStatus.FAILED, str(exc), "invalid_arguments")
|
|
31
|
+
self._audit(action, r)
|
|
32
|
+
return r
|
|
33
|
+
d = self.policy.check(tool.risk, action)
|
|
34
|
+
if not d.allowed:
|
|
35
|
+
r = ToolResult(action.id, ActionStatus.BLOCKED, d.reason, "approval_required")
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
r = ToolResult(action.id, ActionStatus.COMPLETED, str(tool.run(**dict(action.arguments))))
|
|
39
|
+
except (OSError, ValueError, TypeError, KeyError) as e:
|
|
40
|
+
r = ToolResult(action.id, ActionStatus.FAILED, str(e), type(e).__name__, False)
|
|
41
|
+
self._audit(action, r)
|
|
42
|
+
return r
|
|
43
|
+
|
|
44
|
+
def _audit(self, action: Action, result: ToolResult) -> None:
|
|
45
|
+
self.audit.append(
|
|
46
|
+
{
|
|
47
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
48
|
+
"action": {"id": action.id, "tool": action.tool, "arguments": action.arguments},
|
|
49
|
+
"status": result.status.value,
|
|
50
|
+
"error_code": result.error_code,
|
|
51
|
+
"output_sha256": hashlib.sha256(result.output.encode()).hexdigest(),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def run(self, goal: str, planner: Planner, max_steps: int = 8) -> list[ToolResult]:
|
|
56
|
+
history: list[ToolResult] = []
|
|
57
|
+
for _ in range(max_steps):
|
|
58
|
+
action = planner.plan(goal, self.registry.catalogue(), history)
|
|
59
|
+
if action is None:
|
|
60
|
+
break
|
|
61
|
+
result = self.execute(action)
|
|
62
|
+
history.append(result)
|
|
63
|
+
if result.status in {ActionStatus.BLOCKED, ActionStatus.FAILED}:
|
|
64
|
+
break
|
|
65
|
+
return history
|
openmuse/effects.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed, approval-bound external effect proposals."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class MessageEffect:
|
|
9
|
+
identity: str
|
|
10
|
+
recipient: str
|
|
11
|
+
body: str
|
|
12
|
+
channel: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ScheduleEffect:
|
|
17
|
+
identity: str
|
|
18
|
+
summary: str
|
|
19
|
+
starts_at: str
|
|
20
|
+
ends_at: str
|
|
21
|
+
attendees: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PurchaseEffect:
|
|
26
|
+
merchant: str
|
|
27
|
+
currency: str
|
|
28
|
+
subtotal: Decimal
|
|
29
|
+
tax: Decimal
|
|
30
|
+
shipping: Decimal
|
|
31
|
+
fees: Decimal
|
|
32
|
+
total: Decimal
|
|
33
|
+
address_hint: str
|
|
34
|
+
payment_hint: str
|
|
35
|
+
|
|
36
|
+
def validate(self) -> None:
|
|
37
|
+
expected = self.subtotal + self.tax + self.shipping + self.fees
|
|
38
|
+
if expected != self.total:
|
|
39
|
+
raise ValueError(f"final total mismatch: {expected} != {self.total}")
|
openmuse/ephemeral.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""One-time ephemeral values for OTP and credential-use grants."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class EphemeralBroker:
|
|
10
|
+
values: dict[str, tuple[str, float, str]] = field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
def issue(self, value: str, purpose: str, ttl_seconds: int = 120) -> str:
|
|
13
|
+
handle = uuid4().hex
|
|
14
|
+
self.values[handle] = (value, time.time() + ttl_seconds, purpose)
|
|
15
|
+
return handle
|
|
16
|
+
|
|
17
|
+
def consume(self, handle: str, purpose: str) -> str:
|
|
18
|
+
value, expires, bound = self.values.pop(handle)
|
|
19
|
+
if bound != purpose or expires < time.time():
|
|
20
|
+
raise ValueError("ephemeral grant invalid or expired")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CredentialUseGrant:
|
|
26
|
+
credential_handle: str
|
|
27
|
+
operation: str
|
|
28
|
+
origin: str
|
|
29
|
+
expires_at: float
|
|
30
|
+
|
|
31
|
+
def permits(self, operation: str, origin: str) -> bool:
|
|
32
|
+
return self.operation == operation and self.origin == origin and self.expires_at >= time.time()
|
openmuse/evidence.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Evidence, verification state, and bounded retry policy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Verification(str, Enum):
|
|
8
|
+
VERIFIED = "verified"
|
|
9
|
+
UNVERIFIED = "unverified"
|
|
10
|
+
CONFLICTED = "conflicted"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Evidence:
|
|
15
|
+
claim: str
|
|
16
|
+
source_url: str | None
|
|
17
|
+
verification: Verification
|
|
18
|
+
observed_at: str
|
|
19
|
+
detail: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RetryBudget:
|
|
24
|
+
maximum: int = 1
|
|
25
|
+
attempts: int = 0
|
|
26
|
+
|
|
27
|
+
def take(self, retryable: bool) -> bool:
|
|
28
|
+
if not retryable or self.attempts >= self.maximum:
|
|
29
|
+
return False
|
|
30
|
+
self.attempts += 1
|
|
31
|
+
return True
|