Skip to content

Commit 2e81c96

Browse files
committed
Drop "remember" prompt on commands with volatile line args; allow nl
1 parent 2b42f78 commit 2e81c96

3 files changed

Lines changed: 253 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- **Permission prompt drops the "and remember" options for bash commands whose args won't repeat.** The four-choice prompt (`Yes` / `Yes and remember` / `No` / `No and remember`) offered to persist the exact command string — which for `sed -n '1270,1320p'`, `head -n 50`, `tail -n 100`, `grep -A 5 pat`, `awk 'NR==5'` was useless because the line number / range / count changes every call, so the remembered rule never matched again. Sofos now detects these four shapes (sed numeric addresses, head/tail numeric counts, grep/`rg` context flags `-A`/`-B`/`-C`, awk `NR==|<=|>=|<|>` predicates) per pipe segment and falls back to a plain `Yes` / `No` prompt. Commands with stable args still get the full four-choice prompt. To allowlist a whole invocation family, add a `Bash(cmd:*)` entry to `.sofos/config.local.toml` directly.
10+
11+
### Added
12+
13+
- **`nl` added to the built-in auto-allow list** alongside sibling read-only inspection tools (`cat`, `head`, `tail`, `less`, `more`). Commands like `nl -ba file.rs | sed -n '1270,1320p'` no longer prompt.
14+
715
## [0.2.3] - 2026-04-23
816

917
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ Tools auto-discovered, prefixed with server name (e.g., `filesystem_read_file`).
248248

249249
1. **Allowed (auto-execute):** Build tools (cargo, npm, go), read-only commands (ls, cat, grep), system info (pwd, date), git read-only commands (`status`, `log`, `diff`, `show`, `branch`, …).
250250
2. **Forbidden (always blocked):** file destruction (`rm`, `rmdir`, `touch`, `ln`); permissions (`chmod`, `chown`, `chgrp`); disk / partition (`dd`, `mkfs`, `fdisk`, `parted`, `mkswap`, `mount`, `umount`); system control (`shutdown`, `reboot`, `halt`, `systemctl`, `service`); user management (`useradd`, `usermod`, `passwd`, …); process signals (`kill`, `killall`, `pkill`); privilege escalation (`sudo`, `su`); directory navigation (`cd`, `pushd`, `popd`); destructive git operations (`git push`, `git reset --hard`, `git clean`, `git checkout -f`, `git checkout -b`, `git switch`, `git rebase`, `git commit`, …).
251-
3. **Ask (prompt user):** `cp`, `mv`, `mkdir`, `git checkout <branch>` / `git checkout HEAD~N` / `git checkout -- <path>`, commands referencing paths outside the workspace, and any unknown command. Approvals can be session-scoped or remembered in config.
251+
3. **Ask (prompt user):** `cp`, `mv`, `mkdir`, `git checkout <branch>` / `git checkout HEAD~N` / `git checkout -- <path>`, commands referencing paths outside the workspace, and any unknown command. Approvals can be session-scoped or remembered in config. Commands whose args wouldn't repeat — `sed -n 'N,Mp'`, `head -n N`, `tail -n N`, `grep -A/-B/-C N`, `awk 'NR==N'` — drop the "and remember" options and show a plain Yes / No, since the exact command string won't match the next call.
252252

253253
## Configuration
254254

src/tools/permissions.rs

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ impl PermissionManager {
169169
"cat",
170170
"head",
171171
"tail",
172+
"nl",
172173
"less",
173174
"more",
174175
"grep",
@@ -758,7 +759,21 @@ impl PermissionManager {
758759
pub fn ask_user_permission(&mut self, command: &str) -> Result<(bool, bool)> {
759760
let normalized = Self::normalize_command(command);
760761
let prompt = format!("Allow command `{}`?", command);
761-
let (confirmed, remember) = Self::ask_three_way(&prompt)?;
762+
763+
// For commands whose args change every call (sed line ranges,
764+
// head/tail line counts, grep context flags, awk NR predicates),
765+
// "remember this exact command" would never match the next
766+
// invocation. Drop those to a plain Yes/No so the user isn't
767+
// offered a useless persistence option. Users who want to
768+
// allowlist the invocation family can add `Bash(cmd:*)` to
769+
// settings directly.
770+
let (confirmed, remember) = if Self::command_has_volatile_line_args(command) {
771+
let choices = ["Yes", "No"];
772+
let idx = confirm_multi_choice(&prompt, &choices, 1, ConfirmationType::Permission)?;
773+
(idx == 0, false)
774+
} else {
775+
Self::ask_three_way(&prompt)?
776+
};
762777

763778
if remember {
764779
if confirmed {
@@ -773,6 +788,122 @@ impl PermissionManager {
773788
Ok((confirmed, remember))
774789
}
775790

791+
/// Heuristic: does `command` carry line-number / line-range args that
792+
/// make the exact string un-rememberable? Checks each pipe segment
793+
/// for:
794+
///
795+
/// - sed numeric addresses: `sed -n '10,20p'`, `sed '5d'`, `sed 1,5q`
796+
/// - head/tail numeric counts: `head -50`, `head -n 50`, `tail +20`
797+
/// - grep/rg context flags: `grep -A 3`, `grep -B5`, `grep -C 10`
798+
/// - awk record-number predicates: `awk 'NR==5'`, `awk 'NR<=10'`
799+
///
800+
/// False positives just downgrade the prompt to Yes/No, so the
801+
/// heuristic prefers simple matches over exhaustive parsing.
802+
fn command_has_volatile_line_args(command: &str) -> bool {
803+
command
804+
.split('|')
805+
.any(|segment| Self::segment_has_volatile_line_args(segment.trim()))
806+
}
807+
808+
fn segment_has_volatile_line_args(segment: &str) -> bool {
809+
let mut tokens = segment.split_whitespace();
810+
// Skip leading env-var assignments the same way extract_base_command does.
811+
let base = loop {
812+
match tokens.next() {
813+
Some(t) if is_env_assignment(t) => continue,
814+
Some(t) => break t,
815+
None => return false,
816+
}
817+
};
818+
let args: Vec<&str> = tokens.collect();
819+
match base {
820+
"sed" => Self::sed_has_numeric_address(&args),
821+
"head" | "tail" => Self::head_tail_has_numeric_count(&args),
822+
"grep" | "egrep" | "fgrep" | "rg" => Self::grep_has_context_count(&args),
823+
"awk" => Self::awk_has_nr_predicate(&args),
824+
_ => false,
825+
}
826+
}
827+
828+
fn sed_has_numeric_address(args: &[&str]) -> bool {
829+
args.iter().any(|raw| {
830+
let s = raw.trim_matches(['\'', '"']);
831+
// Strip trailing sed command letters (p/d/q/!) — what remains
832+
// must look like N or N,M with only digits.
833+
let addr = s.trim_end_matches(['p', 'd', 'q', '!']);
834+
if addr.is_empty() || addr == s {
835+
return false;
836+
}
837+
addr.split(',')
838+
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
839+
})
840+
}
841+
842+
fn head_tail_has_numeric_count(args: &[&str]) -> bool {
843+
// Separate form: `-n 50`, `-c 100`. Glued form: `-n50`, `-c100`,
844+
// bare digits with `-`/`+`: `-50`, `+20`. `-n`/`-c` are listed in
845+
// both lists so `-n50` is caught by the glued path while `-n`
846+
// alone is caught by the separate path.
847+
Self::scan_numeric_flag_arg(args, &["-n", "-c"], &["-n", "-c", "-", "+"])
848+
}
849+
850+
fn grep_has_context_count(args: &[&str]) -> bool {
851+
Self::scan_numeric_flag_arg(args, &["-A", "-B", "-C"], &["-A", "-B", "-C"])
852+
}
853+
854+
/// Shared scanner for "does this arg list contain a flag whose value
855+
/// is a line number"? Flags in `separate_flags` consume the next arg
856+
/// (which must be all-digits). Prefixes in `glued_prefixes` match
857+
/// flag+digits in a single token (e.g. `-n50`, `-A3`, `+20`).
858+
fn scan_numeric_flag_arg(
859+
args: &[&str],
860+
separate_flags: &[&str],
861+
glued_prefixes: &[&str],
862+
) -> bool {
863+
let mut prev_was_flag = false;
864+
for arg in args {
865+
if prev_was_flag {
866+
prev_was_flag = false;
867+
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_digit()) {
868+
return true;
869+
}
870+
continue;
871+
}
872+
if separate_flags.contains(arg) {
873+
prev_was_flag = true;
874+
continue;
875+
}
876+
for prefix in glued_prefixes {
877+
if let Some(rest) = arg.strip_prefix(prefix) {
878+
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
879+
return true;
880+
}
881+
}
882+
}
883+
}
884+
false
885+
}
886+
887+
fn awk_has_nr_predicate(args: &[&str]) -> bool {
888+
args.iter().any(|raw| {
889+
let s = raw.trim_matches(['\'', '"']);
890+
for op in ["NR==", "NR<=", "NR>=", "NR<", "NR>"] {
891+
// Scan every occurrence of `op` — an earlier non-digit
892+
// match (e.g. `NR==var`) shouldn't shadow a later
893+
// numeric one (`NR==5`).
894+
let mut rest = s;
895+
while let Some(pos) = rest.find(op) {
896+
let tail = &rest[pos + op.len()..];
897+
if tail.chars().next().is_some_and(|c| c.is_ascii_digit()) {
898+
return true;
899+
}
900+
rest = tail;
901+
}
902+
}
903+
false
904+
})
905+
}
906+
776907
/// Ask user for path-scoped permission (Read, Write, or Bash access to a directory).
777908
/// `scope` is "Read", "Write", or "Bash". `dir` is the directory to grant access to.
778909
/// Returns (allowed, remembered).
@@ -1809,4 +1940,116 @@ ask = []
18091940
CommandPermission::Denied
18101941
);
18111942
}
1943+
1944+
#[test]
1945+
fn test_volatile_line_args_sed_range() {
1946+
// The user's original example — sed with a numeric address range.
1947+
assert!(PermissionManager::command_has_volatile_line_args(
1948+
"nl -ba tests/foo.rs | sed -n '1270,1320p'"
1949+
));
1950+
assert!(PermissionManager::command_has_volatile_line_args(
1951+
"sed -n 10,20p file.txt"
1952+
));
1953+
assert!(PermissionManager::command_has_volatile_line_args(
1954+
"sed '5d' file.txt"
1955+
));
1956+
// `$` is not numeric → not a line-number range.
1957+
assert!(!PermissionManager::command_has_volatile_line_args(
1958+
"sed \"1,$q\" file.txt"
1959+
));
1960+
}
1961+
1962+
#[test]
1963+
fn test_volatile_line_args_head_tail() {
1964+
assert!(PermissionManager::command_has_volatile_line_args(
1965+
"head -n 50 big.log"
1966+
));
1967+
assert!(PermissionManager::command_has_volatile_line_args(
1968+
"head -50 big.log"
1969+
));
1970+
assert!(PermissionManager::command_has_volatile_line_args(
1971+
"tail -n 100 /var/log/syslog"
1972+
));
1973+
assert!(PermissionManager::command_has_volatile_line_args(
1974+
"tail +20 file.txt"
1975+
));
1976+
// No numeric flag → not considered volatile.
1977+
assert!(!PermissionManager::command_has_volatile_line_args(
1978+
"head file.txt"
1979+
));
1980+
assert!(!PermissionManager::command_has_volatile_line_args(
1981+
"tail -f /var/log/syslog"
1982+
));
1983+
}
1984+
1985+
#[test]
1986+
fn test_volatile_line_args_grep_context() {
1987+
assert!(PermissionManager::command_has_volatile_line_args(
1988+
"grep -A 3 pattern file.txt"
1989+
));
1990+
assert!(PermissionManager::command_has_volatile_line_args(
1991+
"grep -B5 pattern file.txt"
1992+
));
1993+
assert!(PermissionManager::command_has_volatile_line_args(
1994+
"rg -C 10 needle ."
1995+
));
1996+
assert!(!PermissionManager::command_has_volatile_line_args(
1997+
"grep -i pattern file.txt"
1998+
));
1999+
}
2000+
2001+
#[test]
2002+
fn test_volatile_line_args_awk_nr() {
2003+
assert!(PermissionManager::command_has_volatile_line_args(
2004+
"awk 'NR==5' file.txt"
2005+
));
2006+
assert!(PermissionManager::command_has_volatile_line_args(
2007+
"awk 'NR<=10{print}' file.txt"
2008+
));
2009+
assert!(!PermissionManager::command_has_volatile_line_args(
2010+
"awk '/pattern/' file.txt"
2011+
));
2012+
// A non-digit NR== earlier in the program must not shadow a
2013+
// later numeric NR==. Regression for the `.find()` first-hit bug.
2014+
assert!(PermissionManager::command_has_volatile_line_args(
2015+
"awk 'NR==var; NR==5 {print}' file.txt"
2016+
));
2017+
}
2018+
2019+
#[test]
2020+
fn test_volatile_line_args_covers_named_patterns() {
2021+
// Explicit coverage for every pattern the user called out as
2022+
// "should drop to Yes/No only": sed -n 'Np', sed -n 'N,Mp',
2023+
// head -n N, tail -n N, grep -A/-B/-C N, awk 'NR==N'.
2024+
let cases = [
2025+
"sed -n '5p' file.txt",
2026+
"sed -n '10,20p' file.txt",
2027+
"head -n 50 big.log",
2028+
"tail -n 100 /var/log/syslog",
2029+
"grep -A 5 pattern file.txt",
2030+
"grep -B 5 pattern file.txt",
2031+
"grep -C 5 pattern file.txt",
2032+
"awk 'NR==5' file.txt",
2033+
];
2034+
for cmd in cases {
2035+
assert!(
2036+
PermissionManager::command_has_volatile_line_args(cmd),
2037+
"expected `{cmd}` to be classified as volatile"
2038+
);
2039+
}
2040+
}
2041+
2042+
#[test]
2043+
fn test_volatile_line_args_plain_commands() {
2044+
// Normal commands with stable args should NOT be flagged.
2045+
assert!(!PermissionManager::command_has_volatile_line_args(
2046+
"cargo build --release"
2047+
));
2048+
assert!(!PermissionManager::command_has_volatile_line_args(
2049+
"git log --oneline"
2050+
));
2051+
assert!(!PermissionManager::command_has_volatile_line_args(
2052+
"ls -la src/"
2053+
));
2054+
}
18122055
}

0 commit comments

Comments
 (0)