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.
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/PKG-INFO +2 -2
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/pyproject.toml +1 -1
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/activities.py +100 -10
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/config.py +32 -2
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/egress.py +120 -14
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/mcp.py +7 -1
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/registration.py +3 -0
- agentic_runner-3.22.0/src/agentic_runner/runner_limit.py +342 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/service.py +95 -8
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/acp_runtime.py +87 -6
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/command_policy.py +13 -1
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workstation.py +8 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/.gitignore +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/LICENSE +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/README.md +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/launch.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/oauth_connectors.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/skill.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/usage_windows.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.21.0 → agentic_runner-3.22.0}/src/agentic_runner/workers/settings.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -77,7 +77,14 @@ from agentic_runner.credentials import (
|
|
|
77
77
|
EmptyCredentialStore,
|
|
78
78
|
UnresolvableCredentialReferenceError,
|
|
79
79
|
)
|
|
80
|
-
from agentic_runner.egress import
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
for entry in allow_list
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|