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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_runtime.py
4
+ tide/__init__.py
5
+ tide/policy.py
6
+ tide/store.py
7
+ tide_runtime.egg-info/PKG-INFO
8
+ tide_runtime.egg-info/SOURCES.txt
9
+ tide_runtime.egg-info/dependency_links.txt
10
+ tide_runtime.egg-info/top_level.txt