-
Notifications
You must be signed in to change notification settings - Fork 0
Establish minimal deterministic SentinelAI production boundary #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
23cfa9c
Add minimal API application boundary
cryptofixyup f235114
Add minimal API health endpoint
cryptofixyup 001cadf
Add minimal API dependencies
cryptofixyup 7538043
Add deterministic risk engine package
cryptofixyup 9c67fca
Implement minimal deterministic risk policy
cryptofixyup 459ce41
Add minimal reputation and decision telemetry schema
cryptofixyup 5990fe7
Replace placeholder with real API health test
cryptofixyup 19a7bd1
Add deterministic risk policy tests
cryptofixyup 09df6f1
Add test dependencies
cryptofixyup 24b567d
Add strict transaction decision API models
cryptofixyup 2e92dc0
Expose deterministic transaction decision endpoint
cryptofixyup 6245401
Add API decision contract tests
cryptofixyup b4961ae
Configure repository test discovery
cryptofixyup dde7913
Add deterministic Python CI test gate
cryptofixyup e4fdb3f
Remove accidental repository scaffold file
cryptofixyup e9f74b4
Make service package imports explicit
cryptofixyup d6cb9d4
Make API package imports explicit
cryptofixyup 2f79804
Make intelligence package imports explicit
cryptofixyup 2531c02
fix: validate Ethereum addresses at API boundary
cryptofixyup 9d4e369
fix: enforce strict transaction identity validation
cryptofixyup 7c952b9
fix: bind risk decisions to chain identity
cryptofixyup 98c48de
fix: make risk transaction identity chain-aware
cryptofixyup e8c4e35
test: align API tests with strict transaction validation
cryptofixyup 4badb27
test: cover strict deterministic policy boundaries
cryptofixyup File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| pull_request: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
| cache: pip | ||
|
|
||
| - name: Install dependencies | ||
| run: python -m pip install -r requirements-dev.txt | ||
|
|
||
| - name: Run tests | ||
| run: python -m pytest -q | ||
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| [tool.pytest.ini_options] | ||
| testpaths = ["tests"] | ||
| pythonpath = ["."] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| pytest>=8.4,<9.0 | ||
| httpx>=0.28,<1.0 | ||
| -r services/api/requirements.txt |
Empty file.
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| from fastapi import FastAPI | ||
|
|
||
| from services.api.app.models import CheckTransactionRequest, CheckTransactionResponse | ||
| from services.intelligence.sentinel_risk.engine import ( | ||
| Confidence, | ||
| Reputation, | ||
| ReputationStatus, | ||
| TransactionFacts, | ||
| evaluate_transaction, | ||
| ) | ||
|
|
||
| POLICY_VERSION = "day13-v3" | ||
|
|
||
| app = FastAPI(title="SentinelAI API", version="0.1.0") | ||
|
|
||
|
|
||
| @app.get("/health", tags=["system"]) | ||
| def health() -> dict[str, str]: | ||
| return {"status": "ok"} | ||
|
|
||
|
|
||
| @app.post("/v1/check-tx", response_model=CheckTransactionResponse, tags=["risk"]) | ||
| def check_transaction(request: CheckTransactionRequest) -> CheckTransactionResponse: | ||
| tx = TransactionFacts( | ||
| chain_id=request.chain_id, | ||
| destination=request.destination, | ||
| is_unlimited_approval=request.is_unlimited_approval, | ||
| spender=request.spender, | ||
| ) | ||
| destination_reputation = Reputation( | ||
| status=ReputationStatus(request.destination_reputation.status), | ||
| confidence=Confidence(request.destination_reputation.confidence), | ||
| ) | ||
| spender_reputation = ( | ||
| Reputation( | ||
| status=ReputationStatus(request.spender_reputation.status), | ||
| confidence=Confidence(request.spender_reputation.confidence), | ||
| ) | ||
| if request.spender_reputation | ||
| else None | ||
| ) | ||
|
|
||
| decision = evaluate_transaction( | ||
| tx, | ||
| destination_reputation, | ||
| spender_reputation, | ||
| ) | ||
|
|
||
| return CheckTransactionResponse( | ||
| decision=decision.value, | ||
| policy_version=POLICY_VERSION, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| from pydantic import BaseModel, ConfigDict, Field, field_validator | ||
|
|
||
| from services.api.app.validation import normalize_address | ||
|
|
||
|
|
||
| class ReputationInput(BaseModel): | ||
| model_config = ConfigDict(extra="forbid") | ||
|
|
||
| status: str = Field(pattern="^(trusted|unknown|malicious)$") | ||
| confidence: str = Field(pattern="^(low|medium|high)$") | ||
|
|
||
|
|
||
| class CheckTransactionRequest(BaseModel): | ||
| model_config = ConfigDict(extra="forbid") | ||
|
|
||
| chain_id: int = Field(gt=0) | ||
| destination: str | ||
| is_unlimited_approval: bool = False | ||
| spender: str | None = None | ||
| destination_reputation: ReputationInput | ||
| spender_reputation: ReputationInput | None = None | ||
|
|
||
| @field_validator("destination") | ||
| @classmethod | ||
| def validate_destination(cls, value: str) -> str: | ||
| return normalize_address(value) | ||
|
|
||
| @field_validator("spender") | ||
| @classmethod | ||
| def validate_spender(cls, value: str | None) -> str | None: | ||
| return normalize_address(value) if value is not None else None | ||
|
|
||
|
|
||
| class CheckTransactionResponse(BaseModel): | ||
| model_config = ConfigDict(extra="forbid") | ||
|
|
||
| decision: str = Field(pattern="^(allow|warn|block)$") | ||
| policy_version: str |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import re | ||
|
|
||
| ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") | ||
|
|
||
|
|
||
| def normalize_address(value: str) -> str: | ||
| if not ADDRESS_RE.fullmatch(value): | ||
| raise ValueError("invalid Ethereum address") | ||
| return value.lower() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| fastapi>=0.116,<1.0 | ||
|
cryptofixyup marked this conversation as resolved.
|
||
| uvicorn[standard]>=0.35,<1.0 | ||
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| from dataclasses import dataclass | ||
| from enum import StrEnum | ||
|
|
||
|
|
||
| class Decision(StrEnum): | ||
| ALLOW = "allow" | ||
| WARN = "warn" | ||
| BLOCK = "block" | ||
|
|
||
|
|
||
| class ReputationStatus(StrEnum): | ||
| TRUSTED = "trusted" | ||
| UNKNOWN = "unknown" | ||
| MALICIOUS = "malicious" | ||
|
|
||
|
|
||
| class Confidence(StrEnum): | ||
| LOW = "low" | ||
| MEDIUM = "medium" | ||
| HIGH = "high" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Reputation: | ||
| status: ReputationStatus | ||
| confidence: Confidence | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class TransactionFacts: | ||
| chain_id: int | ||
| destination: str | ||
| is_unlimited_approval: bool = False | ||
| spender: str | None = None | ||
|
|
||
|
|
||
| def evaluate_transaction( | ||
| tx: TransactionFacts, | ||
| destination_reputation: Reputation, | ||
| spender_reputation: Reputation | None = None, | ||
| ) -> Decision: | ||
| """Apply the minimal deterministic policy. | ||
|
|
||
| Only high-confidence malicious evidence can BLOCK. Unknown or | ||
| unavailable evidence never becomes an implicit ALLOW. | ||
| """ | ||
| if ( | ||
| destination_reputation.status is ReputationStatus.MALICIOUS | ||
| and destination_reputation.confidence is Confidence.HIGH | ||
| ): | ||
| return Decision.BLOCK | ||
|
|
||
| if ( | ||
| tx.is_unlimited_approval | ||
| and tx.spender is not None | ||
| and spender_reputation is not None | ||
| and spender_reputation.status is ReputationStatus.MALICIOUS | ||
| and spender_reputation.confidence is Confidence.HIGH | ||
| ): | ||
| return Decision.BLOCK | ||
|
|
||
| if ( | ||
| destination_reputation.status is ReputationStatus.UNKNOWN | ||
| or destination_reputation.confidence is Confidence.LOW | ||
| ): | ||
|
cryptofixyup marked this conversation as resolved.
|
||
| return Decision.WARN | ||
|
cryptofixyup marked this conversation as resolved.
|
||
|
|
||
| if tx.is_unlimited_approval: | ||
| return Decision.WARN | ||
|
|
||
| return Decision.ALLOW | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| CREATE TABLE IF NOT EXISTS reputation_entries ( | ||
| chain_id BIGINT NOT NULL, | ||
| address TEXT NOT NULL, | ||
| status TEXT NOT NULL CHECK (status IN ('trusted', 'unknown', 'malicious')), | ||
| confidence TEXT NOT NULL CHECK (confidence IN ('low', 'medium', 'high')), | ||
| source TEXT NOT NULL, | ||
| observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| PRIMARY KEY (chain_id, address) | ||
| ); | ||
|
|
||
| CREATE TABLE IF NOT EXISTS decision_events ( | ||
| id UUID PRIMARY KEY, | ||
| request_id UUID NOT NULL, | ||
| chain_id BIGINT NOT NULL, | ||
| from_address TEXT NOT NULL, | ||
| to_address TEXT NOT NULL, | ||
| decision TEXT NOT NULL CHECK (decision IN ('allow', 'warn', 'block')), | ||
| risk_score INTEGER NOT NULL CHECK (risk_score BETWEEN 0 AND 100), | ||
| category TEXT NOT NULL, | ||
| confidence TEXT NOT NULL CHECK (confidence IN ('low', 'medium', 'high')), | ||
| reasons JSONB NOT NULL, | ||
| latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0), | ||
| runtime_mode TEXT NOT NULL CHECK (runtime_mode IN ('shadow', 'enforce')), | ||
| policy_version TEXT NOT NULL, | ||
| user_action TEXT CHECK (user_action IS NULL OR user_action IN ('accepted', 'cancelled', 'overridden')), | ||
| wallet_outcome TEXT CHECK (wallet_outcome IS NULL OR wallet_outcome IN ('blocked', 'submitted', 'rejected', 'failed')), | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS decision_events_created_idx ON decision_events (created_at); | ||
| CREATE INDEX IF NOT EXISTS decision_events_decision_idx ON decision_events (decision); | ||
| CREATE INDEX IF NOT EXISTS decision_events_request_idx ON decision_events (request_id); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from services.api.app.main import app | ||
|
|
||
|
|
||
| client = TestClient(app) | ||
| VALID_DESTINATION = "0x000000000000000000000000000000000000dEaD" | ||
| VALID_UNKNOWN = "0x0000000000000000000000000000000000000001" | ||
|
|
||
|
|
||
| def test_high_confidence_malicious_destination_blocks() -> None: | ||
| response = client.post( | ||
| "/v1/check-tx", | ||
| json={ | ||
| "chain_id": 1, | ||
| "destination": VALID_DESTINATION, | ||
| "destination_reputation": { | ||
| "status": "malicious", | ||
| "confidence": "high", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json() == { | ||
| "decision": "block", | ||
| "policy_version": "day13-v3", | ||
| } | ||
|
|
||
|
|
||
| def test_unknown_destination_never_becomes_allow() -> None: | ||
| response = client.post( | ||
| "/v1/check-tx", | ||
| json={ | ||
| "chain_id": 1, | ||
| "destination": VALID_UNKNOWN, | ||
| "destination_reputation": { | ||
| "status": "unknown", | ||
| "confidence": "low", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["decision"] == "warn" | ||
|
|
||
|
|
||
| def test_invalid_ethereum_address_is_rejected() -> None: | ||
| response = client.post( | ||
| "/v1/check-tx", | ||
| json={ | ||
| "chain_id": 1, | ||
| "destination": "0xdead", | ||
| "destination_reputation": { | ||
| "status": "trusted", | ||
| "confidence": "high", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
|
|
||
| def test_extra_request_fields_are_rejected() -> None: | ||
| response = client.post( | ||
| "/v1/check-tx", | ||
| json={ | ||
| "chain_id": 1, | ||
| "destination": VALID_DESTINATION, | ||
| "unexpected": True, | ||
| "destination_reputation": { | ||
| "status": "trusted", | ||
| "confidence": "high", | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| assert response.status_code == 422 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,13 @@ | ||
| def test_placeholder_health(): | ||
| # Placeholder test so CI has at least one deterministic test target. | ||
| # Replace with real API health test when app entrypoint is confirmed. | ||
| assert True | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from services.api.app.main import app | ||
|
|
||
|
|
||
| client = TestClient(app) | ||
|
|
||
|
|
||
| def test_health_endpoint() -> None: | ||
| response = client.get("/health") | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json() == {"status": "ok"} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.