const std = @import("std");const command_classification = @import("../shell_command/command_classification.zig"); const RiskKind = enum { discard_version_control_state, forceful_file_removal,}; /// Returns a short policy note for command text with a high-risk shape.pub fn command_risk_note_for(command: []const u8) ?[]const u8 { const analysis = command_classification.analysis_command_tail(command); const risk = risk_kind_in_analysis(analysis) orelse return null; return risk_note_for(risk);} /// Returns a narrow alternative when the command text has an unambiguous safer path.pub fn command_safer_alternative_for(command: []const u8) ?[]const u8 { const analysis = command_classification.analysis_command_tail(command); if (risk_kind_in_analysis(analysis)) |risk| { return safer_alternative_for_risk(risk); } if (has_shell_boundary(command)) return null; const base = command_classification.base_command_token(analysis) orelse return null; if (std.mem.eql(u8, base, "cat") or std.mem.eql(u8, base, "less") or std.mem.eql(u8, base, "more")) { return "safer: use read_file for file inspection"; } if (std.mem.eql(u8, base, "ls")) { return "safer: use list_files or glob_files for discovery"; } if (is_pattern_matcher(base)) { return "safer: use grep_files for exact local search"; } if (std.mem.eql(u8, base, "find")) { return "safer: use glob_files or list_files for discovery"; } return null;} fn risk_note_for(risk: RiskKind) []const u8 { return switch (risk) { .discard_version_control_state => "note: command may discard version-control state", .forceful_file_removal => "note: command may remove files forcefully", };} fn safer_alternative_for_risk(risk: RiskKind) []const u8 { return switch (risk) { .discard_version_control_state => "safer: inspect git status first and revert only the intended files", .forceful_file_removal => "safer: inspect targets first or use delete_file for explicit files", };} /// Returns an informational annotation for non-error non-zero exit codes.pub fn semantic_exit_annotation(command_text: []const u8, exit_code: i64, stderr_text: []const u8) ?[]const u8 { if (exit_code == 0) return null; const analysis = command_classification.analysis_command_tail(command_text); const terminal = command_classification.terminal_exit_segment(analysis) orelse return null; const base = command_classification.base_command_token(terminal) orelse return null; if (is_pattern_matcher(base)) { return if (exit_code == 1) "note: no matches found" else null; } if (std.mem.eql(u8, base, "find")) { if (exit_code != 1 or !looks_like_find_access_issue(stderr_text)) return null; return "note: some paths could not be read"; } if (std.mem.eql(u8, base, "diff") or std.mem.eql(u8, base, "cmp")) { return if (exit_code == 1) "note: compared files differ" else null; } if (std.mem.eql(u8, base, "test") or std.mem.eql(u8, base, "[")) { return if (exit_code == 1) "note: condition evaluated false" else null; } return null;} fn risk_kind_in_analysis(command: []const u8) ?RiskKind { if (warning_at_command_position(command)) |risk| return risk; var in_single = false; var in_double = false; var escaped = false; var i: usize = 0; while (i < command.len) : (i += 1) { const ch = command[i]; if (escaped) { escaped = false; continue; } if (ch == '\\' and !in_single) { escaped = true; continue; } if (ch == '\'' and !in_double) { in_single = !in_single; continue; } if (ch == '"' and !in_single) { in_double = !in_double; continue; } if (in_single or in_double) continue; const boundary_end = switch (ch) { ';', '\n', '&' => blk: { if (ch == '&' and i + 1 < command.len and command[i + 1] == '&') { i += 1; break :blk i + 1; } break :blk i + 1; }, '|' => blk: { if (i + 1 < command.len and command[i + 1] == '|') { i += 1; break :blk i + 1; } break :blk i + 1; }, else => continue, }; if (warning_at_command_position(command[boundary_end..])) |risk| return risk; } return null;} fn warning_at_command_position(command: []const u8) ?RiskKind { var cursor = command_classification.skip_whitespace(command, 0); while (command_classification.next_token(command, cursor)) |token| { if (!command_classification.is_env_assignment(token.text)) break; cursor = token.end; } const first = command_classification.next_token(command, cursor) orelse return null; if (privileged_command_tail(command, first)) |tail| { if (warning_at_command_position(tail)) |risk| return risk; } const rest = command[first.end..]; if (git_hard_reset(first.text, rest)) |risk| return risk; if (forceful_file_removal(first.text, rest)) |risk| return risk; return null;} fn privileged_command_tail(command: []const u8, first: command_classification.Token) ?[]const u8 { if (std.mem.eql(u8, first.text, "sudo") or std.mem.eql(u8, first.text, "doas")) { return sudo_like_command_tail(command, first.end); } if (std.mem.eql(u8, first.text, "su")) { return su_command_tail(command, first.end); } return null;} fn sudo_like_command_tail(command: []const u8, start: usize) ?[]const u8 { var cursor = start; while (command_classification.next_token(command, cursor)) |token| { if (std.mem.eql(u8, token.text, "--")) { const next = command_classification.next_token(command, token.end) orelse return null; return command[next.start..]; } if (command_classification.is_env_assignment(token.text)) { cursor = token.end; continue; } if (std.mem.startsWith(u8, token.text, "-")) { if (privilege_option_takes_value(token.text)) { const value = command_classification.next_token(command, token.end) orelse return null; cursor = value.end; } else { cursor = token.end; } continue; } return command[token.start..]; } return null;} fn privilege_option_takes_value(option: []const u8) bool { return std.mem.eql(u8, option, "-u") or std.mem.eql(u8, option, "-g") or std.mem.eql(u8, option, "-h") or std.mem.eql(u8, option, "-p") or std.mem.eql(u8, option, "-C") or std.mem.eql(u8, option, "-T") or std.mem.eql(u8, option, "-U") or std.mem.eql(u8, option, "--user") or std.mem.eql(u8, option, "--group") or std.mem.eql(u8, option, "--host") or std.mem.eql(u8, option, "--prompt") or std.mem.eql(u8, option, "--close-from") or std.mem.eql(u8, option, "--command-timeout") or std.mem.eql(u8, option, "--other-user");} fn su_command_tail(command: []const u8, start: usize) ?[]const u8 { var cursor = start; while (command_classification.next_token(command, cursor)) |token| { if (std.mem.eql(u8, token.text, "-c")) { return parse_su_command_argument(command, token.end); } cursor = token.end; } return null;} fn parse_su_command_argument(command: []const u8, start: usize) ?[]const u8 { const cursor = command_classification.skip_whitespace(command, start); if (cursor >= command.len) return null; const quote = command[cursor]; if (quote == '\'' or quote == '"') { const payload_start = cursor + 1; var escaped = false; var i = payload_start; while (i < command.len) : (i += 1) { const ch = command[i]; if (escaped) { escaped = false; continue; } if (quote == '"' and ch == '\\') { escaped = true; continue; } if (ch == quote) return command[payload_start..i]; } return null; } const token = command_classification.next_token(command, cursor) orelse return null; return token.text;} fn git_hard_reset(command_name: []const u8, rest: []const u8) ?RiskKind { if (!std.mem.eql(u8, command_name, "git")) return null; const sub = command_classification.next_token(rest, 0) orelse return null; const args = rest[sub.end..]; if (std.mem.eql(u8, sub.text, "reset") and has_token(args, "--hard")) { return .discard_version_control_state; } return null;} fn forceful_file_removal(command_name: []const u8, rest: []const u8) ?RiskKind { if (!std.mem.eql(u8, command_name, "rm")) return null; var has_recursive = false; var has_force = false; var cursor: usize = 0; while (command_classification.next_token(rest, cursor)) |token| { cursor = token.end; if (!std.mem.startsWith(u8, token.text, "-")) continue; if (std.mem.eql(u8, token.text, "--recursive") or std.mem.eql(u8, token.text, "-r") or std.mem.eql(u8, token.text, "-R")) has_recursive = true; if (std.mem.eql(u8, token.text, "--force") or std.mem.eql(u8, token.text, "-f")) has_force = true; if (token.text.len > 1 and token.text[1] != '-') { for (token.text[1..]) |ch| { if (ch == 'r' or ch == 'R') has_recursive = true; if (ch == 'f') has_force = true; } } } return if (has_recursive or has_force) .forceful_file_removal else null;} fn is_pattern_matcher(command: []const u8) bool { return std.mem.eql(u8, command, "grep") or std.mem.eql(u8, command, "egrep") or std.mem.eql(u8, command, "fgrep") or std.mem.eql(u8, command, "rg") or std.mem.eql(u8, command, "ag");} fn looks_like_find_access_issue(stderr_text: []const u8) bool { return contains_ascii_ignore_case(stderr_text, "permission denied") or contains_ascii_ignore_case(stderr_text, "operation not permitted");} fn has_token(rest: []const u8, needle: []const u8) bool { var cursor: usize = 0; while (command_classification.next_token(rest, cursor)) |token| { cursor = token.end; if (std.mem.eql(u8, token.text, needle)) return true; } return false;} fn contains_ascii_ignore_case(haystack: []const u8, needle: []const u8) bool { if (needle.len == 0) return true; if (haystack.len < needle.len) return false; var i: usize = 0; while (i + needle.len <= haystack.len) : (i += 1) { if (ascii_eql_ignore_case(haystack[i .. i + needle.len], needle)) return true; } return false;} fn ascii_eql_ignore_case(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |left, right| { if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false; } return true;} fn has_shell_boundary(command: []const u8) bool { return std.mem.findScalar(u8, command, ';') != null or std.mem.findScalar(u8, command, '&') != null or std.mem.findScalar(u8, command, '|') != null or std.mem.findScalar(u8, command, '<') != null or std.mem.findScalar(u8, command, '>') != null or std.mem.findScalar(u8, command, '$') != null or std.mem.findScalar(u8, command, '`') != null or std.mem.findScalar(u8, command, '\n') != null;} test "command risk note detects git hard reset" { try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("git reset --hard").?);} test "command safer alternative explains risky commands" { try std.testing.expectEqualStrings("safer: inspect git status first and revert only the intended files", command_safer_alternative_for("git reset --hard").?); try std.testing.expectEqualStrings("safer: inspect targets first or use delete_file for explicit files", command_safer_alternative_for("rm -rf /tmp/x").?);} test "command safer alternative maps shell inspection to dedicated tools" { try std.testing.expectEqualStrings("safer: use read_file for file inspection", command_safer_alternative_for("cat src/main.zig").?); try std.testing.expectEqualStrings("safer: use list_files or glob_files for discovery", command_safer_alternative_for("ls src").?); try std.testing.expectEqualStrings("safer: use grep_files for exact local search", command_safer_alternative_for("rg needle src").?); try std.testing.expectEqualStrings("safer: use glob_files or list_files for discovery", command_safer_alternative_for("find src -name '*.zig'").?);} test "command safer alternative stays conservative for compound commands" { try std.testing.expect(command_safer_alternative_for("cat src/main.zig | wc -l") == null); try std.testing.expect(command_safer_alternative_for("git status") == null);} test "command risk note stays narrow for ordinary git operations" { try std.testing.expect(command_risk_note_for("git status") == null); try std.testing.expect(command_risk_note_for("git log --oneline") == null); try std.testing.expect(command_risk_note_for("git commit --no-verify -m ok") == null);} test "command risk note detects forceful removal" { try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("rm -rf /tmp/x").?);} test "command risk note detects privilege-wrapped removal" { try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("sudo rm -rf /tmp").?); try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("doas rm -rf /tmp").?);} test "command risk note detects privilege-wrapped git reset" { try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("sudo git reset --hard").?);} test "command risk note detects quoted su command payload" { try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("su -c 'rm -rf /tmp'").?); try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("su -c \"git reset --hard\"").?); try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("su root -c 'rm -rf /tmp'").?);} test "command risk note fails closed for malformed su command payload" { try std.testing.expect(command_risk_note_for("su -c 'rm -rf /tmp") == null); try std.testing.expect(command_risk_note_for("su -c \"git reset --hard") == null);} test "command risk note detects process-control wrapped removal" { try std.testing.expect(command_risk_note_for("nice rm -rf /tmp") != null);} test "command risk note detects timeout wrapped git reset" { try std.testing.expect(command_risk_note_for("timeout 5 git reset --hard") != null);} test "command risk note detects safe env wrapped git reset" { try std.testing.expect(command_risk_note_for("env NODE_ENV=prod git reset --hard") != null);} test "command risk note sees removal after unknown env prefix" { try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("MY_SECRET=x rm -rf /tmp").?);} test "command risk note ignores quoted command text" { try std.testing.expect(command_risk_note_for("echo 'rm -rf /tmp'") == null);} test "command risk note ignores command text in argument string" { try std.testing.expect(command_risk_note_for("git commit -m \"rm -rf /tmp\"") == null);} test "command risk note detects command after sequence boundary" { try std.testing.expect(command_risk_note_for("echo ok; rm -f scratch") != null);} test "semantic annotation explains grep exit one" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("grep foo bar", 1, "").?);} test "semantic annotation leaves grep real errors alone" { try std.testing.expect(semantic_exit_annotation("grep foo bar", 2, "") == null);} test "semantic annotation leaves zero exits alone" { try std.testing.expect(semantic_exit_annotation("grep foo bar", 0, "") == null);} test "semantic annotation explains find partial access" { try std.testing.expectEqualStrings("note: some paths could not be read", semantic_exit_annotation("find /tmp -name foo", 1, "find: /tmp/private: Permission denied").?); try std.testing.expectEqualStrings("note: some paths could not be read", semantic_exit_annotation("find /tmp -name foo", 1, "find: /tmp/private: Operation not permitted").?);} test "semantic annotation leaves find usage errors alone" { try std.testing.expect(semantic_exit_annotation("find /tmp -bad", 1, "find: -bad: unknown primary or operator") == null); try std.testing.expect(semantic_exit_annotation("find /tmp -name", 1, "find: -name: requires additional arguments") == null);} test "semantic annotation explains diff differences" { try std.testing.expectEqualStrings("note: compared files differ", semantic_exit_annotation("diff a b", 1, "").?);} test "semantic annotation explains test false" { try std.testing.expectEqualStrings("note: condition evaluated false", semantic_exit_annotation("test -f foo", 1, "").?);} test "semantic annotation explains bracket alias false" { try std.testing.expectEqualStrings("note: condition evaluated false", semantic_exit_annotation("[ -f foo ]", 1, "").?);} test "semantic annotation handles wrapped grep" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("timeout 5 grep foo bar.txt", 1, "").?);} test "semantic annotation uses last pipe segment" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("diff a b | grep changed", 1, "").?);} test "semantic annotation uses last sequence segment" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("ls -la; grep foo bar.txt", 1, "").?);} test "semantic annotation uses last newline segment" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("ls -la\ngrep foo bar.txt", 1, "").?);} test "semantic annotation ignores default last pipe segment" { try std.testing.expect(semantic_exit_annotation("grep foo bar | wc -l", 1, "") == null);} test "semantic annotation declines short circuit commands" { try std.testing.expect(semantic_exit_annotation("grep foo bar && echo done", 1, "") == null);} test "semantic annotation declines background commands" { try std.testing.expect(semantic_exit_annotation("true & grep foo bar", 1, "") == null);} test "semantic annotation ignores quoted pipe" { try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("grep 'foo|bar' baz", 1, "").?);} test "semantic annotation ignores unknown base command" { try std.testing.expect(semantic_exit_annotation("python3 myscript.py", 1, "") == null);} test "semantic annotation ignores default nonzero command" { try std.testing.expect(semantic_exit_annotation("ls -z", 1, "") == null);} test "semantic annotation handles empty and malformed input" { try std.testing.expect(semantic_exit_annotation("", 1, "") == null); try std.testing.expect(semantic_exit_annotation("grep 'unterminated", 1, "") == null);}
const std = @import("std");
const command_classification = @import("../shell_command/command_classification.zig");
const RiskKind = enum {
discard_version_control_state,
forceful_file_removal,
};
/// Returns a short policy note for command text with a high-risk shape.
pub fn command_risk_note_for(command: []const u8) ?[]const u8 {
const analysis = command_classification.analysis_command_tail(command);
const risk = risk_kind_in_analysis(analysis) orelse return null;
return risk_note_for(risk);
}
/// Returns a narrow alternative when the command text has an unambiguous safer path.
pub fn command_safer_alternative_for(command: []const u8) ?[]const u8 {
const analysis = command_classification.analysis_command_tail(command);
if (risk_kind_in_analysis(analysis)) |risk| {
return safer_alternative_for_risk(risk);
}
if (has_shell_boundary(command)) return null;
const base = command_classification.base_command_token(analysis) orelse return null;
if (std.mem.eql(u8, base, "cat") or std.mem.eql(u8, base, "less") or std.mem.eql(u8, base, "more")) {
return "safer: use read_file for file inspection";
}
if (std.mem.eql(u8, base, "ls")) {
return "safer: use list_files or glob_files for discovery";
}
if (is_pattern_matcher(base)) {
return "safer: use grep_files for exact local search";
}
if (std.mem.eql(u8, base, "find")) {
return "safer: use glob_files or list_files for discovery";
}
return null;
}
fn risk_note_for(risk: RiskKind) []const u8 {
return switch (risk) {
.discard_version_control_state => "note: command may discard version-control state",
.forceful_file_removal => "note: command may remove files forcefully",
};
}
fn safer_alternative_for_risk(risk: RiskKind) []const u8 {
return switch (risk) {
.discard_version_control_state => "safer: inspect git status first and revert only the intended files",
.forceful_file_removal => "safer: inspect targets first or use delete_file for explicit files",
};
}
/// Returns an informational annotation for non-error non-zero exit codes.
pub fn semantic_exit_annotation(command_text: []const u8, exit_code: i64, stderr_text: []const u8) ?[]const u8 {
if (exit_code == 0) return null;
const analysis = command_classification.analysis_command_tail(command_text);
const terminal = command_classification.terminal_exit_segment(analysis) orelse return null;
const base = command_classification.base_command_token(terminal) orelse return null;
if (is_pattern_matcher(base)) {
return if (exit_code == 1) "note: no matches found" else null;
}
if (std.mem.eql(u8, base, "find")) {
if (exit_code != 1 or !looks_like_find_access_issue(stderr_text)) return null;
return "note: some paths could not be read";
}
if (std.mem.eql(u8, base, "diff") or std.mem.eql(u8, base, "cmp")) {
return if (exit_code == 1) "note: compared files differ" else null;
}
if (std.mem.eql(u8, base, "test") or std.mem.eql(u8, base, "[")) {
return if (exit_code == 1) "note: condition evaluated false" else null;
}
return null;
}
fn risk_kind_in_analysis(command: []const u8) ?RiskKind {
if (warning_at_command_position(command)) |risk| return risk;
var in_single = false;
var in_double = false;
var escaped = false;
var i: usize = 0;
while (i < command.len) : (i += 1) {
const ch = command[i];
if (escaped) {
escaped = false;
continue;
}
if (ch == '\\' and !in_single) {
escaped = true;
continue;
}
if (ch == '\'' and !in_double) {
in_single = !in_single;
continue;
}
if (ch == '"' and !in_single) {
in_double = !in_double;
continue;
}
if (in_single or in_double) continue;
const boundary_end = switch (ch) {
';', '\n', '&' => blk: {
if (ch == '&' and i + 1 < command.len and command[i + 1] == '&') {
i += 1;
break :blk i + 1;
}
break :blk i + 1;
},
'|' => blk: {
if (i + 1 < command.len and command[i + 1] == '|') {
i += 1;
break :blk i + 1;
}
break :blk i + 1;
},
else => continue,
};
if (warning_at_command_position(command[boundary_end..])) |risk| return risk;
}
return null;
}
fn warning_at_command_position(command: []const u8) ?RiskKind {
var cursor = command_classification.skip_whitespace(command, 0);
while (command_classification.next_token(command, cursor)) |token| {
if (!command_classification.is_env_assignment(token.text)) break;
cursor = token.end;
}
const first = command_classification.next_token(command, cursor) orelse return null;
if (privileged_command_tail(command, first)) |tail| {
if (warning_at_command_position(tail)) |risk| return risk;
}
const rest = command[first.end..];
if (git_hard_reset(first.text, rest)) |risk| return risk;
if (forceful_file_removal(first.text, rest)) |risk| return risk;
return null;
}
fn privileged_command_tail(command: []const u8, first: command_classification.Token) ?[]const u8 {
if (std.mem.eql(u8, first.text, "sudo") or std.mem.eql(u8, first.text, "doas")) {
return sudo_like_command_tail(command, first.end);
}
if (std.mem.eql(u8, first.text, "su")) {
return su_command_tail(command, first.end);
}
return null;
}
fn sudo_like_command_tail(command: []const u8, start: usize) ?[]const u8 {
var cursor = start;
while (command_classification.next_token(command, cursor)) |token| {
if (std.mem.eql(u8, token.text, "--")) {
const next = command_classification.next_token(command, token.end) orelse return null;
return command[next.start..];
}
if (command_classification.is_env_assignment(token.text)) {
cursor = token.end;
continue;
}
if (std.mem.startsWith(u8, token.text, "-")) {
if (privilege_option_takes_value(token.text)) {
const value = command_classification.next_token(command, token.end) orelse return null;
cursor = value.end;
} else {
cursor = token.end;
}
continue;
}
return command[token.start..];
}
return null;
}
fn privilege_option_takes_value(option: []const u8) bool {
return std.mem.eql(u8, option, "-u") or
std.mem.eql(u8, option, "-g") or
std.mem.eql(u8, option, "-h") or
std.mem.eql(u8, option, "-p") or
std.mem.eql(u8, option, "-C") or
std.mem.eql(u8, option, "-T") or
std.mem.eql(u8, option, "-U") or
std.mem.eql(u8, option, "--user") or
std.mem.eql(u8, option, "--group") or
std.mem.eql(u8, option, "--host") or
std.mem.eql(u8, option, "--prompt") or
std.mem.eql(u8, option, "--close-from") or
std.mem.eql(u8, option, "--command-timeout") or
std.mem.eql(u8, option, "--other-user");
}
fn su_command_tail(command: []const u8, start: usize) ?[]const u8 {
var cursor = start;
while (command_classification.next_token(command, cursor)) |token| {
if (std.mem.eql(u8, token.text, "-c")) {
return parse_su_command_argument(command, token.end);
}
cursor = token.end;
}
return null;
}
fn parse_su_command_argument(command: []const u8, start: usize) ?[]const u8 {
const cursor = command_classification.skip_whitespace(command, start);
if (cursor >= command.len) return null;
const quote = command[cursor];
if (quote == '\'' or quote == '"') {
const payload_start = cursor + 1;
var escaped = false;
var i = payload_start;
while (i < command.len) : (i += 1) {
const ch = command[i];
if (escaped) {
escaped = false;
continue;
}
if (quote == '"' and ch == '\\') {
escaped = true;
continue;
}
if (ch == quote) return command[payload_start..i];
}
return null;
}
const token = command_classification.next_token(command, cursor) orelse return null;
return token.text;
}
fn git_hard_reset(command_name: []const u8, rest: []const u8) ?RiskKind {
if (!std.mem.eql(u8, command_name, "git")) return null;
const sub = command_classification.next_token(rest, 0) orelse return null;
const args = rest[sub.end..];
if (std.mem.eql(u8, sub.text, "reset") and has_token(args, "--hard")) {
return .discard_version_control_state;
}
return null;
}
fn forceful_file_removal(command_name: []const u8, rest: []const u8) ?RiskKind {
if (!std.mem.eql(u8, command_name, "rm")) return null;
var has_recursive = false;
var has_force = false;
var cursor: usize = 0;
while (command_classification.next_token(rest, cursor)) |token| {
cursor = token.end;
if (!std.mem.startsWith(u8, token.text, "-")) continue;
if (std.mem.eql(u8, token.text, "--recursive") or std.mem.eql(u8, token.text, "-r") or std.mem.eql(u8, token.text, "-R")) has_recursive = true;
if (std.mem.eql(u8, token.text, "--force") or std.mem.eql(u8, token.text, "-f")) has_force = true;
if (token.text.len > 1 and token.text[1] != '-') {
for (token.text[1..]) |ch| {
if (ch == 'r' or ch == 'R') has_recursive = true;
if (ch == 'f') has_force = true;
}
}
}
return if (has_recursive or has_force) .forceful_file_removal else null;
}
fn is_pattern_matcher(command: []const u8) bool {
return std.mem.eql(u8, command, "grep") or
std.mem.eql(u8, command, "egrep") or
std.mem.eql(u8, command, "fgrep") or
std.mem.eql(u8, command, "rg") or
std.mem.eql(u8, command, "ag");
}
fn looks_like_find_access_issue(stderr_text: []const u8) bool {
return contains_ascii_ignore_case(stderr_text, "permission denied") or
contains_ascii_ignore_case(stderr_text, "operation not permitted");
}
fn has_token(rest: []const u8, needle: []const u8) bool {
var cursor: usize = 0;
while (command_classification.next_token(rest, cursor)) |token| {
cursor = token.end;
if (std.mem.eql(u8, token.text, needle)) return true;
}
return false;
}
fn contains_ascii_ignore_case(haystack: []const u8, needle: []const u8) bool {
if (needle.len == 0) return true;
if (haystack.len < needle.len) return false;
var i: usize = 0;
while (i + needle.len <= haystack.len) : (i += 1) {
if (ascii_eql_ignore_case(haystack[i .. i + needle.len], needle)) return true;
}
return false;
}
fn ascii_eql_ignore_case(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |left, right| {
if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false;
}
return true;
}
fn has_shell_boundary(command: []const u8) bool {
return std.mem.findScalar(u8, command, ';') != null or
std.mem.findScalar(u8, command, '&') != null or
std.mem.findScalar(u8, command, '|') != null or
std.mem.findScalar(u8, command, '<') != null or
std.mem.findScalar(u8, command, '>') != null or
std.mem.findScalar(u8, command, '$') != null or
std.mem.findScalar(u8, command, '`') != null or
std.mem.findScalar(u8, command, '\n') != null;
}
test "command risk note detects git hard reset" {
try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("git reset --hard").?);
}
test "command safer alternative explains risky commands" {
try std.testing.expectEqualStrings("safer: inspect git status first and revert only the intended files", command_safer_alternative_for("git reset --hard").?);
try std.testing.expectEqualStrings("safer: inspect targets first or use delete_file for explicit files", command_safer_alternative_for("rm -rf /tmp/x").?);
}
test "command safer alternative maps shell inspection to dedicated tools" {
try std.testing.expectEqualStrings("safer: use read_file for file inspection", command_safer_alternative_for("cat src/main.zig").?);
try std.testing.expectEqualStrings("safer: use list_files or glob_files for discovery", command_safer_alternative_for("ls src").?);
try std.testing.expectEqualStrings("safer: use grep_files for exact local search", command_safer_alternative_for("rg needle src").?);
try std.testing.expectEqualStrings("safer: use glob_files or list_files for discovery", command_safer_alternative_for("find src -name '*.zig'").?);
}
test "command safer alternative stays conservative for compound commands" {
try std.testing.expect(command_safer_alternative_for("cat src/main.zig | wc -l") == null);
try std.testing.expect(command_safer_alternative_for("git status") == null);
}
test "command risk note stays narrow for ordinary git operations" {
try std.testing.expect(command_risk_note_for("git status") == null);
try std.testing.expect(command_risk_note_for("git log --oneline") == null);
try std.testing.expect(command_risk_note_for("git commit --no-verify -m ok") == null);
}
test "command risk note detects forceful removal" {
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("rm -rf /tmp/x").?);
}
test "command risk note detects privilege-wrapped removal" {
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("sudo rm -rf /tmp").?);
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("doas rm -rf /tmp").?);
}
test "command risk note detects privilege-wrapped git reset" {
try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("sudo git reset --hard").?);
}
test "command risk note detects quoted su command payload" {
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("su -c 'rm -rf /tmp'").?);
try std.testing.expectEqualStrings("note: command may discard version-control state", command_risk_note_for("su -c \"git reset --hard\"").?);
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("su root -c 'rm -rf /tmp'").?);
}
test "command risk note fails closed for malformed su command payload" {
try std.testing.expect(command_risk_note_for("su -c 'rm -rf /tmp") == null);
try std.testing.expect(command_risk_note_for("su -c \"git reset --hard") == null);
}
test "command risk note detects process-control wrapped removal" {
try std.testing.expect(command_risk_note_for("nice rm -rf /tmp") != null);
}
test "command risk note detects timeout wrapped git reset" {
try std.testing.expect(command_risk_note_for("timeout 5 git reset --hard") != null);
}
test "command risk note detects safe env wrapped git reset" {
try std.testing.expect(command_risk_note_for("env NODE_ENV=prod git reset --hard") != null);
}
test "command risk note sees removal after unknown env prefix" {
try std.testing.expectEqualStrings("note: command may remove files forcefully", command_risk_note_for("MY_SECRET=x rm -rf /tmp").?);
}
test "command risk note ignores quoted command text" {
try std.testing.expect(command_risk_note_for("echo 'rm -rf /tmp'") == null);
}
test "command risk note ignores command text in argument string" {
try std.testing.expect(command_risk_note_for("git commit -m \"rm -rf /tmp\"") == null);
}
test "command risk note detects command after sequence boundary" {
try std.testing.expect(command_risk_note_for("echo ok; rm -f scratch") != null);
}
test "semantic annotation explains grep exit one" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("grep foo bar", 1, "").?);
}
test "semantic annotation leaves grep real errors alone" {
try std.testing.expect(semantic_exit_annotation("grep foo bar", 2, "") == null);
}
test "semantic annotation leaves zero exits alone" {
try std.testing.expect(semantic_exit_annotation("grep foo bar", 0, "") == null);
}
test "semantic annotation explains find partial access" {
try std.testing.expectEqualStrings("note: some paths could not be read", semantic_exit_annotation("find /tmp -name foo", 1, "find: /tmp/private: Permission denied").?);
try std.testing.expectEqualStrings("note: some paths could not be read", semantic_exit_annotation("find /tmp -name foo", 1, "find: /tmp/private: Operation not permitted").?);
}
test "semantic annotation leaves find usage errors alone" {
try std.testing.expect(semantic_exit_annotation("find /tmp -bad", 1, "find: -bad: unknown primary or operator") == null);
try std.testing.expect(semantic_exit_annotation("find /tmp -name", 1, "find: -name: requires additional arguments") == null);
}
test "semantic annotation explains diff differences" {
try std.testing.expectEqualStrings("note: compared files differ", semantic_exit_annotation("diff a b", 1, "").?);
}
test "semantic annotation explains test false" {
try std.testing.expectEqualStrings("note: condition evaluated false", semantic_exit_annotation("test -f foo", 1, "").?);
}
test "semantic annotation explains bracket alias false" {
try std.testing.expectEqualStrings("note: condition evaluated false", semantic_exit_annotation("[ -f foo ]", 1, "").?);
}
test "semantic annotation handles wrapped grep" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("timeout 5 grep foo bar.txt", 1, "").?);
}
test "semantic annotation uses last pipe segment" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("diff a b | grep changed", 1, "").?);
}
test "semantic annotation uses last sequence segment" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("ls -la; grep foo bar.txt", 1, "").?);
}
test "semantic annotation uses last newline segment" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("ls -la\ngrep foo bar.txt", 1, "").?);
}
test "semantic annotation ignores default last pipe segment" {
try std.testing.expect(semantic_exit_annotation("grep foo bar | wc -l", 1, "") == null);
}
test "semantic annotation declines short circuit commands" {
try std.testing.expect(semantic_exit_annotation("grep foo bar && echo done", 1, "") == null);
}
test "semantic annotation declines background commands" {
try std.testing.expect(semantic_exit_annotation("true & grep foo bar", 1, "") == null);
}
test "semantic annotation ignores quoted pipe" {
try std.testing.expectEqualStrings("note: no matches found", semantic_exit_annotation("grep 'foo|bar' baz", 1, "").?);
}
test "semantic annotation ignores unknown base command" {
try std.testing.expect(semantic_exit_annotation("python3 myscript.py", 1, "") == null);
}
test "semantic annotation ignores default nonzero command" {
try std.testing.expect(semantic_exit_annotation("ls -z", 1, "") == null);
}
test "semantic annotation handles empty and malformed input" {
try std.testing.expect(semantic_exit_annotation("", 1, "") == null);
try std.testing.expect(semantic_exit_annotation("grep 'unterminated", 1, "") == null);
}