forked from Skull-boy/agent-contracts
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_enforcer.py
More file actions
431 lines (352 loc) · 12.3 KB
/
Copy pathtest_enforcer.py
File metadata and controls
431 lines (352 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
"""
Comprehensive test suite for Scyvera Runtime Enforcer.
Verifies:
- Happy paths for permissions and side effects
- Default-deny contract violations
- Approval gates and ApprovalPendingError
- Tamper resistance (in-memory freezing and on-disk SHA-256)
- Threat mitigations T4–T10
- Backward compatibility with v1 contracts
"""
import logging
from pathlib import Path
import warnings
import pytest
from scyvera import (
ApprovalPendingError,
AuditEntry,
ContractEnforcer,
ContractTamperError,
ContractValidationError,
ContractVersionError,
ContractViolationError,
)
ROOT = Path(__file__).resolve().parent.parent
DUPLICATE_ISSUE_CONTRACT = (
ROOT
/ "implementations"
/ "n8n"
/ "duplicate-issue-detector"
/ "contract.yaml"
)
LIFECYCLE_FIXTURES = ROOT / "tests" / "fixtures" / "v1.1"
# =============================================================================
# 1. Happy Path Tests
# =============================================================================
def test_enforcer_loads_v1_contract_with_defaults():
"""A v1 contract loads cleanly and defaults lifecycle to request-response."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
assert enforcer.integrity_hash is not None
assert len(enforcer.integrity_hash) == 64
assert enforcer.contract["lifecycle"]["mode"] == "request-response"
assert enforcer.contract["lifecycle"]["initiation"] == "human-only"
assert enforcer.contract["lifecycle"]["resumability"] == "stateless"
def test_allowed_permission_passes_gate_and_logs():
"""An allowed permission executes and creates an ALLOWED audit entry."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
@enforcer.gate("github: issues:write", "permission")
def post_to_github(issue_id: int, message: str) -> str:
return f"Commented on {issue_id}: {message}"
result = post_to_github(123, "Looks like a duplicate")
assert result == "Commented on 123: Looks like a duplicate"
audit = enforcer.get_audit_log()
assert len(audit) == 1
assert audit[0].action_name == "github: issues:write"
assert audit[0].action_type == "permission"
assert audit[0].decision == "ALLOWED"
assert audit[0].contract_field_reference == "permissions"
def test_allowed_side_effect_passes_gate_and_logs():
"""An allowed side effect executes and creates an ALLOWED audit entry."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
@enforcer.gate("comment", "side_effect")
def write_comment(body: str) -> bool:
return True
assert write_comment("Duplicate detected") is True
audit = enforcer.get_audit_log()
assert len(audit) == 1
assert audit[0].action_name == "comment"
assert audit[0].action_type == "side_effect"
assert audit[0].decision == "ALLOWED"
assert audit[0].contract_field_reference == "side_effects"
# =============================================================================
# 2. Contract Violation Paths (Default-Deny)
# =============================================================================
def test_undeclared_permission_raises_violation():
"""An undeclared permission raises ContractViolationError naming permissions."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
@enforcer.gate("aws_s3:read", "permission")
def read_s3_bucket():
return "data"
with pytest.raises(ContractViolationError) as exc_info:
read_s3_bucket()
err = exc_info.value
assert err.action_name == "aws_s3:read"
assert err.suggestion == "permissions"
assert "not declared" in str(err)
assert "Suggestion: Declare this action in the 'permissions' field" in str(err)
audit = enforcer.get_audit_log()
assert len(audit) == 1
assert audit[0].decision == "DENIED"
def test_undeclared_side_effect_raises_violation():
"""An undeclared side effect raises ContractViolationError naming side_effects."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
@enforcer.gate("delete_repository", "side_effect")
def delete_repo():
return "deleted"
with pytest.raises(ContractViolationError) as exc_info:
delete_repo()
err = exc_info.value
assert err.action_name == "delete_repository"
assert err.suggestion == "side_effects"
assert "Suggestion: Declare this action in the 'side_effects' field" in str(err)
audit = enforcer.get_audit_log()
assert len(audit) == 1
assert audit[0].decision == "DENIED"
# =============================================================================
# 3. Approval Paths
# =============================================================================
def test_approval_point_raises_approval_pending_error(tmp_path):
"""An action defined in approvals raises ApprovalPendingError with approval config."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"""version: 1.1
system:
name: deployment-agent
lifecycle:
mode: request-response
inputs: []
outputs: []
permissions:
- resource: prod_cluster
actions: [deploy]
side_effects:
- type: deployment
resource: prod_cluster
description: Deploys code to production
approvals:
- action: prod_cluster:deploy
required: true
approver: secops_lead
channel: telegram
timeout: "15m"
dependencies: []
state:
persistence: none
recovery:
strategy: retry
replay:
mode: idempotent
observability:
level: basic
risk:
level: high
""",
encoding="utf-8",
)
enforcer = ContractEnforcer.load(contract_file)
@enforcer.gate("prod_cluster:deploy", "side_effect")
def deploy_to_prod():
return "Deployed!"
# Execution is halted prior to running deploy_to_prod
with pytest.raises(ApprovalPendingError) as exc_info:
deploy_to_prod()
err = exc_info.value
assert err.action_name == "prod_cluster:deploy"
assert err.approval_config["approver"] == "secops_lead"
assert err.approval_config["channel"] == "telegram"
assert err.approval_config["timeout"] == "15m"
audit = enforcer.get_audit_log()
assert len(audit) == 1
assert audit[0].decision == "PENDING"
# Manual approval unblocks the action
enforcer.approve("prod_cluster:deploy", token="AUTH_TOKEN_999")
res = deploy_to_prod()
assert res == "Deployed!"
# =============================================================================
# 4. Tamper Resistance Tests (T5, T9)
# =============================================================================
def test_contract_object_is_immutable():
"""Attempting to mutate the frozen contract object raises ContractTamperError."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
with pytest.raises(ContractTamperError):
enforcer.contract["side_effects"] = []
with pytest.raises(ContractTamperError):
enforcer.contract["permissions"].append({"rogue": "root"})
def test_verify_integrity_passes_on_unmodified_file():
"""verify_integrity() succeeds when file content is intact."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
assert enforcer.verify_integrity() is True
def test_verify_integrity_raises_on_disk_modification(tmp_path):
"""verify_integrity() raises ContractTamperError when disk content changes (T5)."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"version: 1\nworkflow: test\ninputs: []\noutputs: []\npermissions: []\n"
"side_effects: []\napproval_points: []\nrecovery_strategy: retry\n"
"replay_semantics: idempotent\ndependencies: []\nstate: none\nobservability: []\n",
encoding="utf-8",
)
enforcer = ContractEnforcer.load(contract_file)
assert enforcer.verify_integrity() is True
# Malicious actor tampers with the file on disk
contract_file.write_text("TAMPERED CONTENT", encoding="utf-8")
with pytest.raises(ContractTamperError) as exc_info:
enforcer.verify_integrity()
assert "tampered with on disk" in str(exc_info.value)
# =============================================================================
# 5. Threat-Specific Tests (T4, T7, T8, T9, T10)
# =============================================================================
def test_t4_assert_gated_utility():
"""assert_gated(fn) validates whether a function is protected by an enforcer gate."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
def ungated_function():
return "raw"
@enforcer.gate("github: issues:write", "permission")
def gated_function():
return "secure"
assert enforcer.assert_gated(gated_function) is True
with pytest.raises(ContractViolationError) as exc_info:
enforcer.assert_gated(ungated_function)
assert "Decorate this function with @enforcer.gate" in str(exc_info.value)
def test_t7_wildcard_permission_fails_at_load_time(tmp_path):
"""A contract with permissions: ['*'] is rejected at load time (T7)."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"""version: 1.1
system:
name: malicious-agent
lifecycle:
mode: request-response
inputs: []
outputs: []
permissions:
- "*"
side_effects: []
approvals: []
dependencies: []
state:
persistence: none
recovery:
strategy: retry
replay:
mode: idempotent
observability:
level: basic
risk:
level: high
""",
encoding="utf-8",
)
with pytest.raises(ContractValidationError) as exc_info:
ContractEnforcer.load(contract_file)
assert "Wildcard permission" in str(exc_info.value)
def test_t7_invalid_service_scope_pattern_fails_at_load_time(tmp_path):
"""A flat permission string not following service:scope fails at load time (T7)."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"""version: 1.1
system:
name: bad-pattern-agent
lifecycle:
mode: request-response
inputs: []
outputs: []
permissions:
- "invalid_unscoped_permission"
side_effects: []
approvals: []
dependencies: []
state:
persistence: none
recovery:
strategy: retry
replay:
mode: idempotent
observability:
level: basic
risk:
level: low
""",
encoding="utf-8",
)
with pytest.raises(ContractValidationError) as exc_info:
ContractEnforcer.load(contract_file)
assert "service:scope pattern" in str(exc_info.value)
def test_t8_irreversible_side_effect_empty_approvals_warns(tmp_path, caplog):
"""A contract with irreversible: true and empty approvals emits a warning (T8)."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"""version: 1.1
system:
name: destructive-agent
lifecycle:
mode: request-response
inputs: []
outputs: []
permissions: []
side_effects:
- type: drop_database
resource: production
irreversible: true
approvals: []
dependencies: []
state:
persistence: none
recovery:
strategy: retry
replay:
mode: idempotent
observability:
level: basic
risk:
level: high
""",
encoding="utf-8",
)
with caplog.at_level(logging.WARNING, logger="scyvera.enforcer"):
ContractEnforcer.load(contract_file)
assert "declares irreversible side effect" in caplog.text
assert "drop_database" in caplog.text
def test_t9_audit_log_tamper_proofing():
"""Modifying the returned audit log list does not alter the enforcer's log (T9)."""
enforcer = ContractEnforcer.load(DUPLICATE_ISSUE_CONTRACT)
@enforcer.gate("github: issues:write", "permission")
def act():
return True
act()
audit_copy = enforcer.get_audit_log()
assert len(audit_copy) == 1
# Tamper with the returned copy
audit_copy.clear()
assert len(audit_copy) == 0
# Internal log remains intact
assert len(enforcer.get_audit_log()) == 1
def test_t10_explicit_v1_1_missing_lifecycle_raises_version_error(tmp_path):
"""A contract declaring contract_version: '1.1' without lifecycle raises ContractVersionError (T10)."""
contract_file = tmp_path / "contract.yaml"
contract_file.write_text(
"""version: 1.1
contract_version: "1.1"
system:
name: missing-lifecycle-agent
inputs: []
outputs: []
permissions: []
side_effects: []
approvals: []
dependencies: []
state:
persistence: none
recovery:
strategy: retry
replay:
mode: idempotent
observability:
level: basic
risk:
level: low
""",
encoding="utf-8",
)
with pytest.raises(ContractVersionError) as exc_info:
ContractEnforcer.load(contract_file)
assert "missing the required 'lifecycle' field" in str(exc_info.value)