-
-
Notifications
You must be signed in to change notification settings - Fork 77
Expand file tree
/
Copy pathexternal-action-gate.js
More file actions
190 lines (170 loc) · 7.5 KB
/
Copy pathexternal-action-gate.js
File metadata and controls
190 lines (170 loc) · 7.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/usr/bin/env node
/**
* external-action-gate.js - PreToolUse hook (Bash)
*
* Uses the First-Encounter Consent pattern to handle external actions.
*
* Three tiers:
* SECRETS - Always blocked. Reading or writing .env files via Bash.
* HARD - Always blocked per-action. Irreversible by default (merge, close,
* delete, release, fork). Configurable via policy.externalActions.hard.
* SOFT - Governed by consent preference. Empty by default; teams can opt
* reversible actions into consent via policy.externalActions.soft.
* ALLOW - Reversible delivery actions by default (push, PR create/comment,
* issue create/comment/edit). Configurable via policy.externalActions.allow.
*
* Policy overrides (harness.json):
* policy.externalActions.protectedBranches - branches that can never be deleted
* policy.externalActions.hard - labels that are always per-action confirmed
* policy.externalActions.soft - labels governed by consent preference
*
* When a label appears in both hard[] and soft[], hard wins.
* When a label is in soft[] but was in default HARD, it moves to consent-gated.
* This lets users unlock merge/close for autonomous workflows.
*
* Exit codes:
* 0 = allowed
* 2 = blocked - message written to stderr so Claude Code surfaces it to the agent
*/
const health = require('./harness-health-util');
const {
detectExternalAction,
readExternalActionPolicy,
} = require('../core/policy/external-actions');
const CITADEL_UI = process.env.CITADEL_UI === 'true';
// For CITADEL_UI (desktop app): structured JSON to stdout.
// For CLI: human-readable message to stderr — Claude Code includes stderr in the
// hook error that the agent reads, so this is what surfaces as the block reason.
function hookOutput(hookName, action, message, data = {}) {
if (CITADEL_UI) {
process.stdout.write(JSON.stringify({
hook: hookName,
action,
message,
timestamp: new Date().toISOString(),
data,
}));
} else {
process.stderr.write(message);
}
}
function main() {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
run(input);
} catch {
process.exit(0);
}
});
}
function run(input) {
let event;
try {
event = JSON.parse(input);
} catch {
process.exit(0);
}
if ((event.tool_name || '') !== 'Bash') process.exit(0);
const command = event.tool_input?.command || '';
if (!command) process.exit(0);
const policy = readExternalActionPolicy(health.readConfig());
const action = detectExternalAction(command, policy);
if (!action) process.exit(0);
if (action.kind === 'secret') {
health.logBlock('external-action-gate', 'blocked', `${action.label}: ${command.slice(0, 200)}`);
hookOutput(
'external-action-gate',
'blocked',
`[Citadel] Blocked — secrets access: "${action.label}"\n` +
(action.label.includes('secrets write')
? `Writing .env files via Bash is always blocked. Template files ending in .example, .sample, or .template are allowed.\n`
: `Reading .env files and credentials is always blocked.\n`),
{ label: action.label, tier: action.tier }
);
process.exit(2);
}
if (action.kind === 'protected-branch') {
health.logBlock('external-action-gate', 'blocked', `delete protected branch ${action.branch}: ${command.slice(0, 200)}`);
hookOutput(
'external-action-gate',
'blocked',
`[Citadel] Blocked — protected branch: "${action.branch}"\n` +
`This branch is configured as protected in harness.json and cannot be deleted.\n` +
`To unprotect it, remove it from policy.externalActions.protectedBranches.\n`,
{ label: action.label, tier: action.tier }
);
process.exit(2);
}
if (action.tier === 'allow') process.exit(0);
if (action.tier === 'hard') {
health.logBlock('external-action-gate', 'blocked', `${action.label}: ${command.slice(0, 200)}`);
hookOutput(
'external-action-gate',
'blocked',
`[Citadel] Approval required — irreversible action: "${action.label}"\n` +
`Command: ${command.slice(0, 200)}\n\n` +
`This action cannot be undone. Please review the exact command above and explicitly\n` +
`confirm with the user before proceeding. Do not retry until confirmed.\n`,
{ label: action.label, tier: action.tier }
);
process.exit(2);
}
const consent = health.checkConsent('externalActions');
if (consent.action === 'allow') process.exit(0);
if (consent.action === 'first-encounter') {
health.logBlock('external-action-gate', 'first-encounter', `${action.label}: ${command.slice(0, 200)}`);
hookOutput(
'external-action-gate',
'first-encounter',
`[Citadel] First external action — preference not set\n` +
`Action: ${action.label} | Command: ${command.slice(0, 120)}\n\n` +
`Citadel can push branches, create PRs, and post comments on your behalf.\n` +
`How would you like to handle this going forward?\n\n` +
` 1. Always ask — pause and confirm every time (most control)\n` +
` 2. This session — allow now, ask again next session (recommended)\n` +
` 3. Auto-allow — never ask again (most autonomous)\n\n` +
`Recommendation: option 2 — "${action.label}" is reversible and this keeps\n` +
`you informed across sessions without blocking autonomous workflows.\n\n` +
`Ask the user which they prefer (1/2/3), then apply with:\n` +
` 1 → node -e "require('./hooks_src/harness-health-util').writeConsent('externalActions','always-ask')"\n` +
` 2 → node -e "require('./hooks_src/harness-health-util').writeConsent('externalActions','session-allow')" && \\\n` +
` node -e "require('./hooks_src/harness-health-util').grantSessionAllow('externalActions')"\n` +
` 3 → node -e "require('./hooks_src/harness-health-util').writeConsent('externalActions','auto-allow')"\n\n` +
`Then retry the command automatically.\n`,
{ label: action.label, tier: action.tier, consent: 'first-encounter' }
);
process.exit(2);
}
health.logBlock('external-action-gate', 'consent-block', `${action.label}: ${command.slice(0, 200)}`);
const pref = health.readConsent('externalActions');
if (pref === 'session-allow') {
hookOutput(
'external-action-gate',
'consent-block',
`[Citadel] New session — external action needs approval\n` +
`Action: ${action.label} | Command: ${command.slice(0, 120)}\n\n` +
`Your preference is "session-allow". Approve to enable external actions for this session.\n\n` +
`Ask the user yes/no. If approved, run:\n` +
` node -e "require('./hooks_src/harness-health-util').grantSessionAllow('externalActions')"\n` +
`Then retry the command automatically.\n`,
{ label: action.label, tier: action.tier, consent: 'session-renew' }
);
} else {
hookOutput(
'external-action-gate',
'consent-block',
`[Citadel] External action — approval required\n` +
`Action: ${action.label} | Command: ${command.slice(0, 120)}\n\n` +
`Your preference is "always-ask". Show the user the command above and ask for approval.\n\n` +
`If approved, run:\n` +
` node -e "require('./hooks_src/harness-health-util').grantOneTimeAllow('externalActions')"\n` +
`Then retry the command automatically.\n`,
{ label: action.label, tier: action.tier, consent: 'always-ask' }
);
}
process.exit(2);
}
main();