agentevo-cloud 0.1.2__tar.gz → 0.1.3__tar.gz

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 (59) hide show
  1. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/LICENSE +1 -1
  2. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/PKG-INFO +8 -2
  3. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/README.md +6 -0
  4. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/pyproject.toml +1 -1
  5. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/__init__.py +1 -0
  6. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/analytics.py +1 -0
  7. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/app.py +61 -10
  8. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/cli.py +1 -0
  9. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/config.py +1 -0
  10. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/db.py +16 -1
  11. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/devices.py +5 -2
  12. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/events.py +1 -0
  13. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/jobs.py +33 -14
  14. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/managed.py +1 -0
  15. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/metering.py +1 -0
  16. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/oidc.py +1 -0
  17. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/openapi.py +1 -0
  18. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/promote.py +3 -1
  19. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/recall.py +1 -0
  20. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/records.py +1 -0
  21. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/revoke.py +4 -1
  22. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/screen.py +1 -0
  23. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/sync.py +1 -0
  24. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/tenants.py +1 -0
  25. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/tokens.py +51 -4
  26. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/webhooks.py +1 -0
  27. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/workers.py +1 -0
  28. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_acceptance_m12.py +1 -0
  29. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_acceptance_m8.py +1 -0
  30. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_analytics.py +1 -0
  31. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app.py +1 -0
  32. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_auth.py +63 -4
  33. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_jobs.py +61 -0
  34. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_managed.py +1 -0
  35. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_objects.py +1 -0
  36. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_sync.py +1 -0
  37. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_body_limit.py +1 -0
  38. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_cli.py +1 -0
  39. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_compile.py +1 -0
  40. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_devices_ci.py +2 -1
  41. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_events.py +1 -0
  42. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_foundation.py +55 -10
  43. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_jobs.py +59 -0
  44. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_maintenance.py +1 -0
  45. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_managed.py +4 -3
  46. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_metering_records.py +1 -0
  47. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_oidc.py +1 -0
  48. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_openapi.py +1 -0
  49. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_promote.py +1 -0
  50. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_recall.py +1 -0
  51. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_revoke.py +1 -0
  52. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_screen.py +1 -0
  53. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_smoke.py +1 -0
  54. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_sync_mode.py +1 -0
  55. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_sync_push_pull.py +1 -0
  56. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_webhooks.py +1 -0
  57. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_workers.py +1 -0
  58. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/.gitignore +0 -0
  59. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/openapi/openapi.v1.json +0 -0
@@ -3,7 +3,7 @@ Business Source License 1.1
3
3
  Parameters
4
4
 
5
5
  Licensor: Agentevo
6
- Licensed Work: agentevo-cloud 0.1.0
6
+ Licensed Work: agentevo-cloud 0.1.3
7
7
  Copyright Notice: Copyright (c) 2026 Agentevo
8
8
 
9
9
  Additional Use Grant:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentevo-cloud
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Agentevo SaaS (TZ-3 control plane, TZ-4 managed intelligence): auth, metering, C2 APIs, workers.
5
5
  Project-URL: Homepage, https://agentevo.sh
6
6
  Author: Agentevo
@@ -9,7 +9,7 @@ License: Business Source License 1.1
9
9
  Parameters
10
10
 
11
11
  Licensor: Agentevo
12
- Licensed Work: agentevo-cloud 0.1.0
12
+ Licensed Work: agentevo-cloud 0.1.3
13
13
  Copyright Notice: Copyright (c) 2026 Agentevo
14
14
 
15
15
  Additional Use Grant:
@@ -125,3 +125,9 @@ agentevo-cloud serve
125
125
  ```
126
126
 
127
127
  Part of the Agentevo monorepo; see the [main README](../../README.md).
128
+
129
+ ## License
130
+
131
+ Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
132
+ production use in your own infrastructure; converts to Apache-2.0 on the
133
+ Change Date stated in the license.
@@ -12,3 +12,9 @@ agentevo-cloud serve
12
12
  ```
13
13
 
14
14
  Part of the Agentevo monorepo; see the [main README](../../README.md).
15
+
16
+ ## License
17
+
18
+ Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
19
+ production use in your own infrastructure; converts to Apache-2.0 on the
20
+ Change Date stated in the license.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentevo-cloud"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Agentevo SaaS (TZ-3 control plane, TZ-4 managed intelligence): auth, metering, C2 APIs, workers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Agentevo SaaS control plane and managed intelligence APIs."""
2
3
 
3
4
  from agentevo_cloud.analytics import (
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Team analytics: tenant-scoped aggregates over the managed index.
2
3
 
3
4
  Spec grounding: PRD §15 ("record improved execution metrics"), PRD §16
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Cloud Control API: FastAPI factory, error model, and routes.
2
3
 
3
4
  Every route authenticates via Bearer tokens except /healthz and the auth
@@ -95,6 +96,7 @@ from agentevo_cloud.tokens import (
95
96
  hash_token,
96
97
  issue_pat,
97
98
  list_tokens,
99
+ require_workspace,
98
100
  revoke_token,
99
101
  rotate_refresh,
100
102
  verify_token,
@@ -538,9 +540,16 @@ def _client_ip(request: Request) -> str:
538
540
 
539
541
 
540
542
  def _auth_rate_limit(request: Request, conn: sqlite3.Connection, name: str) -> None:
541
- """IP-keyed burst guard for the unauthenticated auth flows."""
543
+ """IP-keyed burst guard for the unauthenticated auth flows.
544
+
545
+ Commits the increment immediately: burst counters must survive the
546
+ request transaction's rollback-on-error, or failed guesses would never
547
+ count. Callers must run this before any request-conn writes (all do:
548
+ it gates handler entry, and dependencies only read).
549
+ """
542
550
  settings: Settings = request.app.state.settings
543
551
  check_rate_limit(conn, f"auth:{name}:{_client_ip(request)}", settings.rate_limit_per_minute)
552
+ conn.commit()
544
553
 
545
554
 
546
555
  def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
@@ -555,6 +564,8 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
555
564
 
556
565
  Anonymous bootstrap flows (device start, CI exchange) share the empty
557
566
  tenant namespace; their responses carry no tenant data before approval.
567
+ Stored replays withhold single-view secrets (issuance responses keep
568
+ ids/scopes/expiry only); the live response is unaffected.
558
569
  """
559
570
  route = f"{request.method} {request.url.path}"
560
571
  if key is not None:
@@ -573,7 +584,7 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
573
584
  key,
574
585
  body=body,
575
586
  status_code=status,
576
- response=payload,
587
+ response=_scrub_replay_secrets(payload),
577
588
  ttl=timedelta(seconds=settings.idempotency_ttl_seconds),
578
589
  )
579
590
  if raced is not None:
@@ -619,6 +630,8 @@ def _issue_token(
619
630
  user_id=principal.user_id or "",
620
631
  workspaces=workspaces,
621
632
  ttl=ttl,
633
+ grantor_scopes=list(principal.scopes),
634
+ grantor_workspaces=list(principal.workspaces),
622
635
  )
623
636
  audit(
624
637
  conn,
@@ -639,6 +652,19 @@ def _issue_token(
639
652
  return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
640
653
 
641
654
 
655
+ #: Top-level response keys withheld from the idempotency replay store.
656
+ #: Single-view issuance secrets must not persist in plaintext for the key
657
+ #: TTL: replays of issuance routes return metadata (ids, scopes, expiry)
658
+ #: without the secret itself. (Request bodies are hash-compared, never
659
+ #: stored; short-lived device/user codes stay stored: retry UX needs them.)
660
+ _SECRET_REPLAY_KEYS = frozenset({"secret", "access_token", "refresh_token", "token"})
661
+
662
+
663
+ def _scrub_replay_secrets(payload: dict[str, Any]) -> dict[str, Any]:
664
+ """Copy ``payload`` minus single-view credential fields."""
665
+ return {key: value for key, value in payload.items() if key not in _SECRET_REPLAY_KEYS}
666
+
667
+
642
668
  def _list_tokens(
643
669
  principal: Principal = Depends(CONTROL_READ),
644
670
  conn: sqlite3.Connection = Depends(get_conn),
@@ -1625,9 +1651,13 @@ def _body_target(body: dict[str, Any]) -> TrustState:
1625
1651
 
1626
1652
 
1627
1653
  def _managed_or_404(
1628
- conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
1654
+ conn: sqlite3.Connection,
1655
+ tenant_id: str,
1656
+ object_id: str,
1657
+ version: int,
1658
+ principal: Principal,
1629
1659
  ) -> ManagedObject:
1630
- """One indexed version, tenant-scoped (404 reveals nothing across tenants)."""
1660
+ """One indexed version, tenant- and workspace-scoped (404 reveals nothing)."""
1631
1661
  target = get_managed(conn, tenant_id, object_id, version)
1632
1662
  if target is None:
1633
1663
  raise AgentevoError(
@@ -1635,6 +1665,7 @@ def _managed_or_404(
1635
1665
  "unknown object version",
1636
1666
  {"object_id": object_id, "version": version},
1637
1667
  )
1668
+ require_workspace(principal, target.workspace_id)
1638
1669
  return target
1639
1670
 
1640
1671
 
@@ -1649,7 +1680,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
1649
1680
  version = _body_version(body)
1650
1681
  environment = body.get("environment")
1651
1682
  parse_environment(environment)
1652
- _managed_or_404(conn, principal.tenant_id, object_id, version)
1683
+ target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
1653
1684
  params = {} if environment is None else {"environment": environment}
1654
1685
 
1655
1686
  def compute() -> tuple[int, dict[str, Any]]:
@@ -1660,6 +1691,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
1660
1691
  object_id=object_id,
1661
1692
  version=version,
1662
1693
  params=params,
1694
+ workspace_id=target.workspace_id,
1663
1695
  )
1664
1696
  audit(
1665
1697
  conn,
@@ -1685,7 +1717,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
1685
1717
  version = _body_version(body)
1686
1718
  environment = body.get("environment")
1687
1719
  parse_environment(environment)
1688
- target = _managed_or_404(conn, principal.tenant_id, object_id, version)
1720
+ target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
1689
1721
  if target.state not in _REVALIDATABLE_STATES:
1690
1722
  raise AgentevoError(
1691
1723
  ErrorCode.CONFLICT,
@@ -1704,6 +1736,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
1704
1736
  object_id=object_id,
1705
1737
  version=version,
1706
1738
  params=params,
1739
+ workspace_id=target.workspace_id,
1707
1740
  )
1708
1741
  audit(
1709
1742
  conn,
@@ -1724,7 +1757,7 @@ def _get_object_version(
1724
1757
  principal: Principal = Depends(RECALL_READ),
1725
1758
  conn: sqlite3.Connection = Depends(get_conn),
1726
1759
  ) -> dict[str, Any]:
1727
- return _managed_or_404(conn, principal.tenant_id, object_id, version).to_dict()
1760
+ return _managed_or_404(conn, principal.tenant_id, object_id, version, principal).to_dict()
1728
1761
 
1729
1762
 
1730
1763
  def _body_reason(body: dict[str, Any]) -> RevocationReason:
@@ -1768,12 +1801,17 @@ def _compile_experiences(
1768
1801
  workspace_id = parse_required_str(body.get("workspace_id"), "workspace_id")
1769
1802
  project_id = parse_required_str(body.get("project_id", DEFAULT_COMPILE_PROJECT), "project_id")
1770
1803
  ensure_workspace(conn, principal.tenant_id, workspace_id)
1804
+ require_workspace(principal, workspace_id)
1771
1805
  require_searchable(conn, principal.tenant_id, workspace_id, "compile managed experiences")
1772
1806
  params = {"session_id": session_id, "workspace_id": workspace_id, "project_id": project_id}
1773
1807
 
1774
1808
  def compute() -> tuple[int, dict[str, Any]]:
1775
1809
  job = create_job(
1776
- conn, principal.tenant_id, JobType.COMPILE_EXPERIENCES.value, params=params
1810
+ conn,
1811
+ principal.tenant_id,
1812
+ JobType.COMPILE_EXPERIENCES.value,
1813
+ params=params,
1814
+ workspace_id=workspace_id,
1777
1815
  )
1778
1816
  audit(
1779
1817
  conn,
@@ -1810,6 +1848,7 @@ def _compile_workflows(
1810
1848
  "cross-workspace compile rejected",
1811
1849
  {"workspaces": sorted({row.workspace_id for row in rows})},
1812
1850
  )
1851
+ require_workspace(principal, rows[0].workspace_id)
1813
1852
  require_searchable(conn, principal.tenant_id, rows[0].workspace_id, "compile managed workflows")
1814
1853
  params: dict[str, Any] = {
1815
1854
  "skills": [{"object_id": row.object_id, "version": row.version} for row in rows],
@@ -1820,7 +1859,13 @@ def _compile_workflows(
1820
1859
  }
1821
1860
 
1822
1861
  def compute() -> tuple[int, dict[str, Any]]:
1823
- job = create_job(conn, principal.tenant_id, JobType.COMPILE_WORKFLOW.value, params=params)
1862
+ job = create_job(
1863
+ conn,
1864
+ principal.tenant_id,
1865
+ JobType.COMPILE_WORKFLOW.value,
1866
+ params=params,
1867
+ workspace_id=rows[0].workspace_id,
1868
+ )
1824
1869
  audit(
1825
1870
  conn,
1826
1871
  principal.tenant_id,
@@ -1908,6 +1953,8 @@ def _get_job(
1908
1953
  if job is None:
1909
1954
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1910
1955
  _require_job_scope(principal, job.job_type)
1956
+ if job.workspace_id is not None:
1957
+ require_workspace(principal, job.workspace_id)
1911
1958
  return job.to_dict()
1912
1959
 
1913
1960
 
@@ -1927,7 +1974,9 @@ def _list_jobs(
1927
1974
  raise AgentevoError(
1928
1975
  ErrorCode.INVALID_ARGUMENT, f"unknown job state: {state}", {"state": state}
1929
1976
  )
1930
- jobs = list_jobs(conn, principal.tenant_id, state=state, limit=limit)
1977
+ jobs = list_jobs(
1978
+ conn, principal.tenant_id, state=state, limit=limit, workspaces=principal.workspaces
1979
+ )
1931
1980
  return {"jobs": [job.to_dict() for job in jobs]}
1932
1981
 
1933
1982
 
@@ -1942,6 +1991,8 @@ def _cancel_job(
1942
1991
  if job is None:
1943
1992
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1944
1993
  _require_job_scope(principal, job.job_type)
1994
+ if job.workspace_id is not None:
1995
+ require_workspace(principal, job.workspace_id)
1945
1996
 
1946
1997
  def compute() -> tuple[int, dict[str, Any]]:
1947
1998
  canceled = cancel_job(conn, principal.tenant_id, job_id)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Control-plane operator CLI: tenant provisioning, serving, and job driving."""
2
3
 
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Cloud Control API settings (environment-driven, no secrets in code)."""
2
3
 
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Control-plane storage: SQLite engine plus versioned migrations.
2
3
 
3
4
  The repository layer speaks plain SQL that stays portable to server RDBMS
@@ -16,7 +17,7 @@ from collections.abc import Callable, Iterator
16
17
  from contextlib import contextmanager, suppress
17
18
  from pathlib import Path
18
19
 
19
- SCHEMA_VERSION = 9
20
+ SCHEMA_VERSION = 10
20
21
 
21
22
  #: Migration mutual exclusion. Five compose services share one SQLite file
22
23
  #: and migrate lazily on first use; without this, concurrent first-boots race
@@ -358,6 +359,19 @@ def _ensure_job_durability_columns(raw: sqlite3.Connection) -> None:
358
359
 
359
360
  _MIGRATION_FOLLOWUPS[8] = _ensure_job_durability_columns
360
361
 
362
+ _SCHEMA_V10 = "-- V10: job workspace scoping column (applied by followup).\n"
363
+
364
+
365
+ def _ensure_job_workspace_column(raw: sqlite3.Connection) -> None:
366
+ """Add `jobs.workspace_id` unless a replay already did (NULL = legacy)."""
367
+ names = {row[1] for row in raw.execute("PRAGMA table_info(jobs)").fetchall()}
368
+ if "workspace_id" not in names:
369
+ raw.execute("ALTER TABLE jobs ADD COLUMN workspace_id TEXT")
370
+ raw.execute("CREATE INDEX IF NOT EXISTS idx_jobs_workspace ON jobs (tenant_id, workspace_id)")
371
+
372
+
373
+ _MIGRATION_FOLLOWUPS[10] = _ensure_job_workspace_column
374
+
361
375
  _SCHEMA_V9 = """
362
376
  CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_links_repo ON repo_links (repo);
363
377
  """
@@ -372,6 +386,7 @@ MIGRATIONS: tuple[tuple[int, str], ...] = (
372
386
  (7, _SCHEMA_V7),
373
387
  (8, _SCHEMA_V8),
374
388
  (9, _SCHEMA_V9),
389
+ (10, _SCHEMA_V10),
375
390
  )
376
391
 
377
392
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Workspace devices and the OAuth device authorization flow (RFC 8628).
2
3
 
3
4
  Devices register public keys under TOFU: each ``(key_id, workspace)``
@@ -669,9 +670,11 @@ def _granted_workspace(
669
670
  {"workspace": requested},
670
671
  )
671
672
  if identity.workspaces and requested not in identity.workspaces:
673
+ # Same 404 as a missing workspace: a scoped approver must not learn
674
+ # whether the slug exists outside its grant (require_workspace rule).
672
675
  raise AgentevoError(
673
- ErrorCode.FORBIDDEN,
674
- "approver token is not valid for the requested workspace",
676
+ ErrorCode.NOT_FOUND,
677
+ f"unknown workspace: {requested}",
675
678
  {"workspace": requested},
676
679
  )
677
680
  return requested
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed experience ingest: sanitized C1/C2 event summaries.
2
3
 
3
4
  Spec: API §5/§7. ``POST /v1/events/batch`` carries up to 100 canonical
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Async job orchestration: durable verify/compile jobs with poll + webhooks.
2
3
 
3
4
  Spec: API §5/§9/§11/§23. Submissions return 202 with a queued job;
@@ -96,6 +97,7 @@ class Job:
96
97
  finished_at: str | None
97
98
  attempts: int = 0
98
99
  next_run_at: str | None = None
100
+ workspace_id: str | None = None
99
101
 
100
102
  def to_dict(self) -> dict[str, Any]:
101
103
  """Poll response shape (tenant omitted: derived server-side)."""
@@ -104,6 +106,7 @@ class Job:
104
106
  "type": self.job_type,
105
107
  "object_id": self.object_id,
106
108
  "version": self.version,
109
+ "workspace_id": self.workspace_id,
107
110
  "state": self.state,
108
111
  "params": self.params,
109
112
  "result": self.result,
@@ -156,8 +159,9 @@ def create_job( # noqa: PLR0913 - submission carries job context
156
159
  version: int | None = None,
157
160
  params: dict[str, Any] | None = None,
158
161
  now: datetime | None = None,
162
+ workspace_id: str | None = None,
159
163
  ) -> Job:
160
- """Queue one job (202 submission path)."""
164
+ """Queue one job (202 submission path; workspace stamps its scope)."""
161
165
  try:
162
166
  kind = JobType(job_type)
163
167
  except ValueError as exc:
@@ -169,7 +173,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
169
173
  payload = params or {}
170
174
  conn.execute(
171
175
  "INSERT INTO jobs (tenant_id, job_id, job_type, object_id, version,"
172
- " state, params, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
176
+ " state, params, created_at, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
173
177
  (
174
178
  tenant_id,
175
179
  job_id,
@@ -179,6 +183,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
179
183
  JobState.QUEUED.value,
180
184
  _dump(payload),
181
185
  moment,
186
+ workspace_id,
182
187
  ),
183
188
  )
184
189
  return Job(
@@ -194,6 +199,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
194
199
  created_at=moment,
195
200
  started_at=None,
196
201
  finished_at=None,
202
+ workspace_id=workspace_id,
197
203
  )
198
204
 
199
205
 
@@ -206,23 +212,34 @@ def get_job(conn: sqlite3.Connection, tenant_id: str, job_id: str) -> Job | None
206
212
 
207
213
 
208
214
  def list_jobs(
209
- conn: sqlite3.Connection, tenant_id: str, *, state: str | None = None, limit: int = 50
215
+ conn: sqlite3.Connection,
216
+ tenant_id: str,
217
+ *,
218
+ state: str | None = None,
219
+ limit: int = 50,
220
+ workspaces: tuple[str, ...] = (),
210
221
  ) -> list[Job]:
211
- """Tenant jobs, newest first (operator listing)."""
222
+ """Tenant jobs, newest first (operator listing; scope-filtered)."""
212
223
  if not 1 <= limit <= LIST_JOBS_MAX:
213
224
  raise AgentevoError(
214
225
  ErrorCode.INVALID_ARGUMENT, f"limit must be within [1, {LIST_JOBS_MAX}]", {}
215
226
  )
216
- if state is None:
217
- rows = conn.execute(
218
- "SELECT * FROM jobs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?",
219
- (tenant_id, limit),
220
- ).fetchall()
221
- else:
222
- rows = conn.execute(
223
- "SELECT * FROM jobs WHERE tenant_id = ? AND state = ? ORDER BY created_at DESC LIMIT ?",
224
- (tenant_id, state, limit),
225
- ).fetchall()
227
+ clauses = ["tenant_id = ?"]
228
+ params: list[Any] = [tenant_id]
229
+ if state is not None:
230
+ clauses.append("state = ?")
231
+ params.append(state)
232
+ if workspaces:
233
+ placeholders = ", ".join("?" for _ in workspaces)
234
+ clauses.append(f"workspace_id IN ({placeholders})")
235
+ params.extend(workspaces)
236
+ query = (
237
+ "SELECT * FROM jobs WHERE " # noqa: S608 - static fragments + binds
238
+ + " AND ".join(clauses)
239
+ + " ORDER BY created_at DESC LIMIT ?"
240
+ )
241
+ params.append(limit)
242
+ rows = conn.execute(query, params).fetchall()
226
243
  return [_job_row(row) for row in rows]
227
244
 
228
245
 
@@ -546,6 +563,8 @@ def _job_row(row: sqlite3.Row) -> Job:
546
563
  finished_at=row["finished_at"],
547
564
  attempts=row["attempts"],
548
565
  next_run_at=row["next_run_at"],
566
+ # NOTE: sqlite3.Row `in` scans values, so `.keys()` is required here.
567
+ workspace_id=row["workspace_id"] if "workspace_id" in row.keys() else None, # noqa: SIM118
549
568
  )
550
569
 
551
570
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed C2 registry: tenant-partitioned index over ingested sync deltas.
2
3
 
3
4
  Spec: API §8/§12, Data Flow §7 steps 5-7. Push ingest stores the
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Request metering: quotas, monthly cycles, burst rate limits.
2
3
 
3
4
  Exactly the spec's billable set is metered: authenticated platform
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """OIDC/JWT verification shared by CI exchange and browser device approval.
2
3
 
3
4
  RS256 via the issuer JWKS is the production path (GitHub Actions for CI,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """OpenAPI snapshot: regenerate the checked-in v1 contract (API §25).
2
3
 
3
4
  The checked-in ``openapi/openapi.v1.json`` is generated from the live app,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed promotion: Candidate -> Verified -> Trusted advancement.
2
3
 
3
4
  Spec: PRD §17/§19, API §20. Mirrors the local PromotionService gates over
@@ -24,7 +25,7 @@ from datetime import UTC, datetime
24
25
  from agentevo_cloud.jobs import JobState, JobType
25
26
  from agentevo_cloud.managed import ManagedObject, get_managed
26
27
  from agentevo_cloud.records import audit
27
- from agentevo_cloud.tokens import Principal
28
+ from agentevo_cloud.tokens import Principal, require_workspace
28
29
  from agentevo_core.errors import AgentevoError, ErrorCode
29
30
  from agentevo_core.risk import RiskClass, requires_human_approval
30
31
  from agentevo_core.sync import DeltaKind
@@ -75,6 +76,7 @@ def promote_managed( # noqa: PLR0913 - promotion carries object context
75
76
  "unknown object version",
76
77
  {"object_id": object_id, "version": version},
77
78
  )
79
+ require_workspace(principal, row.workspace_id)
78
80
  check_transition(TrustState(row.state), target)
79
81
  moment = now or datetime.now(UTC)
80
82
  tally = _tally(conn, principal.tenant_id, object_id, version)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed recall: tenant-scoped retrieval over the C2 index.
2
3
 
3
4
  Spec: API §8/§13. The server authenticates, derives tenant scope,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Append-only audit log (BR-014) and idempotency-key store (API §15).
2
3
 
3
4
  Audit details carry C1 labels only — never request bodies, secrets, or
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Server-attested revocation: object-wide tombstones with full propagation.
2
3
 
3
4
  Spec: Data Flow §13, API §20. Revocation is the safety override: one
@@ -35,7 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
35
36
  from agentevo_cloud.managed import index_accepted
36
37
  from agentevo_cloud.records import audit
37
38
  from agentevo_cloud.sync import store_server_delta
38
- from agentevo_cloud.tokens import Principal
39
+ from agentevo_cloud.tokens import Principal, require_workspace
39
40
  from agentevo_core.classification import Classification
40
41
  from agentevo_core.errors import AgentevoError, ErrorCode
41
42
  from agentevo_core.objects import LearnedObjectType
@@ -95,6 +96,8 @@ def revoke_object(
95
96
  ).fetchall()
96
97
  if not rows:
97
98
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown object", {"object_id": object_id})
99
+ for row in rows:
100
+ require_workspace(principal, row["workspace_id"])
98
101
  live = [row for row in rows if row["state"] not in _TERMINAL_REVOKE_STATES]
99
102
  moment = now or datetime.now(UTC)
100
103
  actor = principal.user_id or principal.token_id
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Second-pass ingest screening: classification, C4 patterns, C3 evacuation.
2
3
 
3
4
  Spec: API §12/§17/§25, Data Flow §7. The local runtime minimizes and
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed sync ingest: push validation, ordered store, pull pages.
2
3
 
3
4
  Spec: API §5/§12/§20. This module mirrors the ``LoopbackTransport``
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Tenants, memberships, workspaces, and plan entitlements.
2
3
 
3
4
  Plans: Free carries exactly 10,000 billable requests per monthly cycle
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Token lifecycle: PATs, access tokens, refresh rotation, CI tokens.
2
3
 
3
4
  All tokens are opaque random strings, SHA-256-hashed at rest, prefixed by
@@ -85,6 +86,16 @@ class Principal:
85
86
  return not self.workspaces or slug in self.workspaces
86
87
 
87
88
 
89
+ def require_workspace(principal: Principal, slug: str) -> None:
90
+ """404 unless the token's workspace scope covers ``slug``.
91
+
92
+ Cross-scope access fails exactly like a missing object: callers must
93
+ never reveal whether the target exists outside the token's scope.
94
+ """
95
+ if not principal.allows_workspace(slug):
96
+ raise AgentevoError(ErrorCode.NOT_FOUND, "unknown object version", {})
97
+
98
+
88
99
  def hash_token(secret: str) -> str:
89
100
  """SHA-256 hex digest used for at-rest token lookup.
90
101
 
@@ -106,9 +117,23 @@ def issue_pat( # noqa: PLR0913 - issuance carries grant context
106
117
  workspaces: list[str] | None = None,
107
118
  ttl: timedelta = timedelta(days=90),
108
119
  now: datetime | None = None,
120
+ grantor_scopes: list[str] | None = None,
121
+ grantor_workspaces: list[str] | None = None,
109
122
  ) -> IssuedToken:
110
- """Issue a scoped personal token (elevated scopes need owner/admin)."""
111
- _check_grantable(conn, tenant_id, user_id, scopes)
123
+ """Issue a scoped personal token (elevated scopes need owner/admin).
124
+
125
+ When the grantor's own grant is given, the new token is a subset of it;
126
+ ``None`` means unconstrained bootstrap (operator provision only).
127
+ """
128
+ _check_grantable(
129
+ conn,
130
+ tenant_id,
131
+ user_id,
132
+ scopes,
133
+ workspaces,
134
+ grantor_scopes,
135
+ grantor_workspaces,
136
+ )
112
137
  return _store(
113
138
  conn,
114
139
  tenant_id=tenant_id,
@@ -309,8 +334,14 @@ def list_tokens(conn: sqlite3.Connection, tenant_id: str) -> list[TokenRecord]:
309
334
  return [_record(row) for row in rows]
310
335
 
311
336
 
312
- def _check_grantable(
313
- conn: sqlite3.Connection, tenant_id: str, user_id: str, scopes: list[str]
337
+ def _check_grantable( # noqa: PLR0913, PLR0917 - grant check carries both grants
338
+ conn: sqlite3.Connection,
339
+ tenant_id: str,
340
+ user_id: str,
341
+ scopes: list[str],
342
+ workspaces: list[str] | None = None,
343
+ grantor_scopes: list[str] | None = None,
344
+ grantor_workspaces: list[str] | None = None,
314
345
  ) -> None:
315
346
  for scope in scopes:
316
347
  if scope not in GRANTABLE_SCOPES:
@@ -319,6 +350,22 @@ def _check_grantable(
319
350
  f"unknown scope: {scope}",
320
351
  {"scope": scope},
321
352
  )
353
+ if grantor_scopes is not None and not set(scopes) <= set(grantor_scopes):
354
+ raise AgentevoError(
355
+ ErrorCode.FORBIDDEN,
356
+ "cannot grant scopes beyond your own",
357
+ {"tenant_id": tenant_id},
358
+ )
359
+ if (
360
+ grantor_workspaces
361
+ and workspaces is not None
362
+ and not set(workspaces) <= set(grantor_workspaces)
363
+ ):
364
+ raise AgentevoError(
365
+ ErrorCode.FORBIDDEN,
366
+ "cannot grant workspaces beyond your own",
367
+ {"tenant_id": tenant_id},
368
+ )
322
369
  if not set(scopes) & ELEVATED_SCOPES:
323
370
  return
324
371
  membership = get_membership(conn, tenant_id, user_id)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Outbound webhooks: signed at-least-once delivery to customer endpoints.
2
3
 
3
4
  Every delivery signs ``timestamp + raw body`` with HMAC-SHA256; receivers