Status: Draft Version: 0.2.0-dev Normative: Yes Last Updated: 2026-01-14
Permit tokens are cryptographically signed capability objects that bind:
- WHO authorized (issuer identity)
- WHAT is authorized (tool, parameters, constraints)
- WHEN it is valid (time/step/state window)
- WHY it was authorized (proposal/evidence linkage)
- HOW it's verified (deterministic, fail-closed)
Permits are the enforcement primitive that translates governance decisions into execution constraints.
- Malicious Worker - Attempts to forge permits, escalate privileges, or execute unauthorized actions
- Compromised Cockpit - Issues overly broad permits or backdated permits
- Replay Attacker - Reuses valid permits beyond their intended scope
- Tampering Adversary - Modifies permit fields to expand authorization
- Time Manipulation - Attempts to exploit window boundaries or clock skew
- Jurisdiction integrity - Workers execute only within authorized scope
- Audit completeness - Every execution traces to a permit → proposal → evidence chain
- Replay resistance - Each permit is single-use (or bounded-use)
- Constraint enforcement - Parameters, resource limits, and allowlists are enforced
┌─────────────────────────────────────────────────────┐
│ COCKPIT (Operator Interface) │
│ - Has signing key (HMAC secret) │
│ - Issues permits after operator approval │
│ - Trusted to enforce operator intent │
└─────────────────┬───────────────────────────────────┘
│ Permit Token (signed)
↓
┌─────────────────────────────────────────────────────┐
│ KERNEL (Governance Plane) │
│ - Has verification key (same HMAC secret) │
│ - Verifies permit before allowing execution │
│ - Maintains nonce registry (replay protection) │
│ - NEVER has network access or LLM calls │
└─────────────────┬───────────────────────────────────┘
│ Execution if permit valid
↓
┌─────────────────────────────────────────────────────┐
│ WORKER (Execution Plane) │
│ - Receives permit token (opaque blob) │
│ - Cannot forge, modify, or verify permits │
│ - Executes only if kernel accepts permit │
└─────────────────────────────────────────────────────┘
Key property: Worker never sees signing key; cockpit and kernel share HMAC secret.
@dataclass(frozen=True)
class PermitToken:
"""
Immutable capability token authorizing a specific action.
Invariants:
- All fields are immutable (frozen dataclass)
- permit_id is deterministic (content hash of canonical form)
- Signature is HMAC-SHA256 over canonical encoding
- Missing fields cause verification to fail
"""
# Identity
permit_id: str # SHA-256 hash of canonical permit (excludes signature)
issuer: str # Cockpit principal who authorized this permit
subject: str # Worker identity authorized to execute
# Authorization scope
jurisdiction: str # Explicit jurisdiction this permit operates within
action: str # Tool name or worker action being authorized
params: dict[str, Any] # Exact parameters allowed (must match request)
# Constraints
constraints: dict[str, Any] # Resource limits, allowlists, parameter bounds
max_executions: int # How many times this permit can be used (1 = single-use)
# Validity window
valid_from_ms: int # Monotonic timestamp (milliseconds) when permit becomes valid
valid_until_ms: int # Monotonic timestamp when permit expires
# Audit linkage
evidence_hash: str # SHA-256 hash of evidence packet that justified this permit
proposal_hash: str # SHA-256 hash of proposal that requested this action
# Replay protection
nonce: str # Unique value (UUID4 or hash-derived) for replay detection
# Cryptographic binding
signature: str # HMAC-SHA256(key, canonical_permit_bytes) in hex
key_id: str # Identifier for which HMAC key was used (for rotation)| Field | Type | Constraints | Semantics |
|---|---|---|---|
permit_id |
str | 64-char hex (SHA-256) | Stable identifier; content hash of canonical permit |
issuer |
str | Non-empty, max 256 chars | Cockpit principal or operator identity |
subject |
str | Non-empty, max 256 chars | Worker identity; must match execution context |
jurisdiction |
str | Non-empty, max 256 chars | Must match kernel's active jurisdiction policy |
action |
str | Non-empty, max 256 chars | Tool name; must be on allowlist |
params |
dict | Max 64KB serialized | Exact params; request params must match or be subset |
constraints |
dict | Max 64KB serialized | Bounds: max_time_ms, max_memory_mb, allowed_domains, etc. |
max_executions |
int | ≥1 | Single-use = 1; multi-use = N; unlimited = -1 (discouraged) |
valid_from_ms |
int | ≥0 | Monotonic time; permit invalid before this |
valid_until_ms |
int | > valid_from_ms | Monotonic time; permit invalid after this |
evidence_hash |
str | 64-char hex or empty | Hash of evidence packet; may be empty for low-risk actions |
proposal_hash |
str | 64-char hex | Hash of proposal that initiated this workflow |
nonce |
str | 32-char hex (min) | Random UUID or derived hash; must be unique per issuer+subject |
signature |
str | 64-char hex (HMAC-SHA256) | Cryptographic signature over canonical permit |
key_id |
str | Non-empty, max 64 chars | Key identifier; e.g., "kernel-v1", "cockpit-2026-01" |
To ensure deterministic hashing and verification:
- Field ordering: Alphabetical by field name
- Dict ordering: Keys sorted alphabetically at all nesting levels
- No floating point: Use integers (milliseconds, not seconds)
- UTF-8 encoding: All strings as UTF-8 bytes
- No whitespace: Compact JSON (no spaces, newlines)
- Null handling: Empty strings
"", notnull - Signature exclusion: The
signaturefield is NOT included in canonical form (it signs everything else)
def canonical_permit_bytes(permit: PermitToken) -> bytes:
"""
Produce deterministic byte representation of permit (excluding signature).
Returns: UTF-8 encoded compact JSON with sorted keys.
"""
data = {
"action": permit.action,
"constraints": _sort_dict_recursive(permit.constraints),
"evidence_hash": permit.evidence_hash,
"issuer": permit.issuer,
"jurisdiction": permit.jurisdiction,
"key_id": permit.key_id,
"max_executions": permit.max_executions,
"nonce": permit.nonce,
"params": _sort_dict_recursive(permit.params),
"permit_id": permit.permit_id,
"proposal_hash": permit.proposal_hash,
"subject": permit.subject,
"valid_from_ms": permit.valid_from_ms,
"valid_until_ms": permit.valid_until_ms,
}
# Note: 'signature' deliberately excluded
return json.dumps(data, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')permit_id = sha256(canonical_permit_bytes(permit_without_id_and_sig)).hexdigest()Bootstrapping: When creating a new permit, permit_id and signature are initially empty, then computed:
- Set
permit_id = ""andsignature = "" - Compute
canonical_bytes = canonical_permit_bytes(permit) - Compute
permit_id = sha256(canonical_bytes).hexdigest() - Update permit with
permit_id - Recompute
canonical_byteswith realpermit_id - Compute
signature = hmac.new(key, canonical_bytes, sha256).hexdigest() - Final permit has both
permit_idandsignature
Why HMAC instead of RSA/ECDSA?
- Constraint: Standard library only (no external crypto dependencies)
- Python
hmacmodule is stdlib - HMAC-SHA256 provides:
- Authentication (only keyholder can produce valid signature)
- Integrity (any modification invalidates signature)
- Determinism (same input + key = same signature)
Key management:
- Kernel and cockpit share a secret HMAC key (256 bits recommended)
- Key distribution is out-of-band (environment variable, config file, HSM for production)
- Worker NEVER receives the key
def sign_permit(permit: PermitToken, key: bytes, key_id: str) -> PermitToken:
"""
Sign a permit token with HMAC-SHA256.
Args:
permit: Permit with permit_id computed but signature empty
key: HMAC secret key (32 bytes recommended)
key_id: Key identifier for rotation support
Returns:
New permit with signature field populated
"""
canonical = canonical_permit_bytes(permit)
sig = hmac.new(key, canonical, hashlib.sha256).hexdigest()
return dataclasses.replace(permit, signature=sig, key_id=key_id)def verify_permit(permit: PermitToken, keyring: dict[str, bytes]) -> PermitVerificationResult:
"""
Verify permit signature and constraints.
Fail-closed: ANY uncertainty or violation returns DENY.
Returns:
PermitVerificationResult with status (ALLOW/DENY) and reason codes
"""
reasons = []
# 1. Key ID check
if permit.key_id not in keyring:
return PermitVerificationResult(status=ReceiptStatus.DENY,
reasons=["UNKNOWN_KEY_ID"])
key = keyring[permit.key_id]
# 2. Signature verification
canonical = canonical_permit_bytes(permit)
expected_sig = hmac.new(key, canonical, hashlib.sha256).hexdigest()
if not hmac.compare_digest(permit.signature, expected_sig):
return PermitVerificationResult(status=ReceiptStatus.DENY,
reasons=["SIGNATURE_INVALID"])
# 3. Permit ID verification
permit_without_sig = dataclasses.replace(permit, signature="", permit_id="")
canonical_for_id = canonical_permit_bytes(permit_without_sig)
expected_id = hashlib.sha256(canonical_for_id).hexdigest()
if permit.permit_id != expected_id:
return PermitVerificationResult(status=ReceiptStatus.DENY,
reasons=["PERMIT_ID_MISMATCH"])
# 4-10: Additional checks (see Section 6)
return PermitVerificationResult(status=ReceiptStatus.ALLOW, reasons=[])Security property: Uses hmac.compare_digest() for constant-time comparison (timing attack resistance).
The kernel MUST verify all constraints before allowing execution. Any failure = DENY.
| # | Check | Denial Reason Code | Description |
|---|---|---|---|
| 1 | Key ID known | UNKNOWN_KEY_ID |
key_id not in kernel's keyring |
| 2 | Signature valid | SIGNATURE_INVALID |
HMAC mismatch |
| 3 | Permit ID valid | PERMIT_ID_MISMATCH |
Hash doesn't match canonical form |
| 4 | Time window | EXPIRED, NOT_YET_VALID |
Current time outside [valid_from, valid_until] |
| 5 | Jurisdiction match | JURISDICTION_MISMATCH |
Permit jurisdiction ≠ kernel jurisdiction |
| 6 | Action allowed | ACTION_NOT_ALLOWED |
Action not on allowlist for this jurisdiction |
| 7 | Subject match | SUBJECT_MISMATCH |
Permit subject ≠ request actor |
| 8 | Params match | PARAMS_MISMATCH |
Request params exceed permit params |
| 9 | Nonce fresh | REPLAY_DETECTED |
Nonce already used |
| 10 | Execution count | MAX_EXECUTIONS_EXCEEDED |
Permit already used max_executions times |
| 11 | Constraints satisfied | CONSTRAINT_VIOLATION |
Resource limits, allowlists, bounds violated |
IF (any check fails) THEN
status = DENY
audit_entry.decision = DENY
audit_entry.denial_reasons = [reason_codes...]
return DENY
ELSE
status = ALLOW
audit_entry.decision = ALLOW
audit_entry.permit_digest = permit.permit_id
return ALLOW
No partial acceptance: Either all checks pass (ALLOW) or any fails (DENY).
The kernel maintains an append-only nonce registry to detect replays:
@dataclass(frozen=True)
class NonceRecord:
nonce: str # The nonce value
issuer: str # Who issued the permit with this nonce
subject: str # Who was authorized
first_seen_ms: int # When first used
use_count: int # How many times seen
permit_id: str # Which permit used this nonceStorage: In-memory dict for performance; persisted in audit ledger for reconstruction.
def check_nonce(nonce: str, issuer: str, subject: str, max_executions: int) -> bool:
"""
Check if nonce is fresh (not replayed).
Returns:
True if nonce is fresh (allow execution)
False if replay detected (deny execution)
"""
key = (nonce, issuer, subject)
if key not in nonce_registry:
# First use: record it
nonce_registry[key] = NonceRecord(nonce, issuer, subject,
current_time_ms(), 1, permit_id)
return True # Fresh
record = nonce_registry[key]
if record.use_count >= max_executions:
return False # Replay: exceeded max uses
# Increment use count
nonce_registry[key] = dataclasses.replace(record, use_count=record.use_count + 1)
return True # Still within allowed usesdef generate_nonce() -> str:
"""Generate cryptographically random nonce."""
return uuid.uuid4().hex # 32 hex charsAlternative (deterministic nonces): For testing or replay scenarios:
def deterministic_nonce(proposal_hash: str, sequence: int) -> str:
"""Derive nonce from proposal hash + sequence number."""
data = f"{proposal_hash}:{sequence}".encode('utf-8')
return hashlib.sha256(data).hexdigest()[:32]Critical invariant: Nonces are per-permit-instance, NOT per-use.
A single permit with max_executions=N uses the same nonce for all N executions. The nonce uniquely identifies the permit instance, and the registry tracks use_count to enforce the execution limit.
Why this matters:
-
Ledger-backed reconstruction: After kernel restart, the nonce registry is rebuilt by replaying audit entries. Each ALLOW entry with the same nonce increments
use_count. -
Multi-use permits: A permit with
max_executions=3can be used 3 times with the same nonce. The 4th attempt is rejected asREPLAY_DETECTEDbecauseuse_count=3 >= max_executions=3. -
Cross-restart invariant: Total accepted executions ≤ max_executions, even across restarts.
Reconstruction algorithm:
def rebuild_nonce_registry_from_ledger(ledger_entries: list[AuditEntry]) -> NonceRegistry:
"""
Rebuild nonce registry from audit ledger entries.
Processes entries in deterministic order (by ledger_seq) to ensure
consistent use_count reconstruction.
"""
registry = NonceRegistry()
# Sort by ledger_seq for deterministic ordering (timestamp ties are broken)
for entry in sorted(ledger_entries, key=lambda e: e.ledger_seq):
if entry.permit_verification == "ALLOW" and entry.permit_nonce:
# Reconstruct nonce usage by calling check_and_record
# This increments use_count for each ALLOW entry
registry.check_and_record(
nonce=entry.permit_nonce,
issuer=entry.permit_issuer,
subject=entry.permit_subject,
permit_id=entry.permit_digest,
max_executions=entry.permit_max_executions,
current_time_ms=entry.ts_ms,
)
return registryStorage requirements:
Audit entries must persist the following fields for nonce reconstruction:
permit_nonce: The nonce valuepermit_issuer: Issuer identity (part of registry key)permit_subject: Subject identity (part of registry key)permit_max_executions: Maximum allowed usespermit_verification: "ALLOW" or "DENY" (only ALLOW entries count)ledger_seq: Monotonic sequence number for deterministic ordering
Deterministic ordering:
Entries are sorted by ledger_seq (not ts_ms) to prevent non-determinism when multiple entries have identical timestamps. This ensures reconstruction produces identical nonce registries across restarts.
The constraints dict supports:
constraints = {
"max_time_ms": 5000, # Max execution time
"max_memory_mb": 512, # Max memory usage
"allowed_domains": ["api.example.com"], # Network allowlist
"forbidden_params": ["--unsafe"], # Parameter blocklist
"require_evidence": True, # Must have evidence_hash
"risk_class": "low", # Risk classification
}def validate_constraints(permit: PermitToken, request: KernelRequest) -> list[str]:
"""
Validate request against permit constraints.
Returns:
List of violation reason codes (empty = all satisfied)
"""
violations = []
# Example: Check max_time_ms
if "max_time_ms" in permit.constraints:
if request.estimated_time_ms > permit.constraints["max_time_ms"]:
violations.append("TIME_LIMIT_EXCEEDED")
# Example: Check allowed_domains
if "allowed_domains" in permit.constraints:
if request.target_domain not in permit.constraints["allowed_domains"]:
violations.append("DOMAIN_NOT_ALLOWED")
# Example: Check forbidden params
if "forbidden_params" in permit.constraints:
for forbidden in permit.constraints["forbidden_params"]:
if forbidden in request.params:
violations.append("FORBIDDEN_PARAM_DETECTED")
return violationsWhen a permit is verified, the audit entry MUST include:
@dataclass(frozen=True)
class AuditEntry:
# ... existing fields ...
# Permit-related fields (added in v0.2.0)
permit_digest: str | None # permit_id if permit was used
permit_verification: str # "ALLOW" | "DENY"
permit_denial_reasons: list[str] # Reason codes if denied
proposal_hash: str | None # From permit.proposal_hash
evidence_hash: str | None # From permit.evidence_hashFor any execution, an auditor can trace:
Execution (audit entry)
→ permit_digest
→ PermitToken
→ proposal_hash
→ Proposal
→ evidence_hash
→ Evidence Packet
This provides complete lineage from operator intent to execution result.
The kernel maintains a keyring of active HMAC keys:
keyring: dict[str, bytes] = {
"kernel-v1": bytes.fromhex("a1b2c3..."), # Primary key
"kernel-v0": bytes.fromhex("d4e5f6..."), # Deprecated key (still verify)
}- Add new key: Add to keyring with new
key_id - Dual signing period: Cockpit signs with new key; kernel accepts both old and new
- Deprecate old key: After grace period, remove old key from keyring
- Audit: Every key addition/removal logged in audit trail
If key_id not in keyring:
status = DENY
reason = "UNKNOWN_KEY_ID"
This allows graceful key rotation without breaking existing permits during transition.
| Invariant | Permit Mechanism |
|---|---|
| INV-STATE | Permits verified during VALIDATING/ARBITRATING transitions |
| INV-TRANSITION | Verification logic called from transition functions |
| INV-JURISDICTION | Permit jurisdiction must match kernel policy |
| INV-AUDIT | Every verification (allow/deny) emits audit entry |
| INV-HASH-CHAIN | Permit verification results chained into ledger |
| INV-FAIL-CLOSED | Any verification failure → DENY; no partial acceptance |
| INV-DETERMINISM | HMAC and hashing are deterministic; same inputs = same outputs |
| INV-HALT | Verification errors can trigger HALT in StrictKernel |
| INV-EVIDENCE | Permits link to evidence_hash for traceability |
| INV-NO-IMPLICIT-ALLOW | Permit required for execution; missing permit = deny |
| Attack | Mitigation |
|---|---|
| Permit forgery | HMAC signature; worker has no signing key |
| Permit tampering | Any field change invalidates HMAC |
| Replay attacks | Nonce registry; single-use or bounded-use |
| Time window exploitation | Monotonic clock; strict boundary checks |
| Privilege escalation | Params must exactly match or be subset |
| Constraint bypass | All constraints validated before execution |
| Key compromise | Key rotation support; revoke old keys |
| Nonce collision | UUID4 has ~2^122 space; cryptographically unlikely |
-
HMAC key compromise: If attacker gets signing key, they can forge permits
- Mitigation: Secure key storage (env vars, secrets manager, HSM)
- Detection: Audit trail shows unauthorized permits
-
Cockpit compromise: Malicious cockpit can issue overly broad permits
- Mitigation: Operator approval workflows; permit review
- Detection: Audit permits for anomalies
-
Clock skew: If kernel clock diverges, time windows may be exploited
- Mitigation: Use virtual monotonic clock; NTP sync
- Detection: Clock drift alerts
All cryptographic operations use Python standard library:
hashlib.sha256()- Hashinghmac.new(key, msg, sha256)- HMAC signinghmac.compare_digest()- Constant-time comparisonjson.dumps(sort_keys=True)- Canonical serializationuuid.uuid4()- Nonce generationdataclasses.dataclass(frozen=True)- Immutable types
No external dependencies required.
✅ AC-1: Identical token bytes verify identically across runs.
Test: Serialize permit → verify → serialize again → verify; all results identical.
✅ AC-2: Any change to token fields invalidates signature.
Test: Change any field by 1 character → verification fails with SIGNATURE_INVALID.
✅ AC-3: Reusing same token fails after max_executions exceeded.
Test: Use single-use permit twice → second use denied with REPLAY_DETECTED.
✅ AC-4: Permit authorizes strict subset of actions; divergence denies.
Test: Permit allows {"action": "read", "path": "/foo"}; request {"action": "write"} → denied with PARAMS_MISMATCH.
✅ AC-5: For any execution, trace from entry → permit → proposal → evidence.
Test: Export ledger → find entry → extract permit_digest → find permit → extract proposal_hash → verify chain complete.
Implement at least 25 negative tests covering:
UNKNOWN_KEY_ID- Key not in keyringSIGNATURE_INVALID- Tampered signaturePERMIT_ID_MISMATCH- Hash doesn't match canonicalEXPIRED- Current time > valid_untilNOT_YET_VALID- Current time < valid_fromJURISDICTION_MISMATCH- Wrong jurisdictionACTION_NOT_ALLOWED- Tool not on allowlistSUBJECT_MISMATCH- Wrong worker identityPARAMS_MISMATCH- Request params exceed permitREPLAY_DETECTED- Nonce reusedMAX_EXECUTIONS_EXCEEDED- Used too many timesCONSTRAINT_VIOLATION- Resource limit exceeded- Missing
issuerfield - Missing
subjectfield - Missing
jurisdictionfield - Missing
actionfield - Missing
noncefield - Missing
signaturefield - Negative
max_executions valid_until_ms < valid_from_ms- Non-hex signature
- Wrong signature length
- Empty
permit_id - Non-dict
params - Non-dict
constraints
Idea: Require N-of-M signatures (e.g., 2 operators must approve).
Implementation: Replace single signature field with:
signatures: list[tuple[str, str]] # [(key_id, signature), ...]
required_signatures: int # Minimum NIdea: Permit valid only if certain conditions hold.
Implementation: Add conditions dict:
conditions: dict[str, Any] = {
"state_hash_must_be": "abc123...", # Kernel state must match
"ledger_size_max": 1000, # Ledger must be below size
}Idea: Permit holder can issue sub-permits with reduced scope.
Implementation: Add delegated_from field pointing to parent permit.
- SPEC.md - Core kernel specification
- AUDIT.md - Audit ledger structure
- ERROR_MODEL.md - Fail-closed semantics
- JURISDICTION.md - Policy enforcement
- PROPOSAL.md - Proposal schema (to be implemented)
- EVIDENCE.md - Evidence packet structure
| Version | Date | Changes |
|---|---|---|
| 0.2.0-dev | 2026-01-14 | Initial permit token specification |
END OF SPECIFICATION