Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/current-clippy-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Keep Apps Script file selection compatible with the current Clippy checks.
13 changes: 13 additions & 0 deletions .changeset/scoped-file-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@googleworkspace/cli": minor
---

Allow operators to set `GOOGLE_WORKSPACE_CLI_FILE_ROOT` to an existing directory
for `--output` and `--upload` paths while keeping CWD confinement by default.
Relative CLI paths remain CWD-relative. Reject invalid roots, parent traversal
with an explicit root, control characters, and symlink escapes, including
dangling symlinks. Directory flags retain their existing boundaries.

Reject canonical file paths that cannot be represented as UTF-8 at the CLI
string boundary, so explicit output/upload paths cannot silently become omitted
arguments.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ When adding new helpers or CLI flags that accept file paths, **always validate**
| -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path (`--output`, `--upload`) | `validate::validate_safe_file_path()` | Paths outside CWD or the trusted `GOOGLE_WORKSPACE_CLI_FILE_ROOT`, control chars, symlink escapes; `..` components with an explicit root |
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |

```rust
Expand Down Expand Up @@ -208,6 +209,7 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md)
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) |
| `GOOGLE_WORKSPACE_CLI_FILE_ROOT` | Trusted boundary for `--output` / `--upload` files (default: CWD). Must be an existing directory; canonicalized. Relative CLI paths remain CWD-relative. Does not expand directory validators. |

### OAuth Client

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,39 @@ Installing this extension gives your Gemini CLI agent direct access to all `gws`
gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf
```

### Output and upload file roots

`--output` and `--upload` accept paths within the current working directory
(CWD) by default, including absolute paths that resolve inside CWD. To allow
files elsewhere, set a trusted operator environment variable to an existing
directory:

```bash
mkdir -p /tmp/gws-files
export GOOGLE_WORKSPACE_CLI_FILE_ROOT=/tmp/gws-files
gws drive files get --params '{"fileId":"FILE_ID","alt":"media"}' \
--output /tmp/gws-files/report.pdf
gws drive files create --json '{"name":"report.pdf"}' \
--upload /tmp/gws-files/report.pdf
```

The root replaces the allowed file boundary; relative CLI paths still resolve
from CWD. For example, `--output report.pdf` is rejected if CWD is outside the
configured root. The root is canonicalized and must exist as a directory; an
empty or invalid value fails validation. Relative root settings resolve from
CWD too. With an explicit root, CLI paths containing `..` components are
rejected. Control characters and symlinks escaping the boundary are rejected;
symlinks resolving inside it are allowed, but dangling symlinks are rejected.
These CLI file flags require a UTF-8 canonical path. If a symlink resolves to a
path with unsupported encoding, the command returns a validation error rather
than dropping the upload or selecting the default output file.

This setting affects only these file flags, not `--dir` or `--output-dir`.
It does not create parent directories or change the default download filename
when `--output` is omitted. Validation cannot prevent another local process
from replacing a path component between validation and I/O; choose a root
whose directories you control. Unset the variable to restore the CWD boundary.

### Pagination

| Flag | Description | Default |
Expand Down Expand Up @@ -382,6 +415,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) |
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |
| `GOOGLE_WORKSPACE_CLI_FILE_ROOT` | Existing directory allowed for `--output` / `--upload` paths (default: CWD); relative CLI paths remain CWD-relative |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
| `GOOGLE_WORKSPACE_CLI_LOG` | Log level for stderr (e.g., `gws=debug`). Off by default. |
Expand Down
38 changes: 38 additions & 0 deletions crates/google-workspace-cli/src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3008,6 +3008,28 @@ mod tests {

// --- Attachment tests ---

// Attachment parsing calls the public file validator. Default-policy tests
// must ignore and restore an inherited operator root, including on panic.
// All users of this guard are serialized with the other environment tests.
struct DefaultFileRoot(Option<std::ffi::OsString>);

impl DefaultFileRoot {
fn unset() -> Self {
let saved = Self(std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT"));
std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT");
saved
}
}

impl Drop for DefaultFileRoot {
fn drop(&mut self) {
match &self.0 {
Some(root) => std::env::set_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT", root),
None => std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT"),
}
}
}

fn make_attach_matches(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test").arg(
Arg::new("attach")
Expand Down Expand Up @@ -3095,29 +3117,37 @@ mod tests {
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_rejects_control_chars() {
let _root = DefaultFileRoot::unset();
let matches = make_attach_matches(&["test", "-a", "file\0name.pdf"]);
let err = parse_attachments(&matches).unwrap_err();
assert!(err.to_string().contains("control characters"));
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_rejects_directory() {
let _root = DefaultFileRoot::unset();
// Use a relative directory that exists in CWD
let matches = make_attach_matches(&["test", "-a", "src"]);
let err = parse_attachments(&matches).unwrap_err();
assert!(err.to_string().contains("not a regular file"));
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_empty_returns_empty_vec() {
let _root = DefaultFileRoot::unset();
let matches = make_attach_matches(&["test"]);
let attachments = parse_attachments(&matches).unwrap();
assert!(attachments.is_empty());
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_reads_real_file() {
let _root = DefaultFileRoot::unset();
use std::io::Write;
let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
Expand All @@ -3137,7 +3167,9 @@ mod tests {
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_nonexistent_file() {
let _root = DefaultFileRoot::unset();
let matches = make_attach_matches(&["test", "-a", "nonexistent_file.pdf"]);
let err = parse_attachments(&matches).unwrap_err();
assert!(
Expand All @@ -3148,7 +3180,9 @@ mod tests {
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_unknown_extension_falls_back_to_octet_stream() {
let _root = DefaultFileRoot::unset();
use std::io::Write;
let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
Expand All @@ -3165,7 +3199,9 @@ mod tests {
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_size_limit_accumulates() {
let _root = DefaultFileRoot::unset();
let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();

Expand Down Expand Up @@ -3193,7 +3229,9 @@ mod tests {
}

#[test]
#[serial_test::serial]
fn test_parse_attachments_rejects_empty_file() {
let _root = DefaultFileRoot::unset();
let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let file_path = dir.path().join("empty.txt");
Expand Down
8 changes: 1 addition & 7 deletions crates/google-workspace-cli/src/helpers/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,7 @@ fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
filename.trim_end_matches(".js").trim_end_matches(".gs"),
),
"html" => ("HTML", filename.trim_end_matches(".html")),
"json" => {
if filename == "appsscript.json" {
("JSON", "appsscript")
} else {
return Ok(None);
}
}
"json" if filename == "appsscript.json" => ("JSON", "appsscript"),
_ => return Ok(None),
};

Expand Down
73 changes: 70 additions & 3 deletions crates/google-workspace-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ async fn run() -> Result<(), GwsError> {

// Validate file paths against traversal before any I/O.
// Use the returned canonical paths so the validated path is the one
// actually used for I/O (closes TOCTOU gap).
// actually used for I/O. Local path-replacement races still apply.
let upload_path_buf = if let Some(p) = upload_path {
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
} else {
Expand All @@ -236,8 +236,8 @@ async fn run() -> Result<(), GwsError> {
} else {
None
};
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
let upload_path = optional_file_path_as_str(upload_path_buf.as_deref(), "--upload")?;
let output_path = optional_file_path_as_str(output_path_buf.as_deref(), "--output")?;

let upload = {
let upload_content_type = matched_args
Expand Down Expand Up @@ -299,6 +299,22 @@ async fn run() -> Result<(), GwsError> {
.map(|_| ())
}

// The executor takes strings. An explicit path must never become an omitted
// argument just because canonicalization found a non-UTF-8 component.
fn optional_file_path_as_str<'a>(
path: Option<&'a std::path::Path>,
flag_name: &str,
) -> Result<Option<&'a str>, GwsError> {
path.map(|path| {
path.to_str().ok_or_else(|| {
GwsError::Validation(format!(
"{flag_name} resolves to a path that is not valid UTF-8; choose a path whose canonical components are valid UTF-8"
))
})
})
.transpose()
}

/// Select the best scope from a method's scope list.
///
/// Discovery Documents list method scopes as alternatives — any single scope
Expand Down Expand Up @@ -525,6 +541,57 @@ fn is_version_flag(arg: &str) -> bool {
mod tests {
use super::*;

#[test]
fn file_root_path_encoding_preserves_present_and_absent_paths() {
for flag in ["--output", "--upload"] {
assert_eq!(optional_file_path_as_str(None, flag).unwrap(), None);
assert_eq!(
optional_file_path_as_str(Some(std::path::Path::new("résumé.pdf")), flag).unwrap(),
Some("résumé.pdf")
);
}
}

#[cfg(any(unix, windows))]
fn non_utf8_canonical_path() -> std::path::PathBuf {
// Construct an OS path in memory: no filesystem support is required.
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(b"/files/bytes-\xff/report.pdf".to_vec()).into()
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
let mut units: Vec<u16> = r"C:\files\bytes-".encode_utf16().collect();
units.push(0xD800); // unpaired surrogate
units.extend(r"\report.pdf".encode_utf16());
std::ffi::OsString::from_wide(&units).into()
}
}

#[cfg(any(unix, windows))]
#[test]
fn file_root_path_encoding_rejects_explicit_output_instead_of_fallback() {
let path = non_utf8_canonical_path();
let error = optional_file_path_as_str(Some(&path), "--output").unwrap_err();
assert!(matches!(error, GwsError::Validation(_)));
let message = error.to_string();
assert!(message.contains("--output"), "{message}");
assert!(message.contains("UTF-8"), "{message}");
}

#[cfg(any(unix, windows))]
#[test]
fn file_root_path_encoding_rejects_explicit_upload_instead_of_omitting_it() {
let path = non_utf8_canonical_path();
let error = optional_file_path_as_str(Some(&path), "--upload").unwrap_err();
assert!(matches!(error, GwsError::Validation(_)));
let message = error.to_string();
assert!(message.contains("--upload"), "{message}");
assert!(message.contains("UTF-8"), "{message}");
}

#[test]
fn test_parse_pagination_config_defaults() {
let matches = clap::Command::new("test")
Expand Down
Loading
Loading