tide-runtime 0.2.5__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.
- tide_runtime-0.2.5/PKG-INFO +37 -0
- tide_runtime-0.2.5/README.md +29 -0
- tide_runtime-0.2.5/pyproject.toml +16 -0
- tide_runtime-0.2.5/setup.cfg +4 -0
- tide_runtime-0.2.5/tests/test_runtime.py +184 -0
- tide_runtime-0.2.5/tide/__init__.py +250 -0
- tide_runtime-0.2.5/tide/policy.py +43 -0
- tide_runtime-0.2.5/tide/store.py +101 -0
- tide_runtime-0.2.5/tide_runtime.egg-info/PKG-INFO +37 -0
- tide_runtime-0.2.5/tide_runtime.egg-info/SOURCES.txt +10 -0
- tide_runtime-0.2.5/tide_runtime.egg-info/dependency_links.txt +1 -0
- tide_runtime-0.2.5/tide_runtime.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tide-runtime
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
|
|
5
|
+
Project-URL: Repository, https://github.com/rippletideco/rippletide-package
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# Tide Runtime SDK
|
|
10
|
+
|
|
11
|
+
Python SDK for wrapping production agent tools with Tide policy, approvals, and
|
|
12
|
+
earned-use logging.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tide-runtime
|
|
16
|
+
tide ledger init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tide import configure, controlled_tool, record_outcome
|
|
21
|
+
|
|
22
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
23
|
+
|
|
24
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write", capture=["lead_id"])
|
|
25
|
+
def update_lead(lead_id, status):
|
|
26
|
+
return {"lead_id": lead_id, "status": status}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
update_lead("L-1", "qualified")
|
|
30
|
+
record_outcome("success")
|
|
31
|
+
except Exception:
|
|
32
|
+
record_outcome("failure")
|
|
33
|
+
raise
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
|
|
37
|
+
`configure(project="...")`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Tide Runtime SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for wrapping production agent tools with Tide policy, approvals, and
|
|
4
|
+
earned-use logging.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install tide-runtime
|
|
8
|
+
tide ledger init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from tide import configure, controlled_tool, record_outcome
|
|
13
|
+
|
|
14
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
15
|
+
|
|
16
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write", capture=["lead_id"])
|
|
17
|
+
def update_lead(lead_id, status):
|
|
18
|
+
return {"lead_id": lead_id, "status": status}
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
update_lead("L-1", "qualified")
|
|
22
|
+
record_outcome("success")
|
|
23
|
+
except Exception:
|
|
24
|
+
record_outcome("failure")
|
|
25
|
+
raise
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
|
|
29
|
+
`configure(project="...")`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tide-runtime"
|
|
7
|
+
version = "0.2.5"
|
|
8
|
+
description = "Tide runtime policy, approvals, and earned-use ledger for AI agent tools."
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/rippletideco/rippletide-package"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
packages = ["tide"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
|
|
12
|
+
import tide
|
|
13
|
+
from tide import policy as tide_policy
|
|
14
|
+
from tide import store as tide_store
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def events(project):
|
|
18
|
+
return tide_store.read_jsonl(project, tide_store.EVENTS_FILE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TideRuntimeTest(unittest.TestCase):
|
|
22
|
+
def setUp(self):
|
|
23
|
+
self.project = tempfile.mkdtemp(prefix="tide-ledger-test-")
|
|
24
|
+
tide.configure(project=self.project, agent_id="test-agent",
|
|
25
|
+
workflow_id="test-flow", environment="prod",
|
|
26
|
+
on_approval="raise", approval_timeout=5, approval_poll=0.05)
|
|
27
|
+
|
|
28
|
+
def tearDown(self):
|
|
29
|
+
shutil.rmtree(self.project, ignore_errors=True)
|
|
30
|
+
|
|
31
|
+
def test_policy_matrix_defaults(self):
|
|
32
|
+
pol = tide_policy.DEFAULT_POLICY
|
|
33
|
+
self.assertEqual(tide_policy.decide(pol, "t", "read_only", "prod")[0], "allow")
|
|
34
|
+
self.assertEqual(tide_policy.decide(pol, "t", "internal_write", "prod")[0], "require_approval")
|
|
35
|
+
self.assertEqual(tide_policy.decide(pol, "t", "internal_write", "dev")[0], "allow_with_log")
|
|
36
|
+
self.assertEqual(tide_policy.decide(pol, "t", "destructive", "prod")[0], "block")
|
|
37
|
+
|
|
38
|
+
def test_policy_fails_closed(self):
|
|
39
|
+
pol = tide_policy.DEFAULT_POLICY
|
|
40
|
+
self.assertEqual(tide_policy.decide(pol, "t", "not_a_class", "prod")[0], "block")
|
|
41
|
+
self.assertEqual(tide_policy.decide({"decisions": {}}, "t", "read_only", "prod")[0], "block")
|
|
42
|
+
|
|
43
|
+
def test_per_tool_override_wins(self):
|
|
44
|
+
pol = json.loads(json.dumps(tide_policy.DEFAULT_POLICY))
|
|
45
|
+
pol["tools"] = {"crm.nuke": {"decision": "block", "reason": "never"}}
|
|
46
|
+
decision, reason = tide_policy.decide(pol, "crm.nuke", "read_only", "prod")
|
|
47
|
+
self.assertEqual(decision, "block")
|
|
48
|
+
self.assertEqual(reason, "never")
|
|
49
|
+
|
|
50
|
+
def test_dynamic_risk_class(self):
|
|
51
|
+
def classify(named):
|
|
52
|
+
sql = named["sql"].lower()
|
|
53
|
+
return "destructive" if "drop " in sql else "read_only"
|
|
54
|
+
|
|
55
|
+
@tide.controlled_tool(name="sql.query", risk_class=classify)
|
|
56
|
+
def query(sql):
|
|
57
|
+
return "ok"
|
|
58
|
+
|
|
59
|
+
self.assertEqual(query("select * from customers"), "ok")
|
|
60
|
+
with self.assertRaises(tide.LedgerBlocked):
|
|
61
|
+
query("drop table customers")
|
|
62
|
+
|
|
63
|
+
def test_allow_records_event_with_hashes_and_redaction(self):
|
|
64
|
+
@tide.controlled_tool(name="crm.get_lead", risk_class="read_only", capture=["lead_id"])
|
|
65
|
+
def get_lead(lead_id, secret_note):
|
|
66
|
+
return {"lead_id": lead_id, "name": "Ada"}
|
|
67
|
+
|
|
68
|
+
result = get_lead("L-1", secret_note="do not store")
|
|
69
|
+
self.assertEqual(result["name"], "Ada")
|
|
70
|
+
e = events(self.project)[0]
|
|
71
|
+
self.assertEqual(e["policy_decision"], "allow")
|
|
72
|
+
self.assertEqual(e["captured"], {"lead_id": "L-1"})
|
|
73
|
+
self.assertNotIn("do not store", json.dumps(e))
|
|
74
|
+
self.assertTrue(e["input_hash"].startswith("sha256:"))
|
|
75
|
+
self.assertTrue(e["output_hash"].startswith("sha256:"))
|
|
76
|
+
self.assertIsNone(e["error_code"])
|
|
77
|
+
|
|
78
|
+
def test_block_raises_and_records(self):
|
|
79
|
+
@tide.controlled_tool(name="crm.delete_account", risk_class="destructive")
|
|
80
|
+
def delete_account(account_id):
|
|
81
|
+
raise AssertionError("must never run")
|
|
82
|
+
|
|
83
|
+
with self.assertRaises(tide.LedgerBlocked):
|
|
84
|
+
delete_account("A-1")
|
|
85
|
+
e = events(self.project)[0]
|
|
86
|
+
self.assertEqual(e["policy_decision"], "block")
|
|
87
|
+
self.assertEqual(e["error_code"], "blocked_by_policy")
|
|
88
|
+
|
|
89
|
+
def test_require_approval_raises_pending_and_records_request(self):
|
|
90
|
+
@tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
|
|
91
|
+
def update_lead(lead_id):
|
|
92
|
+
return "updated"
|
|
93
|
+
|
|
94
|
+
with self.assertRaises(tide.LedgerPending) as ctx:
|
|
95
|
+
update_lead("L-1")
|
|
96
|
+
approvals = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
|
|
97
|
+
self.assertEqual(approvals[0]["type"], "request")
|
|
98
|
+
self.assertEqual(approvals[0]["approval_id"], ctx.exception.approval_id)
|
|
99
|
+
self.assertEqual(events(self.project)[0]["approval_status"], "pending")
|
|
100
|
+
|
|
101
|
+
def test_grant_is_hash_bound_and_one_shot(self):
|
|
102
|
+
@tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
|
|
103
|
+
def update_lead(lead_id):
|
|
104
|
+
return "updated"
|
|
105
|
+
|
|
106
|
+
with self.assertRaises(tide.LedgerPending) as ctx:
|
|
107
|
+
update_lead("L-1")
|
|
108
|
+
approval_id = ctx.exception.approval_id
|
|
109
|
+
tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
|
|
110
|
+
"type": "decision", "approval_id": approval_id,
|
|
111
|
+
"decision": "approved", "approver": "tester",
|
|
112
|
+
"reason": None, "created_at": tide_store.now_iso(),
|
|
113
|
+
})
|
|
114
|
+
with self.assertRaises(tide.LedgerPending):
|
|
115
|
+
update_lead("L-2")
|
|
116
|
+
self.assertEqual(update_lead("L-1"), "updated")
|
|
117
|
+
ran = [e for e in events(self.project) if e["approval_status"] == "approved"]
|
|
118
|
+
self.assertEqual(len(ran), 1)
|
|
119
|
+
with self.assertRaises(tide.LedgerPending):
|
|
120
|
+
update_lead("L-1")
|
|
121
|
+
|
|
122
|
+
def test_wait_mode_proceeds_after_approval(self):
|
|
123
|
+
tide.configure(project=self.project, environment="prod",
|
|
124
|
+
on_approval="wait", approval_timeout=5, approval_poll=0.05)
|
|
125
|
+
|
|
126
|
+
@tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
|
|
127
|
+
def update_lead(lead_id):
|
|
128
|
+
return "updated"
|
|
129
|
+
|
|
130
|
+
def approve_soon():
|
|
131
|
+
time.sleep(0.2)
|
|
132
|
+
reqs = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
|
|
133
|
+
tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
|
|
134
|
+
"type": "decision", "approval_id": reqs[0]["approval_id"],
|
|
135
|
+
"decision": "approved", "approver": "tester",
|
|
136
|
+
"reason": None, "created_at": tide_store.now_iso(),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
t = threading.Thread(target=approve_soon)
|
|
140
|
+
t.start()
|
|
141
|
+
self.assertEqual(update_lead("L-1"), "updated")
|
|
142
|
+
t.join()
|
|
143
|
+
self.assertEqual(events(self.project)[0]["approval_status"], "approved")
|
|
144
|
+
|
|
145
|
+
def test_wait_mode_denied_raises(self):
|
|
146
|
+
tide.configure(project=self.project, environment="prod",
|
|
147
|
+
on_approval="wait", approval_timeout=5, approval_poll=0.05)
|
|
148
|
+
|
|
149
|
+
@tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
|
|
150
|
+
def update_lead(lead_id):
|
|
151
|
+
raise AssertionError("must never run")
|
|
152
|
+
|
|
153
|
+
def deny_soon():
|
|
154
|
+
time.sleep(0.2)
|
|
155
|
+
reqs = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
|
|
156
|
+
tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
|
|
157
|
+
"type": "decision", "approval_id": reqs[0]["approval_id"],
|
|
158
|
+
"decision": "denied", "approver": "tester",
|
|
159
|
+
"reason": "not now", "created_at": tide_store.now_iso(),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
t = threading.Thread(target=deny_soon)
|
|
163
|
+
t.start()
|
|
164
|
+
with self.assertRaises(tide.LedgerDenied):
|
|
165
|
+
update_lead("L-1")
|
|
166
|
+
t.join()
|
|
167
|
+
e = events(self.project)[0]
|
|
168
|
+
self.assertEqual(e["approval_status"], "denied")
|
|
169
|
+
self.assertEqual(e["error_code"], "approval_denied")
|
|
170
|
+
|
|
171
|
+
def test_outcome_links_to_trace(self):
|
|
172
|
+
@tide.controlled_tool(name="crm.get_lead", risk_class="read_only")
|
|
173
|
+
def get_lead(lead_id):
|
|
174
|
+
return "lead"
|
|
175
|
+
|
|
176
|
+
get_lead("L-1")
|
|
177
|
+
tide.record_outcome("success", human_corrected=False)
|
|
178
|
+
outcome = tide_store.read_jsonl(self.project, tide_store.OUTCOMES_FILE)[0]
|
|
179
|
+
self.assertEqual(outcome["trace_id"], events(self.project)[0]["trace_id"])
|
|
180
|
+
self.assertEqual(outcome["status"], "success")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
unittest.main()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Tide runtime governance for agent tools with real side effects.
|
|
2
|
+
|
|
3
|
+
Wrap the tools your agent uses to touch business systems. Before each call a
|
|
4
|
+
deterministic policy decides allow / allow_with_log / require_approval / block;
|
|
5
|
+
every call, approval, and outcome is appended to a local JSONL ledger that the
|
|
6
|
+
`tide ledger` CLI reads.
|
|
7
|
+
|
|
8
|
+
from tide import configure, controlled_tool, record_outcome
|
|
9
|
+
|
|
10
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
11
|
+
|
|
12
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write",
|
|
13
|
+
capture=["lead_id"])
|
|
14
|
+
def update_lead(lead_id, status):
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Zero dependencies, Python >= 3.8. Payloads are hashed by default; `capture`
|
|
18
|
+
opts specific fields into the ledger.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from . import policy as _policy
|
|
27
|
+
from . import store as _store
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"configure",
|
|
33
|
+
"controlled_tool",
|
|
34
|
+
"record_outcome",
|
|
35
|
+
"LedgerBlocked",
|
|
36
|
+
"LedgerDenied",
|
|
37
|
+
"LedgerPending",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LedgerBlocked(Exception):
|
|
42
|
+
"""Policy decision was block; the tool did not run."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LedgerDenied(Exception):
|
|
46
|
+
"""A human denied the approval request; the tool did not run."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LedgerPending(Exception):
|
|
50
|
+
"""Approval is pending. Approve via `tide ledger approve <id>` and call again."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, approval_id, message):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.approval_id = approval_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_config = {
|
|
58
|
+
"project": os.environ.get("TIDE_LEDGER_DIR", ".tide/ledger"),
|
|
59
|
+
"agent_id": "agent",
|
|
60
|
+
"workflow_id": None,
|
|
61
|
+
"environment": None,
|
|
62
|
+
"trace_id": None,
|
|
63
|
+
"on_approval": "wait",
|
|
64
|
+
"approval_timeout": 300,
|
|
65
|
+
"approval_poll": 2,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def configure(**kwargs):
|
|
70
|
+
"""Set run-level config; starts a new trace unless trace_id is passed."""
|
|
71
|
+
unknown = set(kwargs) - set(_config)
|
|
72
|
+
if unknown:
|
|
73
|
+
raise TypeError("unknown configure() options: %s" % ", ".join(sorted(unknown)))
|
|
74
|
+
_config.update(kwargs)
|
|
75
|
+
if not kwargs.get("trace_id"):
|
|
76
|
+
_config["trace_id"] = _store.new_id()
|
|
77
|
+
return dict(_config)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _trace_id():
|
|
81
|
+
if not _config["trace_id"]:
|
|
82
|
+
_config["trace_id"] = _store.new_id()
|
|
83
|
+
return _config["trace_id"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _environment(pol):
|
|
87
|
+
return (
|
|
88
|
+
_config["environment"]
|
|
89
|
+
or os.environ.get("TIDE_ENV")
|
|
90
|
+
or os.environ.get("LEDGER_ENV")
|
|
91
|
+
or (pol or {}).get("environment")
|
|
92
|
+
or "dev"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bound_args(fn, args, kwargs):
|
|
97
|
+
try:
|
|
98
|
+
bound = inspect.signature(fn).bind(*args, **kwargs)
|
|
99
|
+
bound.apply_defaults()
|
|
100
|
+
return dict(bound.arguments)
|
|
101
|
+
except TypeError:
|
|
102
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _wait_for_decision(project, approval_id):
|
|
106
|
+
deadline = time.time() + _config["approval_timeout"]
|
|
107
|
+
while time.time() < deadline:
|
|
108
|
+
state = _store.approval_state(project, approval_id)
|
|
109
|
+
if state == "approved":
|
|
110
|
+
return "approved"
|
|
111
|
+
if state == "denied":
|
|
112
|
+
return "denied"
|
|
113
|
+
time.sleep(_config["approval_poll"])
|
|
114
|
+
return "timeout"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def record_outcome(status, trace_id=None, workflow_id=None, human_corrected=False,
|
|
118
|
+
failure_tag=None, source="api", project=None):
|
|
119
|
+
"""Record what the workflow achieved: usually success or failure."""
|
|
120
|
+
project = project or _config["project"]
|
|
121
|
+
record = {
|
|
122
|
+
"outcome_id": _store.new_id(),
|
|
123
|
+
"trace_id": trace_id or _trace_id(),
|
|
124
|
+
"workflow_id": workflow_id or _config["workflow_id"],
|
|
125
|
+
"status": status,
|
|
126
|
+
"source": source,
|
|
127
|
+
"human_corrected": bool(human_corrected),
|
|
128
|
+
"failure_tag": failure_tag,
|
|
129
|
+
"created_at": _store.now_iso(),
|
|
130
|
+
}
|
|
131
|
+
_store.append_jsonl(project, _store.OUTCOMES_FILE, record)
|
|
132
|
+
return record
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def controlled_tool(name, version="0.0.0", risk_class="internal_write", owner=None,
|
|
136
|
+
capture=None, cost_usd=None):
|
|
137
|
+
"""Decorator: gate a tool behind policy and record each call."""
|
|
138
|
+
capture = list(capture or [])
|
|
139
|
+
|
|
140
|
+
def decorator(fn):
|
|
141
|
+
@functools.wraps(fn)
|
|
142
|
+
def wrapper(*args, **kwargs):
|
|
143
|
+
project = _config["project"]
|
|
144
|
+
pol = _store.load_policy(project) or dict(_policy.DEFAULT_POLICY)
|
|
145
|
+
env = _environment(pol)
|
|
146
|
+
trace_id = _trace_id()
|
|
147
|
+
named = _bound_args(fn, args, kwargs)
|
|
148
|
+
resolved_risk_class = risk_class(named) if callable(risk_class) else risk_class
|
|
149
|
+
input_hash = _store.canonical_hash(named)
|
|
150
|
+
captured = {k: named[k] for k in capture if k in named}
|
|
151
|
+
decision, reason = _policy.decide(pol, name, resolved_risk_class, env)
|
|
152
|
+
|
|
153
|
+
event = {
|
|
154
|
+
"event_id": _store.new_id(),
|
|
155
|
+
"trace_id": trace_id,
|
|
156
|
+
"workflow_id": _config["workflow_id"],
|
|
157
|
+
"agent_id": _config["agent_id"],
|
|
158
|
+
"tool": name,
|
|
159
|
+
"tool_version": version,
|
|
160
|
+
"owner": owner,
|
|
161
|
+
"risk_class": resolved_risk_class,
|
|
162
|
+
"environment": env,
|
|
163
|
+
"input_hash": input_hash,
|
|
164
|
+
"captured": captured,
|
|
165
|
+
"policy_decision": decision,
|
|
166
|
+
"policy_reason": reason,
|
|
167
|
+
"approval_id": None,
|
|
168
|
+
"approval_status": None,
|
|
169
|
+
"output_hash": None,
|
|
170
|
+
"latency_ms": None,
|
|
171
|
+
"cost_usd": cost_usd,
|
|
172
|
+
"error_code": None,
|
|
173
|
+
"created_at": _store.now_iso(),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if decision == "block":
|
|
177
|
+
event["error_code"] = "blocked_by_policy"
|
|
178
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
179
|
+
raise LedgerBlocked("%s blocked by policy: %s" % (name, reason))
|
|
180
|
+
|
|
181
|
+
if decision == "require_approval":
|
|
182
|
+
grant = _store.find_grant(project, name, input_hash)
|
|
183
|
+
if grant:
|
|
184
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
185
|
+
"type": "consumed",
|
|
186
|
+
"approval_id": grant,
|
|
187
|
+
"event_id": event["event_id"],
|
|
188
|
+
"created_at": _store.now_iso(),
|
|
189
|
+
})
|
|
190
|
+
event["approval_id"] = grant
|
|
191
|
+
event["approval_status"] = "approved"
|
|
192
|
+
else:
|
|
193
|
+
approval_id = _store.new_id()
|
|
194
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
195
|
+
"type": "request",
|
|
196
|
+
"approval_id": approval_id,
|
|
197
|
+
"event_id": event["event_id"],
|
|
198
|
+
"trace_id": event["trace_id"],
|
|
199
|
+
"tool": name,
|
|
200
|
+
"tool_version": version,
|
|
201
|
+
"risk_class": resolved_risk_class,
|
|
202
|
+
"input_hash": input_hash,
|
|
203
|
+
"input_summary": captured,
|
|
204
|
+
"policy_reason": reason,
|
|
205
|
+
"agent_id": _config["agent_id"],
|
|
206
|
+
"workflow_id": _config["workflow_id"],
|
|
207
|
+
"created_at": _store.now_iso(),
|
|
208
|
+
})
|
|
209
|
+
event["approval_id"] = approval_id
|
|
210
|
+
hint = "approve with: tide ledger approve %s --project %s" % (approval_id[:8], project)
|
|
211
|
+
if _config["on_approval"] == "raise":
|
|
212
|
+
event["approval_status"] = "pending"
|
|
213
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
214
|
+
raise LedgerPending(approval_id, "%s needs approval - %s" % (name, hint))
|
|
215
|
+
result_state = _wait_for_decision(project, approval_id)
|
|
216
|
+
if result_state == "denied":
|
|
217
|
+
event["approval_status"] = "denied"
|
|
218
|
+
event["error_code"] = "approval_denied"
|
|
219
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
220
|
+
raise LedgerDenied("%s was denied by a human approver" % name)
|
|
221
|
+
if result_state == "timeout":
|
|
222
|
+
event["approval_status"] = "pending"
|
|
223
|
+
event["error_code"] = "approval_timeout"
|
|
224
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
225
|
+
raise LedgerPending(approval_id, "%s approval timed out - %s" % (name, hint))
|
|
226
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
227
|
+
"type": "consumed",
|
|
228
|
+
"approval_id": approval_id,
|
|
229
|
+
"event_id": event["event_id"],
|
|
230
|
+
"created_at": _store.now_iso(),
|
|
231
|
+
})
|
|
232
|
+
event["approval_status"] = "approved"
|
|
233
|
+
|
|
234
|
+
start = time.time()
|
|
235
|
+
try:
|
|
236
|
+
result = fn(*args, **kwargs)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
event["latency_ms"] = int((time.time() - start) * 1000)
|
|
239
|
+
event["error_code"] = type(exc).__name__
|
|
240
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
241
|
+
raise
|
|
242
|
+
event["latency_ms"] = int((time.time() - start) * 1000)
|
|
243
|
+
event["output_hash"] = _store.canonical_hash(result)
|
|
244
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
wrapper.__tide__ = {"name": name, "version": version, "risk_class": risk_class, "owner": owner}
|
|
248
|
+
return wrapper
|
|
249
|
+
|
|
250
|
+
return decorator
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Deterministic policy engine: risk class x environment -> decision."""
|
|
2
|
+
|
|
3
|
+
RISK_CLASSES = (
|
|
4
|
+
"read_only",
|
|
5
|
+
"draft_only",
|
|
6
|
+
"internal_write",
|
|
7
|
+
"customer_visible",
|
|
8
|
+
"money_movement",
|
|
9
|
+
"destructive",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
DECISIONS = ("allow", "allow_with_log", "require_approval", "block")
|
|
13
|
+
|
|
14
|
+
DEFAULT_POLICY = {
|
|
15
|
+
"version": 1,
|
|
16
|
+
"environment": "dev",
|
|
17
|
+
"decisions": {
|
|
18
|
+
"read_only": {"dev": "allow", "prod": "allow"},
|
|
19
|
+
"draft_only": {"dev": "allow", "prod": "allow_with_log"},
|
|
20
|
+
"internal_write": {"dev": "allow_with_log", "prod": "require_approval"},
|
|
21
|
+
"customer_visible": {"dev": "require_approval", "prod": "require_approval"},
|
|
22
|
+
"money_movement": {"dev": "require_approval", "prod": "require_approval"},
|
|
23
|
+
"destructive": {"dev": "require_approval", "prod": "block"},
|
|
24
|
+
},
|
|
25
|
+
"tools": {},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decide(policy, tool_name, risk_class, environment):
|
|
30
|
+
"""Return (decision, reason). Per-tool override wins over the class table."""
|
|
31
|
+
tool_rule = (policy.get("tools") or {}).get(tool_name)
|
|
32
|
+
if tool_rule:
|
|
33
|
+
decision = tool_rule.get("decision")
|
|
34
|
+
if decision in DECISIONS:
|
|
35
|
+
return decision, tool_rule.get("reason") or ("per-tool override for " + tool_name)
|
|
36
|
+
return "block", "invalid per-tool override for %s - failing closed" % tool_name
|
|
37
|
+
table = (policy.get("decisions") or {}).get(risk_class)
|
|
38
|
+
if not isinstance(table, dict):
|
|
39
|
+
return "block", "unknown risk class '%s' - failing closed" % risk_class
|
|
40
|
+
decision = table.get(environment, table.get("prod"))
|
|
41
|
+
if decision not in DECISIONS:
|
|
42
|
+
return "block", "no valid decision for %s in '%s' - failing closed" % (risk_class, environment)
|
|
43
|
+
return decision, "%s in %s -> %s" % (risk_class, environment, decision)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""File contract for the Tide ledger directory.
|
|
2
|
+
|
|
3
|
+
policy.json policy config
|
|
4
|
+
events.jsonl one record per controlled tool call
|
|
5
|
+
approvals.jsonl request / decision / consumed records
|
|
6
|
+
outcomes.jsonl workflow outcome events
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
POLICY_FILE = "policy.json"
|
|
16
|
+
EVENTS_FILE = "events.jsonl"
|
|
17
|
+
APPROVALS_FILE = "approvals.jsonl"
|
|
18
|
+
OUTCOMES_FILE = "outcomes.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now_iso():
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_id():
|
|
26
|
+
return uuid.uuid4().hex
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def canonical_hash(obj):
|
|
30
|
+
blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
|
|
31
|
+
return "sha256:" + hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ensure_project(project):
|
|
35
|
+
os.makedirs(project, exist_ok=True)
|
|
36
|
+
return project
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_policy(project):
|
|
40
|
+
path = os.path.join(project, POLICY_FILE)
|
|
41
|
+
if not os.path.exists(path):
|
|
42
|
+
return None
|
|
43
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append_jsonl(project, filename, record):
|
|
48
|
+
ensure_project(project)
|
|
49
|
+
with open(os.path.join(project, filename), "a", encoding="utf-8") as f:
|
|
50
|
+
f.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_jsonl(project, filename):
|
|
54
|
+
path = os.path.join(project, filename)
|
|
55
|
+
if not os.path.exists(path):
|
|
56
|
+
return []
|
|
57
|
+
records = []
|
|
58
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
records.append(json.loads(line))
|
|
65
|
+
except ValueError:
|
|
66
|
+
continue
|
|
67
|
+
return records
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def approval_state(project, approval_id):
|
|
71
|
+
state = None
|
|
72
|
+
for r in read_jsonl(project, APPROVALS_FILE):
|
|
73
|
+
if r.get("approval_id") != approval_id:
|
|
74
|
+
continue
|
|
75
|
+
kind = r.get("type")
|
|
76
|
+
if kind == "request":
|
|
77
|
+
state = "pending"
|
|
78
|
+
elif kind == "decision":
|
|
79
|
+
state = "approved" if r.get("decision") == "approved" else "denied"
|
|
80
|
+
elif kind == "consumed":
|
|
81
|
+
state = "consumed"
|
|
82
|
+
return state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def find_grant(project, tool_name, input_hash):
|
|
86
|
+
"""Find an approved, unconsumed approval bound to this exact tool and input."""
|
|
87
|
+
requests = {}
|
|
88
|
+
for r in read_jsonl(project, APPROVALS_FILE):
|
|
89
|
+
aid = r.get("approval_id")
|
|
90
|
+
kind = r.get("type")
|
|
91
|
+
if kind == "request":
|
|
92
|
+
requests[aid] = {"record": r, "state": "pending"}
|
|
93
|
+
elif kind == "decision" and aid in requests:
|
|
94
|
+
requests[aid]["state"] = "approved" if r.get("decision") == "approved" else "denied"
|
|
95
|
+
elif kind == "consumed" and aid in requests:
|
|
96
|
+
requests[aid]["state"] = "consumed"
|
|
97
|
+
for aid, info in requests.items():
|
|
98
|
+
req = info["record"]
|
|
99
|
+
if info["state"] == "approved" and req.get("tool") == tool_name and req.get("input_hash") == input_hash:
|
|
100
|
+
return aid
|
|
101
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tide-runtime
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
|
|
5
|
+
Project-URL: Repository, https://github.com/rippletideco/rippletide-package
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# Tide Runtime SDK
|
|
10
|
+
|
|
11
|
+
Python SDK for wrapping production agent tools with Tide policy, approvals, and
|
|
12
|
+
earned-use logging.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tide-runtime
|
|
16
|
+
tide ledger init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tide import configure, controlled_tool, record_outcome
|
|
21
|
+
|
|
22
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
23
|
+
|
|
24
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write", capture=["lead_id"])
|
|
25
|
+
def update_lead(lead_id, status):
|
|
26
|
+
return {"lead_id": lead_id, "status": status}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
update_lead("L-1", "qualified")
|
|
30
|
+
record_outcome("success")
|
|
31
|
+
except Exception:
|
|
32
|
+
record_outcome("failure")
|
|
33
|
+
raise
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
|
|
37
|
+
`configure(project="...")`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tide
|