agentic-runner 3.21.0__tar.gz → 3.22.0__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 (78) hide show
  1. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/PKG-INFO +2 -2
  2. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/pyproject.toml +1 -1
  3. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/activities.py +100 -10
  5. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/config.py +32 -2
  6. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/egress.py +120 -14
  7. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/mcp.py +7 -1
  8. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/registration.py +3 -0
  9. agentic_runner-3.22.0/src/agentic_runner/runner_limit.py +342 -0
  10. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/service.py +95 -8
  11. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/acp_runtime.py +87 -6
  12. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/command_policy.py +13 -1
  13. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workstation.py +8 -0
  14. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/.gitignore +0 -0
  15. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/LICENSE +0 -0
  16. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/README.md +0 -0
  17. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/auth_mode.py +0 -0
  19. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/build.py +0 -0
  20. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/callback.py +0 -0
  21. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/child_watcher.py +0 -0
  22. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/cli.py +0 -0
  23. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/cli_floor.py +0 -0
  24. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/credentials.py +0 -0
  25. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/device_login_activities.py +0 -0
  26. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/harness_self_test.py +0 -0
  27. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/heartbeat_link.py +0 -0
  28. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/hooks.py +0 -0
  29. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/host_store.py +0 -0
  30. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/__init__.py +0 -0
  31. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  32. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  33. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  34. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  35. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  36. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  37. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  38. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  39. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  40. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  41. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  42. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/launch.py +0 -0
  43. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/lifecycle.py +0 -0
  44. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/llm_proxy.py +0 -0
  45. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/message_store.py +0 -0
  46. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/oauth_connectors.py +0 -0
  47. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/private_state.py +0 -0
  48. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/py.typed +0 -0
  49. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  50. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/runtime/__init__.py +0 -0
  51. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  52. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/sealed_box.py +0 -0
  53. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/skill.py +0 -0
  54. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  55. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/__init__.py +0 -0
  56. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/__main__.py +0 -0
  57. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/control_plane.py +0 -0
  58. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/plugin.py +0 -0
  59. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  60. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/workflow.py +0 -0
  61. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/tiny_http.py +0 -0
  62. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/triage_activities.py +0 -0
  63. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/usage_windows.py +0 -0
  64. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/user_sources.py +0 -0
  65. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/__init__.py +0 -0
  66. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  67. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  68. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  69. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  70. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  71. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  72. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  73. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  74. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  75. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  76. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  77. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/settings.py +0 -0
  78. {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.21.0
3
+ Version: 3.22.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.12
8
- Requires-Dist: agentic-runner-contracts==3.21.0
8
+ Requires-Dist: agentic-runner-contracts==3.22.0
9
9
  Requires-Dist: cryptography<51,>=50
10
10
  Requires-Dist: httpx<1.0,>=0.27
11
11
  Requires-Dist: pydantic-settings<3.0,>=2.0
@@ -10,7 +10,7 @@ dependencies = [
10
10
  # Exactly this release's contracts: the two ship as one version, and an unpinned
11
11
  # requirement let `uv tool upgrade` keep an older contracts wheel the Runner cannot
12
12
  # import from (QTS-1473). `scripts/set-version.py` moves it with the version.
13
- "agentic-runner-contracts==3.21.0",
13
+ "agentic-runner-contracts==3.22.0",
14
14
  # Imported directly by `registration.py` (the Ed25519 identity that signs every
15
15
  # heartbeat); already present transitively through pyjwt[crypto], declared because
16
16
  # we import it.
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.21.0"
10
+ __version__ = "3.22.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -77,7 +77,14 @@ from agentic_runner.credentials import (
77
77
  EmptyCredentialStore,
78
78
  UnresolvableCredentialReferenceError,
79
79
  )
80
- from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
80
+ from agentic_runner.egress import (
81
+ EGRESS_REFUSED_SOURCE,
82
+ EgressProxy,
83
+ Resolver,
84
+ destination_denied,
85
+ host_of,
86
+ url_destination,
87
+ )
81
88
  from agentic_runner.harness_self_test import DirectivesInFlight
82
89
  from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
83
90
  from agentic_runner.hooks import (
@@ -119,6 +126,11 @@ from agentic_runner.mcp import (
119
126
  )
120
127
  from agentic_runner.message_store import MessageStore, WorkflowSignaller
121
128
  from agentic_runner.registration import RunnerRegistrationError
129
+ from agentic_runner.runner_limit import (
130
+ EMPTY_RUNNER_LIMIT,
131
+ RUNNER_LIMIT_EVIDENCE_SOURCE,
132
+ AppliedRunnerLimit,
133
+ )
122
134
  from agentic_runner.runtime import verifier_command
123
135
  from agentic_runner.runtime.verifier_command import CommandValidationError
124
136
  from agentic_runner.usage_windows import UsageWindows
@@ -261,6 +273,7 @@ from agentic_runner_contracts.grants import (
261
273
  REPO_RESOURCE_TYPE,
262
274
  UNENFORCED_SNAPSHOT,
263
275
  Decision,
276
+ GrantLink,
264
277
  GrantSnapshot,
265
278
  )
266
279
  from agentic_runner_contracts.grants.seam import VerbDecision, decide_verb
@@ -340,6 +353,8 @@ _CRITIC_VETO_SOURCE = "critic.veto"
340
353
  # lets the Directive write; the Runner reads it and deletes it before anything else runs.
341
354
  _LEARNING_RUN_SOURCE = "learning.directive_run"
342
355
  _LESSONS_FILE = Path(".agentic-learner") / "lessons.json"
356
+ # The `ending_kind` agentic-os sends for the leaving Assignee's Learning Directive.
357
+ _LEARNING_ENDING_HANDOFF = "HANDOFF"
343
358
  # PRD issue 59: where a Lead's Directive writes its plan (children to open, children to
344
359
  # end, whether it ends the Epic). Read and deleted before anything is committed, so the
345
360
  # plan never lands on the branch; the control plane evaluates `work.open` / `work.end`.
@@ -1090,10 +1105,19 @@ def _learning_prompt(request: LearningDirectiveInput, *, base_branch: str, trans
1090
1105
  transcript_section = section("Conversation transcript", transcript or "(none)")
1091
1106
  lessons_section = section("Existing Lessons", request.existing_lessons or "(none)")
1092
1107
  cut_line = f"Cut to fit: {'; '.join(notes)}.\n\n" if notes else ""
1093
- return (
1094
- "You are the Learner. This Work Record has ended "
1108
+ # A hand-off is no ending: the work goes on under the next Assignee, and the Evidence
1109
+ # the platform sent is the leaving Agent's own Directives only (QTS-1566, QTS-1574).
1110
+ occasion = (
1111
+ "The Agent whose work you review handed this Work Record off and is no longer its "
1112
+ "Assignee; the work goes on without it. Propose Lessons from its own part of the work "
1113
+ "that a future Directive of the same Persona should know"
1114
+ if request.ending_kind == _LEARNING_ENDING_HANDOFF
1115
+ else "This Work Record has ended "
1095
1116
  f"({request.ending_kind}{' / ' + request.end_reason if request.end_reason else ''}). "
1096
- "Propose Lessons a future Directive of the same Persona should know -- from a "
1117
+ "Propose Lessons a future Directive of the same Persona should know"
1118
+ )
1119
+ return (
1120
+ f"You are the Learner. {occasion} -- from a "
1097
1121
  "failure as much as from a success. Propose nothing rather than something vague, "
1098
1122
  "and never repeat an existing Lesson below.\n\n"
1099
1123
  f"The change is the branch checked out here: read it with `git diff "
@@ -1688,8 +1712,12 @@ class RunnerRalphActivities:
1688
1712
  attempt_records: AttemptRecords | None = None,
1689
1713
  directives_in_flight: DirectivesInFlight | None = None,
1690
1714
  usage_windows: UsageWindows | None = None,
1715
+ runner_limit: AppliedRunnerLimit | None = None,
1691
1716
  ) -> None:
1692
1717
  self._fastapi_client = fastapi_client
1718
+ # What this Runner's host denies every Agent on it, checked at start (ADR-0022
1719
+ # §52-§55). Empty by default, which narrows nothing.
1720
+ self._runner_limit = runner_limit or EMPTY_RUNNER_LIMIT
1693
1721
  # Read by the harness self-test, which never runs beside a Contract's Directive.
1694
1722
  self._directives_in_flight = directives_in_flight or DirectivesInFlight()
1695
1723
  # Local-agents 10: None off a user-hosted Runner, which reports no usage windows.
@@ -2850,6 +2878,7 @@ class RunnerRalphActivities:
2850
2878
  state.mcp_servers,
2851
2879
  consented=consented,
2852
2880
  declined=declined,
2881
+ runner_limit=self._runner_limit.tools_grant,
2853
2882
  )
2854
2883
  granted = list(plan.granted)
2855
2884
  withheld = list(plan.withheld)
@@ -2904,7 +2933,8 @@ class RunnerRalphActivities:
2904
2933
  proxy variables.
2905
2934
  """
2906
2935
 
2907
- needs_token = bool(state.egress_allow_list) or any(
2936
+ denied_networks = self._runner_limit.networks
2937
+ needs_token = bool(state.egress_allow_list or denied_networks) or any(
2908
2938
  spec.credential_reference is not None for spec, _ in plan.granted
2909
2939
  )
2910
2940
  if needs_token and CALLBACK_TOKEN_ENV not in env:
@@ -2936,6 +2966,7 @@ class RunnerRalphActivities:
2936
2966
  )
2937
2967
  entries.append(entry_for(spec, hosted_url=hosted.url, bearer_env=CALLBACK_TOKEN_ENV))
2938
2968
 
2969
+ await self._refuse_denied_destinations(work_record_id, state, entries, env)
2939
2970
  allow_list: tuple[str, ...] = ()
2940
2971
  if state.egress_allow_list:
2941
2972
  allow_list = tuple(
@@ -2959,17 +2990,27 @@ class RunnerRalphActivities:
2959
2990
  )
2960
2991
  )
2961
2992
 
2962
- async def refused(host: str, port: int) -> None:
2993
+ if state.egress_allow_list or denied_networks:
2994
+
2995
+ async def refused(host: str, port: int, limit_entry: int | None) -> None:
2996
+ payload: dict[str, object] = {"event": "egress.refused", "host": host, "port": port}
2997
+ if limit_entry is not None:
2998
+ payload.update(
2999
+ deciding_link=GrantLink.RUNNER_LIMIT.value,
3000
+ entry=f"networks[{limit_entry}]",
3001
+ )
2963
3002
  await self._fastapi_client.append_evidence(
2964
- work_record_id,
2965
- source=EGRESS_REFUSED_SOURCE,
2966
- payload={"event": "egress.refused", "host": host, "port": port},
3003
+ work_record_id, source=EGRESS_REFUSED_SOURCE, payload=payload
2967
3004
  )
2968
3005
 
3006
+ # No Profile allow-list leaves the proxy only the Runner Limit to refuse with:
3007
+ # `allow_list` stays empty, so a runtime that opens the network on a Profile
3008
+ # list (Codex) keeps it shut exactly as before.
2969
3009
  proxy = await stack.enter_async_context(
2970
3010
  EgressProxy(
2971
- allow_list,
3011
+ allow_list if state.egress_allow_list else None,
2972
3012
  token=env[CALLBACK_TOKEN_ENV],
3013
+ deny_list=denied_networks,
2973
3014
  resolver=self._egress_resolver,
2974
3015
  on_refused=refused,
2975
3016
  )
@@ -2988,6 +3029,54 @@ class RunnerRalphActivities:
2988
3029
  )
2989
3030
  return (tuple(entries) if state.mcp_servers else None), allow_list
2990
3031
 
3032
+ async def _refuse_denied_destinations(
3033
+ self,
3034
+ work_record_id: str,
3035
+ state: _RuntimeContextState,
3036
+ entries: Sequence[McpServerEntry],
3037
+ env: Mapping[str, str],
3038
+ ) -> None:
3039
+ """Fail the Directive at its start when something it needs is denied (§54).
3040
+
3041
+ The git remote, each granted MCP server's URL and each LLM endpoint: denied, the
3042
+ Directive could only fail half-way, so it fails now, non-retryably, naming the
3043
+ entry by its position and the destination it needed.
3044
+ """
3045
+
3046
+ if not self._runner_limit.networks:
3047
+ return
3048
+ needed = [
3049
+ ("git remote", _github_remote_url(state.repository)),
3050
+ *((f"MCP server {entry.slug}", entry.url) for entry in entries if entry.url),
3051
+ *(("LLM endpoint", value) for name, value in env.items() if name.endswith("_BASE_URL")),
3052
+ ]
3053
+ for needed_for, url in needed:
3054
+ destination = url_destination(url)
3055
+ if destination is None:
3056
+ continue
3057
+ index = destination_denied(self._runner_limit.networks, *destination)
3058
+ if index is None:
3059
+ continue
3060
+ await _raise_recorded(
3061
+ ApplicationError(
3062
+ f"this Runner's Runner Limit networks[{index}] denies {destination[0]}, "
3063
+ f"which this Directive's {needed_for} needs",
3064
+ non_retryable=True,
3065
+ ),
3066
+ self._fastapi_client,
3067
+ work_record_id,
3068
+ source=RUNNER_LIMIT_EVIDENCE_SOURCE,
3069
+ actor=self._actor(),
3070
+ payload={
3071
+ "event": "runner_limit.destination_denied",
3072
+ "deciding_link": GrantLink.RUNNER_LIMIT.value,
3073
+ "entry": f"networks[{index}]",
3074
+ "host": destination[0],
3075
+ "port": destination[1],
3076
+ "needed_for": needed_for,
3077
+ },
3078
+ )
3079
+
2991
3080
  async def _resolve_credentials(
2992
3081
  self,
2993
3082
  work_record_id: str,
@@ -3832,6 +3921,7 @@ class RunnerRalphActivities:
3832
3921
  verb=verb,
3833
3922
  identifier=identifier,
3834
3923
  resource_type=resource_type,
3924
+ runner_limit=self._runner_limit.tools_grant,
3835
3925
  )
3836
3926
  evidence = decision.evidence()
3837
3927
  refusal = (
@@ -11,17 +11,26 @@ operator-typed command — the Directive is dispatched, not scripted — and its
11
11
  namespace and task queues are delivered at bootstrap and never typed. ``WorkerSettings``
12
12
  remains the pod's env-only surface for the Temporal/FastAPI wiring; this is the surface a
13
13
  workstation Runner (PRD issue 47) and the Helm chart (issue 46) actually fill.
14
+
15
+ ``limits`` is the Runner Limit (ADR-0022 §57): a ``[limits]`` table in the file, or JSON in
16
+ ``AGENTIC_RUNNER_LIMITS`` (what the chart's ``limits`` value renders to). One that is present
17
+ but does not parse raises here, and the Runner refuses to start: never an empty limit in
18
+ its place, which would let the host believe a limit holds that does not.
14
19
  """
15
20
 
16
21
  from __future__ import annotations
17
22
 
23
+ import json
18
24
  import os
19
25
  import tomllib
20
26
  from collections.abc import Mapping
21
27
  from pathlib import Path
22
28
  from typing import Any, Final
23
29
 
24
- from pydantic import BaseModel, ConfigDict
30
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
31
+
32
+ from agentic_runner.runner_limit import expand_home
33
+ from agentic_runner_contracts.runner_limit import RunnerLimit
25
34
 
26
35
  __all__ = ["CONFIG_FILE_ENV", "DEFAULT_CONFIG_FILE", "ENV_PREFIX", "RunnerConfig", "load"]
27
36
 
@@ -57,6 +66,21 @@ class RunnerConfig(BaseModel):
57
66
  openai_base_url: str | None = None
58
67
  anthropic_base_url: str | None = None
59
68
  log_level: str = "info"
69
+ # What this Runner's host denies every Agent on it (ADR-0022 §52); empty narrows nothing.
70
+ limits: RunnerLimit = Field(default_factory=RunnerLimit)
71
+
72
+ @field_validator("limits", mode="before")
73
+ @classmethod
74
+ def _expand_folder_homes(cls, value: object) -> object:
75
+ # A folder entry must be absolute (the contracts model), and `~/clients` is what a
76
+ # person writes: expanded here, against the Runner's own home, before that check.
77
+ if isinstance(value, Mapping) and isinstance(value.get("folders"), list | tuple):
78
+ folders = [
79
+ expand_home(folder) if isinstance(folder, str) else folder
80
+ for folder in value["folders"]
81
+ ]
82
+ return {**value, "folders": folders}
83
+ return value
60
84
 
61
85
 
62
86
  def load(
@@ -103,8 +127,14 @@ def _from_file(path: Path) -> dict[str, Any]:
103
127
 
104
128
 
105
129
  def _from_env(env: Mapping[str, str]) -> dict[str, Any]:
106
- return {
130
+ values: dict[str, Any] = {
107
131
  field: env[f"{ENV_PREFIX}{field.upper()}"]
108
132
  for field in RunnerConfig.model_fields
109
133
  if env.get(f"{ENV_PREFIX}{field.upper()}", "").strip()
110
134
  }
135
+ if "limits" in values:
136
+ try:
137
+ values["limits"] = json.loads(values["limits"])
138
+ except ValueError as error:
139
+ raise ValueError(f"{ENV_PREFIX}LIMITS is not JSON: {error}") from None
140
+ return values
@@ -13,6 +13,11 @@ What this is *not* is a kernel boundary. A process that ignores the proxy variab
13
13
  opens a raw socket is stopped only by what the host adds underneath (a pod
14
14
  NetworkPolicy); a workstation adds nothing, which is why it reports its posture as
15
15
  ``unenforced`` in the heartbeat rather than claiming the list binds.
16
+
17
+ The Runner Limit's ``networks`` part (ADR-0022 §54) is a deny-list in the same syntax, read
18
+ by the same proxy after the allow-list: a destination passes only if the Profile allows it
19
+ and no entry denies it. Both lists are compared through :func:`parse_destination`, so a
20
+ case, a trailing dot, a port or an IP literal's spelling means the same in either.
16
21
  """
17
22
 
18
23
  from __future__ import annotations
@@ -21,7 +26,10 @@ import asyncio
21
26
  import base64
22
27
  import contextlib
23
28
  import hmac
24
- from collections.abc import Awaitable, Callable, Iterable
29
+ import ipaddress
30
+ import re
31
+ import socket
32
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
25
33
  from fnmatch import fnmatchcase
26
34
  from types import TracebackType
27
35
  from typing import Final
@@ -35,7 +43,10 @@ __all__ = [
35
43
  "EgressProxy",
36
44
  "Resolver",
37
45
  "destination_allowed",
46
+ "destination_denied",
38
47
  "host_of",
48
+ "parse_destination",
49
+ "url_destination",
39
50
  ]
40
51
 
41
52
  EGRESS_REFUSED_SOURCE: Final[str] = "runner.egress_refused"
@@ -60,6 +71,7 @@ EGRESS_ENV_NAMES: Final[frozenset[str]] = frozenset(
60
71
  _LOOPBACK: Final[str] = "127.0.0.1,localhost,::1"
61
72
  _PROXY_USER: Final[str] = "agentic"
62
73
  _PIPE_CHUNK: Final[int] = 65536
74
+ _INET_ATON_CHARS: Final[re.Pattern[str]] = re.compile(r"[0-9a-fx.]+")
63
75
 
64
76
  Resolver = Callable[[str, int], Awaitable[tuple[str, int]]]
65
77
 
@@ -70,17 +82,91 @@ async def _system_resolver(host: str, port: int) -> tuple[str, int]:
70
82
  return str(address[0]), int(address[1])
71
83
 
72
84
 
85
+ def normalise_host(host: str) -> str:
86
+ """A host as both lists compare it: lower case, no brackets, no trailing dot, and an IP
87
+ literal in its one canonical spelling (an IPv4-mapped IPv6 address as the IPv4 one)."""
88
+
89
+ host = host.strip().lower().strip("[]").rstrip(".")
90
+ try:
91
+ address = ipaddress.ip_address(host)
92
+ except ValueError:
93
+ return _inet_aton_spelling(host) or host
94
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
95
+ return str(address.ipv4_mapped)
96
+ return address.compressed
97
+
98
+
99
+ def _inet_aton_spelling(host: str) -> str | None:
100
+ """The dotted quad for an IPv4 address in a spelling ``ipaddress`` refuses and
101
+ ``getaddrinfo`` still connects to (``167772165``, ``0xa.0.0.5``, ``10.5``, ``012.0.0.5``):
102
+ left as a name, it would walk past a deny entry for the address it reaches."""
103
+
104
+ # Only the digits, hex and dots inet_aton parses: glibc's also accepts trailing garbage.
105
+ if not _INET_ATON_CHARS.fullmatch(host):
106
+ return None
107
+ try:
108
+ return socket.inet_ntoa(socket.inet_aton(host))
109
+ except OSError:
110
+ return None
111
+
112
+
113
+ def parse_destination(entry: str) -> tuple[str, int | None]:
114
+ """A ``network_policy`` destination as ``(host pattern, port)``; None admits any port.
115
+
116
+ ``host``, ``host:port``, ``*.domain``, ``[v6]:port`` or a bare IPv6 literal. Raises
117
+ ``ValueError`` for an entry whose port is not one.
118
+ """
119
+
120
+ text = entry.strip()
121
+ if text.startswith("["):
122
+ host, closed, rest = text[1:].partition("]")
123
+ if not closed or (rest and not rest.startswith(":")):
124
+ raise ValueError(f"{entry!r} is not a destination")
125
+ raw_port: str | None = rest[1:] if rest else None
126
+ elif text.count(":") == 1:
127
+ host, _, raw_port = text.partition(":")
128
+ else:
129
+ # A bare host, or an IPv6 literal written without brackets and so without a port.
130
+ host, raw_port = text, None
131
+ if not host.strip():
132
+ raise ValueError(f"{entry!r} names no host")
133
+ if raw_port is None:
134
+ return normalise_host(host), None
135
+ if not raw_port.isdigit() or not 0 < int(raw_port) < 65536:
136
+ raise ValueError(f"{entry!r} has a port that is not a number from 1 to 65535")
137
+ return normalise_host(host), int(raw_port)
138
+
139
+
140
+ def _matches(entry: str, host: str, port: int) -> bool:
141
+ try:
142
+ pattern, entry_port = parse_destination(entry)
143
+ except ValueError:
144
+ # Never a match, as before parsing: an allow-list entry that is not a destination
145
+ # admits nothing, and the Runner Limit refuses to start on one (`runner_limit`).
146
+ return False
147
+ if entry_port is not None and entry_port != port:
148
+ return False
149
+ return fnmatchcase(host, pattern)
150
+
151
+
73
152
  def destination_allowed(allow_list: Iterable[str], host: str, port: int) -> bool:
74
153
  """``host``, ``host:port`` or a ``*.domain`` pattern; a bare host admits any port."""
75
154
 
76
- host = host.lower().strip("[]")
77
- for entry in allow_list:
78
- pattern, _, entry_port = entry.lower().partition(":")
79
- if entry_port and entry_port != str(port):
80
- continue
81
- if fnmatchcase(host, pattern):
82
- return True
83
- return False
155
+ host = normalise_host(host)
156
+ return any(_matches(entry, host, port) for entry in allow_list)
157
+
158
+
159
+ def destination_denied(deny_list: Sequence[str], host: str, port: int) -> int | None:
160
+ """The position of the first Runner Limit ``networks`` entry denying this, or None.
161
+
162
+ A hostname entry denies that name only: the proxy compares what the Directive asked
163
+ for, never what it resolves to (ADR-0022 §54).
164
+ """
165
+
166
+ host = normalise_host(host)
167
+ return next(
168
+ (index for index, entry in enumerate(deny_list) if _matches(entry, host, port)), None
169
+ )
84
170
 
85
171
 
86
172
  def host_of(url: str) -> str | None:
@@ -92,19 +178,35 @@ def host_of(url: str) -> str | None:
92
178
  return parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
93
179
 
94
180
 
181
+ def url_destination(url: str) -> tuple[str, int] | None:
182
+ """Where a URL connects: its host and its port, the scheme's default when it names none."""
183
+
184
+ parts = urlsplit(url)
185
+ if not parts.hostname:
186
+ return None
187
+ try:
188
+ port = parts.port
189
+ except ValueError:
190
+ return None
191
+ return parts.hostname, port or (80 if parts.scheme in {"http", "ws"} else 443)
192
+
193
+
95
194
  class EgressProxy:
96
195
  """One attempt's forward proxy: CONNECT for TLS, absolute-form for plain HTTP."""
97
196
 
98
197
  def __init__(
99
198
  self,
100
- allow_list: Iterable[str],
199
+ allow_list: Iterable[str] | None,
101
200
  *,
102
201
  token: str,
202
+ deny_list: Sequence[str] = (),
103
203
  resolver: Resolver | None = None,
104
- on_refused: Callable[[str, int], Awaitable[None]] | None = None,
204
+ on_refused: Callable[[str, int, int | None], Awaitable[None]] | None = None,
105
205
  host: str = "127.0.0.1",
106
206
  ) -> None:
107
- self.allow_list = tuple(dict.fromkeys(allow_list))
207
+ # None: no Profile allow-list, so only the Runner Limit's `networks` can refuse.
208
+ self.allow_list = None if allow_list is None else tuple(dict.fromkeys(allow_list))
209
+ self.deny_list = tuple(deny_list)
108
210
  self._token = token
109
211
  self._resolver = resolver or _system_resolver
110
212
  self._on_refused = on_refused
@@ -148,9 +250,13 @@ class EgressProxy:
148
250
  await self._reply(writer, 400)
149
251
  return
150
252
  host, port = destination
151
- if not destination_allowed(self.allow_list, host, port):
253
+ # The Profile's list first, as the Grant is named before the Runner Limit: a
254
+ # destination both refuse is refused for the reason no other Runner would lift.
255
+ allowed = self.allow_list is None or destination_allowed(self.allow_list, host, port)
256
+ denied_by = None if not allowed else destination_denied(self.deny_list, host, port)
257
+ if not allowed or denied_by is not None:
152
258
  if self._on_refused is not None:
153
- await self._on_refused(host, port)
259
+ await self._on_refused(host, port, denied_by)
154
260
  await self._reply(writer, 403)
155
261
  return
156
262
  try:
@@ -31,6 +31,7 @@ from agentic_runner.tiny_http import bearer_matches, read_request, response_head
31
31
  from agentic_runner.workers.mcp_config import McpServerEntry
32
32
  from agentic_runner_contracts.grants import (
33
33
  Decision,
34
+ Grant,
34
35
  GrantSnapshot,
35
36
  McpServerDecision,
36
37
  decide_mcp_server,
@@ -94,19 +95,24 @@ def plan_mcp(
94
95
  *,
95
96
  consented: Collection[str] = (),
96
97
  declined: Collection[str] = (),
98
+ runner_limit: Grant | None = None,
97
99
  ) -> McpPlan:
98
100
  """Decide every bound server against the Effective Grant.
99
101
 
100
102
  ``consented`` / ``declined`` are the ``mcp:<server>`` names the owner has already
101
103
  answered for this step; anything else that evaluates ``confirm`` is left in
102
104
  ``to_confirm`` for the caller to ask about before a single server is started.
105
+ ``runner_limit`` is the Runner Limit's tools part: a server it denies is withheld,
106
+ never started (ADR-0022 §53).
103
107
  """
104
108
 
105
109
  granted: list[tuple[McpServerSpec, McpServerDecision]] = []
106
110
  withheld: list[tuple[McpServerSpec, McpServerDecision, str]] = []
107
111
  to_confirm: list[tuple[McpServerSpec, McpServerDecision]] = []
108
112
  for spec in servers:
109
- decision = decide_mcp_server(snapshot, server=spec.slug, tools=spec.tools)
113
+ decision = decide_mcp_server(
114
+ snapshot, server=spec.slug, tools=spec.tools, runner_limit=runner_limit
115
+ )
110
116
  confirm = decision.decision is Decision.CONFIRM
111
117
  if decision.decision is Decision.ALLOW or (confirm and decision.verb in consented):
112
118
  granted.append((spec, decision))
@@ -46,6 +46,7 @@ from agentic_runner_contracts.runner_registration import (
46
46
  HeartbeatEnvelope,
47
47
  IsolationMode,
48
48
  RecipientKey,
49
+ RunnerLimitReport,
49
50
  )
50
51
  from agentic_runner_contracts.runner_signature import (
51
52
  RUNNER_ID_HEADER,
@@ -258,6 +259,7 @@ class RunnerRegistrationClient:
258
259
  recipient_key: RecipientKey,
259
260
  max_concurrent_directives: int = 1,
260
261
  can_separate_uids: bool,
262
+ runner_limit: RunnerLimitReport | None = None,
261
263
  ) -> tuple[RunnerState, BootstrapResponse]:
262
264
  """Exchange the Runner Registration Token for this process's durable identity."""
263
265
 
@@ -271,6 +273,7 @@ class RunnerRegistrationClient:
271
273
  recipient_key=recipient_key,
272
274
  max_concurrent_directives=max_concurrent_directives,
273
275
  build_id=build_id(),
276
+ runner_limit=runner_limit,
274
277
  )
275
278
  payload = await self._post(BOOTSTRAP_PATH, content=request.model_dump_json(), headers={})
276
279
  response = BootstrapResponse.model_validate(payload)