Skip to content

Commit 1891886

Browse files
node9aiclaude
andcommitted
feat: approval cards, the daemon checks the call with the caller's cwd
node9-proxy 2.35 gives every approval card one owner and one decision record. The SDK evaluates no policy itself: it registers a card and waits for the daemon's answer. - POST /check sends kind "evaluate": the daemon checks the call itself, with the SDK's working directory, so a rule in the project's node9.config.json applies (an older daemon checked it without the project's config). - With the daemon's internal token (from ~/.node9/daemon.pid), the card is a protocol-2 card and GET /wait sends the token. - The wait follows the card's own deadline (deadline + 5 s; no limit when the card has no timer). It used to give up after a fixed 65 s, shorter than the daemon's default 120 s approval timeout. - A deny without a top-level reason takes the decision record's reason. An older daemon ignores kind and protocol and sends no deadline: the SDK keeps the fixed wait there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bdffa58 commit 1891886

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

‎node9/_client.py‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,40 @@ def _post(path: str, payload: dict) -> dict:
9292
raise DaemonNotFoundError(DAEMON_PORT)
9393

9494

95-
def _get(path: str) -> dict:
96-
req = urllib.request.Request(f"{_DAEMON_BASE}{path}", method="GET")
95+
_CARD_PROTOCOL = 2
96+
97+
98+
def _internal_token() -> str | None:
99+
"""The daemon's per-process token from ~/.node9/daemon.pid (mode 0600,
100+
readable by the user that runs the daemon). None when unreadable."""
101+
pid_file = os.path.join(os.path.expanduser("~"), ".node9", "daemon.pid")
102+
try:
103+
with open(pid_file, encoding="utf-8") as f:
104+
token = json.load(f).get("internalToken")
105+
return token if isinstance(token, str) and token else None
106+
except (OSError, ValueError):
107+
return None
108+
109+
110+
def _wait_timeout(check_result: dict) -> float | None:
111+
"""How long to wait on /wait. A daemon that speaks the card protocol says
112+
when the card's own timer decides it (`deadline`, epoch ms, or null for no
113+
timer): wait a little longer than that. An older daemon: the fixed wait."""
114+
if check_result.get("protocol") != _CARD_PROTOCOL:
115+
return _WAIT_TIMEOUT
116+
deadline = check_result.get("deadline")
117+
if deadline is None:
118+
return None # no timer: the card waits for a person
119+
if isinstance(deadline, (int, float)):
120+
return max(deadline / 1000 - time.time(), 0) + 5
121+
return _WAIT_TIMEOUT
122+
123+
124+
def _get(path: str, timeout: float | None = _WAIT_TIMEOUT, token: str | None = None) -> dict:
125+
headers = {"X-Node9-Internal": token} if token else {}
126+
req = urllib.request.Request(f"{_DAEMON_BASE}{path}", headers=headers, method="GET")
97127
try:
98-
with urllib.request.urlopen(req, timeout=_WAIT_TIMEOUT) as resp:
128+
with urllib.request.urlopen(req, timeout=timeout) as resp:
99129
return json.loads(resp.read())
100130
except (urllib.error.URLError, http.client.HTTPException):
101131
# URLError = timeout / connection error
@@ -327,22 +357,30 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None:
327357
_offline_audit(tool_name, args, run_id=run_id)
328358
return
329359

360+
# kind "evaluate": the daemon checks this call itself, with this cwd (the
361+
# SDK evaluates no policy of its own). With the daemon's token, the card is
362+
# a protocol-2 card whose decision only a token holder can read.
363+
token = _internal_token()
330364
result = _post("/check", {
331365
"toolName": tool_name,
332366
"args": args,
333367
"cwd": os.getcwd(),
334368
"agent": (_config.get()[0] or "Python SDK"),
335369
"policy": _config.get()[1],
336370
"runId": run_id,
371+
"kind": "evaluate",
372+
**({"protocol": _CARD_PROTOCOL} if token else {}),
337373
})
338374
request_id = result.get("id")
339375
if not request_id:
340376
raise RuntimeError(f"[Node9] Unexpected daemon response: {result}")
341377

342378
print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", file=sys.stderr, flush=True)
343-
decision_result = _get(f"/wait/{request_id}")
379+
decision_result = _get(f"/wait/{request_id}", timeout=_wait_timeout(result), token=token)
344380
decision = decision_result.get("decision", "deny")
345381

346382
if decision != "allow":
347-
reason = decision_result.get("reason", "Denied by Node9 policy")
383+
record = decision_result.get("record")
384+
record_reason = record.get("reason") if isinstance(record, dict) else None
385+
reason = decision_result.get("reason") or record_reason or "Denied by Node9 policy"
348386
raise ActionDeniedException(tool_name, reason)

‎tests/test_client.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the daemon HTTP client."""
22
import json
3+
import time
34
import pytest
45
from unittest.mock import patch, MagicMock
56
from http.client import RemoteDisconnected
@@ -100,6 +101,79 @@ def side_effect(req, timeout):
100101
with pytest.raises(ActionDeniedException):
101102
evaluate("deploy", {"server": "prod"})
102103

104+
# Approval cards (node9-proxy 2.35): the SDK asks the daemon to check the
105+
# call itself ("evaluate"), reads the decision with the daemon's token, and
106+
# waits as long as the card's own timer.
107+
def test_check_asks_the_daemon_to_evaluate_with_the_token(self):
108+
calls = []
109+
110+
def side_effect(req, timeout):
111+
calls.append((req, timeout))
112+
if req.get_method() == "POST":
113+
return _make_response({"id": "req-1", "protocol": 2, "deadline": None})
114+
return _make_response({"decision": "allow"})
115+
116+
with patch("node9._client._internal_token", return_value="tok"), \
117+
patch("urllib.request.urlopen", side_effect=side_effect):
118+
evaluate("write_file", {"path": "/tmp/x"})
119+
120+
check_body = json.loads(calls[0][0].data)
121+
assert check_body["kind"] == "evaluate"
122+
assert check_body["protocol"] == 2
123+
wait_req, wait_timeout = calls[1]
124+
assert wait_req.get_header("X-node9-internal") == "tok"
125+
# deadline null: the card has no timer, so the SDK waits for a person.
126+
assert wait_timeout is None
127+
128+
def test_wait_follows_the_card_deadline(self):
129+
calls = []
130+
deadline_ms = (time.time() + 120) * 1000
131+
132+
def side_effect(req, timeout):
133+
calls.append(timeout)
134+
if req.get_method() == "POST":
135+
return _make_response({"id": "req-1", "protocol": 2, "deadline": deadline_ms})
136+
return _make_response({"decision": "allow"})
137+
138+
with patch("node9._client._internal_token", return_value="tok"), \
139+
patch("urllib.request.urlopen", side_effect=side_effect):
140+
evaluate("write_file", {"path": "/tmp/x"})
141+
142+
# Before: a fixed 65 s, shorter than the daemon's default 120 s timer.
143+
assert 120 < calls[1] <= 126
144+
145+
def test_without_the_token_the_card_is_not_protocol_2(self):
146+
calls = []
147+
148+
def side_effect(req, timeout):
149+
calls.append((req, timeout))
150+
if req.get_method() == "POST":
151+
return _make_response({"id": "req-1"})
152+
return _make_response({"decision": "allow"})
153+
154+
with patch("node9._client._internal_token", return_value=None), \
155+
patch("urllib.request.urlopen", side_effect=side_effect):
156+
evaluate("write_file", {"path": "/tmp/x"})
157+
158+
check_body = json.loads(calls[0][0].data)
159+
assert check_body["kind"] == "evaluate"
160+
assert "protocol" not in check_body
161+
assert calls[1][0].get_header("X-node9-internal") is None
162+
assert calls[1][1] == 65 # an older daemon: the fixed wait
163+
164+
def test_deny_reason_from_the_decision_record(self):
165+
check_resp = _make_response({"id": "req-1"})
166+
wait_resp = _make_response({
167+
"decision": "deny",
168+
"record": {"decision": "deny", "decidedBy": "policy", "label": "Smart Rule: x",
169+
"reason": "the project rule blocks this"},
170+
})
171+
with patch("node9._client._internal_token", return_value=None), \
172+
patch("urllib.request.urlopen", side_effect=[check_resp, wait_resp]):
173+
with pytest.raises(ActionDeniedException) as exc:
174+
evaluate("bash", {"command": "rm -rf /"})
175+
assert exc.value.reason == "the project rule blocks this"
176+
103177
def test_node9_skip_env_bypasses_daemon(self, monkeypatch):
104178
# _SKIP is read once at import time — patch the flag directly
105179
monkeypatch.setattr("node9._client._SKIP", True)

0 commit comments

Comments
 (0)