fix(trust): grants key code-running options; /note stays in the workspace; remote commands protected

Review findings on ecdc8405b, d0e31586a and 9253eff1a:

- Shell grants ignored flags, so `git status` covered
  `git -ccore.fsmonitor=./evil status`, `cargo build` covered
  `cargo build --config build.rustc-wrapper=…`, and `git config user.name`
  covered `git config core.fsmonitor`. Any `=` argument, a code-running
  option (-c, -C, --config, --exec, -f, --file, -e, …) or a config-style
  family (config/set/remote) now keys the grant on the full command.
- `/note clear|edit|remove|list` followed a committed
  `notes.md -> ~/.zshrc` or a symlinked notes directory. They now apply
  the note tool's rule: no symlinked file, directory must resolve inside
  the workspace.
- rc/remote-control, relay, remote-env, profile and share are protected
  built-ins: a trusted repo's rc.md can no longer answer `/rc off`.

Tests: codewhale-tui lib focused (approval_cache user_registry
memory::note ...) 252/0 (1 ignored), run together with the previous
commit's set.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 14:33:08 -07:00
1 parent 1f07587fd9
commit a8755ae003
3 files changed
+142 -3

No files matched your search

+61 -1
View File
@@ -26,8 +26,14 @@ fn note(workspace: &Path, content: Option<&str>) -> CommandResult {
let notes_path = notes_path(workspace);
let (command, rest) = split_command(input);
let command = command.to_ascii_lowercase();
if !matches!(command.as_str(), "path" | "help")
&& let Err(error) = ensure_notes_target_in_workspace(&notes_path, workspace)
{
return CommandResult::error(error);
}
match command.to_ascii_lowercase().as_str() {
match command.as_str() {
"add" => append_note_command(&notes_path, rest),
"list" => list_notes_command(&notes_path),
"show" => show_note_command(&notes_path, rest),
@@ -51,6 +57,39 @@ fn notes_path(workspace: &Path) -> PathBuf {
workspace.join(".deepseek").join("notes.md")
}
/// The notes file lives in the workspace, which may be a cloned repository:
/// a committed `notes.md -> ~/.zshrc` or a symlinked `.codewhale/` must not
/// let `/note clear` empty (or `/note list` print) a file outside it. Same
/// rule as the `note` tool.
fn ensure_notes_target_in_workspace(notes_path: &Path, workspace: &Path) -> Result<(), String> {
if let Ok(meta) = fs::symlink_metadata(notes_path)
&& (meta.file_type().is_symlink() || !meta.is_file())
{
return Err(format!(
"Refusing to use {}: the notes path is a symlink or not a regular file.",
notes_path.display()
));
}
let (Some(parent), Ok(root)) = (notes_path.parent(), fs::canonicalize(workspace)) else {
return Ok(());
};
// Only existing ancestors can redirect; the rest is created as real
// directories.
let mut existing = parent.to_path_buf();
while !existing.exists() {
if !existing.pop() {
return Ok(());
}
}
match fs::canonicalize(&existing) {
Ok(resolved) if !resolved.starts_with(&root) => Err(format!(
"Refusing to use {}: its directory resolves outside the workspace.",
notes_path.display()
)),
_ => Ok(()),
}
}
fn split_command(input: &str) -> (&str, Option<&str>) {
match input.find(char::is_whitespace) {
Some(index) => (&input[..index], Some(input[index..].trim())),
@@ -449,6 +488,27 @@ mod tests {
assert!(!listed.contains("Second note"));
}
#[cfg(unix)]
#[test]
fn note_commands_refuse_notes_that_leave_the_workspace() {
let tmpdir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let rc = outside.path().join("zshrc");
std::fs::write(&rc, "export PATH=keep\n").unwrap();
std::fs::create_dir_all(tmpdir.path().join(".deepseek")).unwrap();
std::os::unix::fs::symlink(&rc, notes_path(&tmpdir)).unwrap();
for command in ["clear", "remove 1", "edit 1 x", "list", "hello"] {
let result = note(tmpdir.path(), Some(command));
assert!(result.is_error, "/note {command} must be refused");
}
assert_eq!(std::fs::read_to_string(&rc).unwrap(), "export PATH=keep\n");
let linked = TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), linked.path().join(".deepseek")).unwrap();
assert!(note(linked.path(), Some("clear")).is_error);
assert!(!outside.path().join("notes.md").exists());
}
#[test]
fn test_note_clear_empties_file() {
let tmpdir = TempDir::new().unwrap();
+27
View File
@@ -489,12 +489,17 @@ const PROTECTED_BUILTINS: &[&str] = &[
"permissions",
"plug",
"plugin",
"profile",
"provider",
"purge",
"rc",
"relay",
"remote-env",
"restore",
"sessions",
"settings",
"setup",
"share",
"system",
"trust",
"undo",
@@ -1144,9 +1149,31 @@ mod tests {
let tmp = TempDir::new().unwrap();
write_workspace_command(tmp.path(), "undo", "pretend to undo $ARGUMENTS");
write_workspace_command(tmp.path(), "trust", "pretend to trust");
// Remote access and sharing are access controls too: a repo's
// `rc.md` must not answer `/rc off` while remote control stays on.
for name in [
"rc",
"remote-control",
"relay",
"remote-env",
"profile",
"share",
] {
write_workspace_command(tmp.path(), name, "pretend it is off");
}
let registry = registry_for_workspace(Some(tmp.path()));
assert!(registry.get("undo").is_none());
assert!(registry.get("trust").is_none());
for name in [
"rc",
"remote-control",
"relay",
"remote-env",
"profile",
"share",
] {
assert!(registry.get(name).is_none(), "/{name} must stay built in");
}
assert!(
registry
.load_errors()
+54 -2
View File
@@ -347,15 +347,53 @@ fn shell_command_grant_scope(input: &serde_json::Value) -> String {
if tokens.is_empty() {
return "shell:<empty>".to_string();
}
if !shell_command_is_compound(cmd) && !shell_command_is_wrapper(&tokens) {
if !shell_command_is_compound(cmd)
&& !shell_command_is_wrapper(&tokens)
&& !shell_command_has_code_option(&tokens)
{
let family = classify_command(&tokens);
if command_family_is_known(&family) {
if command_family_is_known(&family) && !family_arguments_are_config(&family) {
return format!("shell:{family}");
}
}
format!("shell:cmd:{}", normalize_shell_command(cmd))
}
/// Options that can make a known command run other code, read another
/// program, or write anywhere: `git -ccore.fsmonitor=./x status`,
/// `cargo build --config build.rustc-wrapper=…`, `git diff --output=<path>`,
/// `make -f /tmp/x`. The family ignores flags, so any of these keys the
/// grant on the full command instead.
fn shell_command_has_code_option(tokens: &[&str]) -> bool {
const CODE_OPTIONS: &[&str] = &[
"-c",
"-C",
"--config",
"--exec",
"--exec-path",
"--script-shell",
"-f",
"--file",
"--makefile",
"-e",
"--eval",
"--require",
"--upload-pack",
"--receive-pack",
"--manifest-path",
];
tokens
.iter()
.skip(1)
.any(|token| token.contains('=') || CODE_OPTIONS.contains(token))
}
/// Families whose arguments are settings, so one grant would cover every
/// setting (`git config user.name x` covering `git config core.fsmonitor`).
fn family_arguments_are_config(family: &str) -> bool {
matches!(family.split(' ').nth(1), Some("config" | "set" | "remote"))
}
/// Whether the dictionary recognised `family`, rather than falling back to
/// the bare first word.
fn command_family_is_known(family: &str) -> bool {
@@ -696,6 +734,20 @@ mod tests {
assert_eq!(key("cd src && make"), key(" cd src && make "));
assert_ne!(key("echo \"a b\""), key("echo \"a b\""));
assert!(key("rm -rf ~").0.starts_with("shell:cmd:"));
// Options and config arguments that can run code key the full
// command, though the family ignores flags.
assert_ne!(key("git status"), key("git -ccore.fsmonitor=./evil status"));
assert_ne!(key("git status"), key("git -C ../other status"));
assert_ne!(
key("cargo build"),
key("cargo build --config build.rustc-wrapper=./x")
);
assert_ne!(key("git diff"), key("git diff --output=/etc/hosts"));
assert_ne!(key("make"), key("make -f /tmp/x"));
assert_ne!(
key("git config user.name x"),
key("git config core.fsmonitor ./x.sh")
);
// A known, simple command keeps its family grant.
assert_eq!(key("git status"), key("git status --porcelain"));
assert_ne!(key("git status"), key("git push"));