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.
Files changed (137) hide show
  1. fastfence/__init__.py +1 -0
  2. fastfence/app/__init__.py +0 -0
  3. fastfence/app/factory.py +66 -0
  4. fastfence/app/interfaces/__init__.py +0 -0
  5. fastfence/app/interfaces/cli/__init__.py +0 -0
  6. fastfence/app/interfaces/cli/bootstrap_config.py +78 -0
  7. fastfence/app/interfaces/cli/initialize.py +139 -0
  8. fastfence/app/interfaces/cli/laya_install.py +64 -0
  9. fastfence/app/interfaces/cli/main.py +82 -0
  10. fastfence/app/interfaces/cli/ocr_install.py +62 -0
  11. fastfence/app/interfaces/cli/startup.py +178 -0
  12. fastfence/app/interfaces/http/__init__.py +0 -0
  13. fastfence/app/interfaces/http/authoring_process.py +133 -0
  14. fastfence/app/interfaces/http/authoring_records.py +127 -0
  15. fastfence/app/interfaces/http/authoring_regression.py +63 -0
  16. fastfence/app/interfaces/http/authoring_session.py +250 -0
  17. fastfence/app/interfaces/http/documents.py +121 -0
  18. fastfence/app/interfaces/http/ingress.py +102 -0
  19. fastfence/app/interfaces/http/openai.py +210 -0
  20. fastfence/app/interfaces/http/policy_authoring.py +61 -0
  21. fastfence/app/interfaces/http/routes.py +194 -0
  22. fastfence/app/interfaces/http/rule_authoring.py +39 -0
  23. fastfence/app/interfaces/http/semantic_preview.py +48 -0
  24. fastfence/app/interfaces/http/web/audit.js +106 -0
  25. fastfence/app/interfaces/http/web/console.css +6 -0
  26. fastfence/app/interfaces/http/web/console.js +181 -0
  27. fastfence/app/interfaces/http/web/documents.js +38 -0
  28. fastfence/app/interfaces/http/web/index.html +130 -0
  29. fastfence/app/interfaces/http/web/logo.svg +1 -0
  30. fastfence/app/interfaces/http/web/playground.js +76 -0
  31. fastfence/app/interfaces/http/web/policy-manager.js +309 -0
  32. fastfence/app/interfaces/http/web/policy-studio.js +251 -0
  33. fastfence/app/interfaces/http/web/rules.js +93 -0
  34. fastfence/modules/__init__.py +0 -0
  35. fastfence/modules/anonymization/__init__.py +1 -0
  36. fastfence/modules/anonymization/application/__init__.py +0 -0
  37. fastfence/modules/anonymization/application/facade.py +104 -0
  38. fastfence/modules/anonymization/application/services/__init__.py +0 -0
  39. fastfence/modules/anonymization/application/services/transform.py +182 -0
  40. fastfence/modules/anonymization/application/use_cases/__init__.py +0 -0
  41. fastfence/modules/anonymization/contracts/__init__.py +0 -0
  42. fastfence/modules/anonymization/contracts/ports.py +24 -0
  43. fastfence/modules/anonymization/domain/__init__.py +0 -0
  44. fastfence/modules/anonymization/domain/matching.py +87 -0
  45. fastfence/modules/anonymization/domain/tokens.py +86 -0
  46. fastfence/modules/anonymization/interfaces/__init__.py +0 -0
  47. fastfence/modules/anonymization/persistence/__init__.py +0 -0
  48. fastfence/modules/anonymization/persistence/asymmetric.py +96 -0
  49. fastfence/modules/anonymization/persistence/crypto.py +238 -0
  50. fastfence/modules/control/__init__.py +0 -0
  51. fastfence/modules/control/application/__init__.py +0 -0
  52. fastfence/modules/control/application/facade.py +201 -0
  53. fastfence/modules/control/application/services/__init__.py +0 -0
  54. fastfence/modules/control/application/services/alias_session.py +91 -0
  55. fastfence/modules/control/application/services/engine.py +192 -0
  56. fastfence/modules/control/application/services/execution.py +152 -0
  57. fastfence/modules/control/application/services/inspection.py +253 -0
  58. fastfence/modules/control/application/use_cases/__init__.py +0 -0
  59. fastfence/modules/control/application/use_cases/content.py +61 -0
  60. fastfence/modules/control/application/use_cases/management.py +102 -0
  61. fastfence/modules/control/application/use_cases/policy_preview.py +140 -0
  62. fastfence/modules/control/application/use_cases/semantic_preview.py +51 -0
  63. fastfence/modules/control/contracts/__init__.py +0 -0
  64. fastfence/modules/control/contracts/dto.py +18 -0
  65. fastfence/modules/control/contracts/ports.py +153 -0
  66. fastfence/modules/control/domain/__init__.py +0 -0
  67. fastfence/modules/control/domain/controls.py +102 -0
  68. fastfence/modules/control/domain/exceptions.py +17 -0
  69. fastfence/modules/control/domain/frozen.py +35 -0
  70. fastfence/modules/control/domain/limits.py +14 -0
  71. fastfence/modules/control/domain/models.py +268 -0
  72. fastfence/modules/control/domain/policy_authoring.py +273 -0
  73. fastfence/modules/control/domain/policy_tests.py +34 -0
  74. fastfence/modules/control/domain/privacy.py +37 -0
  75. fastfence/modules/control/domain/semantic_rules.py +31 -0
  76. fastfence/modules/control/domain/signature_matching.py +204 -0
  77. fastfence/modules/control/domain/text_rules.py +142 -0
  78. fastfence/modules/control/interfaces/__init__.py +0 -0
  79. fastfence/modules/control/interfaces/mcp/__init__.py +0 -0
  80. fastfence/modules/control/interfaces/mcp/server.py +103 -0
  81. fastfence/modules/control/persistence/__init__.py +0 -0
  82. fastfence/modules/control/persistence/config_providers.py +161 -0
  83. fastfence/modules/control/persistence/identity.py +64 -0
  84. fastfence/modules/control/persistence/laya_semantic.py +172 -0
  85. fastfence/modules/control/persistence/ledger.py +264 -0
  86. fastfence/modules/control/persistence/models.py +205 -0
  87. fastfence/modules/control/persistence/openai_models.py +142 -0
  88. fastfence/modules/control/persistence/policy.py +215 -0
  89. fastfence/modules/control/persistence/secrets.py +179 -0
  90. fastfence/modules/control/persistence/semantic_severity.py +66 -0
  91. fastfence/modules/control/persistence/tools.py +21 -0
  92. fastfence/modules/ocr/__init__.py +0 -0
  93. fastfence/modules/ocr/application/__init__.py +0 -0
  94. fastfence/modules/ocr/application/facade.py +26 -0
  95. fastfence/modules/ocr/application/services/__init__.py +0 -0
  96. fastfence/modules/ocr/application/use_cases/__init__.py +0 -0
  97. fastfence/modules/ocr/contracts/__init__.py +0 -0
  98. fastfence/modules/ocr/contracts/ports.py +9 -0
  99. fastfence/modules/ocr/domain/__init__.py +0 -0
  100. fastfence/modules/ocr/domain/markdown.py +23 -0
  101. fastfence/modules/ocr/interfaces/__init__.py +0 -0
  102. fastfence/modules/ocr/interfaces/smoke.py +133 -0
  103. fastfence/modules/ocr/persistence/__init__.py +0 -0
  104. fastfence/modules/ocr/persistence/bootstrap.py +36 -0
  105. fastfence/modules/ocr/persistence/isolation.py +14 -0
  106. fastfence/modules/ocr/persistence/media.py +86 -0
  107. fastfence/modules/ocr/persistence/models.py +46 -0
  108. fastfence/modules/ocr/persistence/provider.py +88 -0
  109. fastfence/modules/ocr/persistence/worker.py +109 -0
  110. fastfence/shared/__init__.py +0 -0
  111. fastfence/shared/anonymization.py +139 -0
  112. fastfence/shared/defaults/__init__.py +1 -0
  113. fastfence/shared/defaults/ocr-requirements.txt +750 -0
  114. fastfence/shared/defaults/policy.yaml +35 -0
  115. fastfence/shared/defaults/signatures.json +32 -0
  116. fastfence/shared/laya_bundle/author_rule.py +312 -0
  117. fastfence/shared/laya_bundle/authoring_contracts.py +129 -0
  118. fastfence/shared/laya_bundle/draft_policy.py +211 -0
  119. fastfence/shared/laya_bundle/policy_generation.py +71 -0
  120. fastfence/shared/laya_bundle/run_demo.py +282 -0
  121. fastfence/shared/laya_bundle/semantic_response.py +67 -0
  122. fastfence/shared/laya_bundle/semantic_worker.py +186 -0
  123. fastfence/shared/laya_bundle/setup.sh +25 -0
  124. fastfence/shared/models.py +7 -0
  125. fastfence/shared/ocr.py +64 -0
  126. fastfence/shared/settings/__init__.py +0 -0
  127. fastfence/shared/settings/app_settings.py +183 -0
  128. fastfence/shared/settings/upstream_url.py +39 -0
  129. fastfence/workflows/__init__.py +0 -0
  130. fastfence/workflows/anonymization.py +195 -0
  131. fastfence/workflows/document_markdown.py +59 -0
  132. fastfence-0.1.1.dist-info/METADATA +167 -0
  133. fastfence-0.1.1.dist-info/RECORD +137 -0
  134. fastfence-0.1.1.dist-info/WHEEL +4 -0
  135. fastfence-0.1.1.dist-info/entry_points.txt +2 -0
  136. fastfence-0.1.1.dist-info/licenses/LICENSE +202 -0
  137. 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
@@ -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