@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.
Files changed (50) hide show
  1. package/.cursorrules +33 -0
  2. package/.github/workflows/ci.yml +23 -0
  3. package/.github/workflows/publish.yml +52 -0
  4. package/AGENTS.md +74 -0
  5. package/CLAUDE.md +56 -0
  6. package/LICENSE +674 -0
  7. package/README.md +242 -0
  8. package/SKILL.md +36 -0
  9. package/bin/cli.js +18 -0
  10. package/docs/ARCHITECTURE.md +510 -0
  11. package/docs/SECURITY_DESIGN.md +315 -0
  12. package/docs/SOVEREIGN_SINGULARITY.md +371 -0
  13. package/docs/TOKEN_SYSTEM.md +201 -0
  14. package/index.d.ts +9 -0
  15. package/index.js +32 -0
  16. package/package.json +32 -0
  17. package/pyproject.toml +84 -0
  18. package/src/skcapstone/__init__.py +13 -0
  19. package/src/skcapstone/cli.py +1441 -0
  20. package/src/skcapstone/connectors/__init__.py +6 -0
  21. package/src/skcapstone/coordination.py +590 -0
  22. package/src/skcapstone/discovery.py +275 -0
  23. package/src/skcapstone/memory_engine.py +457 -0
  24. package/src/skcapstone/models.py +223 -0
  25. package/src/skcapstone/pillars/__init__.py +8 -0
  26. package/src/skcapstone/pillars/identity.py +91 -0
  27. package/src/skcapstone/pillars/memory.py +61 -0
  28. package/src/skcapstone/pillars/security.py +83 -0
  29. package/src/skcapstone/pillars/sync.py +486 -0
  30. package/src/skcapstone/pillars/trust.py +335 -0
  31. package/src/skcapstone/runtime.py +190 -0
  32. package/src/skcapstone/skills/__init__.py +1 -0
  33. package/src/skcapstone/skills/syncthing_setup.py +297 -0
  34. package/src/skcapstone/sync/__init__.py +14 -0
  35. package/src/skcapstone/sync/backends.py +330 -0
  36. package/src/skcapstone/sync/engine.py +301 -0
  37. package/src/skcapstone/sync/models.py +97 -0
  38. package/src/skcapstone/sync/vault.py +284 -0
  39. package/src/skcapstone/tokens.py +439 -0
  40. package/tests/__init__.py +0 -0
  41. package/tests/conftest.py +42 -0
  42. package/tests/test_coordination.py +299 -0
  43. package/tests/test_discovery.py +57 -0
  44. package/tests/test_memory_engine.py +391 -0
  45. package/tests/test_models.py +63 -0
  46. package/tests/test_pillars.py +87 -0
  47. package/tests/test_runtime.py +60 -0
  48. package/tests/test_sync.py +507 -0
  49. package/tests/test_syncthing_setup.py +76 -0
  50. 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