@monster-orange/den 0.4.5 → 0.4.7
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/CHANGELOG.md +140 -0
- package/README.md +21 -7
- package/docs/distributions.md +221 -0
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/agents/den-claude.ts +19 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +55 -6
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +67 -6
- package/src/shared/dev-metrics/emit.ts +59 -5
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/port-pick.ts +60 -0
- package/src/ui/cli/agent-ops.ts +7 -3
- package/src/ui/cli/agents-command.ts +51 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +87 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -0
- package/src/ui/cli/index.ts +352 -5
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +3 -0
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/public/settings-registry.js +3 -3
- package/src/ui/server/request-guard.ts +1 -1
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* `<state>/sessions/**`. It binds a
|
|
16
16
|
* cooperating agent only: the agent runs as the same OS user, so nothing
|
|
17
17
|
* stops one that ignores its permission config from reading them.
|
|
18
|
+
* - Den's guardrail hooks (den#5, guardrails.ts), when the persona's
|
|
19
|
+
* guardrails include them: the same settings file carries
|
|
20
|
+
* guardrails/hooks.json's `hooks` (PreToolUse on Bash and Read, and Stop),
|
|
21
|
+
* alongside any hooks the user has; Den never writes ~/.claude.
|
|
18
22
|
* - Each launch picks a fresh conversation id (--session-id); resume
|
|
19
23
|
* continues it with --resume <id>, in the same cwd, with the same MCP.
|
|
20
24
|
* - Permission prompts stay in the terminal (approvals: "terminal"):
|
|
@@ -41,6 +45,7 @@ import * as fs from "fs";
|
|
|
41
45
|
import * as path from "path";
|
|
42
46
|
import { configuredRuntimeBin } from "../den-config.js";
|
|
43
47
|
import { rolePrompt } from "./personas.js";
|
|
48
|
+
import { guardrailHooks } from "./guardrails.js";
|
|
44
49
|
import {
|
|
45
50
|
LaunchError,
|
|
46
51
|
mcpConfigJson,
|
|
@@ -221,7 +226,8 @@ function common(input: LaunchInput): {
|
|
|
221
226
|
} {
|
|
222
227
|
const file = mcpConfigPath(input);
|
|
223
228
|
const mode = permissionMode(input);
|
|
224
|
-
const
|
|
229
|
+
const hooks = input.persona.guardrails?.hooks ? guardrailHooks() : null;
|
|
230
|
+
const settings = input.stateDir || hooks ? settingsPath(input) : null;
|
|
225
231
|
return {
|
|
226
232
|
argv: [
|
|
227
233
|
"--mcp-config",
|
|
@@ -241,13 +247,21 @@ function common(input: LaunchInput): {
|
|
|
241
247
|
],
|
|
242
248
|
files: {
|
|
243
249
|
[file]: mcpConfigJson(input.mcp),
|
|
244
|
-
...(settings
|
|
250
|
+
...(settings
|
|
245
251
|
? {
|
|
246
252
|
[settings]: `${JSON.stringify(
|
|
247
253
|
{
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
...(input.stateDir
|
|
255
|
+
? {
|
|
256
|
+
permissions: {
|
|
257
|
+
deny: stateDirDenyRules(
|
|
258
|
+
input.stateDir,
|
|
259
|
+
input.env?.HOME,
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
: {}),
|
|
264
|
+
...(hooks ? { hooks } : {}),
|
|
251
265
|
},
|
|
252
266
|
null,
|
|
253
267
|
2,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash|Read",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "{pretooluse}",
|
|
10
|
+
"timeout": 10
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"Stop": [
|
|
16
|
+
{
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "{stop}",
|
|
21
|
+
"timeout": 30
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
## How you work and write (every Den persona)
|
|
2
|
+
|
|
3
|
+
### Writing to people
|
|
4
|
+
|
|
5
|
+
- Write plainly. Use short sentences.
|
|
6
|
+
- Never name an issue or an ID without saying what it is. Write "#5 (the
|
|
7
|
+
hooks issue)" or "the plain-writing rule (R12)", never a bare "#5" or "R12".
|
|
8
|
+
- Ask each decision as a numbered yes/no question. One question per number.
|
|
9
|
+
- No caveat sections. Leave out anything the person doesn't need in order to
|
|
10
|
+
act.
|
|
11
|
+
|
|
12
|
+
### Reading and waiting
|
|
13
|
+
|
|
14
|
+
- Don't read a whole log or a large file into the conversation. Read a slice
|
|
15
|
+
(`tail -n 100`, `grep -n`), or hand the file to a subagent and ask for the
|
|
16
|
+
conclusion.
|
|
17
|
+
- Don't poll with `sleep` or `until …; do sleep …; done`. Run long work in the
|
|
18
|
+
background and wait to be told it ended, or use a wait primitive.
|
|
19
|
+
|
|
20
|
+
### Handing work back
|
|
21
|
+
|
|
22
|
+
When you hand work back to a person, end with this, filled in. Den checks it:
|
|
23
|
+
a hand-back with a missing part, an unexplained ID, or a decision that is not
|
|
24
|
+
a numbered yes/no question is sent back to you to fix.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
## Hand-back
|
|
28
|
+
- URL: <the full link the person opens, with https://>
|
|
29
|
+
- Machine: <the machine it runs on>
|
|
30
|
+
- Branch: <the branch>
|
|
31
|
+
- What changed: <what is different now, in a sentence or two>
|
|
32
|
+
- What to click: <the steps to see it working>
|
|
33
|
+
- Decisions:
|
|
34
|
+
1. <a question the person answers yes or no>?
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Write "Decisions: none" when there are none, and "URL: none" (with a short
|
|
38
|
+
reason) when the work has no page to open. Every message you end a turn
|
|
39
|
+
with is read by a person, hand-back or not: explain each ID in it.
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Den's PreToolUse hook for Claude Code sessions it launches (den-claude).
|
|
4
|
+
* Den passes it in the agent's own settings file (--settings); it never
|
|
5
|
+
* touches the user's ~/.claude. Plain Node with no imports beyond node:*,
|
|
6
|
+
* so it starts in a few tens of milliseconds on every Bash and Read call.
|
|
7
|
+
*
|
|
8
|
+
* Hook contract (https://code.claude.com/docs/en/hooks, "PreToolUse" and
|
|
9
|
+
* "Exit code output"): the tool call arrives as JSON on stdin
|
|
10
|
+
* ({ tool_name, tool_input, cwd, ... }). Exit 2 blocks the call, and Claude
|
|
11
|
+
* gets stderr as the reason. Exit 0 with no output lets the normal
|
|
12
|
+
* permission flow decide. Any error here exits 0: a broken hook never
|
|
13
|
+
* blocks anything.
|
|
14
|
+
*
|
|
15
|
+
* It blocks exactly two things, and nothing else:
|
|
16
|
+
*
|
|
17
|
+
* 1. Whole-file reads of a log or a large file into the conversation:
|
|
18
|
+
* - Read with no `limit`;
|
|
19
|
+
* - Bash `cat` or `less` of it, when its output reaches the
|
|
20
|
+
* conversation (the last stage of a pipeline, stdout not redirected);
|
|
21
|
+
* - Bash `tail` from a line or byte (`tail +1`, `-n +1`, `--lines=+1`),
|
|
22
|
+
* which prints the whole rest of the file, likewise. A plain `tail`
|
|
23
|
+
* prints 10 lines, a slice, and passes.
|
|
24
|
+
* A log is `*.log` (or `*.log.<N>`), a `.jsonl` under `.dev-metrics/`,
|
|
25
|
+
* or a `.jsonl` under a `.claude/projects/` directory (a transcript).
|
|
26
|
+
* Large is over LARGE_BYTES.
|
|
27
|
+
* 2. Foreground `sleep` in Bash: `sleep N` on its own or inside an
|
|
28
|
+
* `until ...; do sleep N; done` (or while/for) polling loop. A Bash call
|
|
29
|
+
* with run_in_background, or a `sleep ... &`, is not blocked.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
|
|
35
|
+
/** Over this many bytes, a file is large (Claude Code's own Read cap). */
|
|
36
|
+
export const LARGE_BYTES = 256 * 1024;
|
|
37
|
+
|
|
38
|
+
const READ_HINT =
|
|
39
|
+
"Read a slice instead: `tail -n 100 <file>`, `grep -n <pattern> <file>`, " +
|
|
40
|
+
"or a Read with offset and limit. To go through all of it, hand it to a " +
|
|
41
|
+
"subagent and ask it for the conclusion.";
|
|
42
|
+
|
|
43
|
+
const SLEEP_HINT =
|
|
44
|
+
"Waiting with sleep is polling. Run the long command with " +
|
|
45
|
+
"run_in_background (you are told when it ends), or use a wait primitive " +
|
|
46
|
+
"(the Monitor tool with an until-condition, or the command's own --wait). " +
|
|
47
|
+
"Do not loop on sleep.";
|
|
48
|
+
|
|
49
|
+
/** Is this path a log: *.log, .dev-metrics/*.jsonl, or a transcript? */
|
|
50
|
+
export function isLogPath(p) {
|
|
51
|
+
const s = String(p).replace(/\\/g, "/");
|
|
52
|
+
const base = s.slice(s.lastIndexOf("/") + 1);
|
|
53
|
+
if (/\.log(\.\d+)?$/i.test(base)) return true;
|
|
54
|
+
if (!/\.jsonl$/i.test(base)) return false;
|
|
55
|
+
if (/(^|\/)\.dev-metrics\//.test(s)) return true;
|
|
56
|
+
return /(^|\/)\.claude\/projects\//.test(s);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The file's size, or -1 when it can't be read (a glob, a variable). */
|
|
60
|
+
function sizeOf(p, cwd, home) {
|
|
61
|
+
try {
|
|
62
|
+
let f = String(p);
|
|
63
|
+
if (home && (f === "~" || f.startsWith("~/")))
|
|
64
|
+
f = path.join(home, f.slice(1));
|
|
65
|
+
const st = fs.statSync(path.resolve(cwd || ".", f));
|
|
66
|
+
return st.isFile() ? st.size : -1;
|
|
67
|
+
} catch {
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Why reading all of this file is blocked, or null. */
|
|
73
|
+
export function wholeReadReason(p, cwd, home) {
|
|
74
|
+
if (isLogPath(p)) return "a log";
|
|
75
|
+
const n = sizeOf(p, cwd, home);
|
|
76
|
+
if (n > LARGE_BYTES) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Split a shell command into pipelines of stages. Each stage is
|
|
82
|
+
* { words, stdoutRedirected, background }. Quotes and backslashes are
|
|
83
|
+
* honoured, `$( … )` stays inside its word, comments and heredoc bodies are
|
|
84
|
+
* skipped. Not a full shell parser: anything it can't read is left alone.
|
|
85
|
+
*/
|
|
86
|
+
export function parseShell(src) {
|
|
87
|
+
const pipelines = [];
|
|
88
|
+
let stages = [];
|
|
89
|
+
let words = [];
|
|
90
|
+
let word = "";
|
|
91
|
+
let inWord = false;
|
|
92
|
+
const heredocs = [];
|
|
93
|
+
|
|
94
|
+
const endWord = () => {
|
|
95
|
+
if (inWord) words.push(word);
|
|
96
|
+
word = "";
|
|
97
|
+
inWord = false;
|
|
98
|
+
};
|
|
99
|
+
const endStage = () => {
|
|
100
|
+
endWord();
|
|
101
|
+
if (words.length) stages.push(stage(words));
|
|
102
|
+
words = [];
|
|
103
|
+
};
|
|
104
|
+
const endPipeline = (background = false) => {
|
|
105
|
+
endStage();
|
|
106
|
+
if (stages.length) {
|
|
107
|
+
if (background) stages[stages.length - 1].background = true;
|
|
108
|
+
pipelines.push(stages);
|
|
109
|
+
}
|
|
110
|
+
stages = [];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let i = 0;
|
|
114
|
+
while (i < src.length) {
|
|
115
|
+
const c = src[i];
|
|
116
|
+
if (c === "\\" && i + 1 < src.length) {
|
|
117
|
+
if (src[i + 1] !== "\n") word += src[i + 1];
|
|
118
|
+
inWord = inWord || src[i + 1] !== "\n";
|
|
119
|
+
i += 2;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === "'") {
|
|
123
|
+
const j = src.indexOf("'", i + 1);
|
|
124
|
+
const end = j < 0 ? src.length : j;
|
|
125
|
+
word += src.slice(i + 1, end);
|
|
126
|
+
inWord = true;
|
|
127
|
+
i = end + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (c === '"') {
|
|
131
|
+
let j = i + 1;
|
|
132
|
+
while (j < src.length && src[j] !== '"') {
|
|
133
|
+
if (src[j] === "\\" && j + 1 < src.length) {
|
|
134
|
+
word += src[j + 1];
|
|
135
|
+
j += 2;
|
|
136
|
+
} else word += src[j++];
|
|
137
|
+
}
|
|
138
|
+
inWord = true;
|
|
139
|
+
i = j + 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c === "$" && src[i + 1] === "(") {
|
|
143
|
+
let depth = 0;
|
|
144
|
+
let j = i + 1;
|
|
145
|
+
for (; j < src.length; j++) {
|
|
146
|
+
if (src[j] === "(") depth++;
|
|
147
|
+
else if (src[j] === ")" && --depth === 0) break;
|
|
148
|
+
}
|
|
149
|
+
word += src.slice(i, j + 1);
|
|
150
|
+
inWord = true;
|
|
151
|
+
i = j + 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c === "`") {
|
|
155
|
+
const j = src.indexOf("`", i + 1);
|
|
156
|
+
const end = j < 0 ? src.length : j;
|
|
157
|
+
word += src.slice(i, end + 1);
|
|
158
|
+
inWord = true;
|
|
159
|
+
i = end + 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (c === "#" && !inWord) {
|
|
163
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === "\n") {
|
|
167
|
+
endPipeline();
|
|
168
|
+
i++;
|
|
169
|
+
// Skip heredoc bodies that start on this line.
|
|
170
|
+
while (heredocs.length) {
|
|
171
|
+
const delim = heredocs.shift();
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (i >= src.length) break;
|
|
174
|
+
const nl = src.indexOf("\n", i);
|
|
175
|
+
const line = src.slice(i, nl < 0 ? src.length : nl);
|
|
176
|
+
i = nl < 0 ? src.length : nl + 1;
|
|
177
|
+
if (line.replace(/^\t+/, "") === delim) break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (c === " " || c === "\t") {
|
|
183
|
+
endWord();
|
|
184
|
+
i++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (c === "<" && src[i + 1] === "<" && src[i + 2] !== "<") {
|
|
188
|
+
endWord();
|
|
189
|
+
let j = i + 2;
|
|
190
|
+
if (src[j] === "-") j++;
|
|
191
|
+
while (src[j] === " " || src[j] === "\t") j++;
|
|
192
|
+
let d = "";
|
|
193
|
+
while (j < src.length && !/[\s;&|<>()]/.test(src[j])) d += src[j++];
|
|
194
|
+
heredocs.push(d.replace(/['"\\]/g, ""));
|
|
195
|
+
words.push("<<");
|
|
196
|
+
i = j;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (c === "&" && src[i + 1] === "&") {
|
|
200
|
+
endPipeline();
|
|
201
|
+
i += 2;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (c === "|" && src[i + 1] === "|") {
|
|
205
|
+
endPipeline();
|
|
206
|
+
i += 2;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (c === "&" && src[i + 1] === ">") {
|
|
210
|
+
endWord();
|
|
211
|
+
words.push(src[i + 2] === ">" ? "&>>" : "&>");
|
|
212
|
+
i += src[i + 2] === ">" ? 3 : 2;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (c === "|") {
|
|
216
|
+
endStage();
|
|
217
|
+
i += src[i + 1] === "&" ? 2 : 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (c === "&") {
|
|
221
|
+
endPipeline(true);
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (c === ";" || c === "(" || c === ")") {
|
|
226
|
+
endPipeline();
|
|
227
|
+
i++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (c === ">" || c === "<") {
|
|
231
|
+
// A redirection operator: its own word, with any fd before it.
|
|
232
|
+
const fd = inWord && /^\d+$/.test(word) ? word : "";
|
|
233
|
+
if (fd) {
|
|
234
|
+
word = "";
|
|
235
|
+
inWord = false;
|
|
236
|
+
} else endWord();
|
|
237
|
+
let op = fd + c;
|
|
238
|
+
let j = i + 1;
|
|
239
|
+
if (src[j] === ">" || src[j] === "&") op += src[j++];
|
|
240
|
+
words.push(op);
|
|
241
|
+
i = j;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
word += c;
|
|
245
|
+
inWord = true;
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
endPipeline();
|
|
249
|
+
return pipelines;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const PREFIX = new Set([
|
|
253
|
+
"do",
|
|
254
|
+
"then",
|
|
255
|
+
"else",
|
|
256
|
+
"elif",
|
|
257
|
+
"while",
|
|
258
|
+
"until",
|
|
259
|
+
"if",
|
|
260
|
+
"!",
|
|
261
|
+
"{",
|
|
262
|
+
"}",
|
|
263
|
+
"time",
|
|
264
|
+
"exec",
|
|
265
|
+
"command",
|
|
266
|
+
"nohup",
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
/** One stage: its command and arguments, with redirections taken out. */
|
|
270
|
+
function stage(raw) {
|
|
271
|
+
let k = 0;
|
|
272
|
+
while (
|
|
273
|
+
k < raw.length &&
|
|
274
|
+
(PREFIX.has(raw[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw[k]))
|
|
275
|
+
)
|
|
276
|
+
k++;
|
|
277
|
+
const words = [];
|
|
278
|
+
const inputs = [];
|
|
279
|
+
let stdoutRedirected = false;
|
|
280
|
+
for (let j = k; j < raw.length; j++) {
|
|
281
|
+
const w = raw[j];
|
|
282
|
+
const r = /^(\d*|&)>>?(&?)$/.exec(w);
|
|
283
|
+
if (r) {
|
|
284
|
+
// Claude Code shows stdout and stderr both, so only stdout sent to a
|
|
285
|
+
// file leaves the chat: `>f`, `1>f`, `&>f`. `2>f`, `2>&1`, `>&2` don't.
|
|
286
|
+
const toFd = r[2] === "&" && /^\d+$/.test(raw[j + 1] ?? "");
|
|
287
|
+
if ((r[1] === "" || r[1] === "1" || r[1] === "&") && !toFd)
|
|
288
|
+
stdoutRedirected = true;
|
|
289
|
+
j++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (w === "<") {
|
|
293
|
+
if (raw[j + 1] !== undefined) inputs.push(raw[j + 1]);
|
|
294
|
+
j++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (w === "<<") {
|
|
298
|
+
j++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
words.push(w);
|
|
302
|
+
}
|
|
303
|
+
return { words, inputs, stdoutRedirected, background: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const base = (w) => String(w).slice(String(w).lastIndexOf("/") + 1);
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* tail reads the whole rest of the file only when it starts from a line or
|
|
310
|
+
* byte (`+N`, `-n +N`, `-n+N`, `--lines=+N`, the same for -c/--bytes); a
|
|
311
|
+
* plain tail prints 10 lines, and a count or a follow is a slice.
|
|
312
|
+
*/
|
|
313
|
+
function tailReadsAll(args) {
|
|
314
|
+
for (let j = 0; j < args.length; j++) {
|
|
315
|
+
const a = args[j];
|
|
316
|
+
if (/^\+\d+$/.test(a)) return true;
|
|
317
|
+
if (/^-[nc]\+\d+$/.test(a) || /^--(lines|bytes)=\+\d+$/.test(a))
|
|
318
|
+
return true;
|
|
319
|
+
if (
|
|
320
|
+
(a === "-n" || a === "-c" || a === "--lines" || a === "--bytes") &&
|
|
321
|
+
/^\+\d+$/.test(args[j + 1] ?? "")
|
|
322
|
+
)
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The file arguments of cat, less or tail (options and their values out). */
|
|
329
|
+
function fileArgs(cmd, args) {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (let j = 0; j < args.length; j++) {
|
|
332
|
+
const a = args[j];
|
|
333
|
+
if (a === "--") {
|
|
334
|
+
out.push(...args.slice(j + 1));
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (a === "-") continue;
|
|
338
|
+
if (a.startsWith("-") || (cmd === "tail" && /^\+\d+$/.test(a))) {
|
|
339
|
+
if (cmd === "tail" && (a === "-n" || a === "-c")) j++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
out.push(a);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The reason to block this Bash command, or null. */
|
|
348
|
+
export function bashDecision(command, opts = {}) {
|
|
349
|
+
const { cwd, home, background } = opts;
|
|
350
|
+
let pipelines;
|
|
351
|
+
try {
|
|
352
|
+
pipelines = parseShell(String(command ?? ""));
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
for (const stages of pipelines)
|
|
357
|
+
for (let s = 0; s < stages.length; s++) {
|
|
358
|
+
const st = stages[s];
|
|
359
|
+
const cmd = base(st.words[0] ?? "");
|
|
360
|
+
const args = st.words.slice(1);
|
|
361
|
+
if (cmd === "sleep" && args.length && !background && !st.background)
|
|
362
|
+
return `Den blocked \`sleep ${args.join(" ")}\`. ${SLEEP_HINT}`;
|
|
363
|
+
if (cmd !== "cat" && cmd !== "less" && cmd !== "tail") continue;
|
|
364
|
+
// Output that goes on to another command or a file never reaches the chat.
|
|
365
|
+
if (s < stages.length - 1 || st.stdoutRedirected) continue;
|
|
366
|
+
if (cmd === "tail" && !tailReadsAll(args)) continue;
|
|
367
|
+
for (const f of [...fileArgs(cmd, args), ...st.inputs]) {
|
|
368
|
+
const why = wholeReadReason(f, cwd, home);
|
|
369
|
+
if (why)
|
|
370
|
+
return `Den blocked \`${[cmd, ...args].join(" ")}\`: it reads all of ${f} (${why}) into the conversation. ${READ_HINT}`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The reason to block this tool call, or null. */
|
|
377
|
+
export function decide(input, env = process.env) {
|
|
378
|
+
if (!input || typeof input !== "object") return null;
|
|
379
|
+
const ti =
|
|
380
|
+
input.tool_input && typeof input.tool_input === "object"
|
|
381
|
+
? input.tool_input
|
|
382
|
+
: {};
|
|
383
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
|
|
384
|
+
const home = env.HOME;
|
|
385
|
+
if (input.tool_name === "Read") {
|
|
386
|
+
if (ti.limit !== undefined && ti.limit !== null) return null;
|
|
387
|
+
if (typeof ti.file_path !== "string") return null;
|
|
388
|
+
const why = wholeReadReason(ti.file_path, cwd, home);
|
|
389
|
+
return why
|
|
390
|
+
? `Den blocked a Read of all of ${ti.file_path} (${why}). ${READ_HINT}`
|
|
391
|
+
: null;
|
|
392
|
+
}
|
|
393
|
+
if (input.tool_name === "Bash")
|
|
394
|
+
return bashDecision(ti.command, {
|
|
395
|
+
cwd,
|
|
396
|
+
home,
|
|
397
|
+
background: ti.run_in_background === true,
|
|
398
|
+
});
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function main() {
|
|
403
|
+
let text = "";
|
|
404
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
405
|
+
let reason = null;
|
|
406
|
+
try {
|
|
407
|
+
reason = decide(JSON.parse(text));
|
|
408
|
+
} catch {
|
|
409
|
+
reason = null;
|
|
410
|
+
}
|
|
411
|
+
if (reason) {
|
|
412
|
+
process.stderr.write(`${reason}\n`);
|
|
413
|
+
process.exit(2);
|
|
414
|
+
}
|
|
415
|
+
process.exit(0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Run as a script (the hook), not imported. */
|
|
419
|
+
function isMain() {
|
|
420
|
+
try {
|
|
421
|
+
return (
|
|
422
|
+
!!process.argv[1] &&
|
|
423
|
+
fs.realpathSync(process.argv[1]) ===
|
|
424
|
+
fs.realpathSync(fileURLToPath(import.meta.url))
|
|
425
|
+
);
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (isMain()) main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Den's Stop hook for Claude Code sessions it launches (den-claude): the
|
|
3
|
+
* hand-back check (handback.ts) on the turn's last message.
|
|
4
|
+
*
|
|
5
|
+
* Hook contract (https://code.claude.com/docs/en/hooks, "Stop input" and
|
|
6
|
+
* "Stop decision control"): JSON on stdin with `last_assistant_message`
|
|
7
|
+
* and `stop_hook_active`. Printing `{"decision":"block","reason":…}` keeps
|
|
8
|
+
* Claude going with the reason; printing nothing lets it stop.
|
|
9
|
+
*
|
|
10
|
+
* A message with the "Hand-back" heading (the template's) gets the whole
|
|
11
|
+
* hand-back check. Any other last message is still what the person reads,
|
|
12
|
+
* so it gets the plain-writing part only: every ID explained (R12).
|
|
13
|
+
* When Claude is already continuing because of a stop hook
|
|
14
|
+
* (stop_hook_active), it lets the turn end, so it never loops. Any error
|
|
15
|
+
* lets the turn end too.
|
|
16
|
+
*/
|
|
17
|
+
import { bareIds, checkHandback, looksLikeHandback } from "../handback.js";
|
|
18
|
+
|
|
19
|
+
/** The hook's stdout for one Stop input ("" lets the turn end). */
|
|
20
|
+
export function stopDecision(input: unknown): string {
|
|
21
|
+
if (!input || typeof input !== "object") return "";
|
|
22
|
+
const i = input as {
|
|
23
|
+
stop_hook_active?: unknown;
|
|
24
|
+
last_assistant_message?: unknown;
|
|
25
|
+
};
|
|
26
|
+
if (i.stop_hook_active === true) return "";
|
|
27
|
+
const msg = i.last_assistant_message;
|
|
28
|
+
if (typeof msg !== "string") return "";
|
|
29
|
+
// A hand-back is checked whole. Any other final message is what the
|
|
30
|
+
// person reads too, so it gets the plain-writing check: no bare IDs.
|
|
31
|
+
const handback = looksLikeHandback(msg);
|
|
32
|
+
const problems = handback
|
|
33
|
+
? checkHandback(msg).problems
|
|
34
|
+
: bareIds(msg).map(
|
|
35
|
+
(id) =>
|
|
36
|
+
`"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
|
|
37
|
+
);
|
|
38
|
+
if (problems.length === 0) return "";
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
decision: "block",
|
|
41
|
+
reason: [
|
|
42
|
+
handback
|
|
43
|
+
? "Den checked your hand-back and it is not ready. Fix these, then send it again:"
|
|
44
|
+
: "Den checked your message to the person and it is not plain yet. Fix these, then send it again:",
|
|
45
|
+
...problems.map((p) => `- ${p}`),
|
|
46
|
+
].join("\n"),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main(): Promise<void> {
|
|
51
|
+
let text = "";
|
|
52
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
53
|
+
let out = "";
|
|
54
|
+
try {
|
|
55
|
+
out = stopDecision(JSON.parse(text));
|
|
56
|
+
} catch {
|
|
57
|
+
out = "";
|
|
58
|
+
}
|
|
59
|
+
if (out) process.stdout.write(`${out}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] && /stop-hook\.[jt]s$/.test(process.argv[1]))
|
|
63
|
+
main().then(
|
|
64
|
+
() => process.exit(0),
|
|
65
|
+
() => process.exit(0),
|
|
66
|
+
);
|