apsimo 1.3.0__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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/api/authority.py
ADDED
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
"""Authenticated API principals and server-derived person authority.
|
|
2
|
+
|
|
3
|
+
The legacy ``COLONY_API_KEY`` remains a migration credential. New scoped
|
|
4
|
+
credentials are loaded from a private JSON keyring and bind a service
|
|
5
|
+
principal to exact API scopes, a viewer/person, and explicit audience lanes.
|
|
6
|
+
Request bodies may narrow to a granted lane; they cannot broaden authority.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import stat
|
|
21
|
+
import threading
|
|
22
|
+
from typing import Any, Mapping
|
|
23
|
+
|
|
24
|
+
from fastapi import HTTPException
|
|
25
|
+
from starlette.requests import Request
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$")
|
|
31
|
+
_CREDENTIAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,191}$")
|
|
32
|
+
_PERSON_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/+\-]{0,127}$")
|
|
33
|
+
_SCOPE_RE = re.compile(r"^(?:\*|[a-z][a-z0-9_.-]*:[a-z][a-z0-9_.-]*)$")
|
|
34
|
+
_PLATFORM_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
35
|
+
_WORKER_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$")
|
|
36
|
+
_RESERVED_PRINCIPALS = frozenset({"legacy", "anonymous-dev", "unauthenticated", "public"})
|
|
37
|
+
_AUDIENCES = frozenset({"viewer", "owner", "shared", "global"})
|
|
38
|
+
_ACCEPTING_STATUSES = frozenset({"active", "retiring"})
|
|
39
|
+
_KNOWN_STATUSES = frozenset({"active", "retiring", "disabled", "revoked"})
|
|
40
|
+
_GOVERNED_ACTION_PRINCIPAL = "host-action-worker"
|
|
41
|
+
_GOVERNED_ACTION_SCOPES = frozenset({"actions:execute", "actions:verify"})
|
|
42
|
+
|
|
43
|
+
# Canonical read-only work surfaces used to assemble an operator's current
|
|
44
|
+
# working set. Keep this exact: collection reads belong to ``work:read``;
|
|
45
|
+
# neighbouring item reads and every mutation retain their existing authority.
|
|
46
|
+
WORK_READ_SURFACE_V1 = frozenset({
|
|
47
|
+
("GET", "/v1/host/goals"),
|
|
48
|
+
("GET", "/v1/host/projects"),
|
|
49
|
+
("GET", "/v1/host/queue/jobs/pending"),
|
|
50
|
+
("GET", "/v1/host/queue/jobs/neutral"),
|
|
51
|
+
})
|
|
52
|
+
_API_ACCESS_COMPATIBILITY = frozenset({"api:access"})
|
|
53
|
+
_NO_COMPATIBILITY_SCOPES: frozenset[str] = frozenset()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KeyringError(ValueError):
|
|
57
|
+
"""The configured keyring is unsafe or malformed."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Credential:
|
|
62
|
+
credential_id: str
|
|
63
|
+
secret: str
|
|
64
|
+
status: str
|
|
65
|
+
accept_until: datetime | None
|
|
66
|
+
|
|
67
|
+
def accepts(self, now: datetime) -> bool:
|
|
68
|
+
return _accepts(self.status, self.accept_until, now)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class WorkerGrant:
|
|
73
|
+
"""Server-owned ceiling for one worker node authenticated by a principal."""
|
|
74
|
+
|
|
75
|
+
node_id: str
|
|
76
|
+
capabilities: frozenset[str]
|
|
77
|
+
capacity: tuple[tuple[str, float], ...]
|
|
78
|
+
max_concurrent: int
|
|
79
|
+
job_types: frozenset[str]
|
|
80
|
+
|
|
81
|
+
def capacity_map(self) -> dict[str, float]:
|
|
82
|
+
return dict(self.capacity)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Principal:
|
|
87
|
+
principal_id: str
|
|
88
|
+
status: str
|
|
89
|
+
accept_until: datetime | None
|
|
90
|
+
scopes: frozenset[str]
|
|
91
|
+
allow_unscoped_api: bool
|
|
92
|
+
viewer_person_id: str | None
|
|
93
|
+
person_ids: frozenset[str]
|
|
94
|
+
audiences: frozenset[str]
|
|
95
|
+
turn_ingress_platforms: frozenset[str]
|
|
96
|
+
attested_contact_platforms: frozenset[str]
|
|
97
|
+
attested_contact_limit: int
|
|
98
|
+
worker_grants: tuple[WorkerGrant, ...]
|
|
99
|
+
credentials: tuple[Credential, ...]
|
|
100
|
+
|
|
101
|
+
def accepts(self, now: datetime) -> bool:
|
|
102
|
+
return _accepts(self.status, self.accept_until, now)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AuthenticatedCredential:
|
|
107
|
+
principal: Principal
|
|
108
|
+
credential: Credential
|
|
109
|
+
|
|
110
|
+
def accepts(self, now: datetime | None = None) -> bool:
|
|
111
|
+
current = now or datetime.now(timezone.utc)
|
|
112
|
+
return self.principal.accepts(current) and self.credential.accepts(current)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class RequestAuthority:
|
|
117
|
+
"""Authority attached to ``request.state`` by the auth middleware."""
|
|
118
|
+
|
|
119
|
+
principal_id: str
|
|
120
|
+
credential_id: str | None
|
|
121
|
+
scopes: frozenset[str]
|
|
122
|
+
viewer_person_id: str | None
|
|
123
|
+
person_ids: frozenset[str]
|
|
124
|
+
audiences: frozenset[str]
|
|
125
|
+
authenticated: bool
|
|
126
|
+
static_person_ids: frozenset[str] = frozenset()
|
|
127
|
+
turn_ingress_platforms: frozenset[str] = frozenset()
|
|
128
|
+
allow_unscoped_api: bool = True
|
|
129
|
+
legacy: bool = False
|
|
130
|
+
anonymous: bool = False
|
|
131
|
+
attested_contact_platforms: frozenset[str] = frozenset()
|
|
132
|
+
attested_contact_limit: int = 0
|
|
133
|
+
worker_grants: tuple[WorkerGrant, ...] = ()
|
|
134
|
+
|
|
135
|
+
def has_scope(self, required: str) -> bool:
|
|
136
|
+
return self.legacy or "*" in self.scopes or required in self.scopes
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _clean_string(value: Any, *, field: str, required: bool = False) -> str | None:
|
|
140
|
+
if value is None:
|
|
141
|
+
if required:
|
|
142
|
+
raise KeyringError(f"{field} is required")
|
|
143
|
+
return None
|
|
144
|
+
if not isinstance(value, str):
|
|
145
|
+
raise KeyringError(f"{field} must be a string")
|
|
146
|
+
cleaned = value.strip()
|
|
147
|
+
if not cleaned:
|
|
148
|
+
if required:
|
|
149
|
+
raise KeyringError(f"{field} must not be empty")
|
|
150
|
+
return None
|
|
151
|
+
return cleaned
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_time(value: Any, *, field: str) -> datetime | None:
|
|
155
|
+
cleaned = _clean_string(value, field=field)
|
|
156
|
+
if cleaned is None:
|
|
157
|
+
return None
|
|
158
|
+
if cleaned.endswith("Z"):
|
|
159
|
+
cleaned = cleaned[:-1] + "+00:00"
|
|
160
|
+
try:
|
|
161
|
+
parsed = datetime.fromisoformat(cleaned)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise KeyringError(f"{field} must be an RFC3339 timestamp") from exc
|
|
164
|
+
if parsed.tzinfo is None:
|
|
165
|
+
raise KeyringError(f"{field} must include a timezone")
|
|
166
|
+
return parsed.astimezone(timezone.utc)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_status(value: Any, *, field: str) -> str:
|
|
170
|
+
status = _clean_string(value if value is not None else "active", field=field, required=True)
|
|
171
|
+
assert status is not None
|
|
172
|
+
status = status.lower()
|
|
173
|
+
if status not in _KNOWN_STATUSES:
|
|
174
|
+
raise KeyringError(
|
|
175
|
+
f"{field} must be one of {', '.join(sorted(_KNOWN_STATUSES))}"
|
|
176
|
+
)
|
|
177
|
+
return status
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _accepts(status: str, accept_until: datetime | None, now: datetime) -> bool:
|
|
181
|
+
if status not in _ACCEPTING_STATUSES:
|
|
182
|
+
return False
|
|
183
|
+
if accept_until is not None and now >= accept_until:
|
|
184
|
+
return False
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _string_set(value: Any, *, field: str) -> frozenset[str]:
|
|
189
|
+
if value is None:
|
|
190
|
+
return frozenset()
|
|
191
|
+
if not isinstance(value, list):
|
|
192
|
+
raise KeyringError(f"{field} must be a JSON array")
|
|
193
|
+
result: set[str] = set()
|
|
194
|
+
for index, item in enumerate(value):
|
|
195
|
+
cleaned = _clean_string(item, field=f"{field}[{index}]", required=True)
|
|
196
|
+
assert cleaned is not None
|
|
197
|
+
result.add(cleaned)
|
|
198
|
+
return frozenset(result)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _person_id(value: Any, *, field: str) -> str:
|
|
202
|
+
cleaned = _clean_string(value, field=field, required=True)
|
|
203
|
+
assert cleaned is not None
|
|
204
|
+
if "*" in cleaned:
|
|
205
|
+
raise KeyringError(f"{field} cannot contain a wildcard")
|
|
206
|
+
if value != cleaned or not _PERSON_ID_RE.fullmatch(cleaned):
|
|
207
|
+
raise KeyringError(
|
|
208
|
+
f"{field} must be a canonical person ID of at most 128 characters"
|
|
209
|
+
)
|
|
210
|
+
return cleaned
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _person_id_set(value: Any, *, field: str) -> frozenset[str]:
|
|
214
|
+
if value is None:
|
|
215
|
+
return frozenset()
|
|
216
|
+
if not isinstance(value, list):
|
|
217
|
+
raise KeyringError(f"{field} must be a JSON array")
|
|
218
|
+
return frozenset(
|
|
219
|
+
_person_id(item, field=f"{field}[{index}]")
|
|
220
|
+
for index, item in enumerate(value)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_worker_grants(
|
|
225
|
+
value: Any,
|
|
226
|
+
*,
|
|
227
|
+
field: str,
|
|
228
|
+
scopes: frozenset[str],
|
|
229
|
+
) -> tuple[WorkerGrant, ...]:
|
|
230
|
+
if value is None:
|
|
231
|
+
return ()
|
|
232
|
+
if not isinstance(value, list):
|
|
233
|
+
raise KeyringError(f"{field} must be a JSON array")
|
|
234
|
+
if len(value) > 64:
|
|
235
|
+
raise KeyringError(f"{field} supports at most 64 exact nodes")
|
|
236
|
+
grants: list[WorkerGrant] = []
|
|
237
|
+
seen_nodes: set[str] = set()
|
|
238
|
+
for index, raw in enumerate(value):
|
|
239
|
+
prefix = f"{field}[{index}]"
|
|
240
|
+
if not isinstance(raw, Mapping):
|
|
241
|
+
raise KeyringError(f"{prefix} must be an object")
|
|
242
|
+
unknown = set(raw) - {
|
|
243
|
+
"node_id", "capabilities", "capacity", "max_concurrent", "job_types",
|
|
244
|
+
}
|
|
245
|
+
if unknown:
|
|
246
|
+
raise KeyringError(f"{prefix} contains unsupported fields")
|
|
247
|
+
node_id = _clean_string(
|
|
248
|
+
raw.get("node_id"), field=f"{prefix}.node_id", required=True,
|
|
249
|
+
)
|
|
250
|
+
assert node_id is not None
|
|
251
|
+
if not _WORKER_TOKEN_RE.fullmatch(node_id) or "*" in node_id:
|
|
252
|
+
raise KeyringError(f"{prefix}.node_id is invalid")
|
|
253
|
+
if node_id in seen_nodes:
|
|
254
|
+
raise KeyringError(f"{field} contains duplicate node_id {node_id!r}")
|
|
255
|
+
seen_nodes.add(node_id)
|
|
256
|
+
|
|
257
|
+
capabilities = _string_set(
|
|
258
|
+
raw.get("capabilities"), field=f"{prefix}.capabilities",
|
|
259
|
+
)
|
|
260
|
+
if any(
|
|
261
|
+
not _WORKER_TOKEN_RE.fullmatch(item) or "*" in item
|
|
262
|
+
for item in capabilities
|
|
263
|
+
):
|
|
264
|
+
raise KeyringError(f"{prefix}.capabilities contains an invalid name")
|
|
265
|
+
job_types = _string_set(
|
|
266
|
+
raw.get("job_types"), field=f"{prefix}.job_types",
|
|
267
|
+
)
|
|
268
|
+
if not job_types:
|
|
269
|
+
raise KeyringError(f"{prefix}.job_types must not be empty")
|
|
270
|
+
if any(
|
|
271
|
+
not _WORKER_TOKEN_RE.fullmatch(item) or "*" in item
|
|
272
|
+
for item in job_types
|
|
273
|
+
):
|
|
274
|
+
raise KeyringError(f"{prefix}.job_types contains an invalid name")
|
|
275
|
+
from apsimo.task_queue.models import JobType
|
|
276
|
+
known_job_types = {item.value for item in JobType}
|
|
277
|
+
unknown_job_types = job_types - known_job_types
|
|
278
|
+
if unknown_job_types:
|
|
279
|
+
raise KeyringError(
|
|
280
|
+
f"{prefix}.job_types contains unsupported values: "
|
|
281
|
+
f"{', '.join(sorted(unknown_job_types))}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
raw_capacity = raw.get("capacity", {})
|
|
285
|
+
if not isinstance(raw_capacity, Mapping):
|
|
286
|
+
raise KeyringError(f"{prefix}.capacity must be an object")
|
|
287
|
+
capacity: list[tuple[str, float]] = []
|
|
288
|
+
for key, raw_amount in raw_capacity.items():
|
|
289
|
+
if (
|
|
290
|
+
not isinstance(key, str)
|
|
291
|
+
or not _WORKER_TOKEN_RE.fullmatch(key)
|
|
292
|
+
or "*" in key
|
|
293
|
+
):
|
|
294
|
+
raise KeyringError(f"{prefix}.capacity contains an invalid name")
|
|
295
|
+
if isinstance(raw_amount, bool) or not isinstance(raw_amount, (int, float)):
|
|
296
|
+
raise KeyringError(f"{prefix}.capacity.{key} must be numeric")
|
|
297
|
+
amount = float(raw_amount)
|
|
298
|
+
if not math.isfinite(amount) or amount < 0:
|
|
299
|
+
raise KeyringError(
|
|
300
|
+
f"{prefix}.capacity.{key} must be finite and non-negative"
|
|
301
|
+
)
|
|
302
|
+
capacity.append((key, amount))
|
|
303
|
+
|
|
304
|
+
max_concurrent = raw.get("max_concurrent", 1)
|
|
305
|
+
if (
|
|
306
|
+
isinstance(max_concurrent, bool)
|
|
307
|
+
or not isinstance(max_concurrent, int)
|
|
308
|
+
or not 1 <= max_concurrent <= 1024
|
|
309
|
+
):
|
|
310
|
+
raise KeyringError(f"{prefix}.max_concurrent must be 1..1024")
|
|
311
|
+
grants.append(WorkerGrant(
|
|
312
|
+
node_id=node_id,
|
|
313
|
+
capabilities=capabilities,
|
|
314
|
+
capacity=tuple(sorted(capacity)),
|
|
315
|
+
max_concurrent=max_concurrent,
|
|
316
|
+
job_types=job_types,
|
|
317
|
+
))
|
|
318
|
+
|
|
319
|
+
worker_scopes = {
|
|
320
|
+
"workers:register", "workers:claim", "workers:lifecycle",
|
|
321
|
+
}
|
|
322
|
+
if grants and "*" not in scopes and not (worker_scopes & set(scopes)):
|
|
323
|
+
raise KeyringError(f"{field} requires at least one workers:* scope")
|
|
324
|
+
if (
|
|
325
|
+
grants
|
|
326
|
+
and "*" not in scopes
|
|
327
|
+
and "workers:claim" in scopes
|
|
328
|
+
and "workers:lifecycle" not in scopes
|
|
329
|
+
):
|
|
330
|
+
raise KeyringError(
|
|
331
|
+
f"{field} with workers:claim also requires workers:lifecycle"
|
|
332
|
+
)
|
|
333
|
+
return tuple(grants)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_principal(raw: Any, *, index: int) -> Principal:
|
|
337
|
+
if not isinstance(raw, Mapping):
|
|
338
|
+
raise KeyringError(f"principals[{index}] must be an object")
|
|
339
|
+
prefix = f"principals[{index}]"
|
|
340
|
+
raw_principal_id = raw.get("principal")
|
|
341
|
+
principal_id = _clean_string(
|
|
342
|
+
raw_principal_id, field=f"{prefix}.principal", required=True
|
|
343
|
+
)
|
|
344
|
+
assert principal_id is not None
|
|
345
|
+
if raw_principal_id != principal_id or not _PRINCIPAL_RE.fullmatch(principal_id):
|
|
346
|
+
raise KeyringError(
|
|
347
|
+
f"{prefix}.principal must be a canonical principal ID of at most "
|
|
348
|
+
"128 characters"
|
|
349
|
+
)
|
|
350
|
+
if principal_id in _RESERVED_PRINCIPALS:
|
|
351
|
+
raise KeyringError(f"{prefix}.principal uses a reserved telemetry identity")
|
|
352
|
+
|
|
353
|
+
scopes = _string_set(raw.get("scopes"), field=f"{prefix}.scopes")
|
|
354
|
+
for scope in scopes:
|
|
355
|
+
if not _SCOPE_RE.fullmatch(scope):
|
|
356
|
+
raise KeyringError(f"{prefix}.scopes contains invalid scope {scope!r}")
|
|
357
|
+
allow_unscoped_api = raw.get("allow_unscoped_api", True)
|
|
358
|
+
if not isinstance(allow_unscoped_api, bool):
|
|
359
|
+
raise KeyringError(f"{prefix}.allow_unscoped_api must be a boolean")
|
|
360
|
+
|
|
361
|
+
raw_viewer = raw.get("viewer_person_id")
|
|
362
|
+
viewer = (
|
|
363
|
+
_person_id(raw_viewer, field=f"{prefix}.viewer_person_id")
|
|
364
|
+
if raw_viewer is not None
|
|
365
|
+
else None
|
|
366
|
+
)
|
|
367
|
+
person_ids = _person_id_set(
|
|
368
|
+
raw.get("person_ids"), field=f"{prefix}.person_ids",
|
|
369
|
+
)
|
|
370
|
+
audiences = _string_set(raw.get("audiences"), field=f"{prefix}.audiences")
|
|
371
|
+
unknown_audiences = audiences - _AUDIENCES
|
|
372
|
+
if unknown_audiences:
|
|
373
|
+
raise KeyringError(
|
|
374
|
+
f"{prefix}.audiences contains unknown lanes: "
|
|
375
|
+
f"{', '.join(sorted(unknown_audiences))}"
|
|
376
|
+
)
|
|
377
|
+
if "viewer" in audiences and not viewer:
|
|
378
|
+
raise KeyringError(f"{prefix}.audiences grants viewer without viewer_person_id")
|
|
379
|
+
if principal_id == _GOVERNED_ACTION_PRINCIPAL and (
|
|
380
|
+
scopes != _GOVERNED_ACTION_SCOPES
|
|
381
|
+
or allow_unscoped_api is not False
|
|
382
|
+
or not viewer
|
|
383
|
+
or viewer not in person_ids
|
|
384
|
+
or audiences != frozenset({"owner"})
|
|
385
|
+
):
|
|
386
|
+
raise KeyringError(
|
|
387
|
+
f"{prefix} must be the exact owner-bound governed-action role"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
worker_grants = _parse_worker_grants(
|
|
391
|
+
raw.get("worker_grants"), field=f"{prefix}.worker_grants", scopes=scopes,
|
|
392
|
+
)
|
|
393
|
+
if "workers:attest" in scopes:
|
|
394
|
+
execution_scopes = scopes & {
|
|
395
|
+
"workers:register", "workers:claim", "workers:lifecycle", "*",
|
|
396
|
+
}
|
|
397
|
+
if worker_grants or execution_scopes:
|
|
398
|
+
raise KeyringError(
|
|
399
|
+
f"{prefix}.workers:attest requires a verifier-only principal "
|
|
400
|
+
"with no worker grants or execution scopes"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
turn_ingress_platforms_explicit = "turn_ingress_platforms" in raw
|
|
404
|
+
turn_ingress_platforms = _string_set(
|
|
405
|
+
raw.get("turn_ingress_platforms"),
|
|
406
|
+
field=f"{prefix}.turn_ingress_platforms",
|
|
407
|
+
)
|
|
408
|
+
for platform in turn_ingress_platforms:
|
|
409
|
+
if not _PLATFORM_RE.fullmatch(platform) or "*" in platform:
|
|
410
|
+
raise KeyringError(
|
|
411
|
+
f"{prefix}.turn_ingress_platforms contains invalid platform"
|
|
412
|
+
)
|
|
413
|
+
if (
|
|
414
|
+
turn_ingress_platforms
|
|
415
|
+
and "turns:write" not in scopes
|
|
416
|
+
and "*" not in scopes
|
|
417
|
+
):
|
|
418
|
+
raise KeyringError(
|
|
419
|
+
f"{prefix}.turn_ingress_platforms requires turns:write"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
attested_policy = raw.get("attested_contact_grants")
|
|
423
|
+
attested_platforms: frozenset[str] = frozenset()
|
|
424
|
+
attested_limit = 0
|
|
425
|
+
if attested_policy is not None:
|
|
426
|
+
if not isinstance(attested_policy, Mapping):
|
|
427
|
+
raise KeyringError(f"{prefix}.attested_contact_grants must be an object")
|
|
428
|
+
unknown = set(attested_policy) - {"platforms", "max_person_ids"}
|
|
429
|
+
if unknown:
|
|
430
|
+
raise KeyringError(
|
|
431
|
+
f"{prefix}.attested_contact_grants contains unsupported fields"
|
|
432
|
+
)
|
|
433
|
+
attested_platforms = _string_set(
|
|
434
|
+
attested_policy.get("platforms"),
|
|
435
|
+
field=f"{prefix}.attested_contact_grants.platforms",
|
|
436
|
+
)
|
|
437
|
+
if not attested_platforms:
|
|
438
|
+
raise KeyringError(
|
|
439
|
+
f"{prefix}.attested_contact_grants.platforms must not be empty"
|
|
440
|
+
)
|
|
441
|
+
for platform in attested_platforms:
|
|
442
|
+
if not _PLATFORM_RE.fullmatch(platform) or "*" in platform:
|
|
443
|
+
raise KeyringError(
|
|
444
|
+
f"{prefix}.attested_contact_grants contains invalid platform"
|
|
445
|
+
)
|
|
446
|
+
raw_limit = attested_policy.get("max_person_ids", 512)
|
|
447
|
+
if (
|
|
448
|
+
isinstance(raw_limit, bool)
|
|
449
|
+
or not isinstance(raw_limit, int)
|
|
450
|
+
or not 1 <= raw_limit <= 4096
|
|
451
|
+
):
|
|
452
|
+
raise KeyringError(
|
|
453
|
+
f"{prefix}.attested_contact_grants.max_person_ids must be 1..4096"
|
|
454
|
+
)
|
|
455
|
+
attested_limit = raw_limit
|
|
456
|
+
if "turns:resolve-sender" not in scopes and "*" not in scopes:
|
|
457
|
+
raise KeyringError(
|
|
458
|
+
f"{prefix}.attested_contact_grants requires turns:resolve-sender"
|
|
459
|
+
)
|
|
460
|
+
# Compatibility is deliberately server-owned and default-off. An
|
|
461
|
+
# existing turns writer that omitted the new role derives it only
|
|
462
|
+
# from the already validated static contact-attestation policy. An
|
|
463
|
+
# explicit role (including an explicit empty role) is authoritative.
|
|
464
|
+
if (
|
|
465
|
+
not turn_ingress_platforms_explicit
|
|
466
|
+
and ("turns:write" in scopes or "*" in scopes)
|
|
467
|
+
):
|
|
468
|
+
turn_ingress_platforms = attested_platforms
|
|
469
|
+
if (
|
|
470
|
+
turn_ingress_platforms_explicit
|
|
471
|
+
and not attested_platforms.issubset(turn_ingress_platforms)
|
|
472
|
+
):
|
|
473
|
+
raise KeyringError(
|
|
474
|
+
f"{prefix}.attested_contact_grants.platforms must be a subset "
|
|
475
|
+
"of turn_ingress_platforms"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
raw_credentials = raw.get("credentials")
|
|
479
|
+
if not isinstance(raw_credentials, list) or not raw_credentials:
|
|
480
|
+
raise KeyringError(f"{prefix}.credentials must be a non-empty JSON array")
|
|
481
|
+
credentials: list[Credential] = []
|
|
482
|
+
credential_ids: set[str] = set()
|
|
483
|
+
for cred_index, raw_credential in enumerate(raw_credentials):
|
|
484
|
+
cp = f"{prefix}.credentials[{cred_index}]"
|
|
485
|
+
if not isinstance(raw_credential, Mapping):
|
|
486
|
+
raise KeyringError(f"{cp} must be an object")
|
|
487
|
+
raw_credential_id = raw_credential.get("id")
|
|
488
|
+
credential_id = _clean_string(
|
|
489
|
+
raw_credential_id, field=f"{cp}.id", required=True
|
|
490
|
+
)
|
|
491
|
+
secret = _clean_string(
|
|
492
|
+
raw_credential.get("secret"), field=f"{cp}.secret", required=True
|
|
493
|
+
)
|
|
494
|
+
assert credential_id is not None and secret is not None
|
|
495
|
+
if (
|
|
496
|
+
raw_credential_id != credential_id
|
|
497
|
+
or not _CREDENTIAL_ID_RE.fullmatch(credential_id)
|
|
498
|
+
):
|
|
499
|
+
raise KeyringError(
|
|
500
|
+
f"{cp}.id must be a canonical credential ID of at most 192 characters"
|
|
501
|
+
)
|
|
502
|
+
if credential_id in credential_ids:
|
|
503
|
+
raise KeyringError(f"duplicate credential id {credential_id!r} in {principal_id}")
|
|
504
|
+
credential_ids.add(credential_id)
|
|
505
|
+
credential_status = _parse_status(
|
|
506
|
+
raw_credential.get("status"), field=f"{cp}.status"
|
|
507
|
+
)
|
|
508
|
+
credential_accept_until = _parse_time(
|
|
509
|
+
raw_credential.get("accept_until"), field=f"{cp}.accept_until"
|
|
510
|
+
)
|
|
511
|
+
if credential_status == "retiring" and credential_accept_until is None:
|
|
512
|
+
raise KeyringError(f"{cp}.retiring credential requires accept_until")
|
|
513
|
+
credentials.append(Credential(
|
|
514
|
+
credential_id=credential_id,
|
|
515
|
+
secret=secret,
|
|
516
|
+
status=credential_status,
|
|
517
|
+
accept_until=credential_accept_until,
|
|
518
|
+
))
|
|
519
|
+
|
|
520
|
+
principal_status = _parse_status(raw.get("status"), field=f"{prefix}.status")
|
|
521
|
+
principal_accept_until = _parse_time(
|
|
522
|
+
raw.get("accept_until"), field=f"{prefix}.accept_until"
|
|
523
|
+
)
|
|
524
|
+
if principal_status == "retiring" and principal_accept_until is None:
|
|
525
|
+
raise KeyringError(f"{prefix}.retiring principal requires accept_until")
|
|
526
|
+
return Principal(
|
|
527
|
+
principal_id=principal_id,
|
|
528
|
+
status=principal_status,
|
|
529
|
+
accept_until=principal_accept_until,
|
|
530
|
+
scopes=scopes,
|
|
531
|
+
allow_unscoped_api=allow_unscoped_api,
|
|
532
|
+
viewer_person_id=viewer,
|
|
533
|
+
person_ids=person_ids,
|
|
534
|
+
audiences=audiences,
|
|
535
|
+
turn_ingress_platforms=turn_ingress_platforms,
|
|
536
|
+
attested_contact_platforms=attested_platforms,
|
|
537
|
+
attested_contact_limit=attested_limit,
|
|
538
|
+
worker_grants=worker_grants,
|
|
539
|
+
credentials=tuple(credentials),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def load_keyring(path: Path) -> tuple[Principal, ...]:
|
|
544
|
+
"""Load one private keyring, rejecting group/world-readable files."""
|
|
545
|
+
|
|
546
|
+
info = path.stat()
|
|
547
|
+
if not stat.S_ISREG(info.st_mode):
|
|
548
|
+
raise KeyringError("keyring must be a regular file")
|
|
549
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
550
|
+
if mode & 0o077:
|
|
551
|
+
raise KeyringError(
|
|
552
|
+
f"keyring permissions must be private (chmod 600); found {mode:04o}"
|
|
553
|
+
)
|
|
554
|
+
try:
|
|
555
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
556
|
+
except json.JSONDecodeError as exc:
|
|
557
|
+
raise KeyringError("keyring is not valid JSON") from exc
|
|
558
|
+
if not isinstance(raw, Mapping) or raw.get("version") != 1:
|
|
559
|
+
raise KeyringError("keyring version must be 1")
|
|
560
|
+
raw_principals = raw.get("principals")
|
|
561
|
+
if not isinstance(raw_principals, list):
|
|
562
|
+
raise KeyringError("keyring principals must be a JSON array")
|
|
563
|
+
|
|
564
|
+
principals = tuple(
|
|
565
|
+
_parse_principal(value, index=index)
|
|
566
|
+
for index, value in enumerate(raw_principals)
|
|
567
|
+
)
|
|
568
|
+
principal_ids: set[str] = set()
|
|
569
|
+
secrets: set[str] = set()
|
|
570
|
+
worker_nodes: set[str] = set()
|
|
571
|
+
for principal in principals:
|
|
572
|
+
if principal.principal_id in principal_ids:
|
|
573
|
+
raise KeyringError(f"duplicate principal {principal.principal_id!r}")
|
|
574
|
+
principal_ids.add(principal.principal_id)
|
|
575
|
+
for grant in principal.worker_grants:
|
|
576
|
+
if grant.node_id in worker_nodes:
|
|
577
|
+
raise KeyringError(
|
|
578
|
+
f"worker node {grant.node_id!r} is granted to multiple principals"
|
|
579
|
+
)
|
|
580
|
+
worker_nodes.add(grant.node_id)
|
|
581
|
+
for credential in principal.credentials:
|
|
582
|
+
if credential.secret in secrets:
|
|
583
|
+
raise KeyringError("credential secrets must be unique across the keyring")
|
|
584
|
+
secrets.add(credential.secret)
|
|
585
|
+
return principals
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class KeyringLoader:
|
|
589
|
+
"""Reload a keyring when its identity, contents, timestamps, or mode change.
|
|
590
|
+
|
|
591
|
+
An invalid replacement disables scoped credentials until a valid private
|
|
592
|
+
file is installed. The separately configured legacy key remains usable,
|
|
593
|
+
which makes migration failures recoverable without accepting stale revoked
|
|
594
|
+
scoped keys.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def __init__(self, path: str | os.PathLike[str] | None) -> None:
|
|
598
|
+
self.path = Path(path).expanduser() if path else None
|
|
599
|
+
self._signature: tuple[int, int, int, int, int] | tuple[str] | None = None
|
|
600
|
+
self._principals: tuple[Principal, ...] = ()
|
|
601
|
+
self._error: str | None = None
|
|
602
|
+
self._lock = threading.RLock()
|
|
603
|
+
|
|
604
|
+
@property
|
|
605
|
+
def configured(self) -> bool:
|
|
606
|
+
return self.path is not None
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def error(self) -> str | None:
|
|
610
|
+
self._reload_if_needed()
|
|
611
|
+
return self._error
|
|
612
|
+
|
|
613
|
+
def _current_signature(self) -> tuple[int, int, int, int, int] | tuple[str]:
|
|
614
|
+
assert self.path is not None
|
|
615
|
+
try:
|
|
616
|
+
info = self.path.stat()
|
|
617
|
+
except OSError:
|
|
618
|
+
return ("missing",)
|
|
619
|
+
return (
|
|
620
|
+
info.st_ino,
|
|
621
|
+
info.st_size,
|
|
622
|
+
info.st_mtime_ns,
|
|
623
|
+
info.st_ctime_ns,
|
|
624
|
+
stat.S_IMODE(info.st_mode),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def _reload_if_needed(self) -> None:
|
|
628
|
+
if self.path is None:
|
|
629
|
+
return
|
|
630
|
+
signature = self._current_signature()
|
|
631
|
+
with self._lock:
|
|
632
|
+
if signature == self._signature:
|
|
633
|
+
return
|
|
634
|
+
self._signature = signature
|
|
635
|
+
try:
|
|
636
|
+
self._principals = load_keyring(self.path)
|
|
637
|
+
self._error = None
|
|
638
|
+
logger.info(
|
|
639
|
+
"Loaded %d scoped API principals from %s",
|
|
640
|
+
len(self._principals), self.path,
|
|
641
|
+
)
|
|
642
|
+
except (OSError, KeyringError) as exc:
|
|
643
|
+
self._principals = ()
|
|
644
|
+
self._error = str(exc)
|
|
645
|
+
logger.error("Scoped API keyring unavailable: %s", exc)
|
|
646
|
+
|
|
647
|
+
def authenticate(self, token: str) -> AuthenticatedCredential | None:
|
|
648
|
+
self._reload_if_needed()
|
|
649
|
+
match: AuthenticatedCredential | None = None
|
|
650
|
+
# Always inspect every configured secret rather than returning on the
|
|
651
|
+
# first comparison. The keyring is deliberately small and this avoids
|
|
652
|
+
# making credential order an obvious timing oracle.
|
|
653
|
+
for principal in self._principals:
|
|
654
|
+
for credential in principal.credentials:
|
|
655
|
+
if hmac.compare_digest(
|
|
656
|
+
token.encode("utf-8"), credential.secret.encode("utf-8")
|
|
657
|
+
):
|
|
658
|
+
match = AuthenticatedCredential(principal, credential)
|
|
659
|
+
return match
|
|
660
|
+
|
|
661
|
+
def status(self) -> dict[str, Any]:
|
|
662
|
+
"""Return loader health and counts without principals or key material."""
|
|
663
|
+
|
|
664
|
+
self._reload_if_needed()
|
|
665
|
+
with self._lock:
|
|
666
|
+
return {
|
|
667
|
+
"configured": self.configured,
|
|
668
|
+
"available": self.configured and self._error is None,
|
|
669
|
+
"error": self._error,
|
|
670
|
+
"principal_count": len(self._principals),
|
|
671
|
+
"credential_count": sum(
|
|
672
|
+
len(principal.credentials) for principal in self._principals
|
|
673
|
+
),
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _audience_person_ids() -> dict[str, str]:
|
|
678
|
+
owner = (
|
|
679
|
+
os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
|
|
680
|
+
or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
|
|
681
|
+
or "owner"
|
|
682
|
+
)
|
|
683
|
+
return {
|
|
684
|
+
"owner": owner,
|
|
685
|
+
"shared": os.environ.get("COLONY_SHARED_PERSON_ID", "shared").strip() or "shared",
|
|
686
|
+
"global": os.environ.get("COLONY_GLOBAL_PERSON_ID", "global").strip() or "global",
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def scoped_authority(
|
|
691
|
+
match: AuthenticatedCredential,
|
|
692
|
+
granted_person_ids: frozenset[str] = frozenset(),
|
|
693
|
+
) -> RequestAuthority:
|
|
694
|
+
principal = match.principal
|
|
695
|
+
mapped = _audience_person_ids()
|
|
696
|
+
static_person_ids = set(principal.person_ids)
|
|
697
|
+
if principal.viewer_person_id:
|
|
698
|
+
static_person_ids.add(principal.viewer_person_id)
|
|
699
|
+
for audience in principal.audiences:
|
|
700
|
+
if audience in mapped:
|
|
701
|
+
static_person_ids.add(mapped[audience])
|
|
702
|
+
person_ids = set(static_person_ids)
|
|
703
|
+
if principal.attested_contact_platforms:
|
|
704
|
+
person_ids.update(granted_person_ids)
|
|
705
|
+
return RequestAuthority(
|
|
706
|
+
principal_id=principal.principal_id,
|
|
707
|
+
credential_id=match.credential.credential_id,
|
|
708
|
+
scopes=principal.scopes,
|
|
709
|
+
allow_unscoped_api=principal.allow_unscoped_api,
|
|
710
|
+
viewer_person_id=principal.viewer_person_id,
|
|
711
|
+
person_ids=frozenset(person_ids),
|
|
712
|
+
audiences=principal.audiences,
|
|
713
|
+
authenticated=True,
|
|
714
|
+
static_person_ids=frozenset(static_person_ids),
|
|
715
|
+
turn_ingress_platforms=principal.turn_ingress_platforms,
|
|
716
|
+
attested_contact_platforms=principal.attested_contact_platforms,
|
|
717
|
+
attested_contact_limit=principal.attested_contact_limit,
|
|
718
|
+
worker_grants=principal.worker_grants,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def legacy_authority() -> RequestAuthority:
|
|
723
|
+
return RequestAuthority(
|
|
724
|
+
principal_id="legacy",
|
|
725
|
+
credential_id="COLONY_API_KEY",
|
|
726
|
+
scopes=frozenset({"*"}),
|
|
727
|
+
viewer_person_id=None,
|
|
728
|
+
person_ids=frozenset(),
|
|
729
|
+
audiences=_AUDIENCES,
|
|
730
|
+
authenticated=True,
|
|
731
|
+
legacy=True,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def anonymous_authority() -> RequestAuthority:
|
|
736
|
+
return RequestAuthority(
|
|
737
|
+
principal_id="anonymous-dev",
|
|
738
|
+
credential_id=None,
|
|
739
|
+
scopes=frozenset(),
|
|
740
|
+
viewer_person_id=None,
|
|
741
|
+
person_ids=frozenset(),
|
|
742
|
+
audiences=frozenset(),
|
|
743
|
+
authenticated=False,
|
|
744
|
+
anonymous=True,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def record_attested_contact_grant(
|
|
749
|
+
request: Request | None,
|
|
750
|
+
*,
|
|
751
|
+
platform: str,
|
|
752
|
+
person_id: str,
|
|
753
|
+
) -> bool:
|
|
754
|
+
"""Project one ParticipantResolver result into exact principal authority.
|
|
755
|
+
|
|
756
|
+
This function is called only after the server-side resolver has returned a
|
|
757
|
+
concrete contact. It never reads a body-asserted contact ID and never adds
|
|
758
|
+
an audience lane or scope.
|
|
759
|
+
"""
|
|
760
|
+
|
|
761
|
+
if request is None:
|
|
762
|
+
return False
|
|
763
|
+
authority = request_authority(request)
|
|
764
|
+
platform = (platform or "").strip().lower()
|
|
765
|
+
person_id = (person_id or "").strip()
|
|
766
|
+
if (
|
|
767
|
+
authority.legacy
|
|
768
|
+
or authority.anonymous
|
|
769
|
+
or not authority.authenticated
|
|
770
|
+
or not authority.has_scope("turns:resolve-sender")
|
|
771
|
+
or platform not in authority.attested_contact_platforms
|
|
772
|
+
or authority.attested_contact_limit <= 0
|
|
773
|
+
or not person_id
|
|
774
|
+
):
|
|
775
|
+
return False
|
|
776
|
+
registry = getattr(request.state, "colony_contact_grants", None)
|
|
777
|
+
if registry is None:
|
|
778
|
+
return False
|
|
779
|
+
try:
|
|
780
|
+
return bool(registry.grant(
|
|
781
|
+
authority.principal_id,
|
|
782
|
+
person_id,
|
|
783
|
+
max_person_ids=authority.attested_contact_limit,
|
|
784
|
+
))
|
|
785
|
+
except Exception:
|
|
786
|
+
logger.warning(
|
|
787
|
+
"Failed to persist server-attested contact grant for principal %s",
|
|
788
|
+
authority.principal_id,
|
|
789
|
+
exc_info=True,
|
|
790
|
+
)
|
|
791
|
+
return False
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _authority_error(code: str, message: str) -> HTTPException:
|
|
795
|
+
return HTTPException(status_code=403, detail={"code": code, "message": message})
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def request_authority(request: Request | None) -> RequestAuthority:
|
|
799
|
+
if request is None:
|
|
800
|
+
# Direct in-process router calls predate HTTP authority middleware and
|
|
801
|
+
# are trusted like other internal method calls. Actual HTTP dev-mode
|
|
802
|
+
# requests always carry the explicit anonymous authority below.
|
|
803
|
+
return legacy_authority()
|
|
804
|
+
value = getattr(request.state, "colony_authority", None)
|
|
805
|
+
if isinstance(value, RequestAuthority):
|
|
806
|
+
return value
|
|
807
|
+
# Routers are mounted without middleware in a number of focused unit tests.
|
|
808
|
+
# Treat that path exactly like loopback dev mode, never like owner authority.
|
|
809
|
+
return anonymous_authority()
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def resolve_request_person(
|
|
813
|
+
request: Request | None,
|
|
814
|
+
*,
|
|
815
|
+
claimed_person_id: str | None = None,
|
|
816
|
+
context_person_id: str | None = None,
|
|
817
|
+
audience: str | None = None,
|
|
818
|
+
) -> str | None:
|
|
819
|
+
"""Resolve one exact person/audience lane from authenticated authority."""
|
|
820
|
+
|
|
821
|
+
authority = request_authority(request)
|
|
822
|
+
claimed = (claimed_person_id or "").strip() or None
|
|
823
|
+
context = (context_person_id or "").strip() or None
|
|
824
|
+
if claimed and context and claimed != context:
|
|
825
|
+
raise _authority_error(
|
|
826
|
+
"person_scope_conflict",
|
|
827
|
+
"person_id and context.contact_id must identify the same person",
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
audience = (audience or "").strip().lower() or None
|
|
831
|
+
if audience and audience not in _AUDIENCES:
|
|
832
|
+
raise _authority_error("invalid_audience", "unknown audience lane")
|
|
833
|
+
|
|
834
|
+
mapped = _audience_person_ids()
|
|
835
|
+
lane_person: str | None = None
|
|
836
|
+
if audience:
|
|
837
|
+
if authority.anonymous:
|
|
838
|
+
raise _authority_error(
|
|
839
|
+
"audience_not_granted",
|
|
840
|
+
"anonymous dev mode cannot select authenticated audience lanes",
|
|
841
|
+
)
|
|
842
|
+
if not authority.legacy and audience not in authority.audiences:
|
|
843
|
+
raise _authority_error(
|
|
844
|
+
"audience_not_granted",
|
|
845
|
+
f"principal {authority.principal_id!r} is not granted audience {audience!r}",
|
|
846
|
+
)
|
|
847
|
+
lane_person = (
|
|
848
|
+
authority.viewer_person_id if audience == "viewer" else mapped[audience]
|
|
849
|
+
)
|
|
850
|
+
if not lane_person:
|
|
851
|
+
raise _authority_error(
|
|
852
|
+
"audience_unbound", "the selected audience has no person binding"
|
|
853
|
+
)
|
|
854
|
+
body_person = claimed or context
|
|
855
|
+
if body_person and body_person != lane_person:
|
|
856
|
+
raise _authority_error(
|
|
857
|
+
"person_scope_conflict",
|
|
858
|
+
"body person does not match the selected audience lane",
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
target = lane_person or claimed or context
|
|
862
|
+
if authority.legacy:
|
|
863
|
+
# Compatibility carve-out: the old global bearer keeps its historical
|
|
864
|
+
# body-selected/no-filter behavior until all consumers are migrated.
|
|
865
|
+
return target
|
|
866
|
+
|
|
867
|
+
if authority.anonymous:
|
|
868
|
+
target = target or (
|
|
869
|
+
os.environ.get("COLONY_DEV_PERSON_ID", "dev-anonymous").strip()
|
|
870
|
+
or "dev-anonymous"
|
|
871
|
+
)
|
|
872
|
+
reserved = set(mapped.values())
|
|
873
|
+
if target in reserved:
|
|
874
|
+
raise _authority_error(
|
|
875
|
+
"reserved_authority_required",
|
|
876
|
+
"owner, shared, and global memory require an authenticated principal",
|
|
877
|
+
)
|
|
878
|
+
return target
|
|
879
|
+
|
|
880
|
+
target = target or authority.viewer_person_id
|
|
881
|
+
if not target:
|
|
882
|
+
raise _authority_error(
|
|
883
|
+
"person_binding_required",
|
|
884
|
+
"this scoped principal has no default viewer/person binding",
|
|
885
|
+
)
|
|
886
|
+
if target not in authority.person_ids:
|
|
887
|
+
raise _authority_error(
|
|
888
|
+
"person_scope_not_granted",
|
|
889
|
+
f"principal {authority.principal_id!r} is not granted person {target!r}",
|
|
890
|
+
)
|
|
891
|
+
return target
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def resolve_turn_person(
|
|
895
|
+
request: Request | None,
|
|
896
|
+
*,
|
|
897
|
+
context_person_id: str | None,
|
|
898
|
+
has_sender: bool,
|
|
899
|
+
) -> str | None:
|
|
900
|
+
"""Authorize a turn's initial contact before idempotency reservation.
|
|
901
|
+
|
|
902
|
+
Most principals must submit their exact bound viewer/person. A transport
|
|
903
|
+
adapter may additionally receive ``turns:resolve-sender``; for a structured
|
|
904
|
+
sender envelope Colony then discards the body contact claim, starts from the
|
|
905
|
+
principal's viewer binding, and lets ``ParticipantResolver`` establish the
|
|
906
|
+
human contact server-side inside turn processing.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
authority = request_authority(request)
|
|
910
|
+
if (
|
|
911
|
+
has_sender
|
|
912
|
+
and not authority.legacy
|
|
913
|
+
and not authority.anonymous
|
|
914
|
+
and authority.has_scope("turns:resolve-sender")
|
|
915
|
+
):
|
|
916
|
+
if not authority.viewer_person_id:
|
|
917
|
+
raise _authority_error(
|
|
918
|
+
"person_binding_required",
|
|
919
|
+
"sender-resolving principals need a default viewer/person binding",
|
|
920
|
+
)
|
|
921
|
+
return authority.viewer_person_id
|
|
922
|
+
return resolve_request_person(
|
|
923
|
+
request,
|
|
924
|
+
context_person_id=context_person_id,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def query_person_is_granted(authority: RequestAuthority, person_id: str) -> bool:
|
|
929
|
+
"""Validate legacy query-string person selectors without trusting them."""
|
|
930
|
+
|
|
931
|
+
target = (person_id or "").strip()
|
|
932
|
+
if not target or authority.legacy:
|
|
933
|
+
return True
|
|
934
|
+
if authority.anonymous:
|
|
935
|
+
return target not in set(_audience_person_ids().values())
|
|
936
|
+
return target in authority.person_ids
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def worker_authority_mode() -> str:
|
|
940
|
+
"""Return the worker HTTP authority migration posture.
|
|
941
|
+
|
|
942
|
+
``shadow`` preserves legacy/global-bearer consumers while recording the
|
|
943
|
+
exact posture that enforcement would require. ``enforce`` accepts only a
|
|
944
|
+
scoped principal with an exact worker grant. Unknown values are exposed
|
|
945
|
+
as ``invalid`` so handlers return an operator-fixable 503 rather than
|
|
946
|
+
silently choosing a weaker policy.
|
|
947
|
+
"""
|
|
948
|
+
|
|
949
|
+
value = os.environ.get("COLONY_WORKER_AUTHORITY_MODE", "shadow").strip().lower()
|
|
950
|
+
return value if value in {"shadow", "enforce"} else "invalid"
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def compatible_scopes(method: str, path: str) -> frozenset[str]:
|
|
954
|
+
"""Return deliberate legacy scope aliases for one exact route.
|
|
955
|
+
|
|
956
|
+
Compatibility never makes ``api:access`` a global parent of focused
|
|
957
|
+
scopes. Middleware also applies ``allow_unscoped_api`` before accepting
|
|
958
|
+
this route-local alias.
|
|
959
|
+
"""
|
|
960
|
+
|
|
961
|
+
if (method.upper(), path) in WORK_READ_SURFACE_V1 or (method.upper(), path) in {
|
|
962
|
+
("POST", "/v1/host/learning/correction"),
|
|
963
|
+
("POST", "/v1/host/response-guard/check"),
|
|
964
|
+
}:
|
|
965
|
+
return _API_ACCESS_COMPATIBILITY
|
|
966
|
+
return _NO_COMPATIBILITY_SCOPES
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def required_scope(method: str, path: str) -> str:
|
|
970
|
+
"""Return the exact scope required for a scoped credential."""
|
|
971
|
+
|
|
972
|
+
key = (method.upper(), path)
|
|
973
|
+
if (method.upper() == 'POST' and path in {
|
|
974
|
+
'/v1/host/transport/ingress/admit', '/v1/host/transport/ingress/handoff',
|
|
975
|
+
'/v1/host/transport/ingress/coverage'} or
|
|
976
|
+
method.upper() == 'GET' and path == '/v1/host/transport/ingress/receipts'):
|
|
977
|
+
return 'transport:write'
|
|
978
|
+
if method.upper() == 'GET' and re.fullmatch(r'/v1/host/initiative-work(?:/[^/]+)?', path):
|
|
979
|
+
return 'context:read'
|
|
980
|
+
if method.upper() == 'POST' and re.fullmatch(r'/v1/host/initiative-work/[^/]+/(?:native-task|observe)', path):
|
|
981
|
+
return 'turns:write'
|
|
982
|
+
if method.upper() == 'POST' and re.fullmatch(r'/v1/host/temporal-followups/[^/]+/(?:verify-plan|check-plan)', path):
|
|
983
|
+
return 'transport:write'
|
|
984
|
+
if method.upper() == 'GET' and re.fullmatch(r'/v1/host/temporal-followups(?:/[^/]+)?', path):
|
|
985
|
+
return 'context:read'
|
|
986
|
+
if method.upper() == 'POST' and re.fullmatch(r'/v1/host/temporal-followups(?:/[^/]+/(?:change|native-task|prepare|observe|bind-plan))?', path):
|
|
987
|
+
return 'turns:write'
|
|
988
|
+
if method.upper() == "POST" and path.startswith("/v1/host/commitments/") and path.endswith("/work"):
|
|
989
|
+
return "turns:write"
|
|
990
|
+
if (method.upper() == 'POST'
|
|
991
|
+
and re.fullmatch(r'/v1/host/commitments/(?:local-draft|[^/]+/local-draft|local-work/next|local-work/[^/]+/(?:finish|native-task|native-run))', path)):
|
|
992
|
+
return 'turns:write'
|
|
993
|
+
if method.upper() == 'GET' and re.fullmatch(r'/v1/host/commitments/local-work/[^/]+', path):
|
|
994
|
+
return 'turns:write'
|
|
995
|
+
if key in WORK_READ_SURFACE_V1:
|
|
996
|
+
return "work:read"
|
|
997
|
+
if method.upper() == "GET" and path.startswith("/v1/host/memory/sources/assets/"):
|
|
998
|
+
return "memory:read"
|
|
999
|
+
if method.upper() == "POST" and path in {
|
|
1000
|
+
"/v1/host/contact-policy/standing",
|
|
1001
|
+
"/v1/host/contact-policy/provision",
|
|
1002
|
+
}:
|
|
1003
|
+
return "contacts:policy-write"
|
|
1004
|
+
if path.startswith("/v1/host/actions/"):
|
|
1005
|
+
if method.upper() == "PUT":
|
|
1006
|
+
return "actions:execute"
|
|
1007
|
+
if method.upper() == "GET":
|
|
1008
|
+
return "actions:verify"
|
|
1009
|
+
worker_mode = worker_authority_mode()
|
|
1010
|
+
if (
|
|
1011
|
+
method.upper() == "POST"
|
|
1012
|
+
and path == "/v1/host/queue/work/reconciliations"
|
|
1013
|
+
):
|
|
1014
|
+
return "workers:attest"
|
|
1015
|
+
if (
|
|
1016
|
+
path == "/v1/host/queue/work"
|
|
1017
|
+
or path.startswith("/v1/host/queue/work/")
|
|
1018
|
+
):
|
|
1019
|
+
return "work:read" if method.upper() == "GET" else "work:control"
|
|
1020
|
+
if (
|
|
1021
|
+
path.startswith("/v1/host/queue/workers/")
|
|
1022
|
+
and "/controls" in path
|
|
1023
|
+
):
|
|
1024
|
+
return "workers:lifecycle"
|
|
1025
|
+
if (
|
|
1026
|
+
method.upper() == "POST"
|
|
1027
|
+
and path.startswith("/v1/host/queue/attestations/jobs/")
|
|
1028
|
+
):
|
|
1029
|
+
return "workers:attest"
|
|
1030
|
+
if worker_mode in {"enforce", "invalid"} and method.upper() == "POST":
|
|
1031
|
+
if path == "/v1/host/queue/workers/register" or (
|
|
1032
|
+
path.startswith("/v1/host/queue/workers/")
|
|
1033
|
+
and path.endswith("/deregister")
|
|
1034
|
+
):
|
|
1035
|
+
return "workers:register"
|
|
1036
|
+
if path == "/v1/host/queue/jobs/claim":
|
|
1037
|
+
return "workers:claim"
|
|
1038
|
+
if (
|
|
1039
|
+
path.startswith("/v1/host/queue/workers/")
|
|
1040
|
+
and path.endswith("/heartbeat")
|
|
1041
|
+
) or (
|
|
1042
|
+
path.startswith("/v1/host/queue/jobs/")
|
|
1043
|
+
and path.endswith((
|
|
1044
|
+
"/start", "/complete", "/fail", "/heartbeat", "/release",
|
|
1045
|
+
))
|
|
1046
|
+
):
|
|
1047
|
+
return "workers:lifecycle"
|
|
1048
|
+
if (
|
|
1049
|
+
method.upper() == "GET"
|
|
1050
|
+
and path.startswith("/v1/host/queue/inspection/jobs/")
|
|
1051
|
+
):
|
|
1052
|
+
return "workers:inspect"
|
|
1053
|
+
# Approval routes always have stable exact scopes so restricted scoped
|
|
1054
|
+
# principals can canary them in shadow without gaining generic api:access
|
|
1055
|
+
# fallback. Shadow may still accept the legacy bearer; enforce additionally
|
|
1056
|
+
# requires an exact scoped principal in middleware.
|
|
1057
|
+
if path.startswith("/v1/host/queue/approvals/"):
|
|
1058
|
+
if method.upper() == "GET":
|
|
1059
|
+
return "approvals:read"
|
|
1060
|
+
if method.upper() == "DELETE":
|
|
1061
|
+
return "approvals:manage"
|
|
1062
|
+
return "approvals:decide"
|
|
1063
|
+
if (
|
|
1064
|
+
method.upper() == "GET"
|
|
1065
|
+
and path == "/v1/host/queue/jobs/blocked"
|
|
1066
|
+
):
|
|
1067
|
+
return "approvals:read"
|
|
1068
|
+
if (
|
|
1069
|
+
path.startswith("/v1/host/queue/jobs/")
|
|
1070
|
+
and path.endswith(("/approve", "/reject"))
|
|
1071
|
+
):
|
|
1072
|
+
return "approvals:decide"
|
|
1073
|
+
if (
|
|
1074
|
+
method.upper() == "POST"
|
|
1075
|
+
and path.startswith("/v1/host/self/tools/")
|
|
1076
|
+
and path.endswith("/graduate")
|
|
1077
|
+
):
|
|
1078
|
+
return "toolsmith:graduate"
|
|
1079
|
+
if (
|
|
1080
|
+
method.upper() == "POST"
|
|
1081
|
+
and path.startswith("/v1/host/self/tools/")
|
|
1082
|
+
and path.endswith("/shadow-compare")
|
|
1083
|
+
):
|
|
1084
|
+
return "toolsmith:evaluate"
|
|
1085
|
+
if method.upper() == "POST" and path == "/v1/host/sandbox/run":
|
|
1086
|
+
return "sandbox:execute"
|
|
1087
|
+
if (
|
|
1088
|
+
method.upper() == "POST"
|
|
1089
|
+
and path.startswith("/v1/host/cognition/charters/")
|
|
1090
|
+
and path.endswith(("/request-activation", "/request-revocation"))
|
|
1091
|
+
):
|
|
1092
|
+
return "charter:request"
|
|
1093
|
+
if (
|
|
1094
|
+
method.upper() == "POST"
|
|
1095
|
+
and path.startswith("/v1/host/cognition/charters/")
|
|
1096
|
+
and path.endswith("/ratify")
|
|
1097
|
+
):
|
|
1098
|
+
return "charter:ratify"
|
|
1099
|
+
if path.startswith(
|
|
1100
|
+
"/v1/host/cognition/charter-transition-approvals"
|
|
1101
|
+
):
|
|
1102
|
+
if method.upper() == "GET":
|
|
1103
|
+
return "charter:approval-read"
|
|
1104
|
+
if method.upper() == "POST" and path.endswith("/decision"):
|
|
1105
|
+
return "charter:approval-decide"
|
|
1106
|
+
return "api:access"
|
|
1107
|
+
exact = {
|
|
1108
|
+
("GET", "/v1/host/admin/auth/status"): "auth:admin",
|
|
1109
|
+
("GET", "/v1/host/contact-policy"): "contacts:policy-read",
|
|
1110
|
+
("POST", "/v1/host/response-guard/check"): "response-guard:check",
|
|
1111
|
+
("GET", "/v1/host/queue/contract"): "workers:contract",
|
|
1112
|
+
("POST", "/v1/host/memory/read"): "memory:read",
|
|
1113
|
+
("POST", "/v1/host/memory/search"): "memory:search",
|
|
1114
|
+
("POST", "/v1/host/memory/write"): "memory:write",
|
|
1115
|
+
("POST", "/v1/host/memory/sources/forget"): "memory:write",
|
|
1116
|
+
("POST", "/v1/host/memory/sources/annotations"): "memory:write",
|
|
1117
|
+
("GET", "/v1/host/memory/sources/erasures"): "turns:write",
|
|
1118
|
+
("POST", "/v1/host/memory/sources/erasures"): "turns:write",
|
|
1119
|
+
("GET", "/v1/host/memory/sources/claims/status"): "memory:read",
|
|
1120
|
+
("GET", "/v1/host/preferences"): "context:read",
|
|
1121
|
+
("GET", "/v1/host/self"): "context:read",
|
|
1122
|
+
("POST", "/v1/host/transport/observe"): "transport:write",
|
|
1123
|
+
("GET", "/v1/host/social/appraisals"): "context:read",
|
|
1124
|
+
("POST", "/v1/host/social/appraisals/correct"): "turns:write",
|
|
1125
|
+
("GET", "/v1/host/social/contacts"): "context:read",
|
|
1126
|
+
("POST", "/v1/host/social/contacts/correct-identity"): "turns:write",
|
|
1127
|
+
("POST", "/v1/host/preferences/learn"): "memory:write",
|
|
1128
|
+
("POST", "/v1/host/learning/correction"): "memory:write",
|
|
1129
|
+
("POST", "/v1/host/context/assemble"): "context:read",
|
|
1130
|
+
("GET", "/v1/host/executions"): "context:read",
|
|
1131
|
+
("POST", "/v1/host/executions/observe"): "turns:write",
|
|
1132
|
+
("GET", "/v1/host/context/projection-readiness"): "context:read",
|
|
1133
|
+
("POST", "/v1/host/context/enriched"): "context:read",
|
|
1134
|
+
("GET", "/v1/host/context/temporal"): "context:read",
|
|
1135
|
+
("POST", "/v1/host/turns/sync"): "turns:write",
|
|
1136
|
+
("GET", "/v1/host/events/replay"): "events:read",
|
|
1137
|
+
("GET", "/v1/host/self/workspace"): "cognition:read",
|
|
1138
|
+
("GET", "/v1/host/self/benchmark"): "cognition:benchmark-read",
|
|
1139
|
+
("POST", "/v1/host/self/benchmark/samples"):
|
|
1140
|
+
"cognition:benchmark-manage",
|
|
1141
|
+
("POST", "/v1/host/self/benchmark/recall-probe"):
|
|
1142
|
+
"cognition:benchmark-manage",
|
|
1143
|
+
("POST", "/v1/host/self/benchmark/compute"):
|
|
1144
|
+
"cognition:benchmark-manage",
|
|
1145
|
+
("GET", "/v1/host/self/experiments"):
|
|
1146
|
+
"cognition:experiment-read",
|
|
1147
|
+
("POST", "/v1/host/self/experiments"):
|
|
1148
|
+
"cognition:experiment-manage",
|
|
1149
|
+
("GET", "/v1/host/self/params"): "cognition:experiment-read",
|
|
1150
|
+
("GET", "/v1/host/cognition/cpi"): "cognition:benchmark-read",
|
|
1151
|
+
("POST", "/v1/host/cognition/cycle"):
|
|
1152
|
+
"cognition:benchmark-manage",
|
|
1153
|
+
("GET", "/v1/host/self/situation"): "cognition:read",
|
|
1154
|
+
("GET", "/v1/host/autonomy/posture"): "cognition:read",
|
|
1155
|
+
("GET", "/v1/host/autonomy/schedule"): "cognition:read",
|
|
1156
|
+
("GET", "/v1/host/autonomy/status"): "cognition:read",
|
|
1157
|
+
("GET", "/v1/host/cognition/evidence"): "cognition:read",
|
|
1158
|
+
("GET", "/v1/host/cognition/drives"): "drives:read",
|
|
1159
|
+
("GET", "/v1/host/cognition/charters"): "charter:read",
|
|
1160
|
+
("GET", "/v1/host/cognition/rankings"): "drives:read",
|
|
1161
|
+
("GET", "/v1/host/cognition/spine"): "cognition:read",
|
|
1162
|
+
("POST", "/v1/host/cognition/events"): "cognition:events-ingest",
|
|
1163
|
+
("GET", "/v1/host/proposals"): "cognition:read",
|
|
1164
|
+
("GET", "/v1/host/tom/p8/status"): "tom:read",
|
|
1165
|
+
("GET", "/v1/host/tom/p8/deck"): "tom:read",
|
|
1166
|
+
("GET", "/v1/host/tom2/report"): "tom:read",
|
|
1167
|
+
("POST", "/v1/host/cognition/drives"): "drives:propose",
|
|
1168
|
+
("POST", "/v1/host/cognition/drive-signals"): "drives:signal",
|
|
1169
|
+
("POST", "/v1/host/cognition/charters"): "charter:propose",
|
|
1170
|
+
}
|
|
1171
|
+
if key in exact:
|
|
1172
|
+
return exact[key]
|
|
1173
|
+
if method.upper() == "PUT" and path.startswith("/v2/host/turns/"):
|
|
1174
|
+
return "turns:write"
|
|
1175
|
+
if (
|
|
1176
|
+
method.upper() == "POST"
|
|
1177
|
+
and path.startswith("/v1/host/cognition/concerns/")
|
|
1178
|
+
and path.endswith("/promote")
|
|
1179
|
+
):
|
|
1180
|
+
return "cognition:manage"
|
|
1181
|
+
if (
|
|
1182
|
+
method.upper() == "POST"
|
|
1183
|
+
and path.startswith("/v1/host/cognition/goals/")
|
|
1184
|
+
and path.endswith("/promote")
|
|
1185
|
+
):
|
|
1186
|
+
return "cognition:manage"
|
|
1187
|
+
if (
|
|
1188
|
+
method.upper() == "GET"
|
|
1189
|
+
and path.startswith("/v1/host/self/workspace/")
|
|
1190
|
+
and path.endswith("/resolution")
|
|
1191
|
+
):
|
|
1192
|
+
return "cognition:read"
|
|
1193
|
+
if (
|
|
1194
|
+
method.upper() == "POST"
|
|
1195
|
+
and path.startswith("/v1/host/self/workspace/")
|
|
1196
|
+
and path.endswith("/resolve")
|
|
1197
|
+
):
|
|
1198
|
+
return "cognition:manage"
|
|
1199
|
+
if path.startswith("/v1/host/self/experiments/"):
|
|
1200
|
+
if method.upper() == "GET":
|
|
1201
|
+
return "cognition:experiment-read"
|
|
1202
|
+
return "cognition:experiment-manage"
|
|
1203
|
+
return "api:access"
|