agent-guardrails 0.3.1 → 0.3.3
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 +52 -0
- package/lib/check/detectors/oss.js +59 -0
- package/lib/cli.js +35 -1
- package/lib/commands/check.js +5 -0
- package/lib/commands/daemon.js +323 -0
- package/lib/daemon/worker.js +266 -0
- package/lib/i18n.js +130 -6
- package/lib/mcp/server.js +4 -4
- package/lib/runtime/service.js +94 -0
- package/lib/utils.js +13 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -523,6 +523,58 @@ The first recommended MCP flow is:
|
|
|
523
523
|
|
|
524
524
|
`suggest_task_contract` and `run_guardrail_check` still exist as lower-level MCP tools, but they are not the preferred first-run chat flow.
|
|
525
525
|
|
|
526
|
+
## Daemon Mode / 守护进程模式
|
|
527
|
+
|
|
528
|
+
Run guardrails automatically in the background while you code:
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
# Start the daemon (background mode)
|
|
532
|
+
agent-guardrails start
|
|
533
|
+
|
|
534
|
+
# Check daemon status
|
|
535
|
+
agent-guardrails status
|
|
536
|
+
|
|
537
|
+
# Stop the daemon
|
|
538
|
+
agent-guardrails stop
|
|
539
|
+
|
|
540
|
+
# Run in foreground (useful for debugging or Docker)
|
|
541
|
+
agent-guardrails start --foreground
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### How It Works
|
|
545
|
+
|
|
546
|
+
The daemon monitors file changes and automatically runs guardrail checks:
|
|
547
|
+
- Watches `src/`, `lib/`, `tests/` by default
|
|
548
|
+
- Debounces checks (5 second interval)
|
|
549
|
+
- Logs to `.agent-guardrails/daemon.log`
|
|
550
|
+
|
|
551
|
+
### Configuration (`.agent-guardrails/daemon.json`)
|
|
552
|
+
|
|
553
|
+
| Option | Default | Description |
|
|
554
|
+
|--------|---------|-------------|
|
|
555
|
+
| `watchPaths` | `["src/", "lib/", "tests/"]` | Paths to monitor |
|
|
556
|
+
| `ignorePatterns` | `["node_modules", ".git", ...]` | Patterns to ignore |
|
|
557
|
+
| `checkInterval` | `5000` | Debounce interval (ms) |
|
|
558
|
+
| `blockOnHighRisk` | `true` | Block on high-risk findings |
|
|
559
|
+
| `autoFix` | `false` | Auto-fix issues when possible |
|
|
560
|
+
|
|
561
|
+
### Use Cases
|
|
562
|
+
|
|
563
|
+
- **Local development**: Get instant feedback while coding
|
|
564
|
+
- **CI/CD integration**: Run in containers with `--foreground`
|
|
565
|
+
- **Team guardrails**: Shared daemon config in repo
|
|
566
|
+
|
|
567
|
+
### Daemon vs Manual Check
|
|
568
|
+
|
|
569
|
+
| Daemon Mode | Manual Check |
|
|
570
|
+
|-------------|--------------|
|
|
571
|
+
| Continuous monitoring | One-time check |
|
|
572
|
+
| Automatic on file change | Run when you want |
|
|
573
|
+
| Background process | Foreground process |
|
|
574
|
+
| Best for active development | Best for pre-commit/CI |
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
526
578
|
## CLI Fallback Quick Start
|
|
527
579
|
|
|
528
580
|
If you want the shortest manual path, copy this:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createFinding } from "../finding.js";
|
|
2
2
|
import { normalizeChangeType } from "../../utils.js";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
|
|
4
5
|
function toBoolean(value) {
|
|
5
6
|
if (value === true || value === false) {
|
|
@@ -461,5 +462,63 @@ export const ossDetectors = [
|
|
|
461
462
|
}));
|
|
462
463
|
}
|
|
463
464
|
}
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: "secrets-safety",
|
|
468
|
+
run({ context, addFinding, t }) {
|
|
469
|
+
const { repoRoot, changedFiles } = context;
|
|
470
|
+
if (!changedFiles || changedFiles.length === 0) return;
|
|
471
|
+
|
|
472
|
+
const patterns = [
|
|
473
|
+
{ regex: /(?:api[_-]?key|apikey)\s*[=:]\s*["'][^"']{8,}["']/i, label: "API key" },
|
|
474
|
+
{ regex: /(?:password|passwd|pwd)\s*[=:]\s*["'][^"']{6,}["']/i, label: "Password" },
|
|
475
|
+
{ regex: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/i, label: "Bearer token" },
|
|
476
|
+
{ regex: /-----BEGIN(?:\s+(?:RSA|EC|DSA|OPENSSH|PRIVATE))?[\s-]*PRIVATE KEY-----/, label: "Private key" },
|
|
477
|
+
{ regex: /(?:secret|token)\s*[=:]\s*["'][A-Za-z0-9\-._~+/]{16,}["']/i, label: "Secret/token" }
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
const testLike = /\.(test|spec)\.(js|ts|mjs|cjs|jsx|tsx)$|__tests__|fixtures|mock/i;
|
|
481
|
+
let matched = false;
|
|
482
|
+
|
|
483
|
+
for (const filePath of changedFiles) {
|
|
484
|
+
if (testLike.test(filePath)) continue;
|
|
485
|
+
let content;
|
|
486
|
+
try {
|
|
487
|
+
content = execFileSync(
|
|
488
|
+
"git",
|
|
489
|
+
["diff", "--cached", "--", filePath],
|
|
490
|
+
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
491
|
+
);
|
|
492
|
+
if (!content.trim()) {
|
|
493
|
+
content = execFileSync(
|
|
494
|
+
"git",
|
|
495
|
+
["diff", "--", filePath],
|
|
496
|
+
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const addedLines = content.split("\n").filter((line) => /^\+/.test(line) && !/^\+\+\+/.test(line));
|
|
503
|
+
for (const { regex, label } of patterns) {
|
|
504
|
+
for (const line of addedLines) {
|
|
505
|
+
if (regex.test(line)) {
|
|
506
|
+
matched = true;
|
|
507
|
+
addFinding(createFinding({
|
|
508
|
+
severity: "warning",
|
|
509
|
+
category: "risk",
|
|
510
|
+
code: "secrets-safety",
|
|
511
|
+
message: t("findings.secretsSafetyDetected", { file: filePath, type: label }),
|
|
512
|
+
action: t("actions.moveSecretToEnv"),
|
|
513
|
+
files: [filePath]
|
|
514
|
+
}));
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (matched) break;
|
|
519
|
+
}
|
|
520
|
+
if (matched) break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
464
523
|
}
|
|
465
524
|
];
|
package/lib/cli.js
CHANGED
|
@@ -5,7 +5,8 @@ import { runSetup } from "./commands/setup.js";
|
|
|
5
5
|
import { createTranslator, supportedLocales } from "./i18n.js";
|
|
6
6
|
import { runPlan } from "./commands/plan.js";
|
|
7
7
|
import { runGenerateAgentsMd } from "./commands/agents-md.js";
|
|
8
|
-
import {
|
|
8
|
+
import { startDaemon, stopDaemon, showDaemonStatus } from "./commands/daemon.js";
|
|
9
|
+
import { supportedAdapters, supportedPresets, readOwnPackageJson } from "./utils.js";
|
|
9
10
|
|
|
10
11
|
function parseArgs(argv) {
|
|
11
12
|
const positional = [];
|
|
@@ -43,14 +44,26 @@ ${t("cli.usage")}
|
|
|
43
44
|
agent-guardrails setup [targetDir] --agent <name> [--preset <name>] [--lang <locale>] [--json] [--write-repo-config]
|
|
44
45
|
agent-guardrails plan --task "<task description>" [--intended-files "src/service.js,tests/service.test.js"] [--allowed-change-types "implementation-only"] [--allow-paths "src/,tests/"] [--required-commands "npm test"] [--evidence ".agent-guardrails/evidence/current-task.md"] [--risk-level high] [--lang <locale>] [--print-only]
|
|
45
46
|
agent-guardrails check [--contract-path <path>] [--base-ref <ref>] [--commands-run "npm test"] [--review] [--lang <locale>] [--json]
|
|
47
|
+
agent-guardrails start [--foreground] - ${t("cli.startSummary")}
|
|
48
|
+
agent-guardrails stop - ${t("cli.stopSummary")}
|
|
49
|
+
agent-guardrails status - ${t("cli.statusSummary")}
|
|
46
50
|
agent-guardrails generate-agents [targetDir] [--preset <name>] [--lang <locale>]
|
|
47
51
|
agent-guardrails mcp
|
|
52
|
+
agent-guardrails --version
|
|
53
|
+
|
|
54
|
+
${t("cli.globalOptions")}
|
|
55
|
+
--version, -v ${t("cli.versionSummary") ?? "Show version number"}
|
|
56
|
+
--help, -h ${t("cli.helpSummary") ?? "Show this help"}
|
|
57
|
+
--lang <locale> ${t("cli.langSummary") ?? "Language for output (en, zh-CN)"}
|
|
48
58
|
|
|
49
59
|
${t("cli.commands")}
|
|
50
60
|
init ${t("cli.initSummary")}
|
|
51
61
|
setup ${t("cli.setupSummary")}
|
|
52
62
|
plan ${t("cli.planSummary")}
|
|
53
63
|
check ${t("cli.checkSummary")}
|
|
64
|
+
start ${t("cli.startSummary")}
|
|
65
|
+
stop ${t("cli.stopSummary")}
|
|
66
|
+
status ${t("cli.statusSummary")}
|
|
54
67
|
generate-agents Generate AGENTS.md for Harness Engineering
|
|
55
68
|
mcp ${t("cli.mcpSummary")}
|
|
56
69
|
|
|
@@ -70,6 +83,12 @@ export async function runCli(argv) {
|
|
|
70
83
|
const [command, ...rest] = positional;
|
|
71
84
|
const locale = flags.lang ?? null;
|
|
72
85
|
|
|
86
|
+
if (flags.version || flags.v) {
|
|
87
|
+
const pkg = readOwnPackageJson();
|
|
88
|
+
console.log(`agent-guardrails v${pkg.version}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
if (!command || command === "help" || flags.help) {
|
|
74
93
|
printHelp(locale);
|
|
75
94
|
return;
|
|
@@ -100,6 +119,21 @@ export async function runCli(argv) {
|
|
|
100
119
|
return;
|
|
101
120
|
}
|
|
102
121
|
|
|
122
|
+
if (command === "start") {
|
|
123
|
+
await startDaemon(process.cwd(), { locale, foreground: flags.foreground || false });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (command === "stop") {
|
|
128
|
+
stopDaemon(process.cwd(), { locale });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (command === "status") {
|
|
133
|
+
showDaemonStatus(process.cwd(), { locale });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
if (command === "generate-agents" || command === "gen-agents") {
|
|
104
138
|
await runGenerateAgentsMd({ positional: rest, flags, locale });
|
|
105
139
|
return;
|
package/lib/commands/check.js
CHANGED
|
@@ -475,6 +475,11 @@ function printTextResult(result, t, { reviewMode = false } = {}) {
|
|
|
475
475
|
if (result.failures.length === 0) {
|
|
476
476
|
console.log(`\n${t("check.allPassed")}`);
|
|
477
477
|
}
|
|
478
|
+
|
|
479
|
+
if (result.runtime?.costHints?.entries?.length > 0) {
|
|
480
|
+
console.log(`\n${t("check.costAwareness")}`);
|
|
481
|
+
console.log(result.runtime.costHints.entries.map((entry) => `- ${t(entry.key, entry.vars)}`).join("\n"));
|
|
482
|
+
}
|
|
478
483
|
}
|
|
479
484
|
|
|
480
485
|
function printJsonResult(result) {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Guardrails Daemon Mode
|
|
3
|
+
*
|
|
4
|
+
* Event-driven file watcher — zero resource usage when idle.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* agent-guardrails start [--foreground] - Start daemon (--foreground for terminal)
|
|
8
|
+
* agent-guardrails stop - Stop daemon
|
|
9
|
+
* agent-guardrails status - Show status
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { createTranslator } from "../i18n.js";
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
|
|
21
|
+
const DAEMON_PID_FILE = ".agent-guardrails/daemon.pid";
|
|
22
|
+
const DAEMON_LOG_FILE = ".agent-guardrails/daemon.log";
|
|
23
|
+
const DAEMON_CONFIG_FILE = ".agent-guardrails/daemon.json";
|
|
24
|
+
const DAEMON_INFO_FILE = ".agent-guardrails/daemon-info.json";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 默认守护配置
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_DAEMON_CONFIG = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
watchPaths: ["src/", "lib/", "tests/"],
|
|
32
|
+
ignorePatterns: ["node_modules", ".git", "dist", "coverage"],
|
|
33
|
+
checkInterval: 5000,
|
|
34
|
+
notifications: {
|
|
35
|
+
sound: false,
|
|
36
|
+
desktop: false
|
|
37
|
+
},
|
|
38
|
+
autoFix: false,
|
|
39
|
+
blockOnHighRisk: true
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 获取守护配置
|
|
44
|
+
*/
|
|
45
|
+
export function getDaemonConfig(repoRoot) {
|
|
46
|
+
const configPath = path.join(repoRoot, DAEMON_CONFIG_FILE);
|
|
47
|
+
|
|
48
|
+
if (fs.existsSync(configPath)) {
|
|
49
|
+
try {
|
|
50
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
51
|
+
return { ...DEFAULT_DAEMON_CONFIG, ...JSON.parse(content) };
|
|
52
|
+
} catch {
|
|
53
|
+
// 配置解析失败,使用默认
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return DEFAULT_DAEMON_CONFIG;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 写入守护配置
|
|
62
|
+
*/
|
|
63
|
+
export function writeDaemonConfig(repoRoot, config) {
|
|
64
|
+
const configPath = path.join(repoRoot, DAEMON_CONFIG_FILE);
|
|
65
|
+
const dir = path.dirname(configPath);
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(dir)) {
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 检查守护进程是否运行
|
|
76
|
+
*/
|
|
77
|
+
export function isDaemonRunning(repoRoot) {
|
|
78
|
+
const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(pidFile)) {
|
|
81
|
+
return { running: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
|
86
|
+
|
|
87
|
+
if (Number.isNaN(pid)) {
|
|
88
|
+
return { running: false };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 跨平台检查进程
|
|
92
|
+
let running = false;
|
|
93
|
+
try {
|
|
94
|
+
if (process.platform === "win32") {
|
|
95
|
+
const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
timeout: 5000
|
|
98
|
+
});
|
|
99
|
+
// 精确匹配 PID,避免 "123" 匹配 "1234"
|
|
100
|
+
running = new RegExp(`\\b${pid}\\b`).test(result.stdout);
|
|
101
|
+
} else {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
running = true;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
running = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!running) {
|
|
110
|
+
fs.unlinkSync(pidFile);
|
|
111
|
+
return { running: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 读取守护进程信息
|
|
115
|
+
const infoFile = path.join(repoRoot, DAEMON_INFO_FILE);
|
|
116
|
+
let info = {};
|
|
117
|
+
if (fs.existsSync(infoFile)) {
|
|
118
|
+
try {
|
|
119
|
+
info = JSON.parse(fs.readFileSync(infoFile, "utf8"));
|
|
120
|
+
} catch {
|
|
121
|
+
// ignore
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
running: true,
|
|
127
|
+
pid,
|
|
128
|
+
startTime: info.startTime,
|
|
129
|
+
checksRun: info.checksRun || 0,
|
|
130
|
+
lastCheck: info.lastCheck
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return { running: false };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 事件驱动等待 PID 文件出现(替代 busy-wait 轮询)
|
|
139
|
+
*/
|
|
140
|
+
async function waitForPidFile(pidFile, timeoutMs = 5000) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
if (fs.existsSync(pidFile)) {
|
|
143
|
+
try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dir = path.dirname(pidFile);
|
|
148
|
+
let watcher;
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
if (watcher) watcher.close();
|
|
151
|
+
// 最终检查一次
|
|
152
|
+
if (fs.existsSync(pidFile)) {
|
|
153
|
+
try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
|
|
154
|
+
} else {
|
|
155
|
+
resolve(null);
|
|
156
|
+
}
|
|
157
|
+
}, timeoutMs);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
watcher = fs.watch(dir, (eventType) => {
|
|
161
|
+
if (eventType === "rename" && fs.existsSync(pidFile)) {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
watcher.close();
|
|
164
|
+
try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve(null);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 启动守护进程
|
|
176
|
+
*/
|
|
177
|
+
export async function startDaemon(repoRoot, options = {}) {
|
|
178
|
+
const locale = options.locale || null;
|
|
179
|
+
const foreground = options.foreground || false;
|
|
180
|
+
const { t } = createTranslator(locale);
|
|
181
|
+
|
|
182
|
+
// 检查是否已运行
|
|
183
|
+
const status = isDaemonRunning(repoRoot);
|
|
184
|
+
if (status.running && !foreground) {
|
|
185
|
+
console.log(`\n${t("daemon.alreadyRunning")}`);
|
|
186
|
+
console.log(` PID: ${status.pid}`);
|
|
187
|
+
console.log(` ${t("daemon.startTime")}: ${status.startTime || "unknown"}`);
|
|
188
|
+
console.log(`\n ${t("daemon.useStop")}`);
|
|
189
|
+
return { success: false, reason: "already_running", status };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const config = getDaemonConfig(repoRoot);
|
|
193
|
+
|
|
194
|
+
// 前台模式 — 直接调用 worker.run()
|
|
195
|
+
if (foreground) {
|
|
196
|
+
const { run } = await import("../daemon/worker.js");
|
|
197
|
+
return run({ repoRoot, config, foreground: true, locale });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 后台模式 — spawn worker.js 子进程
|
|
201
|
+
console.log(`\n${t("daemon.starting")}\n`);
|
|
202
|
+
|
|
203
|
+
const workerPath = path.resolve(__dirname, "..", "daemon", "worker.js");
|
|
204
|
+
const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
|
|
205
|
+
|
|
206
|
+
const child = spawn(process.execPath, [
|
|
207
|
+
workerPath,
|
|
208
|
+
"--repo-root", repoRoot,
|
|
209
|
+
"--config", JSON.stringify(config)
|
|
210
|
+
], {
|
|
211
|
+
detached: true,
|
|
212
|
+
stdio: "ignore",
|
|
213
|
+
cwd: repoRoot
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
child.unref();
|
|
217
|
+
|
|
218
|
+
// 事件驱动等待 PID 文件(替代 busy-wait)
|
|
219
|
+
const pid = await waitForPidFile(pidFile, 5000);
|
|
220
|
+
|
|
221
|
+
if (pid) {
|
|
222
|
+
console.log(`${t("daemon.started")}`);
|
|
223
|
+
console.log(` PID: ${pid}`);
|
|
224
|
+
console.log(`\n${t("daemon.logFile")}: ${DAEMON_LOG_FILE}`);
|
|
225
|
+
console.log(`${t("daemon.configFile")}: ${DAEMON_CONFIG_FILE}`);
|
|
226
|
+
console.log(`\n${t("daemon.useStop")}`);
|
|
227
|
+
console.log(`${t("daemon.useStatus")}`);
|
|
228
|
+
return { success: true, pid: parseInt(pid, 10) };
|
|
229
|
+
} else {
|
|
230
|
+
console.log(`${t("daemon.startFailed")}`);
|
|
231
|
+
return { success: false, reason: "timeout" };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 停止守护进程
|
|
237
|
+
*/
|
|
238
|
+
export async function stopDaemon(repoRoot, options = {}) {
|
|
239
|
+
const locale = options.locale || null;
|
|
240
|
+
const { t } = createTranslator(locale);
|
|
241
|
+
|
|
242
|
+
const status = isDaemonRunning(repoRoot);
|
|
243
|
+
|
|
244
|
+
if (!status.running) {
|
|
245
|
+
console.log(`\n${t("daemon.notRunning")}`);
|
|
246
|
+
return { success: false, reason: "not_running" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(`\n${t("daemon.stopping")}`);
|
|
250
|
+
console.log(` PID: ${status.pid}`);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
if (process.platform === "win32") {
|
|
254
|
+
// 先尝试 taskkill /T(终止进程树,给 SIGTERM handler 机会)
|
|
255
|
+
spawnSync("taskkill", ["/PID", status.pid.toString(), "/T"], {
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
timeout: 5000
|
|
258
|
+
});
|
|
259
|
+
// 等待进程退出
|
|
260
|
+
const { setTimeout: delay } = await import("node:timers/promises");
|
|
261
|
+
await delay(1000);
|
|
262
|
+
// 如果还活着,强制终止
|
|
263
|
+
if (isDaemonRunning(repoRoot).running) {
|
|
264
|
+
spawnSync("taskkill", ["/PID", status.pid.toString(), "/F", "/T"], {
|
|
265
|
+
encoding: "utf8",
|
|
266
|
+
timeout: 5000
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
process.kill(status.pid, "SIGTERM");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 清理文件
|
|
274
|
+
const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
|
|
275
|
+
if (fs.existsSync(pidFile)) {
|
|
276
|
+
fs.unlinkSync(pidFile);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`\n${t("daemon.stopped")}`);
|
|
280
|
+
return { success: true };
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.log(`\n${t("daemon.stopFailed")}: ${error.message}`);
|
|
283
|
+
return { success: false, reason: error.message };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 显示守护进程状态
|
|
289
|
+
*/
|
|
290
|
+
export function showDaemonStatus(repoRoot, options = {}) {
|
|
291
|
+
const locale = options.locale || null;
|
|
292
|
+
const { t } = createTranslator(locale);
|
|
293
|
+
|
|
294
|
+
const status = isDaemonRunning(repoRoot);
|
|
295
|
+
const config = getDaemonConfig(repoRoot);
|
|
296
|
+
|
|
297
|
+
console.log(`\n${t("daemon.status")}\n`);
|
|
298
|
+
|
|
299
|
+
if (status.running) {
|
|
300
|
+
console.log(` ${t("daemon.state")}: ${t("daemon.running")}`);
|
|
301
|
+
console.log(` PID: ${status.pid}`);
|
|
302
|
+
console.log(` ${t("daemon.startTime")}: ${status.startTime || "unknown"}`);
|
|
303
|
+
console.log(` ${t("daemon.checksRun")}: ${status.checksRun}`);
|
|
304
|
+
console.log(` ${t("daemon.lastCheck")}: ${status.lastCheck || "never"}`);
|
|
305
|
+
} else {
|
|
306
|
+
console.log(` ${t("daemon.state")}: ${t("daemon.stopped")}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(`\n ${t("daemon.config")}:`);
|
|
310
|
+
console.log(` ${t("daemon.watchPaths")}: ${config.watchPaths.join(", ")}`);
|
|
311
|
+
console.log(` ${t("daemon.checkInterval")}: ${config.checkInterval}ms`);
|
|
312
|
+
console.log(` ${t("daemon.blockOnHighRisk")}: ${config.blockOnHighRisk ? "yes" : "no"}`);
|
|
313
|
+
|
|
314
|
+
console.log(`\n ${t("daemon.commands")}:`);
|
|
315
|
+
if (status.running) {
|
|
316
|
+
console.log(` agent-guardrails stop - ${t("daemon.stopDesc")}`);
|
|
317
|
+
} else {
|
|
318
|
+
console.log(` agent-guardrails start - ${t("daemon.startDesc")}`);
|
|
319
|
+
}
|
|
320
|
+
console.log(` agent-guardrails status - ${t("daemon.statusDesc")}`);
|
|
321
|
+
|
|
322
|
+
return status;
|
|
323
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Guardrails Daemon Worker
|
|
3
|
+
*
|
|
4
|
+
* Core daemon logic shared by foreground and background modes.
|
|
5
|
+
* Event-driven — no polling when idle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Args
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function parseArgs(argv = process.argv) {
|
|
16
|
+
const args = {};
|
|
17
|
+
for (let i = 2; i < argv.length; i++) {
|
|
18
|
+
const flag = argv[i];
|
|
19
|
+
if (flag === "--foreground") {
|
|
20
|
+
args.foreground = true;
|
|
21
|
+
} else if (flag === "--repo-root" && argv[i + 1]) {
|
|
22
|
+
args.repoRoot = argv[++i];
|
|
23
|
+
} else if (flag === "--config" && argv[i + 1]) {
|
|
24
|
+
try { args.config = JSON.parse(argv[++i]); } catch { /* ignore */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return args;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Logger
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export function createLogger(logFile, foreground = false) {
|
|
35
|
+
function ensureDir() {
|
|
36
|
+
const dir = path.dirname(logFile);
|
|
37
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function log(message) {
|
|
41
|
+
const ts = new Date().toISOString();
|
|
42
|
+
const line = `[${ts}] ${message}`;
|
|
43
|
+
if (foreground) {
|
|
44
|
+
console.log(line);
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
ensureDir();
|
|
48
|
+
fs.appendFileSync(logFile, line + "\n");
|
|
49
|
+
} catch { /* ignore write errors */ }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { log };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Info store — tracks daemon state on disk
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export function createInfoStore(infoFile, startTime) {
|
|
61
|
+
const state = {
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
startTime,
|
|
64
|
+
checksRun: 0,
|
|
65
|
+
lastCheck: null
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function save() {
|
|
69
|
+
const dir = path.dirname(infoFile);
|
|
70
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
fs.writeFileSync(infoFile, JSON.stringify(state, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
incrementChecks() { state.checksRun++; },
|
|
76
|
+
updateLastCheck() { state.lastCheck = new Date().toISOString(); },
|
|
77
|
+
save
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// File watcher — chokidar with fs.watch fallback
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function createFallbackWatcher(repoRoot, config, onChange, log) {
|
|
86
|
+
const watchers = new Map();
|
|
87
|
+
let closed = false;
|
|
88
|
+
|
|
89
|
+
function watchDir(dir) {
|
|
90
|
+
if (closed) return;
|
|
91
|
+
try {
|
|
92
|
+
const w = fs.watch(dir, { recursive: false }, (_eventType, filename) => {
|
|
93
|
+
if (!filename) return;
|
|
94
|
+
onChange(path.join(dir, filename));
|
|
95
|
+
});
|
|
96
|
+
watchers.set(dir, w);
|
|
97
|
+
} catch { /* skip directories we can't watch */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scanDirs(baseDir, depth) {
|
|
101
|
+
if (closed || depth > 10) return;
|
|
102
|
+
try {
|
|
103
|
+
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (config.ignorePatterns.some((p) => entry.name.includes(p))) continue;
|
|
106
|
+
const fullPath = path.join(baseDir, entry.name);
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
watchDir(fullPath);
|
|
109
|
+
scanDirs(fullPath, depth + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch { /* skip */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const watchPath of config.watchPaths) {
|
|
116
|
+
const absolute = path.resolve(repoRoot, watchPath);
|
|
117
|
+
if (fs.existsSync(absolute)) {
|
|
118
|
+
watchDir(absolute);
|
|
119
|
+
scanDirs(absolute, 0);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
close() {
|
|
125
|
+
closed = true;
|
|
126
|
+
for (const [, w] of watchers) w.close();
|
|
127
|
+
watchers.clear();
|
|
128
|
+
},
|
|
129
|
+
ready: Promise.resolve()
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function createChokidarWatcher(repoRoot, config, onChange, log) {
|
|
134
|
+
const chokidar = await import("chokidar");
|
|
135
|
+
const watcher = chokidar.watch(config.watchPaths, {
|
|
136
|
+
cwd: repoRoot,
|
|
137
|
+
ignored: config.ignorePatterns,
|
|
138
|
+
persistent: true,
|
|
139
|
+
ignoreInitial: true
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
watcher.on("change", (filePath) => onChange(filePath));
|
|
143
|
+
watcher.on("add", (filePath) => onChange(filePath));
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
watcher.on("ready", () => {
|
|
147
|
+
resolve({
|
|
148
|
+
close: () => watcher.close(),
|
|
149
|
+
ready: Promise.resolve()
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function createWatcher(repoRoot, config, onChange, log) {
|
|
156
|
+
try {
|
|
157
|
+
log("Attempting to use chokidar for file watching...");
|
|
158
|
+
const watcher = await createChokidarWatcher(repoRoot, config, onChange, log);
|
|
159
|
+
log("chokidar initialized successfully");
|
|
160
|
+
return watcher;
|
|
161
|
+
} catch {
|
|
162
|
+
log("chokidar unavailable, falling back to fs.watch");
|
|
163
|
+
return createFallbackWatcher(repoRoot, config, onChange, log);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Check runner — direct import, no npx
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export async function createCheckRunner(repoRoot, config, log, t) {
|
|
172
|
+
let isCheckRunning = false;
|
|
173
|
+
let checkTimeout = null;
|
|
174
|
+
let runCount = 0;
|
|
175
|
+
|
|
176
|
+
const { executeCheck } = await import("../commands/check.js");
|
|
177
|
+
|
|
178
|
+
function scheduleCheck() {
|
|
179
|
+
if (checkTimeout) clearTimeout(checkTimeout);
|
|
180
|
+
checkTimeout = setTimeout(runCheck, config.checkInterval);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function runCheck() {
|
|
184
|
+
if (isCheckRunning) {
|
|
185
|
+
log(t("daemon.checkSkipped"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
isCheckRunning = true;
|
|
189
|
+
runCount++;
|
|
190
|
+
log(`Running guardrail check (#${runCount})...`);
|
|
191
|
+
|
|
192
|
+
executeCheck({
|
|
193
|
+
repoRoot,
|
|
194
|
+
flags: { json: false },
|
|
195
|
+
suppressExitCode: true
|
|
196
|
+
})
|
|
197
|
+
.then((result) => {
|
|
198
|
+
const ok = result?.ok;
|
|
199
|
+
log(ok ? "Check passed" : "Check completed with issues");
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => {
|
|
202
|
+
log(`Check error: ${err.message}`);
|
|
203
|
+
})
|
|
204
|
+
.finally(() => {
|
|
205
|
+
isCheckRunning = false;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { scheduleCheck };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Main worker entry
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export async function run({ repoRoot, config, foreground = false, locale = null }) {
|
|
217
|
+
const { createTranslator } = await import("../i18n.js");
|
|
218
|
+
const { t } = createTranslator(locale);
|
|
219
|
+
|
|
220
|
+
const startTime = new Date().toISOString();
|
|
221
|
+
const logFile = path.join(repoRoot, ".agent-guardrails", "daemon.log");
|
|
222
|
+
const infoFile = path.join(repoRoot, ".agent-guardrails", "daemon-info.json");
|
|
223
|
+
const pidFile = path.join(repoRoot, ".agent-guardrails", "daemon.pid");
|
|
224
|
+
|
|
225
|
+
const { log } = createLogger(logFile, foreground);
|
|
226
|
+
const info = createInfoStore(infoFile, startTime);
|
|
227
|
+
const { scheduleCheck } = await createCheckRunner(repoRoot, config, log, t);
|
|
228
|
+
|
|
229
|
+
// Cleanup handler
|
|
230
|
+
let watcher = null;
|
|
231
|
+
function cleanup() {
|
|
232
|
+
log("Shutting down...");
|
|
233
|
+
if (watcher) watcher.close();
|
|
234
|
+
try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); } catch { /* ignore */ }
|
|
235
|
+
try { if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); } catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
239
|
+
process.on("SIGINT", () => { console.log(""); cleanup(); process.exit(0); });
|
|
240
|
+
|
|
241
|
+
// Write PID file
|
|
242
|
+
const dir = path.dirname(pidFile);
|
|
243
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
244
|
+
fs.writeFileSync(pidFile, process.pid.toString());
|
|
245
|
+
|
|
246
|
+
log(`Daemon started with PID ${process.pid}`);
|
|
247
|
+
log(`Working directory: ${repoRoot}`);
|
|
248
|
+
log(`Watch paths: ${config.watchPaths.join(", ")}`);
|
|
249
|
+
log(`Check interval: ${config.checkInterval}ms (debounce, not polling)`);
|
|
250
|
+
if (!foreground) log("Press Ctrl+C or run 'agent-guardrails stop' to stop\n");
|
|
251
|
+
info.save();
|
|
252
|
+
|
|
253
|
+
// Start file watcher
|
|
254
|
+
watcher = await createWatcher(repoRoot, config, (filePath) => {
|
|
255
|
+
log(`File changed: ${filePath}`);
|
|
256
|
+
info.incrementChecks();
|
|
257
|
+
info.updateLastCheck();
|
|
258
|
+
info.save();
|
|
259
|
+
scheduleCheck();
|
|
260
|
+
}, log);
|
|
261
|
+
|
|
262
|
+
log("File watcher ready");
|
|
263
|
+
|
|
264
|
+
// Keep process alive
|
|
265
|
+
return new Promise(() => { /* never resolve */ });
|
|
266
|
+
}
|
package/lib/i18n.js
CHANGED
|
@@ -3,14 +3,21 @@ const resources = {
|
|
|
3
3
|
cli: {
|
|
4
4
|
usage: "Usage:",
|
|
5
5
|
commands: "Commands:",
|
|
6
|
+
globalOptions: "Global Options:",
|
|
6
7
|
supportedPresets: "Supported presets:",
|
|
7
8
|
supportedAdapters: "Supported adapters:",
|
|
8
9
|
supportedLocales: "Supported locales:",
|
|
10
|
+
versionSummary: "Show version number",
|
|
11
|
+
helpSummary: "Show this help message",
|
|
12
|
+
langSummary: "Language for output (en, zh-CN)",
|
|
9
13
|
initSummary: "Seed guardrail files into a repository",
|
|
10
14
|
setupSummary: "Auto-initialize a repo, generate MCP config, and point an agent at the runtime",
|
|
11
15
|
planSummary: "Print a bounded implementation brief and write a richer task contract by default",
|
|
12
16
|
checkSummary: "Validate scope, consistency, correctness, risk, and production-profile rules for the current change",
|
|
13
17
|
mcpSummary: "Expose the runtime through a stdio MCP server",
|
|
18
|
+
startSummary: "Start daemon mode for automatic background checking",
|
|
19
|
+
stopSummary: "Stop the daemon process",
|
|
20
|
+
statusSummary: "Show daemon status and configuration",
|
|
14
21
|
unknownCommand: "Unknown command \"{command}\""
|
|
15
22
|
},
|
|
16
23
|
errors: {
|
|
@@ -247,7 +254,8 @@ const resources = {
|
|
|
247
254
|
mentionObservability: "Mention metrics, logging, tracing, or monitoring changes in the evidence note.",
|
|
248
255
|
mentionPerfValidation: "Call out how concurrency, load, performance, or reliability was validated.",
|
|
249
256
|
extendExistingTarget: "Extend the existing target first: {value}",
|
|
250
|
-
preserveExistingStructure: "Preserve the current structure or explicitly widen the task contract before restructuring it."
|
|
257
|
+
preserveExistingStructure: "Preserve the current structure or explicitly widen the task contract before restructuring it.",
|
|
258
|
+
moveSecretToEnv: "Move the secret to an environment variable or a secrets manager, and add the variable name to a .env.example file."
|
|
251
259
|
},
|
|
252
260
|
check: {
|
|
253
261
|
title: "Agent Guardrails Check",
|
|
@@ -305,7 +313,15 @@ const resources = {
|
|
|
305
313
|
nextActions: "Next actions:",
|
|
306
314
|
finishCommand: "Finish-time command: {value}",
|
|
307
315
|
action: "Action: {value}",
|
|
308
|
-
findingSeverity: "[{severity}] {message}"
|
|
316
|
+
findingSeverity: "[{severity}] {message}",
|
|
317
|
+
costAwareness: "Cost awareness:",
|
|
318
|
+
costSizeSmall: "Change size: Small ({totalFiles} files, {sourceFiles} source)",
|
|
319
|
+
costSizeMedium: "Change size: Medium ({totalFiles} files, {sourceFiles} source)",
|
|
320
|
+
costSizeLarge: "Change size: Large ({totalFiles} files, {sourceFiles} source)",
|
|
321
|
+
costSizeVeryLarge: "Change size: Very large ({totalFiles} files, {sourceFiles} source)",
|
|
322
|
+
costTokenEstimate: "Estimated token cost: {low}-{high}",
|
|
323
|
+
costLargeChangeWarning: "Large change detected — consider splitting into smaller tasks to reduce token usage and review burden.",
|
|
324
|
+
costFixCostHint: "Fix cost: {errorCount} error(s), {warningCount} warning(s) need to be resolved."
|
|
309
325
|
},
|
|
310
326
|
findings: {
|
|
311
327
|
missingRequiredFile: "Required file is missing: {path}",
|
|
@@ -336,7 +352,17 @@ const resources = {
|
|
|
336
352
|
criticalPathTouchedWithoutRollback: "Critical path changed without rollback notes: {files}",
|
|
337
353
|
performanceSensitiveAreaTouched: "Performance-sensitive area changed: {files}",
|
|
338
354
|
observabilityRequirementsUnaddressed: "Observability requirements were declared, but the evidence note does not mention them.",
|
|
339
|
-
concurrencyRequirementsUnaddressed: "Concurrency or performance requirements were declared, but the evidence note does not mention validation for them."
|
|
355
|
+
concurrencyRequirementsUnaddressed: "Concurrency or performance requirements were declared, but the evidence note does not mention validation for them.",
|
|
356
|
+
secretsSafetyDetected: "Potential {type} detected in added lines of {file}. Move secrets to environment variables or a secrets manager."
|
|
357
|
+
},
|
|
358
|
+
recovery: {
|
|
359
|
+
revertOrNarrowScope: "Recovery: revert out-of-scope changes or update the task contract to include them.",
|
|
360
|
+
runOrSkipCommands: "Recovery: run the missing required commands, or explicitly skip them with justification in evidence.",
|
|
361
|
+
writeEvidenceFile: "Recovery: create the required evidence file and document what was done and any residual risk.",
|
|
362
|
+
revertOrAddReviewNotes: "Recovery: revert the protected-area changes or add detailed review notes explaining the rationale.",
|
|
363
|
+
moveSecretsToEnv: "Recovery: move detected secrets to environment variables or a secrets manager immediately.",
|
|
364
|
+
revertOrWidenContract: "Recovery: revert the unexpected change-type changes or update the task contract accordingly.",
|
|
365
|
+
addTestsOrAcknowledge: "Recovery: add tests for the changed source files, or document why tests are deferred."
|
|
340
366
|
},
|
|
341
367
|
roughIntent: {
|
|
342
368
|
analyzing: "Analyzing your intent...",
|
|
@@ -364,19 +390,63 @@ const resources = {
|
|
|
364
390
|
actionConfirm: "✅ Confirm",
|
|
365
391
|
actionModify: "✏️ Modify scope",
|
|
366
392
|
actionCancel: "❌ Cancel"
|
|
393
|
+
},
|
|
394
|
+
daemon: {
|
|
395
|
+
starting: "Starting daemon...",
|
|
396
|
+
foregroundMode: "foreground mode",
|
|
397
|
+
started: "✅ Daemon started successfully!",
|
|
398
|
+
startFailed: "❌ Failed to start daemon",
|
|
399
|
+
alreadyRunning: "Daemon is already running!",
|
|
400
|
+
stopping: "Stopping daemon...",
|
|
401
|
+
stopped: "✅ Daemon stopped.",
|
|
402
|
+
stopFailed: "❌ Failed to stop daemon",
|
|
403
|
+
notRunning: "Daemon is not running.",
|
|
404
|
+
status: "Daemon Status",
|
|
405
|
+
state: "State",
|
|
406
|
+
running: "Running",
|
|
407
|
+
config: "Configuration",
|
|
408
|
+
watchPaths: "Watch paths",
|
|
409
|
+
checkInterval: "Check interval",
|
|
410
|
+
blockOnHighRisk: "Block on high risk",
|
|
411
|
+
commands: "Commands",
|
|
412
|
+
startTime: "Started at",
|
|
413
|
+
checksRun: "Checks run",
|
|
414
|
+
lastCheck: "Last check",
|
|
415
|
+
logFile: "Log file",
|
|
416
|
+
configFile: "Config file",
|
|
417
|
+
useStop: "Use 'agent-guardrails stop' to stop.",
|
|
418
|
+
useStatus: "Use 'agent-guardrails status' to check status.",
|
|
419
|
+
startDesc: "Start the daemon",
|
|
420
|
+
stopDesc: "Stop the daemon",
|
|
421
|
+
statusDesc: "Show daemon status",
|
|
422
|
+
configUpdated: "✅ Daemon configuration updated!",
|
|
423
|
+
startDescLong: "Start the daemon in background mode",
|
|
424
|
+
stopDesc: "Stop the daemon",
|
|
425
|
+
stopDescLong: "Stop the daemon",
|
|
426
|
+
statusDesc: "Show daemon status",
|
|
427
|
+
statusDescLong: "Show daemon status and configuration",
|
|
428
|
+
chokidarFallback: "chokidar unavailable, falling back to fs.watch (less reliable on some platforms)",
|
|
429
|
+
checkSkipped: "Check already in progress, skipped"
|
|
367
430
|
}
|
|
368
431
|
},
|
|
369
432
|
"zh-CN": {
|
|
370
433
|
cli: {
|
|
371
434
|
usage: "用法:",
|
|
372
435
|
commands: "命令:",
|
|
436
|
+
globalOptions: "全局选项:",
|
|
373
437
|
supportedPresets: "支持的 preset:",
|
|
374
438
|
supportedAdapters: "支持的 adapter:",
|
|
375
439
|
supportedLocales: "支持的语言:",
|
|
440
|
+
versionSummary: "显示版本号",
|
|
441
|
+
helpSummary: "显示帮助信息",
|
|
442
|
+
langSummary: "输出语言 (en, zh-CN)",
|
|
376
443
|
initSummary: "向仓库写入 guardrail 文件",
|
|
377
444
|
planSummary: "输出受约束的实现简报,并默认写入更丰富的任务契约",
|
|
378
445
|
checkSummary: "校验当前改动的范围、一致性、正确性、风险和生产画像规则",
|
|
379
446
|
mcpSummary: "通过 stdio MCP server 暴露运行时能力",
|
|
447
|
+
startSummary: "启动守护进程,自动后台检查",
|
|
448
|
+
stopSummary: "停止守护进程",
|
|
449
|
+
statusSummary: "查看守护进程状态和配置",
|
|
380
450
|
unknownCommand: "未知命令 \"{command}\""
|
|
381
451
|
},
|
|
382
452
|
errors: {
|
|
@@ -538,7 +608,8 @@ const resources = {
|
|
|
538
608
|
mentionObservability: "在证据说明里说明 metrics、logging、tracing 或 monitoring 的变化。",
|
|
539
609
|
mentionPerfValidation: "说明并发、负载、性能或可靠性是如何验证的。",
|
|
540
610
|
extendExistingTarget: "优先扩展现有目标:{value}",
|
|
541
|
-
preserveExistingStructure: "尽量保持当前结构,除非先显式放宽任务契约。"
|
|
611
|
+
preserveExistingStructure: "尽量保持当前结构,除非先显式放宽任务契约。",
|
|
612
|
+
moveSecretToEnv: "将密钥移至环境变量或密钥管理器,并在 .env.example 中记录变量名。"
|
|
542
613
|
},
|
|
543
614
|
check: {
|
|
544
615
|
title: "Agent Guardrails 检查结果",
|
|
@@ -589,7 +660,15 @@ const resources = {
|
|
|
589
660
|
nextActions: "下一步:",
|
|
590
661
|
finishCommand: "收尾命令:{value}",
|
|
591
662
|
action: "处理建议:{value}",
|
|
592
|
-
findingSeverity: "[{severity}] {message}"
|
|
663
|
+
findingSeverity: "[{severity}] {message}",
|
|
664
|
+
costAwareness: "成本感知:",
|
|
665
|
+
costSizeSmall: "变更规模:小({totalFiles} 个文件,{sourceFiles} 个源码)",
|
|
666
|
+
costSizeMedium: "变更规模:中({totalFiles} 个文件,{sourceFiles} 个源码)",
|
|
667
|
+
costSizeLarge: "变更规模:大({totalFiles} 个文件,{sourceFiles} 个源码)",
|
|
668
|
+
costSizeVeryLarge: "变更规模:非常大({totalFiles} 个文件,{sourceFiles} 个源码)",
|
|
669
|
+
costTokenEstimate: "预估 token 成本:{low}-{high}",
|
|
670
|
+
costLargeChangeWarning: "检测到大范围变更 — 建议拆分为更小的任务以降低 token 消耗和审查负担。",
|
|
671
|
+
costFixCostHint: "修复成本:{errorCount} 个错误、{warningCount} 个警告需要解决。"
|
|
593
672
|
},
|
|
594
673
|
findings: {
|
|
595
674
|
missingRequiredFile: "缺少必需文件:{path}",
|
|
@@ -621,7 +700,17 @@ const resources = {
|
|
|
621
700
|
performanceSensitiveAreaTouched: "触及性能敏感区域:{files}",
|
|
622
701
|
observabilityRequirementsUnaddressed: "任务声明了可观测性要求,但证据中没有体现。",
|
|
623
702
|
concurrencyRequirementsUnaddressed: "任务声明了并发或性能要求,但证据中没有体现相关验证。",
|
|
624
|
-
protectedAreaDefaultAction: "收窄任务契约,或为 {path} 补充更明确的 review 说明。"
|
|
703
|
+
protectedAreaDefaultAction: "收窄任务契约,或为 {path} 补充更明确的 review 说明。",
|
|
704
|
+
secretsSafetyDetected: "在 {file} 的新增行中检测到可能的 {type}。请将密钥移至环境变量或密钥管理器。"
|
|
705
|
+
},
|
|
706
|
+
recovery: {
|
|
707
|
+
revertOrNarrowScope: "恢复建议:撤回超出范围的变更,或更新任务契约以包含这些文件。",
|
|
708
|
+
runOrSkipCommands: "恢复建议:执行缺失的必需命令,或在证据中说明跳过原因。",
|
|
709
|
+
writeEvidenceFile: "恢复建议:创建必需的证据文件,记录已执行的操作和残余风险。",
|
|
710
|
+
revertOrAddReviewNotes: "恢复建议:撤回保护区域的变更,或添加详细的 review 说明。",
|
|
711
|
+
moveSecretsToEnv: "恢复建议:立即将检测到的密钥移至环境变量或密钥管理器。",
|
|
712
|
+
revertOrWidenContract: "恢复建议:撤回意外的改动类型变更,或更新任务契约。",
|
|
713
|
+
addTestsOrAcknowledge: "恢复建议:为变更的源码添加测试,或说明推迟测试的原因。"
|
|
625
714
|
},
|
|
626
715
|
roughIntent: {
|
|
627
716
|
analyzing: "正在分析你的意图...",
|
|
@@ -649,6 +738,41 @@ const resources = {
|
|
|
649
738
|
actionConfirm: "✅ 确认,继续",
|
|
650
739
|
actionModify: "✏️ 修改范围",
|
|
651
740
|
actionCancel: "❌ 取消"
|
|
741
|
+
},
|
|
742
|
+
daemon: {
|
|
743
|
+
starting: "正在启动守护进程...",
|
|
744
|
+
foregroundMode: "前台模式",
|
|
745
|
+
started: "✅ 守护进程启动成功!",
|
|
746
|
+
startFailed: "❌ 守护进程启动失败",
|
|
747
|
+
alreadyRunning: "守护进程已在运行中!",
|
|
748
|
+
stopping: "正在停止守护进程...",
|
|
749
|
+
stopped: "✅ 守护进程已停止。",
|
|
750
|
+
stopFailed: "❌ 停止守护进程失败",
|
|
751
|
+
notRunning: "守护进程未运行。",
|
|
752
|
+
status: "守护进程状态",
|
|
753
|
+
state: "状态",
|
|
754
|
+
running: "运行中",
|
|
755
|
+
config: "配置",
|
|
756
|
+
watchPaths: "监控路径",
|
|
757
|
+
checkInterval: "检查间隔",
|
|
758
|
+
blockOnHighRisk: "高风险时阻断",
|
|
759
|
+
commands: "命令",
|
|
760
|
+
startTime: "启动时间",
|
|
761
|
+
checksRun: "已运行检查",
|
|
762
|
+
lastCheck: "上次检查",
|
|
763
|
+
logFile: "日志文件",
|
|
764
|
+
configFile: "配置文件",
|
|
765
|
+
useStop: "使用 'agent-guardrails stop' 停止。",
|
|
766
|
+
useStatus: "使用 'agent-guardrails status' 查看状态。",
|
|
767
|
+
startDesc: "启动守护进程",
|
|
768
|
+
stopDesc: "停止守护进程",
|
|
769
|
+
statusDesc: "查看守护进程状态",
|
|
770
|
+
configUpdated: "✅ 守护进程配置已更新!",
|
|
771
|
+
startDescLong: "在后台启动守护进程",
|
|
772
|
+
stopDescLong: "停止守护进程",
|
|
773
|
+
statusDescLong: "显示守护进程状态和配置",
|
|
774
|
+
chokidarFallback: "chokidar 不可用,降级使用 fs.watch(部分平台可靠性较低)",
|
|
775
|
+
checkSkipped: "检查正在进行中,已跳过"
|
|
652
776
|
}
|
|
653
777
|
}
|
|
654
778
|
};
|
package/lib/mcp/server.js
CHANGED
|
@@ -181,13 +181,13 @@ const TOOL_DEFINITIONS = [
|
|
|
181
181
|
},
|
|
182
182
|
mode: {
|
|
183
183
|
type: "string",
|
|
184
|
-
enum
|
|
185
|
-
|
|
184
|
+
enum: ["suggest", "auto", "strict"],
|
|
185
|
+
description: "suggest = return suggestion for confirmation, auto = auto-accept if confidence >= 0.6, strict = always require confirmation"
|
|
186
186
|
},
|
|
187
187
|
locale: {
|
|
188
188
|
type: "string",
|
|
189
|
-
enum
|
|
190
|
-
|
|
189
|
+
enum: ["en", "zh-CN"],
|
|
190
|
+
description: "Language for the response text."
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
193
|
required: ["task"],
|
package/lib/runtime/service.js
CHANGED
|
@@ -748,10 +748,103 @@ export function prepareFinishCheck({ repoRoot, session = null, commandsRun = [],
|
|
|
748
748
|
};
|
|
749
749
|
}
|
|
750
750
|
|
|
751
|
+
function buildCostHints(result) {
|
|
752
|
+
const totalFiles = (result.changedFiles || []).length;
|
|
753
|
+
const sourceFiles = (result.sourceFiles || []).length;
|
|
754
|
+
const hasErrors = result.findings.some((f) => f.severity === "error");
|
|
755
|
+
const hasWarnings = result.findings.some((f) => f.severity === "warning");
|
|
756
|
+
|
|
757
|
+
const lowEstimate = totalFiles * 50;
|
|
758
|
+
const highEstimate = sourceFiles * 1500 + (totalFiles - sourceFiles) * 200;
|
|
759
|
+
|
|
760
|
+
let sizeLevel;
|
|
761
|
+
if (totalFiles <= 3) {
|
|
762
|
+
sizeLevel = "Small";
|
|
763
|
+
} else if (totalFiles <= 8) {
|
|
764
|
+
sizeLevel = "Medium";
|
|
765
|
+
} else if (totalFiles <= 15) {
|
|
766
|
+
sizeLevel = "Large";
|
|
767
|
+
} else {
|
|
768
|
+
sizeLevel = "VeryLarge";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const entries = [];
|
|
772
|
+
|
|
773
|
+
entries.push({
|
|
774
|
+
key: `check.costSize${sizeLevel}`,
|
|
775
|
+
vars: { totalFiles, sourceFiles }
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
entries.push({
|
|
779
|
+
key: "check.costTokenEstimate",
|
|
780
|
+
vars: { low: formatTokens(lowEstimate), high: formatTokens(highEstimate) }
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
if (totalFiles > 8) {
|
|
784
|
+
entries.push({ key: "check.costLargeChangeWarning", vars: {} });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (hasErrors || hasWarnings) {
|
|
788
|
+
const errorCount = result.findings.filter((f) => f.severity === "error").length;
|
|
789
|
+
const warningCount = result.findings.filter((f) => f.severity === "warning").length;
|
|
790
|
+
entries.push({
|
|
791
|
+
key: "check.costFixCostHint",
|
|
792
|
+
vars: { errorCount, warningCount }
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
sizeLevel,
|
|
798
|
+
tokenEstimate: { low: lowEstimate, high: highEstimate },
|
|
799
|
+
entries
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function formatTokens(count) {
|
|
804
|
+
if (count >= 1000) return `${Math.round(count / 1000)}k`;
|
|
805
|
+
return String(count);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const RECOVERY_MAP = {
|
|
809
|
+
"path-scope-violation": "recovery.revertOrNarrowScope",
|
|
810
|
+
"task-scope-violation": "recovery.revertOrNarrowScope",
|
|
811
|
+
"intended-file-violation": "recovery.revertOrNarrowScope",
|
|
812
|
+
"missing-required-commands": "recovery.runOrSkipCommands",
|
|
813
|
+
"missing-evidence": "recovery.writeEvidenceFile",
|
|
814
|
+
"protected-area-touched": "recovery.revertOrAddReviewNotes",
|
|
815
|
+
"secrets-safety": "recovery.moveSecretsToEnv",
|
|
816
|
+
"change-type-violation": "recovery.revertOrWidenContract",
|
|
817
|
+
"test-coverage-missing": "recovery.addTestsOrAcknowledge"
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
function buildRecoveryGuidance(result, t) {
|
|
821
|
+
const findings = result.findings || [];
|
|
822
|
+
const errorFindings = findings.filter((f) => f.severity === "error");
|
|
823
|
+
if (errorFindings.length === 0) return [];
|
|
824
|
+
|
|
825
|
+
const guidance = [];
|
|
826
|
+
const seen = new Set();
|
|
827
|
+
|
|
828
|
+
for (const finding of errorFindings) {
|
|
829
|
+
const key = RECOVERY_MAP[finding.code];
|
|
830
|
+
if (key && !seen.has(key)) {
|
|
831
|
+
seen.add(key);
|
|
832
|
+
guidance.push(t(key));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return guidance;
|
|
837
|
+
}
|
|
838
|
+
|
|
751
839
|
export function summarizeReviewRisks(result, locale = null) {
|
|
752
840
|
const { t, locale: resolvedLocale } = createTranslator(locale ?? "en");
|
|
753
841
|
const nextActions = [];
|
|
754
842
|
|
|
843
|
+
const recoveryGuidance = buildRecoveryGuidance(result, t);
|
|
844
|
+
if (recoveryGuidance.length > 0) {
|
|
845
|
+
nextActions.push(...recoveryGuidance);
|
|
846
|
+
}
|
|
847
|
+
|
|
755
848
|
if (result.missingRequiredCommands.length > 0) {
|
|
756
849
|
nextActions.push(t("runtime.reviewRunMissingCommands", { value: result.missingRequiredCommands.join(", ") }));
|
|
757
850
|
}
|
|
@@ -935,6 +1028,7 @@ export function summarizeReviewRisks(result, locale = null) {
|
|
|
935
1028
|
verdict,
|
|
936
1029
|
deployReadiness,
|
|
937
1030
|
postDeployMaintenance,
|
|
1031
|
+
costHints: buildCostHints(result),
|
|
938
1032
|
topRisks: [
|
|
939
1033
|
...result.review.scopeIssues,
|
|
940
1034
|
...result.review.validationIssues,
|
package/lib/utils.js
CHANGED
|
@@ -362,3 +362,16 @@ export function normalizeChangeType(value) {
|
|
|
362
362
|
|
|
363
363
|
return normalized;
|
|
364
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Read the agent-guardrails package.json version
|
|
368
|
+
*/
|
|
369
|
+
export function readOwnPackageJson() {
|
|
370
|
+
const packageJsonPath = path.join(import.meta.dirname, "..", "package.json");
|
|
371
|
+
try {
|
|
372
|
+
const content = fs.readFileSync(packageJsonPath, "utf8");
|
|
373
|
+
return JSON.parse(content);
|
|
374
|
+
} catch {
|
|
375
|
+
return { version: "unknown", name: "agent-guardrails" };
|
|
376
|
+
}
|
|
377
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-guardrails",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"mcpName": "io.github.logi-cmd/agent-guardrails",
|
|
4
5
|
"description": "Production guardrails for AI coding agents",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"files": [
|
|
@@ -46,12 +47,23 @@
|
|
|
46
47
|
"ai",
|
|
47
48
|
"agent",
|
|
48
49
|
"guardrails",
|
|
50
|
+
"mcp",
|
|
51
|
+
"mcp-server",
|
|
52
|
+
"model-context-protocol",
|
|
53
|
+
"code-review",
|
|
54
|
+
"merge-gate",
|
|
49
55
|
"cli",
|
|
50
56
|
"developer-tools",
|
|
51
57
|
"codex",
|
|
52
58
|
"cursor",
|
|
53
59
|
"claude-code",
|
|
54
|
-
"
|
|
60
|
+
"windsurf",
|
|
61
|
+
"openhands",
|
|
62
|
+
"ai-safety",
|
|
63
|
+
"llm-guardrails"
|
|
55
64
|
],
|
|
56
|
-
"license": "MIT"
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"optionalDependencies": {
|
|
67
|
+
"chokidar": "^3.6.0"
|
|
68
|
+
}
|
|
57
69
|
}
|