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 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: false };
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
- list.push({
174
- hooks: [{ type: "command", command, _marker: marker }],
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
5
5
  "type": "module",
6
6
  "bin": {