Skip to content

Commit 39deb49

Browse files
authored
Add Bash substitution rules (#26)
1 parent a50abed commit 39deb49

16 files changed

Lines changed: 703 additions & 56 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ When Nudge has something to share, it responds in one of three ways:
8888
- **Passthrough**: Nothing to note. Carry on!
8989
- **Continue**: For UserPromptSubmit hooks, Nudge injects context as plain text
9090
- **Interrupt**: For PreToolUse hooks, Nudge blocks the operation and explains what to fix
91+
- **Substitute**: For deterministic PreToolUse Bash rules, Nudge rewrites the command and lets it proceed
9192

9293
The response type is determined by the hook type:
93-
- `PreToolUse` rules always **interrupt** (block provider-supported Write/Edit/WebFetch/Bash operations)
94+
- `PreToolUse` block rules **interrupt** (block provider-supported Write/Edit/WebFetch/Bash operations)
95+
- `PreToolUse` substitute rules **allow with updated input** (Claude Code and Codex CLI Bash commands)
9496
- `UserPromptSubmit` rules always **continue** (inject guidance into the conversation)
9597
- `PermissionRequest` is parsed but always **passes through** until Nudge has a permission-specific rule surface
9698
- `Delete` is normalized but not yet matchable from YAML rules

‎CLAUDE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ When Nudge has something to share, it responds in one of three ways:
8888
- **Passthrough**: Nothing to note. Carry on!
8989
- **Continue**: For UserPromptSubmit hooks, Nudge injects context as plain text
9090
- **Interrupt**: For PreToolUse hooks, Nudge blocks the operation and explains what to fix
91+
- **Substitute**: For deterministic PreToolUse Bash rules, Nudge rewrites the command and lets it proceed
9192

9293
The response type is determined by the hook type:
93-
- `PreToolUse` rules always **interrupt** (block provider-supported Write/Edit/WebFetch/Bash operations)
94+
- `PreToolUse` block rules **interrupt** (block provider-supported Write/Edit/WebFetch/Bash operations)
95+
- `PreToolUse` substitute rules **allow with updated input** (Claude Code and Codex CLI Bash commands)
9496
- `UserPromptSubmit` rules always **continue** (inject guidance into the conversation)
9597
- `PermissionRequest` is parsed but always **passes through** until Nudge has a permission-specific rule surface
9698
- `Delete` is normalized but not yet matchable from YAML rules

‎README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Nudge uses agent hook systems to watch supported operations:
2222
When something matches a rule you've defined:
2323

2424
- **Interrupt** (PreToolUse rules): Nudge catches the issue *before* it's written and explains what to fix
25+
- **Substitute** (PreToolUse Bash rules): Nudge rewrites simple deterministic commands, lets the tool proceed, and tells the model what changed
2526
- **Continue** (UserPromptSubmit rules): Nudge injects context into the conversation to guide the agent
2627
- **Passthrough**: No rules matched, everything proceeds normally
2728

@@ -71,6 +72,24 @@ error: rule violation
7172

7273
The pattern: **what's wrong** -> **how to fix** -> **retry**.
7374

75+
For simple mechanical Bash command rewrites, use `action: substitute` with a regex `replace:` template instead of a blocking message:
76+
77+
```yaml
78+
version: 1
79+
rules:
80+
- name: use-yarn-add
81+
action: substitute
82+
on:
83+
- hook: PreToolUse
84+
tool: Bash
85+
command:
86+
- kind: Regex
87+
pattern: "^npm install(?: (?P<args>.*))?$"
88+
replace: "yarn add {{ $args }}"
89+
```
90+
91+
Substitutions work for Claude Code and Codex CLI. Nudge returns the provider's full updated tool input with only `command` changed, and adds `hookSpecificOutput.additionalContext` so the model sees what was rewritten.
92+
7493
For the full rule syntax and copy-pasteable examples, run `nudge claude docs` or `nudge codex docs`.
7594

7695
### Rule Writing Is Iterative
@@ -155,6 +174,8 @@ nudge check "**/*.rs"
155174
nudge check || exit 1
156175
```
157176

177+
`nudge check` only evaluates file-based block rules for `PreToolUse` Write/Edit matchers. It ignores `action: substitute` rules because substitutions rewrite live Bash hook payloads and need a provider to receive `updatedInput`.
178+
158179
Example output when violations are found:
159180

160181
```
@@ -216,7 +237,7 @@ echo '{
216237
}
217238
}' | nudge codex hook
218239

219-
# Exit 0 with JSON output = Interrupt (rule matched, operation blocked)
240+
# Exit 0 with JSON output = Interrupt or Substitute (rule matched)
220241
# Exit 0 with plain text = Continue (UserPromptSubmit context injected)
221242
# Exit 0 with no output = Passthrough (nothing to note)
222243
```

‎docs/rfc/0002-claude-and-codex-hooks.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ Codex `PreToolUse` denial should be returned through the hook-specific shape:
9191
}
9292
```
9393

94+
Codex `PreToolUse` substitution should use `permissionDecision: "allow"` with
95+
`updatedInput`, and should include `hookSpecificOutput.additionalContext` when
96+
Nudge needs the model to know what was rewritten:
97+
98+
```json
99+
{
100+
"systemMessage": "Nudge substituted `npm install foo` -> `yarn add foo`.",
101+
"hookSpecificOutput": {
102+
"hookEventName": "PreToolUse",
103+
"permissionDecision": "allow",
104+
"updatedInput": {
105+
"command": "yarn add foo"
106+
},
107+
"additionalContext": "Nudge rewrote the Bash command from `npm install foo` to `yarn add foo` before execution."
108+
}
109+
}
110+
```
111+
94112
Important compatibility detail: Codex documents `continue`, `stopReason`, and
95113
`suppressOutput` as unsupported for `PreToolUse`. Returning those fields on
96114
`PreToolUse` makes the hook run fail and Codex continues the tool call. Nudge's
@@ -408,7 +426,8 @@ pub fn evaluate_hook(hook: &NudgeHook, rules: &[Rule]) -> Evaluation;
408426

409427
The existing behavior remains:
410428

411-
- `PreToolUse` matches produce a blocking denial.
429+
- `PreToolUse` block matches produce a blocking denial.
430+
- `PreToolUse` Bash substitute matches produce an allow response with updated input.
412431
- `UserPromptSubmit` matches produce context on stdout.
413432
- `PermissionRequest` always passes through in this update.
414433
- No matches produce no output and exit 0.
@@ -425,6 +444,11 @@ Define an abstract outcome:
425444
pub enum HookOutcome {
426445
Passthrough,
427446
DenyPreToolUse { message: String },
447+
UpdatePreToolUse {
448+
system_message: String,
449+
additional_context: String,
450+
updated_input: Value,
451+
},
428452
AddContext { context: String },
429453
}
430454
```
@@ -460,6 +484,12 @@ Codex `DenyPreToolUse`:
460484
Do not include `continue`, `stopReason`, or `suppressOutput` on `PreToolUse`.
461485
This is the key wire-format fix for Codex.
462486

487+
For `UpdatePreToolUse`, render `permissionDecision: "allow"` with a full
488+
`updatedInput` object. Preserve every original tool input field and replace
489+
only the rewritten field. Put the model-visible explanation in
490+
`hookSpecificOutput.additionalContext`; use `systemMessage` only as an
491+
audit/user-visible note.
492+
463493
For `UserPromptSubmit`, prefer plain stdout for both agents. It is simple and
464494
documented by both hook systems as model-visible context.
465495

‎packages/nudge/src/agent/claude.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub fn parse_hook(raw: Value) -> Result<Vec<NudgeHook>> {
2323

2424
match event {
2525
"PreToolUse" => Ok(vec![NudgeHook::PreToolUse(PreToolUse {
26+
tool_input: raw.get("tool_input").cloned().unwrap_or(Value::Null),
2627
tool: tool_use(&raw)?,
2728
context,
2829
})]),
@@ -111,6 +112,8 @@ fn path_field(value: &Value, field: &str) -> Result<PathBuf> {
111112
mod tests {
112113
use serde_json::json;
113114

115+
use pretty_assertions::assert_eq as pretty_assert_eq;
116+
114117
use crate::hook::{NudgeHook, ToolUse};
115118

116119
use super::parse_hook;
@@ -154,13 +157,17 @@ mod tests {
154157
"hook_event_name": "PreToolUse",
155158
"cwd": "/tmp",
156159
"tool_name": "Bash",
157-
"tool_input": { "command": "cargo test" }
160+
"tool_input": { "command": "cargo test", "timeout": 120 }
158161
}))
159162
.expect("parse hook");
160163

161164
assert!(
162165
matches!(hooks.as_slice(), [NudgeHook::PreToolUse(payload)] if matches!(payload.tool, ToolUse::Bash(_)))
163166
);
167+
let [NudgeHook::PreToolUse(payload)] = hooks.as_slice() else {
168+
panic!("expected PreToolUse");
169+
};
170+
pretty_assert_eq!(payload.tool_input["timeout"], 120);
164171
}
165172

166173
#[test]

‎packages/nudge/src/agent/codex.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub fn parse_hook(raw: Value) -> Result<Vec<NudgeHook>> {
3737

3838
fn pretooluse(raw: Value, context: HookContext) -> Vec<NudgeHook> {
3939
let tool = tool_use(&raw);
40+
let tool_input = raw.get("tool_input").cloned().unwrap_or(Value::Null);
4041
match tool {
4142
ToolUse::Other { tool_name, input } if tool_name == "apply_patch" => {
4243
let command = input
@@ -49,6 +50,7 @@ fn pretooluse(raw: Value, context: HookContext) -> Vec<NudgeHook> {
4950
.map(|tool| {
5051
NudgeHook::PreToolUse(PreToolUse {
5152
context: context.clone(),
53+
tool_input: tool_input.clone(),
5254
tool,
5355
})
5456
})
@@ -59,7 +61,11 @@ fn pretooluse(raw: Value, context: HookContext) -> Vec<NudgeHook> {
5961
}
6062
}
6163
}
62-
tool => vec![NudgeHook::PreToolUse(PreToolUse { context, tool })],
64+
tool => vec![NudgeHook::PreToolUse(PreToolUse {
65+
context,
66+
tool_input,
67+
tool,
68+
})],
6369
}
6470
}
6571

‎packages/nudge/src/cmd/check.rs‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use color_eyre::eyre::{Context, Result};
1313
use glob::Pattern;
1414
use ignore::WalkBuilder;
1515

16-
use nudge::rules::{self, ContentMatcher, GlobMatcher, Hook, PreToolUseMatcher, Rule};
16+
use nudge::rules::{self, ContentMatcher, GlobMatcher, Hook, PreToolUseMatcher, Rule, RuleAction};
1717

1818
#[derive(Args, Clone, Debug)]
1919
pub struct Config {
@@ -93,19 +93,27 @@ fn collect_file_rules(rules_by_source: &[(PathBuf, Vec<Rule>)]) -> (Vec<FileRule
9393
}
9494

9595
fn file_rules_for_rule(rule: &Rule) -> impl Iterator<Item = FileRule<'_>> {
96-
rule.on.iter().filter_map(move |hook| match hook {
97-
Hook::PreToolUse(PreToolUseMatcher::Write(matcher)) => Some(FileRule {
98-
pattern: &matcher.file,
99-
matchers: ContentMatcherSet::Write(&matcher.content),
100-
rule,
101-
}),
102-
Hook::PreToolUse(PreToolUseMatcher::Edit(matcher)) => Some(FileRule {
103-
pattern: &matcher.file,
104-
matchers: ContentMatcherSet::Edit(&matcher.new_content),
105-
rule,
106-
}),
107-
_ => None,
108-
})
96+
if rule.action != RuleAction::Block {
97+
return Vec::new().into_iter();
98+
}
99+
100+
rule.on
101+
.iter()
102+
.filter_map(move |hook| match hook {
103+
Hook::PreToolUse(PreToolUseMatcher::Write(matcher)) => Some(FileRule {
104+
pattern: &matcher.file,
105+
matchers: ContentMatcherSet::Write(&matcher.content),
106+
rule,
107+
}),
108+
Hook::PreToolUse(PreToolUseMatcher::Edit(matcher)) => Some(FileRule {
109+
pattern: &matcher.file,
110+
matchers: ContentMatcherSet::Edit(&matcher.new_content),
111+
rule,
112+
}),
113+
_ => None,
114+
})
115+
.collect::<Vec<_>>()
116+
.into_iter()
109117
}
110118

111119
fn check_files(files: &[PathBuf], file_rules: &[FileRule<'_>]) -> (Vec<Issue>, usize) {
@@ -164,7 +172,7 @@ fn rule_issues(file: &Path, content: &str, file_rule: &FileRule<'_>) -> Vec<Issu
164172
.flat_map(|matcher| matcher.matches_with_context(content))
165173
.map(|m| {
166174
let line = byte_offset_to_line(content, m.span.start);
167-
let message = nudge::template::interpolate(&file_rule.rule.message, &m.captures);
175+
let message = nudge::template::interpolate(file_rule.rule.message(), &m.captures);
168176

169177
Issue {
170178
file: file.to_path_buf(),

‎packages/nudge/src/cmd/claude/docs.rs‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ const DOCS: &str = cstr!("\
4545
<yellow>rules:</yellow>
4646
<yellow>- name: rule-identifier</yellow>
4747
<yellow>description: \"Human-readable description\"</yellow> <dim># Optional</dim>
48-
<yellow>message: \"Your message shown at each match\"</yellow>
48+
<yellow>action: block</yellow> <dim># block (default) or substitute</dim>
49+
<yellow>message: \"Your message shown at each match\"</yellow> <dim># Required for useful block rules</dim>
4950
5051
<yellow>on:</yellow> <dim># List of matchers (any match triggers the rule)</dim>
5152
<yellow>- hook: PreToolUse</yellow> <dim># PreToolUse or UserPromptSubmit</dim>
@@ -54,6 +55,7 @@ const DOCS: &str = cstr!("\
5455
<yellow>content:</yellow> <dim># Patterns to match (Write tool)</dim>
5556
<yellow>- kind: Regex</yellow>
5657
<yellow>pattern: \"your-regex\"</yellow>
58+
<yellow>replace: \"optional\"</yellow> <dim># Template for substitute action</dim>
5759
<yellow>suggestion: \"optional\"</yellow> <dim># Template for suggested fix</dim>
5860
5961
<yellow>- hook: PreToolUse</yellow> <dim># Same rule can match multiple scenarios</dim>
@@ -67,8 +69,8 @@ const DOCS: &str = cstr!("\
6769
<bold>Hook Types</bold>
6870
6971
<green>PreToolUse</green> Triggers before provider-supported Write/Edit/WebFetch/Bash
70-
operations. Always <cyan>interrupts</cyan> (blocks the operation until
71-
the issue is fixed).
72+
operations. Block rules <cyan>interrupt</cyan>; Bash substitute rules
73+
rewrite the command and allow it to proceed.
7274
7375
<green>UserPromptSubmit</green> Triggers when user submits a prompt. Always <cyan>continues</cyan>
7476
(injects context into the conversation).
@@ -144,6 +146,35 @@ const DOCS: &str = cstr!("\
144146
145147
Each match gets its own suggestion with its specific captures.
146148
149+
<bold>Substitution Rules</bold>
150+
151+
Use <cyan>action: substitute</cyan> for deterministic Bash command rewrites. Substitute rules
152+
do not need a message. They match the current Bash command, apply Regex <cyan>replace:</cyan>
153+
templates in rule order, return the provider's full updated tool input, and add
154+
<cyan>hookSpecificOutput.additionalContext</cyan> so the model sees what changed.
155+
156+
<white>Basic Syntax:</white>
157+
<yellow>- name: use-yarn-add</yellow>
158+
<yellow>description: Use yarn add instead of npm install</yellow>
159+
<yellow>action: substitute</yellow>
160+
<yellow>on:</yellow>
161+
<yellow>- hook: PreToolUse</yellow>
162+
<yellow>tool: Bash</yellow>
163+
<yellow>command:</yellow>
164+
<yellow>- kind: Regex</yellow>
165+
<yellow>pattern: \"^npm install(?: (?P<<args>>.*))?$\"</yellow>
166+
<yellow>replace: \"yarn add {{ $args }}\"</yellow>
167+
168+
<dim>For input</dim> <green>npm install lodash</green><dim>, Nudge runs</dim> <green>yarn add lodash</green><dim>.</dim>
169+
170+
<dim>Substitutions currently apply to Bash commands for Claude Code and Codex CLI.</dim>
171+
<dim>Write/Edit substitutions are intentionally not exposed because Codex apply_patch</dim>
172+
<dim>normalization would need a lossless patch rewrite.</dim>
173+
174+
<dim>CI note: nudge check ignores substitute rules. Check mode scans repository</dim>
175+
<dim>files against file-based block rules; substitutions need a live Bash hook</dim>
176+
<dim>payload and a provider that can receive updatedInput.</dim>
177+
147178
<bold>How Messages Are Displayed</bold>
148179
149180
When a rule matches, Nudge displays a <cyan>code snippet</cyan> with your message shown
@@ -395,6 +426,21 @@ const DOCS: &str = cstr!("\
395426
<yellow>- kind: Regex</yellow>
396427
<yellow>pattern: \"^main$\"</yellow>
397428
429+
<cyan>Substitute npm install with yarn add (Bash)</cyan>
430+
431+
<dim># Mechanical command rewrites can proceed without a retry loop.</dim>
432+
433+
<yellow>- name: use-yarn-add</yellow>
434+
<yellow>description: Use yarn add instead of npm install</yellow>
435+
<yellow>action: substitute</yellow>
436+
<yellow>on:</yellow>
437+
<yellow>- hook: PreToolUse</yellow>
438+
<yellow>tool: Bash</yellow>
439+
<yellow>command:</yellow>
440+
<yellow>- kind: Regex</yellow>
441+
<yellow>pattern: \"^npm install(?: (?P<<args>>.*))?$\"</yellow>
442+
<yellow>replace: \"yarn add {{ $args }}\"</yellow>
443+
398444
<cyan>Block dangerous commands (Bash)</cyan>
399445
400446
<dim># Prevent accidentally running dangerous commands like rm -rf /</dim>

‎packages/nudge/src/cmd/test.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ pub fn main(config: Config) -> Result<()> {
6767
println!("Matched content:");
6868
println!("{message}");
6969
}
70+
HookOutcome::UpdatePreToolUse {
71+
system_message,
72+
additional_context,
73+
updated_input,
74+
} => {
75+
println!("Result: Substitute");
76+
println!();
77+
println!("{system_message}");
78+
println!("{additional_context}");
79+
println!("{}", serde_json::to_string_pretty(&updated_input)?);
80+
}
7081
HookOutcome::AddContext { context } => {
7182
println!("Result: Continue");
7283
println!();

‎packages/nudge/src/hook.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ pub struct PreToolUse {
5757
/// Shared hook context.
5858
pub context: HookContext,
5959

60+
/// Original provider tool input.
61+
///
62+
/// Nudge evaluates normalized tool shapes, but provider rewrite responses
63+
/// must return the complete tool input object with only the intended fields
64+
/// changed.
65+
pub tool_input: Value,
66+
6067
/// Normalized tool input.
6168
pub tool: ToolUse,
6269
}

0 commit comments

Comments
 (0)