Skip to content

Commit 85759ba

Browse files
authored
Merge pull request #5046 from loopx-project/codex/steward-product-cycle-20260925
fix(manager): configure external delivery targets safely
2 parents 18ed2c1 + 640aaa1 commit 85759ba

4 files changed

Lines changed: 258 additions & 0 deletions

File tree

‎loopx/capabilities/manager_context/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ External channels need an owner-configured grant in
1616

1717
Use the actual connection channel and provider sender identity. Keep this file
1818
private (0600); do not commit it. Missing grants disable external delivery.
19+
For an existing channel with an authorized sender, use the local operator CLI
20+
to preview, grant, or revoke one registered recipient without editing the
21+
policy file by hand:
22+
23+
```sh
24+
loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker
25+
loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker --execute
26+
loopx manager-inbox revoke-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker --execute
27+
```
28+
29+
Pass the same `--registry` and `--runtime-root` used by the manager connection.
30+
Without `--execute`, these commands only preview the target and count change.
31+
Grant requires an active registered Goal and Agent, an existing sender-bound
32+
channel, and membership in any explicit audience Goal read scope. The command
33+
does not create a sender grant, launch the Agent, or grant protected-operation
34+
authority. Revocation also works when the former Agent is no longer registered.
1935
Remove a source/target grant to revoke future delivery, including replay attempts.
2036
Provider ingress receipts bind the current message digest, channel and sender;
2137
a model cannot create that provenance through its response.

‎loopx/capabilities/manager_context/__init__.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,103 @@ def configure_evidence_scope(runtime_root: Path, registry_path: Path, *, channel
291291
return {"ok": True, "executed": execute, "channel_id": channel,
292292
"evidence_goal_ids": ids, "scope": "audience_goal_summaries",
293293
"delegation_authority_changed": False}
294+
295+
296+
def configure_delivery_target(
297+
runtime_root: Path,
298+
registry_path: Path,
299+
*,
300+
channel: str,
301+
goal_id: str,
302+
agent_id: str,
303+
grant: bool,
304+
execute: bool = False,
305+
) -> dict:
306+
"""Preview or change one sender-bound recipient on an existing external channel."""
307+
if not re.fullmatch(r"manager\.external\.[a-f0-9]{24}", channel):
308+
raise ValueError("an exact external manager channel is required")
309+
if not goal_id or not agent_id:
310+
raise ValueError("an exact Goal and Agent are required")
311+
target = {"goal_id": goal_id, "agent_id": agent_id}
312+
313+
def is_target(item: dict) -> bool:
314+
return item.get("goal_id") == goal_id and item.get("agent_id") == agent_id
315+
316+
if grant:
317+
registry = load_registry(registry_path)
318+
goal = next(
319+
(g for g in registry.get("goals", []) if isinstance(g, dict) and g.get("id") == goal_id),
320+
None,
321+
)
322+
if (
323+
goal is None
324+
or goal_is_stopped(goal)
325+
or agent_id not in registered_agent_ids_for_goal(goal)
326+
):
327+
raise ValueError("delivery target must be a registered Agent in an active Goal")
328+
329+
path = _root(runtime_root) / "policy.json"
330+
331+
def update() -> dict:
332+
policy = _read(path)
333+
if policy.get("schema_version") != POLICY_SCHEMA or not isinstance(
334+
policy.get("sources"), dict
335+
):
336+
raise ValueError("invalid manager policy")
337+
source = policy["sources"].get(channel)
338+
if not isinstance(source, dict):
339+
raise ValueError("external manager channel must already be configured")
340+
senders = source.get("sender_ids")
341+
if grant and (
342+
not isinstance(senders, list)
343+
or not senders
344+
or any(not isinstance(sender, str) or not sender for sender in senders)
345+
):
346+
raise ValueError("external manager channel has no valid sender grant")
347+
if (
348+
grant
349+
and "evidence_goal_ids" in source
350+
and goal_id not in (evidence_goal_scope(runtime_root, channel) or [])
351+
):
352+
raise ValueError("target Goal is outside the channel read scope")
353+
targets = source.get("targets", [])
354+
if not isinstance(targets, list) or any(
355+
not isinstance(item, dict)
356+
or not isinstance(item.get("goal_id"), str)
357+
or not isinstance(item.get("agent_id"), str)
358+
for item in targets
359+
):
360+
raise ValueError("invalid external manager delivery targets")
361+
before = any(is_target(item) for item in targets)
362+
if grant:
363+
updated_targets = targets if before else [*targets, target]
364+
else:
365+
updated_targets = [item for item in targets if not is_target(item)]
366+
changed = updated_targets != targets
367+
if execute and changed:
368+
source["targets"] = updated_targets
369+
_write(path, policy)
370+
return {
371+
"ok": True,
372+
"executed": execute,
373+
"changed": changed if execute else False,
374+
"would_change": changed,
375+
"channel_id": channel,
376+
"target": target,
377+
"granted_before": before,
378+
"granted_after": grant,
379+
"existing_target_count": len(targets),
380+
"resulting_target_count": len(updated_targets),
381+
"scope": "sender_bound_context_delivery",
382+
"execution_started": False,
383+
}
384+
385+
if not execute:
386+
return update()
387+
with exclusive_file_lock(path.with_suffix(".lock")):
388+
result = update()
389+
saved = _read(path)
390+
saved_targets = saved.get("sources", {}).get(channel, {}).get("targets", [])
391+
if any(is_target(item) for item in saved_targets) != grant:
392+
raise ValueError("delivery target verification failed")
393+
return {**result, "readback_verified": True}

‎loopx/cli_commands/manager_inbox.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ..history import load_registry
77
from ..capabilities.manager_context import (
88
acknowledge,
9+
configure_delivery_target,
910
configure_evidence_scope,
1011
)
1112

@@ -28,6 +29,8 @@ def register_manager_inbox(subparsers, add_format):
2829
"status",
2930
"configure-read-scope",
3031
"configure-ssh-read-scope",
32+
"grant-delivery-target",
33+
"revoke-delivery-target",
3134
),
3235
)
3336
parser.add_argument("--peer-agent-id", help="For request: a registered peer of the same Goal.")
@@ -75,6 +78,18 @@ def handle_manager_inbox(args, registry_path, runtime_root):
7578
)
7679
print(json.dumps(result, ensure_ascii=False, indent=2))
7780
return 0
81+
if args.manager_inbox_action in {"grant-delivery-target", "revoke-delivery-target"}:
82+
result = configure_delivery_target(
83+
runtime_root,
84+
registry_path,
85+
channel=args.channel_id or "",
86+
goal_id=args.goal_id or "",
87+
agent_id=args.agent_id or "",
88+
grant=args.manager_inbox_action == "grant-delivery-target",
89+
execute=args.execute,
90+
)
91+
print(json.dumps(result, ensure_ascii=False, indent=2))
92+
return 0
7893
registry = load_registry(registry_path)
7994
goal = next(
8095
(g for g in registry.get("goals", []) if g.get("id") == args.goal_id), None

‎tests/test_manager_context_handoff.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import json
2+
import subprocess
3+
import sys
24
from concurrent.futures import ThreadPoolExecutor
35

46
import pytest
@@ -9,6 +11,7 @@
911
_write,
1012
acknowledge,
1113
authority,
14+
configure_delivery_target,
1215
deliver,
1316
pending,
1417
register_ingress,
@@ -226,6 +229,130 @@ def test_external_authority_requires_exact_sender_source_and_recipient(fixture):
226229
assert receipt["status"] == "delivered"
227230

228231

232+
def test_operator_delivery_target_preview_grant_revoke_and_live_authority(fixture):
233+
root, registry, session, turn, request = fixture
234+
channel = "manager.external." + "a" * 24
235+
session["channel_id"] = channel
236+
turn["origin"] = "lark"
237+
other = {"goal_id": "other", "agent_id": "peer"}
238+
policy_path = _root(root) / "policy.json"
239+
_write(policy_path, {
240+
"schema_version": POLICY_SCHEMA,
241+
"sources": {channel: {
242+
"sender_ids": ["owner"], "targets": [other],
243+
"evidence_goal_ids": ["research", "other"],
244+
"evidence_ssh_hosts": {"example-host": ["research"]},
245+
}},
246+
})
247+
before = policy_path.read_bytes()
248+
preview = configure_delivery_target(
249+
root, registry, channel=channel, **request, grant=True
250+
)
251+
assert preview["would_change"] and not preview["executed"]
252+
assert preview["resulting_target_count"] == 2
253+
assert policy_path.read_bytes() == before
254+
255+
register_ingress(
256+
root, session_id=session["session_id"], client_turn_id=turn["client_turn_id"],
257+
channel=channel, sender_id="owner", message=turn["message"],
258+
source_id="lark:original",
259+
)
260+
assert authority(root, registry, session, turn)["targets"] == [other]
261+
applied = configure_delivery_target(
262+
root, registry, channel=channel, **request, grant=True, execute=True
263+
)
264+
assert applied["changed"] and applied["granted_after"] and applied["readback_verified"]
265+
assert authority(root, registry, session, turn)["targets"] == [other, request]
266+
assert not configure_delivery_target(
267+
root, registry, channel=channel, **request, grant=True, execute=True
268+
)["changed"]
269+
saved = json.loads(policy_path.read_text())
270+
assert saved["sources"][channel]["sender_ids"] == ["owner"]
271+
assert saved["sources"][channel]["evidence_ssh_hosts"] == {"example-host": ["research"]}
272+
273+
revoked = configure_delivery_target(
274+
root, registry, channel=channel, **request, grant=False, execute=True
275+
)
276+
assert revoked["changed"] and not revoked["granted_after"] and revoked["readback_verified"]
277+
assert authority(root, registry, session, turn)["targets"] == [other]
278+
assert not configure_delivery_target(
279+
root, registry, channel=channel, **request, grant=False, execute=True
280+
)["changed"]
281+
282+
# Older policy rows may carry metadata; recipient identity is still the pair.
283+
saved = json.loads(policy_path.read_text())
284+
saved["sources"][channel]["targets"] = [other, {**request, "note": "legacy"}, request]
285+
_write(policy_path, saved)
286+
assert not configure_delivery_target(
287+
root, registry, channel=channel, **request, grant=True, execute=True
288+
)["changed"]
289+
assert authority(root, registry, session, turn)["targets"] == [other, request]
290+
assert configure_delivery_target(
291+
root, registry, channel=channel, **request, grant=False, execute=True
292+
)["readback_verified"]
293+
assert json.loads(policy_path.read_text())["sources"][channel]["targets"] == [other]
294+
295+
296+
def test_operator_target_grant_fails_closed_without_audited_source_or_agent(fixture):
297+
root, registry, _, _, request = fixture
298+
channel = "manager.external." + "b" * 24
299+
with pytest.raises((OSError, ValueError)):
300+
configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True)
301+
302+
policy_path = _root(root) / "policy.json"
303+
source = {"sender_ids": ["owner"], "evidence_goal_ids": ["other"], "targets": []}
304+
_write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: source}})
305+
with pytest.raises(ValueError, match="outside the channel read scope"):
306+
configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True)
307+
source["evidence_goal_ids"] = ["research"]
308+
_write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: source}})
309+
with pytest.raises(ValueError, match="registered Agent"):
310+
configure_delivery_target(root, registry, channel=channel, goal_id="research",
311+
agent_id="unknown", grant=True, execute=True)
312+
source["evidence_goal_ids"] = ["research", "*"]
313+
_write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: source}})
314+
with pytest.raises(ValueError, match="outside the channel read scope"):
315+
configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True)
316+
source["evidence_goal_ids"] = ["research"]
317+
_write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: source}})
318+
data = json.loads(registry.read_text())
319+
data["goals"][0]["activation_state"] = "stopped"
320+
registry.write_text(json.dumps(data))
321+
with pytest.raises(ValueError, match="active Goal"):
322+
configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True)
323+
assert json.loads(policy_path.read_text())["sources"][channel]["targets"] == []
324+
325+
326+
def test_manager_inbox_cli_previews_and_applies_one_delivery_target(fixture):
327+
root, registry, _, _, request = fixture
328+
channel = "manager.external." + "c" * 24
329+
policy_path = _root(root) / "policy.json"
330+
_write(policy_path, {
331+
"schema_version": POLICY_SCHEMA,
332+
"sources": {channel: {"sender_ids": ["owner"], "targets": []}},
333+
})
334+
base = [
335+
sys.executable, "-m", "loopx.cli", "--registry", str(registry),
336+
"--runtime-root", str(root), "manager-inbox",
337+
]
338+
options = ["--channel-id", channel, "--goal-id", request["goal_id"],
339+
"--agent-id", request["agent_id"]]
340+
341+
def call(action, execute=False):
342+
completed = subprocess.run(
343+
[*base, action, *options, *(["--execute"] if execute else [])],
344+
capture_output=True, text=True, check=True,
345+
)
346+
return json.loads(completed.stdout)
347+
348+
original = policy_path.read_bytes()
349+
assert call("grant-delivery-target")["would_change"]
350+
assert policy_path.read_bytes() == original
351+
assert call("grant-delivery-target", execute=True)["granted_after"]
352+
assert call("revoke-delivery-target", execute=True)["granted_after"] is False
353+
assert json.loads(policy_path.read_text())["sources"][channel]["targets"] == []
354+
355+
229356
def test_same_goal_recipients_keep_inboxes_and_decisions_separate(fixture):
230357
root, registry, session, turn, request = fixture
231358
data = json.loads(registry.read_text())

0 commit comments

Comments
 (0)