tripwire-cli 0.2.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.
File without changes
@@ -0,0 +1,4 @@
1
+ from tripwire_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
tripwire_cli/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Command-line client for Tripwire canaries.
2
+
3
+ Commands:
4
+ tripwire login log in (email-code by default), cache a
5
+ token; --user-id/--password for operators
6
+ tripwire logout forget the cached token
7
+ tripwire whoami print the cached identity
8
+ tripwire canaries list list your canaries (summary only)
9
+ tripwire canaries get <id> show one canary summary
10
+ tripwire canaries create --type ... create a canary; its credential is
11
+ returned once, in this response
12
+ tripwire canaries deactivate <id> deactivate a canary
13
+ tripwire canaries delete <id> delete a canary
14
+
15
+ `login` does not take a server flag. The server is resolved as
16
+ $TRIPWIRE_SERVER, then the last-used cached server, then the default; set
17
+ $TRIPWIRE_SERVER to point at a self-hosted or test server.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ import json
24
+ import os
25
+ import subprocess
26
+ from collections.abc import Callable
27
+ from typing import Any
28
+
29
+ import click
30
+
31
+ from tripwire_cli import credentials
32
+ from tripwire_cli.client import CREATE_READ_TIMEOUT, ApiClient, ApiError
33
+
34
+
35
+ def _git_user_email() -> str | None:
36
+ """Best-effort default email from ``git config user.email``; ``None`` if
37
+ git is absent or unset. Shown to the user as a default, never used
38
+ silently."""
39
+ try:
40
+ result = subprocess.run(
41
+ ["git", "config", "user.email"],
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=5,
45
+ )
46
+ except (OSError, subprocess.SubprocessError):
47
+ return None
48
+ email = result.stdout.strip()
49
+ return email or None
50
+
51
+ DEFAULT_SERVER = "https://tripwire.so/api/v1"
52
+
53
+ CANARY_TYPES = ["dns_label", "aws_access_key", "anthropic_api_key", "github_pat"]
54
+
55
+ # How long the create read timeout may run, in seconds. Must stay above the
56
+ # server's synchronous create wait window (180s) so the client never abandons a
57
+ # request whose one-time credential reveal the server is still preparing.
58
+ CREATE_TIMEOUT_ENV = "TRIPWIRE_CREATE_TIMEOUT"
59
+
60
+ # How many times to re-prompt for the emailed code before giving up. Re-prompts
61
+ # never re-call /auth/login/start (which is rate-limited); they re-submit a new
62
+ # code against the same challenge.
63
+ EMAIL_CODE_ATTEMPTS = 3
64
+
65
+
66
+ def resolve_login_server(env: dict[str, str], cached: str | None) -> str:
67
+ """Server URL for `login`: env override, else the last-used cached server,
68
+ else the default."""
69
+ return env.get("TRIPWIRE_SERVER") or cached or DEFAULT_SERVER
70
+
71
+
72
+ def build_create_payload(*, canary_type: str, memo: str | None = None) -> dict[str, Any]:
73
+ """Build the create request body from the supported flags."""
74
+ payload: dict[str, Any] = {"type": canary_type}
75
+ if memo:
76
+ payload["memo"] = memo
77
+ return payload
78
+
79
+
80
+ class Context:
81
+ """Shared state for the commands: the credential store and a factory that
82
+ builds an :class:`ApiClient` from a server URL and optional token. Both are
83
+ injectable so tests can supply fakes."""
84
+
85
+ def __init__(
86
+ self,
87
+ store: credentials.CredentialStore | None = None,
88
+ client_factory: Callable[[str, str | None], ApiClient] | None = None,
89
+ git_email: Callable[[], str | None] | None = None,
90
+ ):
91
+ self.store = store or credentials.default_store()
92
+ self._client_factory = client_factory or (
93
+ lambda server, token=None: ApiClient(base_url=server, token=token)
94
+ )
95
+ # Injectable so tests can supply a fake; defaults to `git config
96
+ # user.email`.
97
+ self._git_email = git_email or _git_user_email
98
+
99
+ def git_email(self) -> str | None:
100
+ return self._git_email()
101
+
102
+ def client(self, server: str, token: str | None = None) -> ApiClient:
103
+ return self._client_factory(server, token)
104
+
105
+ def authed_client(self) -> ApiClient:
106
+ creds = self.store.load()
107
+ return self.client(creds.server, creds.access_token)
108
+
109
+ def cached_server(self) -> str | None:
110
+ try:
111
+ return self.store.load().server
112
+ except credentials.NoCredentialsError:
113
+ return None
114
+
115
+
116
+ def _handle_errors(func):
117
+ """Translate API/credential errors into a clean CLI error and exit code."""
118
+
119
+ @functools.wraps(func)
120
+ def wrapper(*args, **kwargs):
121
+ try:
122
+ return func(*args, **kwargs)
123
+ except ApiError as exc:
124
+ message = f"{exc.status}: {exc.detail}"
125
+ if exc.status == 401:
126
+ message += "\nhint: run `tripwire login`"
127
+ raise click.ClickException(message) from exc
128
+ except (credentials.NoCredentialsError, ValueError) as exc:
129
+ raise click.ClickException(str(exc)) from exc
130
+
131
+ return wrapper
132
+
133
+
134
+ def _echo_json(value: Any) -> None:
135
+ click.echo(json.dumps(value, indent=2))
136
+
137
+
138
+ @click.group()
139
+ @click.version_option(package_name="tripwire-cli", prog_name="tripwire")
140
+ @click.pass_context
141
+ def cli(ctx: click.Context) -> None:
142
+ """Tripwire canary client."""
143
+ ctx.obj = ctx.obj or Context()
144
+
145
+
146
+ @cli.command()
147
+ @click.option("--user-id", help="operator user id (selects password login)")
148
+ @click.option(
149
+ "--password", help="operator password (selects password login; prefer the prompt)"
150
+ )
151
+ @click.pass_obj
152
+ @_handle_errors
153
+ def login(obj: Context, user_id: str | None, password: str | None) -> None:
154
+ """Log in and cache a token.
155
+
156
+ Defaults to passwordless email-code login. Passing --user-id or --password
157
+ selects the operator (user-id + password) login instead, so operator
158
+ scripts keep working unchanged.
159
+ """
160
+ server = resolve_login_server(dict(os.environ), obj.cached_server())
161
+ client = obj.client(server)
162
+ if user_id is not None or password is not None:
163
+ creds = _password_login(client, server, user_id, password)
164
+ else:
165
+ creds = _email_login(client, server, obj.git_email())
166
+ path = obj.store.save(creds)
167
+ click.echo(f"logged in as {creds.user_id} ({creds.role}); token cached at {path}")
168
+
169
+
170
+ def _password_login(
171
+ client: ApiClient, server: str, user_id: str | None, password: str | None
172
+ ) -> credentials.Credentials:
173
+ """Operator login: user-id + password."""
174
+ user_id = user_id or click.prompt("user_id")
175
+ password = password or click.prompt("password", hide_input=True)
176
+ response = client.login(user_id, password)
177
+ return _credentials_from_login(server, response)
178
+
179
+
180
+ def _email_login(
181
+ client: ApiClient, server: str, default_email: str | None
182
+ ) -> credentials.Credentials:
183
+ """Passwordless email-code login. Calls /auth/login/start once (it is
184
+ rate-limited), then re-prompts for the 6-digit code in-band on an
185
+ invalid/expired code without re-calling start."""
186
+ email = click.prompt("email", default=default_email)
187
+ client.login_start(email)
188
+ click.echo(f"sent a 6-digit sign-in code to {email}; check your inbox.")
189
+ last_error: ApiError | None = None
190
+ for attempt in range(EMAIL_CODE_ATTEMPTS):
191
+ code = click.prompt("code")
192
+ try:
193
+ response = client.login_with_code(email, code)
194
+ except ApiError as exc:
195
+ if exc.status == 400 and exc.detail == "invalid_or_expired_code":
196
+ last_error = exc
197
+ remaining = EMAIL_CODE_ATTEMPTS - attempt - 1
198
+ if remaining:
199
+ click.echo(
200
+ f"invalid or expired code; {remaining} attempt(s) left.",
201
+ err=True,
202
+ )
203
+ continue
204
+ raise
205
+ return _credentials_from_login(server, response, email=email)
206
+ # Exhausted the re-prompt budget; surface the last invalid-code error.
207
+ raise last_error if last_error is not None else ApiError(400, "invalid_or_expired_code")
208
+
209
+
210
+ def _credentials_from_login(
211
+ server: str, response: dict[str, Any], email: str | None = None
212
+ ) -> credentials.Credentials:
213
+ return credentials.Credentials(
214
+ server=server,
215
+ user_id=response["user_id"],
216
+ access_token=response["access_token"],
217
+ expires_at=int(response["expires_at"]),
218
+ role=response["role"],
219
+ email=email,
220
+ )
221
+
222
+
223
+ @cli.command()
224
+ @click.pass_obj
225
+ @_handle_errors
226
+ def logout(obj: Context) -> None:
227
+ """Forget the cached token."""
228
+ click.echo("cached token removed" if obj.store.clear() else "no cached token")
229
+
230
+
231
+ @cli.command()
232
+ @click.pass_obj
233
+ @_handle_errors
234
+ def whoami(obj: Context) -> None:
235
+ """Print the cached identity."""
236
+ creds = obj.store.load()
237
+ click.echo(f"server: {creds.server}")
238
+ click.echo(f"user_id: {creds.user_id}")
239
+ if creds.email:
240
+ click.echo(f"email: {creds.email}")
241
+ click.echo(f"role: {creds.role}")
242
+
243
+
244
+ @cli.group()
245
+ def canaries() -> None:
246
+ """Manage canaries."""
247
+
248
+
249
+ @canaries.command("list")
250
+ @click.pass_obj
251
+ @_handle_errors
252
+ def canaries_list(obj: Context) -> None:
253
+ """List the canaries you own (summary only)."""
254
+ _echo_json(obj.authed_client().list_canaries())
255
+
256
+
257
+ @canaries.command("get")
258
+ @click.argument("canary_id")
259
+ @click.pass_obj
260
+ @_handle_errors
261
+ def canaries_get(obj: Context, canary_id: str) -> None:
262
+ """Show one canary summary (no credential)."""
263
+ _echo_json(obj.authed_client().get_canary(canary_id))
264
+
265
+
266
+ @canaries.command("create")
267
+ @click.option(
268
+ "--type", "canary_type", type=click.Choice(CANARY_TYPES), required=True
269
+ )
270
+ @click.option("--memo", help="free-form note about this canary")
271
+ @click.option(
272
+ "--timeout",
273
+ "timeout",
274
+ type=float,
275
+ default=None,
276
+ help=(
277
+ "read timeout in seconds for this create "
278
+ f"(env {CREATE_TIMEOUT_ENV}; default 240). Must exceed the server's "
279
+ "~180s provisioning wait."
280
+ ),
281
+ )
282
+ @click.pass_obj
283
+ @_handle_errors
284
+ def canaries_create(
285
+ obj: Context, canary_type: str, memo: str | None, timeout: float | None
286
+ ) -> None:
287
+ """Create a canary. The credential is returned once, in this response."""
288
+ read_timeout = _resolve_create_timeout(timeout, dict(os.environ))
289
+ payload = build_create_payload(canary_type=canary_type, memo=memo)
290
+ click.echo(
291
+ "creating the canary (usually a second or two; up to ~2 min if the "
292
+ "warm pool is cold).",
293
+ err=True,
294
+ )
295
+ try:
296
+ result = obj.authed_client().create_canary(payload, timeout=read_timeout)
297
+ except ApiError as exc:
298
+ message = _create_error_message(exc)
299
+ if message is None:
300
+ raise
301
+ raise click.ClickException(message) from exc
302
+ _echo_json(result)
303
+
304
+
305
+ def _resolve_create_timeout(flag: float | None, env: dict[str, str]) -> float:
306
+ """Read timeout for create, in seconds: --timeout flag, else
307
+ ``TRIPWIRE_CREATE_TIMEOUT`` env, else ``CREATE_READ_TIMEOUT`` (240). Always
308
+ above the server's ~180s provisioning wait so the client never abandons a
309
+ create whose one-time reveal is still being prepared."""
310
+ if flag is not None:
311
+ return flag
312
+ raw = env.get(CREATE_TIMEOUT_ENV)
313
+ if not raw:
314
+ return CREATE_READ_TIMEOUT
315
+ try:
316
+ return float(raw)
317
+ except ValueError as exc:
318
+ raise click.ClickException(
319
+ f"invalid {CREATE_TIMEOUT_ENV}={raw!r}: must be a number of seconds"
320
+ ) from exc
321
+
322
+
323
+ def _create_error_message(exc: ApiError) -> str | None:
324
+ """Friendly text for the create-specific failures, or ``None`` to fall back
325
+ to the generic error handler."""
326
+ if exc.status == 429 and exc.detail == "canary_pending":
327
+ return (
328
+ "canary is still provisioning, so its one-time credential reveal was "
329
+ "not returned in this response and cannot be recovered. creating "
330
+ "again would mint a second canary and trip the per-type quota; "
331
+ "instead, find this orphan with `tripwire canaries list`, delete it "
332
+ "with `tripwire canaries delete <id>`, then recreate."
333
+ )
334
+ if exc.status == 502 and exc.detail == "provisioning_failed":
335
+ return (
336
+ "canary provisioning failed; nothing was issued. "
337
+ "try again, and if it persists contact support."
338
+ )
339
+ return None
340
+
341
+
342
+ @canaries.command("deactivate")
343
+ @click.argument("canary_id")
344
+ @click.pass_obj
345
+ @_handle_errors
346
+ def canaries_deactivate(obj: Context, canary_id: str) -> None:
347
+ """Deactivate a canary."""
348
+ _echo_json(obj.authed_client().deactivate_canary(canary_id))
349
+
350
+
351
+ @canaries.command("delete")
352
+ @click.argument("canary_id")
353
+ @click.pass_obj
354
+ @_handle_errors
355
+ def canaries_delete(obj: Context, canary_id: str) -> None:
356
+ """Delete a canary."""
357
+ _echo_json(obj.authed_client().delete_canary(canary_id))
358
+
359
+
360
+ def main() -> None:
361
+ cli(prog_name="tripwire")
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
tripwire_cli/client.py ADDED
@@ -0,0 +1,162 @@
1
+ """HTTP client for the Tripwire REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ DEFAULT_TIMEOUT = 10.0
11
+
12
+ # Connecting should fail fast even when a single request is allowed a long read
13
+ # window, so an unreachable server never hangs for the full read timeout.
14
+ CONNECT_TIMEOUT = 5.0
15
+
16
+ # `POST /canary` is synchronous and provider-minted types can take ~12s/44s/100s
17
+ # today; the server waits up to ``CANARY_CREATE_WAIT_SECONDS`` (180s) before it
18
+ # gives up. The client read timeout MUST stay above that window: if the client
19
+ # abandons the request first, the server still creates the canary, the one-time
20
+ # credential reveal is lost, and the cap-1 quota is consumed with no recovery.
21
+ CREATE_READ_TIMEOUT = 240.0
22
+
23
+
24
+ class ApiError(Exception):
25
+ """A non-2xx response, or a failure to reach the server (``status == 0``)."""
26
+
27
+ def __init__(self, status: int, detail: str):
28
+ super().__init__(f"{status}: {detail}")
29
+ self.status = status
30
+ self.detail = detail
31
+
32
+
33
+ class ApiClient:
34
+ """Thin client over the Tripwire REST API.
35
+
36
+ Pass ``http_client`` to supply your own configured ``httpx.Client``
37
+ (handy for tests and custom transports); otherwise one is created.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ base_url: str,
44
+ token: str | None = None,
45
+ timeout: float = DEFAULT_TIMEOUT,
46
+ http_client: httpx.Client | None = None,
47
+ ):
48
+ self.base_url = base_url.rstrip("/")
49
+ self.token = token
50
+ # Short connect everywhere (a hung connect should fail fast); the
51
+ # read/write/pool budget is ``timeout``. The create path raises its
52
+ # read budget per-request via ``_request(timeout=...)``.
53
+ self._client = http_client or httpx.Client(
54
+ timeout=httpx.Timeout(timeout, connect=CONNECT_TIMEOUT)
55
+ )
56
+
57
+ def __enter__(self) -> ApiClient:
58
+ return self
59
+
60
+ def __exit__(
61
+ self,
62
+ exc_type: type[BaseException] | None,
63
+ exc: BaseException | None,
64
+ tb: TracebackType | None,
65
+ ) -> None:
66
+ self.close()
67
+
68
+ def close(self) -> None:
69
+ self._client.close()
70
+
71
+ def _headers(self) -> dict[str, str]:
72
+ headers = {"accept": "application/json"}
73
+ if self.token:
74
+ headers["authorization"] = f"Bearer {self.token}"
75
+ return headers
76
+
77
+ def _request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ payload: dict[str, Any] | None = None,
82
+ *,
83
+ timeout: float | None = None,
84
+ ) -> dict[str, Any]:
85
+ kwargs: dict[str, Any] = {}
86
+ if timeout is not None:
87
+ # Per-request override: long read window, short connect so an
88
+ # unreachable server still fails fast.
89
+ kwargs["timeout"] = httpx.Timeout(timeout, connect=CONNECT_TIMEOUT)
90
+ try:
91
+ response = self._client.request(
92
+ method,
93
+ self.base_url + path,
94
+ json=payload,
95
+ headers=self._headers(),
96
+ **kwargs,
97
+ )
98
+ except httpx.RequestError as exc:
99
+ raise ApiError(0, f"cannot reach {self.base_url}: {exc}") from exc
100
+ if response.is_error:
101
+ raise ApiError(response.status_code, _error_detail(response))
102
+ if not response.content:
103
+ return {}
104
+ return response.json()
105
+
106
+ def login(self, user_id: str, password: str) -> dict[str, Any]:
107
+ return self._request(
108
+ "POST", "/auth/login", {"user_id": user_id, "password": password}
109
+ )
110
+
111
+ def login_start(self, email: str) -> dict[str, Any]:
112
+ """Begin an email-code login: the server emails a 6-digit code. The
113
+ response is intentionally neutral (``{"status": "ok"}``) and never
114
+ reveals whether the address is known. This endpoint is rate-limited, so
115
+ call it once per login and re-prompt for the code in-band on failure."""
116
+ return self._request("POST", "/auth/login/start", {"email": email})
117
+
118
+ def login_with_code(self, email: str, code: str) -> dict[str, Any]:
119
+ """Exchange an emailed 6-digit code for a token. A wrong/expired/used
120
+ code returns ``400 invalid_or_expired_code``."""
121
+ return self._request("POST", "/auth/login", {"email": email, "code": code})
122
+
123
+ def list_canaries(self) -> dict[str, Any]:
124
+ return self._request("GET", "/canary")
125
+
126
+ def create_canary(
127
+ self, payload: dict[str, Any], *, timeout: float | None = None
128
+ ) -> dict[str, Any]:
129
+ """Create a canary. The response carries the credential inline; this is
130
+ the only time it is returned, so capture it from the result.
131
+
132
+ ``timeout`` is the read timeout for this one request (defaults to
133
+ ``CREATE_READ_TIMEOUT``). It must stay above the server's synchronous
134
+ create wait window, or the client gives up while the server is still
135
+ provisioning and the one-time reveal is lost."""
136
+ return self._request(
137
+ "POST",
138
+ "/canary",
139
+ payload,
140
+ timeout=CREATE_READ_TIMEOUT if timeout is None else timeout,
141
+ )
142
+
143
+ def get_canary(self, canary_id: str) -> dict[str, Any]:
144
+ return self._request("GET", f"/canary/{canary_id}")
145
+
146
+ def deactivate_canary(self, canary_id: str) -> dict[str, Any]:
147
+ return self._request("POST", f"/canary/{canary_id}/deactivate")
148
+
149
+ def delete_canary(self, canary_id: str) -> dict[str, Any]:
150
+ return self._request("DELETE", f"/canary/{canary_id}")
151
+
152
+
153
+ def _error_detail(response: httpx.Response) -> str:
154
+ """Best-effort human-readable detail from an error response: the JSON
155
+ ``detail`` field when present, else the raw body or status reason."""
156
+ try:
157
+ body = response.json()
158
+ except ValueError:
159
+ return response.text or response.reason_phrase
160
+ if isinstance(body, dict) and "detail" in body:
161
+ return str(body["detail"])
162
+ return response.text or response.reason_phrase
@@ -0,0 +1,68 @@
1
+ """Local credential cache for the Tripwire CLI.
2
+
3
+ The token, server URL, and identity are kept in a single JSON file, by default
4
+ ``~/.config/tripwire/credentials.json`` (honoring ``XDG_CONFIG_HOME``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import json
11
+ import os
12
+ import stat
13
+ from dataclasses import asdict, dataclass
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass(frozen=True, kw_only=True)
18
+ class Credentials:
19
+ server: str
20
+ user_id: str
21
+ access_token: str
22
+ expires_at: int
23
+ role: str
24
+ # Optional: present for email-code logins, absent for operator
25
+ # (user-id/password) logins and for caches written by older CLIs.
26
+ email: str | None = None
27
+
28
+
29
+ class NoCredentialsError(Exception):
30
+ pass
31
+
32
+
33
+ def default_path() -> Path:
34
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
35
+ return Path(base) / "tripwire" / "credentials.json"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CredentialStore:
40
+ """Read/write the cached credentials at ``path``."""
41
+
42
+ path: Path
43
+
44
+ def load(self) -> Credentials:
45
+ if not self.path.exists():
46
+ raise NoCredentialsError("not logged in (run `tripwire login`)")
47
+ data = json.loads(self.path.read_text())
48
+ # Forward-compatible: keep only the fields this CLI knows about, so an
49
+ # older CLI reading a cache written by a newer one does not crash on an
50
+ # unexpected keyword argument.
51
+ known = {f.name for f in dataclasses.fields(Credentials)}
52
+ return Credentials(**{k: v for k, v in data.items() if k in known})
53
+
54
+ def save(self, credentials: Credentials) -> Path:
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ self.path.write_text(json.dumps(asdict(credentials), indent=2))
57
+ self.path.chmod(stat.S_IRUSR | stat.S_IWUSR)
58
+ return self.path
59
+
60
+ def clear(self) -> bool:
61
+ if not self.path.exists():
62
+ return False
63
+ self.path.unlink()
64
+ return True
65
+
66
+
67
+ def default_store() -> CredentialStore:
68
+ return CredentialStore(default_path())
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: tripwire-cli
3
+ Version: 0.2.0
4
+ Summary: Command-line client for Tripwire canaries
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: httpx>=0.27
8
+ Description-Content-Type: text/markdown
9
+
10
+ # tripwire-cli
11
+
12
+ Command-line client for [Tripwire](https://tripwire.so) canaries. Installs a
13
+ single `tripwire` command.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ uv tool install --from . tripwire-cli
19
+ ```
20
+
21
+ Or run without installing:
22
+
23
+ ```bash
24
+ uvx --from . tripwire --help
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ # Log in and cache a token. Defaults to passwordless email-code login: it
31
+ # prompts for your email (defaulting to `git config user.email`) and the
32
+ # 6-digit code emailed to you. Operators can pass --user-id / --password to use
33
+ # the user-id + password login instead.
34
+ tripwire login
35
+
36
+ # Create a canary. The credential is returned once, in this response, so
37
+ # capture it now. Provider-minted types (aws/anthropic/github) can take ~2 min;
38
+ # the CLI waits and prints a progress note while it provisions.
39
+ tripwire canaries create --type dns_label --memo "env metrics host"
40
+ tripwire canaries create --type aws_access_key --memo "warehouse reporting key"
41
+
42
+ # Inspect what you own (summaries only; the credential is never shown again).
43
+ tripwire canaries list
44
+ tripwire canaries get can_1234abcd
45
+
46
+ # Wind one down.
47
+ tripwire canaries deactivate can_1234abcd
48
+ tripwire canaries delete can_1234abcd
49
+ ```
50
+
51
+ `canaries` subcommands print JSON to stdout (pipe to `jq`); progress and other
52
+ plain-text messages go to stderr, so stdout stays clean JSON. Run
53
+ `tripwire --help` for the full reference.
54
+
55
+ Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
56
+ and `github_pat`.
57
+
58
+ `canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
59
+ default 240) for the per-request read timeout; it must stay above the server's
60
+ ~180s provisioning wait so the one-time credential reveal is never lost to a
61
+ premature client timeout.
62
+
63
+ ## Server
64
+
65
+ `login` talks to `https://tripwire.so/api/v1` by default. Set `TRIPWIRE_SERVER`
66
+ to point at a self-hosted or test server before logging in; the server is bound
67
+ to your token at login time. The token is cached at
68
+ `~/.config/tripwire/credentials.json` (honoring `XDG_CONFIG_HOME`).
@@ -0,0 +1,9 @@
1
+ tripwire_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tripwire_cli/__main__.py,sha256=BOlVJVISJ_-zRpgR_nxg5GYdXHHzXuQp4FcZrZ-Epuk,73
3
+ tripwire_cli/cli.py,sha256=XlBF1X2zqHyhOP7maQMpqoA74sIqh9t-ZuqevTpP5AY,12655
4
+ tripwire_cli/client.py,sha256=-a1bsWTsUZGCeqPpV78oxYBWLlUiovWxeVNi5MZcx4E,5992
5
+ tripwire_cli/credentials.py,sha256=FMH2XUhWku-tu3XdsdRUtWHmB5hu8Lbh4dWR4GtF_Yo,2032
6
+ tripwire_cli-0.2.0.dist-info/METADATA,sha256=HcLl-k5lHNGGNhydcSSs-PQNH3JRek4LZaEg8-2AnKk,2225
7
+ tripwire_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ tripwire_cli-0.2.0.dist-info/entry_points.txt,sha256=9PdcjDdJygDUBO_006xoyJjTRuqb0K1SjNUgSpiw4wI,88
9
+ tripwire_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tripwire = tripwire_cli.cli:main
3
+ tripwire-cli = tripwire_cli.cli:main