cawdev-cli 1.0.11-beta → 1.1.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.
- package/README.md +11 -1
- package/lib/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +25 -10
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +54 -1
- package/mcp/README.md +48 -36
- package/mcp/server.mjs +263 -72
- package/package.json +1 -1
- package/runner/README.md +101 -25
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +675 -177
- package/runner/split.mjs +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// What a coding agent may push — R565.
|
|
3
|
+
//
|
|
4
|
+
// Both a pure function (`judge`) and a Claude Code PreToolUse hook (the bottom
|
|
5
|
+
// of the file). The runner wires it in with `--settings`; an exit code of 2
|
|
6
|
+
// blocks the tool call and shows stderr to the model.
|
|
7
|
+
//
|
|
8
|
+
// A guard against MISTAKES, not a security boundary: `git -c alias.x='!…'`, a
|
|
9
|
+
// host CLI by absolute path through a script, or any language with a socket
|
|
10
|
+
// gets round a pattern match. The wall is branch protection on the host. What
|
|
11
|
+
// this buys is that the obvious wrong move is refused with a reason, in the
|
|
12
|
+
// session, instead of being discovered afterwards by `auditPushes`.
|
|
13
|
+
//
|
|
14
|
+
// No dependencies, and in `tools/runner/` on purpose — see git-host.mjs.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { basename } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const HOST_CLIS = new Set(['gh', 'glab', 'hub', 'bb', 'lab', 'tea']);
|
|
20
|
+
const HOST_CLI_REASON = 'Branches, pull requests and merges are done from cawdev, not by the agent.';
|
|
21
|
+
const WRAPPERS = new Set(['env', 'command', 'exec', 'sudo', 'nohup', 'time']);
|
|
22
|
+
|
|
23
|
+
/** Splits on `; && || |` and newlines, outside quotes; `$( … )` and backticks become segments of their own. */
|
|
24
|
+
export function segmentsOf(command) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const walk = (text) => {
|
|
27
|
+
let current = '';
|
|
28
|
+
let quote = null;
|
|
29
|
+
const flush = () => {
|
|
30
|
+
if (current.trim()) out.push(current.trim());
|
|
31
|
+
current = '';
|
|
32
|
+
};
|
|
33
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
34
|
+
const c = text[i];
|
|
35
|
+
if (quote === "'") {
|
|
36
|
+
if (c === "'") quote = null;
|
|
37
|
+
current += c;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
41
|
+
current += c + text[i + 1];
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (c === '$' && text[i + 1] === '(') {
|
|
46
|
+
let depth = 1;
|
|
47
|
+
let j = i + 2;
|
|
48
|
+
while (j < text.length && depth > 0) {
|
|
49
|
+
if (text[j] === '(') depth += 1;
|
|
50
|
+
if (text[j] === ')') depth -= 1;
|
|
51
|
+
j += 1;
|
|
52
|
+
}
|
|
53
|
+
walk(text.slice(i + 2, j - 1));
|
|
54
|
+
current += ' ';
|
|
55
|
+
i = j - 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '`') {
|
|
59
|
+
const end = text.indexOf('`', i + 1);
|
|
60
|
+
const stop = end < 0 ? text.length : end;
|
|
61
|
+
walk(text.slice(i + 1, stop));
|
|
62
|
+
current += ' ';
|
|
63
|
+
i = stop;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (quote === '"') {
|
|
67
|
+
if (c === '"') quote = null;
|
|
68
|
+
current += c;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (c === '"' || c === "'") {
|
|
72
|
+
quote = c;
|
|
73
|
+
current += c;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (c === '&' && (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>')) {
|
|
77
|
+
current += c; // a redirection (`2>&1`, `&>`), not a command separator
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === ';' || c === '\n' || c === '|' || c === '&') {
|
|
81
|
+
flush();
|
|
82
|
+
if ((c === '|' || c === '&') && text[i + 1] === c) i += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
current += c;
|
|
86
|
+
}
|
|
87
|
+
flush();
|
|
88
|
+
};
|
|
89
|
+
walk(String(command ?? ''));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shell-ish words: quotes removed, no expansion. */
|
|
94
|
+
export function wordsOf(segment) {
|
|
95
|
+
const words = [];
|
|
96
|
+
let current = '';
|
|
97
|
+
let has = false;
|
|
98
|
+
let quote = null;
|
|
99
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
100
|
+
const c = segment[i];
|
|
101
|
+
if (quote) {
|
|
102
|
+
if (c === quote) quote = null;
|
|
103
|
+
else if (c === '\\' && quote === '"' && i + 1 < segment.length) current += segment[++i];
|
|
104
|
+
else current += c;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === '"' || c === "'") {
|
|
108
|
+
quote = c;
|
|
109
|
+
has = true;
|
|
110
|
+
} else if (c === '\\' && i + 1 < segment.length) {
|
|
111
|
+
current += segment[++i];
|
|
112
|
+
has = true;
|
|
113
|
+
} else if (/\s/.test(c)) {
|
|
114
|
+
if (has) words.push(current);
|
|
115
|
+
current = '';
|
|
116
|
+
has = false;
|
|
117
|
+
} else {
|
|
118
|
+
current += c;
|
|
119
|
+
has = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (has) words.push(current);
|
|
123
|
+
return words;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Drops `VAR=…`, `env`, `command`, `exec`, `sudo` (and their own flags) from the front. */
|
|
127
|
+
function stripPrefix(input) {
|
|
128
|
+
// `(gh …)`, `{ gh …; }`, `! gh`, `then gh`: group and keyword words in front.
|
|
129
|
+
const words = input.map((w, index) => (index === 0 ? w.replace(/^[({]+/, '') : w))
|
|
130
|
+
.filter((w, index) => !(index < 3 && w === ''));
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < words.length) {
|
|
133
|
+
const w = words[i];
|
|
134
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || /^(\{|!|then|do|else|elif|if|while|until|time)$/.test(w)) {
|
|
135
|
+
i += 1;
|
|
136
|
+
} else if (WRAPPERS.has(basename(w))) {
|
|
137
|
+
i += 1;
|
|
138
|
+
// Their own flags, and the value of those that take one (`env -u X`, `sudo -u root`).
|
|
139
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
140
|
+
i += /^-(u|g|C|h|p|U|r|t|D)$/.test(words[i]) ? 2 : 1;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return words.slice(i);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Drops `2>&1`, `> file`, `>>file`, `&> file`, `< in` so they are not read as arguments. */
|
|
150
|
+
function withoutRedirects(words) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
153
|
+
const w = words[i];
|
|
154
|
+
if (/^(\d*|&)[<>]+(&\d+|&-)?$/.test(w)) {
|
|
155
|
+
if (!/&[\d-]+$/.test(w)) i += 1; // the file name that follows
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/^(\d*|&)[<>]+\S+/.test(w)) continue;
|
|
159
|
+
out.push(w);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FORCE = /^(-f|--force|--force-with-lease(=.*)?|--force-if-includes)$/;
|
|
165
|
+
const BAD_FLAGS = /^(-d|--delete|--mirror|--all|--prune)$/;
|
|
166
|
+
const WITH_VALUE = new Set(['--receive-pack', '--exec', '-o', '--push-option', '--signed']);
|
|
167
|
+
|
|
168
|
+
/** The first non-option word after `git`'s own global options, and the words after it. */
|
|
169
|
+
function gitSubcommand(words) {
|
|
170
|
+
let i = 1;
|
|
171
|
+
const globals = [];
|
|
172
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
173
|
+
const w = words[i];
|
|
174
|
+
globals.push(w);
|
|
175
|
+
if ((w === '-c' || w === '-C' || w === '--git-dir' || w === '--work-tree' || w === '--namespace') && i + 1 < words.length) {
|
|
176
|
+
globals.push(words[i + 1]);
|
|
177
|
+
i += 2;
|
|
178
|
+
} else {
|
|
179
|
+
i += 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { sub: words[i] ?? null, rest: words.slice(i + 1), globals };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function judgePush(rest, { branch, defaultBranch, allowTags, head, isTag }) {
|
|
186
|
+
const positional = [];
|
|
187
|
+
let hasRepoFlag = false;
|
|
188
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
189
|
+
const w = rest[i];
|
|
190
|
+
if (FORCE.test(w)) return 'A force push is refused: the agent may only fast-forward its own branch.';
|
|
191
|
+
if (BAD_FLAGS.test(w)) return `git push ${w} is refused: the agent may only push its own branch.`;
|
|
192
|
+
if (/^-[a-zA-Z]*[fd][a-zA-Z]*$/.test(w) && !w.startsWith('--')) {
|
|
193
|
+
// Combined short flags such as -fu or -df.
|
|
194
|
+
if (/[fd]/.test(w.slice(1))) return `git push ${w} is refused: a force or delete is not the agent's to do.`;
|
|
195
|
+
}
|
|
196
|
+
if (w === '--tags' || w === '--follow-tags') {
|
|
197
|
+
if (!allowTags) return `git push ${w} is refused: only a release session pushes tags.`;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (w === '--repo' || w.startsWith('--repo=')) {
|
|
201
|
+
// The remote is named by the flag, so every positional is a refspec.
|
|
202
|
+
hasRepoFlag = true;
|
|
203
|
+
if (w === '--repo') i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (WITH_VALUE.has(w)) {
|
|
207
|
+
i += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (w.startsWith('-')) continue;
|
|
211
|
+
positional.push(w);
|
|
212
|
+
}
|
|
213
|
+
if (!branch || branch === defaultBranch) {
|
|
214
|
+
return 'Pushing is refused: this session has no branch of its own to push.';
|
|
215
|
+
}
|
|
216
|
+
// A remote that is a URL or a path is somewhere the guard cannot vouch for.
|
|
217
|
+
if (!hasRepoFlag && positional[0] && /[:@/]/.test(positional[0])) {
|
|
218
|
+
return `Pushing to ${positional[0]} is refused: push to a named remote.`;
|
|
219
|
+
}
|
|
220
|
+
const refspecs = hasRepoFlag ? positional : positional.slice(1); // else the first positional is the remote
|
|
221
|
+
if (refspecs.length === 0) {
|
|
222
|
+
return head === branch ? null
|
|
223
|
+
: `A push with no refspec is refused unless HEAD is ${branch} (it is ${head || 'detached'}). Push ${branch} by name.`;
|
|
224
|
+
}
|
|
225
|
+
const own = new Set([
|
|
226
|
+
branch, 'HEAD', `HEAD:${branch}`, `${branch}:${branch}`, `HEAD:refs/heads/${branch}`,
|
|
227
|
+
`refs/heads/${branch}`, `refs/heads/${branch}:refs/heads/${branch}`,
|
|
228
|
+
]);
|
|
229
|
+
for (const spec of refspecs) {
|
|
230
|
+
if (spec.startsWith('+')) return `The refspec ${spec} forces the update; it is refused.`;
|
|
231
|
+
if (spec.startsWith(':')) return `The refspec ${spec} deletes a ref; it is refused.`;
|
|
232
|
+
if (own.has(spec)) {
|
|
233
|
+
if ((spec === 'HEAD' || spec.startsWith('HEAD:')) && head !== branch) {
|
|
234
|
+
return `HEAD is ${head || 'detached'}, not ${branch}: pushing it would put it on ${branch} or elsewhere. Check out ${branch} first.`;
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (allowTags) {
|
|
239
|
+
const name = spec.replace(/^refs\/tags\//, '');
|
|
240
|
+
const target = spec.includes(':') ? null : name;
|
|
241
|
+
if (target && (spec.startsWith('refs/tags/') || isTag?.(target))) continue;
|
|
242
|
+
}
|
|
243
|
+
return `Pushing ${spec} is refused: this session may push ${branch} and nothing else.`;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `null` when the command may run, otherwise the reason it may not.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} command
|
|
252
|
+
* @param {{ branch: string, defaultBranch?: string, allowTags?: boolean,
|
|
253
|
+
* head?: string, isTag?: (name: string) => boolean }} context
|
|
254
|
+
*/
|
|
255
|
+
export function judge(command, context) {
|
|
256
|
+
const ctx = { branch: '', defaultBranch: '', allowTags: false, head: '', isTag: () => false, ...context };
|
|
257
|
+
for (const segment of segmentsOf(command)) {
|
|
258
|
+
const words = withoutRedirects(stripPrefix(wordsOf(segment)));
|
|
259
|
+
if (!words.length) continue;
|
|
260
|
+
const first = basename(words[0]);
|
|
261
|
+
if (HOST_CLIS.has(first)) return HOST_CLI_REASON;
|
|
262
|
+
if (/^(bash|sh|zsh|dash|ksh)$/.test(first) || first === 'eval') {
|
|
263
|
+
// `bash -c "git push origin main"`: judge the script it was handed.
|
|
264
|
+
const at = first === 'eval' ? 0 : words.indexOf('-c');
|
|
265
|
+
const script = first === 'eval' ? words.slice(1).join(' ') : words[at + 1];
|
|
266
|
+
if (script) {
|
|
267
|
+
const inner = judge(script, ctx);
|
|
268
|
+
if (inner) return inner;
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (first !== 'git') continue;
|
|
273
|
+
const { sub, rest, globals } = gitSubcommand(words);
|
|
274
|
+
for (let i = 0; i < globals.length; i += 1) {
|
|
275
|
+
if (globals[i] === '-c' && /^(alias\.|core\.hookspath=|core\.sshcommand=|remote\.[^=]*\.(push)?url=|url\.)/i.test(globals[i + 1] ?? '')) {
|
|
276
|
+
return 'git -c alias.*, core.hooksPath, core.sshCommand and remote/url rewrites are refused: they run commands or send the push somewhere the guard cannot read.';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (sub === 'config' && rest.some((w) => /^alias\./i.test(w))) {
|
|
280
|
+
return 'git config alias.* is refused: an alias can run any command.';
|
|
281
|
+
}
|
|
282
|
+
if (sub === 'push') {
|
|
283
|
+
const reason = judgePush(rest, ctx);
|
|
284
|
+
if (reason) return reason;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- the hook -------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function git(cwd, args) {
|
|
293
|
+
const done = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
294
|
+
return { code: done.status, out: (done.stdout ?? '').trim() };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function readStdin() {
|
|
298
|
+
let text = '';
|
|
299
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
300
|
+
return text;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function main() {
|
|
304
|
+
let input;
|
|
305
|
+
try {
|
|
306
|
+
input = JSON.parse(await readStdin());
|
|
307
|
+
} catch {
|
|
308
|
+
process.stderr.write('push-guard: could not read the hook input; allowing\n');
|
|
309
|
+
process.exit(0); // not a hook call; never block on a parse failure
|
|
310
|
+
}
|
|
311
|
+
if (input?.tool_name !== 'Bash') {
|
|
312
|
+
process.exit(0);
|
|
313
|
+
}
|
|
314
|
+
const cwd = input.cwd || process.cwd();
|
|
315
|
+
const command = input.tool_input?.command ?? '';
|
|
316
|
+
const reason = judge(command, {
|
|
317
|
+
branch: process.env.CAWDEV_PUSH_BRANCH ?? '',
|
|
318
|
+
defaultBranch: process.env.CAWDEV_DEFAULT_BRANCH ?? '',
|
|
319
|
+
allowTags: process.env.CAWDEV_PUSH_TAGS === '1',
|
|
320
|
+
// Only a push cares where HEAD is; every other command skips the git call.
|
|
321
|
+
head: /\bpush\b/.test(command) ? git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).out : '',
|
|
322
|
+
isTag: (name) => git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${name}`]).code === 0,
|
|
323
|
+
});
|
|
324
|
+
if (reason) {
|
|
325
|
+
process.stderr.write(`${reason}\n`);
|
|
326
|
+
process.exit(2);
|
|
327
|
+
}
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (process.argv[1]?.endsWith('push-guard.mjs')) {
|
|
332
|
+
main();
|
|
333
|
+
}
|