@smilintux/skcapstone 0.1.0
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.
- package/.cursorrules +33 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +52 -0
- package/AGENTS.md +74 -0
- package/CLAUDE.md +56 -0
- package/LICENSE +674 -0
- package/README.md +242 -0
- package/SKILL.md +36 -0
- package/bin/cli.js +18 -0
- package/docs/ARCHITECTURE.md +510 -0
- package/docs/SECURITY_DESIGN.md +315 -0
- package/docs/SOVEREIGN_SINGULARITY.md +371 -0
- package/docs/TOKEN_SYSTEM.md +201 -0
- package/index.d.ts +9 -0
- package/index.js +32 -0
- package/package.json +32 -0
- package/pyproject.toml +84 -0
- package/src/skcapstone/__init__.py +13 -0
- package/src/skcapstone/cli.py +1441 -0
- package/src/skcapstone/connectors/__init__.py +6 -0
- package/src/skcapstone/coordination.py +590 -0
- package/src/skcapstone/discovery.py +275 -0
- package/src/skcapstone/memory_engine.py +457 -0
- package/src/skcapstone/models.py +223 -0
- package/src/skcapstone/pillars/__init__.py +8 -0
- package/src/skcapstone/pillars/identity.py +91 -0
- package/src/skcapstone/pillars/memory.py +61 -0
- package/src/skcapstone/pillars/security.py +83 -0
- package/src/skcapstone/pillars/sync.py +486 -0
- package/src/skcapstone/pillars/trust.py +335 -0
- package/src/skcapstone/runtime.py +190 -0
- package/src/skcapstone/skills/__init__.py +1 -0
- package/src/skcapstone/skills/syncthing_setup.py +297 -0
- package/src/skcapstone/sync/__init__.py +14 -0
- package/src/skcapstone/sync/backends.py +330 -0
- package/src/skcapstone/sync/engine.py +301 -0
- package/src/skcapstone/sync/models.py +97 -0
- package/src/skcapstone/sync/vault.py +284 -0
- package/src/skcapstone/tokens.py +439 -0
- package/tests/__init__.py +0 -0
- package/tests/conftest.py +42 -0
- package/tests/test_coordination.py +299 -0
- package/tests/test_discovery.py +57 -0
- package/tests/test_memory_engine.py +391 -0
- package/tests/test_models.py +63 -0
- package/tests/test_pillars.py +87 -0
- package/tests/test_runtime.py +60 -0
- package/tests/test_sync.py +507 -0
- package/tests/test_syncthing_setup.py +76 -0
- package/tests/test_tokens.py +265 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capability token issuance and verification.
|
|
3
|
+
|
|
4
|
+
CapAuth tokens are PGP-signed JSON payloads that grant specific
|
|
5
|
+
permissions to agents or services. They are self-contained,
|
|
6
|
+
cryptographically verifiable, and don't require a central authority.
|
|
7
|
+
|
|
8
|
+
Token types:
|
|
9
|
+
- AgentToken: proves identity, grants access to agent runtime
|
|
10
|
+
- CapabilityToken: grants specific permissions (read memory, push sync, etc.)
|
|
11
|
+
- DelegationToken: allows one agent to act on behalf of another
|
|
12
|
+
|
|
13
|
+
The issuer signs with their CapAuth PGP key. Any holder can verify
|
|
14
|
+
with the issuer's public key. No server required.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import subprocess
|
|
23
|
+
import shutil
|
|
24
|
+
from datetime import datetime, timedelta, timezone
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("skcapstone.tokens")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TokenType(str, Enum):
|
|
35
|
+
"""Types of capability tokens."""
|
|
36
|
+
|
|
37
|
+
AGENT = "agent"
|
|
38
|
+
CAPABILITY = "capability"
|
|
39
|
+
DELEGATION = "delegation"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Capability(str, Enum):
|
|
43
|
+
"""Granular permissions that can be granted via token."""
|
|
44
|
+
|
|
45
|
+
MEMORY_READ = "memory:read"
|
|
46
|
+
MEMORY_WRITE = "memory:write"
|
|
47
|
+
SYNC_PUSH = "sync:push"
|
|
48
|
+
SYNC_PULL = "sync:pull"
|
|
49
|
+
IDENTITY_VERIFY = "identity:verify"
|
|
50
|
+
IDENTITY_SIGN = "identity:sign"
|
|
51
|
+
TRUST_READ = "trust:read"
|
|
52
|
+
TRUST_WRITE = "trust:write"
|
|
53
|
+
AUDIT_READ = "audit:read"
|
|
54
|
+
AGENT_STATUS = "agent:status"
|
|
55
|
+
AGENT_CONNECT = "agent:connect"
|
|
56
|
+
TOKEN_ISSUE = "token:issue"
|
|
57
|
+
ALL = "*"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TokenPayload(BaseModel):
|
|
61
|
+
"""The signed content of a capability token.
|
|
62
|
+
|
|
63
|
+
This is the JSON structure that gets PGP-signed.
|
|
64
|
+
It's self-describing and independently verifiable.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
token_id: str = Field(description="Unique token identifier (SHA-256 hash)")
|
|
68
|
+
token_type: TokenType = Field(description="What kind of token this is")
|
|
69
|
+
issuer: str = Field(description="PGP fingerprint of the issuer")
|
|
70
|
+
subject: str = Field(description="Who/what this token is for (fingerprint or name)")
|
|
71
|
+
capabilities: list[str] = Field(
|
|
72
|
+
default_factory=list, description="List of granted capabilities"
|
|
73
|
+
)
|
|
74
|
+
issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
75
|
+
expires_at: Optional[datetime] = Field(
|
|
76
|
+
default=None, description="When the token expires (None = no expiry)"
|
|
77
|
+
)
|
|
78
|
+
not_before: Optional[datetime] = Field(
|
|
79
|
+
default=None, description="Token not valid before this time"
|
|
80
|
+
)
|
|
81
|
+
metadata: dict = Field(
|
|
82
|
+
default_factory=dict, description="Additional claims (agent name, platform, etc.)"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_expired(self) -> bool:
|
|
87
|
+
"""Check if the token has expired."""
|
|
88
|
+
if self.expires_at is None:
|
|
89
|
+
return False
|
|
90
|
+
return datetime.now(timezone.utc) > self.expires_at
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_active(self) -> bool:
|
|
94
|
+
"""Check if the token is currently valid (time-wise)."""
|
|
95
|
+
now = datetime.now(timezone.utc)
|
|
96
|
+
if self.expires_at and now > self.expires_at:
|
|
97
|
+
return False
|
|
98
|
+
if self.not_before and now < self.not_before:
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def has_capability(self, cap: str) -> bool:
|
|
103
|
+
"""Check if this token grants a specific capability.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
cap: The capability string to check (e.g., 'memory:read').
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the capability is granted (or ALL is granted).
|
|
110
|
+
"""
|
|
111
|
+
return Capability.ALL.value in self.capabilities or cap in self.capabilities
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SignedToken(BaseModel):
|
|
115
|
+
"""A complete token with its PGP signature."""
|
|
116
|
+
|
|
117
|
+
payload: TokenPayload
|
|
118
|
+
signature: Optional[str] = Field(
|
|
119
|
+
default=None, description="PGP detached signature (ASCII-armored)"
|
|
120
|
+
)
|
|
121
|
+
verified: bool = Field(default=False, description="Whether signature has been verified")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def issue_token(
|
|
125
|
+
home: Path,
|
|
126
|
+
subject: str,
|
|
127
|
+
capabilities: list[str],
|
|
128
|
+
token_type: TokenType = TokenType.CAPABILITY,
|
|
129
|
+
ttl_hours: Optional[int] = 24,
|
|
130
|
+
metadata: Optional[dict] = None,
|
|
131
|
+
sign: bool = True,
|
|
132
|
+
) -> SignedToken:
|
|
133
|
+
"""Issue a new capability token signed by the agent's CapAuth key.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
home: Agent home directory (~/.skcapstone).
|
|
137
|
+
subject: Who the token is for (fingerprint, name, or email).
|
|
138
|
+
capabilities: List of capability strings to grant.
|
|
139
|
+
token_type: Type of token to issue.
|
|
140
|
+
ttl_hours: Hours until expiry (None = no expiry).
|
|
141
|
+
metadata: Additional claims to embed.
|
|
142
|
+
sign: Whether to PGP-sign the token.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
SignedToken with the payload and optional signature.
|
|
146
|
+
"""
|
|
147
|
+
issuer_fp = _get_issuer_fingerprint(home)
|
|
148
|
+
now = datetime.now(timezone.utc)
|
|
149
|
+
|
|
150
|
+
payload = TokenPayload(
|
|
151
|
+
token_id="",
|
|
152
|
+
token_type=token_type,
|
|
153
|
+
issuer=issuer_fp,
|
|
154
|
+
subject=subject,
|
|
155
|
+
capabilities=capabilities,
|
|
156
|
+
issued_at=now,
|
|
157
|
+
expires_at=now + timedelta(hours=ttl_hours) if ttl_hours else None,
|
|
158
|
+
metadata=metadata or {},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
payload.token_id = _compute_token_id(payload)
|
|
162
|
+
|
|
163
|
+
token = SignedToken(payload=payload)
|
|
164
|
+
|
|
165
|
+
if sign:
|
|
166
|
+
signature = _pgp_sign_payload(payload, home)
|
|
167
|
+
if signature:
|
|
168
|
+
token.signature = signature
|
|
169
|
+
token.verified = True
|
|
170
|
+
|
|
171
|
+
_store_token(home, token)
|
|
172
|
+
logger.info("Issued token %s for %s (%s)", payload.token_id[:12], subject, token_type.value)
|
|
173
|
+
return token
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def verify_token(token: SignedToken, home: Optional[Path] = None) -> bool:
|
|
177
|
+
"""Verify a token's signature and validity.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
token: The signed token to verify.
|
|
181
|
+
home: Agent home for accessing the keyring.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if the token is valid and signature checks out.
|
|
185
|
+
"""
|
|
186
|
+
if not token.payload.is_active:
|
|
187
|
+
logger.warning("Token %s is not active (expired or not yet valid)", token.payload.token_id[:12])
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
if token.signature:
|
|
191
|
+
verified = _pgp_verify_signature(token.payload, token.signature, home)
|
|
192
|
+
token.verified = verified
|
|
193
|
+
return verified
|
|
194
|
+
|
|
195
|
+
logger.warning("Token %s has no signature", token.payload.token_id[:12])
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def revoke_token(home: Path, token_id: str) -> bool:
|
|
200
|
+
"""Revoke a previously issued token.
|
|
201
|
+
|
|
202
|
+
Adds the token ID to the revocation list. Revoked tokens
|
|
203
|
+
fail verification even if their signature is valid.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
home: Agent home directory.
|
|
207
|
+
token_id: The token ID to revoke.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if the token was found and revoked.
|
|
211
|
+
"""
|
|
212
|
+
revocation_file = home / "security" / "revoked-tokens.json"
|
|
213
|
+
revoked = _load_revocation_list(revocation_file)
|
|
214
|
+
|
|
215
|
+
if token_id in revoked:
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
revoked[token_id] = {
|
|
219
|
+
"revoked_at": datetime.now(timezone.utc).isoformat(),
|
|
220
|
+
"reason": "manual_revocation",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
revocation_file.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
revocation_file.write_text(json.dumps(revoked, indent=2))
|
|
225
|
+
logger.info("Revoked token %s", token_id[:12])
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_revoked(home: Path, token_id: str) -> bool:
|
|
230
|
+
"""Check if a token has been revoked.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
home: Agent home directory.
|
|
234
|
+
token_id: The token ID to check.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
True if the token is on the revocation list.
|
|
238
|
+
"""
|
|
239
|
+
revocation_file = home / "security" / "revoked-tokens.json"
|
|
240
|
+
revoked = _load_revocation_list(revocation_file)
|
|
241
|
+
return token_id in revoked
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def list_tokens(home: Path) -> list[SignedToken]:
|
|
245
|
+
"""List all issued tokens.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
home: Agent home directory.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of all stored tokens.
|
|
252
|
+
"""
|
|
253
|
+
token_dir = home / "security" / "tokens"
|
|
254
|
+
if not token_dir.exists():
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
tokens = []
|
|
258
|
+
for f in sorted(token_dir.iterdir()):
|
|
259
|
+
if f.suffix == ".json":
|
|
260
|
+
try:
|
|
261
|
+
data = json.loads(f.read_text())
|
|
262
|
+
token = SignedToken(
|
|
263
|
+
payload=TokenPayload(**data["payload"]),
|
|
264
|
+
signature=data.get("signature"),
|
|
265
|
+
verified=data.get("verified", False),
|
|
266
|
+
)
|
|
267
|
+
tokens.append(token)
|
|
268
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
269
|
+
logger.warning("Failed to load token %s: %s", f.name, exc)
|
|
270
|
+
return tokens
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def export_token(token: SignedToken) -> str:
|
|
274
|
+
"""Export a token as a portable JSON string.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
token: The token to export.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
JSON string suitable for sharing.
|
|
281
|
+
"""
|
|
282
|
+
return json.dumps(
|
|
283
|
+
{
|
|
284
|
+
"skcapstone_token": "1.0",
|
|
285
|
+
"payload": token.payload.model_dump(mode="json"),
|
|
286
|
+
"signature": token.signature,
|
|
287
|
+
},
|
|
288
|
+
indent=2,
|
|
289
|
+
default=str,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def import_token(token_json: str) -> SignedToken:
|
|
294
|
+
"""Import a token from a JSON string.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
token_json: JSON string from export_token().
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The reconstructed SignedToken.
|
|
301
|
+
|
|
302
|
+
Raises:
|
|
303
|
+
ValueError: If the JSON is not a valid token.
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
data = json.loads(token_json)
|
|
307
|
+
if "skcapstone_token" not in data:
|
|
308
|
+
raise ValueError("Not an SKCapstone token")
|
|
309
|
+
return SignedToken(
|
|
310
|
+
payload=TokenPayload(**data["payload"]),
|
|
311
|
+
signature=data.get("signature"),
|
|
312
|
+
verified=False,
|
|
313
|
+
)
|
|
314
|
+
except (json.JSONDecodeError, KeyError) as exc:
|
|
315
|
+
raise ValueError(f"Invalid token format: {exc}") from exc
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --- Private helpers ---
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _get_issuer_fingerprint(home: Path) -> str:
|
|
322
|
+
"""Get the agent's PGP fingerprint for signing tokens."""
|
|
323
|
+
identity_file = home / "identity" / "identity.json"
|
|
324
|
+
if identity_file.exists():
|
|
325
|
+
try:
|
|
326
|
+
data = json.loads(identity_file.read_text())
|
|
327
|
+
fp = data.get("fingerprint")
|
|
328
|
+
if fp:
|
|
329
|
+
return fp
|
|
330
|
+
except (json.JSONDecodeError, OSError):
|
|
331
|
+
pass
|
|
332
|
+
return "unknown"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _compute_token_id(payload: TokenPayload) -> str:
|
|
336
|
+
"""Compute a deterministic token ID from the payload content."""
|
|
337
|
+
content = json.dumps(
|
|
338
|
+
{
|
|
339
|
+
"issuer": payload.issuer,
|
|
340
|
+
"subject": payload.subject,
|
|
341
|
+
"capabilities": sorted(payload.capabilities),
|
|
342
|
+
"issued_at": payload.issued_at.isoformat(),
|
|
343
|
+
"type": payload.token_type.value,
|
|
344
|
+
},
|
|
345
|
+
sort_keys=True,
|
|
346
|
+
)
|
|
347
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _pgp_sign_payload(payload: TokenPayload, home: Path) -> Optional[str]:
|
|
351
|
+
"""PGP-sign a token payload using the agent's CapAuth key."""
|
|
352
|
+
if not shutil.which("gpg"):
|
|
353
|
+
logger.warning("gpg not found — token will be unsigned")
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
issuer_fp = _get_issuer_fingerprint(home)
|
|
357
|
+
payload_json = payload.model_dump_json()
|
|
358
|
+
try:
|
|
359
|
+
cmd = [
|
|
360
|
+
"gpg", "--batch", "--yes", "--armor", "--detach-sign",
|
|
361
|
+
"--local-user", issuer_fp,
|
|
362
|
+
"--passphrase", "",
|
|
363
|
+
"--pinentry-mode", "loopback",
|
|
364
|
+
]
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
cmd,
|
|
367
|
+
input=payload_json,
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
timeout=15,
|
|
371
|
+
)
|
|
372
|
+
if result.returncode == 0:
|
|
373
|
+
return result.stdout
|
|
374
|
+
logger.warning("GPG signing failed: %s", result.stderr.strip())
|
|
375
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
376
|
+
logger.warning("GPG signing error: %s", exc)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _pgp_verify_signature(
|
|
381
|
+
payload: TokenPayload,
|
|
382
|
+
signature: str,
|
|
383
|
+
home: Optional[Path] = None,
|
|
384
|
+
) -> bool:
|
|
385
|
+
"""Verify a PGP detached signature against a token payload."""
|
|
386
|
+
if not shutil.which("gpg"):
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
import tempfile
|
|
390
|
+
|
|
391
|
+
payload_json = payload.model_dump_json()
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sig", delete=False) as sig_file:
|
|
395
|
+
sig_file.write(signature)
|
|
396
|
+
sig_path = sig_file.name
|
|
397
|
+
|
|
398
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as data_file:
|
|
399
|
+
data_file.write(payload_json)
|
|
400
|
+
data_path = data_file.name
|
|
401
|
+
|
|
402
|
+
result = subprocess.run(
|
|
403
|
+
["gpg", "--batch", "--verify", sig_path, data_path],
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
timeout=15,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
Path(sig_path).unlink(missing_ok=True)
|
|
410
|
+
Path(data_path).unlink(missing_ok=True)
|
|
411
|
+
|
|
412
|
+
return result.returncode == 0
|
|
413
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
414
|
+
logger.warning("GPG verify error: %s", exc)
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _store_token(home: Path, token: SignedToken) -> None:
|
|
419
|
+
"""Persist a token to disk."""
|
|
420
|
+
token_dir = home / "security" / "tokens"
|
|
421
|
+
token_dir.mkdir(parents=True, exist_ok=True)
|
|
422
|
+
|
|
423
|
+
token_file = token_dir / f"{token.payload.token_id[:16]}.json"
|
|
424
|
+
data = {
|
|
425
|
+
"payload": token.payload.model_dump(mode="json"),
|
|
426
|
+
"signature": token.signature,
|
|
427
|
+
"verified": token.verified,
|
|
428
|
+
}
|
|
429
|
+
token_file.write_text(json.dumps(data, indent=2, default=str))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _load_revocation_list(revocation_file: Path) -> dict:
|
|
433
|
+
"""Load the token revocation list."""
|
|
434
|
+
if not revocation_file.exists():
|
|
435
|
+
return {}
|
|
436
|
+
try:
|
|
437
|
+
return json.loads(revocation_file.read_text())
|
|
438
|
+
except (json.JSONDecodeError, OSError):
|
|
439
|
+
return {}
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared test fixtures for skcapstone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def tmp_agent_home(tmp_path: Path) -> Path:
|
|
12
|
+
"""Provide a temporary agent home directory for testing."""
|
|
13
|
+
agent_home = tmp_path / ".skcapstone"
|
|
14
|
+
agent_home.mkdir()
|
|
15
|
+
return agent_home
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def initialized_agent_home(tmp_agent_home: Path) -> Path:
|
|
20
|
+
"""Provide a fully initialized agent home with directory structure."""
|
|
21
|
+
for subdir in ("identity", "memory", "trust", "security", "skills", "config", "sync"):
|
|
22
|
+
(tmp_agent_home / subdir).mkdir()
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
manifest = {
|
|
28
|
+
"name": "test-agent",
|
|
29
|
+
"version": "0.1.0",
|
|
30
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
"connectors": [],
|
|
32
|
+
}
|
|
33
|
+
(tmp_agent_home / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
config = {"agent_name": "test-agent", "auto_rehydrate": True, "auto_audit": True}
|
|
38
|
+
(tmp_agent_home / "config" / "config.yaml").write_text(
|
|
39
|
+
yaml.dump(config, default_flow_style=False)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return tmp_agent_home
|