ticketlens 0.10.18 → 0.11.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/README.md CHANGED
@@ -79,7 +79,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
79
79
  | `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
80
80
  | `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
81
81
 
82
- `init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. `config` is tracker-aware and always re-validates the connection after edits.
82
+ `init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. If your Jira instance sits behind a VPN and resolves to a private/internal address, you'll be asked to confirm you trust that connection before it's allowed through — a one-time confirmation, remembered per profile and scoped to that exact host (changing the URL asks again). `config` is tracker-aware and always re-validates the connection after edits.
83
83
 
84
84
  `config` uses merge semantics: new ticket prefixes and triage statuses are added to existing lists, never replaced. Partial matching resolves `QA` to `QA Testing` if that's the status in your Jira.
85
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.10.18",
3
+ "version": "0.11.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.10.18 -->
1
+ <!-- jtb-skill-version: 0.11.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -1134,7 +1134,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1134
1134
  const authHeader = buildAuthHeader(jiraEnv);
1135
1135
  const apiVersion = conn.auth === 'cloud' ? 3 : 2;
1136
1136
  const jiraOrigin = new URL(jiraEnv.JIRA_BASE_URL).origin;
1137
- const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion });
1137
+ const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp });
1138
1138
  const MAX_CONFLUENCE_PAGES = 10;
1139
1139
  const safeLinks = links
1140
1140
  .filter(l => { try { return new URL(l.url).origin === jiraOrigin; } catch { return false; } })
@@ -1189,6 +1189,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1189
1189
  fetcher,
1190
1190
  noCache: args.includes('--no-cache'),
1191
1191
  onProgress: (msg) => process.stderr.write(msg + '\n'),
1192
+ allowPrivateIp: conn.allowPrivateIp,
1192
1193
  });
1193
1194
  const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
1194
1195
  const cached = ticket.localAttachments.filter(r => r.skipReason === 'cached').length;
@@ -11,9 +11,9 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
11
11
 
12
12
  return {
13
13
  type: 'jira',
14
- fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, ...opts }),
15
- fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, ...opts }),
16
- searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, ...opts }),
17
- fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, ...opts }),
14
+ fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
15
+ fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
16
+ searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
17
+ fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
18
18
  };
19
19
  }
@@ -20,10 +20,15 @@ const MAX_REDIRECT_HOPS = 2;
20
20
  * code-review remediation). Auth headers are only ever sent on the initial
21
21
  * request — every hop after that is auth-less, matching Jira Cloud's
22
22
  * presigned-URL CDN redirect model.
23
+ *
24
+ * `allowPrivateIp` only ever applies to the initial hop (the user's own
25
+ * trusted Jira origin). A redirect target is server-controlled — a
26
+ * categorically different trust domain — so it is never passed through,
27
+ * regardless of the caller's trust level.
23
28
  */
24
- async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
29
+ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers, allowPrivateIp = false) {
25
30
  let url = initialUrl;
26
- await validateResolvedHost(new URL(url).hostname, lookup);
31
+ await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
27
32
  let response = await fetcher(url, { headers, redirect: 'manual' });
28
33
  let hops = 0;
29
34
  while (response.status >= 300 && response.status < 400) {
@@ -53,6 +58,7 @@ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
53
58
  * @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
54
59
  * @param {boolean} opts.noCache Force re-download even if cached (default: false)
55
60
  * @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
61
+ * @param {boolean} opts.allowPrivateIp Skip the private-IP DNS check on the initial hop only (default: false)
56
62
  *
57
63
  * @returns {Promise<Array<{filename, mimeType, size, localPath, skipped, skipReason, error}>>}
58
64
  */
@@ -64,6 +70,7 @@ export async function downloadAttachments(ticket, opts = {}) {
64
70
  configDir = DEFAULT_CONFIG_DIR,
65
71
  noCache = false,
66
72
  onProgress = null,
73
+ allowPrivateIp = false,
67
74
  } = opts;
68
75
 
69
76
  const attachments = (ticket.attachments ?? []).filter(a => a.content);
@@ -118,7 +125,7 @@ export async function downloadAttachments(ticket, opts = {}) {
118
125
  onProgress?.(` download ${a.filename}`);
119
126
  try {
120
127
  const headers = buildAuthHeader(env);
121
- const response = await followValidatedRedirects(a.content, fetcher, lookup, headers);
128
+ const response = await followValidatedRedirects(a.content, fetcher, lookup, headers, allowPrivateIp);
122
129
  if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
123
130
  const buffer = await response.arrayBuffer();
124
131
  fs.writeFileSync(localPath, Buffer.from(buffer));
@@ -16,7 +16,7 @@ import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.m
16
16
  import { promptSelect } from './select-prompt.mjs';
17
17
  import { parseAge } from './cache-manager.mjs';
18
18
  import { DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
19
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
19
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
21
  import { isLicensed } from './license.mjs';
22
22
  import { siteBase } from './api-utils.mjs';
@@ -103,6 +103,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
103
103
  let email = profile.email || '';
104
104
  const existingToken = profileCreds.pat || profileCreds.apiToken || '';
105
105
  let token = existingToken;
106
+ // Trust is bound to the exact hostname it was granted for — never a flat
107
+ // per-profile boolean. Editing the URL to a different host must re-trigger
108
+ // the private-IP block and a fresh "Trust this connection?" prompt.
109
+ let trustedHostname = (profile.allowPrivateIp && hostnameOf(profile.baseUrl)) || null;
106
110
 
107
111
  // ── URL ───────────────────────────────────────────────────────────────────
108
112
  const urlTyped = await promptText(
@@ -158,6 +162,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
158
162
  let connected = false;
159
163
  let startFrom = 'test';
160
164
 
165
+ function isTrustedForCurrentUrl() {
166
+ return trustedHostname !== null && hostnameOf(url) === trustedHostname;
167
+ }
168
+
169
+ async function attemptConnection() {
170
+ if (isJira) {
171
+ const testEnv = {
172
+ JIRA_BASE_URL: url,
173
+ JIRA_EMAIL: email,
174
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
175
+ JIRA_PAT: auth === 'pat' ? token : '',
176
+ };
177
+ await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
178
+ } else {
179
+ await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
180
+ }
181
+ }
182
+
161
183
  setupLoop: while (true) {
162
184
  // Re-prompt URL + auth (on retry with 'url' option)
163
185
  if (startFrom === 'url') {
@@ -216,17 +238,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
216
238
  session.spin('Testing connection...');
217
239
 
218
240
  try {
219
- if (isJira) {
220
- const testEnv = {
221
- JIRA_BASE_URL: url,
222
- JIRA_EMAIL: email,
223
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
224
- JIRA_PAT: auth === 'pat' ? token : '',
225
- };
226
- await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
227
- } else {
228
- await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
229
- }
241
+ await attemptConnection();
230
242
  session.connected();
231
243
  connected = true;
232
244
  break setupLoop;
@@ -234,6 +246,15 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
234
246
  session.failed();
235
247
  const classified = classifyError(err, { baseUrl: url, profileName: target });
236
248
  session.footer(classified.message, 'error', classified.hint);
249
+
250
+ if (classified.privateIpBlocked) {
251
+ const trust = await promptYN('Trust this connection?', { stream });
252
+ if (trust) {
253
+ trustedHostname = hostnameOf(url);
254
+ startFrom = 'test';
255
+ continue setupLoop;
256
+ }
257
+ }
237
258
  }
238
259
 
239
260
  stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
@@ -326,7 +347,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
326
347
  JIRA_API_TOKEN: auth !== 'pat' ? token : '',
327
348
  JIRA_PAT: auth === 'pat' ? token : '',
328
349
  };
329
- available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
350
+ available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
330
351
  } else {
331
352
  available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
332
353
  }
@@ -397,6 +418,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
397
418
  else delete updated.projectPaths;
398
419
  if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
399
420
  else delete updated.cacheTtl;
421
+ if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
422
+ else delete updated.allowPrivateIp;
400
423
 
401
424
  const credData = (token !== existingToken)
402
425
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
@@ -68,6 +68,12 @@ export function stripCr(str) {
68
68
  return str.replace(/\r/g, '');
69
69
  }
70
70
 
71
+ /** Parse a URL's hostname, or null if unparseable. Used to scope trust
72
+ * grants (e.g. allowPrivateIp) to the exact host they were confirmed for. */
73
+ export function hostnameOf(url) {
74
+ try { return new URL(url).hostname; } catch { return null; }
75
+ }
76
+
71
77
  /**
72
78
  * Build the env-like object expected by jira-client functions.
73
79
  * @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn
@@ -29,6 +29,7 @@ export async function testConnections({
29
29
  email: profile.email,
30
30
  apiToken: profileCreds.apiToken,
31
31
  pat: profileCreds.pat,
32
+ allowPrivateIp: profile.allowPrivateIp,
32
33
  };
33
34
 
34
35
  const session = createSession(
@@ -18,6 +18,15 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
18
18
  const profile = profileName || 'your profile';
19
19
  const code = getNetworkCode(err);
20
20
 
21
+ if (err.code === 'PRIVATE_IP_BLOCKED') {
22
+ const address = err.blockedAddress ? ` (${err.blockedAddress})` : '';
23
+ return {
24
+ message: `${err.blockedHostname} resolves to a private address${address}`,
25
+ hint: "This is expected if you're on a VPN connecting to an on-prem Jira instance. It could also mean something is wrong.",
26
+ privateIpBlocked: true,
27
+ };
28
+ }
29
+
21
30
  // Network-level errors (fetch threw before getting an HTTP response)
22
31
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
23
32
  return {
@@ -16,7 +16,7 @@ import { promptSelect } from './select-prompt.mjs';
16
16
  import { runSwitch } from './profile-switcher.mjs';
17
17
  import { renderWordmark } from './wordmark.mjs';
18
18
  import { printQuickStart } from './quick-start-panel.mjs';
19
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
19
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
21
 
22
22
  const RETRY_OPTIONS = [
@@ -328,6 +328,12 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
328
328
  let baseUrl = '', authType = '', email = '', token = '';
329
329
  let env = {}, apiVersion = 2;
330
330
  let startFrom = 'url';
331
+ // Trust is bound to the exact hostname it was granted for — never a flat
332
+ // per-run boolean. Choosing "Edit from URL" after granting trust for one
333
+ // host must re-trigger the private-IP block and a fresh confirmation
334
+ // for the new host, not silently inherit trust.
335
+ let trustedHostname = null;
336
+ const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
331
337
 
332
338
  setupLoop: while (true) {
333
339
  // ── URL + auth type ────────────────────────────────────────────────────
@@ -432,7 +438,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
432
438
  session.spin('Testing connection...');
433
439
 
434
440
  try {
435
- await fetchCurrentUser({ env, apiVersion });
441
+ await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
436
442
  session.connected();
437
443
  connected = true;
438
444
  break setupLoop;
@@ -440,6 +446,15 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
440
446
  session.failed();
441
447
  const classified = classifyError(err, { baseUrl, profileName });
442
448
  session.footer(classified.message, 'error', classified.hint);
449
+
450
+ if (classified.privateIpBlocked) {
451
+ const trust = await promptYN('Trust this connection?', { stream });
452
+ if (trust) {
453
+ trustedHostname = hostnameOf(baseUrl);
454
+ startFrom = 'retry';
455
+ continue setupLoop;
456
+ }
457
+ }
443
458
  }
444
459
 
445
460
  // ── Retry options ──────────────────────────────────────────────────────
@@ -503,7 +518,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
503
518
 
504
519
  stream.write(` ${s.dim('Validating statuses...')}\n`);
505
520
  try {
506
- const available = await fetchStatuses({ env, apiVersion });
521
+ const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
507
522
  const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
508
523
  stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
509
524
  const corrected = [];
@@ -540,6 +555,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
540
555
  ...(email ? { email } : {}),
541
556
  ...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
542
557
  ...(projectPaths.length > 0 ? { projectPaths } : {}),
558
+ ...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
543
559
  triageStatuses,
544
560
  };
545
561
  const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
@@ -125,7 +125,14 @@ function normalizeHostname(hostname) {
125
125
  return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
126
126
  }
127
127
 
128
- export function validateBaseUrl(url) {
128
+ /**
129
+ * @param {string} url
130
+ * @param {boolean} [allowPrivateIp] - skip the private/internal blocklist check
131
+ * for THIS hostname. Set only via explicit interactive user confirmation
132
+ * during setup, persisted per-profile — never from synced/network data
133
+ * (see team-jira-sync.mjs). HTTPS enforcement is never skippable.
134
+ */
135
+ export function validateBaseUrl(url, allowPrivateIp = false) {
129
136
  let parsed;
130
137
  try { parsed = new URL(url); } catch {
131
138
  throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
@@ -133,9 +140,14 @@ export function validateBaseUrl(url) {
133
140
  if (parsed.protocol !== 'https:') {
134
141
  throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
135
142
  }
143
+ if (allowPrivateIp) return;
136
144
  const hostname = normalizeHostname(parsed.hostname);
137
145
  if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
138
- throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
146
+ const err = new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
147
+ err.code = 'PRIVATE_IP_BLOCKED';
148
+ err.blockedHostname = hostname;
149
+ err.blockedAddress = null;
150
+ throw err;
139
151
  }
140
152
  }
141
153
 
@@ -189,9 +201,17 @@ const dnsValidationCaches = new WeakMap();
189
201
  * `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
190
202
  * there is nothing to protect. Production always uses globalThis.fetch, which
191
203
  * always gets the real resolver (see defaultLookupFor).
204
+ *
205
+ * `allowPrivateIp` skips the lookup entirely (not just the block) when a
206
+ * profile has an explicit, user-confirmed trust exception for this exact
207
+ * hostname — set only during interactive setup, never from synced/network
208
+ * data. Skipping the resolution outright (rather than resolving-then-
209
+ * ignoring) avoids the DNS round-trip and sidesteps any question of how the
210
+ * per-lookup-fn cache should key on a flag that no real caller varies
211
+ * concurrently for the same hostname.
192
212
  */
193
- export async function validateResolvedHost(hostname, lookup) {
194
- if (!lookup) return;
213
+ export async function validateResolvedHost(hostname, lookup, allowPrivateIp = false) {
214
+ if (!lookup || allowPrivateIp) return;
195
215
  let cache = dnsValidationCaches.get(lookup);
196
216
  if (!cache) {
197
217
  cache = new Map();
@@ -202,7 +222,11 @@ export async function validateResolvedHost(hostname, lookup) {
202
222
  const addresses = await lookup(hostname, { all: true });
203
223
  for (const { address } of addresses) {
204
224
  if (BLOCKED_PATTERNS.some(re => re.test(address))) {
205
- throw new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
225
+ const err = new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
226
+ err.code = 'PRIVATE_IP_BLOCKED';
227
+ err.blockedHostname = hostname;
228
+ err.blockedAddress = address;
229
+ throw err;
206
230
  }
207
231
  }
208
232
  })();
@@ -223,8 +247,8 @@ export async function validateResolvedHost(hostname, lookup) {
223
247
  * Error message carries the redirect target's HOSTNAME only — Location values
224
248
  * can contain presigned tokens in path/query.
225
249
  */
226
- export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
227
- await validateResolvedHost(new URL(url).hostname, lookup);
250
+ export async function guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp = false }) {
251
+ await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
228
252
  const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
229
253
  if (response.status >= 300 && response.status < 400) {
230
254
  let target = 'unknown host';
@@ -248,15 +272,15 @@ export function buildAuthHeader(env) {
248
272
  }
249
273
 
250
274
  export async function fetchCurrentUser(opts = {}) {
251
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
252
- validateBaseUrl(env.JIRA_BASE_URL);
275
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
276
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
253
277
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
254
278
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
255
279
 
256
280
  const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
257
281
  const fetchOpts = { headers };
258
282
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
259
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
283
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
260
284
 
261
285
  if (!response.ok) {
262
286
  const err = new Error(`Jira API error ${response.status} fetching current user`);
@@ -274,15 +298,15 @@ export async function fetchCurrentUser(opts = {}) {
274
298
  }
275
299
 
276
300
  export async function fetchStatuses(opts = {}) {
277
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
278
- validateBaseUrl(env.JIRA_BASE_URL);
301
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
302
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
279
303
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
280
304
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
281
305
 
282
306
  const url = `${baseUrl}/rest/api/${apiVersion}/status`;
283
307
  const fetchOpts = { headers };
284
308
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
285
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
309
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
286
310
 
287
311
  if (!response.ok) {
288
312
  const err = new Error(`Jira API error ${response.status} fetching statuses`);
@@ -295,8 +319,8 @@ export async function fetchStatuses(opts = {}) {
295
319
  }
296
320
 
297
321
  export async function searchTickets(jql, opts = {}) {
298
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
299
- validateBaseUrl(env.JIRA_BASE_URL);
322
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
323
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
300
324
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
301
325
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
302
326
 
@@ -307,7 +331,7 @@ export async function searchTickets(jql, opts = {}) {
307
331
  const url = `${baseUrl}${endpoint}?${params}`;
308
332
  const fetchOpts = { headers };
309
333
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
310
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
334
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
311
335
 
312
336
  if (!response.ok) {
313
337
  let detail = '';
@@ -323,15 +347,15 @@ export async function searchTickets(jql, opts = {}) {
323
347
  }
324
348
 
325
349
  export async function fetchRemoteLinks(ticketKey, opts = {}) {
326
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
327
- validateBaseUrl(env.JIRA_BASE_URL);
350
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
351
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
328
352
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
329
353
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
330
354
 
331
355
  const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
332
356
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
333
357
 
334
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
358
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
335
359
  if (!response.ok) return [];
336
360
 
337
361
  const raw = await response.json();
@@ -341,8 +365,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
341
365
  }
342
366
 
343
367
  export async function fetchTicket(ticketKey, opts = {}) {
344
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
345
- validateBaseUrl(env.JIRA_BASE_URL);
368
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
369
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
346
370
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
347
371
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
348
372
 
@@ -350,7 +374,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
350
374
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
351
375
  const fetchOpts = { headers };
352
376
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
353
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
377
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
354
378
 
355
379
  if (!response.ok) {
356
380
  const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
@@ -233,6 +233,7 @@ export function resolveConnection(ticketKey, opts = {}) {
233
233
  ticketPrefixes: profile.ticketPrefixes || null,
234
234
  attentionRules: profile.attentionRules ?? null,
235
235
  staleRule: profile.staleRule ?? null,
236
+ allowPrivateIp: profile.allowPrivateIp || false,
236
237
  source: 'profile',
237
238
  profileName: profile.name,
238
239
  };
@@ -118,6 +118,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
118
118
  JIRA_PAT: profileCreds.pat || '',
119
119
  };
120
120
  const apiVersion = profile.auth === 'cloud' ? 3 : 2;
121
+ const allowPrivateIp = profile.allowPrivateIp;
121
122
 
122
123
  let frame = 0;
123
124
  const spinLine = () => ` ${s.cyan(SPINNER_FRAMES[frame])} Connecting to ${s.bold(chosen.name)}...`;
@@ -129,7 +130,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
129
130
  }, 80);
130
131
 
131
132
  try {
132
- await fetchCurrentUser({ env, apiVersion });
133
+ await fetchCurrentUser({ env, apiVersion, allowPrivateIp });
133
134
  clearInterval(timer);
134
135
  stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
135
136
  await saveDefault(chosen.name, configDir);
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { readCliToken } from './cli-auth.mjs';
10
10
  import { saveProfile, loadProfiles } from './profile-resolver.mjs';
11
11
  import { apiBase } from './api-utils.mjs';
12
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
12
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
13
13
 
14
14
  const META_FILE = 'team-jira-meta.json';
15
15
 
@@ -78,11 +78,23 @@ function teamConfigToCliProfile(apiData) {
78
78
 
79
79
  /**
80
80
  * Writes team config to the named profile without touching credentials.
81
+ * Carries forward `allowPrivateIp` from the existing LOCAL profile only —
82
+ * never from server data (see teamConfigToCliProfile) — and only when the
83
+ * synced baseUrl resolves to the SAME hostname the trust was granted for.
84
+ * A sync that changes the host (legitimate re-point, or a compromised/
85
+ * malicious sync response) must never inherit trust for a different,
86
+ * unconfirmed host — the member has to re-confirm via the wizard.
81
87
  * Returns { error, message } on failure — never throws.
82
88
  */
83
89
  async function applyTeamJiraConfig(groupName, profileData, configDir) {
84
90
  try {
85
- await saveProfile(groupName, profileData, null, configDir);
91
+ const existing = loadProfiles(configDir)?.profiles?.[groupName];
92
+ const sameHost = existing?.baseUrl && profileData.baseUrl &&
93
+ hostnameOf(existing.baseUrl) === hostnameOf(profileData.baseUrl);
94
+ const merged = (existing?.allowPrivateIp && sameHost)
95
+ ? { ...profileData, allowPrivateIp: true }
96
+ : profileData;
97
+ await saveProfile(groupName, merged, null, configDir);
86
98
  return { ok: true };
87
99
  } catch (e) {
88
100
  return { error: 'save-failed', message: e.message };