@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 +13 -0
- package/README.md +2 -2
- package/dist/bin/tiro.js +146 -31
- package/package.json +3 -2
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 #
|
|
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
|
|
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
|
|
516
|
+
if (override !== void 0) return validateHostname(override);
|
|
448
517
|
const env = process.env["TIRO_HOSTNAME"];
|
|
449
|
-
if (env) return
|
|
450
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
by
|
|
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
|
|
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
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
+
"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
|
-
"
|
|
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",
|