@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,265 @@
|
|
|
1
|
+
"""Tests for the capability token issuance system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from skcapstone.tokens import (
|
|
12
|
+
Capability,
|
|
13
|
+
SignedToken,
|
|
14
|
+
TokenPayload,
|
|
15
|
+
TokenType,
|
|
16
|
+
export_token,
|
|
17
|
+
import_token,
|
|
18
|
+
is_revoked,
|
|
19
|
+
issue_token,
|
|
20
|
+
list_tokens,
|
|
21
|
+
revoke_token,
|
|
22
|
+
verify_token,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def agent_home(tmp_path: Path) -> Path:
|
|
28
|
+
"""Create a minimal agent home with identity."""
|
|
29
|
+
home = tmp_path / ".skcapstone"
|
|
30
|
+
identity_dir = home / "identity"
|
|
31
|
+
identity_dir.mkdir(parents=True)
|
|
32
|
+
security_dir = home / "security"
|
|
33
|
+
security_dir.mkdir(parents=True)
|
|
34
|
+
|
|
35
|
+
identity = {
|
|
36
|
+
"name": "TestAgent",
|
|
37
|
+
"email": "test@skcapstone.local",
|
|
38
|
+
"fingerprint": "AABBCCDDEE1122334455AABBCCDDEE1122334455",
|
|
39
|
+
"capauth_managed": True,
|
|
40
|
+
}
|
|
41
|
+
(identity_dir / "identity.json").write_text(json.dumps(identity))
|
|
42
|
+
return home
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestTokenPayload:
|
|
46
|
+
"""Tests for the TokenPayload model."""
|
|
47
|
+
|
|
48
|
+
def test_default_payload_is_active(self):
|
|
49
|
+
"""Fresh token should be active."""
|
|
50
|
+
payload = TokenPayload(
|
|
51
|
+
token_id="abc123",
|
|
52
|
+
token_type=TokenType.CAPABILITY,
|
|
53
|
+
issuer="fingerprint",
|
|
54
|
+
subject="target",
|
|
55
|
+
capabilities=["memory:read"],
|
|
56
|
+
)
|
|
57
|
+
assert payload.is_active
|
|
58
|
+
assert not payload.is_expired
|
|
59
|
+
|
|
60
|
+
def test_expired_payload(self):
|
|
61
|
+
"""Expired token should be inactive."""
|
|
62
|
+
payload = TokenPayload(
|
|
63
|
+
token_id="abc123",
|
|
64
|
+
token_type=TokenType.CAPABILITY,
|
|
65
|
+
issuer="fp",
|
|
66
|
+
subject="target",
|
|
67
|
+
capabilities=["*"],
|
|
68
|
+
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
|
69
|
+
)
|
|
70
|
+
assert payload.is_expired
|
|
71
|
+
assert not payload.is_active
|
|
72
|
+
|
|
73
|
+
def test_not_before_payload(self):
|
|
74
|
+
"""Token with future not_before should be inactive."""
|
|
75
|
+
payload = TokenPayload(
|
|
76
|
+
token_id="abc123",
|
|
77
|
+
token_type=TokenType.CAPABILITY,
|
|
78
|
+
issuer="fp",
|
|
79
|
+
subject="target",
|
|
80
|
+
capabilities=["*"],
|
|
81
|
+
not_before=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
82
|
+
)
|
|
83
|
+
assert not payload.is_active
|
|
84
|
+
|
|
85
|
+
def test_has_capability_exact(self):
|
|
86
|
+
"""Token should grant exact capability match."""
|
|
87
|
+
payload = TokenPayload(
|
|
88
|
+
token_id="abc",
|
|
89
|
+
token_type=TokenType.CAPABILITY,
|
|
90
|
+
issuer="fp",
|
|
91
|
+
subject="target",
|
|
92
|
+
capabilities=["memory:read", "sync:push"],
|
|
93
|
+
)
|
|
94
|
+
assert payload.has_capability("memory:read")
|
|
95
|
+
assert payload.has_capability("sync:push")
|
|
96
|
+
assert not payload.has_capability("memory:write")
|
|
97
|
+
|
|
98
|
+
def test_has_capability_wildcard(self):
|
|
99
|
+
"""Token with ALL capability should grant everything."""
|
|
100
|
+
payload = TokenPayload(
|
|
101
|
+
token_id="abc",
|
|
102
|
+
token_type=TokenType.CAPABILITY,
|
|
103
|
+
issuer="fp",
|
|
104
|
+
subject="target",
|
|
105
|
+
capabilities=["*"],
|
|
106
|
+
)
|
|
107
|
+
assert payload.has_capability("memory:read")
|
|
108
|
+
assert payload.has_capability("anything:here")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestTokenIssuance:
|
|
112
|
+
"""Tests for issuing and storing tokens."""
|
|
113
|
+
|
|
114
|
+
def test_issue_creates_token(self, agent_home: Path):
|
|
115
|
+
"""Issue should create a token with correct fields."""
|
|
116
|
+
token = issue_token(
|
|
117
|
+
home=agent_home,
|
|
118
|
+
subject="partner-agent",
|
|
119
|
+
capabilities=["memory:read", "sync:pull"],
|
|
120
|
+
sign=False,
|
|
121
|
+
)
|
|
122
|
+
assert token.payload.subject == "partner-agent"
|
|
123
|
+
assert "memory:read" in token.payload.capabilities
|
|
124
|
+
assert token.payload.issuer == "AABBCCDDEE1122334455AABBCCDDEE1122334455"
|
|
125
|
+
assert token.payload.token_id
|
|
126
|
+
|
|
127
|
+
def test_issue_stores_token(self, agent_home: Path):
|
|
128
|
+
"""Issued tokens should be persisted to disk."""
|
|
129
|
+
issue_token(
|
|
130
|
+
home=agent_home,
|
|
131
|
+
subject="test",
|
|
132
|
+
capabilities=["*"],
|
|
133
|
+
sign=False,
|
|
134
|
+
)
|
|
135
|
+
token_dir = agent_home / "security" / "tokens"
|
|
136
|
+
assert token_dir.exists()
|
|
137
|
+
assert len(list(token_dir.iterdir())) == 1
|
|
138
|
+
|
|
139
|
+
def test_issue_with_ttl(self, agent_home: Path):
|
|
140
|
+
"""Token with TTL should have expiry set."""
|
|
141
|
+
token = issue_token(
|
|
142
|
+
home=agent_home,
|
|
143
|
+
subject="test",
|
|
144
|
+
capabilities=["*"],
|
|
145
|
+
ttl_hours=48,
|
|
146
|
+
sign=False,
|
|
147
|
+
)
|
|
148
|
+
assert token.payload.expires_at is not None
|
|
149
|
+
delta = token.payload.expires_at - token.payload.issued_at
|
|
150
|
+
assert 47 < delta.total_seconds() / 3600 < 49
|
|
151
|
+
|
|
152
|
+
def test_issue_no_expiry(self, agent_home: Path):
|
|
153
|
+
"""Token with ttl_hours=None should never expire."""
|
|
154
|
+
token = issue_token(
|
|
155
|
+
home=agent_home,
|
|
156
|
+
subject="test",
|
|
157
|
+
capabilities=["*"],
|
|
158
|
+
ttl_hours=None,
|
|
159
|
+
sign=False,
|
|
160
|
+
)
|
|
161
|
+
assert token.payload.expires_at is None
|
|
162
|
+
assert not token.payload.is_expired
|
|
163
|
+
|
|
164
|
+
def test_issue_with_metadata(self, agent_home: Path):
|
|
165
|
+
"""Token can carry custom metadata."""
|
|
166
|
+
token = issue_token(
|
|
167
|
+
home=agent_home,
|
|
168
|
+
subject="test",
|
|
169
|
+
capabilities=["*"],
|
|
170
|
+
metadata={"platform": "cursor", "version": "0.1.0"},
|
|
171
|
+
sign=False,
|
|
172
|
+
)
|
|
173
|
+
assert token.payload.metadata["platform"] == "cursor"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestTokenVerification:
|
|
177
|
+
"""Tests for token verification."""
|
|
178
|
+
|
|
179
|
+
def test_unsigned_token_fails_verification(self, agent_home: Path):
|
|
180
|
+
"""Unsigned tokens should fail verification."""
|
|
181
|
+
token = issue_token(
|
|
182
|
+
home=agent_home,
|
|
183
|
+
subject="test",
|
|
184
|
+
capabilities=["*"],
|
|
185
|
+
sign=False,
|
|
186
|
+
)
|
|
187
|
+
assert not verify_token(token, agent_home)
|
|
188
|
+
|
|
189
|
+
def test_expired_token_fails_verification(self, agent_home: Path):
|
|
190
|
+
"""Expired tokens should fail verification."""
|
|
191
|
+
token = issue_token(
|
|
192
|
+
home=agent_home,
|
|
193
|
+
subject="test",
|
|
194
|
+
capabilities=["*"],
|
|
195
|
+
ttl_hours=0,
|
|
196
|
+
sign=False,
|
|
197
|
+
)
|
|
198
|
+
token.payload.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
199
|
+
assert not verify_token(token, agent_home)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestTokenRevocation:
|
|
203
|
+
"""Tests for token revocation."""
|
|
204
|
+
|
|
205
|
+
def test_revoke_token(self, agent_home: Path):
|
|
206
|
+
"""Revoking a token should add it to the revocation list."""
|
|
207
|
+
token = issue_token(
|
|
208
|
+
home=agent_home,
|
|
209
|
+
subject="test",
|
|
210
|
+
capabilities=["*"],
|
|
211
|
+
sign=False,
|
|
212
|
+
)
|
|
213
|
+
assert not is_revoked(agent_home, token.payload.token_id)
|
|
214
|
+
revoke_token(agent_home, token.payload.token_id)
|
|
215
|
+
assert is_revoked(agent_home, token.payload.token_id)
|
|
216
|
+
|
|
217
|
+
def test_revoke_creates_file(self, agent_home: Path):
|
|
218
|
+
"""Revocation should create the revoked-tokens.json file."""
|
|
219
|
+
revoke_token(agent_home, "fake-token-id")
|
|
220
|
+
revoked_file = agent_home / "security" / "revoked-tokens.json"
|
|
221
|
+
assert revoked_file.exists()
|
|
222
|
+
|
|
223
|
+
def test_revoke_idempotent(self, agent_home: Path):
|
|
224
|
+
"""Revoking the same token twice should work."""
|
|
225
|
+
assert revoke_token(agent_home, "same-id")
|
|
226
|
+
assert revoke_token(agent_home, "same-id")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TestTokenListAndExport:
|
|
230
|
+
"""Tests for listing and exporting tokens."""
|
|
231
|
+
|
|
232
|
+
def test_list_tokens_empty(self, agent_home: Path):
|
|
233
|
+
"""Empty token store should return empty list."""
|
|
234
|
+
assert list_tokens(agent_home) == []
|
|
235
|
+
|
|
236
|
+
def test_list_tokens_returns_issued(self, agent_home: Path):
|
|
237
|
+
"""Listed tokens should include all issued tokens."""
|
|
238
|
+
issue_token(home=agent_home, subject="a", capabilities=["*"], sign=False)
|
|
239
|
+
issue_token(home=agent_home, subject="b", capabilities=["memory:read"], sign=False)
|
|
240
|
+
tokens = list_tokens(agent_home)
|
|
241
|
+
assert len(tokens) == 2
|
|
242
|
+
|
|
243
|
+
def test_export_import_roundtrip(self, agent_home: Path):
|
|
244
|
+
"""Exported token should be importable."""
|
|
245
|
+
original = issue_token(
|
|
246
|
+
home=agent_home,
|
|
247
|
+
subject="roundtrip",
|
|
248
|
+
capabilities=["memory:read", "sync:push"],
|
|
249
|
+
sign=False,
|
|
250
|
+
)
|
|
251
|
+
exported = export_token(original)
|
|
252
|
+
imported = import_token(exported)
|
|
253
|
+
assert imported.payload.subject == "roundtrip"
|
|
254
|
+
assert imported.payload.capabilities == ["memory:read", "sync:push"]
|
|
255
|
+
assert imported.payload.token_id == original.payload.token_id
|
|
256
|
+
|
|
257
|
+
def test_import_invalid_json(self):
|
|
258
|
+
"""Invalid JSON should raise ValueError."""
|
|
259
|
+
with pytest.raises(ValueError):
|
|
260
|
+
import_token("not json")
|
|
261
|
+
|
|
262
|
+
def test_import_wrong_format(self):
|
|
263
|
+
"""Non-token JSON should raise ValueError."""
|
|
264
|
+
with pytest.raises(ValueError):
|
|
265
|
+
import_token('{"foo": "bar"}')
|