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 ADDED
@@ -0,0 +1,3 @@
1
+ """OpenMuse: a local-first personal agent runtime."""
2
+
3
+ __version__ = "0.3.0a0"
@@ -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,4 @@
1
+ from .base import Channel, IncomingMessage
2
+ from .web import WebChannel
3
+
4
+ __all__ = ["Channel", "IncomingMessage", "WebChannel"]
@@ -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: ...
@@ -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