etch-record 0.1.0__py3-none-any.whl
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.
- etch_record/__init__.py +3 -0
- etch_record/cli.py +619 -0
- etch_record/config.py +47 -0
- etch_record/governance_client.py +146 -0
- etch_record/mcp_client.py +178 -0
- etch_record/rate_limit.py +111 -0
- etch_record-0.1.0.dist-info/METADATA +147 -0
- etch_record-0.1.0.dist-info/RECORD +11 -0
- etch_record-0.1.0.dist-info/WHEEL +4 -0
- etch_record-0.1.0.dist-info/entry_points.txt +2 -0
- etch_record-0.1.0.dist-info/licenses/LICENSE +21 -0
etch_record/__init__.py
ADDED
etch_record/cli.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""CLI entry point for etch-record.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
etch-record "description of the event" [OPTIONS]
|
|
5
|
+
|
|
6
|
+
Only one event_type value is accepted by the Etch server enum today for
|
|
7
|
+
arbitrary user-recorded events: "tool_call". Anything else silently
|
|
8
|
+
drops server-side (this is the exact bug the /docs/quickstart Common
|
|
9
|
+
Gotchas section warns about). So we hardcode "tool_call" and let
|
|
10
|
+
callers differentiate via --tags + --evidence-json.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .config import ConfigError, load
|
|
25
|
+
from .governance_client import GovernanceError, record_governance
|
|
26
|
+
from .mcp_client import McpError, extract_event_id, record_event
|
|
27
|
+
from .rate_limit import check_and_record
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_LARGE_ARG_THRESHOLD_CHARS = 4096
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_session_id() -> str:
|
|
34
|
+
"""Group events emitted on the same UTC calendar day into one session."""
|
|
35
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
36
|
+
return f"etch-record-{today}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_tags(raw: str | None) -> list[str]:
|
|
40
|
+
if not raw:
|
|
41
|
+
return []
|
|
42
|
+
return [t.strip() for t in raw.split(",") if t.strip()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_evidence(
|
|
46
|
+
evidence_json: str | None,
|
|
47
|
+
evidence_file: Path | None,
|
|
48
|
+
) -> dict:
|
|
49
|
+
if evidence_json and evidence_file:
|
|
50
|
+
raise click.UsageError(
|
|
51
|
+
"Pass only one of --evidence-json or --evidence-file, not both.",
|
|
52
|
+
)
|
|
53
|
+
if evidence_json:
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(evidence_json)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise click.UsageError(
|
|
58
|
+
f"--evidence-json is not valid JSON: {exc}",
|
|
59
|
+
) from exc
|
|
60
|
+
if not isinstance(parsed, dict):
|
|
61
|
+
raise click.UsageError("--evidence-json must be a JSON object.")
|
|
62
|
+
return parsed
|
|
63
|
+
if evidence_file:
|
|
64
|
+
try:
|
|
65
|
+
parsed = json.loads(evidence_file.read_text())
|
|
66
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
67
|
+
raise click.UsageError(
|
|
68
|
+
f"--evidence-file could not be loaded: {exc}",
|
|
69
|
+
) from exc
|
|
70
|
+
if not isinstance(parsed, dict):
|
|
71
|
+
raise click.UsageError("--evidence-file must contain a JSON object.")
|
|
72
|
+
return parsed
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_text_source(
|
|
77
|
+
inline_value: str | None,
|
|
78
|
+
file_path: Path | None,
|
|
79
|
+
field_name: str,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Load a text field from either an inline CLI arg or a file.
|
|
82
|
+
|
|
83
|
+
Symmetric with _load_evidence: exactly one may be set.
|
|
84
|
+
File contents are stripped of ONE trailing newline (a common editor
|
|
85
|
+
artifact) and otherwise passed through verbatim.
|
|
86
|
+
"""
|
|
87
|
+
if inline_value and file_path:
|
|
88
|
+
raise click.UsageError(
|
|
89
|
+
f"Pass only one of --{field_name} or --{field_name}-file, not both.",
|
|
90
|
+
)
|
|
91
|
+
if file_path is not None:
|
|
92
|
+
try:
|
|
93
|
+
text = file_path.read_text()
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
raise click.UsageError(
|
|
96
|
+
f"--{field_name}-file could not be loaded: {exc}",
|
|
97
|
+
) from exc
|
|
98
|
+
if text.endswith("\n"):
|
|
99
|
+
text = text[:-1]
|
|
100
|
+
return text
|
|
101
|
+
return inline_value or ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_description(positional: str, file_path: Path | None) -> str:
|
|
105
|
+
"""Description resolution with click's required-positional in mind.
|
|
106
|
+
|
|
107
|
+
- No file: positional is the description (legacy behavior).
|
|
108
|
+
- With file: file content becomes the description; positional stays
|
|
109
|
+
as a short human label captured in the Layer A Bash-hook trace
|
|
110
|
+
but is NOT the recorded payload.
|
|
111
|
+
"""
|
|
112
|
+
if file_path is None:
|
|
113
|
+
return positional
|
|
114
|
+
try:
|
|
115
|
+
text = file_path.read_text()
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise click.UsageError(
|
|
118
|
+
f"--description-file could not be loaded: {exc}",
|
|
119
|
+
) from exc
|
|
120
|
+
if text.endswith("\n"):
|
|
121
|
+
text = text[:-1]
|
|
122
|
+
return text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_action_time(
|
|
126
|
+
action_time: str | None,
|
|
127
|
+
action_time_now: bool,
|
|
128
|
+
) -> str | None:
|
|
129
|
+
"""Normalize the real-world action timestamp for the evidence dict.
|
|
130
|
+
|
|
131
|
+
Returns an ISO-8601 UTC string ("YYYY-MM-DDTHH:MM:SSZ") or None
|
|
132
|
+
when neither flag is set. Rejects passing both at once.
|
|
133
|
+
|
|
134
|
+
Accepts any ISO-8601 form recognized by datetime.fromisoformat
|
|
135
|
+
(Python 3.10+ compatible - Z suffix is normalized to +00:00 first).
|
|
136
|
+
A timezone MUST be present; a naive datetime is rejected because
|
|
137
|
+
"action_time_utc without a timezone" is ambiguous and worse than
|
|
138
|
+
no action_time at all.
|
|
139
|
+
"""
|
|
140
|
+
if action_time and action_time_now:
|
|
141
|
+
raise click.UsageError(
|
|
142
|
+
"Pass only one of --action-time or --action-time-now, not both.",
|
|
143
|
+
)
|
|
144
|
+
if action_time_now:
|
|
145
|
+
now_utc = datetime.now(timezone.utc)
|
|
146
|
+
return now_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
147
|
+
if action_time is None:
|
|
148
|
+
return None
|
|
149
|
+
raw = action_time.strip()
|
|
150
|
+
if not raw:
|
|
151
|
+
raise click.UsageError("--action-time was empty.")
|
|
152
|
+
# Python 3.10 fromisoformat does NOT recognize the Z suffix. Normalize.
|
|
153
|
+
normalized = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
154
|
+
try:
|
|
155
|
+
dt = datetime.fromisoformat(normalized)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise click.UsageError(
|
|
158
|
+
f"--action-time is not a valid ISO-8601 timestamp: {exc}. "
|
|
159
|
+
"Examples: 2026-07-28T14:03:00Z, 2026-07-28T19:33:00+05:30.",
|
|
160
|
+
) from exc
|
|
161
|
+
if dt.tzinfo is None:
|
|
162
|
+
raise click.UsageError(
|
|
163
|
+
"--action-time must include a timezone (e.g. 'Z' suffix or "
|
|
164
|
+
"+HH:MM offset). Naive timestamps are ambiguous.",
|
|
165
|
+
)
|
|
166
|
+
utc_dt = dt.astimezone(timezone.utc)
|
|
167
|
+
return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _maybe_warn_large_arg(name: str, value: str) -> None:
|
|
171
|
+
"""Print a stderr warning when a text arg passed via CLI is large.
|
|
172
|
+
|
|
173
|
+
Large CLI args are a footgun: Claude Code's Bash-hook records the
|
|
174
|
+
invocation with its arguments hashed into an audit event, which
|
|
175
|
+
doubles the payload on the chain and (in multi-tenant deploys)
|
|
176
|
+
can cross tenant boundaries. --reasoning-file / --description-file
|
|
177
|
+
bypass this. This warning nudges users toward those flags for
|
|
178
|
+
sensitive content without blocking legitimate use.
|
|
179
|
+
"""
|
|
180
|
+
if len(value) > _LARGE_ARG_THRESHOLD_CHARS:
|
|
181
|
+
click.echo(
|
|
182
|
+
f"etch-record: warning: --{name} is {len(value)} chars via CLI "
|
|
183
|
+
f"argv. Consider --{name}-file for large or sensitive content; "
|
|
184
|
+
f"CLI args are captured verbatim by shell-hook auditors.",
|
|
185
|
+
err=True,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@click.command(
|
|
190
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
191
|
+
help=(
|
|
192
|
+
"Sign one event into your Etch chain from the command line.\n\n"
|
|
193
|
+
"Every call becomes a signed record_event on your Etch project."
|
|
194
|
+
" Requires ETCH_PROJECT_ID and ETCH_APP_TOKEN in the environment."
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
@click.argument("description", type=str)
|
|
198
|
+
@click.option(
|
|
199
|
+
"--tags",
|
|
200
|
+
"tags_raw",
|
|
201
|
+
type=str,
|
|
202
|
+
default=None,
|
|
203
|
+
help="Comma-separated tags (e.g. research,x,launch). Become the event's entities.",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--evidence-json",
|
|
207
|
+
type=str,
|
|
208
|
+
default=None,
|
|
209
|
+
help="Inline JSON object with extra structured evidence.",
|
|
210
|
+
)
|
|
211
|
+
@click.option(
|
|
212
|
+
"--evidence-file",
|
|
213
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
214
|
+
default=None,
|
|
215
|
+
help="Path to a JSON file containing an evidence object.",
|
|
216
|
+
)
|
|
217
|
+
@click.option(
|
|
218
|
+
"--session-id",
|
|
219
|
+
type=str,
|
|
220
|
+
default=None,
|
|
221
|
+
help=(
|
|
222
|
+
"Group this event with others under the same session id. "
|
|
223
|
+
"Default: etch-record-YYYY-MM-DD (current UTC date)."
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
@click.option(
|
|
227
|
+
"--reasoning",
|
|
228
|
+
type=str,
|
|
229
|
+
default=None,
|
|
230
|
+
help="Optional freeform reasoning string.",
|
|
231
|
+
)
|
|
232
|
+
@click.option(
|
|
233
|
+
"--reasoning-file",
|
|
234
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
235
|
+
default=None,
|
|
236
|
+
help=(
|
|
237
|
+
"Path to a text file whose contents become the reasoning field. "
|
|
238
|
+
"Use this instead of --reasoning for large or sensitive content: "
|
|
239
|
+
"CLI args are captured verbatim by shell-hook auditors."
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
@click.option(
|
|
243
|
+
"--description-file",
|
|
244
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
245
|
+
default=None,
|
|
246
|
+
help=(
|
|
247
|
+
"Path to a text file whose contents OVERRIDE the description "
|
|
248
|
+
"positional argument. When set, the description positional "
|
|
249
|
+
"argument may be a short label like 'from-file'; the file "
|
|
250
|
+
"contents become the actual description in the event. Same "
|
|
251
|
+
"safety motivation as --reasoning-file."
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"--from-hook",
|
|
256
|
+
"from_hook",
|
|
257
|
+
type=str,
|
|
258
|
+
default=None,
|
|
259
|
+
help=(
|
|
260
|
+
"Reference id of the Claude Code hook (Layer A) event this "
|
|
261
|
+
"semantic event corresponds to. Adds `layer_a_ref` to evidence "
|
|
262
|
+
"and the `hook_bound` tag for explicit two-layer bind. Env "
|
|
263
|
+
"fallback: ETCH_HOOK_TOOL_USE_ID."
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
@click.option(
|
|
267
|
+
"--action-time",
|
|
268
|
+
"action_time",
|
|
269
|
+
type=str,
|
|
270
|
+
default=None,
|
|
271
|
+
help=(
|
|
272
|
+
"Real-world timestamp of the action being recorded, ISO-8601 "
|
|
273
|
+
"with timezone (e.g. 2026-07-28T14:03:00Z). Adds "
|
|
274
|
+
"`action_time_utc` to evidence so downstream audit reconstructs "
|
|
275
|
+
"when the action HAPPENED, not just when it was signed. Use for "
|
|
276
|
+
"after-the-fact signing where signing-time and action-time differ."
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
@click.option(
|
|
280
|
+
"--action-time-now",
|
|
281
|
+
is_flag=True,
|
|
282
|
+
default=False,
|
|
283
|
+
help=(
|
|
284
|
+
"Shortcut for --action-time set to the current UTC. Useful when "
|
|
285
|
+
"signing immediately after the action."
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
@click.option(
|
|
289
|
+
"--force-no-rate-limit",
|
|
290
|
+
is_flag=True,
|
|
291
|
+
default=False,
|
|
292
|
+
help=(
|
|
293
|
+
"Bypass the client-side sliding-window rate limit for this "
|
|
294
|
+
"invocation. Use only for legitimate batch or backfill flows."
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--failed",
|
|
299
|
+
is_flag=True,
|
|
300
|
+
default=False,
|
|
301
|
+
help="Mark the event as unsuccessful (success=false).",
|
|
302
|
+
)
|
|
303
|
+
@click.option(
|
|
304
|
+
"--dry-run",
|
|
305
|
+
is_flag=True,
|
|
306
|
+
default=False,
|
|
307
|
+
help="Print the payload that would be sent, do not call Etch.",
|
|
308
|
+
)
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Wave 1 #1 (2026-08-01) — extended governance schema flags.
|
|
311
|
+
# When any of these is set, the CLI makes TWO calls:
|
|
312
|
+
# 1. mcp_client.record_event → creates the base OSS event (unchanged)
|
|
313
|
+
# 2. governance_client.record_governance → attaches a signed
|
|
314
|
+
# governance sub-record to the Etch parallel chain, cross-
|
|
315
|
+
# referencing the OSS event by id
|
|
316
|
+
# When none are set, the CLI behaves exactly as before (single
|
|
317
|
+
# record_event call). Zero behavior change for callers that don't
|
|
318
|
+
# opt in.
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
@click.option(
|
|
321
|
+
"--policy-hash",
|
|
322
|
+
"policy_hash",
|
|
323
|
+
type=str,
|
|
324
|
+
default=None,
|
|
325
|
+
help=(
|
|
326
|
+
"Wave 1 #1 governance: SHA-256 hash of the governance policy "
|
|
327
|
+
"under which this decision was evaluated. Must be 'sha256:<64-hex>' "
|
|
328
|
+
"shape. Triggers a second call to /v1/etch-chain/governance-record "
|
|
329
|
+
"on the Etch chain."
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
@click.option(
|
|
333
|
+
"--authority-file",
|
|
334
|
+
"authority_file",
|
|
335
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
336
|
+
default=None,
|
|
337
|
+
help=(
|
|
338
|
+
"Wave 1 #1 governance: path to a JSON file describing the "
|
|
339
|
+
"approving authority: {identity, scope, expires_at}. See "
|
|
340
|
+
"docs/wave-1-1.md for the schema."
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
@click.option(
|
|
344
|
+
"--assumptions-file",
|
|
345
|
+
"assumptions_file",
|
|
346
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
347
|
+
default=None,
|
|
348
|
+
help=(
|
|
349
|
+
"Wave 1 #1 governance: path to a JSON file listing assumptions "
|
|
350
|
+
"accepted when reaching the decision. Each entry is "
|
|
351
|
+
"{claim, source_ref}."
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
@click.option(
|
|
355
|
+
"--uncertainty",
|
|
356
|
+
"uncertainty",
|
|
357
|
+
type=str,
|
|
358
|
+
default=None,
|
|
359
|
+
help=(
|
|
360
|
+
"Wave 1 #1 governance: uncertainty attached to the decision, "
|
|
361
|
+
"given as 'confidence:basis' (e.g. "
|
|
362
|
+
"'0.87:evidence-hash-lookup-match-rate'). Confidence must be "
|
|
363
|
+
"[0.0, 1.0]. For richer shapes use --uncertainty-file."
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
@click.option(
|
|
367
|
+
"--uncertainty-file",
|
|
368
|
+
"uncertainty_file",
|
|
369
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
370
|
+
default=None,
|
|
371
|
+
help=(
|
|
372
|
+
"Wave 1 #1 governance: path to a JSON file with the full "
|
|
373
|
+
"uncertainty object: {confidence, basis}."
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
@click.option(
|
|
377
|
+
"--invalidation-file",
|
|
378
|
+
"invalidation_file",
|
|
379
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
380
|
+
default=None,
|
|
381
|
+
help=(
|
|
382
|
+
"Wave 1 #1 governance: path to a JSON file listing conditions "
|
|
383
|
+
"under which the approval would have been invalidated. Each "
|
|
384
|
+
"entry is {\"if\": ..., \"then\": ...}."
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
@click.version_option(__version__, prog_name="etch-record")
|
|
388
|
+
def main(
|
|
389
|
+
description: str,
|
|
390
|
+
tags_raw: str | None,
|
|
391
|
+
evidence_json: str | None,
|
|
392
|
+
evidence_file: Path | None,
|
|
393
|
+
session_id: str | None,
|
|
394
|
+
reasoning: str | None,
|
|
395
|
+
reasoning_file: Path | None,
|
|
396
|
+
description_file: Path | None,
|
|
397
|
+
from_hook: str | None,
|
|
398
|
+
action_time: str | None,
|
|
399
|
+
action_time_now: bool,
|
|
400
|
+
force_no_rate_limit: bool,
|
|
401
|
+
failed: bool,
|
|
402
|
+
dry_run: bool,
|
|
403
|
+
policy_hash: str | None,
|
|
404
|
+
authority_file: Path | None,
|
|
405
|
+
assumptions_file: Path | None,
|
|
406
|
+
uncertainty: str | None,
|
|
407
|
+
uncertainty_file: Path | None,
|
|
408
|
+
invalidation_file: Path | None,
|
|
409
|
+
) -> None:
|
|
410
|
+
tags = _parse_tags(tags_raw)
|
|
411
|
+
evidence = _load_evidence(evidence_json, evidence_file)
|
|
412
|
+
session = session_id or _default_session_id()
|
|
413
|
+
|
|
414
|
+
# Resolve reasoning from either inline arg or file (symmetric with
|
|
415
|
+
# --evidence-json / --evidence-file split; both flags are optional).
|
|
416
|
+
resolved_reasoning = _resolve_text_source(
|
|
417
|
+
reasoning, reasoning_file, "reasoning",
|
|
418
|
+
)
|
|
419
|
+
# Description is special: the positional CLI arg is REQUIRED by click,
|
|
420
|
+
# so users cannot omit it. When --description-file is passed, the file
|
|
421
|
+
# content becomes the actual event description; the positional serves
|
|
422
|
+
# as a short human label the user sees in shell history + the Layer A
|
|
423
|
+
# Bash-hook event, keeping the sensitive payload off argv.
|
|
424
|
+
resolved_description = _resolve_description(description, description_file)
|
|
425
|
+
|
|
426
|
+
# Sensitive-arg guard: warn (do not block) on oversized inline
|
|
427
|
+
# text passed via CLI argv. Skipped when the file variant was used.
|
|
428
|
+
if reasoning and not reasoning_file:
|
|
429
|
+
_maybe_warn_large_arg("reasoning", reasoning)
|
|
430
|
+
if description and not description_file:
|
|
431
|
+
_maybe_warn_large_arg("description", description)
|
|
432
|
+
|
|
433
|
+
# Two-layer bind: --from-hook (or env fallback) adds an explicit
|
|
434
|
+
# cross-reference to the Layer A (mechanical) hook event.
|
|
435
|
+
hook_ref = from_hook or os.environ.get("ETCH_HOOK_TOOL_USE_ID", "").strip()
|
|
436
|
+
if hook_ref:
|
|
437
|
+
evidence = {**evidence, "layer_a_ref": hook_ref}
|
|
438
|
+
if "hook_bound" not in tags:
|
|
439
|
+
tags = [*tags, "hook_bound"]
|
|
440
|
+
|
|
441
|
+
# Real-world action timestamp (--action-time / --action-time-now):
|
|
442
|
+
# closes the drift between "when it happened" and "when I signed it".
|
|
443
|
+
resolved_action_time = _resolve_action_time(action_time, action_time_now)
|
|
444
|
+
if resolved_action_time is not None:
|
|
445
|
+
evidence = {**evidence, "action_time_utc": resolved_action_time}
|
|
446
|
+
|
|
447
|
+
arguments = {
|
|
448
|
+
"event_type": "tool_call",
|
|
449
|
+
"session_id": session,
|
|
450
|
+
"entities": tags,
|
|
451
|
+
"description": resolved_description,
|
|
452
|
+
"reasoning": resolved_reasoning,
|
|
453
|
+
"evidence": evidence,
|
|
454
|
+
"success": not failed,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if dry_run:
|
|
458
|
+
click.echo(
|
|
459
|
+
json.dumps(
|
|
460
|
+
{"method": "tools/call", "name": "record_event", "arguments": arguments},
|
|
461
|
+
indent=2,
|
|
462
|
+
sort_keys=True,
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# Client-side rate limit BEFORE config load: a rate-limited caller
|
|
468
|
+
# should get a clear message, not a config error, when their env
|
|
469
|
+
# is not yet set up. Disabled by --force-no-rate-limit or by
|
|
470
|
+
# ETCH_RATE_LIMIT_PER_MINUTE=0.
|
|
471
|
+
if not force_no_rate_limit:
|
|
472
|
+
allowed, count, limit = check_and_record()
|
|
473
|
+
if not allowed:
|
|
474
|
+
click.echo(
|
|
475
|
+
f"etch-record: rate limit hit ({count}/{limit} events in "
|
|
476
|
+
f"the last 60s). Wait a few seconds and retry, or pass "
|
|
477
|
+
f"--force-no-rate-limit for legitimate batch use. Adjust "
|
|
478
|
+
f"the ceiling with ETCH_RATE_LIMIT_PER_MINUTE.",
|
|
479
|
+
err=True,
|
|
480
|
+
)
|
|
481
|
+
sys.exit(4)
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
cfg = load()
|
|
485
|
+
except ConfigError as exc:
|
|
486
|
+
click.echo(str(exc), err=True)
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
body = record_event(cfg, arguments)
|
|
491
|
+
except McpError as exc:
|
|
492
|
+
click.echo(f"etch-record: {exc}", err=True)
|
|
493
|
+
sys.exit(2)
|
|
494
|
+
except Exception as exc: # noqa: BLE001
|
|
495
|
+
click.echo(f"etch-record: unexpected error: {exc}", err=True)
|
|
496
|
+
sys.exit(3)
|
|
497
|
+
|
|
498
|
+
event_id = extract_event_id(body) or "?"
|
|
499
|
+
click.echo(f"OK session={session} event_id={event_id}")
|
|
500
|
+
|
|
501
|
+
# Wave 1 #1 — if any governance flag was set, assemble the
|
|
502
|
+
# governance object and POST it to the Etch chain endpoint.
|
|
503
|
+
governance = _assemble_governance_from_flags(
|
|
504
|
+
policy_hash=policy_hash,
|
|
505
|
+
authority_file=authority_file,
|
|
506
|
+
assumptions_file=assumptions_file,
|
|
507
|
+
uncertainty=uncertainty,
|
|
508
|
+
uncertainty_file=uncertainty_file,
|
|
509
|
+
invalidation_file=invalidation_file,
|
|
510
|
+
)
|
|
511
|
+
if governance is None:
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
result = record_governance(cfg, event_id, governance)
|
|
516
|
+
except GovernanceError as exc:
|
|
517
|
+
click.echo(f"etch-record: governance-record: {exc}", err=True)
|
|
518
|
+
# Base event was recorded successfully; the governance sub-record
|
|
519
|
+
# failed. Exit non-zero so shell callers can catch it, but keep
|
|
520
|
+
# the earlier OK line above so the caller has the event_id to
|
|
521
|
+
# retry against.
|
|
522
|
+
sys.exit(5)
|
|
523
|
+
|
|
524
|
+
click.echo(
|
|
525
|
+
f"OK governance_seq={result.etch_chain_seq} "
|
|
526
|
+
f"governance_hash={result.governance_hash}",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _assemble_governance_from_flags(
|
|
531
|
+
policy_hash: str | None,
|
|
532
|
+
authority_file: Path | None,
|
|
533
|
+
assumptions_file: Path | None,
|
|
534
|
+
uncertainty: str | None,
|
|
535
|
+
uncertainty_file: Path | None,
|
|
536
|
+
invalidation_file: Path | None,
|
|
537
|
+
) -> dict | None:
|
|
538
|
+
"""Compose the governance object from CLI flags, or return None if
|
|
539
|
+
none of them are set (signals: skip the second API call)."""
|
|
540
|
+
if not any((
|
|
541
|
+
policy_hash, authority_file, assumptions_file,
|
|
542
|
+
uncertainty, uncertainty_file, invalidation_file,
|
|
543
|
+
)):
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
if uncertainty and uncertainty_file:
|
|
547
|
+
raise click.UsageError(
|
|
548
|
+
"Pass only one of --uncertainty or --uncertainty-file, "
|
|
549
|
+
"not both.",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
gov: dict = {}
|
|
553
|
+
if policy_hash:
|
|
554
|
+
gov["policy_hash"] = policy_hash
|
|
555
|
+
if authority_file is not None:
|
|
556
|
+
gov["authority"] = _read_json_object(authority_file, "--authority-file")
|
|
557
|
+
if assumptions_file is not None:
|
|
558
|
+
gov["assumptions"] = _read_json_list(
|
|
559
|
+
assumptions_file, "--assumptions-file",
|
|
560
|
+
)
|
|
561
|
+
if uncertainty is not None:
|
|
562
|
+
gov["uncertainty"] = _parse_uncertainty_inline(uncertainty)
|
|
563
|
+
elif uncertainty_file is not None:
|
|
564
|
+
gov["uncertainty"] = _read_json_object(
|
|
565
|
+
uncertainty_file, "--uncertainty-file",
|
|
566
|
+
)
|
|
567
|
+
if invalidation_file is not None:
|
|
568
|
+
gov["invalidation_conditions"] = _read_json_list(
|
|
569
|
+
invalidation_file, "--invalidation-file",
|
|
570
|
+
)
|
|
571
|
+
return gov
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _read_json_object(path: Path, flag_name: str) -> dict:
|
|
575
|
+
try:
|
|
576
|
+
parsed = json.loads(path.read_text())
|
|
577
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
578
|
+
raise click.UsageError(
|
|
579
|
+
f"{flag_name} could not be loaded: {exc}",
|
|
580
|
+
) from exc
|
|
581
|
+
if not isinstance(parsed, dict):
|
|
582
|
+
raise click.UsageError(f"{flag_name} must be a JSON object.")
|
|
583
|
+
return parsed
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _read_json_list(path: Path, flag_name: str) -> list:
|
|
587
|
+
try:
|
|
588
|
+
parsed = json.loads(path.read_text())
|
|
589
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
590
|
+
raise click.UsageError(
|
|
591
|
+
f"{flag_name} could not be loaded: {exc}",
|
|
592
|
+
) from exc
|
|
593
|
+
if not isinstance(parsed, list):
|
|
594
|
+
raise click.UsageError(f"{flag_name} must be a JSON array.")
|
|
595
|
+
return parsed
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _parse_uncertainty_inline(raw: str) -> dict:
|
|
599
|
+
"""Parse '<confidence>:<basis>' into {confidence, basis}."""
|
|
600
|
+
if ":" not in raw:
|
|
601
|
+
raise click.UsageError(
|
|
602
|
+
"--uncertainty must be 'confidence:basis' (e.g. "
|
|
603
|
+
"'0.87:evidence-hash-match-rate').",
|
|
604
|
+
)
|
|
605
|
+
conf_str, basis = raw.split(":", 1)
|
|
606
|
+
try:
|
|
607
|
+
confidence = float(conf_str)
|
|
608
|
+
except ValueError as exc:
|
|
609
|
+
raise click.UsageError(
|
|
610
|
+
f"--uncertainty confidence must be a float: {exc}",
|
|
611
|
+
) from exc
|
|
612
|
+
if not (0.0 <= confidence <= 1.0):
|
|
613
|
+
raise click.UsageError(
|
|
614
|
+
"--uncertainty confidence must be in [0.0, 1.0].",
|
|
615
|
+
)
|
|
616
|
+
basis = basis.strip()
|
|
617
|
+
if not basis:
|
|
618
|
+
raise click.UsageError("--uncertainty basis must be non-empty.")
|
|
619
|
+
return {"confidence": confidence, "basis": basis}
|
etch_record/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Config resolution: env vars first, no on-disk config file for MVP.
|
|
2
|
+
|
|
3
|
+
Missing env vars raise a `ConfigError` the CLI catches and turns into
|
|
4
|
+
a friendly stderr message + exit code 1.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Config:
|
|
19
|
+
project_id: str
|
|
20
|
+
app_token: str
|
|
21
|
+
base_url: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load() -> Config:
|
|
25
|
+
project_id = os.environ.get("ETCH_PROJECT_ID", "").strip()
|
|
26
|
+
app_token = os.environ.get("ETCH_APP_TOKEN", "").strip()
|
|
27
|
+
base_url = os.environ.get("ETCH_BASE_URL", "https://etch.systems").rstrip("/")
|
|
28
|
+
|
|
29
|
+
missing = []
|
|
30
|
+
if not project_id:
|
|
31
|
+
missing.append("ETCH_PROJECT_ID")
|
|
32
|
+
if not app_token:
|
|
33
|
+
missing.append("ETCH_APP_TOKEN")
|
|
34
|
+
if missing:
|
|
35
|
+
raise ConfigError(
|
|
36
|
+
"Missing required environment variable(s): "
|
|
37
|
+
+ ", ".join(missing)
|
|
38
|
+
+ "\nSet in your shell rc (~/.zshrc):\n"
|
|
39
|
+
+ " export ETCH_PROJECT_ID=your_project_id\n"
|
|
40
|
+
+ " export ETCH_APP_TOKEN=wm_your_app_token\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return Config(
|
|
44
|
+
project_id=project_id,
|
|
45
|
+
app_token=app_token,
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""HTTP client for Wave 1 #1 governance-record endpoint (2026-08-01).
|
|
2
|
+
|
|
3
|
+
Companion to mcp_client.py. Where mcp_client speaks MCP StreamableHTTP
|
|
4
|
+
to invoke tools like record_event, this module speaks plain HTTP to
|
|
5
|
+
Etch's own compliance surface (currently: POST
|
|
6
|
+
/v1/etch-chain/governance-record).
|
|
7
|
+
|
|
8
|
+
Kept as a separate module so the CLI can compose:
|
|
9
|
+
1. mcp_client.record_event(...) → get OSS event_id
|
|
10
|
+
2. governance_client.record_governance(cfg, event_id, gov) → attach
|
|
11
|
+
signed compliance metadata to the Etch parallel chain
|
|
12
|
+
|
|
13
|
+
Both calls hit etch.systems with the same Bearer token, so a client
|
|
14
|
+
that can call one can call the other.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TIMEOUT_S = 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GovernanceError(RuntimeError):
|
|
31
|
+
"""Raised when the governance-record HTTP call fails in a way the
|
|
32
|
+
CLI should surface with a friendly stderr message + exit code."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class GovernanceResult:
|
|
37
|
+
"""Response payload from a successful POST
|
|
38
|
+
/v1/etch-chain/governance-record. All fields come from the server;
|
|
39
|
+
the client does not compute anything locally."""
|
|
40
|
+
|
|
41
|
+
etch_chain_seq: int
|
|
42
|
+
etch_row_id: str
|
|
43
|
+
governance_hash: str
|
|
44
|
+
oss_event_id_ref: str
|
|
45
|
+
recorded_at: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record_governance(
|
|
49
|
+
cfg: Config,
|
|
50
|
+
oss_event_id: str,
|
|
51
|
+
governance: dict,
|
|
52
|
+
client: Optional[httpx.Client] = None,
|
|
53
|
+
) -> GovernanceResult:
|
|
54
|
+
"""POST /v1/etch-chain/governance-record.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cfg: loaded Config (has base_url + app_token).
|
|
58
|
+
oss_event_id: the event_id returned by mcp_client.record_event.
|
|
59
|
+
governance: assembled governance object (validated server-side
|
|
60
|
+
via Pydantic; local validation would drift and is skipped).
|
|
61
|
+
client: optional httpx.Client for dependency injection. Tests
|
|
62
|
+
pass a MockTransport-backed client. Prod passes None; the
|
|
63
|
+
function creates a default client bounded by _TIMEOUT_S.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
GovernanceResult on 200.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
GovernanceError on any non-200 or transport failure. Message
|
|
70
|
+
surfaces the server's error code + hint when the response body
|
|
71
|
+
is a valid JSON error envelope.
|
|
72
|
+
"""
|
|
73
|
+
url = f"{cfg.base_url}/v1/etch-chain/governance-record"
|
|
74
|
+
body = {
|
|
75
|
+
"oss_event_id": oss_event_id,
|
|
76
|
+
"governance": governance,
|
|
77
|
+
}
|
|
78
|
+
headers = {
|
|
79
|
+
"Authorization": f"Bearer {cfg.app_token}",
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"Accept": "application/json",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
owned_client = client is None
|
|
85
|
+
if owned_client:
|
|
86
|
+
client = httpx.Client(timeout=_TIMEOUT_S)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
try:
|
|
90
|
+
resp = client.post(url, json=body, headers=headers)
|
|
91
|
+
except httpx.HTTPError as exc:
|
|
92
|
+
raise GovernanceError(
|
|
93
|
+
f"transport failed while calling {url}: {exc}",
|
|
94
|
+
) from exc
|
|
95
|
+
finally:
|
|
96
|
+
if owned_client:
|
|
97
|
+
client.close()
|
|
98
|
+
|
|
99
|
+
if resp.status_code == 200:
|
|
100
|
+
try:
|
|
101
|
+
payload = resp.json()
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise GovernanceError(
|
|
104
|
+
f"200 response was not valid JSON: {exc}",
|
|
105
|
+
) from exc
|
|
106
|
+
return GovernanceResult(
|
|
107
|
+
etch_chain_seq=int(payload["etch_chain_seq"]),
|
|
108
|
+
etch_row_id=str(payload["etch_row_id"]),
|
|
109
|
+
governance_hash=str(payload["governance_hash"]),
|
|
110
|
+
oss_event_id_ref=str(payload["oss_event_id_ref"]),
|
|
111
|
+
recorded_at=str(payload["recorded_at"]),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Non-200 — try to parse the server's JSON error envelope.
|
|
115
|
+
try:
|
|
116
|
+
err_payload = resp.json()
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise GovernanceError(
|
|
119
|
+
f"HTTP {resp.status_code} with non-JSON body: "
|
|
120
|
+
f"{resp.text[:200]!r}",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
err_code = err_payload.get("error", "unknown_error")
|
|
124
|
+
if err_code == "insufficient_scope":
|
|
125
|
+
raise GovernanceError(
|
|
126
|
+
f"insufficient_scope: your app token needs "
|
|
127
|
+
f"{err_payload.get('required_scope')!r} but has "
|
|
128
|
+
f"{err_payload.get('token_scopes')}. Mint a new token "
|
|
129
|
+
f"with mcp:write scope.",
|
|
130
|
+
)
|
|
131
|
+
if err_code == "validation_failed":
|
|
132
|
+
details = err_payload.get("details", [])
|
|
133
|
+
summary = "; ".join(
|
|
134
|
+
f"{d.get('loc')}: {d.get('msg')}" for d in details
|
|
135
|
+
)
|
|
136
|
+
raise GovernanceError(
|
|
137
|
+
f"governance object failed validation: {summary}",
|
|
138
|
+
)
|
|
139
|
+
if err_code == "invalid_json_body":
|
|
140
|
+
raise GovernanceError(
|
|
141
|
+
"server rejected the request body as invalid JSON",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
raise GovernanceError(
|
|
145
|
+
f"HTTP {resp.status_code}: {err_code}",
|
|
146
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Minimal MCP client for the single flow we need: post one `record_event`.
|
|
2
|
+
|
|
3
|
+
Handles the three-step MCP dance:
|
|
4
|
+
1. POST /mcp {method: "initialize"} -> session_id in response header
|
|
5
|
+
2. POST /mcp {method: "notifications/initialized"}
|
|
6
|
+
3. POST /mcp {method: "tools/call", name: "record_event", arguments: {...}}
|
|
7
|
+
|
|
8
|
+
Session ID is per-invocation. We do NOT persist it across CLI runs -
|
|
9
|
+
each `etch-record` call is a fresh session. That is O(1) events per
|
|
10
|
+
invocation, which is fine for CLI usage; a daemon-style caller would
|
|
11
|
+
want to keep the session open.
|
|
12
|
+
|
|
13
|
+
Response format: the Etch /mcp endpoint uses the MCP StreamableHTTP
|
|
14
|
+
transport, which serves responses as text/event-stream (SSE) frames
|
|
15
|
+
containing the JSON-RPC body inside a `data:` line. For a single
|
|
16
|
+
tools/call there is exactly one message frame per response, so we
|
|
17
|
+
extract the data payload and parse it as JSON.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .config import Config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class McpError(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_PROTOCOL_VERSION = "2025-11-05"
|
|
35
|
+
_TIMEOUT_SECONDS = 15.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_mcp_body(response: httpx.Response) -> dict:
|
|
39
|
+
"""Return the JSON-RPC body from an MCP response.
|
|
40
|
+
|
|
41
|
+
Handles both content types the MCP StreamableHTTP transport spec
|
|
42
|
+
permits:
|
|
43
|
+
- application/json (plain JSON body)
|
|
44
|
+
- text/event-stream (SSE frames; the JSON is inside data: lines)
|
|
45
|
+
|
|
46
|
+
For SSE, we concatenate all `data:` lines in the response (per the
|
|
47
|
+
SSE spec: a single message split across multiple data lines is
|
|
48
|
+
reassembled by joining with newlines) and parse the result.
|
|
49
|
+
Returns the parsed JSON-RPC message dict.
|
|
50
|
+
"""
|
|
51
|
+
content_type = (response.headers.get("content-type") or "").lower()
|
|
52
|
+
text = response.text
|
|
53
|
+
|
|
54
|
+
is_sse = (
|
|
55
|
+
"text/event-stream" in content_type
|
|
56
|
+
or text.lstrip().startswith("event:")
|
|
57
|
+
or text.lstrip().startswith("data:")
|
|
58
|
+
)
|
|
59
|
+
if is_sse:
|
|
60
|
+
data_parts: list[str] = []
|
|
61
|
+
for raw in text.splitlines():
|
|
62
|
+
if raw.startswith("data:"):
|
|
63
|
+
# Per SSE spec: strip leading "data:" then a single
|
|
64
|
+
# optional space. Do not strip further whitespace so
|
|
65
|
+
# JSON with intentional leading spaces round-trips.
|
|
66
|
+
chunk = raw[len("data:"):]
|
|
67
|
+
if chunk.startswith(" "):
|
|
68
|
+
chunk = chunk[1:]
|
|
69
|
+
data_parts.append(chunk)
|
|
70
|
+
if not data_parts:
|
|
71
|
+
raise McpError(
|
|
72
|
+
f"SSE response contained no data lines: {text[:200]!r}",
|
|
73
|
+
)
|
|
74
|
+
joined = "\n".join(data_parts)
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(joined)
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
raise McpError(
|
|
79
|
+
f"SSE data was not valid JSON: {joined[:200]!r}",
|
|
80
|
+
) from exc
|
|
81
|
+
|
|
82
|
+
# Plain JSON response.
|
|
83
|
+
try:
|
|
84
|
+
return response.json()
|
|
85
|
+
except json.JSONDecodeError as exc:
|
|
86
|
+
raise McpError(
|
|
87
|
+
f"non-JSON response ({content_type or 'no content-type'}): "
|
|
88
|
+
f"{text[:200]!r}",
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_event_id(body: dict) -> str | None:
|
|
93
|
+
"""Pull the Etch server's event_id out of a tools/call result body.
|
|
94
|
+
|
|
95
|
+
Etch's record_event tool returns a nested JSON string inside
|
|
96
|
+
result.content[0].text, shaped like:
|
|
97
|
+
{"event_id": "uuid", "status": "recorded"}
|
|
98
|
+
This peels the two layers and returns the inner event_id. Returns
|
|
99
|
+
None if the shape doesn't match (call sites should fall back to
|
|
100
|
+
the JSON-RPC message id or "?").
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
content = body.get("result", {}).get("content") or []
|
|
104
|
+
if not content:
|
|
105
|
+
return None
|
|
106
|
+
first = content[0]
|
|
107
|
+
text = first.get("text") if isinstance(first, dict) else None
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
inner = json.loads(text)
|
|
111
|
+
eid = inner.get("event_id")
|
|
112
|
+
return str(eid) if eid is not None else None
|
|
113
|
+
except (json.JSONDecodeError, AttributeError, TypeError, IndexError):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _headers_for(cfg: Config, mcp_session_id: str | None = None) -> dict[str, str]:
|
|
118
|
+
hdrs = {
|
|
119
|
+
"Authorization": f"Bearer {cfg.app_token}",
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"Accept": "application/json, text/event-stream",
|
|
122
|
+
}
|
|
123
|
+
if mcp_session_id:
|
|
124
|
+
hdrs["mcp-session-id"] = mcp_session_id
|
|
125
|
+
return hdrs
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _initialize(client: httpx.Client, cfg: Config) -> str:
|
|
129
|
+
payload = {
|
|
130
|
+
"jsonrpc": "2.0",
|
|
131
|
+
"id": 1,
|
|
132
|
+
"method": "initialize",
|
|
133
|
+
"params": {
|
|
134
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
135
|
+
"capabilities": {},
|
|
136
|
+
"clientInfo": {"name": "etch-record", "version": "0.1.0"},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
r = client.post("/mcp", headers=_headers_for(cfg), json=payload)
|
|
140
|
+
if r.status_code != 200:
|
|
141
|
+
raise McpError(f"initialize failed: {r.status_code} {r.text[:200]}")
|
|
142
|
+
sid = r.headers.get("mcp-session-id")
|
|
143
|
+
if not sid:
|
|
144
|
+
raise McpError("initialize response missing mcp-session-id header")
|
|
145
|
+
return sid
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _notify_initialized(client: httpx.Client, cfg: Config, sid: str) -> None:
|
|
149
|
+
payload = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
150
|
+
r = client.post("/mcp", headers=_headers_for(cfg, sid), json=payload)
|
|
151
|
+
# 202 is the expected response for notifications; 200 also fine.
|
|
152
|
+
if r.status_code not in (200, 202):
|
|
153
|
+
raise McpError(
|
|
154
|
+
f"notifications/initialized failed: {r.status_code} {r.text[:200]}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def record_event(cfg: Config, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
"""Post one record_event tool call. Returns the parsed JSON result."""
|
|
160
|
+
with httpx.Client(base_url=cfg.base_url, timeout=_TIMEOUT_SECONDS) as client:
|
|
161
|
+
sid = _initialize(client, cfg)
|
|
162
|
+
_notify_initialized(client, cfg, sid)
|
|
163
|
+
|
|
164
|
+
payload = {
|
|
165
|
+
"jsonrpc": "2.0",
|
|
166
|
+
"id": 42,
|
|
167
|
+
"method": "tools/call",
|
|
168
|
+
"params": {"name": "record_event", "arguments": arguments},
|
|
169
|
+
}
|
|
170
|
+
r = client.post("/mcp", headers=_headers_for(cfg, sid), json=payload)
|
|
171
|
+
if r.status_code != 200:
|
|
172
|
+
raise McpError(
|
|
173
|
+
f"tools/call failed: {r.status_code} {r.text[:200]}"
|
|
174
|
+
)
|
|
175
|
+
body = _parse_mcp_body(r)
|
|
176
|
+
if "error" in body:
|
|
177
|
+
raise McpError(f"MCP error: {body['error']}")
|
|
178
|
+
return body
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Client-side sliding-window rate limit for etch-record.
|
|
2
|
+
|
|
3
|
+
Guards against tight-loop misuse (dev accidentally emits a hot loop) that
|
|
4
|
+
would double-log through both the Claude Code Bash-invocation hook and
|
|
5
|
+
the etch-record semantic event.
|
|
6
|
+
|
|
7
|
+
Design choices:
|
|
8
|
+
- Sliding window, 60 seconds, event-count based.
|
|
9
|
+
- State persisted at ~/.etch-record/rate_state.json as a list of unix
|
|
10
|
+
timestamps. Prune older-than-window on every read.
|
|
11
|
+
- Fails OPEN: any state-file corruption or FS error is logged to
|
|
12
|
+
stderr and treated as "no prior events". Rate limit exists to catch
|
|
13
|
+
an accidental hot loop, not to be an authoritative security control;
|
|
14
|
+
a broken state file must never block a legitimate event.
|
|
15
|
+
- Default limit is 100 events/minute. Override via env
|
|
16
|
+
ETCH_RATE_LIMIT_PER_MINUTE (int; set to 0 to disable entirely).
|
|
17
|
+
- No inter-process locking. Concurrent invocations may double-count
|
|
18
|
+
slightly; the goal is order-of-magnitude protection, not exactness.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_STATE_DIR = Path.home() / ".etch-record"
|
|
30
|
+
_STATE_FILE = _STATE_DIR / "rate_state.json"
|
|
31
|
+
_WINDOW_SECONDS = 60.0
|
|
32
|
+
_DEFAULT_LIMIT_PER_MINUTE = 100
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_limit() -> int:
|
|
36
|
+
"""Return current per-minute limit. 0 or negative disables the check."""
|
|
37
|
+
raw = os.environ.get("ETCH_RATE_LIMIT_PER_MINUTE", "").strip()
|
|
38
|
+
if not raw:
|
|
39
|
+
return _DEFAULT_LIMIT_PER_MINUTE
|
|
40
|
+
try:
|
|
41
|
+
return int(raw)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return _DEFAULT_LIMIT_PER_MINUTE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_timestamps(state_file: Path, now: float) -> list[float]:
|
|
47
|
+
"""Load and prune stale timestamps. Fails open on any error."""
|
|
48
|
+
if not state_file.exists():
|
|
49
|
+
return []
|
|
50
|
+
try:
|
|
51
|
+
raw = json.loads(state_file.read_text())
|
|
52
|
+
except (OSError, json.JSONDecodeError):
|
|
53
|
+
# Corrupt or unreadable state file: treat as empty. Rate limit
|
|
54
|
+
# is best-effort; a broken state must never block a caller.
|
|
55
|
+
return []
|
|
56
|
+
if not isinstance(raw, list):
|
|
57
|
+
return []
|
|
58
|
+
cutoff = now - _WINDOW_SECONDS
|
|
59
|
+
result: list[float] = []
|
|
60
|
+
for item in raw:
|
|
61
|
+
try:
|
|
62
|
+
ts = float(item)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
continue
|
|
65
|
+
if ts >= cutoff:
|
|
66
|
+
result.append(ts)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_timestamps(state_file: Path, timestamps: list[float]) -> None:
|
|
71
|
+
"""Persist pruned timestamps. Fails silently if the FS refuses."""
|
|
72
|
+
try:
|
|
73
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
state_file.write_text(json.dumps(timestamps))
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
# Never crash on rate-limit state persistence failure.
|
|
77
|
+
print(
|
|
78
|
+
f"etch-record: warning: could not persist rate-limit state "
|
|
79
|
+
f"({exc}). Rate limiting may be inaccurate.",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_and_record(
|
|
85
|
+
now: float | None = None,
|
|
86
|
+
state_file: Path | None = None,
|
|
87
|
+
limit_per_minute: int | None = None,
|
|
88
|
+
) -> tuple[bool, int, int]:
|
|
89
|
+
"""Consume one slot from the sliding window.
|
|
90
|
+
|
|
91
|
+
Returns (allowed, count_in_window, limit). If allowed=False the
|
|
92
|
+
caller should refuse to submit the event. If limit<=0 the check is
|
|
93
|
+
disabled and this returns (True, 0, 0) without touching state.
|
|
94
|
+
|
|
95
|
+
Args exist for tests; production callers pass nothing.
|
|
96
|
+
"""
|
|
97
|
+
limit = _resolve_limit() if limit_per_minute is None else limit_per_minute
|
|
98
|
+
if limit <= 0:
|
|
99
|
+
return True, 0, 0
|
|
100
|
+
now = time.time() if now is None else now
|
|
101
|
+
sf = state_file if state_file is not None else _STATE_FILE
|
|
102
|
+
|
|
103
|
+
timestamps = _load_timestamps(sf, now)
|
|
104
|
+
count_before = len(timestamps)
|
|
105
|
+
if count_before >= limit:
|
|
106
|
+
# At or over the limit; refuse. Do NOT write this timestamp,
|
|
107
|
+
# so the caller can retry after the window slides.
|
|
108
|
+
return False, count_before, limit
|
|
109
|
+
timestamps.append(now)
|
|
110
|
+
_write_timestamps(sf, timestamps)
|
|
111
|
+
return True, count_before + 1, limit
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etch-record
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI helper: sign any event into your Etch audit chain from the command line.
|
|
5
|
+
Author: Saravanan Jaichandaran
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# etch-record
|
|
14
|
+
|
|
15
|
+
Small CLI helper. Signs any event into your Etch audit chain from the command line.
|
|
16
|
+
|
|
17
|
+
Built for the marketing-agent workflow: every research call, draft generation, review decision, and outreach send emits a signed event. Also usable standalone for any local activity you want notarized.
|
|
18
|
+
|
|
19
|
+
**Latest: v0.2.0** — Wave 1 #1 governance flags (`--policy-hash`, `--authority-file`, `--assumptions-file`, `--uncertainty`, `--uncertainty-file`, `--invalidation-file`) attach a signed governance sub-record on the Etch parallel chain. See [CHANGELOG.md](CHANGELOG.md).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install etch-record
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or from a local checkout during development:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ~/etch-marketing/etch-record
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configure
|
|
35
|
+
|
|
36
|
+
Set three env vars in your shell rc (`~/.zshrc` or `~/.bashrc`):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export ETCH_PROJECT_ID="your_project_id"
|
|
40
|
+
export ETCH_APP_TOKEN="wm_your_app_token"
|
|
41
|
+
export ETCH_BASE_URL="https://etch.systems" # default; override for local dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Get `project_id` + `app_token` from your Etch signup provisioning page. `ETCH_BASE_URL` defaults to `https://etch.systems` if unset.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Simple event
|
|
50
|
+
etch-record "posted X thread about Etch's audit chain"
|
|
51
|
+
|
|
52
|
+
# With tags + evidence
|
|
53
|
+
etch-record "researched contact via Gemini" \
|
|
54
|
+
--tags research,marketing \
|
|
55
|
+
--evidence-json '{"contact":"...","dossier_lines":247}'
|
|
56
|
+
|
|
57
|
+
# Load evidence from a file
|
|
58
|
+
etch-record "drafted 3 message variants" \
|
|
59
|
+
--tags draft,claude \
|
|
60
|
+
--evidence-file drafts_evidence.json
|
|
61
|
+
|
|
62
|
+
# Group events under a session (default = today's ISO date)
|
|
63
|
+
etch-record "approved draft v2" --session-id outreach-2026-07-26 --tags review,approved
|
|
64
|
+
|
|
65
|
+
# Print what would be sent without hitting the API
|
|
66
|
+
etch-record "dry run test" --dry-run
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Event shape
|
|
70
|
+
|
|
71
|
+
Every call becomes a signed `record_event` MCP tool call on your Etch chain:
|
|
72
|
+
|
|
73
|
+
- `event_type`: `"tool_call"` (only enum value that works for arbitrary marketing events)
|
|
74
|
+
- `session_id`: `--session-id` OR auto-generated as `etch-record-YYYY-MM-DD`
|
|
75
|
+
- `entities`: derived from `--tags`
|
|
76
|
+
- `description`: your quoted string (positional arg)
|
|
77
|
+
- `evidence`: from `--evidence-json` or `--evidence-file`
|
|
78
|
+
- `success`: `true` unless `--failed`
|
|
79
|
+
|
|
80
|
+
The Etch server appends to the SHA-256 Merkle chain, closes epochs at threshold (default 1024 events), hybrid-signs (Ed25519 + SLH-DSA-SHA2-128f), and optionally anchors to Sigstore Rekor + Bitcoin OpenTimestamps.
|
|
81
|
+
|
|
82
|
+
## Verify
|
|
83
|
+
|
|
84
|
+
Every event is verifiable offline forever:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
etch-verify \
|
|
88
|
+
--base-url https://etch.systems \
|
|
89
|
+
--project-id your_project_id
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Governance metadata (Wave 1 #1, v0.2.0)
|
|
93
|
+
|
|
94
|
+
Attach a signed governance sub-record to any event. Any of the five flags below triggers a second call to `POST /v1/etch-chain/governance-record` on your Etch base URL, which hashes the governance object canonically and signs it into the Etch parallel chain.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
etch-record "KYC decision on customer ABC" \
|
|
98
|
+
--tags kyc,fintech,decision \
|
|
99
|
+
--policy-hash sha256:9f8c... \
|
|
100
|
+
--authority-file authority.json \
|
|
101
|
+
--uncertainty '0.87:hash-lookup-match-rate' \
|
|
102
|
+
--invalidation-file invalidation_conditions.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`authority.json`:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"identity": "compliance-officer@acme.example",
|
|
110
|
+
"scope": ["fintech-kyc-decisions"],
|
|
111
|
+
"expires_at": "2026-12-31T23:59:59Z"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`invalidation_conditions.json`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
[
|
|
119
|
+
{"if": "SOP hash changes", "then": "re-approve required"}
|
|
120
|
+
]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Assumptions file uses the same shape:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
[
|
|
127
|
+
{"claim": "SOP v3.2 is current", "source_ref": "doc_hash:xyz"}
|
|
128
|
+
]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Output when both calls succeed:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
OK session=etch-record-2026-08-01 event_id=abc-def-123
|
|
135
|
+
OK governance_seq=1 governance_hash=sha256:xyz...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The base event was recorded via the OSS chain; the governance sub-record was signed into the Etch parallel chain and cross-references the event by ID. Both chains verify offline via `etch-verify` (OSS) and `etch-chain-verify` (Etch).
|
|
139
|
+
|
|
140
|
+
## Exit codes
|
|
141
|
+
|
|
142
|
+
- `0` success (including two-call success when governance flags were set)
|
|
143
|
+
- `1` config error (missing env vars)
|
|
144
|
+
- `2` MCP `record_event` error
|
|
145
|
+
- `3` unexpected exception
|
|
146
|
+
- `4` rate limit (client-side sliding window)
|
|
147
|
+
- `5` governance sub-record failed — the base event was recorded successfully; use the printed `event_id` to retry the governance call
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
etch_record/__init__.py,sha256=25j-Ap7qWSelGbbrzdw-La0SRLXDWcPQTSSvkcZB8B8,104
|
|
2
|
+
etch_record/cli.py,sha256=eAtYvOiOe7lhhVaD_cV7qcYQ4M3M-MWgHITQOcHI4zo,21204
|
|
3
|
+
etch_record/config.py,sha256=HIVK0T4eptS9P09mCpHuQspygT5pN7WA-56RA-O57JA,1213
|
|
4
|
+
etch_record/governance_client.py,sha256=Y9LRDDospH8xkFE8T6Zo1C_1GlvyVB3hjwKUZfSEPgk,4678
|
|
5
|
+
etch_record/mcp_client.py,sha256=idPLPqFteC1l7LUJuM8uOspA-VHkqIip4QtbAuej-nI,6335
|
|
6
|
+
etch_record/rate_limit.py,sha256=0wqo0_50UKTSaegKXFYc0WXho3SsK41GJUOZCpwasko,3960
|
|
7
|
+
etch_record-0.1.0.dist-info/METADATA,sha256=mzP4IgCAteYTFE7T7xC1URW2zbelTaYiKo50ikXMgWc,4590
|
|
8
|
+
etch_record-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
etch_record-0.1.0.dist-info/entry_points.txt,sha256=N1EfY2v1MXaRlLkpNIXwt-jSh4aBKqabqpNtrHa_VHQ,53
|
|
10
|
+
etch_record-0.1.0.dist-info/licenses/LICENSE,sha256=YvYKNXVQm7ccJcHd0CzLJC9Ma37bdLeC03HZ6_K_v3k,1079
|
|
11
|
+
etch_record-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saravanan Jaichandaran
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|