computer-agent-py 0.2.2__tar.gz → 0.3.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 (53) hide show
  1. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/PKG-INFO +4 -4
  2. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/README.md +3 -3
  3. computer_agent_py-0.3.0/pyproject.toml +177 -0
  4. computer_agent_py-0.2.2/pyproject.toml → computer_agent_py-0.3.0/pyproject.toml.orig +1 -1
  5. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/__init__.py +3 -1
  6. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/_proxy/query.py +2 -2
  7. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/engines/claude_agent.py +85 -1
  8. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/__init__.py +27 -0
  9. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/coordinator.py +76 -0
  10. computer_agent_py-0.3.0/src/computeragent/harness/fork.py +409 -0
  11. computer_agent_py-0.3.0/src/computeragent/identity/gap.py +411 -0
  12. computer_agent_py-0.3.0/src/computeragent/identity/gap_hooks.py +169 -0
  13. computer_agent_py-0.3.0/src/computeragent/identity/gap_subagents.py +137 -0
  14. computer_agent_py-0.3.0/src/computeragent/identity/gap_tools.py +243 -0
  15. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/cedar.py +2 -2
  16. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/srs.py +11 -2
  17. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/protocol_types/__init__.py +7 -0
  18. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/event.py +7 -0
  19. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/sinks/agentos_http.py +5 -0
  20. computer_agent_py-0.2.2/src/computeragent/identity/gap.py +0 -246
  21. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/_proxy/__init__.py +0 -0
  22. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/_proxy/client.py +0 -0
  23. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/agentos.py +0 -0
  24. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/engines/__init__.py +0 -0
  25. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/engines/git_agent.py +0 -0
  26. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/agent_resolve_client.py +0 -0
  27. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/cas_client.py +0 -0
  28. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/errors.py +0 -0
  29. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/git_credential_client.py +0 -0
  30. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/protocol.py +0 -0
  31. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/remote.py +0 -0
  32. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/harness/usage.py +0 -0
  33. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/identity/__init__.py +0 -0
  34. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/identity/passthrough.py +0 -0
  35. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/__init__.py +0 -0
  36. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/authorizer.py +0 -0
  37. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/opa.py +0 -0
  38. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/policy/types.py +0 -0
  39. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/py.typed +0 -0
  40. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/session_stores/__init__.py +0 -0
  41. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/session_stores/memory.py +0 -0
  42. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/substrates/__init__.py +0 -0
  43. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/substrates/local.py +0 -0
  44. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/__init__.py +0 -0
  45. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/config.py +0 -0
  46. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/middleware/__init__.py +0 -0
  47. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/middleware/guardrails.py +0 -0
  48. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/middleware/pii.py +0 -0
  49. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/pipeline.py +0 -0
  50. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/sinks/__init__.py +0 -0
  51. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/sinks/content.py +0 -0
  52. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/telemetry/sinks/otel.py +0 -0
  53. {computer_agent_py-0.2.2 → computer_agent_py-0.3.0}/src/computeragent/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: computer-agent-py
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks.
5
5
  Keywords: computeragent,claude-agent-sdk,claude,agent,telemetry,otel,opentelemetry,agentos,pii
6
6
  Author: Abhi Bhat
@@ -509,7 +509,7 @@ configure(
509
509
  PiiRedactor(strategy="hash", extra_patterns=[r"BADGE-\d{6}"]),
510
510
  GuardrailFilter(
511
511
  max_attribute_length=4096,
512
- tool_name_allowlist={"Read", "Glob", "Grep", "mcp__nordassist-tools__*"},
512
+ tool_name_allowlist={"Read", "Glob", "Grep", "mcp__acme-assist-tools__*"},
513
513
  cost_ceiling_usd=1.50,
514
514
  ),
515
515
  ],
@@ -551,7 +551,7 @@ opa = OpaPolicyEngine(
551
551
  authorizer = PolicyToolAuthorizer(
552
552
  engine=opa,
553
553
  principal_resolver=lambda ctx: PolicyPrincipal(id="alice", groups=["engineer"]),
554
- resource_resolver=lambda ctx: PolicyResource(agent_name="nordassist", model="claude-sonnet-4-5"),
554
+ resource_resolver=lambda ctx: PolicyResource(agent_name="acme-assist", model="claude-sonnet-4-5"),
555
555
  context_resolver=lambda ctx: {"env": "prod"},
556
556
  )
557
557
 
@@ -650,7 +650,7 @@ The proxy is a pure tap — messages are never modified or reordered. Sinks run
650
650
 
651
651
  | File | Demonstrates |
652
652
  |---|---|
653
- | [`examples/pdf_drop_in.py`](examples/pdf_drop_in.py) | The minimum drop-in change |
653
+ | [`examples/drop_in.py`](examples/drop_in.py) | The minimum drop-in change |
654
654
  | [`examples/with_otel.py`](examples/with_otel.py) | OTel pointed at a local collector |
655
655
  | [`examples/with_new_relic.py`](examples/with_new_relic.py) | OTel pointed at New Relic (just env vars) |
656
656
  | [`examples/with_datadog.py`](examples/with_datadog.py) | OTel pointed at Datadog |
@@ -452,7 +452,7 @@ configure(
452
452
  PiiRedactor(strategy="hash", extra_patterns=[r"BADGE-\d{6}"]),
453
453
  GuardrailFilter(
454
454
  max_attribute_length=4096,
455
- tool_name_allowlist={"Read", "Glob", "Grep", "mcp__nordassist-tools__*"},
455
+ tool_name_allowlist={"Read", "Glob", "Grep", "mcp__acme-assist-tools__*"},
456
456
  cost_ceiling_usd=1.50,
457
457
  ),
458
458
  ],
@@ -494,7 +494,7 @@ opa = OpaPolicyEngine(
494
494
  authorizer = PolicyToolAuthorizer(
495
495
  engine=opa,
496
496
  principal_resolver=lambda ctx: PolicyPrincipal(id="alice", groups=["engineer"]),
497
- resource_resolver=lambda ctx: PolicyResource(agent_name="nordassist", model="claude-sonnet-4-5"),
497
+ resource_resolver=lambda ctx: PolicyResource(agent_name="acme-assist", model="claude-sonnet-4-5"),
498
498
  context_resolver=lambda ctx: {"env": "prod"},
499
499
  )
500
500
 
@@ -593,7 +593,7 @@ The proxy is a pure tap — messages are never modified or reordered. Sinks run
593
593
 
594
594
  | File | Demonstrates |
595
595
  |---|---|
596
- | [`examples/pdf_drop_in.py`](examples/pdf_drop_in.py) | The minimum drop-in change |
596
+ | [`examples/drop_in.py`](examples/drop_in.py) | The minimum drop-in change |
597
597
  | [`examples/with_otel.py`](examples/with_otel.py) | OTel pointed at a local collector |
598
598
  | [`examples/with_new_relic.py`](examples/with_new_relic.py) | OTel pointed at New Relic (just env vars) |
599
599
  | [`examples/with_datadog.py`](examples/with_datadog.py) | OTel pointed at Datadog |
@@ -0,0 +1,177 @@
1
+ [project]
2
+ name = "computer-agent-py"
3
+ version = "0.3.0"
4
+ description = "Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ keywords = [
8
+ "computeragent",
9
+ "claude-agent-sdk",
10
+ "claude",
11
+ "agent",
12
+ "telemetry",
13
+ "otel",
14
+ "opentelemetry",
15
+ "agentos",
16
+ "pii",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = [
32
+ "claude-agent-sdk>=0.2,<0.3",
33
+ "typing-extensions>=4.12",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "Abhi Bhat"
38
+ email = "abhishek.bhat@lyzr.ai"
39
+
40
+ [project.license]
41
+ text = "MIT"
42
+
43
+ [project.optional-dependencies]
44
+ otel = [
45
+ "opentelemetry-api>=1.27",
46
+ "opentelemetry-sdk>=1.27",
47
+ "opentelemetry-exporter-otlp>=1.27",
48
+ "opentelemetry-semantic-conventions>=0.48b0",
49
+ ]
50
+ agentos = ["httpx>=0.27"]
51
+ remote = ["httpx>=0.27"]
52
+ cedar = ["cedarpy>=4,<5"]
53
+ gitagent = [
54
+ "openai>=1,<2",
55
+ "anthropic>=0.40,<1",
56
+ "pyyaml>=6,<7",
57
+ ]
58
+ gap = ["pyyaml>=6,<7"]
59
+ sqlite = ["aiosqlite>=0.20,<1"]
60
+ all = [
61
+ "opentelemetry-api>=1.27",
62
+ "opentelemetry-sdk>=1.27",
63
+ "opentelemetry-exporter-otlp>=1.27",
64
+ "opentelemetry-semantic-conventions>=0.48b0",
65
+ "httpx>=0.27",
66
+ "cedarpy>=4,<5",
67
+ "openai>=1,<2",
68
+ "anthropic>=0.40,<1",
69
+ "pyyaml>=6,<7",
70
+ "aiosqlite>=0.20,<1",
71
+ ]
72
+
73
+ [project.urls]
74
+ Homepage = "https://github.com/open-gitagent/computer-agent-py"
75
+ Issues = "https://github.com/open-gitagent/computer-agent-py/issues"
76
+ Changelog = "https://github.com/open-gitagent/computer-agent-py/blob/main/CHANGELOG.md"
77
+
78
+ [build-system]
79
+ requires = ["uv_build>=0.9.24,<0.10.0"]
80
+ build-backend = "uv_build"
81
+
82
+ [tool.uv.build-backend]
83
+ module-name = "computeragent"
84
+
85
+ [tool.ruff]
86
+ line-length = 100
87
+ target-version = "py310"
88
+ src = [
89
+ "src",
90
+ "tests",
91
+ ]
92
+
93
+ [tool.ruff.lint]
94
+ select = [
95
+ "E",
96
+ "F",
97
+ "W",
98
+ "I",
99
+ "UP",
100
+ "B",
101
+ "ASYNC",
102
+ "RET",
103
+ "SIM",
104
+ "TCH",
105
+ ]
106
+ ignore = ["E501"]
107
+
108
+ [tool.ruff.lint.per-file-ignores]
109
+ "tests/**" = [
110
+ "E501",
111
+ "ASYNC",
112
+ "TC001",
113
+ "TC003",
114
+ ]
115
+ "examples/**" = ["E501"]
116
+ "src/computeragent/__init__.py" = [
117
+ "F401",
118
+ "F403",
119
+ ]
120
+ "src/computeragent/types.py" = [
121
+ "F401",
122
+ "F403",
123
+ ]
124
+ "src/computeragent/harness/**" = [
125
+ "TC001",
126
+ "TC003",
127
+ "SIM101",
128
+ ]
129
+ "src/computeragent/engines/**" = [
130
+ "TC001",
131
+ "TC003",
132
+ ]
133
+ "src/computeragent/substrates/**" = ["TC003"]
134
+ "src/computeragent/identity/**" = ["TC003"]
135
+ "src/computeragent/session_stores/**" = ["TC001"]
136
+ "src/computeragent/protocol_types/**" = ["TC003"]
137
+
138
+ [tool.mypy]
139
+ strict = true
140
+ python_version = "3.10"
141
+ files = ["src"]
142
+ ignore_missing_imports = true
143
+
144
+ [[tool.mypy.overrides]]
145
+ module = "tests.*"
146
+ disallow_untyped_defs = false
147
+ disallow_incomplete_defs = false
148
+
149
+ [tool.pytest.ini_options]
150
+ asyncio_mode = "auto"
151
+ testpaths = ["tests"]
152
+ addopts = [
153
+ "-ra",
154
+ "--strict-markers",
155
+ "--strict-config",
156
+ ]
157
+ markers = ["integration: requires real Claude CLI and credentials"]
158
+
159
+ [dependency-groups]
160
+ dev = [
161
+ "pytest>=8",
162
+ "pytest-asyncio>=0.23",
163
+ "pytest-cov>=5",
164
+ "respx>=0.21",
165
+ "ruff>=0.6",
166
+ "mypy>=1.11",
167
+ "httpx>=0.27",
168
+ "opentelemetry-api>=1.27",
169
+ "opentelemetry-sdk>=1.27",
170
+ "opentelemetry-exporter-otlp>=1.27",
171
+ "opentelemetry-semantic-conventions>=0.48b0",
172
+ "cedarpy>=4,<5",
173
+ "openai>=1,<2",
174
+ "anthropic>=0.40,<1",
175
+ "pyyaml>=6,<7",
176
+ "aiosqlite>=0.20,<1",
177
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "computer-agent-py"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks."
5
5
  authors = [
6
6
  { name = "Abhi Bhat", email = "abhishek.bhat@lyzr.ai" },
@@ -42,7 +42,7 @@ _upstream_all = [n for n in getattr(_cas, "__all__", []) if n != "__version__"]
42
42
  for _name in _upstream_all:
43
43
  globals()[_name] = getattr(_cas, _name)
44
44
 
45
- __version__ = "0.2.1"
45
+ __version__ = "0.3.0"
46
46
  upstream_version = getattr(_cas, "__version__", "unknown")
47
47
 
48
48
  # Override the two entry points with the proxied versions. Same signatures,
@@ -60,6 +60,7 @@ from .harness import ( # noqa: E402
60
60
  ChatInput,
61
61
  ChatResult,
62
62
  ComputerAgent,
63
+ ForkConfig,
63
64
  HarnessError,
64
65
  HarnessProtocolError,
65
66
  UnknownEngineError,
@@ -127,6 +128,7 @@ __all__ = sorted({*_upstream_all, "__version__"}) + [
127
128
  "PolicyResource",
128
129
  "PolicyResult",
129
130
  "PolicyToolAuthorizer",
131
+ "ForkConfig",
130
132
  "TelemetryEvent",
131
133
  "TelemetryMiddleware",
132
134
  "TelemetrySink",
@@ -169,8 +169,8 @@ def _derive_agent_name(options: Any) -> str | None:
169
169
  Strategy:
170
170
  1. Explicit ``agent_name`` attribute (not in upstream today but cheap to
171
171
  honour if a fork adds it).
172
- 2. First non-empty line of ``system_prompt`` (NordAssist's prod prompt
173
- starts with "You are NordAssist..." — that line is the natural label).
172
+ 2. First non-empty line of ``system_prompt`` (prod prompts typically
173
+ start with "You are <Name>..." — that line is the natural label).
174
174
  3. ``None`` — the sinks fall back to an anonymous hash.
175
175
  """
176
176
  if options is None:
@@ -105,7 +105,29 @@ def _build_options(ctx: EngineContext) -> Any:
105
105
  # carry one) so the resulting policy_decision telemetry correlates with
106
106
  # this session's open OTel spans instead of landing on "unknown".
107
107
  if ctx.policy_decider is not None:
108
- payload["can_use_tool"] = _bind_session_id(ctx.policy_decider, ctx.session_id)
108
+ bound = _bind_session_id(ctx.policy_decider, ctx.session_id)
109
+ payload["can_use_tool"] = bound
110
+ # Policy enforcement that survives prompt-skipping permission modes.
111
+ # ``can_use_tool`` only fires when the CLI's permission rules evaluate
112
+ # to "ask" — under bypassPermissions / dontAsk / auto (and for edits
113
+ # under acceptEdits) it is skipped entirely, which would leave a
114
+ # console-bound policy unenforced. A PreToolUse hook fires for EVERY
115
+ # tool call regardless of permission mode (SDK-documented), so route
116
+ # the same bound decider through one and translate a deny into the
117
+ # hook's deny decision — mirroring the TS engine. Registered only for
118
+ # the skipping modes so default-mode behavior (and telemetry volume)
119
+ # is unchanged. MERGED with GAP/caller-supplied hooks, never replacing.
120
+ if payload.get("permission_mode") in (
121
+ "bypassPermissions",
122
+ "dontAsk",
123
+ "auto",
124
+ "acceptEdits",
125
+ ):
126
+ hooks = dict(payload.get("hooks") or {})
127
+ pre = list(hooks.get("PreToolUse") or [])
128
+ pre.append(_policy_pretooluse_matcher(bound))
129
+ hooks["PreToolUse"] = pre
130
+ payload["hooks"] = hooks
109
131
 
110
132
  options = ClaudeAgentOptions(**payload)
111
133
  # Surface harness-supplied agent identity to the proxy's
@@ -119,6 +141,68 @@ def _build_options(ctx: EngineContext) -> Any:
119
141
  return options
120
142
 
121
143
 
144
+ def _policy_pretooluse_matcher(decider: Any) -> Any:
145
+ """Wrap the bound ``can_use_tool`` decider as a PreToolUse ``HookMatcher``.
146
+
147
+ The hook receives the SDK's PreToolUse input (``tool_name`` /
148
+ ``tool_input`` / ``tool_use_id``), runs the SAME decider that backs
149
+ ``can_use_tool``, and maps a deny onto the hook's
150
+ ``permissionDecision: "deny"`` — the only gate that fires under
151
+ prompt-skipping permission modes. An allow returns ``{}`` (no opinion).
152
+ An unexpected decider exception fails CLOSED (the authorizer's own
153
+ fail-open handling lives inside it; anything escaping is a bug).
154
+ """
155
+ from claude_agent_sdk.types import HookMatcher
156
+
157
+ async def _hook(hook_input: Any, _tool_use_id: str | None, _context: Any) -> dict[str, Any]:
158
+ data = hook_input if isinstance(hook_input, dict) else {}
159
+ tool_name = str(data.get("tool_name") or "")
160
+ tool_input = data.get("tool_input") or {}
161
+ try:
162
+ result = await decider(tool_name, tool_input, _permission_context())
163
+ except Exception as exc: # noqa: BLE001 — fail closed on decider bugs
164
+ logger.exception("policy PreToolUse hook: decider raised")
165
+ return _hook_deny(f"policy evaluation failed: {exc}")
166
+ behavior = getattr(result, "behavior", None)
167
+ if behavior is None and isinstance(result, dict):
168
+ behavior = result.get("behavior")
169
+ if behavior == "deny":
170
+ message = getattr(result, "message", None)
171
+ if message is None and isinstance(result, dict):
172
+ message = result.get("message")
173
+ return _hook_deny(str(message or "blocked by policy"))
174
+ return {}
175
+
176
+ return HookMatcher(hooks=[_hook])
177
+
178
+
179
+ def _permission_context() -> Any:
180
+ """A best-effort ``ToolPermissionContext`` for hook-originated decisions.
181
+
182
+ The hook path has no real permission context (the CLI never built one —
183
+ the call skipped the prompt), so hand the decider an empty one; the
184
+ ``_bind_session_id`` wrapper still stamps the session id onto it.
185
+ """
186
+ try:
187
+ from claude_agent_sdk.types import ToolPermissionContext
188
+
189
+ return ToolPermissionContext()
190
+ except Exception: # noqa: BLE001 — required fields / import drift
191
+ from types import SimpleNamespace
192
+
193
+ return SimpleNamespace()
194
+
195
+
196
+ def _hook_deny(reason: str) -> dict[str, Any]:
197
+ return {
198
+ "hookSpecificOutput": {
199
+ "hookEventName": "PreToolUse",
200
+ "permissionDecision": "deny",
201
+ "permissionDecisionReason": reason,
202
+ }
203
+ }
204
+
205
+
122
206
  def _bind_session_id(decider: Any, session_id: str) -> Any:
123
207
  """Wrap a ``can_use_tool`` decider so the permission context carries the
124
208
  harness session id. ``ToolPermissionContext`` is a plain dataclass, so the
@@ -204,6 +204,18 @@ class ComputerAgent:
204
204
  # id from the UI and paste it here. When omitted, the policy bound to
205
205
  # `agent_name` in AgentOS (the UI's Policy tab) is fetched and used.
206
206
  policy_id: str | None = None,
207
+ # ── process-fork (opt-in; OFF by default) ───────────────────────────
208
+ # ``fork_mode="manual"`` exposes the ``mcp__fork__spawn`` tool so the
209
+ # agent can spawn SEPARATE child ComputerAgent processes (each its own
210
+ # session/agent id). ``"auto"`` additionally disables the native
211
+ # in-process Task sub-agent and steers all delegation to the fork tool.
212
+ # ``"off"`` (default) = today's behavior, untouched.
213
+ fork_mode: str = "off",
214
+ fork_config: Any = None,
215
+ # Internal — set by the fork tool when THIS agent is itself a child.
216
+ parent_session_id: str | None = None,
217
+ parent_agent_id: str | None = None,
218
+ _fork_depth: int = 0,
207
219
  ) -> None:
208
220
  from .. import agentos as _agentos
209
221
 
@@ -366,6 +378,12 @@ class ComputerAgent:
366
378
 
367
379
  policy_decider = srs_authorizer_from_env(agent_name or "")
368
380
 
381
+ # Fork config: default one in when a mode is on but none supplied.
382
+ if fork_mode != "off" and fork_config is None:
383
+ from .fork import ForkConfig
384
+
385
+ fork_config = ForkConfig()
386
+
369
387
  self._coordinator = HarnessCoordinator(
370
388
  engine=resolved_engine,
371
389
  substrate=resolved_substrate,
@@ -386,6 +404,12 @@ class ComputerAgent:
386
404
  # layers these on top of whatever the identity loader resolves
387
405
  # so explicit caller kwargs always win.
388
406
  options_overlay=merged_options,
407
+ # Process-fork wiring (opt-in).
408
+ parent_session_id=parent_session_id,
409
+ parent_agent_id=parent_agent_id,
410
+ fork_mode=fork_mode,
411
+ fork_config=fork_config,
412
+ fork_depth=_fork_depth,
389
413
  )
390
414
 
391
415
  async def __aenter__(self) -> ComputerAgent:
@@ -517,11 +541,14 @@ async def run_task(
517
541
  return await agent.chat(message)
518
542
 
519
543
 
544
+ from .fork import ForkConfig # noqa: E402 (re-exported for callers)
545
+
520
546
  __all__ = [
521
547
  "ChatHandle",
522
548
  "ChatInput",
523
549
  "ChatResult",
524
550
  "ComputerAgent",
551
+ "ForkConfig",
525
552
  "HarnessError",
526
553
  "HarnessProtocolError",
527
554
  "UnknownEngineError",
@@ -92,6 +92,12 @@ class HarnessCoordinator:
92
92
  telemetry_pipeline: Pipeline | None = None,
93
93
  project_key: str = "default",
94
94
  options_overlay: dict[str, Any] | None = None,
95
+ # ── process-fork (opt-in; see harness/fork.py) ──────────────────────
96
+ parent_session_id: str | None = None,
97
+ parent_agent_id: str | None = None,
98
+ fork_mode: str = "off",
99
+ fork_config: Any = None,
100
+ fork_depth: int = 0,
95
101
  ) -> None:
96
102
  self._engine = engine
97
103
  self._substrate = substrate
@@ -105,6 +111,16 @@ class HarnessCoordinator:
105
111
  # Stable agent identifier (telemetry/registry only — never affects
106
112
  # execution). Stamped onto every emitted event via ``_emit``.
107
113
  self._agent_id = agent_id
114
+ # Process-fork linkage + config. ``parent_*`` are stamped onto every
115
+ # emitted event (like ``agent_id``) so a forked child links to its
116
+ # parent. The fork server (mcp__fork__spawn) is injected into the run
117
+ # options in ``_ensure_booted`` when ``fork_mode != "off"``.
118
+ self._parent_session_id = parent_session_id
119
+ self._parent_agent_id = parent_agent_id
120
+ self._fork_mode = fork_mode
121
+ self._fork_config = fork_config
122
+ self._fork_depth = fork_depth
123
+ self._fork_context: Any = None
108
124
  self._policy_decider = policy_decider
109
125
  self._on_permission_request_user = on_permission_request
110
126
  self._budget_max_usd = budget_max_usd
@@ -174,6 +190,54 @@ class HarnessCoordinator:
174
190
  from dataclasses import replace
175
191
 
176
192
  loaded = replace(loaded, options=merged_options)
193
+ # Loader-declared compliance hardening (e.g. GAP human-in-the-loop →
194
+ # permission mode) runs AFTER the caller overlay, so strictest-wins
195
+ # constraints hold even against caller-supplied options.
196
+ harden = getattr(loaded, "harden", None)
197
+ if callable(harden):
198
+ from dataclasses import replace
199
+
200
+ with suppress(Exception):
201
+ hardened = harden(dict(loaded.options or {}))
202
+ if isinstance(hardened, dict):
203
+ loaded = replace(loaded, options=hardened)
204
+ # ── process-fork tool injection (opt-in) ─────────────────────────────
205
+ # Injected HERE (loader-agnostic, after overlay+harden) rather than via
206
+ # the caller overlay — the overlay is a shallow ``dict.update`` and would
207
+ # CLOBBER the loader's ``mcp_servers`` (e.g. gap_tools). We deep-merge so
208
+ # the fork server coexists with gap_tools + agents. A child only gets the
209
+ # tool while under the depth cap.
210
+ if (
211
+ self._fork_mode != "off"
212
+ and self._fork_config is not None
213
+ and self._fork_depth < getattr(self._fork_config, "max_depth", 0)
214
+ ):
215
+ from dataclasses import replace
216
+
217
+ from .fork import ForkContext, append_steer, build_fork_mcp_server
218
+
219
+ self._fork_context = ForkContext(
220
+ self, self._fork_config, self._fork_depth, self._fork_mode
221
+ )
222
+ server, tool_names = build_fork_mcp_server(self._fork_context)
223
+ opts = dict(loaded.options or {})
224
+ mcp = dict(opts.get("mcp_servers") or {})
225
+ mcp["fork"] = server
226
+ opts["mcp_servers"] = mcp
227
+ # Only extend an EXISTING allowlist — introducing one where there was
228
+ # none would restrict the agent to just the fork tool. When no
229
+ # allowlist is set, all tools (incl. the fork MCP tool) are available.
230
+ if opts.get("allowed_tools"):
231
+ opts["allowed_tools"] = list(opts["allowed_tools"]) + list(tool_names)
232
+ if self._fork_mode == "auto":
233
+ # Disable native in-process sub-agents; steer delegation to fork.
234
+ opts["agents"] = {}
235
+ dis = list(opts.get("disallowed_tools") or [])
236
+ if "Task" not in dis:
237
+ dis.append("Task")
238
+ opts["disallowed_tools"] = dis
239
+ opts["system_prompt"] = append_steer(opts.get("system_prompt"))
240
+ loaded = replace(loaded, options=opts)
177
241
  self._manifest = loaded
178
242
 
179
243
  async def dispose(self) -> None:
@@ -186,6 +250,12 @@ class HarnessCoordinator:
186
250
  if self._lifecycle_started and self._lifecycle_session_id:
187
251
  with suppress(Exception):
188
252
  await self._emit_lifecycle_session_ended()
253
+ # Tear down any still-running forked children first — the SDK's atexit
254
+ # net kills orphaned CLI processes but NOT the children's temp
255
+ # workspaces, so an explicit dispose here prevents leaks.
256
+ if self._fork_context is not None:
257
+ with suppress(Exception):
258
+ await self._fork_context.teardown()
189
259
  if self._substrate_handle is not None:
190
260
  with suppress(Exception):
191
261
  await self._substrate_handle.close()
@@ -521,6 +591,12 @@ class HarnessCoordinator:
521
591
  hands off to the pipeline. ``TelemetryEvent`` is mutable by design."""
522
592
  if self._agent_id is not None:
523
593
  ev.agent_id = self._agent_id
594
+ # Fork linkage — stamp the parent ids so a child's events nest under
595
+ # the parent (None for a top-level agent → field stays None).
596
+ if self._parent_session_id is not None:
597
+ ev.parent_session_id = self._parent_session_id
598
+ if self._parent_agent_id is not None:
599
+ ev.parent_agent_id = self._parent_agent_id
524
600
  await self._pipeline.emit(ev)
525
601
 
526
602
  def _build_permission_resolver(