tide-runtime 0.2.5__tar.gz → 0.2.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tide-runtime
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
5
5
  Project-URL: Repository, https://github.com/rippletideco/rippletide-package
6
6
  Requires-Python: >=3.8
@@ -13,9 +13,12 @@ earned-use logging.
13
13
 
14
14
  ```bash
15
15
  pip install tide-runtime
16
- tide ledger init
16
+ tide init --pack support-refunds --environment prod
17
17
  ```
18
18
 
19
+ `tide init --dry-run --pack <name>` prints the policy before writing it.
20
+ Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
21
+
19
22
  ```python
20
23
  from tide import configure, controlled_tool, record_outcome
21
24
 
@@ -35,3 +38,10 @@ except Exception:
35
38
 
36
39
  Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
37
40
  `configure(project="...")`.
41
+
42
+ ```bash
43
+ tide approvals
44
+ tide approve <id>
45
+ tide deny <id> --reason "not in scope"
46
+ tide report
47
+ ```
@@ -5,9 +5,12 @@ earned-use logging.
5
5
 
6
6
  ```bash
7
7
  pip install tide-runtime
8
- tide ledger init
8
+ tide init --pack support-refunds --environment prod
9
9
  ```
10
10
 
11
+ `tide init --dry-run --pack <name>` prints the policy before writing it.
12
+ Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
13
+
11
14
  ```python
12
15
  from tide import configure, controlled_tool, record_outcome
13
16
 
@@ -27,3 +30,10 @@ except Exception:
27
30
 
28
31
  Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
29
32
  `configure(project="...")`.
33
+
34
+ ```bash
35
+ tide approvals
36
+ tide approve <id>
37
+ tide deny <id> --reason "not in scope"
38
+ tide report
39
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tide-runtime"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "Tide runtime policy, approvals, and earned-use ledger for AI agent tools."
9
9
  requires-python = ">=3.8"
10
10
  readme = "README.md"
@@ -12,5 +12,8 @@ readme = "README.md"
12
12
  [project.urls]
13
13
  Repository = "https://github.com/rippletideco/rippletide-package"
14
14
 
15
+ [project.scripts]
16
+ tide = "tide.cli:main"
17
+
15
18
  [tool.setuptools]
16
19
  packages = ["tide"]
@@ -6,10 +6,14 @@ import tempfile
6
6
  import threading
7
7
  import time
8
8
  import unittest
9
+ from contextlib import redirect_stdout
10
+ from io import StringIO
9
11
 
10
12
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
11
13
 
12
14
  import tide
15
+ from tide import cli as tide_cli
16
+ from tide import packs as tide_packs
13
17
  from tide import policy as tide_policy
14
18
  from tide import store as tide_store
15
19
 
@@ -179,6 +183,59 @@ class TideRuntimeTest(unittest.TestCase):
179
183
  self.assertEqual(outcome["trace_id"], events(self.project)[0]["trace_id"])
180
184
  self.assertEqual(outcome["status"], "success")
181
185
 
186
+ def test_packs_generate_reviewable_policy(self):
187
+ pol = tide_packs.get("support-refunds", environment="prod")
188
+ self.assertEqual(pol["pack"], "support-refunds")
189
+ self.assertEqual(pol["tools"]["stripe.refund"]["decision"], "require_approval")
190
+ self.assertEqual(pol["tools"]["stripe.customer.delete"]["decision"], "block")
191
+
192
+ def test_cli_init_writes_pack_policy(self):
193
+ out = StringIO()
194
+ with redirect_stdout(out):
195
+ code = tide_cli.main(["init", "--project", self.project, "--pack", "sql-analyst", "--force"])
196
+ self.assertEqual(code, 0)
197
+ pol = tide_store.load_policy(self.project)
198
+ self.assertEqual(pol["pack"], "sql-analyst")
199
+ self.assertEqual(pol["decisions"]["internal_write"]["prod"], "block")
200
+
201
+ def test_cli_dry_run_does_not_write_policy(self):
202
+ shutil.rmtree(self.project)
203
+ out = StringIO()
204
+ with redirect_stdout(out):
205
+ code = tide_cli.main(["init", "--project", self.project, "--pack", "mcp-baseline", "--dry-run"])
206
+ self.assertEqual(code, 0)
207
+ self.assertIn('"pack": "mcp-baseline"', out.getvalue())
208
+ self.assertFalse(os.path.exists(os.path.join(self.project, tide_store.POLICY_FILE)))
209
+
210
+ def test_cli_approves_pending_request(self):
211
+ @tide.controlled_tool(name="stripe.refund", risk_class="money_movement", capture=["charge_id"])
212
+ def refund(charge_id):
213
+ return "refunded"
214
+
215
+ with self.assertRaises(tide.LedgerPending) as ctx:
216
+ refund("ch_1")
217
+ prefix = ctx.exception.approval_id[:8]
218
+ out = StringIO()
219
+ with redirect_stdout(out):
220
+ code = tide_cli.main(["approve", prefix, "--project", self.project, "--reason", "ok"])
221
+ self.assertEqual(code, 0)
222
+ self.assertIn("one-shot", out.getvalue())
223
+ self.assertEqual(tide_store.approval_state(self.project, ctx.exception.approval_id), "approved")
224
+
225
+ def test_cli_report_prints_earned_use(self):
226
+ @tide.controlled_tool(name="crm.get_lead", risk_class="read_only")
227
+ def get_lead(lead_id):
228
+ return "lead"
229
+
230
+ get_lead("L-1")
231
+ tide.record_outcome("success")
232
+ out = StringIO()
233
+ with redirect_stdout(out):
234
+ code = tide_cli.main(["report", "--project", self.project])
235
+ self.assertEqual(code, 0)
236
+ self.assertIn("Earned use by tool", out.getvalue())
237
+ self.assertIn("crm.get_lead", out.getvalue())
238
+
182
239
 
183
240
  if __name__ == "__main__":
184
241
  unittest.main()
@@ -26,7 +26,7 @@ import time
26
26
  from . import policy as _policy
27
27
  from . import store as _store
28
28
 
29
- __version__ = "0.1.0"
29
+ __version__ = "0.2.6"
30
30
 
31
31
  __all__ = [
32
32
  "configure",
@@ -0,0 +1,240 @@
1
+ """CLI for the local Tide ledger."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from . import __version__
9
+ from . import packs
10
+ from . import store
11
+
12
+ DEFAULT_PROJECT = os.environ.get("TIDE_LEDGER_DIR", ".tide/ledger")
13
+
14
+
15
+ def _write_policy(args):
16
+ pol = packs.get(args.pack, args.environment)
17
+ if args.dry_run:
18
+ print(json.dumps(pol, indent=2, sort_keys=True))
19
+ return 0
20
+ store.ensure_project(args.project)
21
+ path = os.path.join(args.project, store.POLICY_FILE)
22
+ if os.path.exists(path) and not args.force:
23
+ print("tide: policy already exists at %s" % path, file=sys.stderr)
24
+ print("tide: use --force to replace it or --dry-run to preview a pack", file=sys.stderr)
25
+ return 1
26
+ with open(path, "w", encoding="utf-8") as f:
27
+ json.dump(pol, f, indent=2, sort_keys=True)
28
+ f.write("\n")
29
+ print("tide: wrote %s policy -> %s" % (args.pack, path))
30
+ print("tide: wrap risky tools with @controlled_tool to enforce it.")
31
+ return 0
32
+
33
+
34
+ def _approval_entries(project):
35
+ requests = {}
36
+ for r in store.read_jsonl(project, store.APPROVALS_FILE):
37
+ aid = r.get("approval_id")
38
+ if r.get("type") == "request":
39
+ requests[aid] = {"request": r, "state": "pending", "decision": None}
40
+ elif r.get("type") == "decision" and aid in requests:
41
+ requests[aid]["state"] = "approved" if r.get("decision") == "approved" else "denied"
42
+ requests[aid]["decision"] = r
43
+ elif r.get("type") == "consumed" and aid in requests:
44
+ requests[aid]["state"] = "consumed"
45
+ return requests
46
+
47
+
48
+ def _resolve_approval(project, prefix):
49
+ matches = [aid for aid in _approval_entries(project) if aid.startswith(prefix)]
50
+ if not matches:
51
+ raise ValueError("no approval found matching '%s'" % prefix)
52
+ if len(matches) > 1:
53
+ raise ValueError("'%s' is ambiguous; use more characters" % prefix)
54
+ return matches[0]
55
+
56
+
57
+ def _approvals(args):
58
+ pending = [e["request"] for e in _approval_entries(args.project).values() if e["state"] == "pending"]
59
+ if not pending:
60
+ print("tide: no pending approvals")
61
+ return 0
62
+ print("tide: %d pending approval(s)\n" % len(pending))
63
+ for r in pending:
64
+ print(" %s %s@%s [%s]" % (
65
+ r["approval_id"][:8],
66
+ r.get("tool") or "?",
67
+ r.get("tool_version") or "?",
68
+ r.get("risk_class") or "?",
69
+ ))
70
+ print(" agent=%s workflow=%s at %s" % (
71
+ r.get("agent_id") or "?",
72
+ r.get("workflow_id") or "?",
73
+ r.get("created_at") or "?",
74
+ ))
75
+ if r.get("input_summary"):
76
+ print(" input: %s" % json.dumps(r["input_summary"], sort_keys=True))
77
+ print(" reason: %s" % (r.get("policy_reason") or "?"))
78
+ print(" approve: tide approve %s --project %s\n" % (r["approval_id"][:8], args.project))
79
+ return 0
80
+
81
+
82
+ def _decide(args, decision):
83
+ try:
84
+ aid = _resolve_approval(args.project, args.approval_id)
85
+ except ValueError as exc:
86
+ print("tide: %s" % exc, file=sys.stderr)
87
+ return 1
88
+ entry = _approval_entries(args.project)[aid]
89
+ if entry["state"] != "pending":
90
+ print("tide: approval %s is already %s" % (aid[:8], entry["state"]), file=sys.stderr)
91
+ return 1
92
+ store.append_jsonl(args.project, store.APPROVALS_FILE, {
93
+ "type": "decision",
94
+ "approval_id": aid,
95
+ "decision": decision,
96
+ "approver": os.environ.get("USER") or os.environ.get("USERNAME") or "unknown",
97
+ "reason": args.reason,
98
+ "created_at": store.now_iso(),
99
+ })
100
+ req = entry["request"]
101
+ print("tide: %s %s - %s (%s)" % (decision, aid[:8], req.get("tool"), req.get("risk_class")))
102
+ if decision == "approved":
103
+ print("tide: grant is one-shot and bound to the exact call.")
104
+ return 0
105
+
106
+
107
+ def _pct(n, d):
108
+ return "%d%%" % round((100 * n) / d) if d else "-"
109
+
110
+
111
+ def _report(args):
112
+ events = store.read_jsonl(args.project, store.EVENTS_FILE)
113
+ outcomes = store.read_jsonl(args.project, store.OUTCOMES_FILE)
114
+ if not events and not outcomes:
115
+ print("tide: no events in %s - wrap tools with @controlled_tool and run your agent." % os.path.abspath(args.project))
116
+ return 1
117
+
118
+ by_decision = {}
119
+ for e in events:
120
+ key = e.get("policy_decision")
121
+ by_decision[key] = by_decision.get(key, 0) + 1
122
+ print("Tide Ledger - %s" % os.path.abspath(args.project))
123
+ print("\nTool calls: %d total (%s)" % (
124
+ len(events),
125
+ " / ".join("%s: %d" % (d, by_decision.get(d, 0)) for d in ("allow", "allow_with_log", "require_approval", "block")),
126
+ ))
127
+
128
+ approvals = _approval_entries(args.project).values()
129
+ approval_counts = {"pending": 0, "approved": 0, "denied": 0, "consumed": 0}
130
+ for e in approvals:
131
+ approval_counts[e["state"]] = approval_counts.get(e["state"], 0) + 1
132
+ print("Approvals: %d requested (pending: %d / approved: %d / consumed: %d / denied: %d)" % (
133
+ sum(approval_counts.values()),
134
+ approval_counts.get("pending", 0),
135
+ approval_counts.get("approved", 0),
136
+ approval_counts.get("consumed", 0),
137
+ approval_counts.get("denied", 0),
138
+ ))
139
+
140
+ trace_outcomes = {}
141
+ for o in outcomes:
142
+ bad = o.get("status") != "success" or bool(o.get("human_corrected"))
143
+ prev = trace_outcomes.get(o.get("trace_id"))
144
+ if not prev or bad:
145
+ trace_outcomes[o.get("trace_id")] = {"success": not bad, "corrected": bool(o.get("human_corrected"))}
146
+ print("Outcomes: %d recorded across %d trace(s)" % (len(outcomes), len(trace_outcomes)))
147
+
148
+ tools = {}
149
+ for e in events:
150
+ key = "%s@%s" % (e.get("tool"), e.get("tool_version") or "?")
151
+ t = tools.setdefault(key, {"calls": 0, "blocked": 0, "denied": 0, "errors": 0, "linked": 0, "helped": 0, "corrected": 0})
152
+ t["calls"] += 1
153
+ if e.get("policy_decision") == "block":
154
+ t["blocked"] += 1
155
+ if e.get("approval_status") == "denied":
156
+ t["denied"] += 1
157
+ if e.get("error_code") and e.get("error_code") not in ("blocked_by_policy", "approval_denied", "approval_timeout"):
158
+ t["errors"] += 1
159
+ oc = trace_outcomes.get(e.get("trace_id"))
160
+ if oc and e.get("output_hash"):
161
+ t["linked"] += 1
162
+ if oc["success"]:
163
+ t["helped"] += 1
164
+ if oc["corrected"]:
165
+ t["corrected"] += 1
166
+
167
+ print("\nEarned use by tool:")
168
+ print(" tool calls blocked denied errors outcome-linked success corrected")
169
+ for key, t in sorted(tools.items(), key=lambda item: item[1]["calls"], reverse=True):
170
+ print(
171
+ " %-40s%5d %7d %6d %6d %14d %7s %9s" % (
172
+ key[:40], t["calls"], t["blocked"], t["denied"], t["errors"],
173
+ t["linked"], _pct(t["helped"], t["linked"]), _pct(t["corrected"], t["linked"]),
174
+ )
175
+ )
176
+ return 0
177
+
178
+
179
+ def _packs(args):
180
+ if args.name:
181
+ print(json.dumps(packs.get(args.name), indent=2, sort_keys=True))
182
+ return 0
183
+ for name in packs.names():
184
+ print("%-16s %s" % (name, packs.PACKS[name].get("description", "")))
185
+ return 0
186
+
187
+
188
+ def build_parser():
189
+ parser = argparse.ArgumentParser(description="Tide local policy and approvals for AI agent tools.")
190
+ parser.add_argument("--version", action="version", version="tide-runtime %s" % __version__)
191
+ sub = parser.add_subparsers(dest="command")
192
+
193
+ init = sub.add_parser("init", help="write .tide/ledger/policy.json")
194
+ init.add_argument("--project", default=DEFAULT_PROJECT)
195
+ init.add_argument("--pack", choices=packs.names(), default="baseline")
196
+ init.add_argument("--environment", choices=("dev", "prod"), default=None)
197
+ init.add_argument("--dry-run", action="store_true")
198
+ init.add_argument("--force", action="store_true")
199
+ init.set_defaults(func=_write_policy)
200
+
201
+ show_packs = sub.add_parser("packs", help="list packs or print one pack as JSON")
202
+ show_packs.add_argument("name", nargs="?", choices=packs.names())
203
+ show_packs.set_defaults(func=_packs)
204
+
205
+ approvals = sub.add_parser("approvals", help="list pending approval requests")
206
+ approvals.add_argument("--project", default=DEFAULT_PROJECT)
207
+ approvals.set_defaults(func=_approvals)
208
+
209
+ approve = sub.add_parser("approve", help="approve one exact call")
210
+ approve.add_argument("approval_id")
211
+ approve.add_argument("--project", default=DEFAULT_PROJECT)
212
+ approve.add_argument("--reason")
213
+ approve.set_defaults(func=lambda args: _decide(args, "approved"))
214
+
215
+ deny = sub.add_parser("deny", help="deny a request")
216
+ deny.add_argument("approval_id")
217
+ deny.add_argument("--project", default=DEFAULT_PROJECT)
218
+ deny.add_argument("--reason")
219
+ deny.set_defaults(func=lambda args: _decide(args, "denied"))
220
+
221
+ report = sub.add_parser("report", help="tool calls, approvals, outcomes, earned-use per tool")
222
+ report.add_argument("--project", default=DEFAULT_PROJECT)
223
+ report.set_defaults(func=_report)
224
+ return parser
225
+
226
+
227
+ def main(argv=None):
228
+ argv = list(sys.argv[1:] if argv is None else argv)
229
+ if argv[:1] == ["ledger"]:
230
+ argv = argv[1:]
231
+ parser = build_parser()
232
+ args = parser.parse_args(argv)
233
+ if not hasattr(args, "func"):
234
+ parser.print_help()
235
+ return 1
236
+ return args.func(args)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ raise SystemExit(main())
@@ -0,0 +1,70 @@
1
+ """Built-in starter policies."""
2
+
3
+ from copy import deepcopy
4
+
5
+ from .policy import DEFAULT_POLICY
6
+
7
+
8
+ def _policy(environment="prod", tools=None, decisions=None, description=None):
9
+ pol = deepcopy(DEFAULT_POLICY)
10
+ pol["environment"] = environment
11
+ if decisions:
12
+ pol["decisions"].update(decisions)
13
+ if tools:
14
+ pol["tools"].update(tools)
15
+ if description:
16
+ pol["description"] = description
17
+ return pol
18
+
19
+
20
+ PACKS = {
21
+ "baseline": _policy(
22
+ description="Default Tide policy: approve risky writes, block destructive prod actions.",
23
+ ),
24
+ "support-refunds": _policy(
25
+ description="Support/refund agents: let reads and drafts through, require approval for refunds/customer-visible sends, block destructive customer deletes.",
26
+ tools={
27
+ "stripe.refund": {"decision": "require_approval", "reason": "refund moves money"},
28
+ "stripe.refunds.create": {"decision": "require_approval", "reason": "refund moves money"},
29
+ "shopify.refund": {"decision": "require_approval", "reason": "refund moves money"},
30
+ "shopify.order.refund": {"decision": "require_approval", "reason": "refund moves money"},
31
+ "zendesk.ticket.reply": {"decision": "require_approval", "reason": "customer-visible message"},
32
+ "intercom.message.send": {"decision": "require_approval", "reason": "customer-visible message"},
33
+ "stripe.customer.delete": {"decision": "block", "reason": "customer deletion is outside agent scope"},
34
+ "shopify.customer.delete": {"decision": "block", "reason": "customer deletion is outside agent scope"},
35
+ },
36
+ ),
37
+ "sql-analyst": _policy(
38
+ description="SQL analyst agents: allow read-only queries, block write/destructive classes in every environment.",
39
+ decisions={
40
+ "internal_write": {"dev": "block", "prod": "block"},
41
+ "destructive": {"dev": "block", "prod": "block"},
42
+ },
43
+ ),
44
+ "mcp-baseline": _policy(
45
+ description="MCP agents: approve shell/filesystem/GitHub writes, block common destructive calls.",
46
+ tools={
47
+ "filesystem.write_file": {"decision": "require_approval", "reason": "file write through MCP"},
48
+ "filesystem.delete_file": {"decision": "block", "reason": "file deletion through MCP"},
49
+ "shell.run": {"decision": "require_approval", "reason": "shell command through MCP"},
50
+ "github.merge_pull_request": {"decision": "require_approval", "reason": "repository state change"},
51
+ "github.delete_branch": {"decision": "require_approval", "reason": "repository state change"},
52
+ "kubernetes.delete": {"decision": "block", "reason": "destructive infrastructure action"},
53
+ "terraform.destroy": {"decision": "block", "reason": "destructive infrastructure action"},
54
+ },
55
+ ),
56
+ }
57
+
58
+
59
+ def names():
60
+ return sorted(PACKS)
61
+
62
+
63
+ def get(name, environment=None):
64
+ if name not in PACKS:
65
+ raise KeyError(name)
66
+ pol = deepcopy(PACKS[name])
67
+ if environment:
68
+ pol["environment"] = environment
69
+ pol["pack"] = name
70
+ return pol
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tide-runtime
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
5
5
  Project-URL: Repository, https://github.com/rippletideco/rippletide-package
6
6
  Requires-Python: >=3.8
@@ -13,9 +13,12 @@ earned-use logging.
13
13
 
14
14
  ```bash
15
15
  pip install tide-runtime
16
- tide ledger init
16
+ tide init --pack support-refunds --environment prod
17
17
  ```
18
18
 
19
+ `tide init --dry-run --pack <name>` prints the policy before writing it.
20
+ Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
21
+
19
22
  ```python
20
23
  from tide import configure, controlled_tool, record_outcome
21
24
 
@@ -35,3 +38,10 @@ except Exception:
35
38
 
36
39
  Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
37
40
  `configure(project="...")`.
41
+
42
+ ```bash
43
+ tide approvals
44
+ tide approve <id>
45
+ tide deny <id> --reason "not in scope"
46
+ tide report
47
+ ```
@@ -2,9 +2,12 @@ README.md
2
2
  pyproject.toml
3
3
  tests/test_runtime.py
4
4
  tide/__init__.py
5
+ tide/cli.py
6
+ tide/packs.py
5
7
  tide/policy.py
6
8
  tide/store.py
7
9
  tide_runtime.egg-info/PKG-INFO
8
10
  tide_runtime.egg-info/SOURCES.txt
9
11
  tide_runtime.egg-info/dependency_links.txt
12
+ tide_runtime.egg-info/entry_points.txt
10
13
  tide_runtime.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tide = tide.cli:main
File without changes
File without changes