@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.
- package/AGENTS.md +24 -0
- package/dist/bin/tiro.js +147 -22
- 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
|
|
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);
|
|
@@ -1206,7 +1291,13 @@ function formatDuration(sec) {
|
|
|
1206
1291
|
|
|
1207
1292
|
// src/commands/notes/search.ts
|
|
1208
1293
|
import "commander";
|
|
1209
|
-
|
|
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.
|
|
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
|
+
"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
|
-
"
|
|
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",
|