@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.
- package/CHANGELOG.md +124 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +56 -6
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/config.d.ts +6 -1
- package/dist/cli/commands/config.d.ts.map +1 -1
- package/dist/cli/commands/config.js +39 -23
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +63 -20
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/guard.d.ts.map +1 -1
- package/dist/cli/commands/guard.js +220 -70
- package/dist/cli/commands/guard.js.map +1 -1
- package/dist/cli/commands/hook.d.ts +115 -0
- package/dist/cli/commands/hook.d.ts.map +1 -0
- package/dist/cli/commands/hook.js +767 -0
- package/dist/cli/commands/hook.js.map +1 -0
- package/dist/cli/commands/persist.d.ts +32 -0
- package/dist/cli/commands/persist.d.ts.map +1 -0
- package/dist/cli/commands/persist.js +104 -0
- package/dist/cli/commands/persist.js.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -1
- package/dist/cli/commands/scan.js +157 -54
- package/dist/cli/commands/scan.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +110 -37
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +66 -26
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/up.d.ts.map +1 -1
- package/dist/cli/commands/up.js +380 -96
- package/dist/cli/commands/up.js.map +1 -1
- package/dist/cli/consent.d.ts +26 -6
- package/dist/cli/consent.d.ts.map +1 -1
- package/dist/cli/consent.js +47 -18
- package/dist/cli/consent.js.map +1 -1
- package/dist/cli/credentials.d.ts +11 -1
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +6 -1
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/dashboard-url.d.ts +31 -0
- package/dist/cli/dashboard-url.d.ts.map +1 -0
- package/dist/cli/dashboard-url.js +66 -0
- package/dist/cli/dashboard-url.js.map +1 -0
- package/dist/cli/first-run.d.ts +35 -0
- package/dist/cli/first-run.d.ts.map +1 -0
- package/dist/cli/first-run.js +59 -0
- package/dist/cli/first-run.js.map +1 -0
- package/dist/cli/guard-config.d.ts.map +1 -1
- package/dist/cli/guard-config.js +15 -3
- package/dist/cli/guard-config.js.map +1 -1
- package/dist/cli/index.js +32 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive/actions/setup.d.ts.map +1 -1
- package/dist/cli/interactive/actions/setup.js +2 -10
- package/dist/cli/interactive/actions/setup.js.map +1 -1
- package/dist/cli/interactive/menu-defs.js +6 -6
- package/dist/cli/interactive/render.js +1 -1
- package/dist/cli/interactive/render.js.map +1 -1
- package/dist/cli/workspace-sync.d.ts +0 -1
- package/dist/cli/workspace-sync.d.ts.map +1 -1
- package/dist/cli/workspace-sync.js +3 -1
- package/dist/cli/workspace-sync.js.map +1 -1
- package/dist/init/wizard-runner.d.ts.map +1 -1
- package/dist/init/wizard-runner.js +0 -8
- package/dist/init/wizard-runner.js.map +1 -1
- package/package.json +15 -14
package/dist/cli/commands/up.js
CHANGED
|
@@ -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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
/*
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
152
|
-
for (const p of
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
// ──
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
444
|
+
if (!shouldInject) {
|
|
445
|
+
console.log(`\n ${c.dim('Scan only — runtime protection not injected (--no-proxy).')}`);
|
|
349
446
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
440
|
-
console.log(` ${c.dim(t(lang, '
|
|
441
|
-
console.log(` ${c.dim(t(lang, '
|
|
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
|
|
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 {
|