1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
//! M11 ch3 — `tirith canary create|status|list|prune|rotate` (D3).
//!
//! Thin presenter over [`tirith_core::canary`] (the store, token generation,
//! and detection live in the library). A canary is a clearly-fake
//! secret-shaped token planted as bait; when it later appears in a command,
//! paste, or tool output the engine fires `CanaryTokenTouched` (High).
//!
//! D3 local-first: a canary is local-only by default (finding + audit log, no
//! phone-home). `--callback-url <url>` opts into a best-effort POST of
//! `{kind, detected_at, context}` (NEVER the token) to a URL YOU self-host;
//! there is no tirith-operated endpoint.
use tirith_core::canary::{self, CanaryEntry, CanaryKind};
use super::{confirm, write_json_stdout};
/// Emit an operator error as `{"error": ...}` JSON on stdout (`--json`) or a
/// human stderr line, keeping `--json` surfaces parseable on validation
/// failures. Mirrors `cli::command_card::emit_error`. Returns `false` when the
/// JSON write itself failed (broken pipe), so a caller can surface that instead
/// of a semantic exit with no JSON (CodeRabbit R8 #3); human mode always `true`.
fn emit_error(json: bool, ctx: &str, msg: &str) -> bool {
if json {
let v = serde_json::json!({ "error": msg });
write_json_stdout(&v, &format!("{ctx}: failed to write JSON output"))
} else {
eprintln!("{ctx}: {msg}");
true
}
}
/// `tirith canary create <kind> [--callback-url <url>]` — generate and store a
/// fresh synthetic canary token, printing the token + metadata.
pub fn create(kind: &str, callback_url: Option<String>, json: bool) -> i32 {
let Some(kind) = CanaryKind::parse(kind) else {
// Route through the bool to keep the broken-pipe path explicit.
let _ = emit_error(
json,
"tirith canary create",
&format!(
"unknown kind '{kind}' — supported: {}",
CanaryKind::all().join(", ")
),
);
return 2;
};
match canary::create(kind, callback_url) {
Ok(entry) => {
if json {
if !write_json_stdout(&entry, "tirith canary create: failed to write JSON output") {
return 2;
}
return 0;
}
print_created_human(&entry);
0
}
Err(e) => {
// Broken-pipe JSON write → write-failure exit 2 (not semantic 1 with
// no output); mirrors command-card sign.
if !emit_error(json, "tirith canary create", &e.to_string()) {
return 2;
}
if e.kind() == std::io::ErrorKind::InvalidInput {
2
} else {
1
}
}
}
}
/// `tirith canary list` — print every recorded canary. The token VALUE is shown
/// so the user can plant it (it lives in a local 0600 store either way).
pub fn list(json: bool) -> i32 {
// Completeness-aware: a partial read (FIFO/device, mid-file fault) would be
// hidden by lenient `canary::list()`, so warn rather than show it as whole.
let (entries, complete) = canary::list_complete();
if !complete {
// A partial JSON list must not look authoritative to a stdout-only
// consumer, so FAIL (CodeRabbit R13f); the human path warns + shows it.
if json {
if !emit_error(
json,
"tirith canary list",
"the canary store could not be read completely; refusing to emit a partial list",
) {
return 2;
}
return 1;
}
eprintln!(
"tirith canary list: warning: the canary store could not be read \
completely; the list below may be partial."
);
}
if json {
if !write_json_stdout(&entries, "tirith canary list: failed to write JSON output") {
return 2;
}
return 0;
}
if entries.is_empty() {
print_empty_help();
return 0;
}
println!("Registered canaries ({}):", entries.len());
println!();
for entry in &entries {
print_entry_human(entry, true);
println!();
}
println!("Plant a token where it should never be read. A later command, paste, or");
println!("inspected tool output containing it fires CanaryTokenTouched (High).");
0
}
/// `tirith canary status` — a compact summary (counts + store path), never
/// token values.
pub fn status(json: bool) -> i32 {
// Completeness-aware (see `list`): never report a partial store as authoritative.
let (entries, complete) = canary::list_complete();
if !complete {
// JSON must not report partial counts as authoritative (R13f); fail.
if json {
if !emit_error(
json,
"tirith canary status",
"the canary store could not be read completely; refusing to report partial counts",
) {
return 2;
}
return 1;
}
eprintln!(
"tirith canary status: warning: the canary store could not be read \
completely; the counts below may be partial."
);
}
let with_callback = entries.iter().filter(|e| e.callback_url.is_some()).count();
let store = canary::store_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unresolved>".to_string());
if json {
#[derive(serde::Serialize)]
struct StatusOut {
registered: usize,
with_callback: usize,
local_only: usize,
store_path: String,
}
let out = StatusOut {
registered: entries.len(),
with_callback,
local_only: entries.len() - with_callback,
store_path: store,
};
if !write_json_stdout(&out, "tirith canary status: failed to write JSON output") {
return 2;
}
return 0;
}
println!("Canary status:");
println!(" registered: {}", entries.len());
println!(" local-only: {}", entries.len() - with_callback);
println!(" with callback: {with_callback} (opt-in, user-self-hosted)");
println!(" store: {store}");
if entries.is_empty() {
println!();
print_empty_help();
}
0
}
/// `tirith canary prune <id>` — remove one canary by id (prompts unless --yes).
pub fn prune(id: &str, yes: bool, json: bool) -> i32 {
// Completeness-aware (CodeRabbit R17 #2): only treat "id absent" as a genuine
// no-op when the store read to COMPLETION, else fall through to strict
// `prune_at` (which reports the real read failure) instead of a false exit 0.
let (entries, complete) = canary::list_complete();
let existing = entries.into_iter().find(|e| e.id == id);
if complete && existing.is_none() {
if json {
if !write_json_stdout(
&PruneOut {
id,
pruned: false,
removed: 0,
},
"tirith canary prune: failed to write JSON output",
) {
return 2;
}
} else {
println!("No canary with id '{id}' — nothing to prune.");
}
return 0;
}
// Confirm only with a concrete entry to remove; on an incomplete read skip
// the prompt and let `prune_at` surface the failure.
if existing.is_some() && !json && !confirm(&format!("Prune canary {id}?"), yes) {
println!("Aborted — canary left in place.");
return 0;
}
// JSON mode requires --yes (no prompt on a machine-readable surface).
if json && !yes {
let _ = emit_error(
json,
"tirith canary prune",
"--yes required in JSON mode to confirm removal",
);
return 2;
}
match canary::prune(id) {
Ok(removed) => {
if json {
if !write_json_stdout(
&PruneOut {
id,
pruned: removed > 0,
removed,
},
"tirith canary prune: failed to write JSON output",
) {
return 2;
}
} else if removed == 0 {
// Mirror JSON `pruned: false`: a concurrent prune (or unknown id)
// removed nothing between the pre-check and `prune`.
println!("No canary with id '{id}' — nothing to prune.");
} else {
println!("Pruned canary {id} ({removed} entr{}).", plural(removed));
}
0
}
Err(e) => {
// R13c: a store read-modify-write error is semantic exit 1; only a
// broken-pipe JSON write is the write-failure exit 2.
if !emit_error(json, "tirith canary prune", &e.to_string()) {
return 2;
}
1
}
}
}
#[derive(serde::Serialize)]
struct PruneOut<'a> {
id: &'a str,
pruned: bool,
removed: usize,
}
/// `tirith canary rotate <id>` — fresh token of the same kind, preserving id +
/// callback URL. The old token stops firing; the new one fires going forward.
pub fn rotate(id: &str, json: bool) -> i32 {
match canary::rotate(id) {
Ok(Some(entry)) => {
if json {
if !write_json_stdout(&entry, "tirith canary rotate: failed to write JSON output") {
return 2;
}
return 0;
}
println!("Rotated canary {id} — fresh token generated (old token no longer fires).");
println!();
print_entry_human(&entry, true);
0
}
Ok(None) => {
if json {
#[derive(serde::Serialize)]
struct RotateMiss<'a> {
id: &'a str,
rotated: bool,
}
if !write_json_stdout(
&RotateMiss { id, rotated: false },
"tirith canary rotate: failed to write JSON output",
) {
return 2;
}
} else {
eprintln!("tirith canary rotate: no canary with id '{id}'");
}
1
}
Err(e) => {
// R13c: a store read-modify-write error is semantic exit 1; only a
// broken-pipe JSON write is the write-failure exit 2.
if !emit_error(json, "tirith canary rotate", &e.to_string()) {
return 2;
}
1
}
}
}
fn plural(n: usize) -> &'static str {
if n == 1 {
"y"
} else {
"ies"
}
}
/// Render a freshly-created canary: the token (to plant) plus id and a reminder.
fn print_created_human(entry: &CanaryEntry) {
println!("Created {} canary (id {}).", entry.kind, entry.id);
println!();
println!(" token: {}", entry.token);
println!();
print_entry_human(entry, false);
println!();
println!("This token is CLEARLY SYNTHETIC (see `docs/canary-formats.md`) — it cannot be");
println!("mistaken for a real third-party credential. Plant it where it should never be");
println!("read (a decoy ~/.aws/credentials, a fake .env, a bait repo line). A later");
println!("command, paste, or inspected tool output containing it fires CanaryTokenTouched.");
if entry.callback_url.is_some() {
println!();
println!("On detection, a best-effort POST of {{kind, detected_at, context}} (NEVER the");
println!("token value) is sent to your callback URL. Failures are logged, never block.");
}
}
/// Render one canary entry as indented human output. `show_token` prints the
/// token value (true for create/list/rotate; false for the `create` recap).
fn print_entry_human(entry: &CanaryEntry, show_token: bool) {
println!(" id: {}", entry.id);
println!(" kind: {}", entry.kind);
if show_token {
println!(" token: {}", entry.token);
}
println!(" created_at: {}", entry.created_at);
match &entry.callback_url {
Some(url) => println!(" callback: {url} (opt-in, public user-self-hosted HTTPS)"),
None => println!(" callback: none (local-only)"),
}
}
fn print_empty_help() {
println!("No canaries registered.");
println!();
println!("Create one and plant it as bait:");
println!(" tirith canary create aws-like");
println!(" tirith canary create github-like --callback-url https://my-host.example/hit");
println!();
println!("Supported kinds: {}", CanaryKind::all().join(", "));
}