vibe-forge 0.8.1 → 0.8.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/.claude/commands/configure-vcs.md +102 -102
- package/.claude/commands/forge.md +218 -218
- package/.claude/hooks/worker-loop.js +220 -217
- package/.claude/settings.json +89 -89
- package/README.md +149 -191
- package/agents/aegis/personality.md +303 -303
- package/agents/anvil/personality.md +278 -278
- package/agents/architect/personality.md +260 -260
- package/agents/crucible/personality.md +362 -362
- package/agents/crucible-x/personality.md +210 -210
- package/agents/ember/personality.md +293 -293
- package/agents/flux/personality.md +248 -248
- package/agents/furnace/personality.md +342 -342
- package/agents/herald/personality.md +249 -249
- package/agents/oracle/personality.md +284 -284
- package/agents/pixel/personality.md +140 -140
- package/agents/planning-hub/personality.md +473 -473
- package/agents/scribe/personality.md +253 -253
- package/agents/slag/personality.md +268 -268
- package/agents/temper/personality.md +270 -270
- package/bin/cli.js +372 -372
- package/bin/forge-daemon.sh +477 -477
- package/bin/forge-setup.sh +662 -661
- package/bin/forge-spawn.sh +164 -164
- package/bin/forge.sh +566 -566
- package/docs/commands.md +8 -8
- package/package.json +77 -77
- package/{bin → src}/lib/agents.sh +177 -177
- package/{bin → src}/lib/check-aliases.js +50 -50
- package/{bin → src}/lib/colors.sh +45 -44
- package/{bin → src}/lib/config.sh +347 -347
- package/{bin → src}/lib/constants.sh +241 -241
- package/{bin → src}/lib/daemon/budgets.sh +107 -107
- package/{bin → src}/lib/daemon/dependencies.sh +146 -146
- package/{bin → src}/lib/daemon/display.sh +128 -128
- package/{bin → src}/lib/daemon/notifications.sh +273 -273
- package/{bin → src}/lib/daemon/routing.sh +93 -93
- package/{bin → src}/lib/daemon/state.sh +163 -163
- package/{bin → src}/lib/daemon/sync.sh +103 -103
- package/{bin → src}/lib/database.sh +357 -357
- package/{bin → src}/lib/frontmatter.js +106 -106
- package/{bin → src}/lib/heimdall-setup.js +113 -113
- package/{bin → src}/lib/heimdall.js +265 -265
- package/src/lib/index.sh +25 -0
- package/{bin → src}/lib/json.sh +264 -264
- package/{bin → src}/lib/terminal.js +452 -452
- package/{bin → src}/lib/util.sh +126 -126
- package/{bin → src}/lib/vcs.js +349 -349
- package/{context → templates}/project-context-template.md +122 -122
- package/config/task-template.md +0 -159
- package/config/templates/handoff-template.md +0 -40
|
@@ -1,265 +1,265 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Heimdall -- Forge worker pre-tool hook interceptor (PLAT-1)
|
|
4
|
-
*
|
|
5
|
-
* Guards the Bifrost: intercepts every tool call before execution,
|
|
6
|
-
* checks it against per-task policy, and blocks or allows.
|
|
7
|
-
*
|
|
8
|
-
* Registered as a PreToolUse hook via .claude/settings.local.json
|
|
9
|
-
* written by the forge daemon at worker startup.
|
|
10
|
-
*
|
|
11
|
-
* Exit codes:
|
|
12
|
-
* 0 -- allow (optionally with audit log entry)
|
|
13
|
-
* 2 -- block (explanation written to stdout, fed back to model)
|
|
14
|
-
*
|
|
15
|
-
* Context file (.context.json in process.cwd()) is written by the forge
|
|
16
|
-
* daemon alongside each inbox task. If absent, Heimdall exits 0 immediately
|
|
17
|
-
* -- forge-native tasks are not restricted.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
'use strict'
|
|
21
|
-
|
|
22
|
-
const fs = require('fs')
|
|
23
|
-
const path = require('path')
|
|
24
|
-
|
|
25
|
-
// ── Input ─────────────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
let input
|
|
28
|
-
try {
|
|
29
|
-
input = JSON.parse(fs.readFileSync(0, 'utf8'))
|
|
30
|
-
} catch (e) {
|
|
31
|
-
// Malformed input -- allow and exit rather than blocking the worker
|
|
32
|
-
process.exit(0)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const { tool_name, tool_input } = input
|
|
36
|
-
|
|
37
|
-
// ── Context file ──────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
const contextPath = path.join(process.cwd(), '.context.json')
|
|
40
|
-
|
|
41
|
-
// No context file = forge-native task. Heimdall does not restrict forge-native work.
|
|
42
|
-
if (!fs.existsSync(contextPath)) {
|
|
43
|
-
process.exit(0)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let ctx
|
|
47
|
-
try {
|
|
48
|
-
ctx = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
|
|
49
|
-
} catch (e) {
|
|
50
|
-
// Unreadable context -- fail open to avoid blocking forge-native work
|
|
51
|
-
process.exit(0)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const {
|
|
55
|
-
story_id,
|
|
56
|
-
agent,
|
|
57
|
-
worktree_path,
|
|
58
|
-
has_db_migration,
|
|
59
|
-
allowed_paths,
|
|
60
|
-
escalation_dir,
|
|
61
|
-
audit_log: ctxAuditLog,
|
|
62
|
-
} = ctx
|
|
63
|
-
|
|
64
|
-
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
const AUDIT_LOG = ctxAuditLog
|
|
67
|
-
|| path.join(process.cwd(), '_vibe-chain-output', 'heimdall-audit.log')
|
|
68
|
-
|
|
69
|
-
const MAX_VIOLATIONS = parseInt(process.env.HEIMDALL_MAX_VIOLATIONS || '3', 10)
|
|
70
|
-
|
|
71
|
-
// Violation tracking lives in the worktree root (process.cwd())
|
|
72
|
-
const VIOLATION_FILE = path.join(process.cwd(), `.${story_id}.violations`)
|
|
73
|
-
|
|
74
|
-
// Escalation signal written to the inbox agent dir so the daemon detects it
|
|
75
|
-
const ESCALATION_DIR = escalation_dir || process.cwd()
|
|
76
|
-
|
|
77
|
-
function timestamp() {
|
|
78
|
-
return new Date().toISOString()
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function audit(level, message) {
|
|
82
|
-
const line = `[${timestamp()}] HEIMDALL ${level} ${agent}/${story_id}: ${message}\n`
|
|
83
|
-
try {
|
|
84
|
-
const dir = path.dirname(AUDIT_LOG)
|
|
85
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
86
|
-
fs.appendFileSync(AUDIT_LOG, line)
|
|
87
|
-
} catch (_) {
|
|
88
|
-
// Audit log write failure must never block or crash the hook
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function block(reason) {
|
|
93
|
-
audit('BLOCKED', reason)
|
|
94
|
-
|
|
95
|
-
// Track violations
|
|
96
|
-
let violations = 0
|
|
97
|
-
try {
|
|
98
|
-
if (fs.existsSync(VIOLATION_FILE)) {
|
|
99
|
-
violations = parseInt(fs.readFileSync(VIOLATION_FILE, 'utf8').trim(), 10) || 0
|
|
100
|
-
}
|
|
101
|
-
} catch (_) {}
|
|
102
|
-
|
|
103
|
-
violations++
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
fs.writeFileSync(VIOLATION_FILE, String(violations))
|
|
107
|
-
} catch (_) {}
|
|
108
|
-
|
|
109
|
-
if (violations >= MAX_VIOLATIONS) {
|
|
110
|
-
// Sound the Gjallarhorn
|
|
111
|
-
const escalationFile = path.join(ESCALATION_DIR, `${story_id}.escalation`)
|
|
112
|
-
try {
|
|
113
|
-
fs.writeFileSync(escalationFile, JSON.stringify({
|
|
114
|
-
story_id,
|
|
115
|
-
agent,
|
|
116
|
-
violations,
|
|
117
|
-
last_reason: reason,
|
|
118
|
-
timestamp: timestamp(),
|
|
119
|
-
}, null, 2))
|
|
120
|
-
} catch (_) {}
|
|
121
|
-
|
|
122
|
-
audit('SOUNDED', `${violations} violations -- escalating to human review`)
|
|
123
|
-
|
|
124
|
-
console.log(
|
|
125
|
-
`[HEIMDALL] Action blocked: ${reason}\n` +
|
|
126
|
-
`[HEIMDALL] GJALLARHORN SOUNDED: ${violations} violations recorded.\n` +
|
|
127
|
-
`[HEIMDALL] Story ${story_id} has been escalated to human review. Stop working on this story.`
|
|
128
|
-
)
|
|
129
|
-
} else {
|
|
130
|
-
console.log(
|
|
131
|
-
`[HEIMDALL] Action blocked: ${reason}\n` +
|
|
132
|
-
`[HEIMDALL] Violation ${violations}/${MAX_VIOLATIONS}. Self-correct and continue.`
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
process.exit(2)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function allow(note) {
|
|
140
|
-
if (note) audit('ALLOWED', note)
|
|
141
|
-
process.exit(0)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
function isOutsideAllowedPaths(targetPath) {
|
|
147
|
-
if (!targetPath) return false
|
|
148
|
-
try {
|
|
149
|
-
const resolved = path.resolve(targetPath)
|
|
150
|
-
return !allowed_paths.some(p => resolved.startsWith(path.resolve(p)))
|
|
151
|
-
} catch (_) {
|
|
152
|
-
return true // Unresolvable path -- treat as outside
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ── Bash tool checks ──────────────────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
if (tool_name === 'Bash') {
|
|
159
|
-
const cmd = (tool_input && tool_input.command || '').trim()
|
|
160
|
-
|
|
161
|
-
// Destructive rm -- check if target escapes worktree
|
|
162
|
-
if (/rm\s+-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r/.test(cmd) && /\brm\b/.test(cmd)) {
|
|
163
|
-
// Extract everything after the flags as the target
|
|
164
|
-
const rmMatch = cmd.match(/rm\s+(?:-\S+\s+)(.+)/)
|
|
165
|
-
const rmTarget = rmMatch ? rmMatch[1].trim().replace(/^['"]|['"]$/g, '') : ''
|
|
166
|
-
|
|
167
|
-
const isRootTarget = /^(\/|~)/.test(rmTarget) || rmTarget === '' || rmTarget.includes('..')
|
|
168
|
-
const isOutside = !rmTarget || isRootTarget || isOutsideAllowedPaths(rmTarget)
|
|
169
|
-
|
|
170
|
-
if (isOutside) {
|
|
171
|
-
block(`destructive rm outside worktree: ${cmd}`)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Credential access
|
|
176
|
-
if (/\.(env|pem|key|cert)\b/.test(cmd) && !/\.env\.(example|sample|template)/.test(cmd)) {
|
|
177
|
-
block(`credential file access: ${cmd}`)
|
|
178
|
-
}
|
|
179
|
-
if (/~\/\.(ssh|aws|gnupg)\b/.test(cmd)) {
|
|
180
|
-
block(`credential directory access: ${cmd}`)
|
|
181
|
-
}
|
|
182
|
-
// Echoing or exporting secrets (but not reading them as part of build)
|
|
183
|
-
if (/(echo|printf|export)\s+.*\b(TOKEN|SECRET|PASSWORD|API_KEY)\s*=/.test(cmd)) {
|
|
184
|
-
block(`credential assignment in shell: ${cmd}`)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Dangerous git operations
|
|
188
|
-
if (/git\s+push\b.*--force(-with-lease)?/.test(cmd)) {
|
|
189
|
-
block(`force push blocked: ${cmd}`)
|
|
190
|
-
}
|
|
191
|
-
if (/git\s+reset\s+--hard\s+HEAD~[2-9]/.test(cmd)) {
|
|
192
|
-
block(`multi-commit hard reset blocked: ${cmd}`)
|
|
193
|
-
}
|
|
194
|
-
if (/git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d|-[a-zA-Z]*d[a-zA-Z]*f/.test(cmd) && /\bgit\b/.test(cmd)) {
|
|
195
|
-
block(`git clean -fd blocked: ${cmd}`)
|
|
196
|
-
}
|
|
197
|
-
// Push to a non-origin remote (e.g. git push upstream)
|
|
198
|
-
if (/git\s+push\s+(?!origin\b)(\S+)/.test(cmd)) {
|
|
199
|
-
const remoteMatch = cmd.match(/git\s+push\s+(\S+)/)
|
|
200
|
-
if (remoteMatch && remoteMatch[1] !== 'origin' && !remoteMatch[1].startsWith('-')) {
|
|
201
|
-
block(`push to non-origin remote blocked: ${cmd}`)
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
// Direct push to main/master — all changes must go through PRs
|
|
205
|
-
if (/git\s+push\b/.test(cmd) && /\b(main|master)\b/.test(cmd)) {
|
|
206
|
-
block(`direct push to main/master blocked — create a feature branch and open a PR`)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// DB destructive operations without authorization
|
|
210
|
-
if (/(DROP\s+TABLE|TRUNCATE\s+TABLE)/i.test(cmd) && !has_db_migration) {
|
|
211
|
-
block(`DROP/TRUNCATE requires has_db_migration: true on the story`)
|
|
212
|
-
}
|
|
213
|
-
if (/DELETE\s+FROM\s+\S+\s*;?\s*$/i.test(cmd) && !/WHERE\s+/i.test(cmd)) {
|
|
214
|
-
block(`DELETE without WHERE clause blocked: ${cmd}`)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// High-risk but legitimate -- audit log only
|
|
218
|
-
if (/\b(npm|yarn|pnpm)\s+(install|add|ci)\b/.test(cmd)) {
|
|
219
|
-
allow(`dependency install: ${cmd}`)
|
|
220
|
-
}
|
|
221
|
-
if (/\b(pip|pip3)\s+install\b/.test(cmd)) {
|
|
222
|
-
allow(`pip install: ${cmd}`)
|
|
223
|
-
}
|
|
224
|
-
if (/\bcargo\s+(build|install)\b/.test(cmd)) {
|
|
225
|
-
allow(`cargo build: ${cmd}`)
|
|
226
|
-
}
|
|
227
|
-
if (/\b(curl|wget)\b/.test(cmd)) {
|
|
228
|
-
allow(`network request: ${cmd}`)
|
|
229
|
-
}
|
|
230
|
-
if (/git\s+commit\s+--amend/.test(cmd)) {
|
|
231
|
-
allow(`git commit --amend`)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ── Write / Edit tool checks ──────────────────────────────────────────────────
|
|
236
|
-
|
|
237
|
-
if (tool_name === 'Write' || tool_name === 'Edit') {
|
|
238
|
-
const filePath = tool_input && (tool_input.file_path || tool_input.path) || ''
|
|
239
|
-
|
|
240
|
-
if (filePath) {
|
|
241
|
-
// Credential files
|
|
242
|
-
if (/\.(env|pem|key|cert)$/.test(filePath) && !/\.(example|sample|template)$/.test(filePath)) {
|
|
243
|
-
block(`write to credential file: ${filePath}`)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Settings that could override Heimdall
|
|
247
|
-
if (/\.claude[/\\]settings(\.local)?\.json$/.test(filePath)) {
|
|
248
|
-
block(`write to .claude/settings blocked -- Heimdall config is daemon-managed`)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Path escape check
|
|
252
|
-
if (isOutsideAllowedPaths(filePath)) {
|
|
253
|
-
block(`path escape: ${filePath} is outside allowed paths`)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Pipeline output writes -- allowed but logged
|
|
257
|
-
if (filePath.includes('_vibe-chain-output')) {
|
|
258
|
-
allow(`pipeline output write: ${filePath}`)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── Default: allow ────────────────────────────────────────────────────────────
|
|
264
|
-
|
|
265
|
-
allow(null)
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Heimdall -- Forge worker pre-tool hook interceptor (PLAT-1)
|
|
4
|
+
*
|
|
5
|
+
* Guards the Bifrost: intercepts every tool call before execution,
|
|
6
|
+
* checks it against per-task policy, and blocks or allows.
|
|
7
|
+
*
|
|
8
|
+
* Registered as a PreToolUse hook via .claude/settings.local.json
|
|
9
|
+
* written by the forge daemon at worker startup.
|
|
10
|
+
*
|
|
11
|
+
* Exit codes:
|
|
12
|
+
* 0 -- allow (optionally with audit log entry)
|
|
13
|
+
* 2 -- block (explanation written to stdout, fed back to model)
|
|
14
|
+
*
|
|
15
|
+
* Context file (.context.json in process.cwd()) is written by the forge
|
|
16
|
+
* daemon alongside each inbox task. If absent, Heimdall exits 0 immediately
|
|
17
|
+
* -- forge-native tasks are not restricted.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict'
|
|
21
|
+
|
|
22
|
+
const fs = require('fs')
|
|
23
|
+
const path = require('path')
|
|
24
|
+
|
|
25
|
+
// ── Input ─────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
let input
|
|
28
|
+
try {
|
|
29
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'))
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// Malformed input -- allow and exit rather than blocking the worker
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { tool_name, tool_input } = input
|
|
36
|
+
|
|
37
|
+
// ── Context file ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const contextPath = path.join(process.cwd(), '.context.json')
|
|
40
|
+
|
|
41
|
+
// No context file = forge-native task. Heimdall does not restrict forge-native work.
|
|
42
|
+
if (!fs.existsSync(contextPath)) {
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let ctx
|
|
47
|
+
try {
|
|
48
|
+
ctx = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// Unreadable context -- fail open to avoid blocking forge-native work
|
|
51
|
+
process.exit(0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
story_id,
|
|
56
|
+
agent,
|
|
57
|
+
worktree_path,
|
|
58
|
+
has_db_migration,
|
|
59
|
+
allowed_paths,
|
|
60
|
+
escalation_dir,
|
|
61
|
+
audit_log: ctxAuditLog,
|
|
62
|
+
} = ctx
|
|
63
|
+
|
|
64
|
+
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const AUDIT_LOG = ctxAuditLog
|
|
67
|
+
|| path.join(process.cwd(), '_vibe-chain-output', 'heimdall-audit.log')
|
|
68
|
+
|
|
69
|
+
const MAX_VIOLATIONS = parseInt(process.env.HEIMDALL_MAX_VIOLATIONS || '3', 10)
|
|
70
|
+
|
|
71
|
+
// Violation tracking lives in the worktree root (process.cwd())
|
|
72
|
+
const VIOLATION_FILE = path.join(process.cwd(), `.${story_id}.violations`)
|
|
73
|
+
|
|
74
|
+
// Escalation signal written to the inbox agent dir so the daemon detects it
|
|
75
|
+
const ESCALATION_DIR = escalation_dir || process.cwd()
|
|
76
|
+
|
|
77
|
+
function timestamp() {
|
|
78
|
+
return new Date().toISOString()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function audit(level, message) {
|
|
82
|
+
const line = `[${timestamp()}] HEIMDALL ${level} ${agent}/${story_id}: ${message}\n`
|
|
83
|
+
try {
|
|
84
|
+
const dir = path.dirname(AUDIT_LOG)
|
|
85
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
86
|
+
fs.appendFileSync(AUDIT_LOG, line)
|
|
87
|
+
} catch (_) {
|
|
88
|
+
// Audit log write failure must never block or crash the hook
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function block(reason) {
|
|
93
|
+
audit('BLOCKED', reason)
|
|
94
|
+
|
|
95
|
+
// Track violations
|
|
96
|
+
let violations = 0
|
|
97
|
+
try {
|
|
98
|
+
if (fs.existsSync(VIOLATION_FILE)) {
|
|
99
|
+
violations = parseInt(fs.readFileSync(VIOLATION_FILE, 'utf8').trim(), 10) || 0
|
|
100
|
+
}
|
|
101
|
+
} catch (_) {}
|
|
102
|
+
|
|
103
|
+
violations++
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(VIOLATION_FILE, String(violations))
|
|
107
|
+
} catch (_) {}
|
|
108
|
+
|
|
109
|
+
if (violations >= MAX_VIOLATIONS) {
|
|
110
|
+
// Sound the Gjallarhorn
|
|
111
|
+
const escalationFile = path.join(ESCALATION_DIR, `${story_id}.escalation`)
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(escalationFile, JSON.stringify({
|
|
114
|
+
story_id,
|
|
115
|
+
agent,
|
|
116
|
+
violations,
|
|
117
|
+
last_reason: reason,
|
|
118
|
+
timestamp: timestamp(),
|
|
119
|
+
}, null, 2))
|
|
120
|
+
} catch (_) {}
|
|
121
|
+
|
|
122
|
+
audit('SOUNDED', `${violations} violations -- escalating to human review`)
|
|
123
|
+
|
|
124
|
+
console.log(
|
|
125
|
+
`[HEIMDALL] Action blocked: ${reason}\n` +
|
|
126
|
+
`[HEIMDALL] GJALLARHORN SOUNDED: ${violations} violations recorded.\n` +
|
|
127
|
+
`[HEIMDALL] Story ${story_id} has been escalated to human review. Stop working on this story.`
|
|
128
|
+
)
|
|
129
|
+
} else {
|
|
130
|
+
console.log(
|
|
131
|
+
`[HEIMDALL] Action blocked: ${reason}\n` +
|
|
132
|
+
`[HEIMDALL] Violation ${violations}/${MAX_VIOLATIONS}. Self-correct and continue.`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.exit(2)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function allow(note) {
|
|
140
|
+
if (note) audit('ALLOWED', note)
|
|
141
|
+
process.exit(0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function isOutsideAllowedPaths(targetPath) {
|
|
147
|
+
if (!targetPath) return false
|
|
148
|
+
try {
|
|
149
|
+
const resolved = path.resolve(targetPath)
|
|
150
|
+
return !allowed_paths.some(p => resolved.startsWith(path.resolve(p)))
|
|
151
|
+
} catch (_) {
|
|
152
|
+
return true // Unresolvable path -- treat as outside
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Bash tool checks ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
if (tool_name === 'Bash') {
|
|
159
|
+
const cmd = (tool_input && tool_input.command || '').trim()
|
|
160
|
+
|
|
161
|
+
// Destructive rm -- check if target escapes worktree
|
|
162
|
+
if (/rm\s+-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r/.test(cmd) && /\brm\b/.test(cmd)) {
|
|
163
|
+
// Extract everything after the flags as the target
|
|
164
|
+
const rmMatch = cmd.match(/rm\s+(?:-\S+\s+)(.+)/)
|
|
165
|
+
const rmTarget = rmMatch ? rmMatch[1].trim().replace(/^['"]|['"]$/g, '') : ''
|
|
166
|
+
|
|
167
|
+
const isRootTarget = /^(\/|~)/.test(rmTarget) || rmTarget === '' || rmTarget.includes('..')
|
|
168
|
+
const isOutside = !rmTarget || isRootTarget || isOutsideAllowedPaths(rmTarget)
|
|
169
|
+
|
|
170
|
+
if (isOutside) {
|
|
171
|
+
block(`destructive rm outside worktree: ${cmd}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Credential access
|
|
176
|
+
if (/\.(env|pem|key|cert)\b/.test(cmd) && !/\.env\.(example|sample|template)/.test(cmd)) {
|
|
177
|
+
block(`credential file access: ${cmd}`)
|
|
178
|
+
}
|
|
179
|
+
if (/~\/\.(ssh|aws|gnupg)\b/.test(cmd)) {
|
|
180
|
+
block(`credential directory access: ${cmd}`)
|
|
181
|
+
}
|
|
182
|
+
// Echoing or exporting secrets (but not reading them as part of build)
|
|
183
|
+
if (/(echo|printf|export)\s+.*\b(TOKEN|SECRET|PASSWORD|API_KEY)\s*=/.test(cmd)) {
|
|
184
|
+
block(`credential assignment in shell: ${cmd}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Dangerous git operations
|
|
188
|
+
if (/git\s+push\b.*--force(-with-lease)?/.test(cmd)) {
|
|
189
|
+
block(`force push blocked: ${cmd}`)
|
|
190
|
+
}
|
|
191
|
+
if (/git\s+reset\s+--hard\s+HEAD~[2-9]/.test(cmd)) {
|
|
192
|
+
block(`multi-commit hard reset blocked: ${cmd}`)
|
|
193
|
+
}
|
|
194
|
+
if (/git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d|-[a-zA-Z]*d[a-zA-Z]*f/.test(cmd) && /\bgit\b/.test(cmd)) {
|
|
195
|
+
block(`git clean -fd blocked: ${cmd}`)
|
|
196
|
+
}
|
|
197
|
+
// Push to a non-origin remote (e.g. git push upstream)
|
|
198
|
+
if (/git\s+push\s+(?!origin\b)(\S+)/.test(cmd)) {
|
|
199
|
+
const remoteMatch = cmd.match(/git\s+push\s+(\S+)/)
|
|
200
|
+
if (remoteMatch && remoteMatch[1] !== 'origin' && !remoteMatch[1].startsWith('-')) {
|
|
201
|
+
block(`push to non-origin remote blocked: ${cmd}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Direct push to main/master — all changes must go through PRs
|
|
205
|
+
if (/git\s+push\b/.test(cmd) && /\b(main|master)\b/.test(cmd)) {
|
|
206
|
+
block(`direct push to main/master blocked — create a feature branch and open a PR`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// DB destructive operations without authorization
|
|
210
|
+
if (/(DROP\s+TABLE|TRUNCATE\s+TABLE)/i.test(cmd) && !has_db_migration) {
|
|
211
|
+
block(`DROP/TRUNCATE requires has_db_migration: true on the story`)
|
|
212
|
+
}
|
|
213
|
+
if (/DELETE\s+FROM\s+\S+\s*;?\s*$/i.test(cmd) && !/WHERE\s+/i.test(cmd)) {
|
|
214
|
+
block(`DELETE without WHERE clause blocked: ${cmd}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// High-risk but legitimate -- audit log only
|
|
218
|
+
if (/\b(npm|yarn|pnpm)\s+(install|add|ci)\b/.test(cmd)) {
|
|
219
|
+
allow(`dependency install: ${cmd}`)
|
|
220
|
+
}
|
|
221
|
+
if (/\b(pip|pip3)\s+install\b/.test(cmd)) {
|
|
222
|
+
allow(`pip install: ${cmd}`)
|
|
223
|
+
}
|
|
224
|
+
if (/\bcargo\s+(build|install)\b/.test(cmd)) {
|
|
225
|
+
allow(`cargo build: ${cmd}`)
|
|
226
|
+
}
|
|
227
|
+
if (/\b(curl|wget)\b/.test(cmd)) {
|
|
228
|
+
allow(`network request: ${cmd}`)
|
|
229
|
+
}
|
|
230
|
+
if (/git\s+commit\s+--amend/.test(cmd)) {
|
|
231
|
+
allow(`git commit --amend`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Write / Edit tool checks ──────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
if (tool_name === 'Write' || tool_name === 'Edit') {
|
|
238
|
+
const filePath = tool_input && (tool_input.file_path || tool_input.path) || ''
|
|
239
|
+
|
|
240
|
+
if (filePath) {
|
|
241
|
+
// Credential files
|
|
242
|
+
if (/\.(env|pem|key|cert)$/.test(filePath) && !/\.(example|sample|template)$/.test(filePath)) {
|
|
243
|
+
block(`write to credential file: ${filePath}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Settings that could override Heimdall
|
|
247
|
+
if (/\.claude[/\\]settings(\.local)?\.json$/.test(filePath)) {
|
|
248
|
+
block(`write to .claude/settings blocked -- Heimdall config is daemon-managed`)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Path escape check
|
|
252
|
+
if (isOutsideAllowedPaths(filePath)) {
|
|
253
|
+
block(`path escape: ${filePath} is outside allowed paths`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Pipeline output writes -- allowed but logged
|
|
257
|
+
if (filePath.includes('_vibe-chain-output')) {
|
|
258
|
+
allow(`pipeline output write: ${filePath}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Default: allow ────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
allow(null)
|
package/src/lib/index.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# src/lib/index.sh
|
|
4
|
+
#
|
|
5
|
+
# Central entry point for sourcing common libraries.
|
|
6
|
+
# Scripts source this instead of individually sourcing each lib.
|
|
7
|
+
#
|
|
8
|
+
# Usage: source "$LIB_DIR/index.sh"
|
|
9
|
+
|
|
10
|
+
# Prevent double-sourcing
|
|
11
|
+
[[ -n "${_LIB_INDEX_LOADED:-}" ]] && return 0
|
|
12
|
+
_LIB_INDEX_LOADED=1
|
|
13
|
+
|
|
14
|
+
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
|
|
16
|
+
# Core libraries (order matters: colors first, then constants, then the rest)
|
|
17
|
+
source "$_LIB_DIR/colors.sh"
|
|
18
|
+
source "$_LIB_DIR/constants.sh"
|
|
19
|
+
source "$_LIB_DIR/json.sh"
|
|
20
|
+
source "$_LIB_DIR/util.sh"
|
|
21
|
+
|
|
22
|
+
# Optional libraries (sourced on demand by scripts that need them)
|
|
23
|
+
# source "$_LIB_DIR/config.sh" # Agent config loading
|
|
24
|
+
# source "$_LIB_DIR/agents.sh" # Agent resolution
|
|
25
|
+
# source "$_LIB_DIR/database.sh" # SQLite operations (daemon only)
|