Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions docs/audits/openclaw_hermes/HXA29_TOKEN_SCOPE_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# HXA29: Token Scope Validation (Phase 1)

## Slice ID
`HXA29_TOKEN_SCOPE_VALIDATION_PHASE1`

## Status
**COMPLETE** - Token scope validation against action classes implemented and tested.

## Predecessor
HXA28: D3 Native Classification - `D3_NATIVE_CLASSIFICATION_DEFINED`

## Verdict
`TOKEN_SCOPE_VALIDATION_DEFINED`

---

## Objective

Harden capability token scope validation against Hermes destructive-action classes:
1. Map capability token scopes explicitly to allowed action classes
2. D3 sandbox scopes may authorize ONLY D3 dry-run/sandbox evidence actions
3. D3 scopes must NEVER authorize D4/D5/D6 actions
4. Separate scopes for repo/source/external defined but blocked by guard
5. Scope validation must be deterministic and fail-closed

---

## Implementation

### New Constants Added (`capability_token_validator.py`)

```python
# Action class to scopes mapping
ACTION_CLASS_SCOPES = {
"D0_OBSERVE": ["d0:observe", "d0:read", "d0:status"],
"D1_READ": ["d1:read", "d1:fetch", "d1:load"],
"D2_SIMULATE": ["d2:simulate", "d2:plan", "d2:preview"],
"D3_WRITE_SANDBOX": ["d3:sandbox", "d3:evidence", "d3:dry-run"],
"D4_WRITE_REPO": ["d4:repo", "d4:source", "d4:git"],
"D5_EXTERNAL_SIDE_EFFECT": ["d5:external", "d5:api", "d5:webhook"],
"D6_IRREVERSIBLE": ["d6:delete", "d6:irreversible", "d6:payout"],
}

# Reverse mapping: scope -> action class
SCOPE_TO_ACTION_CLASS = {
"d3:sandbox": "D3_WRITE_SANDBOX",
"d3:evidence": "D3_WRITE_SANDBOX",
"d3:dry-run": "D3_WRITE_SANDBOX",
"d4:repo": "D4_WRITE_REPO",
# ... etc.
}
```

### New Function Added

```python
def validate_scope_for_action_class(scope: str, action_class: Any) -> bool:
"""
Validate that a scope authorizes a specific action class.

Returns True only if scope maps to the requested action class.
Returns False for unknown scopes (fail-closed).
"""
```

---

## Scope Validation Rules

### D3 Scopes (Sandbox/Evidence/Dry-Run)

| Scope | D3 | D4 | D5 | D6 |
|-------|----|----|----|----|
| `d3:sandbox` | ALLOWED | BLOCKED | BLOCKED | BLOCKED |
| `d3:evidence` | ALLOWED | BLOCKED | BLOCKED | BLOCKED |
| `d3:dry-run` | ALLOWED | BLOCKED | BLOCKED | BLOCKED |

### D4/D5/D6 Scopes (Defined but Guard-Blocked)

| Scope | D3 | D4 | D5 | D6 |
|-------|----|----|----|----|
| `d4:repo` | BLOCKED | AUTHORIZED* | BLOCKED | BLOCKED |
| `d5:external` | BLOCKED | BLOCKED | AUTHORIZED* | BLOCKED |
| `d6:delete` | BLOCKED | BLOCKED | BLOCKED | AUTHORIZED* |

*AUTHORIZED by scope but BLOCKED by guard policy in Phase 1.

### Unknown/Missing Scopes

| Condition | Result |
|-----------|--------|
| Missing scope | BLOCKED (fail-closed) |
| Unknown scope | BLOCKED (fail-closed) |
| Empty scopes list | BLOCKED (fail-closed) |

---

## Test Coverage (54 tests)

### Scope Constants Tests (6 tests)
- `TestScopeConstantsExist` - Constants defined correctly

### D3 Scope Authorization Tests (9 tests)
- `TestD3SandboxScopeAuthorizesD3Only` - D3 scopes authorize D3 actions
- `TestD3ScopeDoesNotAuthorizeD4` - D3 scopes blocked for D4
- `TestD3ScopeDoesNotAuthorizeD5` - D3 scopes blocked for D5
- `TestD3ScopeDoesNotAuthorizeD6` - D3 scopes blocked for D6

### D4/D5/D6 Scope Tests (3 tests)
- `TestD4D5D6ScopesDefinedButBlocked` - Scopes validate but guard blocks

### Fail-Closed Tests (4 tests)
- `TestMissingScopeFailsClosed` - Missing scope blocked
- `TestUnknownScopeFailsClosed` - Unknown scope blocked

### Mixed Scope Tests (2 tests)
- `TestMixedScopesObeyActionClass` - Multiple scopes still respect class

### Path Validation Tests (3 tests)
- `TestBlockedPathOverridesAllowedScope` - Path blocks override scope
- `TestPathTraversalBlocked` - Traversal attempts blocked

### Dry-Run Tests (2 tests)
- `TestDryRunOnlyBlocksLiveExecution` - dry_run_only enforced

### WSP 97 Truth Fields Tests (4 tests)
- `TestWSP97TruthFieldsAlwaysFalse` - All truth fields remain False

### Parametrized Scope Validation Tests (14 tests)
- `TestValidateScopeForActionClass` - All scope/class combinations

### Verdict Documentation (1 test)
- `TestHXA29VerdictDocumentation` - Verdict proof

---

## WSP 97 Truth Boundaries

All truth fields remain FALSE in Phase 1:

| Field | Value | Reason |
|-------|-------|--------|
| `live_external_delegate_called` | False | No live delegation |
| `repo_created` | False | No GitHub operations |
| `production_source_modified` | False | No production writes |
| `real_execution_performed` | False | Phase 1 dry-run only |
| `verification_complete` | False | No CABR pipeline |
| `cabr_ready` | False | No CABR pipeline |
| `payout_ready` | False | No payout pipeline |

---

## Regression Tests

All prior HXA tests continue to pass:

| Test Suite | Result |
|------------|--------|
| `test_hxa28_d3_native_classification.py` | 132 passed |
| `test_hxa27_hermes_token_validation_integration.py` | 31 passed |
| `test_hxa26_token_validation_service.py` | 51 passed |
| `test_hermes_job_executor.py` | 94 passed |

---

## Files Changed

### Production Code
- `modules/infrastructure/wre_core/src/capability_token_validator.py`
- Added `ACTION_CLASS_SCOPES` constant
- Added `SCOPE_TO_ACTION_CLASS` constant
- Added `validate_scope_for_action_class()` function

### Tests
- `modules/infrastructure/wre_core/tests/test_hxa29_token_scope_validation.py` (NEW)
- 54 tests covering scope validation

---

## Security Analysis

### Threat Mitigated
D3 scope escalation to D4/D5/D6 actions blocked by:
1. Scope-to-action-class mapping is explicit (no wildcards)
2. Each scope maps to exactly one action class
3. Unknown scopes fail closed (return False)
4. Guard policy provides defense in depth (blocks D4/D5/D6 in Phase 1)

### Defense in Depth
Token scope validation is layer 1. Guard policy is layer 2.
Even if scope somehow authorizes D4 action, guard still blocks:
- `BLOCKED_D4_REPO_WRITE_PHASE1`
- `BLOCKED_D5_EXTERNAL_PHASE1`
- `BLOCKED_D6_IRREVERSIBLE_PHASE1`

---

## Recommended Next Slice

**HXA30: Scope-Aware Token Validation Integration**

Integrate `validate_scope_for_action_class()` into the HermesJobExecutor token validation flow to:
1. Validate requested action class against token scopes before guard
2. Add `SCOPE_NOT_AUTHORIZED_FOR_ACTION_CLASS` reason code
3. Block execution early if scope doesn't authorize action class

This would add a third layer of defense before guard evaluation.

---

## Worker
W1

## Date
2026-05-12

## WSP Compliance
- WSP 97: Truth Boundaries (all safety fields remain False)
- WSP 50: Pre-Action Verification (fail-closed validation)
- WSP 11: Interface contract (typed constants and functions)
65 changes: 65 additions & 0 deletions modules/infrastructure/wre_core/ModLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,71 @@

## Chronological Change Log

### [2026-05-12] - HXA29_TOKEN_SCOPE_VALIDATION_PHASE1 (v0.8.37)

**WSP Protocol References**: WSP 97 (Truthful), WSP 15 (Priority), WSP 50 (Pre-Action)
**Impact Analysis**: Token scope validation against Hermes destructive-action classes

#### Changes Made

- `src/capability_token_validator.py` (MODIFIED - ~80 lines):
- Added `ACTION_CLASS_SCOPES` constant mapping action classes to authorized scopes
- Added `SCOPE_TO_ACTION_CLASS` reverse mapping for scope lookup
- Added `validate_scope_for_action_class()` function for scope-to-class validation
- D3 scopes (d3:sandbox, d3:evidence, d3:dry-run) authorize ONLY D3 actions
- D3 scopes do NOT authorize D4/D5/D6 actions (fail-closed)
- D4/D5/D6 scopes defined but blocked by guard policy
- Unknown scopes fail closed (return False)

- `tests/test_hxa29_token_scope_validation.py` (NEW - 700+ lines):
- 54 tests covering scope validation
- TestScopeConstantsExist (6 tests)
- TestD3SandboxScopeAuthorizesD3Only (3 tests)
- TestD3ScopeDoesNotAuthorizeD4 (4 tests)
- TestD3ScopeDoesNotAuthorizeD5 (4 tests)
- TestD3ScopeDoesNotAuthorizeD6 (4 tests)
- TestD4D5D6ScopesDefinedButBlocked (3 tests)
- TestMissingScopeFailsClosed (2 tests)
- TestUnknownScopeFailsClosed (2 tests)
- TestMixedScopesObeyActionClass (2 tests)
- TestBlockedPathOverridesAllowedScope (1 test)
- TestPathTraversalBlocked (2 tests)
- TestDryRunOnlyBlocksLiveExecution (2 tests)
- TestWSP97TruthFieldsAlwaysFalse (4 tests)
- TestValidateScopeForActionClass (14 tests)
- TestHXA29VerdictDocumentation (1 test)

- `docs/audits/openclaw_hermes/HXA29_TOKEN_SCOPE_VALIDATION.md` (NEW):
- Full audit document with scope mapping tables
- Fail-closed design documented
- Defense in depth documented
- 54 tests passing

#### HXA29 Verdict

```
Verdict: TOKEN_SCOPE_VALIDATION_DEFINED

HXA28 verdict was: D3_NATIVE_CLASSIFICATION_DEFINED

HXA29 proves:
1. D3 sandbox scopes authorize ONLY D3 dry-run/sandbox actions
2. D3 scopes do NOT authorize D4 repo creation
3. D3 scopes do NOT authorize D5 external side effects
4. D3 scopes do NOT authorize D6 irreversible actions
5. D4/D5/D6 scopes defined but blocked by guard policy
6. Missing scope fails closed
7. Unknown scope fails closed
8. Mixed scopes still obey action class restrictions
9. Blocked path overrides allowed scope
10. Path traversal is blocked
11. dry_run_only token blocks live execution
12. All WSP 97 truth fields remain False
13. 362 tests passing across all token/guard test files
```

---

### [2026-05-12] - HXA28_D3_NATIVE_CLASSIFICATION_PHASE1 (v0.8.36)

**WSP Protocol References**: WSP 97 (Truthful), WSP 15 (Priority), WSP 50 (Pre-Action)
Expand Down
78 changes: 78 additions & 0 deletions modules/infrastructure/wre_core/src/capability_token_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,3 +701,81 @@ def reset_default_validator() -> None:
"""
global _default_validator
_default_validator = None


# ===========================================================================
# SECTION 8: Action Class Scope Mappings (HXA29)
# ===========================================================================


# Map action classes to their authorized scopes
# D3 scopes authorize ONLY D3 actions (sandbox/evidence/dry-run)
# D4 scopes authorize D4 actions (repo/source/git) but blocked by guard
# D5 scopes authorize D5 actions (external/api) but blocked by guard
# D6 scopes authorize D6 actions (delete/irreversible) but blocked by guard
ACTION_CLASS_SCOPES: Dict[str, List[str]] = {
"D0_OBSERVE": ["d0:observe", "d0:read", "d0:status"],
"D1_READ": ["d1:read", "d1:fetch", "d1:load"],
"D2_SIMULATE": ["d2:simulate", "d2:plan", "d2:preview"],
"D3_WRITE_SANDBOX": ["d3:sandbox", "d3:evidence", "d3:dry-run"],
"D4_WRITE_REPO": ["d4:repo", "d4:source", "d4:git"],
"D5_EXTERNAL_SIDE_EFFECT": ["d5:external", "d5:api", "d5:webhook"],
"D6_IRREVERSIBLE": ["d6:delete", "d6:irreversible", "d6:payout"],
}


# Reverse mapping: scope -> action class
SCOPE_TO_ACTION_CLASS: Dict[str, str] = {}
for action_class, scopes in ACTION_CLASS_SCOPES.items():
for scope in scopes:
SCOPE_TO_ACTION_CLASS[scope] = action_class


def validate_scope_for_action_class(
scope: str,
action_class: Any, # DestructiveActionClass from destructive_action_guard
) -> bool:
"""
Validate that a scope authorizes a specific action class.

HXA29 Scope Validation Rules:
- D3 scopes (d3:sandbox, d3:evidence, d3:dry-run) authorize ONLY D3 actions
- D3 scopes do NOT authorize D4/D5/D6 actions
- D4 scopes authorize D4 actions (but guard still blocks in Phase 1)
- D5 scopes authorize D5 actions (but guard still blocks in Phase 1)
- D6 scopes authorize D6 actions (but guard still blocks in Phase 1)
- Unknown scopes fail closed (return False)

Key Principle: Scope authorization is separate from guard policy.
Even if scope authorizes action, guard may still block in Phase 1.

Args:
scope: The scope string to validate (e.g., "d3:sandbox")
action_class: The DestructiveActionClass being requested

Returns:
True if scope authorizes the action class, False otherwise

Example:
>>> validate_scope_for_action_class("d3:sandbox", DestructiveActionClass.D3_WRITE_SANDBOX)
True
>>> validate_scope_for_action_class("d3:sandbox", DestructiveActionClass.D4_WRITE_REPO)
False
"""
# Fail-closed: unknown scope does not authorize any action
if scope not in SCOPE_TO_ACTION_CLASS:
return False

# Get the action class that this scope authorizes
authorized_class = SCOPE_TO_ACTION_CLASS[scope]

# Get the requested action class name
# Handle both enum and string
if hasattr(action_class, "value"):
requested_class = action_class.value
else:
requested_class = str(action_class)

# Scope only authorizes its own action class
# D3 scopes do NOT authorize D4/D5/D6
return authorized_class == requested_class
Loading
Loading