continuous-improvement 3.22.1 → 3.24.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 +1 -1
- package/CHANGELOG.md +23 -0
- package/QUICKSTART.md +19 -20
- package/README.md +72 -19
- package/SKILL.md +4 -0
- package/bin/companion-preference-status.mjs +2 -5
- package/bin/generate-plugin-manifests.mjs +21 -2
- package/bin/harvest-friction.mjs +10 -8
- package/bin/install.mjs +208 -33
- package/bin/mcp-server.mjs +4 -9
- package/bin/observe.mjs +3 -3
- package/bin/reconcile-instinct-hashes.mjs +226 -0
- package/commands/discipline.md +5 -2
- package/commands/reconcile.md +1 -1
- package/commands/ship.md +5 -49
- package/commands/superpowers.md +1 -1
- package/commands/verify-install.md +8 -3
- package/hooks/companion-preference.mjs +2 -5
- package/hooks/config-guard.mjs +94 -0
- package/hooks/gateguard.mjs +39 -39
- package/hooks/goal-drift-stop.mjs +2 -2
- package/hooks/query-cost-nudge.mjs +2 -2
- package/hooks/recall-briefing.mjs +2 -2
- package/hooks/route-prompt.mjs +2 -5
- package/hooks/session.mjs +2 -2
- package/hooks/workflow-distill.mjs +2 -2
- package/lib/config-guard-gate.mjs +243 -0
- package/lib/destructive-bash.mjs +216 -0
- package/lib/gateguard-state.mjs +5 -1
- package/lib/plugin-metadata.mjs +12 -1
- package/lib/skill-catalog.mjs +169 -0
- package/llms.txt +11 -0
- package/package.json +1 -1
- package/plugins/beginner.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
- package/plugins/continuous-improvement/README.md +1 -2
- package/plugins/continuous-improvement/bin/mcp-server.mjs +4 -9
- package/plugins/continuous-improvement/bin/observe.mjs +3 -3
- package/plugins/continuous-improvement/commands/discipline.md +5 -2
- package/plugins/continuous-improvement/commands/reconcile.md +1 -1
- package/plugins/continuous-improvement/commands/ship.md +5 -49
- package/plugins/continuous-improvement/commands/superpowers.md +1 -1
- package/plugins/continuous-improvement/commands/verify-install.md +8 -3
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +2 -5
- package/plugins/continuous-improvement/hooks/config-guard.mjs +94 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +39 -39
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +2 -2
- package/plugins/continuous-improvement/hooks/hooks.json +10 -0
- package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +2 -2
- package/plugins/continuous-improvement/hooks/recall-briefing.mjs +2 -2
- package/plugins/continuous-improvement/hooks/route-prompt.mjs +2 -5
- package/plugins/continuous-improvement/hooks/session.mjs +2 -2
- package/plugins/continuous-improvement/hooks/workflow-distill.mjs +2 -2
- package/plugins/continuous-improvement/lib/config-guard-gate.mjs +243 -0
- package/plugins/continuous-improvement/lib/destructive-bash.mjs +216 -0
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +12 -1
- package/plugins/continuous-improvement/skills/README.md +1 -1
- package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +4 -0
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +17 -2
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +0 -1
- package/plugins/continuous-improvement/skills/ship/SKILL.md +139 -0
- package/plugins/expert.json +1 -1
- package/skills/README.md +2 -2
- package/skills/deploy-receipt.md +1 -1
- package/skills/gateguard.md +17 -2
- package/skills/reconcile.md +0 -1
- package/skills/ship.md +139 -0
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
- package/skills/safety-guard.md +0 -77
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { readFileSync } from "node:fs";
|
|
36
36
|
import { dirname, join } from "node:path";
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { classifyDestructiveBash } from "../lib/destructive-bash.mjs";
|
|
38
39
|
import { MAX_CLEARED_FILES, canonicalizeFileKey, canonicalizeProjectRoot, isCapReached, isFileCleared, loadState, markFileCleared, resolveProjectRoot, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
|
|
39
40
|
const TOOL_ROUTE = {
|
|
40
41
|
Read: "allow",
|
|
@@ -48,42 +49,14 @@ const TOOL_ROUTE = {
|
|
|
48
49
|
NotebookEdit: "mutating-file",
|
|
49
50
|
Bash: "allow",
|
|
50
51
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"--force-with-lease",
|
|
58
|
-
"git branch -D",
|
|
59
|
-
"drop table",
|
|
60
|
-
"drop database",
|
|
61
|
-
"drop schema",
|
|
62
|
-
"truncate ",
|
|
63
|
-
"mkfs",
|
|
64
|
-
"dd if=",
|
|
65
|
-
"format ",
|
|
66
|
-
"rmdir /s",
|
|
67
|
-
"del /f /q",
|
|
68
|
-
"del /q /f",
|
|
69
|
-
"Remove-Item -Recurse",
|
|
70
|
-
"Remove-Item -Force",
|
|
71
|
-
];
|
|
72
|
-
// Flags whose VALUE is human prose (a commit message, a PR body) or a filename —
|
|
73
|
-
// never a command to execute. Their contents must not trip the destructive scan:
|
|
74
|
-
// `git commit -m "drop the stale format helper"` and `gh pr create --body "…"`
|
|
75
|
-
// were stranding finished work on their own wording. `-c` is deliberately
|
|
76
|
-
// EXCLUDED — `bash -c "rm -rf /"` carries a real command and must still gate.
|
|
77
|
-
const MESSAGE_FLAG_RE = /(^|\s)(-m|--message|-F|--file|--body|--body-file|--title|--notes|-C|--reuse-message)(=|\s+)('[^']*'|"[^"]*"|\S+)/g;
|
|
78
|
-
// Blank the value of every message/body flag so only executable command syntax
|
|
79
|
-
// remains for the destructive-pattern scan. The flag itself is preserved so a
|
|
80
|
-
// flag like `-F` never accidentally merges with its neighbours.
|
|
81
|
-
function stripMessageArgs(command) {
|
|
82
|
-
return command.replace(MESSAGE_FLAG_RE, (_match, lead, flag) => `${lead}${flag} `);
|
|
83
|
-
}
|
|
52
|
+
// The destructive-Bash classifier lives in lib/destructive-bash.mjs: structured
|
|
53
|
+
// rules (flag order and spelling do not matter: `rm -r -f`, `git clean -fdx`,
|
|
54
|
+
// `git checkout -- .`, `git restore .`, `find -delete`, `git push +ref`,
|
|
55
|
+
// `git stash drop`) plus the original substring list as the fallback. The
|
|
56
|
+
// message-flag carve-out (`git commit -m "…"`) lives there too. Each rule has
|
|
57
|
+
// a stable id the deny reason prints, so a block is explainable.
|
|
84
58
|
function isDestructiveBash(command) {
|
|
85
|
-
|
|
86
|
-
return DESTRUCTIVE_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
|
|
59
|
+
return classifyDestructiveBash(command).destructive;
|
|
87
60
|
}
|
|
88
61
|
function classifyTool(toolName, toolInput) {
|
|
89
62
|
const route = TOOL_ROUTE[toolName] ?? "allow";
|
|
@@ -119,12 +92,34 @@ const EXCLUDE_FRAGMENTS = String(process.env.CI_GATEGUARD_EXCLUDE ?? "")
|
|
|
119
92
|
.split(",")
|
|
120
93
|
.map((fragment) => fragment.trim().replace(/\\/g, "/").toLowerCase())
|
|
121
94
|
.filter((fragment) => fragment !== "");
|
|
122
|
-
function
|
|
95
|
+
function matchedExcludeFragment(filePath) {
|
|
123
96
|
if (EXCLUDE_FRAGMENTS.length === 0 || typeof filePath !== "string" || filePath === "") {
|
|
124
|
-
return
|
|
97
|
+
return null;
|
|
125
98
|
}
|
|
126
99
|
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
127
|
-
return EXCLUDE_FRAGMENTS.
|
|
100
|
+
return EXCLUDE_FRAGMENTS.find((fragment) => normalized.includes(fragment)) ?? null;
|
|
101
|
+
}
|
|
102
|
+
function isExcludedPath(filePath) {
|
|
103
|
+
return matchedExcludeFragment(filePath) !== null;
|
|
104
|
+
}
|
|
105
|
+
// A fragment every path contains ("/", ".", any single character) is not an
|
|
106
|
+
// exclusion, it is an off switch for the whole file gate. It is still honoured —
|
|
107
|
+
// the operator set it — but silently honouring it is how a host ends up with the
|
|
108
|
+
// headline feature off and nobody noticing (this repo's own author's shell had
|
|
109
|
+
// CI_GATEGUARD_EXCLUDE="/,." for weeks). So every exclusion prints one stderr
|
|
110
|
+
// line, and a catch-all says plainly that the gate is off. stderr never changes
|
|
111
|
+
// the decision: allow stays empty stdout + exit 0.
|
|
112
|
+
function isCatchAllFragment(fragment) {
|
|
113
|
+
return fragment.length <= 1 || fragment === "./" || fragment === "..";
|
|
114
|
+
}
|
|
115
|
+
function buildExcludeNotice(paths, fragment) {
|
|
116
|
+
const shown = paths.map((p) => p.replace(/\\/g, "/")).join(", ");
|
|
117
|
+
if (isCatchAllFragment(fragment)) {
|
|
118
|
+
return (`[continuous-improvement] gateguard: CI_GATEGUARD_EXCLUDE fragment "${fragment}" matches every path, ` +
|
|
119
|
+
`so the file gate is off for this session (skipped ${shown}). ` +
|
|
120
|
+
"Narrow it to a directory, e.g. CI_GATEGUARD_EXCLUDE=docs/wiki, to get the gate back.");
|
|
121
|
+
}
|
|
122
|
+
return `[continuous-improvement] gateguard: skipped by CI_GATEGUARD_EXCLUDE (fragment "${fragment}" matched ${shown}).`;
|
|
128
123
|
}
|
|
129
124
|
// --- Target lock (opt-in) --------------------------------------------------
|
|
130
125
|
// A fact-list can't catch a wrong-repo / wrong-worktree write — you can present
|
|
@@ -271,6 +266,7 @@ function buildBraceRefReason(hit) {
|
|
|
271
266
|
].join("\n");
|
|
272
267
|
}
|
|
273
268
|
function buildDestructiveBashReason(command) {
|
|
269
|
+
const rule = classifyDestructiveBash(command).rule ?? "unknown";
|
|
274
270
|
return [
|
|
275
271
|
`Destructive command requested: ${command}`,
|
|
276
272
|
"",
|
|
@@ -278,6 +274,7 @@ function buildDestructiveBashReason(command) {
|
|
|
278
274
|
" 2. Write a one-line rollback procedure",
|
|
279
275
|
" 3. Quote the user's current instruction verbatim",
|
|
280
276
|
"",
|
|
277
|
+
`Matched rule: ${rule}`,
|
|
281
278
|
"Destructive Bash gates EVERY call — clearance is not cached.",
|
|
282
279
|
].join("\n");
|
|
283
280
|
}
|
|
@@ -354,7 +351,10 @@ function main() {
|
|
|
354
351
|
const allTargetPaths = extractFilePaths(toolInput);
|
|
355
352
|
const filePaths = allTargetPaths.filter((path) => !isExcludedPath(path));
|
|
356
353
|
if (allTargetPaths.length > 0 && filePaths.length === 0) {
|
|
357
|
-
|
|
354
|
+
// Every target is under a CI_GATEGUARD_EXCLUDE path; skip the gate, but say so.
|
|
355
|
+
const fragment = matchedExcludeFragment(allTargetPaths[0]) ?? EXCLUDE_FRAGMENTS[0];
|
|
356
|
+
process.stderr.write(`${buildExcludeNotice(allTargetPaths, fragment)}\n`);
|
|
357
|
+
emitAllow();
|
|
358
358
|
return;
|
|
359
359
|
}
|
|
360
360
|
// Target lock runs before the fact gate and independent of clearance: a
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
// construction: any error / missing goal / unreadable observations exits 0 and
|
|
18
18
|
// never blocks. No network. 5s hook budget.
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
21
|
import { join } from "node:path";
|
|
23
22
|
import { evaluateGoalDrift } from "../lib/goal-drift-gate.mjs";
|
|
23
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
24
24
|
import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
|
|
25
25
|
function readStdinSync() {
|
|
26
26
|
try {
|
|
@@ -62,7 +62,7 @@ function resolveProjectRoot() {
|
|
|
62
62
|
return "global";
|
|
63
63
|
}
|
|
64
64
|
function projectHash(root) {
|
|
65
|
-
return
|
|
65
|
+
return hashProjectRoot(root);
|
|
66
66
|
}
|
|
67
67
|
function readGoalMarkdown(projectRoot, instinctsProjectDir) {
|
|
68
68
|
const candidates = [join(projectRoot, "task_plan.md"), join(instinctsProjectDir, "goal.md")];
|
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
"timeout": 30
|
|
25
25
|
}
|
|
26
26
|
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/config-guard.mjs\"",
|
|
34
|
+
"timeout": 30
|
|
35
|
+
}
|
|
36
|
+
]
|
|
27
37
|
}
|
|
28
38
|
],
|
|
29
39
|
"PostToolUse": [
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* the DB files stay dirty. Never blocks; fail-open on any error.
|
|
17
17
|
*/
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
19
|
-
import {
|
|
19
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { dirname, join } from "node:path";
|
|
@@ -71,7 +71,7 @@ function markerKey(sessionId) {
|
|
|
71
71
|
return sanitized || `day-${new Date().toISOString().slice(0, 10)}`;
|
|
72
72
|
}
|
|
73
73
|
function markerPath(home, projectRoot, sessionId) {
|
|
74
|
-
const hash =
|
|
74
|
+
const hash = hashProjectRoot(projectRoot);
|
|
75
75
|
return join(home, ".claude", "instincts", hash, "query-cost-nudge", `${markerKey(sessionId)}.nudged`);
|
|
76
76
|
}
|
|
77
77
|
function main() {
|
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
* ~/.claude/instincts/<project-hash>/recall-briefing-session.json records which
|
|
24
24
|
* session_ids have been briefed so each session is briefed at most once.
|
|
25
25
|
*/
|
|
26
|
-
import { createHash } from "node:crypto";
|
|
27
26
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
27
|
import { homedir } from "node:os";
|
|
29
28
|
import { join } from "node:path";
|
|
30
29
|
import { execFileSync } from "node:child_process";
|
|
31
30
|
import { buildIndex, query } from "../lib/recall-index.mjs";
|
|
32
31
|
import { DEFAULT_MAX_HITS, decideBriefing } from "../lib/recall-briefing.mjs";
|
|
32
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
33
33
|
const ENABLED_VALUES = new Set(["1", "on", "true", "yes"]);
|
|
34
34
|
const MAX_BRIEFED_KEYS = 1000;
|
|
35
35
|
function isEnabled() {
|
|
@@ -65,7 +65,7 @@ function resolveProjectRoot() {
|
|
|
65
65
|
return "global";
|
|
66
66
|
}
|
|
67
67
|
function instinctsDir(home) {
|
|
68
|
-
const hash =
|
|
68
|
+
const hash = hashProjectRoot(resolveProjectRoot());
|
|
69
69
|
return join(home, ".claude", "instincts", hash);
|
|
70
70
|
}
|
|
71
71
|
function readObservations(dir) {
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
* }
|
|
31
31
|
* Rows are evaluated in order; first match wins.
|
|
32
32
|
*/
|
|
33
|
-
import { createHash } from "node:crypto";
|
|
34
33
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
35
34
|
import { homedir } from "node:os";
|
|
36
35
|
import { dirname, join } from "node:path";
|
|
37
36
|
import { execFileSync } from "node:child_process";
|
|
38
37
|
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
39
39
|
function readStdin() {
|
|
40
40
|
try {
|
|
41
41
|
return readFileSync(0, "utf8");
|
|
@@ -65,10 +65,7 @@ function resolveProjectRoot() {
|
|
|
65
65
|
return "global";
|
|
66
66
|
}
|
|
67
67
|
function telemetryPath(home) {
|
|
68
|
-
const hash =
|
|
69
|
-
.update(resolveProjectRoot())
|
|
70
|
-
.digest("hex")
|
|
71
|
-
.slice(0, 12);
|
|
68
|
+
const hash = hashProjectRoot(resolveProjectRoot());
|
|
72
69
|
return join(home, ".claude", "instincts", hash, "route-prompt.jsonl");
|
|
73
70
|
}
|
|
74
71
|
function writeTelemetry(home, event) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
5
4
|
import { join } from "node:path";
|
|
5
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
6
6
|
import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
|
|
7
7
|
function read(path) {
|
|
8
8
|
try {
|
|
@@ -62,7 +62,7 @@ function main() {
|
|
|
62
62
|
if (!home)
|
|
63
63
|
return;
|
|
64
64
|
const instinctsRoot = join(home, ".claude", "instincts");
|
|
65
|
-
const hash =
|
|
65
|
+
const hash = hashProjectRoot(projectRoot());
|
|
66
66
|
const projectDir = join(instinctsRoot, hash);
|
|
67
67
|
const files = [...yamlFiles(projectDir), ...yamlFiles(join(instinctsRoot, "global"))];
|
|
68
68
|
const observations = read(join(projectDir, "observations.jsonl")).split(/\r?\n/).filter(Boolean).length;
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
// name + verify command, so a run is nudged at most once. Fail-open by
|
|
25
25
|
// construction: any error exits 0 and never blocks. No network. 5s hook budget.
|
|
26
26
|
import { execFileSync } from "node:child_process";
|
|
27
|
-
import { createHash } from "node:crypto";
|
|
28
27
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
28
|
import { join } from "node:path";
|
|
29
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
30
30
|
import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
|
|
31
31
|
import { workflowRunFromObservations } from "../lib/skill-distill.mjs";
|
|
32
32
|
function safeJsonParse(text) {
|
|
@@ -60,7 +60,7 @@ function resolveProjectRoot() {
|
|
|
60
60
|
return "global";
|
|
61
61
|
}
|
|
62
62
|
function projectHash(root) {
|
|
63
|
-
return
|
|
63
|
+
return hashProjectRoot(root);
|
|
64
64
|
}
|
|
65
65
|
function readObservations(instinctsProjectDir) {
|
|
66
66
|
const file = join(instinctsProjectDir, "observations.jsonl");
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// config-guard-gate.mts — Pure decision core for the config-guard PreToolUse hook.
|
|
2
|
+
//
|
|
3
|
+
// The guardrails this plugin ships are wired by a handful of files: the
|
|
4
|
+
// Claude Code settings files, the MCP config, hooks.json, the user hooks
|
|
5
|
+
// directory, the installed-plugins cache, and plugin manifests. Nothing stopped
|
|
6
|
+
// an agent from editing those files and switching every gate off, and
|
|
7
|
+
// hooks/gateguard.mjs is designed to be cleared per file, not to hard-deny. This
|
|
8
|
+
// module decides whether a tool call would mutate one of them. No I/O: the hook
|
|
9
|
+
// (src/hooks/config-guard.mts) wires stdin and the mode env var around it, so a
|
|
10
|
+
// table test covers every branch without spawning anything. Lives in lib/ so
|
|
11
|
+
// tests can import it (the test-imports-only invariant forbids hooks/).
|
|
12
|
+
//
|
|
13
|
+
// Idea ported from karanb192/claude-code-hooks `config-guard` (MIT); the code
|
|
14
|
+
// and the pattern list are ours. Fail-open by construction: an unrecognized mode
|
|
15
|
+
// resolves to warn, and a call that names no protected path is always allowed.
|
|
16
|
+
/**
|
|
17
|
+
* Path patterns, matched case-insensitively on the forward-slash form.
|
|
18
|
+
* A trailing "/" means a directory component anywhere in the path; otherwise
|
|
19
|
+
* the pattern must be the whole path or a trailing path segment, so
|
|
20
|
+
* `hooks.json.md` and `config/settings.json` are not matched.
|
|
21
|
+
*/
|
|
22
|
+
export const PROTECTED_PATTERNS = [
|
|
23
|
+
".claude/settings.json",
|
|
24
|
+
".claude/settings.local.json",
|
|
25
|
+
".mcp.json",
|
|
26
|
+
"hooks.json",
|
|
27
|
+
".claude/hooks/",
|
|
28
|
+
".claude/plugins/",
|
|
29
|
+
".claude-plugin/",
|
|
30
|
+
];
|
|
31
|
+
const ALLOW = { action: "allow", reason: "" };
|
|
32
|
+
export function parseMode(raw) {
|
|
33
|
+
const v = (raw ?? "warn").trim().toLowerCase();
|
|
34
|
+
return v === "block" || v === "off" ? v : "warn";
|
|
35
|
+
}
|
|
36
|
+
function normalizePath(p) {
|
|
37
|
+
return p.replace(/\\/g, "/").toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
export function matchProtectedPath(filePath) {
|
|
40
|
+
if (typeof filePath !== "string" || filePath === "")
|
|
41
|
+
return null;
|
|
42
|
+
const n = normalizePath(filePath);
|
|
43
|
+
for (const pattern of PROTECTED_PATTERNS) {
|
|
44
|
+
const lp = pattern.toLowerCase();
|
|
45
|
+
if (lp.endsWith("/")) {
|
|
46
|
+
if (n.includes(lp))
|
|
47
|
+
return pattern;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (n === lp || n.endsWith(`/${lp}`))
|
|
51
|
+
return pattern;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// --- file tools ------------------------------------------------------------
|
|
56
|
+
function fileToolPaths(toolInput) {
|
|
57
|
+
const paths = [];
|
|
58
|
+
if (typeof toolInput.file_path === "string")
|
|
59
|
+
paths.push(toolInput.file_path);
|
|
60
|
+
if (typeof toolInput.notebook_path === "string")
|
|
61
|
+
paths.push(toolInput.notebook_path);
|
|
62
|
+
if (Array.isArray(toolInput.edits)) {
|
|
63
|
+
for (const edit of toolInput.edits) {
|
|
64
|
+
if (edit && typeof edit === "object" && typeof edit.file_path === "string") {
|
|
65
|
+
paths.push(edit.file_path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return paths;
|
|
70
|
+
}
|
|
71
|
+
const FILE_MUTATING_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
72
|
+
// --- Bash --------------------------------------------------------------------
|
|
73
|
+
// Split at shell separators that sit outside single or double quotes.
|
|
74
|
+
function splitSimpleCommands(command) {
|
|
75
|
+
const segments = [];
|
|
76
|
+
let current = "";
|
|
77
|
+
let quote = null;
|
|
78
|
+
for (let i = 0; i < command.length; i++) {
|
|
79
|
+
const ch = command[i];
|
|
80
|
+
if (quote) {
|
|
81
|
+
current += ch;
|
|
82
|
+
if (ch === quote)
|
|
83
|
+
quote = null;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === '"' || ch === "'") {
|
|
87
|
+
quote = ch;
|
|
88
|
+
current += ch;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === "\n" || ch === ";") {
|
|
92
|
+
segments.push(current);
|
|
93
|
+
current = "";
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === "|" || ch === "&") {
|
|
97
|
+
if (command[i + 1] === ch)
|
|
98
|
+
i++;
|
|
99
|
+
segments.push(current);
|
|
100
|
+
current = "";
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
current += ch;
|
|
104
|
+
}
|
|
105
|
+
segments.push(current);
|
|
106
|
+
return segments.map((s) => s.trim()).filter((s) => s !== "");
|
|
107
|
+
}
|
|
108
|
+
function tokenize(segment) {
|
|
109
|
+
return segment.split(/\s+/).filter((t) => t !== "");
|
|
110
|
+
}
|
|
111
|
+
function stripQuotes(token) {
|
|
112
|
+
return token.replace(/^['"]+|['"]+$/g, "");
|
|
113
|
+
}
|
|
114
|
+
function commandWord(tokens) {
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < tokens.length) {
|
|
117
|
+
const t = tokens[i];
|
|
118
|
+
if (t === "sudo" || t === "env" || t === "command" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return { cmd: (tokens[i] ?? "").toLowerCase(), rest: tokens.slice(i + 1) };
|
|
125
|
+
}
|
|
126
|
+
const COPY_MOVE = new Set(["cp", "mv", "copy-item", "move-item"]);
|
|
127
|
+
const OPERAND_WRITERS = new Set([
|
|
128
|
+
"rm",
|
|
129
|
+
"tee",
|
|
130
|
+
"truncate",
|
|
131
|
+
"remove-item",
|
|
132
|
+
"del",
|
|
133
|
+
"set-content",
|
|
134
|
+
"out-file",
|
|
135
|
+
"add-content",
|
|
136
|
+
"clear-content",
|
|
137
|
+
]);
|
|
138
|
+
// Mutation indicators for the generic scan (a protected path mentioned inside
|
|
139
|
+
// a scripting one-liner such as `node -e "writeFileSync('.claude/settings.json')"`).
|
|
140
|
+
const GENERIC_WRITE_RE = /writefilesync|writefile\(|appendfile|unlink|rename\(|copyfile|rmsync|truncate|set-content|out-file|add-content|remove-item|\bdel\b|\bsed\s+-i\b|\btee\b|>{1,2}/i;
|
|
141
|
+
const CLAUDE_CLI_MUTATIONS = {
|
|
142
|
+
plugin: new Set(["install", "uninstall", "enable", "disable", "update", "marketplace"]),
|
|
143
|
+
mcp: new Set(["add", "remove", "add-json", "add-from-claude-desktop", "reset-project-choices"]),
|
|
144
|
+
config: new Set(["set", "add", "remove", "reset", "rm"]),
|
|
145
|
+
};
|
|
146
|
+
function classifyBashSegment(segment) {
|
|
147
|
+
const tokens = tokenize(segment);
|
|
148
|
+
if (tokens.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
const { cmd, rest } = commandWord(tokens);
|
|
151
|
+
// `claude plugin|mcp|config <mutating-verb>`: the CLI edits the same files.
|
|
152
|
+
if (cmd === "claude") {
|
|
153
|
+
const area = (rest[0] ?? "").toLowerCase();
|
|
154
|
+
const verb = (rest[1] ?? "").toLowerCase();
|
|
155
|
+
const verbs = CLAUDE_CLI_MUTATIONS[area];
|
|
156
|
+
if (verbs && verbs.has(verb)) {
|
|
157
|
+
return { target: `claude ${area} ${verb}`, pattern: "claude-cli", via: "claude-cli" };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// cp / mv: only the destination (last operand) can be a protected write.
|
|
162
|
+
if (COPY_MOVE.has(cmd)) {
|
|
163
|
+
const operands = rest.filter((t) => !t.startsWith("-"));
|
|
164
|
+
const dest = operands[operands.length - 1];
|
|
165
|
+
const pattern = dest ? matchProtectedPath(stripQuotes(dest)) : null;
|
|
166
|
+
return pattern && dest ? { target: stripQuotes(dest), pattern, via: "bash" } : null;
|
|
167
|
+
}
|
|
168
|
+
// Redirects: `> path`, `>> path`, `2>path`, `>path`.
|
|
169
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
170
|
+
const t = tokens[i];
|
|
171
|
+
const m = /^(\d?>{1,2})(.*)$/.exec(t);
|
|
172
|
+
if (!m)
|
|
173
|
+
continue;
|
|
174
|
+
const target = m[2] !== "" ? m[2] : (tokens[i + 1] ?? "");
|
|
175
|
+
const pattern = matchProtectedPath(stripQuotes(target));
|
|
176
|
+
if (pattern)
|
|
177
|
+
return { target: stripQuotes(target), pattern, via: "bash" };
|
|
178
|
+
}
|
|
179
|
+
// Operand writers: any operand that is a protected path.
|
|
180
|
+
const sedInPlace = cmd === "sed" && rest.some((t) => /^-[a-zA-Z]*i/.test(t) || t === "--in-place");
|
|
181
|
+
if (OPERAND_WRITERS.has(cmd) || sedInPlace) {
|
|
182
|
+
for (const t of rest) {
|
|
183
|
+
if (t.startsWith("-"))
|
|
184
|
+
continue;
|
|
185
|
+
const clean = stripQuotes(t);
|
|
186
|
+
const pattern = matchProtectedPath(clean);
|
|
187
|
+
if (pattern)
|
|
188
|
+
return { target: clean, pattern, via: "bash" };
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
// Generic scan: a protected path quoted inside a scripting one-liner, next
|
|
193
|
+
// to a write indicator. Reads (`cat`, `grep`) carry no indicator and pass.
|
|
194
|
+
if (GENERIC_WRITE_RE.test(segment)) {
|
|
195
|
+
const candidates = [];
|
|
196
|
+
const quoted = /'([^']+)'|"([^"]+)"/g;
|
|
197
|
+
let q;
|
|
198
|
+
while ((q = quoted.exec(segment)) !== null)
|
|
199
|
+
candidates.push((q[1] ?? q[2]));
|
|
200
|
+
for (const t of tokens)
|
|
201
|
+
candidates.push(stripQuotes(t));
|
|
202
|
+
for (const c of candidates) {
|
|
203
|
+
const inner = /['"]([^'"]*?)['"]/.exec(c);
|
|
204
|
+
for (const candidate of [c, inner?.[1] ?? ""]) {
|
|
205
|
+
const pattern = matchProtectedPath(candidate);
|
|
206
|
+
if (pattern)
|
|
207
|
+
return { target: candidate, pattern, via: "bash" };
|
|
208
|
+
}
|
|
209
|
+
// A path embedded in a longer expression, e.g. writeFileSync('.claude/settings.json','{}')
|
|
210
|
+
const embedded = /(?:^|[('"\s=,])([~A-Za-z0-9_./\\:-]*(?:\.claude\/|\.claude-plugin\/|\.mcp\.json|hooks\.json)[A-Za-z0-9_./\\-]*)/i.exec(c);
|
|
211
|
+
if (embedded) {
|
|
212
|
+
const pattern = matchProtectedPath(embedded[1]);
|
|
213
|
+
if (pattern)
|
|
214
|
+
return { target: embedded[1], pattern, via: "bash" };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/** Decide whether a tool call would mutate a guardrail-wiring file. Pure. */
|
|
221
|
+
export function classifyMutation(toolName, toolInput) {
|
|
222
|
+
if (FILE_MUTATING_TOOLS.has(toolName)) {
|
|
223
|
+
for (const p of fileToolPaths(toolInput)) {
|
|
224
|
+
const pattern = matchProtectedPath(p);
|
|
225
|
+
if (pattern)
|
|
226
|
+
return { target: p, pattern, via: "file" };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (toolName === "Bash" && typeof toolInput.command === "string" && toolInput.command.trim() !== "") {
|
|
231
|
+
for (const segment of splitSimpleCommands(toolInput.command)) {
|
|
232
|
+
const hit = classifyBashSegment(segment);
|
|
233
|
+
if (hit)
|
|
234
|
+
return hit;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
export function decide(mode, gated, reason) {
|
|
240
|
+
if (!gated || mode === "off")
|
|
241
|
+
return ALLOW;
|
|
242
|
+
return { action: mode === "block" ? "block" : "warn", reason };
|
|
243
|
+
}
|