auriga-cli 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks.d.ts +36 -1
- package/dist/hooks.js +81 -6
- package/package.json +1 -1
package/dist/hooks.d.ts
CHANGED
|
@@ -5,7 +5,17 @@ export interface HookDep {
|
|
|
5
5
|
}
|
|
6
6
|
export interface HookSettingsEvent {
|
|
7
7
|
event: string;
|
|
8
|
+
/** Tool-name / regex filter; mapped onto the container-level
|
|
9
|
+
* `matcher` field in settings.json. Absent → every tool fires. */
|
|
8
10
|
matcher?: string;
|
|
11
|
+
/** Permission-rule-syntax filter — mapped onto the nested action-level
|
|
12
|
+
* `if` field in settings.json. Format: `<ToolName>(<substring>)`, e.g.
|
|
13
|
+
* `Bash(gh pr create)` — see IF_RE for the exact grammar. Lets the
|
|
14
|
+
* Claude Code runtime skip hook dispatch when the tool input doesn't
|
|
15
|
+
* match, avoiding a Node subprocess spawn per unrelated call.
|
|
16
|
+
* Absent → no registry-level content filter (the hook script runs
|
|
17
|
+
* for every invocation that passed `matcher`). */
|
|
18
|
+
if?: string;
|
|
9
19
|
}
|
|
10
20
|
export interface HookDef {
|
|
11
21
|
name: string;
|
|
@@ -32,6 +42,10 @@ export interface SettingsHookAction {
|
|
|
32
42
|
type: "command";
|
|
33
43
|
command: string;
|
|
34
44
|
_marker?: string;
|
|
45
|
+
/** Per-action permission-rule filter (Claude Code ≥ 2026-04 schema).
|
|
46
|
+
* Format: `<ToolName>(<substring>)`. Older runtimes ignore unknown
|
|
47
|
+
* fields, so emitting this is forward-safe. */
|
|
48
|
+
if?: string;
|
|
35
49
|
}
|
|
36
50
|
export interface SettingsHookGroup {
|
|
37
51
|
matcher?: string;
|
|
@@ -52,12 +66,33 @@ export interface SettingsFile {
|
|
|
52
66
|
* and never wrote our marker. Without this fallback we would happily
|
|
53
67
|
* append a duplicate next to it and the hook would fire twice.
|
|
54
68
|
*
|
|
69
|
+
* `options.matcher` writes to the container-level `matcher` (tool-name
|
|
70
|
+
* filter); `options.ifRule` writes to the action-level `if` (permission-
|
|
71
|
+
* rule substring filter, Claude Code ≥ 2026-04). Either or both may be
|
|
72
|
+
* absent.
|
|
73
|
+
*
|
|
74
|
+
* Upgrade path: if an entry with our marker already exists but its
|
|
75
|
+
* matcher / if disagrees with the desired values, we update those two
|
|
76
|
+
* fields in place (preserving everything else — command, sibling
|
|
77
|
+
* actions, the user's other groups — untouched). This is the path for
|
|
78
|
+
* a user who installed an older registry version and re-runs the
|
|
79
|
+
* installer after hooks.json changed. Pure no-op when the existing
|
|
80
|
+
* fields already match.
|
|
81
|
+
*
|
|
82
|
+
* Inputs are defense-in-depth revalidated here against IF_RE + the
|
|
83
|
+
* event-name regex even though registry callers already passed
|
|
84
|
+
* loadHooksConfig, so a direct library caller can't write malformed
|
|
85
|
+
* values into settings.json by bypassing the registry loader.
|
|
86
|
+
*
|
|
55
87
|
* Throws if `settings.hooks[event]` exists but is not an array — that
|
|
56
88
|
* means the user has hand-edited their settings into a shape we do not
|
|
57
89
|
* recognize, and silently replacing it with an empty array would lose
|
|
58
90
|
* data. Callers should catch and surface the error to the user.
|
|
59
91
|
*/
|
|
60
|
-
export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string
|
|
92
|
+
export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string, options?: {
|
|
93
|
+
matcher?: string;
|
|
94
|
+
ifRule?: string;
|
|
95
|
+
}): {
|
|
61
96
|
settings: SettingsFile;
|
|
62
97
|
mutated: boolean;
|
|
63
98
|
};
|
package/dist/hooks.js
CHANGED
|
@@ -32,6 +32,29 @@ const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
|
32
32
|
// the form requires a code change here, intentionally — see the security
|
|
33
33
|
// review trail in PR #7 for context.
|
|
34
34
|
const COMMAND_RE = /^(node|python3|bash) "\$HOOK_DIR\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+"$/;
|
|
35
|
+
// Claude Code permission-rule syntax for the `if` field:
|
|
36
|
+
// <ToolName>(<substring>)
|
|
37
|
+
// Tool name: EVENT_NAME-shape identifier, bounded to 64 chars so a
|
|
38
|
+
// malicious registry can't inflate settings.json with a gigantic prefix.
|
|
39
|
+
// Substring body: 1-200 printable-ASCII chars, with parens, backslash,
|
|
40
|
+
// and backtick explicitly excluded so the anchored `\(...\)` wrapper
|
|
41
|
+
// actually delimits a well-formed outer parenthesis pair.
|
|
42
|
+
//
|
|
43
|
+
// The safety argument is NOT that IF_RE strips shell metacharacters —
|
|
44
|
+
// `$ " ' ; | & < > *` are all inside the allowed byte range and left
|
|
45
|
+
// intact. The defense is that this string never reaches a shell: it's
|
|
46
|
+
// written verbatim into settings.json as a JSON string and read by
|
|
47
|
+
// Claude Code's in-process permission-rule matcher. A registry
|
|
48
|
+
// compromise can widen or misdirect the match pattern (causing the hook
|
|
49
|
+
// to fire on unintended inputs or not fire at all) but cannot pivot
|
|
50
|
+
// into command execution from this field.
|
|
51
|
+
//
|
|
52
|
+
// Body range decomposition (what the char class actually covers):
|
|
53
|
+
// 0x20-0x27 space through single-quote (excludes nothing)
|
|
54
|
+
// 0x2A-0x5B asterisk through left-bracket (excludes `(` 0x28, `)` 0x29)
|
|
55
|
+
// 0x5D-0x5F right-bracket through underscore (excludes `\` 0x5C)
|
|
56
|
+
// 0x61-0x7E lowercase through tilde (excludes `` ` `` 0x60)
|
|
57
|
+
const IF_RE = /^[A-Z][A-Za-z0-9_-]{0,63}\([\x20-\x27\x2A-\x5B\x5D-\x5F\x61-\x7E]{1,200}\)$/;
|
|
35
58
|
function isSafeRelativePath(file) {
|
|
36
59
|
if (typeof file !== "string" || file.length === 0)
|
|
37
60
|
return false;
|
|
@@ -104,6 +127,10 @@ function validateHookEntry(hook, idx) {
|
|
|
104
127
|
if (matcher !== undefined && (typeof matcher !== "string" || !EVENT_NAME_RE.test(matcher))) {
|
|
105
128
|
throw new Error(`hooks.json: hooks[${idx}].settingsEvents.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(matcher)})`);
|
|
106
129
|
}
|
|
130
|
+
const ifRule = evt.if;
|
|
131
|
+
if (ifRule !== undefined && (typeof ifRule !== "string" || !IF_RE.test(ifRule))) {
|
|
132
|
+
throw new Error(`hooks.json: hooks[${idx}].settingsEvents.if must match ${IF_RE} (got ${JSON.stringify(ifRule)})`);
|
|
133
|
+
}
|
|
107
134
|
}
|
|
108
135
|
if (typeof h.command !== "string" || !COMMAND_RE.test(h.command)) {
|
|
109
136
|
throw new Error(`hooks.json: hooks[${idx}].command must match the safe template ${COMMAND_RE} (got ${JSON.stringify(h.command)})`);
|
|
@@ -133,12 +160,40 @@ function validateHookEntry(hook, idx) {
|
|
|
133
160
|
* and never wrote our marker. Without this fallback we would happily
|
|
134
161
|
* append a duplicate next to it and the hook would fire twice.
|
|
135
162
|
*
|
|
163
|
+
* `options.matcher` writes to the container-level `matcher` (tool-name
|
|
164
|
+
* filter); `options.ifRule` writes to the action-level `if` (permission-
|
|
165
|
+
* rule substring filter, Claude Code ≥ 2026-04). Either or both may be
|
|
166
|
+
* absent.
|
|
167
|
+
*
|
|
168
|
+
* Upgrade path: if an entry with our marker already exists but its
|
|
169
|
+
* matcher / if disagrees with the desired values, we update those two
|
|
170
|
+
* fields in place (preserving everything else — command, sibling
|
|
171
|
+
* actions, the user's other groups — untouched). This is the path for
|
|
172
|
+
* a user who installed an older registry version and re-runs the
|
|
173
|
+
* installer after hooks.json changed. Pure no-op when the existing
|
|
174
|
+
* fields already match.
|
|
175
|
+
*
|
|
176
|
+
* Inputs are defense-in-depth revalidated here against IF_RE + the
|
|
177
|
+
* event-name regex even though registry callers already passed
|
|
178
|
+
* loadHooksConfig, so a direct library caller can't write malformed
|
|
179
|
+
* values into settings.json by bypassing the registry loader.
|
|
180
|
+
*
|
|
136
181
|
* Throws if `settings.hooks[event]` exists but is not an array — that
|
|
137
182
|
* means the user has hand-edited their settings into a shape we do not
|
|
138
183
|
* recognize, and silently replacing it with an empty array would lose
|
|
139
184
|
* data. Callers should catch and surface the error to the user.
|
|
140
185
|
*/
|
|
141
|
-
export function addHookToSettings(settings, event, command, marker) {
|
|
186
|
+
export function addHookToSettings(settings, event, command, marker, options = {}) {
|
|
187
|
+
// Defense-in-depth: registry callers pre-validate via loadHooksConfig,
|
|
188
|
+
// but a direct programmatic caller could bypass that. Refuse values
|
|
189
|
+
// that wouldn't have cleared the registry validator, so settings.json
|
|
190
|
+
// can never receive a malformed string through this function.
|
|
191
|
+
if (options.matcher !== undefined && !EVENT_NAME_RE.test(options.matcher)) {
|
|
192
|
+
throw new Error(`addHookToSettings: options.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(options.matcher)})`);
|
|
193
|
+
}
|
|
194
|
+
if (options.ifRule !== undefined && !IF_RE.test(options.ifRule)) {
|
|
195
|
+
throw new Error(`addHookToSettings: options.ifRule must match ${IF_RE} (got ${JSON.stringify(options.ifRule)})`);
|
|
196
|
+
}
|
|
142
197
|
const next = JSON.parse(JSON.stringify(settings ?? {}));
|
|
143
198
|
if (next.hooks !== undefined && (typeof next.hooks !== "object" || Array.isArray(next.hooks))) {
|
|
144
199
|
throw new Error(`settings.hooks exists but is not an object; refusing to clobber it`);
|
|
@@ -157,8 +212,21 @@ export function addHookToSettings(settings, event, command, marker) {
|
|
|
157
212
|
if (!action)
|
|
158
213
|
continue;
|
|
159
214
|
if (action._marker === marker) {
|
|
215
|
+
// Our entry already exists. Upgrade matcher / if in place if
|
|
216
|
+
// they drifted from the desired values; leave command + other
|
|
217
|
+
// fields alone (users may have hand-tweaked; we only own the
|
|
218
|
+
// two fields registry declares).
|
|
219
|
+
let drifted = false;
|
|
220
|
+
if (options.matcher !== undefined && group.matcher !== options.matcher) {
|
|
221
|
+
group.matcher = options.matcher;
|
|
222
|
+
drifted = true;
|
|
223
|
+
}
|
|
224
|
+
if (options.ifRule !== undefined && action.if !== options.ifRule) {
|
|
225
|
+
action.if = options.ifRule;
|
|
226
|
+
drifted = true;
|
|
227
|
+
}
|
|
160
228
|
next.hooks[event] = list;
|
|
161
|
-
return { settings: next, mutated:
|
|
229
|
+
return { settings: next, mutated: drifted };
|
|
162
230
|
}
|
|
163
231
|
if (action.type === "command" && action.command === command) {
|
|
164
232
|
// A pre-existing entry (manual or from another tool) already
|
|
@@ -170,9 +238,13 @@ export function addHookToSettings(settings, event, command, marker) {
|
|
|
170
238
|
}
|
|
171
239
|
}
|
|
172
240
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
241
|
+
const action = { type: "command", command, _marker: marker };
|
|
242
|
+
if (options.ifRule !== undefined)
|
|
243
|
+
action.if = options.ifRule;
|
|
244
|
+
const group = { hooks: [action] };
|
|
245
|
+
if (options.matcher !== undefined)
|
|
246
|
+
group.matcher = options.matcher;
|
|
247
|
+
list.push(group);
|
|
176
248
|
next.hooks[event] = list;
|
|
177
249
|
return { settings: next, mutated: true };
|
|
178
250
|
}
|
|
@@ -434,7 +506,10 @@ function writeMergedSettings(resolved, hook, parsed) {
|
|
|
434
506
|
let next = parsed;
|
|
435
507
|
for (const evt of hook.settingsEvents) {
|
|
436
508
|
const cmd = hook.command.replace(/\$HOOK_DIR/g, resolved.commandHookDir);
|
|
437
|
-
const result = addHookToSettings(next, evt.event, cmd, hook.marker
|
|
509
|
+
const result = addHookToSettings(next, evt.event, cmd, hook.marker, {
|
|
510
|
+
matcher: evt.matcher,
|
|
511
|
+
ifRule: evt.if,
|
|
512
|
+
});
|
|
438
513
|
if (result.mutated)
|
|
439
514
|
mutated = true;
|
|
440
515
|
next = result.settings;
|