Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.11.9] - 2026-04-07

### Fixed

- Collab join now fails closed when GitHub API collaborator check fails
- Recipient filtering enforced against authorized list during `collab add` and `collab refresh`
- Secret scan during `team add` is now recursive (catches secrets in subdirectories)
- Secure file permissions on Windows for key cache, identity cache, and decrypted secrets via `icacls`
- Centralized `write_owner_only` helper fixes pre-existing file permissions on Unix

### Changed

- Dashboard TUI palette brightened for better readability on dark and light terminals

## [1.11.8] - 2026-03-09

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tether"
version = "1.11.8"
version = "1.11.9"
edition = "2021"
authors = ["Paddo Tech"]
description = "Sync your development environment across machines automatically"
Expand Down
23 changes: 17 additions & 6 deletions src/cli/commands/collab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,14 @@ pub async fn join(url: &str) -> Result<()> {
}
}
Err(_) => {
// Can't verify - might not have access to check collaborators
Output::warning(&format!(
"Could not verify access to {}/{}",
project_owner, project_repo
));
not_collaborator_on.push(format!(
"{}/{} (verification failed)",
project_owner, project_repo
));
}
}
}
Expand Down Expand Up @@ -356,7 +359,7 @@ pub async fn add(file: &str, project_path: Option<&str>) -> Result<()> {
let normalized_url = normalize_remote_url(&remote_url);

// Find collab for this project
let (collab_name, _collab_config) =
let (collab_name, collab_config) =
config.collab_for_project(&normalized_url).ok_or_else(|| {
anyhow::anyhow!(
"No collab configured for this project. Run 'tether collab init' first."
Expand All @@ -374,9 +377,13 @@ pub async fn add(file: &str, project_path: Option<&str>) -> Result<()> {
let git = GitBackend::open(&collab_dir)?;
git.pull()?;

// Load recipients
// Load recipients filtered by authorized members
let recipients_dir = collab_dir.join("recipients");
let recipients = crate::security::load_recipients(&recipients_dir)?;
let (recipients, skipped) =
crate::security::load_recipients_authorized(&recipients_dir, &collab_config.members_cache)?;
for name in &skipped {
Output::warning(&format!("Skipping unauthorized recipient: {}", name));
}
if recipients.is_empty() {
return Err(anyhow::anyhow!(
"No recipients found in collab. Add recipients first."
Expand Down Expand Up @@ -571,8 +578,12 @@ pub async fn refresh(project_path: Option<&str>) -> Result<()> {
}
}

// Re-encrypt all secrets with current recipients
let recipients = crate::security::load_recipients(&recipients_dir)?;
// Re-encrypt all secrets with only authorized recipients
let (recipients, skipped) =
crate::security::load_recipients_authorized(&recipients_dir, &collaborators)?;
for name in &skipped {
Output::warning(&format!("Skipping unauthorized recipient: {}", name));
}

if recipients.is_empty() {
Output::warning("No recipients found - secrets won't be re-encrypted");
Expand Down
8 changes: 1 addition & 7 deletions src/cli/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,13 +651,7 @@ fn preserve_executable_bit(source: &Path, dest: &Path) {

/// Write decrypted content with secure permissions (0o600 on Unix)
fn write_decrypted(path: &Path, contents: &[u8]) -> Result<()> {
std::fs::write(path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
crate::security::write_owner_only(path, contents)
}

pub fn decrypt_from_repo(
Expand Down
55 changes: 34 additions & 21 deletions src/cli/commands/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,29 +359,42 @@ pub async fn add(url: &str, name: Option<&str>, _no_auto_inject: bool) -> Result
let mut secrets_found = false;

if dotfiles_dir.exists() {
for entry in std::fs::read_dir(&dotfiles_dir)? {
let entry = entry?;
if entry.file_type()?.is_file() {
if let Some(filename) = entry.file_name().to_str() {
team_files.push(filename.to_string());

// Scan for secrets
let file_path = entry.path();
if let Ok(findings) = crate::security::scan_for_secrets(&file_path) {
if !findings.is_empty() {
secrets_found = true;
for entry in walkdir::WalkDir::new(&dotfiles_dir)
.min_depth(1)
.follow_links(false)
{
let entry = match entry {
Ok(e) => e,
Err(e) => {
Output::warning(&format!("Could not read during scan: {}", e));
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let rel_path = entry
.path()
.strip_prefix(&dotfiles_dir)
.unwrap_or(entry.path());
if let Some(rel_str) = rel_path.to_str() {
team_files.push(rel_str.to_string());

// Scan for secrets
if let Ok(findings) = crate::security::scan_for_secrets(entry.path()) {
if !findings.is_empty() {
secrets_found = true;
Output::warning(&format!(
" {} - Found {} potential secret(s)",
rel_str,
findings.len()
));
for finding in findings.iter().take(2) {
Output::warning(&format!(
" {} - Found {} potential secret(s)",
filename,
findings.len()
" Line {}: {}",
finding.line_number,
finding.secret_type.description()
));
for finding in findings.iter().take(2) {
Output::warning(&format!(
" Line {}: {}",
finding.line_number,
finding.secret_type.description()
));
}
}
}
}
Expand Down
57 changes: 35 additions & 22 deletions src/dashboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1558,9 +1558,9 @@ fn render_confirm_popup(f: &mut Frame, title: &str, msg: &str, border_color: Col
Line::from(""),
Line::from(vec![
Span::styled(" y", Style::default().fg(Color::Yellow).bold()),
Span::styled(" confirm ", Style::default().fg(Color::DarkGray)),
Span::styled(" confirm ", Style::default().fg(Color::Gray)),
Span::styled("n/Esc", Style::default().fg(Color::Yellow).bold()),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
Span::styled(" cancel", Style::default().fg(Color::Gray)),
]),
];

Expand Down Expand Up @@ -1609,7 +1609,10 @@ fn render_file_import_popup(f: &mut Frame, picker: &ImportPickerState) {
{
let marker = if i == picker.cursor { "> " } else { " " };
let style = if i == picker.cursor {
Style::default().fg(Color::White).bg(Color::DarkGray).bold()
Style::default()
.fg(Color::White)
.bg(Color::Indexed(240))
.bold()
} else {
Style::default().fg(Color::White)
};
Expand All @@ -1618,21 +1621,23 @@ fn render_file_import_popup(f: &mut Frame, picker: &ImportPickerState) {
Span::styled(
format!(" [{}]", item.source_profile),
if i == picker.cursor {
Style::default().fg(Color::DarkGray).bg(Color::DarkGray)
Style::default()
.fg(Color::Indexed(240))
.bg(Color::Indexed(240))
} else {
Style::default().fg(Color::DarkGray)
Style::default().fg(Color::Gray)
},
),
]));
}
text.push(Line::from(""));
text.push(Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()),
Span::styled(" navigate ", Style::default().fg(Color::DarkGray)),
Span::styled(" navigate ", Style::default().fg(Color::Gray)),
Span::styled("Enter", Style::default().fg(Color::Yellow).bold()),
Span::styled(" import ", Style::default().fg(Color::DarkGray)),
Span::styled(" import ", Style::default().fg(Color::Gray)),
Span::styled("Esc", Style::default().fg(Color::Yellow).bold()),
Span::styled(" close", Style::default().fg(Color::DarkGray)),
Span::styled(" close", Style::default().fg(Color::Gray)),
]));

let paragraph = ratatui::widgets::Paragraph::new(text).block(
Expand Down Expand Up @@ -1685,14 +1690,19 @@ fn render_pkg_import_popup(f: &mut Frame, picker: &PkgImportPickerState) {
let label = widgets::manager_label(&item.manager_key);
let sources = item.sources.join(", ");
let style = if i == picker.cursor {
Style::default().fg(Color::White).bg(Color::DarkGray).bold()
Style::default()
.fg(Color::White)
.bg(Color::Indexed(240))
.bold()
} else {
Style::default().fg(Color::White)
};
let dim = if i == picker.cursor {
Style::default().fg(Color::DarkGray).bg(Color::DarkGray)
Style::default()
.fg(Color::Indexed(240))
.bg(Color::Indexed(240))
} else {
Style::default().fg(Color::DarkGray)
Style::default().fg(Color::Gray)
};
text.push(Line::from(vec![
Span::styled(format!(" {}{}", marker, item.name), style),
Expand All @@ -1703,11 +1713,11 @@ fn render_pkg_import_popup(f: &mut Frame, picker: &PkgImportPickerState) {
text.push(Line::from(""));
text.push(Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()),
Span::styled(" navigate ", Style::default().fg(Color::DarkGray)),
Span::styled(" navigate ", Style::default().fg(Color::Gray)),
Span::styled("Enter", Style::default().fg(Color::Yellow).bold()),
Span::styled(" install ", Style::default().fg(Color::DarkGray)),
Span::styled(" install ", Style::default().fg(Color::Gray)),
Span::styled("Esc", Style::default().fg(Color::Yellow).bold()),
Span::styled(" close", Style::default().fg(Color::DarkGray)),
Span::styled(" close", Style::default().fg(Color::Gray)),
]));

let paragraph = ratatui::widgets::Paragraph::new(text).block(
Expand Down Expand Up @@ -1761,16 +1771,16 @@ fn draw(f: &mut Frame, app: &App) {
])
} else {
Line::from(vec![
Span::styled(num, Style::default().fg(Color::DarkGray)),
Span::styled(num, Style::default().fg(Color::Gray)),
Span::raw(":"),
Span::styled(t.title(), Style::default().fg(Color::DarkGray)),
Span::styled(t.title(), Style::default().fg(Color::Gray)),
])
}
})
.collect();

let tabs = ratatui::widgets::Tabs::new(tab_titles)
.divider(Span::styled(" | ", Style::default().fg(Color::DarkGray)))
.divider(Span::styled(" | ", Style::default().fg(Color::Gray)))
.select(
Tab::all()
.iter()
Expand Down Expand Up @@ -1898,7 +1908,10 @@ fn render_profile_popup(f: &mut Frame, options: &[String], cursor: usize) {
for (i, option) in options.iter().enumerate() {
let marker = if i == cursor { "> " } else { " " };
let style = if i == cursor {
Style::default().fg(Color::White).bg(Color::DarkGray).bold()
Style::default()
.fg(Color::White)
.bg(Color::Indexed(240))
.bold()
} else {
Style::default().fg(Color::White)
};
Expand All @@ -1910,15 +1923,15 @@ fn render_profile_popup(f: &mut Frame, options: &[String], cursor: usize) {
text.push(Line::from(""));
text.push(Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()),
Span::styled(" navigate ", Style::default().fg(Color::DarkGray)),
Span::styled(" navigate ", Style::default().fg(Color::Gray)),
Span::styled("Enter", Style::default().fg(Color::Yellow).bold()),
Span::styled(" select ", Style::default().fg(Color::DarkGray)),
Span::styled(" select ", Style::default().fg(Color::Gray)),
Span::styled("Esc", Style::default().fg(Color::Yellow).bold()),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
Span::styled(" cancel", Style::default().fg(Color::Gray)),
]));
text.push(Line::from(Span::styled(
" New: tether machines profile create <name>",
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::Gray),
)));

let paragraph = ratatui::widgets::Paragraph::new(text).block(
Expand Down
11 changes: 3 additions & 8 deletions src/dashboard/widgets/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,13 @@ pub fn render(f: &mut Frame, area: Rect, lines: &[String]) {
let text = if lines.is_empty() {
Text::from(Span::styled(
" No activity",
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::Gray),
))
} else {
Text::from(
lines
.iter()
.map(|l| {
Line::from(Span::styled(
l.as_str(),
Style::default().fg(Color::DarkGray),
))
})
.map(|l| Line::from(Span::styled(l.as_str(), Style::default().fg(Color::Gray))))
.collect::<Vec<_>>(),
)
};
Expand All @@ -24,7 +19,7 @@ pub fn render(f: &mut Frame, area: Rect, lines: &[String]) {
Block::default()
.title(" Activity ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
.border_style(Style::default().fg(Color::Gray)),
);
f.render_widget(paragraph, area);
}
Loading
Loading