@theplato/tiro-cli 0.3.1 → 0.4.1

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.
package/AGENTS.md CHANGED
@@ -88,3 +88,16 @@ 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 posture (summary)
93
+
94
+ - OAuth uses Authorization Code + PKCE with a CSRF-checked loopback
95
+ redirect. Tokens are held in the OS keychain or `TIRO_TOKEN` only —
96
+ never written to stdout, stderr, or disk in any other form.
97
+ - `TIRO_HOSTNAME` / `--hostname` are validated against an `https://` (or
98
+ loopback) allowlist before any OAuth or API request runs.
99
+ - `--output` paths that escape the working tree require `--force`.
100
+ - A dedicated regression suite (`npm run test:security`) pins these
101
+ surfaces and runs in `prepublishOnly` — a broken defense blocks publish.
102
+
103
+ Always run on the latest released version; older versions are deprecated.
package/README.md CHANGED
@@ -108,7 +108,7 @@ tiro notes list
108
108
  ```bash
109
109
  tiro notes list # pretty table in TTY
110
110
  tiro notes list --json # NDJSON for pipes
111
- tiro notes list --keyword "OKR" --since 30d # OpenSearch reorder by keyword
111
+ tiro notes list --keyword "OKR" --since 30d # Reorder by keyword relevance
112
112
  tiro notes list --folder <id> --limit 100 --cursor <token>
113
113
  ```
114
114
 
@@ -287,7 +287,7 @@ The CLI sits on the public Tiro API alongside the MCP server, sharing the same O
287
287
 
288
288
  ```
289
289
  ┌──────────────────────────────┐
290
- │ Tiro Backend (Kotlin)
290
+ │ Tiro Backend
291
291
  │ /v1/external/* + /v1/mcp/* │
292
292
  └──────────────┬────────────────┘
293
293
 
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);
@@ -1113,17 +1198,18 @@ Examples:
1113
1198
  tiro notes list --folder <id> --limit 50
1114
1199
 
1115
1200
  Keyword matching:
1116
- --keyword reorders results by OpenSearch relevance (case-insensitive,
1117
- full-text against note title and paragraph content). When --keyword is
1118
- set, nextCursor is always null. Without --keyword, results are ordered
1119
- by createdAt desc.
1201
+ --keyword reorders results by full-text relevance over title and
1202
+ paragraph content (case-insensitive), with createdAt desc as tiebreaker
1203
+ when scores are close. When --keyword is set, nextCursor is always null.
1204
+ Without --keyword, results are ordered by recording time desc (falls back
1205
+ to createdAt for memo-only notes).
1120
1206
 
1121
1207
  Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1122
1208
  filtered out by default. A page of N may return fewer than N visible notes \u2014
1123
1209
  keep paginating to fetch more, or pass --include-untitled to surface them.
1124
1210
  `;
1125
1211
  function registerNotesList(parent) {
1126
- parent.command("list").description("List notes (lightweight metadata).").option("--keyword <text>", 'Reorder by OpenSearch relevance for this keyword (e.g. "OKR")').option("--folder <id>", "Restrict to a folder and its descendants").option(
1212
+ parent.command("list").description("List notes (lightweight metadata).").option("--keyword <text>", 'Reorder by full-text relevance for this keyword (e.g. "OKR")').option("--folder <id>", "Restrict to a folder and its descendants").option(
1127
1213
  "--since <date>",
1128
1214
  "Inclusive lower bound on createdAt (ISO-8601 or relative: 7d, 24h, 30m)"
1129
1215
  ).option("--until <date>", "Exclusive upper bound on createdAt").option(
@@ -1222,12 +1308,16 @@ Examples:
1222
1308
  tiro notes search "release" --since 2026-04-01 --until 2026-05-01
1223
1309
 
1224
1310
  Keyword matching:
1225
- Full-text against note title + paragraph content via OpenSearch.
1226
- Case-insensitive. Multi-word keywords are tokenized \u2014 "OKR planning"
1227
- matches notes containing both terms. The deep search variant (this
1228
- command) hydrates each result with its primary documents (one-pager,
1229
- custom) so an MCP/LLM client can read content alongside metadata in
1230
- one call.
1311
+ Full-text search against note title + paragraph content. Case-insensitive.
1312
+ Multi-word keywords are tokenized \u2014 "OKR planning" matches notes containing
1313
+ both terms. The deep search variant (this command) hydrates each result
1314
+ with its primary documents (one-pager, custom) so an MCP/LLM client can
1315
+ read content alongside metadata in one call.
1316
+
1317
+ Ordering:
1318
+ Results are sorted by full-text relevance, then createdAt desc as
1319
+ tiebreaker. Without --limit, the server returns 50 results (capped
1320
+ at 200). Pass --limit to override.
1231
1321
 
1232
1322
  Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1233
1323
  filtered out by default. Pass --include-untitled to surface them.
@@ -1334,8 +1424,33 @@ import "commander";
1334
1424
 
1335
1425
  // src/lib/output/file.ts
1336
1426
  import { mkdir, rename, stat, writeFile, access } from "fs/promises";
1337
- import { dirname as dirname2, resolve as resolve2 } from "path";
1427
+ import { dirname as dirname2, resolve as resolve2, sep } from "path";
1428
+ function assertSafeOutputPath(input) {
1429
+ if (input.trim() === "") {
1430
+ throw outputError("output path is empty", input);
1431
+ }
1432
+ const segments = input.split(/[\\/]/);
1433
+ for (const seg of segments) {
1434
+ if (seg === "..") {
1435
+ throw outputError("path traversal segment '..' is not allowed", input);
1436
+ }
1437
+ }
1438
+ }
1439
+ function outputError(reason, input) {
1440
+ return new TiroError(
1441
+ {
1442
+ code: "unsafe_output_path",
1443
+ message: `Refusing to write to ${JSON.stringify(input)}: ${reason}.`,
1444
+ errorType: "bad_request",
1445
+ suggestion: "Use a path without '..' segments, or pass --force if you really mean it."
1446
+ },
1447
+ ExitCode.Usage
1448
+ );
1449
+ }
1338
1450
  async function writeFileAtomic(filepath, content, opts = {}) {
1451
+ if (!opts.force) {
1452
+ assertSafeOutputPath(filepath);
1453
+ }
1339
1454
  const absPath = resolve2(filepath);
1340
1455
  await mkdir(dirname2(absPath), { recursive: true });
1341
1456
  if (!opts.force) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theplato/tiro-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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",