fastfence 0.1.1__py3-none-any.whl
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.
- fastfence/__init__.py +1 -0
- fastfence/app/__init__.py +0 -0
- fastfence/app/factory.py +66 -0
- fastfence/app/interfaces/__init__.py +0 -0
- fastfence/app/interfaces/cli/__init__.py +0 -0
- fastfence/app/interfaces/cli/bootstrap_config.py +78 -0
- fastfence/app/interfaces/cli/initialize.py +139 -0
- fastfence/app/interfaces/cli/laya_install.py +64 -0
- fastfence/app/interfaces/cli/main.py +82 -0
- fastfence/app/interfaces/cli/ocr_install.py +62 -0
- fastfence/app/interfaces/cli/startup.py +178 -0
- fastfence/app/interfaces/http/__init__.py +0 -0
- fastfence/app/interfaces/http/authoring_process.py +133 -0
- fastfence/app/interfaces/http/authoring_records.py +127 -0
- fastfence/app/interfaces/http/authoring_regression.py +63 -0
- fastfence/app/interfaces/http/authoring_session.py +250 -0
- fastfence/app/interfaces/http/documents.py +121 -0
- fastfence/app/interfaces/http/ingress.py +102 -0
- fastfence/app/interfaces/http/openai.py +210 -0
- fastfence/app/interfaces/http/policy_authoring.py +61 -0
- fastfence/app/interfaces/http/routes.py +194 -0
- fastfence/app/interfaces/http/rule_authoring.py +39 -0
- fastfence/app/interfaces/http/semantic_preview.py +48 -0
- fastfence/app/interfaces/http/web/audit.js +106 -0
- fastfence/app/interfaces/http/web/console.css +6 -0
- fastfence/app/interfaces/http/web/console.js +181 -0
- fastfence/app/interfaces/http/web/documents.js +38 -0
- fastfence/app/interfaces/http/web/index.html +130 -0
- fastfence/app/interfaces/http/web/logo.svg +1 -0
- fastfence/app/interfaces/http/web/playground.js +76 -0
- fastfence/app/interfaces/http/web/policy-manager.js +309 -0
- fastfence/app/interfaces/http/web/policy-studio.js +251 -0
- fastfence/app/interfaces/http/web/rules.js +93 -0
- fastfence/modules/__init__.py +0 -0
- fastfence/modules/anonymization/__init__.py +1 -0
- fastfence/modules/anonymization/application/__init__.py +0 -0
- fastfence/modules/anonymization/application/facade.py +104 -0
- fastfence/modules/anonymization/application/services/__init__.py +0 -0
- fastfence/modules/anonymization/application/services/transform.py +182 -0
- fastfence/modules/anonymization/application/use_cases/__init__.py +0 -0
- fastfence/modules/anonymization/contracts/__init__.py +0 -0
- fastfence/modules/anonymization/contracts/ports.py +24 -0
- fastfence/modules/anonymization/domain/__init__.py +0 -0
- fastfence/modules/anonymization/domain/matching.py +87 -0
- fastfence/modules/anonymization/domain/tokens.py +86 -0
- fastfence/modules/anonymization/interfaces/__init__.py +0 -0
- fastfence/modules/anonymization/persistence/__init__.py +0 -0
- fastfence/modules/anonymization/persistence/asymmetric.py +96 -0
- fastfence/modules/anonymization/persistence/crypto.py +238 -0
- fastfence/modules/control/__init__.py +0 -0
- fastfence/modules/control/application/__init__.py +0 -0
- fastfence/modules/control/application/facade.py +201 -0
- fastfence/modules/control/application/services/__init__.py +0 -0
- fastfence/modules/control/application/services/alias_session.py +91 -0
- fastfence/modules/control/application/services/engine.py +192 -0
- fastfence/modules/control/application/services/execution.py +152 -0
- fastfence/modules/control/application/services/inspection.py +253 -0
- fastfence/modules/control/application/use_cases/__init__.py +0 -0
- fastfence/modules/control/application/use_cases/content.py +61 -0
- fastfence/modules/control/application/use_cases/management.py +102 -0
- fastfence/modules/control/application/use_cases/policy_preview.py +140 -0
- fastfence/modules/control/application/use_cases/semantic_preview.py +51 -0
- fastfence/modules/control/contracts/__init__.py +0 -0
- fastfence/modules/control/contracts/dto.py +18 -0
- fastfence/modules/control/contracts/ports.py +153 -0
- fastfence/modules/control/domain/__init__.py +0 -0
- fastfence/modules/control/domain/controls.py +102 -0
- fastfence/modules/control/domain/exceptions.py +17 -0
- fastfence/modules/control/domain/frozen.py +35 -0
- fastfence/modules/control/domain/limits.py +14 -0
- fastfence/modules/control/domain/models.py +268 -0
- fastfence/modules/control/domain/policy_authoring.py +273 -0
- fastfence/modules/control/domain/policy_tests.py +34 -0
- fastfence/modules/control/domain/privacy.py +37 -0
- fastfence/modules/control/domain/semantic_rules.py +31 -0
- fastfence/modules/control/domain/signature_matching.py +204 -0
- fastfence/modules/control/domain/text_rules.py +142 -0
- fastfence/modules/control/interfaces/__init__.py +0 -0
- fastfence/modules/control/interfaces/mcp/__init__.py +0 -0
- fastfence/modules/control/interfaces/mcp/server.py +103 -0
- fastfence/modules/control/persistence/__init__.py +0 -0
- fastfence/modules/control/persistence/config_providers.py +161 -0
- fastfence/modules/control/persistence/identity.py +64 -0
- fastfence/modules/control/persistence/laya_semantic.py +172 -0
- fastfence/modules/control/persistence/ledger.py +264 -0
- fastfence/modules/control/persistence/models.py +205 -0
- fastfence/modules/control/persistence/openai_models.py +142 -0
- fastfence/modules/control/persistence/policy.py +215 -0
- fastfence/modules/control/persistence/secrets.py +179 -0
- fastfence/modules/control/persistence/semantic_severity.py +66 -0
- fastfence/modules/control/persistence/tools.py +21 -0
- fastfence/modules/ocr/__init__.py +0 -0
- fastfence/modules/ocr/application/__init__.py +0 -0
- fastfence/modules/ocr/application/facade.py +26 -0
- fastfence/modules/ocr/application/services/__init__.py +0 -0
- fastfence/modules/ocr/application/use_cases/__init__.py +0 -0
- fastfence/modules/ocr/contracts/__init__.py +0 -0
- fastfence/modules/ocr/contracts/ports.py +9 -0
- fastfence/modules/ocr/domain/__init__.py +0 -0
- fastfence/modules/ocr/domain/markdown.py +23 -0
- fastfence/modules/ocr/interfaces/__init__.py +0 -0
- fastfence/modules/ocr/interfaces/smoke.py +133 -0
- fastfence/modules/ocr/persistence/__init__.py +0 -0
- fastfence/modules/ocr/persistence/bootstrap.py +36 -0
- fastfence/modules/ocr/persistence/isolation.py +14 -0
- fastfence/modules/ocr/persistence/media.py +86 -0
- fastfence/modules/ocr/persistence/models.py +46 -0
- fastfence/modules/ocr/persistence/provider.py +88 -0
- fastfence/modules/ocr/persistence/worker.py +109 -0
- fastfence/shared/__init__.py +0 -0
- fastfence/shared/anonymization.py +139 -0
- fastfence/shared/defaults/__init__.py +1 -0
- fastfence/shared/defaults/ocr-requirements.txt +750 -0
- fastfence/shared/defaults/policy.yaml +35 -0
- fastfence/shared/defaults/signatures.json +32 -0
- fastfence/shared/laya_bundle/author_rule.py +312 -0
- fastfence/shared/laya_bundle/authoring_contracts.py +129 -0
- fastfence/shared/laya_bundle/draft_policy.py +211 -0
- fastfence/shared/laya_bundle/policy_generation.py +71 -0
- fastfence/shared/laya_bundle/run_demo.py +282 -0
- fastfence/shared/laya_bundle/semantic_response.py +67 -0
- fastfence/shared/laya_bundle/semantic_worker.py +186 -0
- fastfence/shared/laya_bundle/setup.sh +25 -0
- fastfence/shared/models.py +7 -0
- fastfence/shared/ocr.py +64 -0
- fastfence/shared/settings/__init__.py +0 -0
- fastfence/shared/settings/app_settings.py +183 -0
- fastfence/shared/settings/upstream_url.py +39 -0
- fastfence/workflows/__init__.py +0 -0
- fastfence/workflows/anonymization.py +195 -0
- fastfence/workflows/document_markdown.py +59 -0
- fastfence-0.1.1.dist-info/METADATA +167 -0
- fastfence-0.1.1.dist-info/RECORD +137 -0
- fastfence-0.1.1.dist-info/WHEEL +4 -0
- fastfence-0.1.1.dist-info/entry_points.txt +2 -0
- fastfence-0.1.1.dist-info/licenses/LICENSE +202 -0
- fastfence-0.1.1.dist-info/licenses/NOTICE +2 -0
fastfence/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastFence: trusted policy enforcement outside agent reasoning."""
|
|
File without changes
|
fastfence/app/factory.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from contextlib import asynccontextmanager, suppress
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from fastfence.app.interfaces.http.documents import configure_documents
|
|
8
|
+
from fastfence.app.interfaces.http.ingress import ProtectedRestIngress
|
|
9
|
+
from fastfence.app.interfaces.http.openai import create_router
|
|
10
|
+
from fastfence.app.interfaces.http.routes import configure_http
|
|
11
|
+
from fastfence.modules.control.application.facade import (
|
|
12
|
+
ControlRuntime,
|
|
13
|
+
build_runtime,
|
|
14
|
+
)
|
|
15
|
+
from fastfence.modules.control.contracts.ports import ToolsPort
|
|
16
|
+
from fastfence.modules.control.interfaces.mcp.server import create_mcp
|
|
17
|
+
from fastfence.shared.settings.app_settings import AppSettings
|
|
18
|
+
from fastfence.workflows.anonymization import build_anonymization
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def watch_config(runtime: ControlRuntime, interval: float) -> None:
|
|
22
|
+
while True:
|
|
23
|
+
await asyncio.sleep(interval)
|
|
24
|
+
await runtime.refresh_config()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app(
|
|
28
|
+
settings: AppSettings | None = None, *, tools: ToolsPort | None = None
|
|
29
|
+
) -> FastAPI:
|
|
30
|
+
settings = settings or AppSettings.environment()
|
|
31
|
+
runtime = build_runtime(
|
|
32
|
+
settings, anonymization=build_anonymization(settings), tools=tools
|
|
33
|
+
)
|
|
34
|
+
mcp = create_mcp(runtime, runtime.identities)
|
|
35
|
+
mcp_app = mcp.http_app(path="/", stateless_http=True, json_response=True)
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
39
|
+
async with mcp_app.lifespan(app):
|
|
40
|
+
watcher = asyncio.create_task(
|
|
41
|
+
watch_config(runtime, settings.config_poll_interval),
|
|
42
|
+
name="fastfence-config-refresh",
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
watcher.cancel()
|
|
48
|
+
with suppress(asyncio.CancelledError):
|
|
49
|
+
await watcher
|
|
50
|
+
await runtime.aclose()
|
|
51
|
+
|
|
52
|
+
app = FastAPI(title="FastFence", version="0.1.0", lifespan=lifespan)
|
|
53
|
+
app.state.engine = runtime.engine
|
|
54
|
+
app.state.identities = runtime.identities
|
|
55
|
+
app.state.settings = settings
|
|
56
|
+
app.state.runtime = runtime
|
|
57
|
+
app.add_middleware(
|
|
58
|
+
ProtectedRestIngress,
|
|
59
|
+
runtime=runtime,
|
|
60
|
+
management_limit=settings.max_config_source_bytes,
|
|
61
|
+
)
|
|
62
|
+
configure_http(app, runtime)
|
|
63
|
+
configure_documents(app, runtime, settings)
|
|
64
|
+
app.include_router(create_router(runtime))
|
|
65
|
+
app.mount("/mcp", mcp_app)
|
|
66
|
+
return app
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Initialize reviewed configuration resources without replacing operator files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from fastfence.modules.control.domain.models import Snapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigurationInitializationError(ValueError):
|
|
15
|
+
"""A configuration cannot safely be initialized."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_existing(path: Path, maximum: int) -> bytes:
|
|
19
|
+
with path.open("rb") as stream:
|
|
20
|
+
content = stream.read(maximum + 1)
|
|
21
|
+
if len(content) > maximum:
|
|
22
|
+
raise ConfigurationInitializationError(
|
|
23
|
+
"Configuration exceeds the configured size limit; existing files were preserved."
|
|
24
|
+
)
|
|
25
|
+
return content
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _create_exclusive(path: Path, content: bytes) -> None:
|
|
29
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
30
|
+
prefix=".initialize-", dir=path.parent
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
34
|
+
stream.write(content)
|
|
35
|
+
stream.flush()
|
|
36
|
+
os.fsync(stream.fileno())
|
|
37
|
+
# Publish only a complete file. A concurrent initializer cannot overwrite it.
|
|
38
|
+
os.link(temporary, path)
|
|
39
|
+
finally:
|
|
40
|
+
Path(temporary).unlink(missing_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def initialize_config(root: Path, *, max_source_bytes: int = 262_144) -> None:
|
|
44
|
+
"""Validate existing configuration and exclusively create missing defaults.
|
|
45
|
+
|
|
46
|
+
This works from an installed wheel; a source checkout is not required.
|
|
47
|
+
Errors deliberately omit the invalid configuration and validation payload.
|
|
48
|
+
"""
|
|
49
|
+
directory = root / "config"
|
|
50
|
+
resources = files("fastfence.shared.defaults")
|
|
51
|
+
contents: dict[str, bytes] = {}
|
|
52
|
+
missing: list[str] = []
|
|
53
|
+
for name in ("policy.yaml", "signatures.json"):
|
|
54
|
+
path = directory / name
|
|
55
|
+
if path.exists():
|
|
56
|
+
contents[name] = _read_existing(path, max_source_bytes)
|
|
57
|
+
else:
|
|
58
|
+
contents[name] = resources.joinpath(name).read_bytes()
|
|
59
|
+
missing.append(name)
|
|
60
|
+
if len(contents[name]) > max_source_bytes:
|
|
61
|
+
raise ConfigurationInitializationError(
|
|
62
|
+
"Configuration exceeds the configured size limit; existing files were preserved."
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
Snapshot.model_validate(
|
|
66
|
+
{
|
|
67
|
+
"policy": yaml.safe_load(contents["policy.yaml"]),
|
|
68
|
+
"feed": json.loads(contents["signatures.json"]),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
except (ValueError, yaml.YAMLError, RecursionError):
|
|
72
|
+
raise ConfigurationInitializationError(
|
|
73
|
+
"Invalid configuration in config/policy.yaml or config/signatures.json; "
|
|
74
|
+
"repair those files and rerun fastfence init. Existing files were preserved."
|
|
75
|
+
) from None
|
|
76
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
for name in missing:
|
|
78
|
+
_create_exclusive(directory / name, contents[name])
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Explicit local credential provisioning; repeat runs never rotate secrets."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from fastfence.modules.control.domain.models import Identity
|
|
13
|
+
from fastfence.shared.settings.app_settings import AppSettings
|
|
14
|
+
from fastfence.workflows.anonymization import build_anonymization
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InitialRecord(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
19
|
+
token_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
20
|
+
identity: Identity
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_links(records: list[InitialRecord], tokens: dict) -> None:
|
|
24
|
+
subjects = [record.identity.subject for record in records]
|
|
25
|
+
if len(set(subjects)) != len(subjects) or set(tokens) != set(subjects):
|
|
26
|
+
raise ValueError
|
|
27
|
+
for record in records:
|
|
28
|
+
token = tokens[record.identity.subject]
|
|
29
|
+
if not isinstance(token, str) or not token:
|
|
30
|
+
raise ValueError
|
|
31
|
+
if hashlib.sha256(token.encode()).hexdigest() != record.token_sha256:
|
|
32
|
+
raise ValueError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_existing(state: Path) -> bool:
|
|
36
|
+
identities, credentials = (
|
|
37
|
+
state / "identities.json",
|
|
38
|
+
state
|
|
39
|
+
/ (
|
|
40
|
+
"credentials.json"
|
|
41
|
+
if (state / "credentials.json").exists()
|
|
42
|
+
else "demo-tokens.json"
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
if (state / "credentials.json").exists() and (
|
|
46
|
+
state / "demo-tokens.json"
|
|
47
|
+
).exists():
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
"Initialization refused: ambiguous credential files. Existing files were preserved."
|
|
50
|
+
)
|
|
51
|
+
if not identities.exists() and not credentials.exists():
|
|
52
|
+
return False
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(identities.read_text())
|
|
55
|
+
tokens = json.loads(credentials.read_text())
|
|
56
|
+
records = [InitialRecord.model_validate(item) for item in raw]
|
|
57
|
+
if not records or not isinstance(tokens, dict):
|
|
58
|
+
raise ValueError
|
|
59
|
+
if len({record.token_sha256 for record in records}) != len(records):
|
|
60
|
+
raise ValueError
|
|
61
|
+
_validate_links(records, tokens)
|
|
62
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
"Initialization refused: partial or invalid credential state. "
|
|
65
|
+
"Existing files were preserved. Restore the matching identities.json "
|
|
66
|
+
"and credentials.json (or legacy demo-tokens.json) backup, or choose a new directory with --state."
|
|
67
|
+
) from None
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_private(path: Path, data: object) -> None:
|
|
72
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
73
|
+
with os.fdopen(descriptor, "w") as file:
|
|
74
|
+
json.dump(data, file, indent=2)
|
|
75
|
+
file.write("\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def initialize(state: Path) -> None:
|
|
79
|
+
if _validate_existing(state):
|
|
80
|
+
print(f"Already initialized: {state}. Existing credentials preserved.")
|
|
81
|
+
return
|
|
82
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
state.chmod(0o700)
|
|
84
|
+
records, tokens = [], {}
|
|
85
|
+
for subject, tenant, roles, admin in [
|
|
86
|
+
("local-agent", "local", ["analyst"], False),
|
|
87
|
+
("local-admin", "management", [], True),
|
|
88
|
+
]:
|
|
89
|
+
token = secrets.token_urlsafe(32)
|
|
90
|
+
tokens[subject] = token
|
|
91
|
+
records.append(
|
|
92
|
+
{
|
|
93
|
+
"token_sha256": hashlib.sha256(token.encode()).hexdigest(),
|
|
94
|
+
"identity": {
|
|
95
|
+
"subject": subject,
|
|
96
|
+
"tenant": tenant,
|
|
97
|
+
"roles": roles,
|
|
98
|
+
"admin": admin,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
_write_private(state / "identities.json", records)
|
|
103
|
+
_write_private(state / "credentials.json", tokens)
|
|
104
|
+
print(
|
|
105
|
+
f"Initialized {state}. Credentials: {state / 'credentials.json'} (private, gitignored)."
|
|
106
|
+
)
|
|
107
|
+
print(
|
|
108
|
+
"Dashboard: connect local-admin to manage policies; use local-agent to make protected requests."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def initialize_keys(path: Path, key_id: str) -> None:
|
|
113
|
+
if path.exists():
|
|
114
|
+
build_anonymization(
|
|
115
|
+
AppSettings(
|
|
116
|
+
anonymization_keys_file=path, anonymization_key_id=key_id
|
|
117
|
+
)
|
|
118
|
+
).close()
|
|
119
|
+
print(f"Anonymization keyring preserved: {path}")
|
|
120
|
+
return
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
_write_private(
|
|
123
|
+
path, {key_id: base64.b64encode(secrets.token_bytes(32)).decode()}
|
|
124
|
+
)
|
|
125
|
+
print(f"Private anonymization keyring created: {path} (mode 0600).")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def initialize_anonymization(settings: AppSettings) -> None:
|
|
129
|
+
if settings.anonymization_keys_json is not None:
|
|
130
|
+
build_anonymization(settings).close()
|
|
131
|
+
print(
|
|
132
|
+
"Existing environment anonymization keyring validated and preserved."
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
initialize_keys(
|
|
136
|
+
settings.anonymization_keys_file
|
|
137
|
+
or settings.state_path / "anonymization-keys.json",
|
|
138
|
+
settings.anonymization_key_id,
|
|
139
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Install the reviewed Laya helpers from a wheel without a source checkout."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastfence.app.interfaces.cli.bootstrap_config import _create_exclusive
|
|
9
|
+
|
|
10
|
+
LAYA_FILES = (
|
|
11
|
+
"setup.sh",
|
|
12
|
+
"semantic_worker.py",
|
|
13
|
+
"semantic_response.py",
|
|
14
|
+
"authoring_contracts.py",
|
|
15
|
+
"policy_generation.py",
|
|
16
|
+
"draft_policy.py",
|
|
17
|
+
"author_rule.py",
|
|
18
|
+
"run_demo.py",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def stage_laya(root: Path) -> Path:
|
|
23
|
+
bundle = files("fastfence.shared").joinpath("laya_bundle")
|
|
24
|
+
development = Path(__file__).resolve().parents[5] / "integrations/laya"
|
|
25
|
+
contents = {
|
|
26
|
+
name: bundle.joinpath(name).read_bytes()
|
|
27
|
+
if bundle.is_dir()
|
|
28
|
+
else (development / name).read_bytes()
|
|
29
|
+
for name in LAYA_FILES
|
|
30
|
+
}
|
|
31
|
+
destination = root / "integrations/laya"
|
|
32
|
+
for name, content in contents.items():
|
|
33
|
+
path = destination / name
|
|
34
|
+
if path.is_symlink() or (
|
|
35
|
+
path.exists() and path.read_bytes() != content
|
|
36
|
+
):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Existing Laya helper files differ from this package; preserve your changes and choose a new FASTFENCE_AUTHORING_ROOT."
|
|
39
|
+
)
|
|
40
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
for name, content in contents.items():
|
|
42
|
+
path = destination / name
|
|
43
|
+
if not path.exists():
|
|
44
|
+
_create_exclusive(path, content)
|
|
45
|
+
return destination / "setup.sh"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def setup_laya(root: Path) -> None:
|
|
49
|
+
if any(shutil.which(name) is None for name in ("git", "uv", "sh")):
|
|
50
|
+
raise SystemExit(
|
|
51
|
+
"Laya setup requires git, sh and uv. Install Git and run `python -m pip install uv`, then retry `fastfence setup-laya`."
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
script = stage_laya(root)
|
|
55
|
+
except ValueError as error:
|
|
56
|
+
raise SystemExit(str(error)) from None
|
|
57
|
+
result = subprocess.run(["sh", str(script)], cwd=root, check=False)
|
|
58
|
+
if result.returncode:
|
|
59
|
+
raise SystemExit(
|
|
60
|
+
"Laya setup failed; resolve the installer error and retry."
|
|
61
|
+
)
|
|
62
|
+
print(
|
|
63
|
+
"Laya installed. Start Ollama and run `ollama pull qwen3:4b` and `ollama pull qwen3:0.6b`."
|
|
64
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from fastfence.app.interfaces.cli.bootstrap_config import initialize_config
|
|
10
|
+
from fastfence.app.interfaces.cli.initialize import (
|
|
11
|
+
initialize,
|
|
12
|
+
initialize_anonymization,
|
|
13
|
+
)
|
|
14
|
+
from fastfence.app.interfaces.cli.laya_install import setup_laya
|
|
15
|
+
from fastfence.app.interfaces.cli.ocr_install import setup_ocr
|
|
16
|
+
from fastfence.app.interfaces.cli.startup import (
|
|
17
|
+
doctor,
|
|
18
|
+
preflight,
|
|
19
|
+
startup_error,
|
|
20
|
+
)
|
|
21
|
+
from fastfence.shared.settings.app_settings import AppSettings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
parser = argparse.ArgumentParser(description="FastFence AI Control Layer")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"command",
|
|
28
|
+
choices=["init", "serve", "doctor", "setup-laya", "setup-ocr"],
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--state", help="Private state directory (overrides .env)"
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--anonymization",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Provision a private anonymization keyring during init",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--full",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="Doctor: require Laya, OCR, Qwen and anonymization readiness",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
44
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
previous_state = os.environ.get("FASTFENCE_STATE")
|
|
47
|
+
if args.state is not None:
|
|
48
|
+
os.environ["FASTFENCE_STATE"] = str(Path(args.state).resolve())
|
|
49
|
+
try:
|
|
50
|
+
settings = AppSettings.environment()
|
|
51
|
+
if args.command == "init":
|
|
52
|
+
initialize_config(
|
|
53
|
+
settings.root, max_source_bytes=settings.max_config_source_bytes
|
|
54
|
+
)
|
|
55
|
+
initialize(settings.state_path)
|
|
56
|
+
if args.anonymization:
|
|
57
|
+
initialize_anonymization(settings)
|
|
58
|
+
elif args.command == "setup-laya":
|
|
59
|
+
setup_laya(settings.authoring_root or settings.root)
|
|
60
|
+
elif args.command == "setup-ocr":
|
|
61
|
+
setup_ocr(settings.state_path)
|
|
62
|
+
elif args.command == "doctor":
|
|
63
|
+
doctor(settings, full=args.full)
|
|
64
|
+
else:
|
|
65
|
+
preflight(settings)
|
|
66
|
+
uvicorn.run(
|
|
67
|
+
"fastfence.app.factory:create_app",
|
|
68
|
+
factory=True,
|
|
69
|
+
host=args.host,
|
|
70
|
+
port=args.port,
|
|
71
|
+
)
|
|
72
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
73
|
+
raise SystemExit(startup_error()) from None
|
|
74
|
+
finally:
|
|
75
|
+
if previous_state is None:
|
|
76
|
+
os.environ.pop("FASTFENCE_STATE", None)
|
|
77
|
+
else:
|
|
78
|
+
os.environ["FASTFENCE_STATE"] = previous_state
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Install hash-locked OCR dependencies for an installed wheel."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from importlib.resources import as_file, files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run(command: list[str], environment: dict[str, str]) -> None:
|
|
11
|
+
if subprocess.run(command, env=environment, check=False).returncode:
|
|
12
|
+
raise SystemExit(
|
|
13
|
+
"OCR setup failed; resolve the installer error and retry."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def setup_ocr(state: Path) -> None:
|
|
18
|
+
if shutil.which("uv") is None:
|
|
19
|
+
raise SystemExit(
|
|
20
|
+
"OCR setup requires uv. Run `python -m pip install uv`, then retry `fastfence setup-ocr`."
|
|
21
|
+
)
|
|
22
|
+
private = state / "private"
|
|
23
|
+
private.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
24
|
+
python = private / "ocr-env/bin/python"
|
|
25
|
+
models = private / "ocr-models"
|
|
26
|
+
environment = dict(os.environ)
|
|
27
|
+
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[4])
|
|
28
|
+
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
29
|
+
if not python.is_file():
|
|
30
|
+
_run(
|
|
31
|
+
["uv", "venv", "--python", "3.12", str(python.parent.parent)],
|
|
32
|
+
environment,
|
|
33
|
+
)
|
|
34
|
+
requirements = files("fastfence.shared.defaults").joinpath(
|
|
35
|
+
"ocr-requirements.txt"
|
|
36
|
+
)
|
|
37
|
+
with as_file(requirements) as source:
|
|
38
|
+
_run(
|
|
39
|
+
[
|
|
40
|
+
"uv",
|
|
41
|
+
"pip",
|
|
42
|
+
"install",
|
|
43
|
+
"--python",
|
|
44
|
+
str(python),
|
|
45
|
+
"--require-hashes",
|
|
46
|
+
"-r",
|
|
47
|
+
str(source),
|
|
48
|
+
],
|
|
49
|
+
environment,
|
|
50
|
+
)
|
|
51
|
+
_run(
|
|
52
|
+
[
|
|
53
|
+
str(python),
|
|
54
|
+
"-m",
|
|
55
|
+
"fastfence.modules.ocr.persistence.bootstrap",
|
|
56
|
+
str(models),
|
|
57
|
+
],
|
|
58
|
+
environment,
|
|
59
|
+
)
|
|
60
|
+
print(
|
|
61
|
+
"OCR dependencies and local mobile models installed. Restart FastFence to detect the configured state directory's OCR environment."
|
|
62
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Read-only readiness checks. Diagnostic output excludes private input values."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from fastfence.modules.control.application.facade import build_runtime
|
|
11
|
+
from fastfence.shared.settings.app_settings import AppSettings
|
|
12
|
+
from fastfence.workflows.anonymization import build_anonymization
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def startup_error() -> str:
|
|
16
|
+
return (
|
|
17
|
+
"FastFence startup/configuration failed. No credentials were changed. "
|
|
18
|
+
"Run `fastfence doctor` to check prerequisites. "
|
|
19
|
+
"Check private identity/key configuration and config/policy.yaml; "
|
|
20
|
+
"validation input is omitted to protect secrets."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def preflight(settings: AppSettings) -> None:
|
|
25
|
+
if settings.identity_config_json is None:
|
|
26
|
+
path = (
|
|
27
|
+
settings.identity_config_file
|
|
28
|
+
or settings.state_path / "identities.json"
|
|
29
|
+
)
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
command = f"fastfence init --state {shlex.quote(str(settings.state_path))} --anonymization"
|
|
32
|
+
hint = (
|
|
33
|
+
"Provision FASTFENCE_IDENTITY_CONFIG_FILE at the configured location."
|
|
34
|
+
if settings.identity_config_file
|
|
35
|
+
else f"Initialize local credentials first: {command}"
|
|
36
|
+
)
|
|
37
|
+
raise SystemExit(
|
|
38
|
+
f"FastFence cannot start: identity configuration is missing. {hint}"
|
|
39
|
+
)
|
|
40
|
+
for name in ("policy.yaml", "signatures.json"):
|
|
41
|
+
if not (settings.root / "config" / name).is_file():
|
|
42
|
+
raise SystemExit(
|
|
43
|
+
f"FastFence cannot start: config/{name} is missing. Run from the repository root or set FASTFENCE_ROOT."
|
|
44
|
+
)
|
|
45
|
+
runtime = build_runtime(
|
|
46
|
+
settings, anonymization=build_anonymization(settings)
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
snapshot = runtime.snapshot()
|
|
50
|
+
if (
|
|
51
|
+
snapshot.policy.anonymization.enabled
|
|
52
|
+
and runtime.engine.anonymization is not None
|
|
53
|
+
):
|
|
54
|
+
if settings.anonymization_keys_json is None:
|
|
55
|
+
key_file = (
|
|
56
|
+
settings.anonymization_keys_file
|
|
57
|
+
or settings.state_path / "anonymization-keys.json"
|
|
58
|
+
)
|
|
59
|
+
if not key_file.is_file():
|
|
60
|
+
raise SystemExit(
|
|
61
|
+
"Anonymization is enabled but no keyring is configured. Run `fastfence init --anonymization` before serving."
|
|
62
|
+
)
|
|
63
|
+
finally:
|
|
64
|
+
runtime.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _python_imports(interpreter: Path, modules: str) -> bool:
|
|
68
|
+
if not interpreter.is_file() or not os.access(interpreter, os.X_OK):
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
[str(interpreter), "-B", "-c", modules],
|
|
73
|
+
stdin=subprocess.DEVNULL,
|
|
74
|
+
stdout=subprocess.DEVNULL,
|
|
75
|
+
stderr=subprocess.DEVNULL,
|
|
76
|
+
timeout=30,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
return result.returncode == 0
|
|
80
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ocr_ready(settings: AppSettings) -> bool:
|
|
85
|
+
if settings.ocr_python is None or settings.ocr_models is None:
|
|
86
|
+
return False
|
|
87
|
+
names = (
|
|
88
|
+
"PP-LCNet_x1_0_doc_ori",
|
|
89
|
+
"PP-OCRv5_mobile_det",
|
|
90
|
+
"latin_PP-OCRv5_mobile_rec",
|
|
91
|
+
)
|
|
92
|
+
if not all(
|
|
93
|
+
(settings.ocr_models / name / "inference.json").is_file()
|
|
94
|
+
for name in names
|
|
95
|
+
):
|
|
96
|
+
return False
|
|
97
|
+
return _python_imports(
|
|
98
|
+
settings.ocr_python, "import paddle, paddleocr, pypdfium2, PIL"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _laya_ready(settings: AppSettings) -> bool:
|
|
103
|
+
root = settings.authoring_root or settings.root
|
|
104
|
+
paths = [
|
|
105
|
+
root / "integrations/laya/draft_policy.py",
|
|
106
|
+
root / "integrations/laya/semantic_worker.py",
|
|
107
|
+
root / "state/laya/upstream/engine",
|
|
108
|
+
]
|
|
109
|
+
return all(path.exists() for path in paths) and _python_imports(
|
|
110
|
+
root / "state/laya/venv/bin/python",
|
|
111
|
+
"import sys; "
|
|
112
|
+
f"sys.path[:0] = {[str(root / 'integrations/laya'), str(root / 'state/laya/upstream/engine')]!r}; "
|
|
113
|
+
"import semantic_worker, litellm; "
|
|
114
|
+
"from laya.llm import client; assert callable(client.llm_call)",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ollama_ready(settings: AppSettings) -> bool:
|
|
119
|
+
try:
|
|
120
|
+
with httpx.Client(timeout=3, trust_env=False) as client:
|
|
121
|
+
response = client.get(settings.ollama_url.rstrip("/") + "/api/tags")
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
names = {item["name"] for item in response.json()["models"]}
|
|
124
|
+
return {"qwen3:0.6b", "qwen3:4b"} <= names
|
|
125
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def doctor(settings: AppSettings, *, full: bool = False) -> None:
|
|
130
|
+
try:
|
|
131
|
+
preflight(settings)
|
|
132
|
+
except SystemExit as error:
|
|
133
|
+
print(f"FAIL core: {error}")
|
|
134
|
+
raise SystemExit(1) from None
|
|
135
|
+
print(
|
|
136
|
+
"OK core: identities, policy, signatures and configured keyring validated."
|
|
137
|
+
)
|
|
138
|
+
if not full:
|
|
139
|
+
print(
|
|
140
|
+
"Run `fastfence doctor --full` to check optional feature prerequisites."
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
checks = [
|
|
144
|
+
(
|
|
145
|
+
"Laya authoring and text-assessment installation",
|
|
146
|
+
_laya_ready(settings),
|
|
147
|
+
"bash integrations/laya/setup.sh",
|
|
148
|
+
),
|
|
149
|
+
(
|
|
150
|
+
"OCR interpreter and model files",
|
|
151
|
+
_ocr_ready(settings),
|
|
152
|
+
"bash scripts/setup-ocr.sh",
|
|
153
|
+
),
|
|
154
|
+
(
|
|
155
|
+
"Qwen model availability",
|
|
156
|
+
_ollama_ready(settings),
|
|
157
|
+
"ollama serve; ollama pull qwen3:0.6b; ollama pull qwen3:4b",
|
|
158
|
+
),
|
|
159
|
+
(
|
|
160
|
+
"anonymization keyring",
|
|
161
|
+
settings.anonymization_keys_json is not None
|
|
162
|
+
or (
|
|
163
|
+
settings.anonymization_keys_file
|
|
164
|
+
or settings.state_path / "anonymization-keys.json"
|
|
165
|
+
).is_file(),
|
|
166
|
+
"fastfence init --anonymization",
|
|
167
|
+
),
|
|
168
|
+
]
|
|
169
|
+
for label, ready, fix in checks:
|
|
170
|
+
print(
|
|
171
|
+
f"{'OK' if ready else 'FAIL'} {label}"
|
|
172
|
+
+ ("" if ready else f": {fix}")
|
|
173
|
+
)
|
|
174
|
+
if not all(ready for _, ready, _ in checks):
|
|
175
|
+
raise SystemExit(1)
|
|
176
|
+
print(
|
|
177
|
+
"Prerequisite checks passed. Run the manual scenarios to verify end-to-end model behavior."
|
|
178
|
+
)
|
|
File without changes
|