vibecoding-devtools 1.0.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/LICENSE +21 -0
- package/README.md +46 -0
- package/hooks/full-suite.mjs +156 -0
- package/hooks/lib/io.mjs +135 -0
- package/hooks/maestri-protocol.mjs +48 -0
- package/hooks/secret-scan.mjs +90 -0
- package/installer/cli.mjs +263 -0
- package/installer/clis.mjs +46 -0
- package/installer/convert.mjs +91 -0
- package/installer/detect.mjs +389 -0
- package/installer/execute.mjs +386 -0
- package/installer/hooks.mjs +67 -0
- package/installer/json-merge.mjs +211 -0
- package/installer/panel.mjs +99 -0
- package/installer/plan.mjs +255 -0
- package/installer/state.mjs +105 -0
- package/installer/third-party.json +16 -0
- package/installer/third-party.mjs +204 -0
- package/npm-shrinkwrap.json +80 -0
- package/package.json +43 -0
- package/rules/base.md +25 -0
- package/rules/context-on-demand.md +25 -0
- package/rules/maestri-messaging.md +10 -0
- package/rules/maestri-usage.md +12 -0
- package/rules/model-tiers.md +17 -0
- package/rules/risk-process.md +69 -0
- package/rules/testing.md +53 -0
- package/skills/maestri-agent-cli/SKILL.md +150 -0
- package/skills/maestri-agent-cli/references/_template.md +62 -0
- package/skills/maestri-agent-cli/references/antigravity.md +206 -0
- package/skills/maestri-agent-cli/references/claude-code.md +203 -0
- package/skills/maestri-agent-cli/references/codex.md +190 -0
- package/skills/maestri-budget/SKILL.md +118 -0
- package/skills/maestri-protocol/SKILL.md +72 -0
- package/skills/risk-triage/SKILL.md +44 -0
- package/skills/security-surfaces/SKILL.md +31 -0
- package/skills/security-surfaces/surfaces/accounts-and-data.md +41 -0
- package/skills/security-surfaces/surfaces/inputs-and-services.md +47 -0
- package/skills/security-surfaces/surfaces/money.md +57 -0
- package/skills/security-surfaces/surfaces/secrets-and-infra.md +43 -0
- package/templates/AGENTS.md +68 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/coderabbit.yaml +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonne Martins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# vibecoding-devtools
|
|
2
|
+
|
|
3
|
+
Kit de rules, skills, hooks e modelos para desenvolvimento com IA em Claude Code, Codex e Antigravity. O esforço
|
|
4
|
+
acompanha o risco: trocar um ícone leva minutos; mexer com dinheiro, contas ou segredos recebe contrato, testing e
|
|
5
|
+
revisões.
|
|
6
|
+
|
|
7
|
+
O kit integra o [ECC](https://github.com/affaan-m/ECC) e o [Impeccable](https://github.com/pbakaus/impeccable),
|
|
8
|
+
instalados das fontes oficiais.
|
|
9
|
+
|
|
10
|
+
## Instalação
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx vibecoding-devtools@1.0.1 install # instala pelo painel
|
|
14
|
+
npx vibecoding-devtools@1.0.1 update # reutiliza as escolhas salvas
|
|
15
|
+
npx vibecoding-devtools@1.0.1 uninstall # remove só o que o kit instalou
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Alternativa pelo GitHub: `npx github:Or4cu1o/vibecoding-devtools#v1.0.1 install` (aceita os mesmos comandos).
|
|
19
|
+
|
|
20
|
+
Requer Node 22+ e git. O instalador detecta Claude Code, Codex e Antigravity, mostra o plano e só grava depois da
|
|
21
|
+
confirmação. Use `--dry-run` para apenas conferir o plano. Detalhes no [guia do instalador](docs/installer.md).
|
|
22
|
+
|
|
23
|
+
## Conteúdo
|
|
24
|
+
|
|
25
|
+
| Pasta | O que tem |
|
|
26
|
+
|---|---|
|
|
27
|
+
| [`rules/`](rules/) | regras de risco, testes, modelos, contexto e uso do Maestri |
|
|
28
|
+
| [`skills/`](skills/) | triagem de risco, revisão de segurança e operação do Maestri |
|
|
29
|
+
| [`templates/`](templates/) | `AGENTS.md` e `CLAUDE.md` para projetos, e `.coderabbit.yaml` |
|
|
30
|
+
| [Hooks](hooks/README.md) | `secret-scan`, `maestri-protocol` e `full-suite` implementados |
|
|
31
|
+
| [`docs/`](docs/) | instalação, compatibilidade e justificativas das regras |
|
|
32
|
+
|
|
33
|
+
O `secret-scan` verifica segredos com gitleaks antes de commits. O `maestri-protocol` valida mensagens e exige o pacote
|
|
34
|
+
Maestri. O `full-suite` aplica um bloqueio contornável a suítes completas em repositórios com CI; para uma execução
|
|
35
|
+
intencional, inclua `VIBECODING_FULL_SUITE=1` no comando.
|
|
36
|
+
|
|
37
|
+
No escopo de projeto, os hooks são copiados para `.vibecoding-devtools/hooks/` dentro do repositório e registrados nos
|
|
38
|
+
arquivos de projeto de cada CLI. Versione esses arquivos para toda a equipe receber os hooks. No Codex, cada pessoa
|
|
39
|
+
os aprova uma vez em `/hooks`; mudanças nos hooks exigem nova aprovação.
|
|
40
|
+
|
|
41
|
+
Consulte a [compatibilidade entre as CLIs](docs/cli-compatibility.md) e as
|
|
42
|
+
[justificativas das regras](docs/rules-rationale.md).
|
|
43
|
+
|
|
44
|
+
## Licença
|
|
45
|
+
|
|
46
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { statSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve, dirname, win32 } from 'node:path';
|
|
3
|
+
import { executable, isMain, runHook, shellCommands, windowsInput } from './lib/io.mjs';
|
|
4
|
+
|
|
5
|
+
export const FULL_SUITE_REASON = 'CI runs the full suite; run only the tests for what you changed. If the full run is intended, repeat the command with VIBECODING_FULL_SUITE=1 in it.';
|
|
6
|
+
|
|
7
|
+
// Only known non-filtering options are accepted. Unknown options may select tests.
|
|
8
|
+
function optionsOnly(args, booleans = [], values = []) {
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (arg === '--') return i === args.length - 1;
|
|
12
|
+
if (booleans.includes(arg)) continue;
|
|
13
|
+
if (values.includes(arg)) {
|
|
14
|
+
if (!args[++i] || args[i].startsWith('-')) return false;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (values.some(flag => arg.startsWith(`${flag}=`) && arg.length > flag.length + 1)) continue;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isFullSuite(words) {
|
|
24
|
+
words = [...words];
|
|
25
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(words[0] ?? '')) words.shift();
|
|
26
|
+
if (executable(words[0]) === 'env') {
|
|
27
|
+
words.shift();
|
|
28
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(words[0] ?? '')) words.shift();
|
|
29
|
+
}
|
|
30
|
+
const tool = executable(words.shift());
|
|
31
|
+
if (tool === 'node' && words[0] === '--test') {
|
|
32
|
+
return optionsOnly(words.slice(1), [], ['--test-reporter']);
|
|
33
|
+
}
|
|
34
|
+
if (tool === 'pytest' || tool === 'py.test') {
|
|
35
|
+
return optionsOnly(words, ['-q', '-qq', '-v', '-vv', '-s', '-x', '--verbose', '--quiet', '--exitfirst', '--disable-warnings', '--no-header', '--no-summary'], ['--maxfail', '--tb', '--color']);
|
|
36
|
+
}
|
|
37
|
+
if (['python', 'python3', 'py'].includes(tool) && words[0] === '-m' && words[1] === 'pytest') {
|
|
38
|
+
return isFullSuite(words.slice(1));
|
|
39
|
+
}
|
|
40
|
+
if (['npm', 'pnpm', 'yarn'].includes(tool)) {
|
|
41
|
+
if (words[0] === 'run') words.shift();
|
|
42
|
+
if (words.shift() !== 'test') return false;
|
|
43
|
+
if (words[0] === '--') words.shift();
|
|
44
|
+
return optionsOnly(words, ['--silent', '--verbose', '--runInBand', '--ci', '--coverage', '--watch=false', '--watchAll=false']);
|
|
45
|
+
}
|
|
46
|
+
if (tool === 'npx') return ['vitest', 'jest'].includes(executable(words[0])) && isFullSuite(words);
|
|
47
|
+
if (tool === 'vitest' || tool === 'jest') {
|
|
48
|
+
if (tool === 'vitest' && words.shift() === 'run') {
|
|
49
|
+
return optionsOnly(words, ['--coverage', '--silent', '--no-color'], ['--reporter']);
|
|
50
|
+
}
|
|
51
|
+
if (tool === 'jest') {
|
|
52
|
+
return optionsOnly(words, ['--runInBand', '--ci', '--coverage', '--silent', '--verbose', '--watch=false', '--watchAll=false', '--no-cache'], ['--maxWorkers']);
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (tool === 'go' && words.shift() === 'test') {
|
|
57
|
+
if (words.filter(word => word === './...').length !== 1) return false;
|
|
58
|
+
return optionsOnly(words.filter(word => word !== './...'), ['-v', '-race', '-cover', '-short', '-count=1'], ['-count', '-timeout', '-parallel']);
|
|
59
|
+
}
|
|
60
|
+
if (tool === 'cargo' && words.shift() === 'test') {
|
|
61
|
+
return optionsOnly(words, ['--workspace', '--all', '--all-targets', '--all-features', '--no-default-features', '--release', '--locked', '--offline', '--quiet', '-q', '--verbose', '-v']);
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function exists(path, kind, fs) {
|
|
67
|
+
try { return fs.statSync(path)[kind](); }
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code)) return false;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pathApi = cwd => win32.isAbsolute(cwd) && !cwd.startsWith('/') ? win32 : { resolve, dirname };
|
|
75
|
+
|
|
76
|
+
function repositoryRoot(cwd, fs) {
|
|
77
|
+
const paths = pathApi(cwd);
|
|
78
|
+
if (!exists(cwd, 'isDirectory', fs)) return false;
|
|
79
|
+
let directory = paths.resolve(cwd);
|
|
80
|
+
while (true) {
|
|
81
|
+
if (exists(paths.resolve(directory, '.git'), 'isDirectory', fs)
|
|
82
|
+
|| exists(paths.resolve(directory, '.git'), 'isFile', fs)) return directory;
|
|
83
|
+
const parent = paths.dirname(directory);
|
|
84
|
+
if (parent === directory) return null;
|
|
85
|
+
directory = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasCI(directory, fs) {
|
|
90
|
+
const paths = pathApi(directory);
|
|
91
|
+
const workflows = paths.resolve(directory, '.github', 'workflows');
|
|
92
|
+
if (exists(workflows, 'isDirectory', fs)
|
|
93
|
+
&& fs.readdirSync(workflows).some(file => /\.ya?ml$/i.test(file)
|
|
94
|
+
&& exists(paths.resolve(workflows, file), 'isFile', fs))) return true;
|
|
95
|
+
if (['.gitlab-ci.yml', 'azure-pipelines.yml'].some(file => exists(paths.resolve(directory, file), 'isFile', fs))
|
|
96
|
+
|| exists(paths.resolve(directory, '.circleci'), 'isDirectory', fs)) return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function packageSuite(words, cwd, windows, fs) {
|
|
101
|
+
// Scripts are data, never executed. Wrappers, chained scripts and lifecycle
|
|
102
|
+
// scripts leave scope uncertain, so only one supported literal runner denies.
|
|
103
|
+
const packageJson = JSON.parse(fs.readFileSync(pathApi(cwd).resolve(cwd, 'package.json'), 'utf8'));
|
|
104
|
+
const scripts = packageJson?.scripts;
|
|
105
|
+
if (typeof scripts?.test !== 'string' || scripts.pretest || scripts.posttest) return false;
|
|
106
|
+
const commands = shellCommands(scripts.test, { windows });
|
|
107
|
+
if (commands?.length !== 1) return false;
|
|
108
|
+
const script = commands[0];
|
|
109
|
+
const runner = executable(script[0]);
|
|
110
|
+
if (['npm', 'pnpm', 'yarn', 'env'].includes(runner) || /^[A-Za-z_][A-Za-z_0-9]*=/.test(script[0])) return false;
|
|
111
|
+
let args = words.slice(1);
|
|
112
|
+
if (args[0] === 'run') args.shift();
|
|
113
|
+
args.shift(); // test
|
|
114
|
+
if (args[0] === '--') args.shift();
|
|
115
|
+
return isFullSuite([...script, ...args]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function checkFullSuite(input, { fs = { statSync, readdirSync, readFileSync } } = {}) {
|
|
119
|
+
try {
|
|
120
|
+
if (typeof input.command !== 'string') return;
|
|
121
|
+
if (typeof input.cwd !== 'string' || !(isAbsolute(input.cwd) || win32.isAbsolute(input.cwd))) return;
|
|
122
|
+
if (/^\\(?!\\)/.test(input.cwd)
|
|
123
|
+
|| (/^\\\\/.test(input.cwd) && !/^\\\\[^\\]+\\[^\\]+(?:\\|$)/.test(input.cwd))) return;
|
|
124
|
+
const windows = windowsInput(input);
|
|
125
|
+
// Normalize only literal PowerShell override assignments. Other expansion
|
|
126
|
+
// remains unsupported and fails open in the shared parser.
|
|
127
|
+
const literal = input.command.replace(/(^|[;\n])\s*\$env:VIBECODING_FULL_SUITE\s*=\s*(?:'([0-9]+)'|"([0-9]+)"|([0-9]+))(?=\s*(?:[;\n]|$))/g,
|
|
128
|
+
(_, boundary, single, double, bare) => `${boundary} VIBECODING_FULL_SUITE=${single ?? double ?? bare}`);
|
|
129
|
+
const commands = shellCommands(literal, { windows });
|
|
130
|
+
if (!commands) return;
|
|
131
|
+
for (const words of commands) {
|
|
132
|
+
if (executable(words[0]) === 'set' && words.length === 2 && /^VIBECODING_FULL_SUITE=[0-9]+$/.test(words[1])) words.shift();
|
|
133
|
+
}
|
|
134
|
+
if (commands.some(words => words.includes('VIBECODING_FULL_SUITE=1'))) return;
|
|
135
|
+
if (commands.some(words => ['cd', 'chdir', 'set-location', 'push-location', 'pop-location', 'sl', 'pushd', 'popd', 'export', 'set', 'unset', 'source', '.']
|
|
136
|
+
.includes(executable(words[0])))) return;
|
|
137
|
+
const candidates = commands.filter(isFullSuite);
|
|
138
|
+
if (!candidates.length) return;
|
|
139
|
+
const root = repositoryRoot(input.cwd, fs);
|
|
140
|
+
// A runner below the repository root may cover only one monorepo package.
|
|
141
|
+
if (!root || root !== pathApi(input.cwd).resolve(input.cwd) || !hasCI(root, fs)) return;
|
|
142
|
+
for (const words of candidates) {
|
|
143
|
+
// Strip literal environment prefixes consistently before inspecting scripts.
|
|
144
|
+
const invocation = [...words];
|
|
145
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(invocation[0] ?? '')) invocation.shift();
|
|
146
|
+
if (executable(invocation[0]) === 'env') {
|
|
147
|
+
invocation.shift();
|
|
148
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(invocation[0] ?? '')) invocation.shift();
|
|
149
|
+
}
|
|
150
|
+
if (!['npm', 'pnpm', 'yarn'].includes(executable(invocation[0]))
|
|
151
|
+
|| packageSuite(invocation, input.cwd, windows, fs)) return FULL_SUITE_REASON;
|
|
152
|
+
}
|
|
153
|
+
} catch { /* Uncertain commands or inaccessible repository metadata allow. */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isMain(import.meta.url)) process.exitCode = await runHook(checkFullSuite);
|
package/hooks/lib/io.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { resolve, isAbsolute, win32 } from 'node:path';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export function parseInput(text) {
|
|
6
|
+
try {
|
|
7
|
+
const input = JSON.parse(text);
|
|
8
|
+
if (typeof input?.toolCall?.name === 'string') {
|
|
9
|
+
const args = input.toolCall.args;
|
|
10
|
+
// 1.5.2 omits Cwd when using the sole workspace as the default directory.
|
|
11
|
+
// Multiple workspaces or invalid paths do not establish a safe default.
|
|
12
|
+
const workspace = Array.isArray(input.workspacePaths) && input.workspacePaths.length === 1
|
|
13
|
+
? input.workspacePaths[0] : undefined;
|
|
14
|
+
const defaultCwd = typeof workspace === 'string' && (isAbsolute(workspace) || win32.isAbsolute(workspace))
|
|
15
|
+
? workspace : undefined;
|
|
16
|
+
return { cli: 'antigravity', command: input.toolCall.name === 'run_command'
|
|
17
|
+
&& typeof args?.CommandLine === 'string' ? args.CommandLine : undefined,
|
|
18
|
+
cwd: args?.Cwd === undefined ? defaultCwd : args.Cwd, shell: args?.Shell ?? input.shell };
|
|
19
|
+
}
|
|
20
|
+
if (['Bash', 'PowerShell'].includes(input?.tool_name)
|
|
21
|
+
&& typeof input?.tool_input?.command === 'string') {
|
|
22
|
+
// turn_id is a Codex-specific PreToolUse field; Claude also reports Bash.
|
|
23
|
+
return { cli: typeof input.turn_id === 'string' ? 'codex' : 'claude-codex',
|
|
24
|
+
command: input.tool_input.command, cwd: input.cwd,
|
|
25
|
+
shell: input.tool_name === 'PowerShell' ? 'PowerShell' : input.shell };
|
|
26
|
+
}
|
|
27
|
+
} catch { /* Invalid JSON is an uncertain input, so allow it. */ }
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function respond(input, reason, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
32
|
+
if (input?.cli === 'antigravity') {
|
|
33
|
+
stdout.write(`${JSON.stringify(reason ? { decision: 'deny', reason } : { decision: 'allow' })}\n`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
if (input && reason) {
|
|
37
|
+
if (input.cli === 'codex') {
|
|
38
|
+
// PowerShell -Command maps a native exit 2 to exit 1. JSON denial with
|
|
39
|
+
// success survives that wrapper and is supported by Codex PreToolUse.
|
|
40
|
+
stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse',
|
|
41
|
+
permissionDecision: 'deny', permissionDecisionReason: reason } })}\n`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
stderr.write(`${reason}\n`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runHook(check, {
|
|
51
|
+
stdin = process.stdin, stdout = process.stdout, stderr = process.stderr,
|
|
52
|
+
} = {}) {
|
|
53
|
+
let input = null;
|
|
54
|
+
let reason;
|
|
55
|
+
try {
|
|
56
|
+
let text = '';
|
|
57
|
+
for await (const chunk of stdin) text += chunk;
|
|
58
|
+
input = parseInput(text);
|
|
59
|
+
if (typeof input?.command === 'string') reason = await check(input, { stderr });
|
|
60
|
+
} catch { /* A hook must never deny because of its own failure. */ }
|
|
61
|
+
return respond(input, reason, { stdout, stderr });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isMain(url) {
|
|
65
|
+
if (!process.argv[1]) return false;
|
|
66
|
+
let entryPath = resolve(process.argv[1]);
|
|
67
|
+
try { entryPath = realpathSync(entryPath); } catch { /* Keep the resolved path if it cannot be read. */ }
|
|
68
|
+
return pathToFileURL(entryPath).href === url;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function executable(token) {
|
|
72
|
+
return token?.split(/[\\/]/).at(-1).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function windowsInput(input) {
|
|
76
|
+
return process.platform === 'win32'
|
|
77
|
+
|| /^(powershell|pwsh|cmd)(?:\.exe)?$/i.test(executable(input.shell))
|
|
78
|
+
|| /^(?:[a-z]:|\\\\)/i.test(input.cwd ?? '')
|
|
79
|
+
|| /(?:[a-z]:\\|\\\\[^\\\s]+\\)/i.test(input.command ?? '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Deliberately parse only literal shell words. No expansion or shell execution.
|
|
83
|
+
// Unsupported syntax returns null, making hooks fail open.
|
|
84
|
+
export function shellCommands(command, { windows = process.platform === 'win32' } = {}) {
|
|
85
|
+
const commands = [];
|
|
86
|
+
let words = [];
|
|
87
|
+
let word = '';
|
|
88
|
+
let started = false;
|
|
89
|
+
let quote = null;
|
|
90
|
+
const pushWord = () => {
|
|
91
|
+
if (started) words.push(word);
|
|
92
|
+
word = '';
|
|
93
|
+
started = false;
|
|
94
|
+
};
|
|
95
|
+
const pushCommand = () => {
|
|
96
|
+
pushWord();
|
|
97
|
+
if (words.length) commands.push(words);
|
|
98
|
+
words = [];
|
|
99
|
+
};
|
|
100
|
+
for (let i = 0; i < command.length; i++) {
|
|
101
|
+
const char = command[i];
|
|
102
|
+
if (quote) {
|
|
103
|
+
if (char === quote) {
|
|
104
|
+
// Doubled single quotes are ambiguous between PowerShell and Bash.
|
|
105
|
+
if (quote === "'" && command[i + 1] === "'") return null;
|
|
106
|
+
quote = null;
|
|
107
|
+
} else if (quote === '"' && /[$`%]/.test(char)) return null;
|
|
108
|
+
else if (!windows && quote === '"' && char === '\\' && /["\\$`]/.test(command[i + 1] ?? '')) {
|
|
109
|
+
word += command[++i];
|
|
110
|
+
} else word += char;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (/[$`%(){}<>^]/.test(char) || (char === '@' && /['"]/.test(command[i + 1] ?? ''))) return null;
|
|
114
|
+
if (char === '"' || char === "'") {
|
|
115
|
+
quote = char;
|
|
116
|
+
started = true;
|
|
117
|
+
} else if (!windows && char === '\\') {
|
|
118
|
+
if (i + 1 === command.length) return null;
|
|
119
|
+
const next = command[i + 1];
|
|
120
|
+
if (next === '\n') i++;
|
|
121
|
+
else if (/[\s'"\\;&|]/.test(next)) { word += next; i++; started = true; }
|
|
122
|
+
else { word += char; started = true; }
|
|
123
|
+
} else if (/[;&|\n]/.test(char)) pushCommand();
|
|
124
|
+
else if (/\s/.test(char)) pushWord();
|
|
125
|
+
else if (char === '#' && !started) {
|
|
126
|
+
while (i < command.length && command[i] !== '\n') i++;
|
|
127
|
+
pushCommand();
|
|
128
|
+
} else { word += char; started = true; }
|
|
129
|
+
}
|
|
130
|
+
if (quote) return null;
|
|
131
|
+
pushCommand();
|
|
132
|
+
// Literal command lists do not model shell blocks or conditional execution.
|
|
133
|
+
if (commands.some(words => /^(?:if|then|else|elif|fi|for|foreach|while|do|done|until|switch|case|esac|function|filter|try|catch|finally|begin|process|end|trap|class)$/i.test(words[0]))) return null;
|
|
134
|
+
return commands;
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { executable, isMain, runHook, shellCommands, windowsInput } from './lib/io.mjs';
|
|
2
|
+
|
|
3
|
+
const header = /^~ (?<name>[^|\n]+?) \| (?<status>DONE|PENDING|BLOCKED|ERROR|ACK|ASK) \| (?<ref>[^|\n]+?) \| (?<summary>[^\n]+)$/;
|
|
4
|
+
const reason = 'Maestri message must use a valid protocol header or end with the delegation footer.';
|
|
5
|
+
|
|
6
|
+
export function validMessage(message) {
|
|
7
|
+
const text = message.replace(/\r\n/g, '\n');
|
|
8
|
+
if (text.startsWith('~')) return header.test(text.split('\n')[0]);
|
|
9
|
+
// Match the skill footer at the end, including its report-back instruction.
|
|
10
|
+
const footer = text.match(/(?:^|\n)ref: ([^\n]+)\nWhen done, load the maestri-protocol skill and report back with:\nmaestri ask "([^"\n]+)" "~ ([^|\n]+?) \| STATUS \| ([^|\n]+?) \| one-line summary"\nSTATUS is one of DONE, PENDING, BLOCKED, ERROR, ACK\. Details go on the following lines\.\s*$/);
|
|
11
|
+
return Boolean(footer && footer[1] === footer[4]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function messages(words) {
|
|
15
|
+
if (words.includes('--raw')) return null;
|
|
16
|
+
if (words[0] === '--batch' && words.length === 2) {
|
|
17
|
+
try {
|
|
18
|
+
const batch = JSON.parse(words[1]);
|
|
19
|
+
if (!batch || typeof batch !== 'object' || Array.isArray(batch)) return null;
|
|
20
|
+
const values = Object.values(batch);
|
|
21
|
+
return values.every(value => typeof value === 'string') ? values : null;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
// Unknown flags or arity are uncertain; do not guess which word is the message.
|
|
25
|
+
if (words.length === 2 && words.every(word => !word.startsWith('--'))) return [decodeMessage(words[1])];
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeMessage(message) {
|
|
30
|
+
return message.replace(/\\(n|t|e|x[0-9a-fA-F]{2})/g, (_, escape) => {
|
|
31
|
+
if (escape === 'n') return '\n';
|
|
32
|
+
if (escape === 't') return '\t';
|
|
33
|
+
if (escape === 'e') return '\x1b';
|
|
34
|
+
return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkMaestriProtocol(input) {
|
|
39
|
+
const commands = shellCommands(input.command, { windows: windowsInput(input) });
|
|
40
|
+
if (!commands) return;
|
|
41
|
+
for (const words of commands) {
|
|
42
|
+
if (executable(words[0]) !== 'maestri' || words[1] !== 'ask') continue;
|
|
43
|
+
const extracted = messages(words.slice(2));
|
|
44
|
+
if (extracted?.some(message => !validMessage(message))) return reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isMain(import.meta.url)) process.exitCode = await runHook(checkMaestriProtocol);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { isAbsolute, resolve, win32 } from 'node:path';
|
|
3
|
+
import { executable, isMain, runHook, shellCommands, windowsInput } from './lib/io.mjs';
|
|
4
|
+
|
|
5
|
+
const scanFlags = ['--redact', '--no-banner', '--exit-code', '99'];
|
|
6
|
+
export const GITLEAKS_ARGS = ['git', '--pre-commit', '--staged', ...scanFlags];
|
|
7
|
+
const leakReason = 'Commit blocked: changes that can enter the commit contain secrets. Remove them before committing.';
|
|
8
|
+
const warning = 'Secret scan unavailable; allowing command.\n';
|
|
9
|
+
|
|
10
|
+
function commitDirectory(words, cwd, windows) {
|
|
11
|
+
if (executable(words[0]) !== 'git') return null;
|
|
12
|
+
let directory = cwd;
|
|
13
|
+
for (let i = 1; i < words.length; i++) {
|
|
14
|
+
const arg = words[i];
|
|
15
|
+
if (arg === 'commit') return directory;
|
|
16
|
+
if (arg === '-C' || arg.startsWith('-C')) {
|
|
17
|
+
const value = arg === '-C' ? words[++i] : arg.slice(2);
|
|
18
|
+
if (value === undefined) return null;
|
|
19
|
+
// A drive-relative or incomplete UNC path cannot be resolved safely from
|
|
20
|
+
// the supplied cwd. Never let Node fill in another drive's process cwd.
|
|
21
|
+
if (windows && (/^[a-z]:(?![\\/])/i.test(value)
|
|
22
|
+
|| (/^\\\\/.test(value) && !/^\\\\[^\\]+\\[^\\]+(?:\\|$)/.test(value))
|
|
23
|
+
|| (/^\\(?!\\)/.test(value) && !/^[a-z]:/i.test(directory)))) return null;
|
|
24
|
+
directory = /^(?:[a-z]:[\\/]|\\\\)/i.test(directory) || /^(?:[a-z]:[\\/]|\\\\)/i.test(value)
|
|
25
|
+
? win32.resolve(directory, value) : resolve(directory, value);
|
|
26
|
+
} else if (arg === '-c' || arg === '--config-env') {
|
|
27
|
+
if (words[++i] === undefined) return null;
|
|
28
|
+
} else if (arg.startsWith('-c') || arg.startsWith('--config-env=')
|
|
29
|
+
|| ['--no-pager', '--paginate', '--literal-pathspecs', '--no-optional-locks', '--bare'].includes(arg)) {
|
|
30
|
+
continue;
|
|
31
|
+
} else return null;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkSecretScan(input, { runner = spawnSync, stderr = process.stderr } = {}) {
|
|
37
|
+
const windows = windowsInput(input);
|
|
38
|
+
const commands = shellCommands(input.command, { windows });
|
|
39
|
+
if (!commands) return;
|
|
40
|
+
// Earlier commands can change repository selection. Without evaluating their
|
|
41
|
+
// control flow, the effective cwd/environment cannot be established reliably.
|
|
42
|
+
if (commands.some(words => ['cd', 'chdir', 'set-location', 'pushd', 'popd', 'export', 'set', 'unset']
|
|
43
|
+
.includes(executable(words[0])))) return;
|
|
44
|
+
if (typeof input.cwd !== 'string' || !(isAbsolute(input.cwd) || win32.isAbsolute(input.cwd))) return;
|
|
45
|
+
if (windows && (/^\\(?!\\)/.test(input.cwd)
|
|
46
|
+
|| (/^\\\\/.test(input.cwd) && !/^\\\\[^\\]+\\[^\\]+(?:\\|$)/.test(input.cwd)))) return;
|
|
47
|
+
let warned = false;
|
|
48
|
+
const warn = () => {
|
|
49
|
+
if (!warned) {
|
|
50
|
+
stderr.write(warning);
|
|
51
|
+
warned = true;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const run = (command, args, cwd) => {
|
|
55
|
+
try {
|
|
56
|
+
return runner(command, args, {
|
|
57
|
+
cwd, encoding: 'utf8', stdio: 'pipe', timeout: 5000, windowsHide: true,
|
|
58
|
+
});
|
|
59
|
+
} catch { return undefined; }
|
|
60
|
+
};
|
|
61
|
+
const scan = (args, cwd) => {
|
|
62
|
+
const result = run('gitleaks', args, cwd);
|
|
63
|
+
if (!result?.error && !result?.signal && result?.status === 99) return true;
|
|
64
|
+
if (result?.error || result?.signal || result?.status !== 0) warn();
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
for (const words of commands) {
|
|
68
|
+
const cwd = commitDirectory(words, input.cwd, windows);
|
|
69
|
+
if (!cwd) continue;
|
|
70
|
+
if (scan([...GITLEAKS_ARGS], cwd)) return leakReason;
|
|
71
|
+
if (scan(['git', '--pre-commit', ...scanFlags], cwd)) return leakReason;
|
|
72
|
+
const listing = run('git', ['ls-files', '--others', '--exclude-standard', '-z'], cwd);
|
|
73
|
+
if (listing?.error || listing?.signal || listing?.status !== 0 || typeof listing?.stdout !== 'string') {
|
|
74
|
+
warn();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// NUL delimiters preserve spaces, quotes and newlines in Git filenames.
|
|
78
|
+
if (listing.stdout && !listing.stdout.endsWith('\0')) { warn(); continue; }
|
|
79
|
+
for (const file of listing.stdout.split('\0').filter(Boolean)) {
|
|
80
|
+
const paths = win32.isAbsolute(cwd) && !cwd.startsWith('/') ? win32 : { resolve, isAbsolute };
|
|
81
|
+
const target = paths.resolve(cwd, file);
|
|
82
|
+
// Git lists relative files. Reject unexpected absolute/traversal output.
|
|
83
|
+
if (paths.isAbsolute(file) || /^[a-z]:/i.test(file)
|
|
84
|
+
|| file.split(/[\\/]/).includes('..')) { warn(); continue; }
|
|
85
|
+
if (scan(['dir', target, ...scanFlags], cwd)) return leakReason;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isMain(import.meta.url)) process.exitCode = await runHook(checkSecretScan);
|