mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(trust): grants key code-running options; /note stays in the workspace; remote commands protected
Review findings onecdc8405b,d0e31586aand9253eff1a: - 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:
1 parent
1f07587fd9
commit
a8755ae003
3 files changed
+142
-3
No files matched your search
@@ -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(¬es_path, workspace)
|
||||
{
|
||||
return CommandResult::error(error);
|
||||
}
|
||||
|
||||
match command.to_ascii_lowercase().as_str() {
|
||||
match command.as_str() {
|
||||
"add" => append_note_command(¬es_path, rest),
|
||||
"list" => list_notes_command(¬es_path),
|
||||
"show" => show_note_command(¬es_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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user