@panguard-ai/panguard 1.6.0 → 1.7.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 (69) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +56 -6
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/config.d.ts +6 -1
  6. package/dist/cli/commands/config.d.ts.map +1 -1
  7. package/dist/cli/commands/config.js +39 -23
  8. package/dist/cli/commands/config.js.map +1 -1
  9. package/dist/cli/commands/doctor.d.ts.map +1 -1
  10. package/dist/cli/commands/doctor.js +63 -20
  11. package/dist/cli/commands/doctor.js.map +1 -1
  12. package/dist/cli/commands/guard.d.ts.map +1 -1
  13. package/dist/cli/commands/guard.js +220 -70
  14. package/dist/cli/commands/guard.js.map +1 -1
  15. package/dist/cli/commands/hook.d.ts +115 -0
  16. package/dist/cli/commands/hook.d.ts.map +1 -0
  17. package/dist/cli/commands/hook.js +767 -0
  18. package/dist/cli/commands/hook.js.map +1 -0
  19. package/dist/cli/commands/persist.d.ts +32 -0
  20. package/dist/cli/commands/persist.d.ts.map +1 -0
  21. package/dist/cli/commands/persist.js +104 -0
  22. package/dist/cli/commands/persist.js.map +1 -0
  23. package/dist/cli/commands/scan.d.ts.map +1 -1
  24. package/dist/cli/commands/scan.js +157 -54
  25. package/dist/cli/commands/scan.js.map +1 -1
  26. package/dist/cli/commands/setup.d.ts.map +1 -1
  27. package/dist/cli/commands/setup.js +110 -37
  28. package/dist/cli/commands/setup.js.map +1 -1
  29. package/dist/cli/commands/status.d.ts.map +1 -1
  30. package/dist/cli/commands/status.js +66 -26
  31. package/dist/cli/commands/status.js.map +1 -1
  32. package/dist/cli/commands/up.d.ts.map +1 -1
  33. package/dist/cli/commands/up.js +380 -96
  34. package/dist/cli/commands/up.js.map +1 -1
  35. package/dist/cli/consent.d.ts +26 -6
  36. package/dist/cli/consent.d.ts.map +1 -1
  37. package/dist/cli/consent.js +47 -18
  38. package/dist/cli/consent.js.map +1 -1
  39. package/dist/cli/credentials.d.ts +11 -1
  40. package/dist/cli/credentials.d.ts.map +1 -1
  41. package/dist/cli/credentials.js +6 -1
  42. package/dist/cli/credentials.js.map +1 -1
  43. package/dist/cli/dashboard-url.d.ts +31 -0
  44. package/dist/cli/dashboard-url.d.ts.map +1 -0
  45. package/dist/cli/dashboard-url.js +66 -0
  46. package/dist/cli/dashboard-url.js.map +1 -0
  47. package/dist/cli/first-run.d.ts +35 -0
  48. package/dist/cli/first-run.d.ts.map +1 -0
  49. package/dist/cli/first-run.js +59 -0
  50. package/dist/cli/first-run.js.map +1 -0
  51. package/dist/cli/guard-config.d.ts.map +1 -1
  52. package/dist/cli/guard-config.js +15 -3
  53. package/dist/cli/guard-config.js.map +1 -1
  54. package/dist/cli/index.js +32 -11
  55. package/dist/cli/index.js.map +1 -1
  56. package/dist/cli/interactive/actions/setup.d.ts.map +1 -1
  57. package/dist/cli/interactive/actions/setup.js +2 -10
  58. package/dist/cli/interactive/actions/setup.js.map +1 -1
  59. package/dist/cli/interactive/menu-defs.js +6 -6
  60. package/dist/cli/interactive/render.js +1 -1
  61. package/dist/cli/interactive/render.js.map +1 -1
  62. package/dist/cli/workspace-sync.d.ts +0 -1
  63. package/dist/cli/workspace-sync.d.ts.map +1 -1
  64. package/dist/cli/workspace-sync.js +3 -1
  65. package/dist/cli/workspace-sync.js.map +1 -1
  66. package/dist/init/wizard-runner.d.ts.map +1 -1
  67. package/dist/init/wizard-runner.js +0 -8
  68. package/dist/init/wizard-runner.js.map +1 -1
  69. package/package.json +15 -14
@@ -3,19 +3,24 @@
3
3
  *
4
4
  * Flow: scan installed skills → warn about threats → start Guard → open dashboard
5
5
  */
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
6
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, openSync } from 'node:fs';
7
+ import { join, dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { homedir, platform } from 'node:os';
9
- import { execFile } from 'node:child_process';
10
+ import { execFile, spawn } from 'node:child_process';
10
11
  import { Command } from 'commander';
11
12
  import { runCLI } from '@panguard-ai/panguard-guard';
12
13
  import { c, setLogLevel } from '@panguard-ai/core';
13
14
  import { ok, warn, arrow, shield, brandTagline } from '../theme.js';
14
15
  import { detectLang } from '../interactive/lang.js';
16
+ import { ensureTelemetryConsent } from '../consent.js';
17
+ import { installFor, toHookPlatform } from './hook.js';
18
+ import { ensurePersistentService } from './persist.js';
19
+ import { isFirstRun, markInitialized } from '../first-run.js';
20
+ import { readAuthenticatedDashboardUrl, dashboardBaseUrl, } from '../dashboard-url.js';
15
21
  /** Minimal i18n for key user-facing strings */
16
22
  const t = (lang, en, zh) => (lang === 'zh-TW' ? zh : en);
17
23
  const TC_ENDPOINT = 'https://tc.panguard.ai';
18
- const DASHBOARD_URL = 'http://127.0.0.1:3100';
19
24
  function openBrowser(url) {
20
25
  const os = platform();
21
26
  if (os === 'win32') {
@@ -45,24 +50,113 @@ function isGuardRunning() {
45
50
  }
46
51
  }
47
52
  /**
48
- * Read the real rule count loaded by Guard from its TC cache.
49
- * Falls back to 311 (the @panguard-ai/agent-threat-rules@2.0.12 bundled count)
50
- * if the cache hasn't synced yet or is malformed.
53
+ * Whether Guard is installed as a persistent OS service (launchd/systemd). `pga up`
54
+ * starts a background daemon but does NOT install the service so monitoring stops
55
+ * at the next reboot unless the user ran `pga guard install`. Used to keep the
56
+ * summary panel honest about persistence (a security tool must not overclaim it).
57
+ */
58
+ function isServiceInstalled() {
59
+ try {
60
+ const os = platform();
61
+ if (os === 'darwin') {
62
+ const dir = join(homedir(), 'Library', 'LaunchAgents');
63
+ return existsSync(dir) && readdirSync(dir).some((f) => /panguard.*guard.*\.plist$/i.test(f));
64
+ }
65
+ if (os === 'linux') {
66
+ const dir = join(homedir(), '.config', 'systemd', 'user');
67
+ return existsSync(dir) && readdirSync(dir).some((f) => /panguard.*guard/i.test(f));
68
+ }
69
+ }
70
+ catch {
71
+ /* best-effort */
72
+ }
73
+ return false;
74
+ }
75
+ /**
76
+ * Read the real rule count loaded by Guard from its TC cache, falling back to
77
+ * the count actually bundled with THIS install (never a hardcoded guess — the
78
+ * ATR rule count changes daily, so we read it from the shipped package).
51
79
  */
52
80
  function readRuleCountFromCache() {
81
+ // The Guard always loads the rules BUNDLED with this install; Threat Cloud
82
+ // sync can only ADD more on top. So the honest "active" count is at least the
83
+ // bundled count — never the (often smaller) TC cache figure alone, which would
84
+ // understate what the engine is really running and disagree with the dashboard.
85
+ const bundled = readBundledRuleCount();
53
86
  try {
54
87
  const cachePath = join(homedir(), '.panguard-guard', 'threat-cloud-cache.json');
55
88
  if (existsSync(cachePath)) {
56
89
  const cache = JSON.parse(readFileSync(cachePath, 'utf-8'));
57
90
  if (typeof cache.uniqueRulesCount === 'number' && cache.uniqueRulesCount > 0) {
58
- return cache.uniqueRulesCount;
91
+ return Math.max(bundled, cache.uniqueRulesCount);
59
92
  }
60
93
  }
61
94
  }
62
95
  catch {
63
- /* fallback */
96
+ /* fall through to the bundled count */
97
+ }
98
+ return bundled;
99
+ }
100
+ /**
101
+ * Count the detection rules actually bundled with this install by reading the
102
+ * shipped agent-threat-rules package (stats.json if present, else counting the
103
+ * rule YAML files). Returns 0 if it cannot be determined so the caller can omit
104
+ * the line rather than print a wrong number.
105
+ */
106
+ function readBundledRuleCount() {
107
+ try {
108
+ const pkgDir = resolveBundledAtrDir();
109
+ if (!pkgDir)
110
+ return 0;
111
+ const statsPath = join(pkgDir, 'data', 'stats.json');
112
+ if (existsSync(statsPath)) {
113
+ const stats = JSON.parse(readFileSync(statsPath, 'utf-8'));
114
+ if (typeof stats.rules?.total === 'number' && stats.rules.total > 0) {
115
+ return stats.rules.total;
116
+ }
117
+ }
118
+ // Fallback: count rule YAML files on disk
119
+ const rulesDir = join(pkgDir, 'rules');
120
+ let count = 0;
121
+ const walk = (dir) => {
122
+ for (const entry of readdirSync(dir)) {
123
+ const full = join(dir, entry);
124
+ if (statSync(full).isDirectory())
125
+ walk(full);
126
+ else if (entry.endsWith('.yaml') || entry.endsWith('.yml'))
127
+ count++;
128
+ }
129
+ };
130
+ if (existsSync(rulesDir))
131
+ walk(rulesDir);
132
+ return count;
133
+ }
134
+ catch {
135
+ return 0;
136
+ }
137
+ }
138
+ /**
139
+ * Locate the bundled agent-threat-rules package directory by walking up the
140
+ * module tree (filesystem lookup, immune to the package's ESM `exports` map
141
+ * which blocks require.resolve of its package.json).
142
+ */
143
+ function resolveBundledAtrDir() {
144
+ try {
145
+ let dir = dirname(fileURLToPath(import.meta.url));
146
+ for (let i = 0; i < 12; i++) {
147
+ const cand = join(dir, 'node_modules', 'agent-threat-rules');
148
+ if (existsSync(join(cand, 'package.json')))
149
+ return cand;
150
+ const parent = dirname(dir);
151
+ if (parent === dir)
152
+ break;
153
+ dir = parent;
154
+ }
155
+ return null;
156
+ }
157
+ catch {
158
+ return null;
64
159
  }
65
- return 311;
66
160
  }
67
161
  /**
68
162
  * Read the anonymous client ID provisioned during first `pga up`.
@@ -88,6 +182,7 @@ export function upCommand() {
88
182
  .option('--no-proxy', 'Scan only — do not inject runtime protection into agent configs')
89
183
  .option('--verbose', 'Verbose output', false)
90
184
  .option('--skip-scan', 'Skip initial skill scan', false)
185
+ .option('--no-persist', 'Do not install the reboot-surviving service (run only until reboot)')
91
186
  .option('-y, --yes', 'Skip confirmation prompts', false)
92
187
  .action(async (opts) => {
93
188
  // Suppress JSON logs for clean output — set env var BEFORE any dynamic imports
@@ -101,9 +196,13 @@ export function upCommand() {
101
196
  console.log(`\n ${c.sage(c.bold('PanGuard'))} ${c.dim(t(lang, 'Your AI Security Guard', '你的 AI 安全防護'))}`);
102
197
  console.log(` ${c.dim(brandTagline(lang))}\n`);
103
198
  console.log(` ${c.dim('─'.repeat(50))}\n`);
104
- const activatedMarker = join(homedir(), '.panguard', 'activated');
105
- const isFirstRun = !existsSync(activatedMarker);
106
- if (isFirstRun) {
199
+ // First-run detection uses a durable, telemetry-independent marker
200
+ // (~/.panguard/.initialized) so the welcome + interactive setup run ONCE.
201
+ // The old ~/.panguard/activated marker was written only by the opt-in
202
+ // Threat Cloud ping, so an opt-out user (the default) saw the welcome +
203
+ // setup on every single `pga up`.
204
+ const firstRun = isFirstRun();
205
+ if (firstRun) {
107
206
  console.log('');
108
207
  console.log(` ${c.sage(c.bold(t(lang, 'Welcome to PanGuard AI!', '歡迎使用 PanGuard AI!')))}`);
109
208
  console.log('');
@@ -124,13 +223,45 @@ export function upCommand() {
124
223
  process.stderr.write(`[panguard up] Setup failed: ${err instanceof Error ? err.message : String(err)}\n`);
125
224
  }
126
225
  }
127
- // ── Step 1: Detect platforms + inject proxy ─────────────
226
+ // ── Threat Cloud policy + consent (BEFORE we scan or deploy anything) ──
227
+ // Discloses exactly what is shared — anonymized threat signatures + one-way
228
+ // hashes, never code/prompts/PII — and asks once. OPT-IN, default OFF: a
229
+ // bare Enter declines and nothing leaves the machine. This gates all TC
230
+ // upload, and is changeable anytime (pga config set telemetry true / the
231
+ // dashboard Settings + Threat Cloud toggle). Non-interactive (CI) stays OFF.
232
+ const telemetryConsented = await ensureTelemetryConsent();
233
+ // Whether the scan flywheel may upload to Threat Cloud. OPT-IN, default
234
+ // OFF: requires an affirmative consent (telemetryConsented) AND that TC
235
+ // upload is EXPLICITLY enabled in config (threatCloudUploadEnabled ===
236
+ // true). Absent/unset config => OFF (gate is `=== true`, never `!== false`).
237
+ // Nothing leaves the machine unless the user opted in via the first-run
238
+ // prompt / `pga config set telemetry true` / the dashboard toggle.
239
+ const tcUploadAllowed = (() => {
240
+ if (!telemetryConsented)
241
+ return false;
242
+ try {
243
+ const cfgPath = join(homedir(), '.panguard-guard', 'config.json');
244
+ if (existsSync(cfgPath)) {
245
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
246
+ return cfg.threatCloudUploadEnabled === true;
247
+ }
248
+ }
249
+ catch {
250
+ // On any read error, fail closed — do not upload.
251
+ return false;
252
+ }
253
+ // No config on disk yet => not opted in => OFF.
254
+ return false;
255
+ })();
256
+ // ── Step 1: Detect AI platforms (we SCAN before deploying) ──
128
257
  let platformCount = 0;
129
258
  let serverCount = 0;
130
259
  let threatCount = 0;
260
+ // Captured during detection, used to inject AFTER the scan completes.
261
+ let detectedPlatforms = [];
262
+ let injectProxyFn = null;
131
263
  try {
132
264
  let detectPlatforms;
133
- let injectProxyFn;
134
265
  try {
135
266
  // Published package: import from /config subpath
136
267
  const mcp = (await import('@panguard-ai/panguard-mcp/config'));
@@ -148,64 +279,19 @@ export function upCommand() {
148
279
  }
149
280
  console.log(` ${shield()} ${c.bold(t(lang, 'Looking at your setup...', '看看你的環境...'))}\n`);
150
281
  const platforms = await detectPlatforms();
151
- const detected = platforms.filter((p) => p.detected);
152
- for (const p of detected) {
282
+ detectedPlatforms = platforms.filter((p) => p.detected);
283
+ for (const p of detectedPlatforms) {
153
284
  console.log(` ${ok()} ${c.safe(p.name)} ${c.dim(t(lang, 'found', '已找到'))}`);
154
285
  }
155
- if (detected.length === 0) {
286
+ if (detectedPlatforms.length === 0) {
156
287
  console.log(` ${c.dim(t(lang, 'No AI tools found yet.', '尚未找到 AI 工具。'))}`);
157
288
  }
158
- // Build runtime protection by DEFAULT. `pga up` should stand up
159
- // protection out of the box; --no-proxy opts out (scan only). In an
160
- // interactive TTY we still confirm, but the prompt defaults to YES so
161
- // the common path (just run `pga up`) actually protects. Non-TTY
162
- // (CI / unattended) injects by default too — configs are backed up.
163
- if (detected.length > 0) {
164
- let shouldInject = opts.proxy !== false;
165
- if (shouldInject && !opts.yes && process.stdin.isTTY) {
166
- const { createInterface } = await import('node:readline');
167
- const rl = createInterface({ input: process.stdin, output: process.stdout });
168
- const answer = await new Promise((resolve) => {
169
- rl.question(`\n Inject runtime protection into ${detected.length} platform(s)? ` +
170
- `${c.dim('(configs backed up to *.bak)')} [Y/n] `, resolve);
171
- });
172
- rl.close();
173
- shouldInject = answer.trim().toLowerCase() !== 'n';
174
- }
175
- if (!shouldInject) {
176
- console.log(`\n ${c.dim('Scan only — runtime protection not injected (--no-proxy).')}`);
177
- }
178
- else {
179
- console.log(`\n ${shield()} ${c.bold('Watching your agents...')}\n`);
180
- const proxySummary = injectProxyFn(detected.map((p) => p.id));
181
- platformCount = proxySummary.totalPlatforms;
182
- serverCount = proxySummary.totalServersProxied;
183
- if (serverCount > 0) {
184
- console.log(` ${c.safe(`${serverCount} MCP server(s)`)} proxied across ${c.sage(`${platformCount} platform(s)`)}`);
185
- console.log(` ${c.dim('Config backed up to *.bak files')}`);
186
- }
187
- else {
188
- console.log(` ${c.dim('All detected tools are already protected.')}`);
189
- }
190
- // Show errors if any
191
- for (const r of proxySummary.results) {
192
- if (r.error) {
193
- console.log(` ${c.critical(`${r.platformId}: ${r.error}`)}`);
194
- }
195
- }
196
- }
197
- }
198
289
  console.log();
199
290
  }
200
291
  catch {
201
292
  console.log(` ${c.dim('Platform detection skipped (install @panguard-ai/panguard-mcp for full detection).')}\n`);
202
293
  }
203
- // ── Step 2: Open dashboard ──────────────────────────────
204
- if (opts.dashboard) {
205
- console.log(` ${c.sage(`Opening dashboard: ${DASHBOARD_URL}`)}\n`);
206
- openBrowser(DASHBOARD_URL);
207
- }
208
- // ── Step 3: Scan installed skills ────────────────────────
294
+ // ── Step 2: Scan installed skills (BEFORE deploying protection) ──
209
295
  if (!opts.skipScan) {
210
296
  console.log(`\n ${arrow()} ${c.bold(t(lang, 'Scanning installed skills...', '掃描已安裝技能...'))}\n`);
211
297
  try {
@@ -272,8 +358,10 @@ export function upCommand() {
272
358
  riskScore: report.riskScore,
273
359
  });
274
360
  }
275
- // ── Flywheel: submit scan results to TC ──
276
- if (report.riskScore > 0) {
361
+ // ── Flywheel: submit scan results to TC (consent-gated) ──
362
+ // Never upload anything when the user has not consented or
363
+ // has opted out of Threat Cloud.
364
+ if (tcUploadAllowed && report.riskScore > 0) {
277
365
  submitToTC(skill.name, skillDir, report).catch(() => { });
278
366
  }
279
367
  }
@@ -335,39 +423,194 @@ export function upCommand() {
335
423
  console.log(` ${c.dim('Skill scan skipped (discovery unavailable).')}\n`);
336
424
  }
337
425
  }
338
- // ── Activation tracking (one-time, respects telemetry opt-out) ──
339
- const telemetryDisabled = (() => {
340
- try {
341
- const cfgPath = join(homedir(), '.panguard-guard', 'config.json');
342
- if (existsSync(cfgPath)) {
343
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
344
- return cfg.telemetryEnabled === false;
345
- }
426
+ // ── Step 3: Deploy runtime protection (AFTER the scan) ──
427
+ // Build runtime protection by DEFAULT. `pga up` should stand up
428
+ // protection out of the box; --no-proxy opts out (scan only). In an
429
+ // interactive TTY we confirm, but the prompt defaults to YES so the
430
+ // common path (just run `pga up`) actually protects. Non-TTY
431
+ // (CI / unattended) injects by default too — configs are backed up.
432
+ if (detectedPlatforms.length > 0 && injectProxyFn) {
433
+ let shouldInject = opts.proxy !== false;
434
+ if (shouldInject && !opts.yes && process.stdin.isTTY) {
435
+ const { createInterface } = await import('node:readline');
436
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
437
+ const answer = await new Promise((resolve) => {
438
+ rl.question(`\n Inject runtime protection into ${detectedPlatforms.length} platform(s)? ` +
439
+ `${c.dim('(configs backed up to *.bak)')} [Y/n] `, resolve);
440
+ });
441
+ rl.close();
442
+ shouldInject = answer.trim().toLowerCase() !== 'n';
346
443
  }
347
- catch {
348
- /* default to enabled */
444
+ if (!shouldInject) {
445
+ console.log(`\n ${c.dim('Scan only runtime protection not injected (--no-proxy).')}`);
349
446
  }
350
- return false;
351
- })();
352
- if (!telemetryDisabled) {
447
+ else {
448
+ console.log(`\n ${shield()} ${c.bold('Watching your agents...')}\n`);
449
+ const proxySummary = injectProxyFn(detectedPlatforms.map((p) => p.id));
450
+ platformCount = proxySummary.totalPlatforms;
451
+ serverCount = proxySummary.totalServersProxied;
452
+ if (serverCount > 0) {
453
+ console.log(` ${c.safe(`${serverCount} MCP server(s)`)} proxied across ${c.sage(`${platformCount} platform(s)`)}`);
454
+ console.log(` ${c.dim('Config backed up to *.bak files')}`);
455
+ }
456
+ else {
457
+ console.log(` ${c.dim('All detected tools are already protected.')}`);
458
+ }
459
+ for (const r of proxySummary.results) {
460
+ if (r.error) {
461
+ console.log(` ${c.critical(`${r.platformId}: ${r.error}`)}`);
462
+ }
463
+ }
464
+ // The MCP proxy only covers MCP tool servers. An agent's BUILT-IN
465
+ // tools (Bash/Edit/Write/WebFetch) bypass it — the most dangerous
466
+ // surface. Register the per-platform tool-call hook on EVERY detected
467
+ // platform that exposes one, so built-in tools are evaluated too.
468
+ const hookable = detectedPlatforms
469
+ .map((p) => toHookPlatform(p.id))
470
+ .filter((p) => p !== null);
471
+ if (hookable.length) {
472
+ const done = [];
473
+ for (const hp of hookable) {
474
+ try {
475
+ if (installFor(hp) !== 'error')
476
+ done.push(hp);
477
+ }
478
+ catch {
479
+ /* best-effort; pga hook install can retry */
480
+ }
481
+ }
482
+ if (done.length) {
483
+ console.log(` ${c.safe(`Built-in tools guarded on ${done.length} platform(s)`)} ` +
484
+ `${c.dim(`(${done.join(', ')} — restart the agent to activate)`)}`);
485
+ }
486
+ }
487
+ }
488
+ console.log();
489
+ }
490
+ // ── Activation tracking (one-time, OPT-IN) ──
491
+ // The activation ping is non-essential collective telemetry, so it only
492
+ // fires when the user has EXPLICITLY opted in (threatCloudUploadEnabled
493
+ // === true). Default OFF: absent/unset config or any read error => no ping.
494
+ const telemetryDisabled = !tcUploadAllowed;
495
+ if (tcUploadAllowed) {
353
496
  reportActivation().catch(() => { });
354
497
  }
355
498
  // ── Summary + Start Guard ─────────────────────────────
356
499
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
357
500
  const guardAlreadyRunning = isGuardRunning();
501
+ // Persistence: on macOS install a user-level LaunchAgent (no sudo) so
502
+ // protection survives reboot like an antivirus. The service IS the daemon
503
+ // (RunAtLoad), so when we install it we do NOT also spawn an ephemeral one
504
+ // — two daemons would fight over the dashboard port. Linux/Windows keep
505
+ // the ephemeral daemon plus the honest "pga guard install" hint.
506
+ let persistResult = isServiceInstalled() ? 'already' : 'unsupported';
358
507
  if (!guardAlreadyRunning) {
359
- // Suppress guard's own banner/status pga up has its own summary
360
- process.env['PANGUARD_QUIET_GUARD'] = '1';
361
- const args = ['start'];
362
- if (opts.dashboard)
363
- args.push('--dashboard');
364
- if (opts.verbose)
365
- args.push('--verbose');
366
- try {
367
- await runCLI(args);
508
+ if (opts.persist !== false && platform() === 'darwin' && persistResult !== 'already') {
509
+ persistResult = ensurePersistentService();
368
510
  }
369
- catch (err) {
370
- process.stderr.write(` ${c.caution('Guard start failed:')} ${err instanceof Error ? err.message : String(err)}\n`);
511
+ const serviceManages = persistResult === 'installed' || persistResult === 'already';
512
+ if (serviceManages) {
513
+ // launchctl starts the daemon asynchronously — wait briefly (up to
514
+ // ~3s for the PID file) so the summary reflects reality.
515
+ for (let i = 0; i < 30 && !isGuardRunning(); i++) {
516
+ await new Promise((r) => setTimeout(r, 100));
517
+ }
518
+ }
519
+ else {
520
+ // Ephemeral fallback (no reboot-surviving service, e.g. --no-persist
521
+ // or a non-macOS host without an installed service): spawn a DETACHED
522
+ // background daemon so protection actually runs until reboot. Running
523
+ // the guard in-process (runCLI('start')) would die the moment `pga up`
524
+ // returns — commandStart is a foreground daemon, so it must be its own
525
+ // process, exactly like the launchd/systemd path. spawn + detached +
526
+ // unref() is what keeps it alive after this process exits.
527
+ // Re-launch THIS CLI entry running `guard --watch` — the exact command
528
+ // the launchd/systemd service runs — as a detached process. Resolving
529
+ // the guard package would fail here: its exports map defines only an
530
+ // `import` condition, so require.resolve (CJS) throws "No exports main".
531
+ // process.argv[1] is the running CLI entry and needs no resolution.
532
+ const watchArgs = ['guard', '--watch'];
533
+ if (opts.dashboard)
534
+ watchArgs.push('--dashboard');
535
+ if (opts.verbose)
536
+ watchArgs.push('--verbose');
537
+ // Resolve to an absolute path: when invoked as `node ./dist/...` the
538
+ // entry is relative, and a detached child must not depend on inheriting
539
+ // the right cwd to find it.
540
+ const cliEntry = process.argv[1] ? resolve(process.argv[1]) : '';
541
+ try {
542
+ if (cliEntry && existsSync(cliEntry)) {
543
+ // Send the daemon's console output to its own log (like launchd),
544
+ // not /dev/null, so a failed background start is diagnosable.
545
+ let outFd = 'ignore';
546
+ try {
547
+ const gdir = join(homedir(), '.panguard-guard');
548
+ if (!existsSync(gdir))
549
+ mkdirSync(gdir, { recursive: true, mode: 0o700 });
550
+ outFd = openSync(join(gdir, 'panguard-guard.log'), 'a');
551
+ }
552
+ catch {
553
+ outFd = 'ignore';
554
+ }
555
+ const child = spawn(process.execPath, [cliEntry, ...watchArgs], {
556
+ detached: true,
557
+ stdio: ['ignore', outFd, outFd],
558
+ env: { ...process.env, PANGUARD_QUIET_GUARD: '1' },
559
+ });
560
+ child.unref();
561
+ // Wait briefly for the detached daemon to come up so the summary
562
+ // + authenticated-URL step below reflect a running guard.
563
+ for (let i = 0; i < 30 && !isGuardRunning(); i++) {
564
+ await new Promise((r) => setTimeout(r, 100));
565
+ }
566
+ }
567
+ else {
568
+ // Last-resort fallback (no resolvable CLI entry): in-process start.
569
+ // Protection lasts only while this process lives — degraded, but
570
+ // better than no daemon at all.
571
+ process.env['PANGUARD_QUIET_GUARD'] = '1';
572
+ const inProc = ['start'];
573
+ if (opts.dashboard)
574
+ inProc.push('--dashboard');
575
+ if (opts.verbose)
576
+ inProc.push('--verbose');
577
+ await runCLI(inProc);
578
+ }
579
+ }
580
+ catch (err) {
581
+ process.stderr.write(` ${c.caution('Guard start failed:')} ${err instanceof Error ? err.message : String(err)}\n`);
582
+ }
583
+ }
584
+ }
585
+ // ── Resolve the AUTHENTICATED dashboard URL ──────────────────
586
+ // The daemon persists its launch token once the dashboard is listening.
587
+ // The launchd path writes the PID file before the dashboard token, so
588
+ // there can be a brief window where the daemon is up but the token is
589
+ // not yet on disk — poll up to ~2s before falling back to guidance, so
590
+ // we never open/print a bare URL that 401s.
591
+ let dashboardUrl = null;
592
+ if (opts.dashboard && isGuardRunning()) {
593
+ for (let i = 0; i < 20; i++) {
594
+ dashboardUrl = readAuthenticatedDashboardUrl();
595
+ if (dashboardUrl)
596
+ break;
597
+ await new Promise((r) => setTimeout(r, 100));
598
+ }
599
+ }
600
+ // ── Open dashboard (after Guard is up so the server is listening) ──
601
+ // Open + print the AUTHENTICATED URL so it works on rerun, headless, or
602
+ // when copied. If the token is unavailable, print guidance instead of a
603
+ // dead bare URL that would land on a 401 "Invalid token" page.
604
+ if (opts.dashboard) {
605
+ if (dashboardUrl) {
606
+ console.log(`\n ${c.sage(`Opening dashboard: ${dashboardBaseUrl()}`)}\n`);
607
+ openBrowser(dashboardUrl);
608
+ }
609
+ else if (isGuardRunning()) {
610
+ console.log(`\n ${c.caution(t(lang, `Dashboard is still starting. Re-run "pga up" in a moment to open it.`, `儀表板仍在啟動中。請稍後再次執行「pga up」開啟。`))}\n`);
611
+ }
612
+ else {
613
+ console.log(`\n ${c.caution(t(lang, `Dashboard not available (Guard is not running). Start it with: pga up`, `儀表板無法使用(Guard 未執行)。請用以下指令啟動:pga up`))}\n`);
371
614
  }
372
615
  }
373
616
  // ── Read rule count + TC status ──────────
@@ -398,9 +641,21 @@ export function upCommand() {
398
641
  console.log(`\n ${c.dim('\u2500'.repeat(50))}`);
399
642
  console.log(` ${statusLabel} ${c.dim(`\u2014 ${elapsed}s`)}`);
400
643
  console.log(` ${c.dim('\u2500'.repeat(50))}`);
644
+ // Honest persistence framing: say plainly whether protection survives a
645
+ // reboot \u2014 never imply always-on when it's only a until-reboot daemon.
646
+ const persisted = persistResult === 'installed' || persistResult === 'already' || isServiceInstalled();
647
+ if (guardRunning && persisted) {
648
+ console.log(` ${c.dim(t(lang, 'Always-on: protection restarts after reboot (launchd).', '\u5e38\u99d0:\u91cd\u958b\u6a5f\u5f8c\u81ea\u52d5\u6062\u5fa9\u9632\u8b77(launchd)\u3002'))}`);
649
+ }
650
+ else if (guardRunning) {
651
+ console.log(` ${c.dim(t(lang, 'Runs until reboot. For always-on monitoring: pga guard install', '\u6301\u7e8c\u5230\u91cd\u958b\u6a5f\u70ba\u6b62\u3002\u8981\u958b\u6a5f\u5f8c\u4ecd\u6301\u7e8c\u76e3\u63a7:pga guard install'))}`);
652
+ }
401
653
  console.log('');
402
654
  if (opts.dashboard) {
403
- console.log(` ${c.sage('Dashboard')} ${DASHBOARD_URL}`);
655
+ // Print the AUTHENTICATED URL (with token) so copy-pasting it works —
656
+ // a bare URL 401s. Fall back to the base URL only as a last resort
657
+ // when the token is not yet on disk.
658
+ console.log(` ${c.sage('Dashboard')} ${dashboardUrl ?? dashboardBaseUrl()}`);
404
659
  }
405
660
  console.log(` ${c.sage(t(lang, 'Rules', '規則'))} ${ruleCount} ${t(lang, 'detection rules active', '條偵測規則運作中')}`);
406
661
  if (platformCount > 0) {
@@ -425,7 +680,13 @@ export function upCommand() {
425
680
  }
426
681
  // ── Next steps ─────────────────────────────────────────
427
682
  console.log(` ${c.bold(t(lang, 'NEXT STEPS', '下一步'))}`);
428
- console.log(` ${c.dim('1.')} ${t(lang, 'Open dashboard', '開啟儀表板')} ${c.sage(DASHBOARD_URL)}`);
683
+ if (dashboardUrl) {
684
+ console.log(` ${c.dim('1.')} ${t(lang, 'Open dashboard', '開啟儀表板')} ${c.sage(dashboardUrl)}`);
685
+ }
686
+ else {
687
+ // No live token: point at re-running `pga up` rather than a dead URL.
688
+ console.log(` ${c.dim('1.')} ${t(lang, 'Open dashboard', '開啟儀表板')} ${c.dim(t(lang, 'run "pga up" to open the dashboard', '執行「pga up」開啟儀表板'))}`);
689
+ }
429
690
  if (threatCount > 0) {
430
691
  console.log(` ${c.dim('2.')} ${t(lang, 'Review threats', '檢視威脅')} ${c.caution(`${threatCount} ${t(lang, 'flagged', '個標記')}`)} ${c.dim('\u2014 pga audit skill <name>')}`);
431
692
  console.log(` ${c.dim('3.')} ${t(lang, 'Upgrade detection', '升級偵測')} ${c.dim('pga guard setup-ai')}`);
@@ -436,11 +697,28 @@ export function upCommand() {
436
697
  console.log('');
437
698
  console.log(` ${c.dim(t(lang, 'Layer 1 (regex) catches ~70% of attacks at zero cost.', 'Layer 1 (正則) 零成本攔截約 70% 攻擊。'))}`);
438
699
  console.log(` ${c.dim(t(lang, 'Add Layer 2 (local AI) or 3 (cloud AI) for deeper detection.', '加入 Layer 2 (本地 AI) 或 Layer 3 (雲端 AI) 提升偵測深度。'))}`);
439
- if (!telemetryDisabled) {
440
- console.log(` ${c.dim(t(lang, 'Telemetry: anonymous usage stats sent to tc.panguard.ai', '遙測:匿名使用統計會傳送至 tc.panguard.ai'))}`);
441
- console.log(` ${c.dim(t(lang, 'Opt out: pga config set telemetry false', '關閉:pga config set telemetry false'))}`);
700
+ if (tcUploadAllowed) {
701
+ console.log(` ${c.dim(t(lang, 'Collective defense ON: when an attack is blocked, only the matched rule ID,', '集體防禦已開啟:擋下攻擊時,只分享命中的規則 ID、'))}`);
702
+ console.log(` ${c.dim(t(lang, 'a one-way payload hash, and the source type are shared — never your prompts,', 'payload 的單向雜湊、來源類型 絕不含你的 prompt、'))}`);
703
+ console.log(` ${c.dim(t(lang, 'code, file contents, keys, hostname, or IP. Thank you for defending the commons.', '程式碼、檔案內容、金鑰、主機名或 IP。謝謝你一起守護社群。'))}`);
704
+ console.log(` ${c.dim(t(lang, 'Turn off anytime: pga config set telemetry false', '隨時可關閉:pga config set telemetry false'))}`);
705
+ }
706
+ else {
707
+ // OPT-IN guidance (NOT a default, NOT a nag): explain the value + the
708
+ // privacy guarantee + the one command, so the user can make an
709
+ // informed choice. Collective defense stays OFF until they opt in.
710
+ console.log(` ${c.dim(t(lang, 'Collective defense is OFF — nothing leaves this machine.', '集體防禦目前關閉 — 沒有任何資料離開這台機器。'))}`);
711
+ console.log(` ${c.dim(t(lang, 'Want to help? Each attack you block can become a new ATR rule that protects', '想幫忙嗎?你擋下的每次攻擊,都能變成一條保護所有人的新 ATR 規則,'))}`);
712
+ console.log(` ${c.dim(t(lang, 'everyone — and you get community rules back faster. Shared: only the matched', '而你也能更快收到社群回流的規則。分享的只有命中的規則 ID、'))}`);
713
+ console.log(` ${c.dim(t(lang, 'rule ID, a one-way hash, and the source type. Never your prompts, code,', 'payload 的單向雜湊、來源類型。絕不含你的 prompt、程式碼、'))}`);
714
+ console.log(` ${c.dim(t(lang, 'file contents, keys, hostname, or IP. Opt in: pga config set telemetry true', '檔案內容、金鑰、主機名或 IP。開啟:pga config set telemetry true'))}`);
442
715
  }
443
716
  console.log('');
717
+ // The full first run completed: record the durable marker so the next
718
+ // `pga up` (and bare `pga`) skip the welcome + interactive setup and go
719
+ // straight to scan -> protect -> summary. Independent of telemetry
720
+ // consent, so opt-out users are no longer nagged every run.
721
+ markInitialized();
444
722
  });
445
723
  }
446
724
  /** Best-effort submit scan results to Threat Cloud for the flywheel */
@@ -551,7 +829,8 @@ async function reportActivation() {
551
829
  if (fe(marker))
552
830
  return;
553
831
  const { randomUUID } = await import('node:crypto');
554
- const idPath = join(homedir(), '.panguard', 'client-id');
832
+ const idDir = join(homedir(), '.panguard');
833
+ const idPath = join(idDir, 'client-id');
555
834
  let clientId;
556
835
  try {
557
836
  clientId = rf(idPath, 'utf-8').trim();
@@ -559,6 +838,11 @@ async function reportActivation() {
559
838
  catch {
560
839
  clientId = randomUUID();
561
840
  try {
841
+ // On a true first run ~/.panguard may not exist yet; create it (0o700)
842
+ // before writing the client-id, otherwise the write ENOENTs and
843
+ // readSensorId() keeps returning null forever.
844
+ if (!fe(idDir))
845
+ md(idDir, { recursive: true, mode: 0o700 });
562
846
  wf(idPath, clientId, 'utf-8');
563
847
  }
564
848
  catch {