@devrik-tools/claude-gates 0.1.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/.claude-plugin/marketplace.json +17 -0
- package/README.es.md +219 -0
- package/README.md +222 -0
- package/cli/__tests__/config.test.mjs +101 -0
- package/cli/__tests__/init-flags.test.mjs +111 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
- package/cli/__tests__/selection.test.mjs +200 -0
- package/cli/config.mjs +113 -0
- package/cli/constants.mjs +36 -0
- package/cli/index.mjs +97 -0
- package/cli/init.mjs +256 -0
- package/cli/install.mjs +80 -0
- package/cli/materialize.mjs +51 -0
- package/cli/registry.mjs +127 -0
- package/cli/selection.mjs +83 -0
- package/package.json +73 -0
- package/plugins/gates/.claude-plugin/plugin.json +9 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
- package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
- package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
- package/plugins/gates/hooks/hooks.json +266 -0
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
- package/plugins/gates/hooks/lib/config.mjs +165 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
- package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
- package/plugins/tasks/hooks/register-requests.mjs +108 -0
- package/registry.json +668 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
3
|
+
|
|
4
|
+
const GATE_ID = 'audit-before-build';
|
|
5
|
+
const CONFIG_KEY = 'requireAuditBeforeBuilding';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXECUTABLE_EXTENSIONS = [
|
|
8
|
+
'.js',
|
|
9
|
+
'.mjs',
|
|
10
|
+
'.cjs',
|
|
11
|
+
'.ts',
|
|
12
|
+
'.py',
|
|
13
|
+
'.sh',
|
|
14
|
+
'.ps1',
|
|
15
|
+
];
|
|
16
|
+
const DEFAULT_TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/'];
|
|
17
|
+
|
|
18
|
+
const NEW_TOOL_INTENT_PATTERN =
|
|
19
|
+
/\b(create|write|build|add)\b[^.]{0,60}\b(script|verifier|checker|gate|hook|linter|tool)\b/i;
|
|
20
|
+
|
|
21
|
+
const AUDIT_EVIDENCE_PATTERN =
|
|
22
|
+
/already exists|no existing tool|no plugin|audited and|justification:|no existe una herramienta|no existe la herramienta|no hay plugin|ya existe|busque? si ya existe|verifiqu[eé] que no (hay|existe)|audit[eé] herramientas|justificacion:|justificaci[oó]n:/i;
|
|
23
|
+
|
|
24
|
+
const INLINE_JUSTIFICATION_PATTERN =
|
|
25
|
+
/justification:|no-reinvent|audit:|justificacion:|justificaci[oó]n:/i;
|
|
26
|
+
|
|
27
|
+
function checkDelegation(toolInput) {
|
|
28
|
+
const prompt = toolInput?.prompt;
|
|
29
|
+
if (typeof prompt !== 'string') return;
|
|
30
|
+
if (!NEW_TOOL_INTENT_PATTERN.test(prompt)) return;
|
|
31
|
+
if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
|
|
32
|
+
|
|
33
|
+
deny(
|
|
34
|
+
GATE_ID,
|
|
35
|
+
'Delegating creation of a new script/checker/gate/hook/linter/tool without evidence of a prior audit. State what you searched and why no existing tool covers this (e.g. "audited and no existing tool...").',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isNewToolFile(filePath, parameters) {
|
|
40
|
+
if (!filePath) return false;
|
|
41
|
+
const inToolFolder = parameters.toolFolders.some((folder) =>
|
|
42
|
+
filePath.includes(folder),
|
|
43
|
+
);
|
|
44
|
+
if (!inToolFolder) return false;
|
|
45
|
+
return parameters.executableExtensions.some((extension) =>
|
|
46
|
+
filePath.endsWith(extension),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkWrite(toolInput, parameters) {
|
|
51
|
+
const rawPath = toolInput?.file_path ?? '';
|
|
52
|
+
const filePath = rawPath.replace(/\\/g, '/');
|
|
53
|
+
if (!isNewToolFile(filePath, parameters)) return;
|
|
54
|
+
|
|
55
|
+
// Editing an EXISTING file is not building a new tool — only creation needs the audit.
|
|
56
|
+
// A file already on disk (any tool that carries file_path) is an edit, so it is allowed.
|
|
57
|
+
// This is what makes maintaining the gates themselves possible with the gates active.
|
|
58
|
+
if (rawPath && existsSync(rawPath)) return;
|
|
59
|
+
|
|
60
|
+
const content = toolInput?.content ?? toolInput?.new_string;
|
|
61
|
+
if (typeof content !== 'string') return;
|
|
62
|
+
if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
|
|
63
|
+
|
|
64
|
+
deny(
|
|
65
|
+
GATE_ID,
|
|
66
|
+
`Creating a new executable tool at ${filePath} without a justification comment (e.g. "justification: ..."). Document why no existing tool covers this before building a new one.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
runGate(
|
|
71
|
+
{
|
|
72
|
+
id: GATE_ID,
|
|
73
|
+
configKey: CONFIG_KEY,
|
|
74
|
+
enabledByDefault: false,
|
|
75
|
+
defaultParams: {
|
|
76
|
+
executableExtensions: DEFAULT_EXECUTABLE_EXTENSIONS,
|
|
77
|
+
toolFolders: DEFAULT_TOOL_FOLDERS,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
({ toolName, toolInput, parameters }) => {
|
|
81
|
+
if (TOOL_GROUPS.delegation.includes(toolName)) {
|
|
82
|
+
checkDelegation(toolInput);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!TOOL_GROUPS.write.includes(toolName)) return;
|
|
86
|
+
checkWrite(toolInput, parameters);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'audit-before-build-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
+
});
|
|
26
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function delegate(prompt) {
|
|
30
|
+
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
31
|
+
}
|
|
32
|
+
function write(filePath, content) {
|
|
33
|
+
return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
|
|
34
|
+
}
|
|
35
|
+
function isDeny(result) {
|
|
36
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
|
|
40
|
+
|
|
41
|
+
test('denies delegation to create a new tool without audit evidence', () => {
|
|
42
|
+
assert.ok(
|
|
43
|
+
isDeny(runGate(delegate('create a new script that checks X'), ENABLE)),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('allows delegation with audit evidence stated', () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
runGate(
|
|
50
|
+
delegate(
|
|
51
|
+
'create a new script that checks X. audited and no existing tool covers this.',
|
|
52
|
+
),
|
|
53
|
+
ENABLE,
|
|
54
|
+
),
|
|
55
|
+
null,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('denies writing a new executable tool file without a justification comment', () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
runGate(
|
|
62
|
+
write('/repo/scripts/check-thing.mjs', 'export function run() {}'),
|
|
63
|
+
ENABLE,
|
|
64
|
+
)?.hookSpecificOutput?.permissionDecision,
|
|
65
|
+
'deny',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('allows writing a tool file with a justification comment', () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
runGate(
|
|
72
|
+
write(
|
|
73
|
+
'/repo/scripts/check-thing.mjs',
|
|
74
|
+
'// justification: no existing tool does this\nexport function run() {}',
|
|
75
|
+
),
|
|
76
|
+
ENABLE,
|
|
77
|
+
),
|
|
78
|
+
null,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('allows a write outside tool folders or non-executable extension', () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
runGate(write('/repo/docs/notes.md', 'plain notes'), ENABLE),
|
|
85
|
+
null,
|
|
86
|
+
);
|
|
87
|
+
assert.equal(runGate(write('/repo/scripts/data.json', '{}'), ENABLE), null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('disabled by default (registry default is false)', () => {
|
|
91
|
+
assert.equal(runGate(delegate('create a new script that checks X')), null);
|
|
92
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// bash-commands — denies destructive shell commands, and blocks publishing to a remote
|
|
2
|
+
// without fresh authorization. Runs on a real Bash/run_command call and on a delegation
|
|
3
|
+
// prompt (a subagent can be told "run git reset --hard" in prose).
|
|
4
|
+
//
|
|
5
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
6
|
+
// denyPatterns regex sources (matched case-insensitively) of destructive
|
|
7
|
+
// commands to deny. Replaces the built-in list below wholesale.
|
|
8
|
+
// rmRfProtectedAreas targets rm -rf may not hit; woven into the rm -rf pattern.
|
|
9
|
+
// embeddedInterpreterEnabled block `node -e`/`python -c` that does raw file ops. Off
|
|
10
|
+
// by default: this harness legitimately uses inline interpreters.
|
|
11
|
+
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
12
|
+
// its override replaces.
|
|
13
|
+
//
|
|
14
|
+
// ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
|
|
15
|
+
// Remote-publish detection (`git push`, `gh pr merge`, `gh release create`) is a hard
|
|
16
|
+
// block with no config knob and no in-command escape hatch: authorization is something
|
|
17
|
+
// the USER states in chat, never a token the agent could write into its own command.
|
|
18
|
+
//
|
|
19
|
+
// ── Two-stage intent check, only for delegation prompts ─────────────────────────────
|
|
20
|
+
// A real command's `command` field IS what the shell runs — a single-stage regex is
|
|
21
|
+
// right. A delegation prompt is natural language that may DESCRIBE a command ("I extended
|
|
22
|
+
// the guard to deny git push") without asking anyone to run it. There, publish rules run
|
|
23
|
+
// a second stage: strip quoted/example text, then require that at least one surviving
|
|
24
|
+
// mention is not governed by a reporting verb before denying.
|
|
25
|
+
|
|
26
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
27
|
+
|
|
28
|
+
const GATE_ID = 'bash-commands';
|
|
29
|
+
const CONFIG_KEY = 'blockDestructiveShellCommands';
|
|
30
|
+
|
|
31
|
+
const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
|
|
32
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
33
|
+
|
|
34
|
+
// Areas rm -rf must never target. A project overrides this list in config; the rm -rf
|
|
35
|
+
// deny pattern is rebuilt from it at runtime (see rmRfSourceFrom), so an edit takes effect.
|
|
36
|
+
const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
|
|
37
|
+
|
|
38
|
+
// Process-killing command names, assembled from fragments so the spell checker does not
|
|
39
|
+
// read them as prose (the project keeps an empty dictionary by policy).
|
|
40
|
+
const KILL_BY_NAME_COMMANDS = ['task' + 'kill', 'p' + 'kill', 'kill' + 'all'];
|
|
41
|
+
|
|
42
|
+
// The rm -rf deny source, built from the protected-areas list. Separate from the static
|
|
43
|
+
// deny list so a project can edit rmRfProtectedAreas in config and have it take effect at
|
|
44
|
+
// runtime: the list is re-read on every call, not baked in at load time.
|
|
45
|
+
function rmRfSourceFrom(protectedAreas) {
|
|
46
|
+
const rmRfTargets = protectedAreas
|
|
47
|
+
.map((area) => area.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
48
|
+
.join('|');
|
|
49
|
+
return String.raw`\brm\s+-rf\s+(${rmRfTargets})\b`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Static destructive-command rules as `[regexSource, reason]`. Sources (not RegExp) so
|
|
54
|
+
* they are JSON-serializable and a project can override them through config. rm -rf is not
|
|
55
|
+
* here — it is built at runtime from rmRfProtectedAreas (see rmRfSourceFrom).
|
|
56
|
+
*/
|
|
57
|
+
function defaultDenyPatterns() {
|
|
58
|
+
const [taskKill, pKill, killAll] = KILL_BY_NAME_COMMANDS;
|
|
59
|
+
return [
|
|
60
|
+
[
|
|
61
|
+
String.raw`\bgit\s+reset\s+--hard\b`,
|
|
62
|
+
"'git reset --hard' is destructive and needs explicit authorization.",
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
String.raw`\bgit\s+push\s+.*--force\b`,
|
|
66
|
+
"'git push --force' is destructive and is not allowed.",
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
String.raw`\bgit\s+clean\s+-[a-z]*f[a-z]*\b`,
|
|
70
|
+
"'git clean -f' is destructive and is not allowed.",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
// Killing by image name reaches every process with that name on the machine — the
|
|
74
|
+
// server the user is watching, another session's, a half-run tool. The damage is
|
|
75
|
+
// silent. Keep the PID you started and kill that; if lost, identify by full command
|
|
76
|
+
// line and confirm ownership first.
|
|
77
|
+
[
|
|
78
|
+
String.raw`\b(`,
|
|
79
|
+
taskKill,
|
|
80
|
+
String.raw`\s+[^|;]*[/]IM|`,
|
|
81
|
+
pKill,
|
|
82
|
+
String.raw`\s+|`,
|
|
83
|
+
killAll,
|
|
84
|
+
String.raw`\s+)`,
|
|
85
|
+
].join(''),
|
|
86
|
+
'Killing processes by NAME reaches everything with that name, not just yours. ' +
|
|
87
|
+
'Keep the PID you started and kill that; if lost, find it by its full command ' +
|
|
88
|
+
'line and confirm it is yours before touching it.',
|
|
89
|
+
],
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remote-publish rules. Base, non-configurable — see the header. */
|
|
94
|
+
const REMOTE_PUBLISH_RULES = [
|
|
95
|
+
[
|
|
96
|
+
String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
|
|
97
|
+
"Publishing to a remote ('git push') needs fresh authorization from the user naming " +
|
|
98
|
+
'commits, local branch, remote and target. This gate cannot verify that from the ' +
|
|
99
|
+
'command itself — ask the user and let them run it.',
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
|
|
103
|
+
'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
|
|
104
|
+
'authorization from the user naming commits, local branch, remote and target. ' +
|
|
105
|
+
'Ask the user and let them run it.',
|
|
106
|
+
],
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// Interpreter name, then any intermediate flags (e.g. --input-type=module), then an
|
|
110
|
+
// eval flag. Intermediate flags are matched loosely (`-\S+`) to keep the pattern simple.
|
|
111
|
+
const INTERPRETER_EVAL_PATTERN =
|
|
112
|
+
/\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
|
|
113
|
+
const RAW_FILE_OPS_PATTERN =
|
|
114
|
+
/\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
|
|
115
|
+
|
|
116
|
+
// How far back from a mention to look for a governing verb, and the reporting-verb lexicon
|
|
117
|
+
// that marks a mention as description rather than an order. Only the text before a mention
|
|
118
|
+
// is inspected: what governs it is what precedes it (appending words after a real command
|
|
119
|
+
// would otherwise be a trivial bypass).
|
|
120
|
+
const DESCRIPTION_LOOK_BACK = 80;
|
|
121
|
+
const REPORTING_VERB_PATTERN =
|
|
122
|
+
/(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
|
|
123
|
+
|
|
124
|
+
function compile(source) {
|
|
125
|
+
return new RegExp(source, 'i');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stripQuoted(text) {
|
|
129
|
+
return text
|
|
130
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
131
|
+
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
132
|
+
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when a surviving, non-quoted mention is not governed by a reporting verb. */
|
|
136
|
+
function hasRealPublishIntent(text, pattern) {
|
|
137
|
+
if (!pattern.test(text)) return false;
|
|
138
|
+
const cleaned = stripQuoted(text);
|
|
139
|
+
const global = new RegExp(
|
|
140
|
+
pattern.source,
|
|
141
|
+
`${pattern.flags.replace('g', '')}g`,
|
|
142
|
+
);
|
|
143
|
+
const matches = [...cleaned.matchAll(global)];
|
|
144
|
+
if (matches.length === 0) return false;
|
|
145
|
+
return matches.some((match) => {
|
|
146
|
+
const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
|
|
147
|
+
return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkEmbeddedInterpreter(command) {
|
|
152
|
+
const match = INTERPRETER_EVAL_PATTERN.exec(command);
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
if (match[1].toLowerCase().startsWith('python')) {
|
|
155
|
+
return 'Inline Python is not allowed in this harness: write the script to a file with Write and run that.';
|
|
156
|
+
}
|
|
157
|
+
const inline = command.slice(match.index + match[0].length);
|
|
158
|
+
if (RAW_FILE_OPS_PATTERN.test(inline)) {
|
|
159
|
+
return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
|
|
160
|
+
}
|
|
161
|
+
return null; // import/require or fetch: logic the native tools do not cover — allowed
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The text to inspect: a real command's command line, or the delegation prompt. */
|
|
165
|
+
function commandTextFrom(toolName, toolInput) {
|
|
166
|
+
if (SHELL_TOOLS.has(toolName)) {
|
|
167
|
+
return String(toolInput.CommandLine ?? toolInput.command ?? '');
|
|
168
|
+
}
|
|
169
|
+
return String(toolInput.prompt ?? toolInput.description ?? '');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Static deny rules + the runtime rm -rf rule, both read from config params. */
|
|
173
|
+
function checkDestructive(command, parameters) {
|
|
174
|
+
// denyPatterns may be a flat list of sources or [source, reason] pairs; normalize.
|
|
175
|
+
const denyPairs = (parameters.denyPatterns ?? []).map((entry) =>
|
|
176
|
+
Array.isArray(entry)
|
|
177
|
+
? entry
|
|
178
|
+
: [entry, 'Destructive command is not allowed.'],
|
|
179
|
+
);
|
|
180
|
+
for (const [source, reason] of denyPairs) {
|
|
181
|
+
if (compile(source).test(command)) deny(GATE_ID, reason);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// rm -rf over a protected area: areas read from config at runtime, so editing
|
|
185
|
+
// rmRfProtectedAreas takes effect without touching denyPatterns.
|
|
186
|
+
const rmRfAreas =
|
|
187
|
+
parameters.rmRfProtectedAreas ?? DEFAULT_RM_RF_PROTECTED_AREAS;
|
|
188
|
+
if (
|
|
189
|
+
rmRfAreas.length > 0 &&
|
|
190
|
+
compile(rmRfSourceFrom(rmRfAreas)).test(command)
|
|
191
|
+
) {
|
|
192
|
+
deny(GATE_ID, "'rm -rf' over a protected area is not allowed.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (parameters.embeddedInterpreterEnabled) {
|
|
196
|
+
const reason = checkEmbeddedInterpreter(command);
|
|
197
|
+
if (reason) deny(GATE_ID, reason);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Remote-publish rules: a real command is checked literally; a delegation prompt by intent. */
|
|
202
|
+
function checkRemotePublish(command, isShell) {
|
|
203
|
+
for (const [source, reason] of REMOTE_PUBLISH_RULES) {
|
|
204
|
+
const pattern = compile(source);
|
|
205
|
+
if (isShell) {
|
|
206
|
+
if (pattern.test(command)) deny(GATE_ID, reason);
|
|
207
|
+
} else if (hasRealPublishIntent(command, pattern)) {
|
|
208
|
+
deny(GATE_ID, reason);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
runGate(
|
|
214
|
+
{
|
|
215
|
+
id: GATE_ID,
|
|
216
|
+
configKey: CONFIG_KEY,
|
|
217
|
+
enabledByDefault: true,
|
|
218
|
+
defaultParams: {
|
|
219
|
+
denyPatterns: defaultDenyPatterns(),
|
|
220
|
+
rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
|
|
221
|
+
embeddedInterpreterEnabled: false,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
({ toolName, toolInput, parameters }) => {
|
|
225
|
+
const isShell = SHELL_TOOLS.has(toolName);
|
|
226
|
+
const isDelegation = DELEGATION_TOOLS.has(toolName);
|
|
227
|
+
if (!isShell && !isDelegation) return;
|
|
228
|
+
|
|
229
|
+
const command = commandTextFrom(toolName, toolInput);
|
|
230
|
+
checkDestructive(command, parameters);
|
|
231
|
+
checkRemotePublish(command, isShell);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
// Runs the gate as its own process (it calls process.exit), feeding a payload on stdin
|
|
12
|
+
// from inside a temp project so the gate's config lookup is isolated. Returns the parsed
|
|
13
|
+
// stdout, or null when the gate allowed (no output).
|
|
14
|
+
function runGate(payload, { config } = {}) {
|
|
15
|
+
const project = mkdtempSync(join(tmpdir(), 'bash-commands-'));
|
|
16
|
+
mkdirSync(join(project, '.git'));
|
|
17
|
+
if (config) {
|
|
18
|
+
mkdirSync(join(project, '.ai'));
|
|
19
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
20
|
+
}
|
|
21
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
22
|
+
input: JSON.stringify(payload),
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
cwd: project,
|
|
25
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
26
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
27
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
+
});
|
|
29
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bash(command) {
|
|
33
|
+
return { tool_name: 'Bash', tool_input: { command } };
|
|
34
|
+
}
|
|
35
|
+
function delegate(prompt) {
|
|
36
|
+
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
37
|
+
}
|
|
38
|
+
function isDeny(result) {
|
|
39
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('denies git reset --hard, rm -rf on a protected area, force push and kill-by-name', () => {
|
|
43
|
+
assert.ok(isDeny(runGate(bash('git reset --hard HEAD~3'))));
|
|
44
|
+
assert.ok(isDeny(runGate(bash('rm -rf src'))));
|
|
45
|
+
assert.ok(isDeny(runGate(bash('git push origin main --force'))));
|
|
46
|
+
assert.ok(isDeny(runGate(bash('taskkill /F /IM node.exe'))));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('allows an innocuous command', () => {
|
|
50
|
+
assert.equal(runGate(bash('git status')), null);
|
|
51
|
+
assert.equal(runGate(bash('rm -rf ./build/cache')), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('denies a plain git push (remote publish needs fresh authorization)', () => {
|
|
55
|
+
assert.ok(isDeny(runGate(bash('git push origin main'))));
|
|
56
|
+
assert.ok(isDeny(runGate(bash('gh pr merge 12'))));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('in a delegation, denies a real order to publish but allows a description of one', () => {
|
|
60
|
+
assert.ok(
|
|
61
|
+
isDeny(runGate(delegate('Then run git push origin main to publish.'))),
|
|
62
|
+
);
|
|
63
|
+
assert.equal(
|
|
64
|
+
runGate(
|
|
65
|
+
delegate(
|
|
66
|
+
'I extended the guard so it denies "git push" without authorization.',
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
null,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('disabled by config: the gate does not run', () => {
|
|
74
|
+
assert.equal(
|
|
75
|
+
runGate(bash('git reset --hard'), {
|
|
76
|
+
config: { gates: { blockDestructiveShellCommands: false } },
|
|
77
|
+
}),
|
|
78
|
+
null,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('project denyPatterns replace the built-in list', () => {
|
|
83
|
+
// A project that only bans `curl | sh` — git reset --hard is no longer denied.
|
|
84
|
+
const config = {
|
|
85
|
+
gates: {
|
|
86
|
+
blockDestructiveShellCommands: {
|
|
87
|
+
enabled: true,
|
|
88
|
+
denyPatterns: [String.raw`curl\s+.*\|\s*sh`],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
assert.equal(runGate(bash('git reset --hard'), { config }), null);
|
|
93
|
+
assert.ok(isDeny(runGate(bash('curl http://x | sh'), { config })));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('embedded interpreter is off by default, on when enabled', () => {
|
|
97
|
+
const inline = bash("node -e \"require('fs').writeFileSync('x','y')\"");
|
|
98
|
+
assert.equal(runGate(inline), null);
|
|
99
|
+
assert.ok(
|
|
100
|
+
isDeny(
|
|
101
|
+
runGate(inline, {
|
|
102
|
+
config: {
|
|
103
|
+
gates: {
|
|
104
|
+
blockDestructiveShellCommands: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
embeddedInterpreterEnabled: true,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
});
|