@theplato/tiro-cli 0.3.0 → 0.4.0

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.
Files changed (3) hide show
  1. package/AGENTS.md +24 -0
  2. package/dist/bin/tiro.js +147 -22
  3. package/package.json +3 -2
package/AGENTS.md CHANGED
@@ -88,3 +88,27 @@ Two ways to authenticate:
88
88
 
89
89
  The CLI never prints tokens to stdout. `tiro auth status` shows only the
90
90
  first 4 characters of the access token.
91
+
92
+ ## Security regression suite (pre-deploy contract)
93
+
94
+ Every release MUST keep `npm run test:security` green. The suite pins the
95
+ following defenses; if any test breaks, the corresponding attack surface
96
+ has reopened — do not publish.
97
+
98
+ | ID | Surface | What the test guards |
99
+ |---|---|---|
100
+ | **C1** | `lib/config.ts` `validateHostname` | Rejects non-https/non-loopback hostnames so `TIRO_HOSTNAME` / `--hostname` cannot redirect OAuth + API traffic to an attacker. |
101
+ | **H1** | `lib/auth/pkce.ts` `verifyState` | Constant-time OAuth state CSRF compare. |
102
+ | **H2** | `lib/auth/loopback.ts` | OAuth redirect listener is GET-only, single-shot, `Connection: close` — local processes can't race or replay a callback. |
103
+ | **H3** | `lib/api/client.ts` `buildApiUrl` | API path must be relative to the configured hostname — refuses absolute / protocol-relative input that would leak the bearer token. |
104
+ | **H4** | `lib/config.ts` `getOauthClientId` | DCR `client_id` cache is bound to the hostname it was registered against; a swapped hostname misses the cache. |
105
+ | **M1** | `lib/output/file.ts` `assertSafeOutputPath` | `--output` rejects `..` segments unless `--force`; an agent forwarding untrusted input cannot escape its scope. |
106
+ | **M2** | `lib/auth/token.ts` `decodeJwtPayload` | Documented as display-only — claims must never gate auth, scope, identity, or expiry decisions. |
107
+
108
+ Pre-deploy checklist:
109
+
110
+ 1. `npm run typecheck`
111
+ 2. `npm run test:security` (regression gate)
112
+ 3. `npm test` (full suite)
113
+ 4. `npm run build`
114
+ 5. Smoke: `node dist/bin/tiro.js auth status`
package/dist/bin/tiro.js CHANGED
@@ -140,7 +140,7 @@ import "commander";
140
140
  import { z } from "zod";
141
141
 
142
142
  // src/lib/auth/pkce.ts
143
- import { createHash, randomBytes } from "crypto";
143
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
144
144
  function generatePkce() {
145
145
  const codeVerifier = base64url(randomBytes(32));
146
146
  const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
@@ -149,6 +149,12 @@ function generatePkce() {
149
149
  function generateState() {
150
150
  return base64url(randomBytes(24));
151
151
  }
152
+ function verifyState(received, expected) {
153
+ const a = Buffer.from(received, "utf8");
154
+ const b = Buffer.from(expected, "utf8");
155
+ if (a.length !== b.length) return false;
156
+ return timingSafeEqual(a, b);
157
+ }
152
158
  function base64url(buf) {
153
159
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
154
160
  }
@@ -168,21 +174,33 @@ async function startLoopbackServer() {
168
174
  pendingReject = null;
169
175
  fn(arg);
170
176
  };
177
+ let consumed = false;
171
178
  const server = http.createServer((req, res) => {
179
+ res.setHeader("Connection", "close");
180
+ res.setHeader("Cache-Control", "no-store");
172
181
  if (!req.url) {
173
182
  res.writeHead(400).end();
174
183
  return;
175
184
  }
185
+ if (req.method !== "GET") {
186
+ res.writeHead(405, { "Content-Type": "text/plain", Allow: "GET" }).end("Method not allowed");
187
+ return;
188
+ }
176
189
  const url = new URL(req.url, `http://127.0.0.1`);
177
190
  if (url.pathname !== "/callback") {
178
191
  res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
179
192
  return;
180
193
  }
194
+ if (consumed) {
195
+ res.writeHead(410, { "Content-Type": "text/plain" }).end("Callback already consumed");
196
+ return;
197
+ }
181
198
  const code = url.searchParams.get("code");
182
199
  const state = url.searchParams.get("state");
183
200
  const error = url.searchParams.get("error");
184
201
  const errorDesc = url.searchParams.get("error_description") ?? "";
185
202
  if (error) {
203
+ consumed = true;
186
204
  const detail = errorDesc ? ` \u2014 ${errorDesc}` : "";
187
205
  respondError(res, 500, `OAuth error: ${error}${detail}`);
188
206
  if (pendingReject) settle(pendingReject, new Error(`OAuth error: ${error}${detail}`));
@@ -193,6 +211,7 @@ async function startLoopbackServer() {
193
211
  if (pendingReject) settle(pendingReject, new Error("Missing code or state"));
194
212
  return;
195
213
  }
214
+ consumed = true;
196
215
  respondSuccess(res);
197
216
  if (pendingResolve) settle(pendingResolve, { code, state });
198
217
  });
@@ -434,36 +453,90 @@ function decodeJwtPayload(token) {
434
453
  // src/lib/config.ts
435
454
  import Conf from "conf";
436
455
  var DEFAULT_HOSTNAME = "https://api.tiro.ooo";
456
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
437
457
  var config = new Conf({
438
458
  projectName: "tiro",
439
459
  defaults: {
440
460
  hostname: DEFAULT_HOSTNAME,
441
461
  oauthClientId: null,
442
462
  oauthClientIdRegisteredAt: null,
463
+ oauthClientHostname: null,
443
464
  defaultOutputDir: null
444
465
  }
445
466
  });
467
+ function validateHostname(input) {
468
+ const trimmed = input.trim();
469
+ if (trimmed === "") {
470
+ throw hostnameError("empty hostname", input);
471
+ }
472
+ let url;
473
+ try {
474
+ url = new URL(trimmed);
475
+ } catch {
476
+ throw hostnameError("not a valid URL", input);
477
+ }
478
+ if (url.protocol === "https:") {
479
+ } else if (url.protocol === "http:" && isLoopback(url.hostname)) {
480
+ } else {
481
+ throw hostnameError(
482
+ `disallowed scheme "${url.protocol}" \u2014 only https:// or http://localhost is permitted`,
483
+ input
484
+ );
485
+ }
486
+ if (url.username !== "" || url.password !== "") {
487
+ throw hostnameError("URL must not embed credentials", input);
488
+ }
489
+ if (url.search !== "" || url.hash !== "") {
490
+ throw hostnameError("URL must not include query or fragment", input);
491
+ }
492
+ if (url.pathname !== "" && url.pathname !== "/") {
493
+ throw hostnameError("URL must be host root (no path segment)", input);
494
+ }
495
+ const origin = url.origin;
496
+ return stripTrailingSlash(origin);
497
+ }
498
+ function isLoopback(host) {
499
+ if (host.startsWith("[") && host.endsWith("]")) {
500
+ host = host.slice(1, -1);
501
+ }
502
+ return LOOPBACK_HOSTS.has(host);
503
+ }
504
+ function hostnameError(reason, input) {
505
+ return new TiroError(
506
+ {
507
+ code: "invalid_hostname",
508
+ message: `Refusing to use hostname "${input}": ${reason}.`,
509
+ errorType: "bad_request",
510
+ suggestion: "Use https://<host> (or http://localhost for local dev). If TIRO_HOSTNAME is set in your environment, unset it."
511
+ },
512
+ ExitCode.Usage
513
+ );
514
+ }
446
515
  function getHostname(override) {
447
- if (override) return stripTrailingSlash(override);
516
+ if (override !== void 0) return validateHostname(override);
448
517
  const env = process.env["TIRO_HOSTNAME"];
449
- if (env) return stripTrailingSlash(env);
450
- return stripTrailingSlash(config.get("hostname"));
518
+ if (env) return validateHostname(env);
519
+ return validateHostname(config.get("hostname"));
451
520
  }
452
- function getOauthClientId() {
521
+ function getOauthClientId(hostname) {
453
522
  const id = config.get("oauthClientId");
454
523
  const registeredAt = config.get("oauthClientIdRegisteredAt");
524
+ const cachedHostname = config.get("oauthClientHostname");
455
525
  if (!id || !registeredAt) return null;
526
+ if (cachedHostname !== hostname) return null;
456
527
  const ttlMs = 29 * 24 * 60 * 60 * 1e3;
457
528
  if (Date.now() - registeredAt > ttlMs) return null;
458
529
  return id;
459
530
  }
460
- function setOauthClientId(clientId) {
531
+ function setOauthClientId(clientId, hostname) {
461
532
  config.set("oauthClientId", clientId);
462
533
  config.set("oauthClientIdRegisteredAt", Date.now());
534
+ config.set("oauthClientHostname", hostname);
463
535
  }
464
536
  function clearOauthClientId() {
465
537
  config.set("oauthClientId", null);
466
538
  config.set("oauthClientIdRegisteredAt", null);
539
+ config.set("oauthClientHostname", null);
467
540
  }
468
541
  function stripTrailingSlash(s) {
469
542
  return s.endsWith("/") ? s.slice(0, -1) : s;
@@ -509,7 +582,7 @@ ${authorizeUrl}`);
509
582
  await openBrowser(authorizeUrl);
510
583
  }
511
584
  const callback = await loopback.waitForCallback(CALLBACK_TIMEOUT_MS);
512
- if (callback.state !== state) {
585
+ if (!verifyState(callback.state, state)) {
513
586
  throw new TiroError(
514
587
  {
515
588
  code: "auth_state_mismatch",
@@ -544,7 +617,7 @@ ${authorizeUrl}`);
544
617
  }
545
618
  }
546
619
  async function ensureOauthClient(hostname, redirectUri) {
547
- const cached = getOauthClientId();
620
+ const cached = getOauthClientId(hostname);
548
621
  if (cached) return cached;
549
622
  const url = `${hostname}/v1/mcp/oauth/register`;
550
623
  let res;
@@ -597,7 +670,7 @@ async function ensureOauthClient(hostname, redirectUri) {
597
670
  ExitCode.Generic
598
671
  );
599
672
  }
600
- setOauthClientId(parsed.data.client_id);
673
+ setOauthClientId(parsed.data.client_id, hostname);
601
674
  return parsed.data.client_id;
602
675
  }
603
676
  function buildAuthorizeUrl(input) {
@@ -936,16 +1009,7 @@ var TiroApiClient = class {
936
1009
  if (!res.ok) throw await mapHttpError(res, "DELETE", path);
937
1010
  }
938
1011
  buildUrl(path, params) {
939
- const base = path.startsWith("http") ? path : `${this.hostname}${path}`;
940
- const u = new URL(base);
941
- if (params) {
942
- for (const [k, v] of Object.entries(params)) {
943
- if (v !== void 0 && v !== null && v !== "") {
944
- u.searchParams.set(k, String(v));
945
- }
946
- }
947
- }
948
- return u.toString();
1012
+ return buildApiUrl(this.hostname, path, params);
949
1013
  }
950
1014
  async fetch(url, init) {
951
1015
  const headers = new Headers(init.headers);
@@ -1045,6 +1109,27 @@ async function tryParseApiError(res) {
1045
1109
  return null;
1046
1110
  }
1047
1111
  }
1112
+ function buildApiUrl(hostname, path, params) {
1113
+ if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) {
1114
+ throw new TiroError(
1115
+ {
1116
+ code: "internal_error",
1117
+ message: `API path must start with a single "/": got ${JSON.stringify(path)}`,
1118
+ errorType: "internal_error"
1119
+ },
1120
+ ExitCode.Generic
1121
+ );
1122
+ }
1123
+ const u = new URL(`${hostname}${path}`);
1124
+ if (params) {
1125
+ for (const [k, v] of Object.entries(params)) {
1126
+ if (v !== void 0 && v !== null && v !== "") {
1127
+ u.searchParams.set(k, String(v));
1128
+ }
1129
+ }
1130
+ }
1131
+ return u.toString();
1132
+ }
1048
1133
  function createApiClient(opts = {}) {
1049
1134
  if (opts.tokenOverride) {
1050
1135
  return new TiroApiClient(getHostname(opts.hostnameOverride), opts.tokenOverride);
@@ -1206,7 +1291,13 @@ function formatDuration(sec) {
1206
1291
 
1207
1292
  // src/commands/notes/search.ts
1208
1293
  import "commander";
1209
- var SearchResponseSchema = PageCursorResponseSchema(NoteSchema).passthrough();
1294
+ import { z as z4 } from "zod";
1295
+ var SearchResponseSchema = z4.object({
1296
+ notes: z4.array(NoteSchema),
1297
+ nextCursor: z4.string().nullable(),
1298
+ degraded: z4.boolean().optional(),
1299
+ degradedReason: z4.string().nullable().optional()
1300
+ }).passthrough();
1210
1301
  var DEFAULT_PAGE_SIZE2 = 100;
1211
1302
  var MAX_PAGE_SIZE2 = 1e3;
1212
1303
  var HELP_AFTER2 = `
@@ -1276,13 +1367,22 @@ function registerNotesSearch(parent) {
1276
1367
  SearchResponseSchema,
1277
1368
  body
1278
1369
  );
1279
- const visible = opts.includeUntitled === true ? res.content : res.content.filter(isVisibleNote);
1370
+ const visible = opts.includeUntitled === true ? res.notes : res.notes.filter(isVisibleNote);
1280
1371
  const mode = resolveOutputMode(globalOpts);
1281
1372
  if (mode === "json") {
1282
1373
  for (const note of visible) printNdjson(note);
1283
1374
  if (res.nextCursor) printNdjson({ _cursor: res.nextCursor });
1375
+ if (res.degraded === true) {
1376
+ printNdjson({ _degraded: true, _degradedReason: res.degradedReason ?? null });
1377
+ }
1284
1378
  } else {
1285
1379
  printPretty2(visible, res.nextCursor, globalOpts);
1380
+ if (res.degraded === true && globalOpts.quiet !== true) {
1381
+ process.stderr.write(
1382
+ `${color("\u26A0", "yellow", globalOpts)} search results are partial${res.degradedReason ? ` (${res.degradedReason})` : ""}
1383
+ `
1384
+ );
1385
+ }
1286
1386
  }
1287
1387
  });
1288
1388
  }
@@ -1319,8 +1419,33 @@ import "commander";
1319
1419
 
1320
1420
  // src/lib/output/file.ts
1321
1421
  import { mkdir, rename, stat, writeFile, access } from "fs/promises";
1322
- import { dirname as dirname2, resolve as resolve2 } from "path";
1422
+ import { dirname as dirname2, resolve as resolve2, sep } from "path";
1423
+ function assertSafeOutputPath(input) {
1424
+ if (input.trim() === "") {
1425
+ throw outputError("output path is empty", input);
1426
+ }
1427
+ const segments = input.split(/[\\/]/);
1428
+ for (const seg of segments) {
1429
+ if (seg === "..") {
1430
+ throw outputError("path traversal segment '..' is not allowed", input);
1431
+ }
1432
+ }
1433
+ }
1434
+ function outputError(reason, input) {
1435
+ return new TiroError(
1436
+ {
1437
+ code: "unsafe_output_path",
1438
+ message: `Refusing to write to ${JSON.stringify(input)}: ${reason}.`,
1439
+ errorType: "bad_request",
1440
+ suggestion: "Use a path without '..' segments, or pass --force if you really mean it."
1441
+ },
1442
+ ExitCode.Usage
1443
+ );
1444
+ }
1323
1445
  async function writeFileAtomic(filepath, content, opts = {}) {
1446
+ if (!opts.force) {
1447
+ assertSafeOutputPath(filepath);
1448
+ }
1324
1449
  const absPath = resolve2(filepath);
1325
1450
  await mkdir(dirname2(absPath), { recursive: true });
1326
1451
  if (!opts.force) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theplato/tiro-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tiro AI notes & transcripts — agent-first command line",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,8 @@
22
22
  "typecheck": "tsc --noEmit",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
- "prepublishOnly": "npm run typecheck && npm run build"
25
+ "test:security": "vitest run --testNamePattern \"\\\\((C|H|M)[0-9]+\\\\)\"",
26
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
26
27
  },
27
28
  "keywords": [
28
29
  "tiro",