portmark 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.
portmark/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Portable, provider-neutral agent runtime."""
2
+
3
+ from .host import AgentHost
4
+ from .models import AgentEnvelope, AgentManifest, AgentState, Permit
5
+
6
+ __all__ = ["AgentEnvelope", "AgentHost", "AgentManifest", "AgentState", "Permit"]
7
+
portmark/a2a.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from typing import Any
10
+
11
+ from .a2a_types import A2ARequestError, error_response, make_agent_card, parse_jsonrpc_request, success_response, task_from_run_result
12
+ from .host import AgentHost
13
+ from .models import AgentEnvelope, AgentManifest, AgentState, AttestationEvidence, Permit, ResourceBudget, ToolGrant
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+ MAX_REQUEST_BYTES = 1_000_000
18
+
19
+
20
+ def envelope_from_dict(value: dict[str, Any]) -> AgentEnvelope:
21
+ manifest = AgentManifest(**{**value["manifest"], "requested_tools": tuple(value["manifest"]["requested_tools"])})
22
+ permit_value = value["permit"]
23
+ permit = Permit(
24
+ issuer=permit_value["issuer"], subject=permit_value["subject"], audience=permit_value["audience"],
25
+ expires_at=permit_value["expires_at"], nonce=permit_value["nonce"],
26
+ grants=tuple(ToolGrant(**grant) for grant in permit_value["grants"]),
27
+ budget=ResourceBudget(**permit_value.get("budget", {})),
28
+ delegation_allowed=permit_value.get("delegation_allowed", False),
29
+ attestation=AttestationEvidence(**permit_value["attestation"]) if permit_value.get("attestation") else None,
30
+ )
31
+ return AgentEnvelope(
32
+ manifest=manifest,
33
+ permit=permit,
34
+ state=AgentState(**value["state"]),
35
+ previous_audit_hash=value.get("previous_audit_hash", ""),
36
+ signature_key_id=value.get("signature_key_id", ""),
37
+ signature=value["signature"],
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class A2AAuthConfig:
43
+ bearer_token: str | None = None
44
+ realm: str = "portmark"
45
+
46
+ @property
47
+ def required(self) -> bool:
48
+ return bool(self.bearer_token)
49
+
50
+
51
+ def make_handler(host: AgentHost, auth: A2AAuthConfig | None = None, enable_hsts: bool = False):
52
+ auth_config = auth or A2AAuthConfig()
53
+
54
+ class A2AHandler(BaseHTTPRequestHandler):
55
+ server_version = "PortableAgentA2A/1.0"
56
+
57
+ def _json(self, status: int, value: Any, headers: dict[str, str] | None = None) -> None:
58
+ payload = json.dumps(value).encode()
59
+ self.send_response(status)
60
+ self.send_header("Content-Type", "application/json")
61
+ self.send_header("Content-Length", str(len(payload)))
62
+ self.send_header("X-Content-Type-Options", "nosniff")
63
+ self.send_header("Referrer-Policy", "no-referrer")
64
+ self.send_header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
65
+ self.send_header("X-Frame-Options", "DENY")
66
+ self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'")
67
+ if enable_hsts:
68
+ self.send_header("Strict-Transport-Security", "max-age=31536000")
69
+ if headers:
70
+ for key, value in headers.items():
71
+ self.send_header(key, value)
72
+ self.end_headers()
73
+ self.wfile.write(payload)
74
+
75
+ def do_GET(self) -> None:
76
+ if self.path == "/.well-known/agent-card.json":
77
+ self._json(200, make_agent_card(self._base_url(), auth_config.required))
78
+ else:
79
+ self._json(404, {"error": "not found"})
80
+
81
+ def do_POST(self) -> None:
82
+ if self.path != "/message:send":
83
+ self._json(404, error_response(None, -32601, "method not found"))
84
+ return
85
+ if not self._authorized(auth_config):
86
+ self._json(
87
+ 401,
88
+ error_response(None, -32001, "unauthorized"),
89
+ {"WWW-Authenticate": f'Bearer realm="{auth_config.realm}"'},
90
+ )
91
+ return
92
+ request_id: str | int | None = None
93
+ try:
94
+ if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
95
+ raise A2ARequestError(-32600, "invalid request", 415)
96
+ try:
97
+ size = int(self.headers.get("Content-Length", "0"))
98
+ except ValueError as exc:
99
+ raise A2ARequestError(-32600, "invalid request", 400) from exc
100
+ if size <= 0 or size > MAX_REQUEST_BYTES:
101
+ raise A2ARequestError(-32600, "invalid request", 413)
102
+ try:
103
+ payload = json.loads(self.rfile.read(size))
104
+ except json.JSONDecodeError as exc:
105
+ raise A2ARequestError(-32700, "parse error", 400) from exc
106
+ request = parse_jsonrpc_request(payload)
107
+ request_id = request.id
108
+ envelope = envelope_from_dict(request.params.portmark_envelope)
109
+ result = host.run(envelope)
110
+ self._json(200, success_response(request.id, task_from_run_result(result)))
111
+ except A2ARequestError as exc:
112
+ self._json(exc.http_status, error_response(exc.request_id, exc.code, exc.message))
113
+ except Exception:
114
+ logger.exception("A2A message submission failed")
115
+ self._json(400, error_response(request_id, -32000, "message submission failed"))
116
+
117
+ def _base_url(self) -> str:
118
+ return f"http://{self.headers.get('Host', '127.0.0.1')}"
119
+
120
+ def _authorized(self, config: A2AAuthConfig) -> bool:
121
+ if not config.bearer_token:
122
+ return True
123
+ header = self.headers.get("Authorization", "")
124
+ prefix = "Bearer "
125
+ if not header.startswith(prefix):
126
+ return False
127
+ return secrets.compare_digest(header[len(prefix):], config.bearer_token)
128
+
129
+ def log_message(self, format: str, *args: Any) -> None:
130
+ return
131
+
132
+ return A2AHandler
133
+
134
+
135
+ def auth_from_environment() -> A2AAuthConfig:
136
+ return A2AAuthConfig(os.environ.get("PORTMARK_A2A_TOKEN"))
137
+
138
+
139
+ def serve(host: AgentHost, bind: str, port: int, auth: A2AAuthConfig | None = None, enable_hsts: bool = False) -> None:
140
+ ThreadingHTTPServer((bind, port), make_handler(host, auth or auth_from_environment(), enable_hsts)).serve_forever()
portmark/a2a_types.py ADDED
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ A2A_PROTOCOL_VERSION = "1.0"
8
+ JSONRPC_VERSION = "2.0"
9
+ MESSAGE_SEND_METHOD = "message/send"
10
+
11
+
12
+ class A2ARequestError(RuntimeError):
13
+ def __init__(self, code: int, message: str, http_status: int = 400, request_id: str | int | None = None):
14
+ super().__init__(message)
15
+ self.code = code
16
+ self.message = message
17
+ self.http_status = http_status
18
+ self.request_id = request_id
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AgentInterface:
23
+ url: str
24
+ protocolBinding: str = "JSONRPC"
25
+ protocolVersion: str = A2A_PROTOCOL_VERSION
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AgentCapabilities:
30
+ streaming: bool = False
31
+ pushNotifications: bool = False
32
+ stateTransitionHistory: bool = False
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AgentSkill:
37
+ id: str
38
+ name: str
39
+ description: str
40
+ inputModes: tuple[str, ...] = ("application/json",)
41
+ outputModes: tuple[str, ...] = ("application/json",)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SecurityScheme:
46
+ type: str
47
+ scheme: str | None = None
48
+ bearerFormat: str | None = None
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {key: value for key, value in asdict(self).items() if value is not None}
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class AgentCard:
56
+ name: str
57
+ description: str
58
+ url: str
59
+ version: str
60
+ supportedInterfaces: tuple[AgentInterface, ...]
61
+ capabilities: AgentCapabilities
62
+ defaultInputModes: tuple[str, ...]
63
+ defaultOutputModes: tuple[str, ...]
64
+ skills: tuple[AgentSkill, ...]
65
+ protocolVersion: str = A2A_PROTOCOL_VERSION
66
+ securitySchemes: dict[str, SecurityScheme] = field(default_factory=dict)
67
+ security: tuple[dict[str, tuple[str, ...]], ...] = ()
68
+ securityRequirements: tuple[dict[str, tuple[str, ...]], ...] = ()
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ payload = asdict(self)
72
+ payload["securitySchemes"] = {key: scheme.to_dict() for key, scheme in self.securitySchemes.items()}
73
+ if not payload["securitySchemes"]:
74
+ payload.pop("securitySchemes")
75
+ if not payload["security"]:
76
+ payload.pop("security")
77
+ if not payload["securityRequirements"]:
78
+ payload.pop("securityRequirements")
79
+ return payload
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Message:
84
+ messageId: str
85
+ role: str
86
+ parts: tuple[dict[str, Any], ...]
87
+ taskId: str | None = None
88
+ contextId: str | None = None
89
+ metadata: dict[str, Any] = field(default_factory=dict)
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class MessageSendParams:
94
+ message: Message
95
+ metadata: dict[str, Any] = field(default_factory=dict)
96
+
97
+ @property
98
+ def portmark_envelope(self) -> dict[str, Any]:
99
+ envelope = self.metadata.get("portmark_envelope")
100
+ if not isinstance(envelope, dict):
101
+ raise A2ARequestError(-32602, "invalid params")
102
+ return envelope
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class JSONRPCRequest:
107
+ jsonrpc: str
108
+ id: str | int | None
109
+ method: str
110
+ params: MessageSendParams
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class TaskStatus:
115
+ state: str
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Task:
120
+ id: str
121
+ status: TaskStatus
122
+ artifacts: tuple[dict[str, Any], ...] = ()
123
+ metadata: dict[str, Any] = field(default_factory=dict)
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ return asdict(self)
127
+
128
+
129
+ def make_agent_card(base_url: str, require_bearer_auth: bool) -> dict[str, Any]:
130
+ security_schemes: dict[str, SecurityScheme] = {}
131
+ security: tuple[dict[str, tuple[str, ...]], ...] = ()
132
+ if require_bearer_auth:
133
+ security_schemes = {"bearer": SecurityScheme("http", "bearer", "opaque")}
134
+ security = ({"bearer": ()},)
135
+ card = AgentCard(
136
+ name="Portable Wasm Agent Host",
137
+ description="Runs signed, capability-limited portable agents",
138
+ url=f"{base_url}/message:send",
139
+ version="0.1.0",
140
+ supportedInterfaces=(AgentInterface(f"{base_url}/message:send"),),
141
+ capabilities=AgentCapabilities(),
142
+ defaultInputModes=("application/json",),
143
+ defaultOutputModes=("application/json",),
144
+ skills=(AgentSkill("portmark", "Portmark agent execution", "Execute a signed Portmark agent envelope"),),
145
+ securitySchemes=security_schemes,
146
+ security=security,
147
+ securityRequirements=security,
148
+ )
149
+ return card.to_dict()
150
+
151
+
152
+ def parse_jsonrpc_request(value: Any) -> JSONRPCRequest:
153
+ if not isinstance(value, dict):
154
+ raise A2ARequestError(-32600, "invalid request")
155
+ request_id = _valid_request_id(value.get("id"))
156
+ if value.get("jsonrpc") != JSONRPC_VERSION:
157
+ raise A2ARequestError(-32600, "invalid request", request_id=request_id)
158
+ method = value.get("method")
159
+ if not isinstance(method, str):
160
+ raise A2ARequestError(-32600, "invalid request", request_id=request_id)
161
+ if method != MESSAGE_SEND_METHOD:
162
+ raise A2ARequestError(-32601, "method not found", request_id=request_id)
163
+ return JSONRPCRequest(JSONRPC_VERSION, request_id, method, parse_message_send_params(value.get("params")))
164
+
165
+
166
+ def parse_message_send_params(value: Any) -> MessageSendParams:
167
+ if not isinstance(value, dict):
168
+ raise A2ARequestError(-32602, "invalid params")
169
+ message = _parse_message(value.get("message"))
170
+ metadata = _optional_object(value.get("metadata"), "metadata")
171
+ params = MessageSendParams(message, metadata)
172
+ params.portmark_envelope
173
+ return params
174
+
175
+
176
+ def task_from_run_result(result: Any) -> dict[str, Any]:
177
+ return Task(
178
+ id=result.task_id,
179
+ status=TaskStatus(_task_state(result.status)),
180
+ artifacts=(asdict(result),),
181
+ metadata={"portmark_status": result.status},
182
+ ).to_dict()
183
+
184
+
185
+ def success_response(request_id: str | int | None, result: dict[str, Any]) -> dict[str, Any]:
186
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
187
+
188
+
189
+ def error_response(request_id: str | int | None, code: int, message: str) -> dict[str, Any]:
190
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": code, "message": message}}
191
+
192
+
193
+ def _parse_message(value: Any) -> Message:
194
+ if not isinstance(value, dict):
195
+ raise A2ARequestError(-32602, "invalid params")
196
+ message_id = value.get("messageId")
197
+ role = value.get("role")
198
+ parts = value.get("parts")
199
+ if not isinstance(message_id, str) or not message_id:
200
+ raise A2ARequestError(-32602, "invalid params")
201
+ if role not in {"user", "agent"}:
202
+ raise A2ARequestError(-32602, "invalid params")
203
+ if not isinstance(parts, list) or not parts or not all(isinstance(part, dict) for part in parts):
204
+ raise A2ARequestError(-32602, "invalid params")
205
+ return Message(
206
+ message_id,
207
+ role,
208
+ tuple(parts),
209
+ _optional_string(value.get("taskId"), "taskId"),
210
+ _optional_string(value.get("contextId"), "contextId"),
211
+ _optional_object(value.get("metadata"), "metadata"),
212
+ )
213
+
214
+
215
+ def _optional_object(value: Any, name: str) -> dict[str, Any]:
216
+ if value is None:
217
+ return {}
218
+ if not isinstance(value, dict):
219
+ raise A2ARequestError(-32602, "invalid params")
220
+ return value
221
+
222
+
223
+ def _optional_string(value: Any, name: str) -> str | None:
224
+ if value is None:
225
+ return None
226
+ if not isinstance(value, str) or not value:
227
+ raise A2ARequestError(-32602, "invalid params")
228
+ return value
229
+
230
+
231
+ def _valid_request_id(value: Any) -> str | int | None:
232
+ if value is None or isinstance(value, (str, int)):
233
+ return value
234
+ return None
235
+
236
+
237
+ def _task_state(status: str) -> str:
238
+ return {"completed": "completed", "migrated": "completed", "failed": "failed"}.get(status, "working")
portmark/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ from .a2a import A2AAuthConfig, serve
8
+ from .config import RuntimeConfig
9
+ from .factory import make_demo_envelope, make_host
10
+ from .logging_config import configure_logging
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(description="Provider-neutral portable agent runtime")
15
+ parser.add_argument("--host-id", help="host identity used for permit audience and signing issuer")
16
+ parser.add_argument("--provider-endpoint", help="generic HTTP model-provider endpoint")
17
+ parser.add_argument("--wasm-component", help="Wasm capsule (.wasm or .wat) implementing the WIT resume ABI")
18
+ parser.add_argument("--store-path", help="SQLite path for durable nonces, checkpoints, and audit heads")
19
+ parser.add_argument("--policy-path", help="JSON host policy path")
20
+ parser.add_argument("--trust-registry-path", help="JSON trust registry path for envelope signing keys")
21
+ parser.add_argument("--reload-policy", action="store_true", help="reload the JSON host policy before each run")
22
+ parser.add_argument("--log-level", help="logging level")
23
+ parser.add_argument("--log-json", action="store_true", help="emit structured JSON logs")
24
+ parser.add_argument("--enable-hsts", action="store_true", help="emit HSTS header when served behind HTTPS")
25
+ subparsers = parser.add_subparsers(dest="command", required=True)
26
+ demo = subparsers.add_parser("demo")
27
+ demo.add_argument("goal", nargs="?", default="find portable agent architecture references")
28
+ server = subparsers.add_parser("serve")
29
+ server.add_argument("--bind", default="127.0.0.1")
30
+ server.add_argument("--port", type=int, default=8080)
31
+ server.add_argument("--a2a-token", help="require this bearer token for A2A message/send requests")
32
+ args = parser.parse_args()
33
+ from .storage import SQLiteRuntimeStore
34
+
35
+ config = RuntimeConfig.from_environment().merged_with_args(args)
36
+ configure_logging(config.log_level, config.log_json)
37
+ store = SQLiteRuntimeStore(config.store_path) if config.store_path else None
38
+ host = make_host(
39
+ config.provider_endpoint,
40
+ host_id=config.host_id,
41
+ wasm_component=config.wasm_component,
42
+ store=store,
43
+ policy_path=config.policy_path,
44
+ trust_registry_path=config.trust_registry_path,
45
+ reload_policy=config.reload_policy,
46
+ )
47
+ if args.command == "demo":
48
+ provider = "wasm" if config.wasm_component else ("http" if config.provider_endpoint else "deterministic")
49
+ result = host.run(make_demo_envelope(host, args.goal, provider))
50
+ print(json.dumps(asdict(result), indent=2))
51
+ else:
52
+ serve(host, args.bind, args.port, A2AAuthConfig(config.a2a_token) if config.a2a_token else None, config.enable_hsts)
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from typing import Any
6
+
7
+ from .models import AgentState, ProviderDecision
8
+
9
+
10
+ WIT_PACKAGE = "portmark:agent@1.0.0"
11
+ WIT_WORLD = "portmark"
12
+ WIT_ABI = "portmark-json-lowered-v1"
13
+
14
+
15
+ def component_context(state: AgentState, available_tools: tuple[str, ...]) -> dict[str, Any]:
16
+ return {
17
+ "wit": {"package": WIT_PACKAGE, "world": WIT_WORLD, "abi": WIT_ABI},
18
+ "state": asdict(state),
19
+ "available_tools": list(available_tools),
20
+ }
21
+
22
+
23
+ def component_checkpoint(state: AgentState) -> dict[str, Any]:
24
+ return {
25
+ "task_id": state.task_id,
26
+ "step": state.step,
27
+ "tool_calls": state.tool_calls,
28
+ "memory": state.memory,
29
+ "messages": state.messages,
30
+ }
31
+
32
+
33
+ def encode_component_input(value: dict[str, Any]) -> str:
34
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
35
+
36
+
37
+ def decode_component_decision(raw: str, available_tools: tuple[str, ...]) -> ProviderDecision:
38
+ try:
39
+ value = json.loads(raw)
40
+ except json.JSONDecodeError as error:
41
+ raise RuntimeError("Wasm component returned malformed decision JSON") from error
42
+ if not isinstance(value, dict):
43
+ raise RuntimeError("Wasm component decision must be a JSON object")
44
+ outcome = value.get("outcome")
45
+ if outcome == "tool":
46
+ request = value.get("request")
47
+ if not isinstance(request, dict):
48
+ raise RuntimeError("Wasm component tool decision is missing a request")
49
+ name = request.get("name")
50
+ if not isinstance(name, str) or not name:
51
+ raise RuntimeError("Wasm component tool decision has an invalid tool name")
52
+ if name not in available_tools:
53
+ return ProviderDecision("fail", content={"error": "required capability unavailable"})
54
+ arguments = _decode_json_object(request.get("arguments_json", "{}"), "tool arguments")
55
+ return ProviderDecision("tool", name, arguments)
56
+ if outcome == "completed":
57
+ return ProviderDecision("complete", content=_decode_json_value(value.get("content_json", "null"), "completion content"))
58
+ if outcome == "awaiting-input":
59
+ return ProviderDecision("await_input", content=_decode_json_value(value.get("content_json", "null"), "awaiting-input content"))
60
+ if outcome == "migrate":
61
+ destination = value.get("destination")
62
+ if not isinstance(destination, str) or not destination:
63
+ raise RuntimeError("Wasm component migration decision has an invalid destination")
64
+ return ProviderDecision("migrate", destination=destination)
65
+ if outcome == "failed":
66
+ return ProviderDecision("fail", content=_decode_json_value(value.get("content_json", "null"), "failure content"))
67
+ if outcome == "suspended":
68
+ return ProviderDecision("await_input", content=_decode_json_value(value.get("content_json", "null"), "suspension content"))
69
+ raise RuntimeError("Wasm component returned an unknown outcome")
70
+
71
+
72
+ def _decode_json_object(raw: Any, label: str) -> dict[str, Any]:
73
+ value = _decode_json_value(raw, label)
74
+ if not isinstance(value, dict):
75
+ raise RuntimeError(f"Wasm component {label} must decode to a JSON object")
76
+ return value
77
+
78
+
79
+ def _decode_json_value(raw: Any, label: str) -> Any:
80
+ if not isinstance(raw, str):
81
+ raise RuntimeError(f"Wasm component {label} must be encoded as a JSON string")
82
+ try:
83
+ return json.loads(raw)
84
+ except json.JSONDecodeError as error:
85
+ raise RuntimeError(f"Wasm component {label} is malformed JSON") from error
portmark/config.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RuntimeConfig:
9
+ host_id: str = "host:local-demo"
10
+ provider_endpoint: str | None = None
11
+ wasm_component: str | None = None
12
+ store_path: str | None = None
13
+ policy_path: str | None = None
14
+ trust_registry_path: str | None = None
15
+ reload_policy: bool = False
16
+ a2a_token: str | None = None
17
+ log_level: str = "INFO"
18
+ log_json: bool = False
19
+ enable_hsts: bool = False
20
+
21
+ @classmethod
22
+ def from_environment(cls) -> "RuntimeConfig":
23
+ return cls(
24
+ host_id=os.environ.get("PORTMARK_HOST_ID", "host:local-demo"),
25
+ provider_endpoint=os.environ.get("PORTMARK_PROVIDER_ENDPOINT"),
26
+ wasm_component=os.environ.get("PORTMARK_WASM_COMPONENT"),
27
+ store_path=os.environ.get("PORTMARK_STORE_PATH"),
28
+ policy_path=os.environ.get("PORTMARK_POLICY_PATH"),
29
+ trust_registry_path=os.environ.get("PORTMARK_TRUST_REGISTRY_PATH"),
30
+ reload_policy=os.environ.get("PORTMARK_RELOAD_POLICY") == "1",
31
+ a2a_token=os.environ.get("PORTMARK_A2A_TOKEN"),
32
+ log_level=os.environ.get("PORTMARK_LOG_LEVEL", "INFO"),
33
+ log_json=os.environ.get("PORTMARK_LOG_JSON") == "1",
34
+ enable_hsts=os.environ.get("PORTMARK_ENABLE_HSTS") == "1",
35
+ )
36
+
37
+ def merged_with_args(self, args) -> "RuntimeConfig":
38
+ return RuntimeConfig(
39
+ host_id=args.host_id or self.host_id,
40
+ provider_endpoint=args.provider_endpoint or self.provider_endpoint,
41
+ wasm_component=args.wasm_component or self.wasm_component,
42
+ store_path=args.store_path or self.store_path,
43
+ policy_path=args.policy_path or self.policy_path,
44
+ trust_registry_path=args.trust_registry_path or self.trust_registry_path,
45
+ reload_policy=bool(args.reload_policy or self.reload_policy),
46
+ a2a_token=getattr(args, "a2a_token", None) or self.a2a_token,
47
+ log_level=args.log_level or self.log_level,
48
+ log_json=bool(args.log_json or self.log_json),
49
+ enable_hsts=bool(args.enable_hsts or self.enable_hsts),
50
+ )
portmark/factory.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import secrets
6
+ import time
7
+
8
+ from .host import AgentHost
9
+ from .models import AgentEnvelope, AgentManifest, AgentState, Permit, ResourceBudget, ToolGrant
10
+ from .policy import load_host_policy
11
+ from .providers import DeterministicProvider, GenericHttpProvider, WasmDecisionProvider
12
+ from .security import AttestationPolicy, EnvelopeSigner, EnvelopeSigningIdentity, HmacEnvelopeSigner, HostPolicy, load_trust_registry
13
+ from .storage import RuntimeStore, SQLiteRuntimeStore
14
+ from .tools import demo_registry
15
+
16
+
17
+ HOST_ID = "host:local-demo"
18
+
19
+
20
+ def signer_from_environment(host_id: str = HOST_ID, trust_registry_path: str | None = None) -> EnvelopeSigningIdentity:
21
+ registry = load_trust_registry(trust_registry_path) if trust_registry_path else None
22
+ raw_private_key = os.environ.get("PORTMARK_ED25519_PRIVATE_KEY_B64")
23
+ if raw_private_key:
24
+ import base64
25
+
26
+ padding = "=" * (-len(raw_private_key) % 4)
27
+ private_key = base64.urlsafe_b64decode(raw_private_key + padding)
28
+ return EnvelopeSigner.from_private_key_bytes(
29
+ os.environ.get("PORTMARK_SIGNING_KEY_ID", "env-ed25519-key"),
30
+ os.environ.get("PORTMARK_SIGNING_ISSUER", host_id),
31
+ private_key,
32
+ tuple(os.environ.get("PORTMARK_ALLOWED_AUDIENCES", host_id).split(",")),
33
+ registry,
34
+ )
35
+ if os.environ.get("PORTMARK_ALLOW_LEGACY_HMAC") == "1":
36
+ raw = os.environ.get("PORTMARK_SIGNING_KEY", "development-only-signing-key-change-me")
37
+ return HmacEnvelopeSigner(hashlib.sha256(raw.encode()).digest())
38
+ return EnvelopeSigner.generate(issuer=host_id, allowed_audiences=(host_id,))
39
+
40
+
41
+ def make_host(
42
+ provider_endpoint: str | None = None,
43
+ host_id: str = HOST_ID,
44
+ signer: EnvelopeSigningIdentity | None = None,
45
+ wasm_component: str | None = None,
46
+ store: RuntimeStore | None = None,
47
+ attestation_policy: AttestationPolicy | None = None,
48
+ policy_path: str | None = None,
49
+ trust_registry_path: str | None = None,
50
+ reload_policy: bool = False,
51
+ ) -> AgentHost:
52
+ providers = {"deterministic": DeterministicProvider()}
53
+ if provider_endpoint:
54
+ providers["http"] = GenericHttpProvider(provider_endpoint, os.environ.get("MODEL_PROVIDER_TOKEN"))
55
+ if wasm_component:
56
+ providers["wasm"] = WasmDecisionProvider.from_file(wasm_component)
57
+ configured_policy_path = policy_path or os.environ.get("PORTMARK_POLICY_PATH")
58
+ configured_trust_registry_path = trust_registry_path or os.environ.get("PORTMARK_TRUST_REGISTRY_PATH")
59
+ policy_loader = (lambda: load_host_policy(configured_policy_path, host_id)) if configured_policy_path else None
60
+ policy = policy_loader() if policy_loader else HostPolicy(
61
+ host_id,
62
+ grants=(ToolGrant("catalog.search", {"max_limit": 5}), ToolGrant("payments.reserve", {"max_amount": 100, "currency": "USD"})),
63
+ budget=ResourceBudget(max_steps=10, max_tool_calls=5, max_output_bytes=65_536),
64
+ tool_impacts={"catalog.search": "low", "payments.reserve": "external-payment"},
65
+ )
66
+ configured_store = store
67
+ if configured_store is None and os.environ.get("PORTMARK_STORE_PATH"):
68
+ configured_store = SQLiteRuntimeStore(os.environ["PORTMARK_STORE_PATH"])
69
+ return AgentHost(
70
+ host_id,
71
+ signer or signer_from_environment(host_id, configured_trust_registry_path),
72
+ policy,
73
+ demo_registry(),
74
+ providers,
75
+ configured_store,
76
+ attestation_policy,
77
+ policy_loader,
78
+ reload_policy,
79
+ )
80
+
81
+
82
+ def make_demo_envelope(host: AgentHost, goal: str, provider: str = "deterministic") -> AgentEnvelope:
83
+ configured = host.providers.get(provider)
84
+ digest = getattr(configured, "component_digest", "python:reference-agent-v1")
85
+ manifest = AgentManifest("agent:demo", "1.0.0", provider, ("catalog.search", "payments.reserve"), digest)
86
+ permit = Permit(
87
+ issuer=getattr(host.signer, "issuer", host.host_id), subject=manifest.agent_id, audience=host.host_id,
88
+ expires_at=int(time.time()) + 3600, nonce=secrets.token_hex(16),
89
+ grants=(ToolGrant("catalog.search", {"max_limit": 3}),),
90
+ budget=ResourceBudget(max_steps=6, max_tool_calls=2, max_output_bytes=32_768),
91
+ )
92
+ return host.signer.seal(AgentEnvelope(manifest, permit, AgentState(secrets.token_hex(8), goal)))