helloagents 2.3.8 → 3.0.2-beta.1

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 (90) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +46 -0
  4. package/README.md +494 -636
  5. package/README_CN.md +778 -0
  6. package/assets/dogdoing/complete.wav +0 -0
  7. package/assets/dogdoing/confirm.wav +0 -0
  8. package/assets/dogdoing/error.wav +0 -0
  9. package/assets/dogdoing/idle.wav +0 -0
  10. package/assets/dogdoing/warning.wav +0 -0
  11. package/assets/icons/icon-large.png +0 -0
  12. package/assets/icons/icon.png +0 -0
  13. package/assets/sounds/complete.wav +0 -0
  14. package/assets/sounds/confirm.wav +0 -0
  15. package/assets/sounds/error.wav +0 -0
  16. package/assets/sounds/idle.wav +0 -0
  17. package/assets/sounds/warning.wav +0 -0
  18. package/bootstrap-lite.md +199 -0
  19. package/bootstrap.md +296 -0
  20. package/cli.mjs +453 -0
  21. package/gemini-extension.json +8 -0
  22. package/hooks/hooks-claude.json +88 -0
  23. package/hooks/hooks.json +76 -0
  24. package/package.json +36 -6
  25. package/scripts/cli-codex.mjs +428 -0
  26. package/scripts/cli-config.mjs +37 -0
  27. package/scripts/cli-hosts.mjs +75 -0
  28. package/scripts/cli-messages.mjs +92 -0
  29. package/scripts/cli-toml.mjs +251 -0
  30. package/scripts/cli-utils.mjs +139 -0
  31. package/scripts/guard.mjs +217 -0
  32. package/scripts/notify-context.mjs +123 -0
  33. package/scripts/notify-events.mjs +11 -0
  34. package/scripts/notify-shared.mjs +47 -0
  35. package/scripts/notify-ui.mjs +92 -0
  36. package/scripts/notify.mjs +219 -0
  37. package/scripts/ralph-loop.mjs +246 -0
  38. package/skills/_meta/SKILL.md +19 -0
  39. package/skills/commands/auto/SKILL.md +91 -0
  40. package/skills/commands/clean/SKILL.md +21 -0
  41. package/skills/commands/commit/SKILL.md +26 -0
  42. package/skills/commands/design/SKILL.md +108 -0
  43. package/skills/commands/help/SKILL.md +45 -0
  44. package/skills/commands/init/SKILL.md +60 -0
  45. package/skills/commands/loop/SKILL.md +98 -0
  46. package/skills/commands/prd/SKILL.md +151 -0
  47. package/skills/commands/review/SKILL.md +16 -0
  48. package/skills/commands/test/SKILL.md +16 -0
  49. package/skills/commands/verify/SKILL.md +21 -0
  50. package/skills/hello-api/SKILL.md +40 -0
  51. package/skills/hello-arch/SKILL.md +38 -0
  52. package/skills/hello-data/SKILL.md +39 -0
  53. package/skills/hello-debug/SKILL.md +58 -0
  54. package/skills/hello-errors/SKILL.md +39 -0
  55. package/skills/hello-perf/SKILL.md +40 -0
  56. package/skills/hello-reflect/SKILL.md +34 -0
  57. package/skills/hello-review/SKILL.md +33 -0
  58. package/skills/hello-security/SKILL.md +40 -0
  59. package/skills/hello-subagent/SKILL.md +32 -0
  60. package/skills/hello-test/SKILL.md +55 -0
  61. package/skills/hello-ui/SKILL.md +197 -0
  62. package/skills/hello-verify/SKILL.md +132 -0
  63. package/skills/hello-write/SKILL.md +33 -0
  64. package/skills/helloagents/SKILL.md +107 -0
  65. package/templates/CHANGELOG.md +5 -0
  66. package/templates/DESIGN.md +19 -0
  67. package/templates/STATE.md +19 -0
  68. package/templates/archive/_index.md +4 -0
  69. package/templates/context.md +19 -0
  70. package/templates/guidelines.md +15 -0
  71. package/templates/modules/module.md +13 -0
  72. package/templates/plans/decisions.md +10 -0
  73. package/templates/plans/design.md +14 -0
  74. package/templates/plans/prd/00-overview.md +23 -0
  75. package/templates/plans/prd/01-user-stories.md +19 -0
  76. package/templates/plans/prd/02-functional.md +30 -0
  77. package/templates/plans/prd/03-ui-design.md +31 -0
  78. package/templates/plans/prd/04-technical.md +25 -0
  79. package/templates/plans/prd/05-nonfunctional.md +28 -0
  80. package/templates/plans/prd/06-i18n-l10n.md +23 -0
  81. package/templates/plans/prd/07-accessibility.md +20 -0
  82. package/templates/plans/prd/08-content.md +20 -0
  83. package/templates/plans/prd/09-testing.md +22 -0
  84. package/templates/plans/prd/10-deployment.md +23 -0
  85. package/templates/plans/prd/11-legal-privacy.md +18 -0
  86. package/templates/plans/prd/12-timeline.md +21 -0
  87. package/templates/plans/requirements.md +18 -0
  88. package/templates/plans/tasks.md +10 -0
  89. package/templates/verify.yaml +9 -0
  90. package/bin/cli.mjs +0 -106
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Lightweight TOML line-based helpers for HelloAGENTS CLI config edits.
3
+ * Targets the small subset of TOML structures used by Codex CLI config.
4
+ */
5
+
6
+ export function isTomlTableHeader(line) {
7
+ const trimmed = String(line || '').trim();
8
+ return trimmed.startsWith('[') && trimmed.endsWith(']');
9
+ }
10
+
11
+ export function normalizeToml(text) {
12
+ const next = String(text || '')
13
+ .replace(/\r\n/g, '\n')
14
+ .replace(/\n{3,}/g, '\n\n')
15
+ .trimEnd();
16
+ return next ? `${next}\n` : '';
17
+ }
18
+
19
+ function escapeRegExp(text) {
20
+ return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
22
+
23
+ function findFirstTomlSectionIndex(text) {
24
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
25
+ const lines = normalized.split('\n');
26
+ let index = 0;
27
+
28
+ for (const line of lines) {
29
+ if (isTomlTableHeader(line)) return index;
30
+ index += line.length + 1;
31
+ }
32
+
33
+ return normalized.length;
34
+ }
35
+
36
+ function findTopLevelTomlBlock(text, key) {
37
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
38
+ const topLevelEnd = findFirstTomlSectionIndex(normalized);
39
+ const topLevel = normalized.slice(0, topLevelEnd);
40
+ const re = new RegExp(`^${escapeRegExp(key)}\\s*=`, 'm');
41
+ const match = re.exec(topLevel);
42
+ if (!match) return null;
43
+
44
+ const start = match.index;
45
+ const lineEnd = normalized.indexOf('\n', start);
46
+ const firstLineEnd = lineEnd >= 0 ? lineEnd : normalized.length;
47
+ const firstLine = normalized.slice(start, firstLineEnd);
48
+ const value = firstLine.slice(firstLine.indexOf('=') + 1).trim();
49
+
50
+ let end = firstLineEnd;
51
+ if (value.startsWith('"""')) {
52
+ const openIndex = normalized.indexOf('"""', firstLine.indexOf('='));
53
+ const closeIndex = normalized.indexOf('"""', openIndex + 3);
54
+ end = closeIndex >= 0 ? closeIndex + 3 : normalized.length;
55
+ }
56
+
57
+ while (end < normalized.length && normalized[end] === '\n') {
58
+ end += 1;
59
+ }
60
+
61
+ return {
62
+ start,
63
+ end,
64
+ text: normalized.slice(start, end).trimEnd(),
65
+ };
66
+ }
67
+
68
+ export function readTopLevelTomlBlock(text, key) {
69
+ return findTopLevelTomlBlock(text, key)?.text || '';
70
+ }
71
+
72
+ export function upsertTopLevelTomlBlock(text, key, value) {
73
+ const assignment = `${key} = ${String(value || '').trim()}`;
74
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
75
+ const existing = findTopLevelTomlBlock(normalized, key);
76
+ const next = existing
77
+ ? `${normalized.slice(0, existing.start)}${assignment}\n${normalized.slice(existing.end)}`
78
+ : `${assignment}\n${normalized}`;
79
+ return normalizeToml(next);
80
+ }
81
+
82
+ export function ensureTopLevelTomlBlock(text, key, block) {
83
+ const normalized = String(block || '').trim();
84
+ if (!normalized) return normalizeToml(text);
85
+ const value = normalized.slice(normalized.indexOf('=') + 1).trim();
86
+ return upsertTopLevelTomlBlock(text, key, value);
87
+ }
88
+
89
+ export function removeTopLevelTomlBlock(text, key) {
90
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
91
+ const existing = findTopLevelTomlBlock(normalized, key);
92
+ if (!existing) return normalizeToml(text);
93
+ return normalizeToml(`${normalized.slice(0, existing.start)}${normalized.slice(existing.end)}`);
94
+ }
95
+
96
+ export function upsertTopLevelTomlKey(text, key, value) {
97
+ const re = new RegExp(`^${key}\\s*=.*$`, 'm');
98
+ const next = re.test(text)
99
+ ? String(text || '').replace(re, `${key} = ${value}`)
100
+ : `${key} = ${value}\n${String(text || '')}`;
101
+ return normalizeToml(next);
102
+ }
103
+
104
+ export function readTopLevelTomlLine(text, key) {
105
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
106
+ for (const line of lines) {
107
+ const trimmed = line.trim();
108
+ if (!trimmed) continue;
109
+ if (isTomlTableHeader(trimmed)) break;
110
+ if (trimmed.startsWith(`${key} =`)) return trimmed;
111
+ }
112
+ return '';
113
+ }
114
+
115
+ export function ensureTopLevelTomlLine(text, key, line) {
116
+ const normalized = String(line || '').trim();
117
+ if (!normalized) return normalizeToml(text);
118
+ const value = normalized.slice(normalized.indexOf('=') + 1).trim();
119
+ return upsertTopLevelTomlKey(text, key, value);
120
+ }
121
+
122
+ export function readTomlKeyInSection(text, headerLine, key) {
123
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
124
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine);
125
+ if (headerIndex < 0) return '';
126
+
127
+ const keyRe = new RegExp(`^\\s*${key}\\s*=.*$`);
128
+ for (let index = headerIndex + 1; index < lines.length; index += 1) {
129
+ const line = lines[index];
130
+ if (isTomlTableHeader(line)) break;
131
+ if (keyRe.test(line)) return line.trim();
132
+ }
133
+ return '';
134
+ }
135
+
136
+ export function removeTomlKeyInSection(text, headerLine, key) {
137
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
138
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine);
139
+ if (headerIndex < 0) return normalizeToml(text);
140
+
141
+ const keyRe = new RegExp(`^\\s*${key}\\s*=`);
142
+ const nextLines = [];
143
+ let removed = false;
144
+ for (let index = 0; index < lines.length; index += 1) {
145
+ const line = lines[index];
146
+ if (index > headerIndex && isTomlTableHeader(line)) {
147
+ nextLines.push(...lines.slice(index));
148
+ break;
149
+ }
150
+ if (index > headerIndex && keyRe.test(line)) {
151
+ removed = true;
152
+ continue;
153
+ }
154
+ nextLines.push(line);
155
+ }
156
+
157
+ if (!removed) return normalizeToml(text);
158
+ return normalizeToml(nextLines.join('\n'));
159
+ }
160
+
161
+ export function upsertTomlKeyInSection(text, headerLine, key, value) {
162
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
163
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine);
164
+
165
+ if (headerIndex < 0) {
166
+ const base = normalizeToml(text).trimEnd();
167
+ return base
168
+ ? `${base}\n\n${headerLine}\n${key} = ${value}\n`
169
+ : `${headerLine}\n${key} = ${value}\n`;
170
+ }
171
+
172
+ let endIndex = headerIndex + 1;
173
+ while (endIndex < lines.length && !isTomlTableHeader(lines[endIndex])) {
174
+ endIndex += 1;
175
+ }
176
+
177
+ const keyRe = new RegExp(`^\\s*${key}\\s*=`);
178
+ let updated = false;
179
+ for (let index = headerIndex + 1; index < endIndex; index += 1) {
180
+ if (keyRe.test(lines[index])) {
181
+ lines[index] = `${key} = ${value}`;
182
+ updated = true;
183
+ break;
184
+ }
185
+ }
186
+
187
+ if (!updated) {
188
+ lines.splice(endIndex, 0, `${key} = ${value}`);
189
+ }
190
+
191
+ return normalizeToml(lines.join('\n'));
192
+ }
193
+
194
+ export function ensureTomlKeyInSection(text, headerLine, key, line) {
195
+ const normalized = String(line || '').trim();
196
+ if (!normalized) return normalizeToml(text);
197
+ const value = normalized.slice(normalized.indexOf('=') + 1).trim();
198
+ return upsertTomlKeyInSection(text, headerLine, key, value);
199
+ }
200
+
201
+ export function stripTomlSection(text, headerLine) {
202
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
203
+ const kept = [];
204
+ let removed = false;
205
+
206
+ for (let index = 0; index < lines.length;) {
207
+ if (lines[index].trim() === headerLine) {
208
+ removed = true;
209
+ index += 1;
210
+ while (index < lines.length && !isTomlTableHeader(lines[index])) {
211
+ index += 1;
212
+ }
213
+ continue;
214
+ }
215
+
216
+ kept.push(lines[index]);
217
+ index += 1;
218
+ }
219
+
220
+ return {
221
+ removed,
222
+ text: normalizeToml(kept.join('\n')),
223
+ };
224
+ }
225
+
226
+ export function removeTopLevelTomlLines(text, shouldRemove) {
227
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
228
+ const kept = [];
229
+ let currentSection = null;
230
+ let removed = false;
231
+
232
+ for (const line of lines) {
233
+ if (isTomlTableHeader(line)) {
234
+ currentSection = line.trim();
235
+ kept.push(line);
236
+ continue;
237
+ }
238
+
239
+ if (!currentSection && shouldRemove(line.trim())) {
240
+ removed = true;
241
+ continue;
242
+ }
243
+
244
+ kept.push(line);
245
+ }
246
+
247
+ return {
248
+ removed,
249
+ text: normalizeToml(kept.join('\n')),
250
+ };
251
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared utilities for HelloAGENTS CLI installation scripts.
3
+ * File operations, marker injection, settings merge/clean, hooks loading.
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync,
6
+ symlinkSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { platform } from 'node:os';
9
+
10
+ const IS_WIN = platform() === 'win32';
11
+
12
+ export function ensureDir(p) { mkdirSync(p, { recursive: true }); }
13
+ export function safeWrite(p, c) { ensureDir(dirname(p)); writeFileSync(p, c, 'utf-8'); }
14
+ export function safeRead(p) { try { return readFileSync(p, 'utf-8'); } catch { return null; } }
15
+ export function safeJson(p) { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; } }
16
+ export function removeIfExists(p) { try { rmSync(p, { recursive: true, force: true }); } catch {} }
17
+ export function readJsonOrThrow(p, label = p) {
18
+ if (!existsSync(p)) return null;
19
+ try {
20
+ return JSON.parse(readFileSync(p, 'utf-8'));
21
+ } catch {
22
+ throw new Error(`${label} JSON 解析失败: ${p}`);
23
+ }
24
+ }
25
+ export function copyEntries(sourceRoot, targetRoot, entries) {
26
+ for (const entry of entries) {
27
+ const sourcePath = join(sourceRoot, entry);
28
+ if (!existsSync(sourcePath)) continue;
29
+ const targetPath = join(targetRoot, entry);
30
+ ensureDir(dirname(targetPath));
31
+ cpSync(sourcePath, targetPath, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ export function createLink(target, linkPath) {
36
+ removeLink(linkPath);
37
+ try {
38
+ symlinkSync(target, linkPath, IS_WIN ? 'junction' : 'dir');
39
+ return true;
40
+ } catch { return false; }
41
+ }
42
+
43
+ export function removeLink(p) {
44
+ try {
45
+ const stat = lstatSync(p);
46
+ if (IS_WIN && stat.isDirectory()) rmdirSync(p);
47
+ else unlinkSync(p);
48
+ return true;
49
+ } catch { return false; }
50
+ }
51
+
52
+ // ── Marker injection ─────────────────────────────────────────────────
53
+
54
+ const MARKER = '<!-- HELLOAGENTS_START -->';
55
+ const MARKER_END = '<!-- HELLOAGENTS_END -->';
56
+ const MARKER_RE = new RegExp(`\\n*${MARKER}[\\s\\S]*?${MARKER_END}\\n*`, 'g');
57
+
58
+ /** Inject content wrapped in markers, preserving existing content outside markers. */
59
+ export function injectMarkedContent(filePath, content) {
60
+ const existing = safeRead(filePath) || '';
61
+ const wrapped = `\n${MARKER}\n${content}\n${MARKER_END}\n`;
62
+ if (existing.includes(MARKER)) {
63
+ safeWrite(filePath, existing.replace(MARKER_RE, wrapped));
64
+ } else if (existing.trim()) {
65
+ safeWrite(filePath, existing.trimEnd() + '\n' + wrapped);
66
+ } else {
67
+ safeWrite(filePath, wrapped.trim() + '\n');
68
+ }
69
+ }
70
+
71
+ /** Remove marked content from a file. Deletes file if nothing remains. */
72
+ export function removeMarkedContent(filePath) {
73
+ const existing = safeRead(filePath);
74
+ if (!existing || !existing.includes(MARKER)) return;
75
+ const cleaned = existing.replace(MARKER_RE, '\n').trim();
76
+ if (cleaned) safeWrite(filePath, cleaned + '\n');
77
+ else removeIfExists(filePath);
78
+ }
79
+
80
+ // ── Settings merge/clean ─────────────────────────────────────────────
81
+
82
+ /** Deep-merge helloagents hooks into a CLI's settings.json. Optionally merges permissions. */
83
+ export function mergeSettingsHooks(settingsPath, hooksData, extraPermissions) {
84
+ const settings = safeJson(settingsPath) || {};
85
+
86
+ if (extraPermissions?.length) {
87
+ if (!settings.permissions) settings.permissions = {};
88
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
89
+ for (const perm of extraPermissions) {
90
+ if (!settings.permissions.allow.includes(perm)) settings.permissions.allow.push(perm);
91
+ }
92
+ }
93
+
94
+ if (hooksData?.hooks) {
95
+ if (!settings.hooks) settings.hooks = {};
96
+ for (const [event, entries] of Object.entries(hooksData.hooks)) {
97
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
98
+ settings.hooks[event] = settings.hooks[event].filter(e => !JSON.stringify(e).includes('helloagents'));
99
+ settings.hooks[event].push(...entries);
100
+ }
101
+ }
102
+
103
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
104
+ }
105
+
106
+ /** Remove helloagents hooks (and optionally permissions) from a CLI's settings.json. */
107
+ export function cleanSettingsHooks(settingsPath, cleanPermissions = false) {
108
+ const settings = safeJson(settingsPath);
109
+ if (!settings) return;
110
+
111
+ if (cleanPermissions && settings.permissions?.allow) {
112
+ settings.permissions.allow = settings.permissions.allow.filter(p => !p.includes('helloagents'));
113
+ if (!settings.permissions.allow.length) delete settings.permissions.allow;
114
+ if (!Object.keys(settings.permissions).length) delete settings.permissions;
115
+ }
116
+
117
+ if (settings.hooks) {
118
+ for (const [event, entries] of Object.entries(settings.hooks)) {
119
+ if (!Array.isArray(entries)) continue;
120
+ settings.hooks[event] = entries.filter(e => !JSON.stringify(e).includes('helloagents'));
121
+ if (!settings.hooks[event].length) delete settings.hooks[event];
122
+ }
123
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
124
+ }
125
+
126
+ if (Object.keys(settings).length) {
127
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
128
+ } else {
129
+ removeIfExists(settingsPath);
130
+ }
131
+ }
132
+
133
+ /** Read hooks source file and replace path variable with absolute PKG_ROOT. */
134
+ export function loadHooksWithAbsPath(pkgRoot, hooksFile, pathVar) {
135
+ const src = safeRead(join(pkgRoot, 'hooks', hooksFile));
136
+ if (!src) return null;
137
+ const absRoot = pkgRoot.replace(/\\/g, '/');
138
+ return JSON.parse(src.replace(new RegExp(pathVar.replace(/[{}$]/g, '\\$&'), 'g'), absRoot));
139
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HelloAGENTS Guard — Dangerous command blocker + L2 semantic security scan
4
+ * Runs on PreToolUse hook for Bash/shell commands.
5
+ * Runs on PostToolUse hook for Write/Edit (L2 scan).
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+
11
+ const CONFIG_FILE = join(homedir(), '.helloagents', 'helloagents.json');
12
+
13
+ // Hook event name: read from env or infer from CLI mode + --gemini flag.
14
+ // Claude: PreToolUse/PostToolUse, Gemini: BeforeTool/AfterModel.
15
+ const IS_GEMINI = process.argv.includes('--gemini');
16
+ const IS_POST_WRITE = process.argv.includes('post-write');
17
+ const HOOK_EVENT = process.env.HELLOAGENTS_HOOK_EVENT
18
+ || (IS_POST_WRITE ? (IS_GEMINI ? 'AfterModel' : 'PostToolUse') : (IS_GEMINI ? 'BeforeTool' : 'PreToolUse'));
19
+
20
+ function readSettings() {
21
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
22
+ return {};
23
+ }
24
+
25
+ function emitHookPayload(payload) {
26
+ process.stdout.write(JSON.stringify(payload));
27
+ }
28
+
29
+ const DANGEROUS_PATTERNS = [
30
+ // Destructive file operations (including sudo prefix and long options)
31
+ { pattern: /(sudo\s+)?rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~|\*)/, reason: 'Recursive delete of critical path' },
32
+ { pattern: /(sudo\s+)?rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~|\*)/, reason: 'Recursive delete of critical path' },
33
+ { pattern: /(sudo\s+)?rm\s+--recursive/, reason: 'Recursive delete (long option)' },
34
+ { pattern: /(sudo\s+)?rm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\.\.?(\s|$)/, reason: 'Recursive delete of current/parent directory' },
35
+ // Force push
36
+ { pattern: /git\s+push\s+(-f|--force)/, reason: 'Force push (specify branch explicitly)' },
37
+ // Hard reset
38
+ { pattern: /git\s+reset\s+--hard/, reason: 'Hard reset (destructive operation)' },
39
+ // Database destruction
40
+ { pattern: /DROP\s+(DATABASE|TABLE|SCHEMA)/i, reason: 'Database destruction command' },
41
+ { pattern: /TRUNCATE\s+TABLE/i, reason: 'Table truncation' },
42
+ // Dangerous system commands
43
+ { pattern: /chmod\s+777/, reason: 'World-writable permissions' },
44
+ { pattern: /mkfs\b/, reason: 'Filesystem format command' },
45
+ { pattern: /dd\s+.*of=\/dev\//, reason: 'Direct device write' },
46
+ // Redis flush
47
+ { pattern: /FLUSHALL|FLUSHDB/i, reason: 'Redis data flush' },
48
+ ];
49
+
50
+ // ── L2 Semantic Security Patterns (advisory, non-blocking) ──────────────────
51
+
52
+ const SECRET_PATTERNS = [
53
+ { pattern: /AKIA[0-9A-Z]{16}/, reason: 'AWS Access Key ID detected' },
54
+ { pattern: /ghp_[a-zA-Z0-9]{36}/, reason: 'GitHub Personal Access Token detected' },
55
+ { pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/, reason: 'GitHub Fine-grained PAT detected' },
56
+ { pattern: /sk-[a-zA-Z0-9]{20,}/, reason: 'API secret key pattern detected (sk-)' },
57
+ { pattern: /key-[a-zA-Z0-9]{20,}/, reason: 'API key pattern detected (key-)' },
58
+ { pattern: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/, reason: 'Private key detected' },
59
+ { pattern: /password\s*[:=]\s*["'][^"']{4,}["']/i, reason: 'Hardcoded password detected' },
60
+ { pattern: /secret\s*[:=]\s*["'][^"']{4,}["']/i, reason: 'Hardcoded secret detected' },
61
+ { pattern: /AIza[0-9A-Za-z\-_]{35}/, reason: 'Google API Key detected' },
62
+ { pattern: /xox[bpras]-[0-9a-zA-Z\-]+/, reason: 'Slack Token detected' },
63
+ { pattern: /eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+/, reason: 'JWT token detected' },
64
+ { pattern: /(postgres|mysql|mongodb(\+srv)?):\/\/[^:]+:[^@]+@/i, reason: 'Database connection string with credentials detected' },
65
+ { pattern: /sk_live_[a-zA-Z0-9]{24,}/, reason: 'Stripe Secret Key detected' },
66
+ { pattern: /sk-ant-[a-zA-Z0-9\-]{20,}/, reason: 'Anthropic API Key detected' },
67
+ ];
68
+
69
+ function scanForSecrets(content) {
70
+ const warnings = [];
71
+ for (const { pattern, reason } of SECRET_PATTERNS) {
72
+ if (pattern.test(content)) {
73
+ warnings.push(reason);
74
+ }
75
+ }
76
+ return warnings;
77
+ }
78
+
79
+ // ── Post-Write L2 Scan ──────────────────────────────────────────────────────
80
+ // Triggered by PostToolUse matcher: Write|Edit|NotebookEdit (see hooks.json).
81
+ // If Claude Code adds new file-writing tools, update the matcher accordingly.
82
+
83
+ /** Check for unrequested file creation (Write tool only). */
84
+ function scanUnrequestedFiles(filePath, toolName) {
85
+ if (!filePath || toolName?.toLowerCase() !== 'write') return [];
86
+ const basename = filePath.split(/[/\\]/).pop() || '';
87
+ const UNREQUESTED_PATTERNS = [
88
+ { pattern: /^(SUMMARY|NOTES|TODO|SCRATCH|TEMP)\.(md|txt)$/i, reason: `Unrequested file creation: ${basename}` },
89
+ { pattern: /^README.*\.md$/i, test: () => {
90
+ const depth = filePath.replace(/\\/g, '/').split('/').length;
91
+ return depth > 4;
92
+ }, reason: `Suspicious README creation in nested path: ${basename}` },
93
+ ];
94
+ const warnings = [];
95
+ for (const { pattern, test, reason } of UNREQUESTED_PATTERNS) {
96
+ if (pattern.test(basename) && (!test || test())) warnings.push(reason);
97
+ }
98
+ return warnings;
99
+ }
100
+
101
+ /** Check for dangerous npm scripts and unsafe dependency patterns. */
102
+ function scanDangerousPackages(content, filePath) {
103
+ const warnings = [];
104
+ if (filePath.endsWith('package.json')) {
105
+ const dangerousScripts = /("(preinstall|postinstall|preuninstall)")\s*:\s*"[^"]*\b(curl|wget|bash|sh|eval|exec)\b/i;
106
+ if (dangerousScripts.test(content)) {
107
+ warnings.push('Potentially dangerous lifecycle script in package.json (preinstall/postinstall with curl/wget/bash/eval)');
108
+ }
109
+ }
110
+ const unsafeInstall = /npm install\s+[^-].*--ignore-scripts\s*=\s*false|pip install\s+--trusted-host|pip install\s+http:/i;
111
+ if (unsafeInstall.test(content)) {
112
+ warnings.push('Unsafe dependency installation pattern detected');
113
+ }
114
+ return warnings;
115
+ }
116
+
117
+ /** Check if .env file is covered by .gitignore. */
118
+ function scanEnvCoverage(filePath) {
119
+ if (!filePath.endsWith('.env') && !filePath.includes('.env.')) return [];
120
+ let dir = dirname(filePath);
121
+ for (let i = 0; i < 10; i++) {
122
+ try {
123
+ const gitignore = readFileSync(join(dir, '.gitignore'), 'utf-8');
124
+ return gitignore.includes('.env') ? [] : ['.env file written but .gitignore does not contain .env pattern'];
125
+ } catch {
126
+ const parent = dirname(dir);
127
+ if (parent === dir) break;
128
+ dir = parent;
129
+ }
130
+ }
131
+ return ['.env file written but no .gitignore found'];
132
+ }
133
+
134
+ function postWriteScan(data) {
135
+ const settings = readSettings();
136
+ if (settings.guard_enabled === false) {
137
+ return;
138
+ }
139
+
140
+ const content = data.tool_input?.content || data.tool_input?.new_string || '';
141
+ const filePath = data.tool_input?.file_path || '';
142
+
143
+ if (!content && !filePath) {
144
+ return;
145
+ }
146
+
147
+ const warnings = [
148
+ ...scanUnrequestedFiles(filePath, data.tool_name),
149
+ ...(content ? [...scanForSecrets(content), ...scanDangerousPackages(content, filePath)] : []),
150
+ ...scanEnvCoverage(filePath),
151
+ ];
152
+
153
+ if (warnings.length > 0) {
154
+ emitHookPayload({
155
+ hookSpecificOutput: {
156
+ hookEventName: HOOK_EVENT,
157
+ additionalContext: `⚠️ [HelloAGENTS L2 安全扫描] 检测到潜在问题:\n${warnings.map(w => ` - ${w}`).join('\n')}\n请检查以上问题。`,
158
+ },
159
+ });
160
+ }
161
+ }
162
+
163
+ // ── Main ──────────────────────────────────────────────────────────────────
164
+
165
+ async function main() {
166
+ // Latest Codex rejects suppressOutput on PreToolUse/PostToolUse.
167
+ // For pass-through cases, emit nothing and exit 0.
168
+
169
+ // Check if running in post-write mode (PostToolUse)
170
+ const mode = process.argv[2] || '';
171
+ if (mode === 'post-write') {
172
+ let data = {};
173
+ try {
174
+ const input = readFileSync(0, 'utf-8');
175
+ data = JSON.parse(input);
176
+ } catch {}
177
+ postWriteScan(data);
178
+ return;
179
+ }
180
+
181
+ const settings = readSettings();
182
+ if (settings.guard_enabled === false) {
183
+ return;
184
+ }
185
+
186
+ let data = {};
187
+ try {
188
+ const input = readFileSync(0, 'utf-8');
189
+ data = JSON.parse(input);
190
+ } catch {}
191
+
192
+ // Only check Bash/shell tool calls
193
+ const toolName = (data.tool_name || '').toLowerCase();
194
+ if (!['bash', 'shell', 'terminal', 'command'].some(t => toolName.includes(t))) {
195
+ return;
196
+ }
197
+
198
+ const command = data.tool_input?.command || data.tool_input?.input || '';
199
+ if (!command) {
200
+ return;
201
+ }
202
+
203
+ for (const { pattern, reason } of DANGEROUS_PATTERNS) {
204
+ if (pattern.test(command)) {
205
+ emitHookPayload({
206
+ hookSpecificOutput: {
207
+ hookEventName: HOOK_EVENT,
208
+ permissionDecision: 'deny',
209
+ permissionDecisionReason: `[HelloAGENTS Guard] Blocked: ${reason}\nCommand: ${command.slice(0, 200)}`,
210
+ },
211
+ });
212
+ return;
213
+ }
214
+ }
215
+ }
216
+
217
+ main().catch(() => {});