continuous-improvement 3.23.0 → 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 +2 -2
- package/CHANGELOG.md +17 -0
- package/QUICKSTART.md +19 -20
- package/README.md +71 -18
- 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 +4 -14
- 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/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 +12 -1
- package/package.json +2 -2
- package/plugins/beginner.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- 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/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 +0 -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/expert.json +1 -1
- package/skills/README.md +1 -2
- package/skills/deploy-receipt.md +1 -1
- package/skills/gateguard.md +17 -2
- package/skills/reconcile.md +0 -1
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
- package/skills/safety-guard.md +0 -77
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destructive-Bash classifier for hooks/gateguard.mjs.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, pure and dependency-free so a table test and a future
|
|
5
|
+
* `gateguard-explain` CLI can drive it without spawning the hook:
|
|
6
|
+
*
|
|
7
|
+
* 1. Structured rules: the command is split at unquoted separators
|
|
8
|
+
* (`|`, `||`, `&&`, `;`, newline) and each simple command is tokenized,
|
|
9
|
+
* so flag order and spelling stop mattering. `rm -r -f`, `rm -Rf` and
|
|
10
|
+
* `rm --recursive --force` are the same command; `git clean -fdx` and
|
|
11
|
+
* `git clean --force -d` are the same command. Each rule has a stable id
|
|
12
|
+
* the deny reason prints, so a block is explainable.
|
|
13
|
+
* 2. The original substring list, kept verbatim as the fallback so nothing
|
|
14
|
+
* that was caught before stops being caught.
|
|
15
|
+
*
|
|
16
|
+
* What is deliberately NOT here: plain file writes through Bash (`cat > x`,
|
|
17
|
+
* `sed -i`). Gating those would break the operator workflow that uses Bash
|
|
18
|
+
* precisely to write files; that limit is disclosed on the site and README.
|
|
19
|
+
*
|
|
20
|
+
* Message-flag carve-out: the VALUE of a commit message, PR body, title, or
|
|
21
|
+
* note is prose, never a command. It is blanked before any rule runs, so
|
|
22
|
+
* `git commit -m "rm -rf the old helper"` stays allowed. `-c` is not a message
|
|
23
|
+
* flag: `bash -c "rm -rf /"` carries a real command and still gates.
|
|
24
|
+
*/
|
|
25
|
+
export const DESTRUCTIVE_PATTERNS = [
|
|
26
|
+
"rm -rf",
|
|
27
|
+
"rm -fr",
|
|
28
|
+
"git reset --hard",
|
|
29
|
+
"git push --force",
|
|
30
|
+
"git push -f",
|
|
31
|
+
"--force-with-lease",
|
|
32
|
+
"git branch -D",
|
|
33
|
+
"drop table",
|
|
34
|
+
"drop database",
|
|
35
|
+
"drop schema",
|
|
36
|
+
"truncate ",
|
|
37
|
+
"mkfs",
|
|
38
|
+
"dd if=",
|
|
39
|
+
"format ",
|
|
40
|
+
"rmdir /s",
|
|
41
|
+
"del /f /q",
|
|
42
|
+
"del /q /f",
|
|
43
|
+
"Remove-Item -Recurse",
|
|
44
|
+
"Remove-Item -Force",
|
|
45
|
+
];
|
|
46
|
+
// Flags whose VALUE is human prose (a commit message, a PR body) or a filename —
|
|
47
|
+
// never a command to execute. Their contents must not trip the destructive scan.
|
|
48
|
+
const MESSAGE_FLAG_RE = /(^|\s)(-m|--message|-F|--file|--body|--body-file|--title|--notes|-C|--reuse-message)(=|\s+)('[^']*'|"[^"]*"|\S+)/g;
|
|
49
|
+
/** Blank the value of every message/body flag; the flag itself is preserved. */
|
|
50
|
+
export function stripMessageArgs(command) {
|
|
51
|
+
return command.replace(MESSAGE_FLAG_RE, (_match, lead, flag) => `${lead}${flag} `);
|
|
52
|
+
}
|
|
53
|
+
// Split at shell separators that sit outside single or double quotes.
|
|
54
|
+
function splitSimpleCommands(command) {
|
|
55
|
+
const segments = [];
|
|
56
|
+
let current = "";
|
|
57
|
+
let quote = null;
|
|
58
|
+
for (let i = 0; i < command.length; i++) {
|
|
59
|
+
const ch = command[i];
|
|
60
|
+
if (quote) {
|
|
61
|
+
current += ch;
|
|
62
|
+
if (ch === quote)
|
|
63
|
+
quote = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (ch === '"' || ch === "'") {
|
|
67
|
+
quote = ch;
|
|
68
|
+
current += ch;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === "\n" || ch === ";") {
|
|
72
|
+
segments.push(current);
|
|
73
|
+
current = "";
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === "|" || ch === "&") {
|
|
77
|
+
// `||`, `&&`, `|`, `&` all end the simple command; collapse a doubled char.
|
|
78
|
+
if (command[i + 1] === ch)
|
|
79
|
+
i++;
|
|
80
|
+
segments.push(current);
|
|
81
|
+
current = "";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
current += ch;
|
|
85
|
+
}
|
|
86
|
+
segments.push(current);
|
|
87
|
+
return segments.map((s) => s.trim()).filter((s) => s !== "");
|
|
88
|
+
}
|
|
89
|
+
function tokenize(segment) {
|
|
90
|
+
return segment.split(/\s+/).filter((t) => t !== "");
|
|
91
|
+
}
|
|
92
|
+
// Drop `sudo`, `env`, and leading VAR=value assignments so the command word is
|
|
93
|
+
// the first token the rules look at.
|
|
94
|
+
function commandTokens(segment) {
|
|
95
|
+
const tokens = tokenize(segment);
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < tokens.length) {
|
|
98
|
+
const t = tokens[i];
|
|
99
|
+
if (t === "sudo" || t === "env" || t === "command" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
return tokens.slice(i);
|
|
106
|
+
}
|
|
107
|
+
// For `git`, skip global options (`-C <dir>`, `-c k=v`, `--git-dir=…`) to find
|
|
108
|
+
// the subcommand and its own arguments.
|
|
109
|
+
function gitSubcommand(tokens) {
|
|
110
|
+
if (tokens[0] !== "git")
|
|
111
|
+
return null;
|
|
112
|
+
let i = 1;
|
|
113
|
+
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
114
|
+
const t = tokens[i];
|
|
115
|
+
i += t === "-C" || t === "-c" || t === "--git-dir" || t === "--work-tree" ? 2 : 1;
|
|
116
|
+
}
|
|
117
|
+
const sub = tokens[i];
|
|
118
|
+
if (!sub)
|
|
119
|
+
return null;
|
|
120
|
+
return { sub, args: tokens.slice(i + 1) };
|
|
121
|
+
}
|
|
122
|
+
const isShortFlag = (t) => /^-[A-Za-z]+$/.test(t);
|
|
123
|
+
const hasShortLetter = (t, letters) => isShortFlag(t) && letters.test(t.slice(1));
|
|
124
|
+
const STRUCTURED_RULES = [
|
|
125
|
+
{
|
|
126
|
+
id: "rm-recursive-force",
|
|
127
|
+
test: (tokens) => {
|
|
128
|
+
if (tokens[0] !== "rm")
|
|
129
|
+
return false;
|
|
130
|
+
const flags = tokens.slice(1).filter((t) => t.startsWith("-"));
|
|
131
|
+
const recursive = flags.some((t) => t === "--recursive" || hasShortLetter(t, /[rR]/));
|
|
132
|
+
const force = flags.some((t) => t === "--force" || hasShortLetter(t, /f/));
|
|
133
|
+
return recursive && force;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "git-clean-force",
|
|
138
|
+
test: (tokens) => {
|
|
139
|
+
const git = gitSubcommand(tokens);
|
|
140
|
+
if (!git || git.sub !== "clean")
|
|
141
|
+
return false;
|
|
142
|
+
const dryRun = git.args.some((t) => t === "--dry-run" || hasShortLetter(t, /n/));
|
|
143
|
+
const force = git.args.some((t) => t === "--force" || hasShortLetter(t, /f/));
|
|
144
|
+
return force && !dryRun;
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "git-checkout-discard",
|
|
149
|
+
test: (tokens) => {
|
|
150
|
+
const git = gitSubcommand(tokens);
|
|
151
|
+
if (!git || git.sub !== "checkout")
|
|
152
|
+
return false;
|
|
153
|
+
const dash = git.args.indexOf("--");
|
|
154
|
+
if (dash !== -1)
|
|
155
|
+
return git.args.length > dash + 1; // `git checkout -- <pathspec>` discards
|
|
156
|
+
const first = git.args.find((t) => !t.startsWith("-"));
|
|
157
|
+
return first === "." || first === "./";
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "git-restore-discard",
|
|
162
|
+
test: (tokens) => {
|
|
163
|
+
const git = gitSubcommand(tokens);
|
|
164
|
+
if (!git || git.sub !== "restore")
|
|
165
|
+
return false;
|
|
166
|
+
const pathspec = git.args.some((t) => !t.startsWith("-"));
|
|
167
|
+
if (!pathspec)
|
|
168
|
+
return false;
|
|
169
|
+
const staged = git.args.some((t) => t === "--staged" || hasShortLetter(t, /S/));
|
|
170
|
+
const worktree = git.args.some((t) => t === "--worktree" || hasShortLetter(t, /W/));
|
|
171
|
+
return !staged || worktree; // `--staged` alone only unstages
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "find-delete",
|
|
176
|
+
test: (tokens) => tokens[0] === "find" && tokens.includes("-delete"),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "git-push-plus-refspec",
|
|
180
|
+
test: (tokens) => {
|
|
181
|
+
const git = gitSubcommand(tokens);
|
|
182
|
+
if (!git || git.sub !== "push")
|
|
183
|
+
return false;
|
|
184
|
+
return git.args.some((t) => t.startsWith("+") && t.length > 1);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "git-stash-drop",
|
|
189
|
+
test: (tokens) => {
|
|
190
|
+
const git = gitSubcommand(tokens);
|
|
191
|
+
if (!git || git.sub !== "stash")
|
|
192
|
+
return false;
|
|
193
|
+
return git.args[0] === "drop" || git.args[0] === "clear";
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
/** Classify one Bash command string. Pure; never throws on odd input. */
|
|
198
|
+
export function classifyDestructiveBash(command) {
|
|
199
|
+
if (typeof command !== "string" || command.trim() === "")
|
|
200
|
+
return { destructive: false, rule: null };
|
|
201
|
+
const stripped = stripMessageArgs(command);
|
|
202
|
+
for (const segment of splitSimpleCommands(stripped)) {
|
|
203
|
+
const tokens = commandTokens(segment);
|
|
204
|
+
if (tokens.length === 0)
|
|
205
|
+
continue;
|
|
206
|
+
for (const rule of STRUCTURED_RULES) {
|
|
207
|
+
if (rule.test(tokens))
|
|
208
|
+
return { destructive: true, rule: rule.id };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const lower = stripped.toLowerCase();
|
|
212
|
+
const hit = DESTRUCTIVE_PATTERNS.find((p) => lower.includes(p.toLowerCase()));
|
|
213
|
+
if (hit !== undefined)
|
|
214
|
+
return { destructive: true, rule: `substring:${hit}` };
|
|
215
|
+
return { destructive: false, rule: null };
|
|
216
|
+
}
|
|
@@ -45,7 +45,7 @@ export function resolveSessionDir(sessionId) {
|
|
|
45
45
|
if (fromEnv)
|
|
46
46
|
return fromEnv;
|
|
47
47
|
const projectRoot = resolveProjectRoot();
|
|
48
|
-
const projectHash =
|
|
48
|
+
const projectHash = hashProjectRoot(projectRoot);
|
|
49
49
|
const base = join(resolveInstinctsRoot(), projectHash);
|
|
50
50
|
const scoped = sanitizeSessionId(sessionId);
|
|
51
51
|
return scoped ? join(base, "sessions", scoped) : base;
|
|
@@ -160,6 +160,10 @@ export function canonicalizeProjectRoot(p) {
|
|
|
160
160
|
export function canonicalizeFileKey(p) {
|
|
161
161
|
return canonicalizePath(p);
|
|
162
162
|
}
|
|
163
|
+
/** SHA-256[:12] of the canonical project root. C:/x, c:/x, and C:\\x share a bucket. */
|
|
164
|
+
export function hashProjectRoot(projectRoot) {
|
|
165
|
+
return createHash("sha256").update(canonicalizeProjectRoot(projectRoot)).digest("hex").slice(0, 12);
|
|
166
|
+
}
|
|
163
167
|
export function isFileCleared(state, filePath) {
|
|
164
168
|
return canonicalizeFileKey(filePath) in state.cleared_files;
|
|
165
169
|
}
|
|
@@ -26,7 +26,7 @@ const KEYWORDS = [
|
|
|
26
26
|
"transcript-linter",
|
|
27
27
|
];
|
|
28
28
|
const CLAUDE_PLUGIN_CATEGORY = "productivity";
|
|
29
|
-
const SHARED_PLUGIN_DESCRIPTION = "The persistent-memory and runtime-discipline layer for Claude Code. It remembers the corrections you already gave, grounds every edit in real facts before it lands, and — through the Mulahazah engine — turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time with no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as
|
|
29
|
+
const SHARED_PLUGIN_DESCRIPTION = "The persistent-memory and runtime-discipline layer for Claude Code. It remembers the corrections you already gave, grounds every edit in real facts before it lands, and — through the Mulahazah engine — turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time with no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as 28 bundled skills, instinct-aware hooks, an MCP toolset for recall and reflection, and a GitHub Action transcript linter that feeds real work history back into sharper instincts.";
|
|
30
30
|
// Four vendored upstream companions registered alongside the CI plugin.
|
|
31
31
|
// Each entry points at a pinned-SHA snapshot under third-party/<name>/.
|
|
32
32
|
// See third-party/MANIFEST.md for refresh recipes and per-snapshot
|
|
@@ -474,6 +474,11 @@ export function getPluginHooksConfig() {
|
|
|
474
474
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-pack.mjs\"",
|
|
475
475
|
timeout: hookTimeoutSeconds,
|
|
476
476
|
};
|
|
477
|
+
const configGuardCommand = {
|
|
478
|
+
type: "command",
|
|
479
|
+
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/config-guard.mjs\"",
|
|
480
|
+
timeout: hookTimeoutSeconds,
|
|
481
|
+
};
|
|
477
482
|
const observeCommand = {
|
|
478
483
|
type: "command",
|
|
479
484
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/bin/observe.mjs\"",
|
|
@@ -538,6 +543,12 @@ export function getPluginHooksConfig() {
|
|
|
538
543
|
// Edit/Read/etc. Warn-default (CLAUDE_CI_HOOKPACK_GATE) — never blocks
|
|
539
544
|
// until the operator opts in.
|
|
540
545
|
{ matcher: "Bash", hooks: [hookPackCommand] },
|
|
546
|
+
// config-guard watches the files that wire the guardrails
|
|
547
|
+
// (.claude/settings*.json, .mcp.json, hooks.json, .claude/hooks/,
|
|
548
|
+
// .claude/plugins/, .claude-plugin/) and the `claude plugin|mcp|config`
|
|
549
|
+
// CLI forms. Matcher keeps read-only tools off the hot path.
|
|
550
|
+
// Warn-default (CI_CONFIG_GUARD) — denies only under block.
|
|
551
|
+
{ matcher: "Bash|Edit|MultiEdit|Write|NotebookEdit", hooks: [configGuardCommand] },
|
|
541
552
|
],
|
|
542
553
|
PostToolUse: [{ hooks: [observeCommand] }],
|
|
543
554
|
UserPromptSubmit: [{ hooks: [routePromptCommand, recallBriefingCommand] }],
|