Skip to content
Open
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
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature
anyhow = "1"
toml = "0.8"
dirs = "6"
fs2 = "0.4"

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
Expand Down
2 changes: 1 addition & 1 deletion commands/off.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Run `aloud-code disable` to deactivate Slack streaming for this session.
Slack ストリーミングが無効になりました。
2 changes: 1 addition & 1 deletion commands/on.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Run `aloud-code enable` to activate Slack streaming for this session.
Slack ストリーミングが有効になりました。
25 changes: 25 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@
"timeout": 30,
"async": true
}]
}],
"SubagentStop": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh subagent-stop",
"timeout": 30,
"async": true
}]
}],
"Notification": [{
"matcher": "elicitation_dialog",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh notification",
"timeout": 30,
"async": true
}]
}],
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh session-end",
"timeout": 30,
"async": true
}]
}]
}
}
155 changes: 145 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::Result;
use fs2::FileExt;
use serde::Deserialize;
use std::path::PathBuf;

Expand All @@ -24,6 +25,71 @@ impl Config {
}
}

/// セッションごとのカーソルロックガード
/// acquire() でファイルロックを取得し、commit() で更新・解放する
pub struct CursorLockGuard {
lock_file: std::fs::File,
session_id: String,
}

impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
/// カーソルファイルが存在しない(初回有効化・再有効化後)場合は None を返す
pub fn acquire(session_id: &str) -> Result<(Self, Option<u64>)> {
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
Comment on lines +39 to +46

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session_id をそのままファイル名に埋め込んで join(format!("{}.cursor.lock", session_id)) しているため、入力が ../ やパス区切りを含む場合に sessions_dir 外のパスへ到達できます(パストラバーサル)。セッションIDを安全な文字種にバリデート/正規化するか、ハッシュ化した固定長のファイル名を使うなどでファイルパス生成を安全にしてください。

Copilot uses AI. Check for mistakes.
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: session_id.to_string(),
Comment on lines +35 to +52

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session_idをそのままファイル名に埋め込んでjoin()しているため、../やパス区切り文字を含む入力でセッションディレクトリ外の任意パスを参照/作成できてしまいます(activate()/deactivate()/カーソル/ロック全てに影響)。session_idを安全な文字集合に制限する、エスケープ/ハッシュ化してファイル名にする等の対策を入れてください。

Suggested change
impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
pub fn acquire(session_id: &str) -> Result<(Self, u64)> {
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: session_id.to_string(),
/// セッションIDをファイル名として安全に使用できる形式に正規化する
/// - ASCII英数字と `-` / `_` のみを許可し、それ以外は `_` に置換する
/// - すべて置換された場合は `_` を返す
fn sanitize_session_id(session_id: &str) -> String {
let mut sanitized: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
sanitized.push('_');
}
sanitized
}
impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
pub fn acquire(session_id: &str) -> Result<(Self, u64)> {
let safe_session_id = sanitize_session_id(session_id);
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", safe_session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(&safe_session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: safe_session_id,

Copilot uses AI. Check for mistakes.
},
cursor,
))
}

/// カーソルを新しい値に更新してロックを解放する(送信成功後に呼び出す)
pub fn commit(self, new_cursor: u64) -> Result<()> {
write_cursor_inner(&self.session_id, new_cursor)?;
self.lock_file.unlock()?;
Ok(())
}
}

impl Drop for CursorLockGuard {
fn drop(&mut self) {
let _ = self.lock_file.unlock();
}
}

/// カーソルファイルが存在しない場合は None を返す(初回有効化を検出するため)
fn read_cursor_inner(session_id: &str) -> Option<u64> {
let path = cursor_path(session_id).ok()?;
if !path.exists() {
return None;
}
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
}

fn write_cursor_inner(session_id: &str, cursor: u64) -> Result<()> {
let path = cursor_path(session_id)?;
std::fs::write(path, cursor.to_string())?;
Ok(())
}

fn cursor_path(session_id: &str) -> Result<PathBuf> {
Ok(sessions_dir()?.join(format!("{}.cursor", session_id)))
}

pub fn is_active(session_id: &str) -> bool {
sessions_dir()
.map(|d| d.join(session_id).exists())
Expand All @@ -38,12 +104,20 @@ pub fn activate(session_id: &str) -> Result<()> {
}

pub fn deactivate(session_id: &str) -> Result<()> {
let path = sessions_dir()?.join(session_id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
let dir = sessions_dir()?;
let paths = [
dir.join(session_id),
dir.join(format!("{}.cursor", session_id)),
dir.join(format!("{}.cursor.lock", session_id)),
];
for path in &paths {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Comment on lines +113 to +116

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deactivate().cursor.lock(ロック用ファイル)まで削除していますが、別プロセス/別フックがロックファイルを開いたままのタイミングだと、特にWindowsでは開いているファイルを削除できずPermissionDenied等でsession-endが失敗する可能性があります。ロックファイルは削除対象から外すか、削除失敗時は警告に留めて処理を継続するなど、プラットフォーム差分と並行実行を考慮した挙動にしてください。

Suggested change
for path in &paths {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
for path in &paths {
let is_lock_file = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.ends_with(".cursor.lock"))
.unwrap_or(false);
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) if is_lock_file && e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"warning: failed to remove lock file {:?}: {}",
path,
e
);
}

Copilot uses AI. Check for mistakes.
Err(e) => return Err(e.into()),
}
}
Ok(())
}

fn config_file_path() -> Result<PathBuf> {
Expand Down Expand Up @@ -85,14 +159,12 @@ mod tests {

#[test]
fn test_config_default_when_no_file() {
// デフォルト設定が返ることを確認(ファイルが存在しない場合)
let config = Config::default();
assert!(config.webhook.url.is_none());
}

#[test]
fn test_config_parse_webhook_url() {
// TOMLパースのテスト
let toml_str = r#"
[webhook]
url = "https://hooks.slack.com/services/test"
Expand All @@ -113,7 +185,6 @@ url = "https://hooks.slack.com/services/test"

#[test]
fn test_active_flag_lifecycle() {
// セッションIDごとのフラグ作成・確認・削除をテスト
with_temp_state_dir(|| {
let session_id = "test-session-lifecycle";
let _ = deactivate(session_id);
Expand All @@ -129,7 +200,6 @@ url = "https://hooks.slack.com/services/test"

#[test]
fn test_deactivate_idempotent() {
// フラグが存在しなくてもdeactivateはエラーにならない
with_temp_state_dir(|| {
let _ = deactivate("nonexistent-session");
let result = deactivate("nonexistent-session");
Expand All @@ -139,7 +209,6 @@ url = "https://hooks.slack.com/services/test"

#[test]
fn test_multiple_sessions_concurrent() {
// 複数セッションが同時にONにできることを確認
with_temp_state_dir(|| {
activate("session-a").expect("session-a activate失敗");
activate("session-b").expect("session-b activate失敗");
Expand All @@ -149,4 +218,70 @@ url = "https://hooks.slack.com/services/test"
assert!(!is_active("session-c"), "session-cがアクティブになっている");
});
}

#[test]
fn test_cursor_read_write() {
with_temp_state_dir(|| {
let session_id = "cursor-test-session";
// セッションを有効化してディレクトリを作成
activate(session_id).expect("activate失敗");
write_cursor_inner(session_id, 12345).expect("cursor書き込み失敗");
let cursor = read_cursor_inner(session_id);
assert_eq!(cursor, Some(12345));
});
}

#[test]
fn test_cursor_default_none() {
with_temp_state_dir(|| {
// カーソルファイルがない場合は None(初回有効化を検出)
activate("no-cursor-session").expect("activate失敗");
let cursor = read_cursor_inner("no-cursor-session");
assert!(cursor.is_none());
});
}

#[test]
fn test_cursor_deleted_on_deactivate() {
with_temp_state_dir(|| {
let session_id = "deactivate-cursor-session";
activate(session_id).expect("activate失敗");
write_cursor_inner(session_id, 999).expect("cursor書き込み失敗");

let dir = sessions_dir().unwrap();
assert!(dir.join(format!("{}.cursor", session_id)).exists());

deactivate(session_id).expect("deactivate失敗");
assert!(
!dir.join(session_id).exists(),
"セッションファイルが残っている"
);
assert!(
!dir.join(format!("{}.cursor", session_id)).exists(),
"cursorファイルが残っている"
);
});
}

#[test]
fn test_cursor_lock_acquire_commit() {
with_temp_state_dir(|| {
let session_id = "lock-test-session";
activate(session_id).expect("activate失敗");

// 初回: cursor=None(カーソルファイルなし = 初回有効化)
let (guard, cursor) = CursorLockGuard::acquire(session_id).expect("acquire失敗");
assert!(cursor.is_none());
guard.commit(500).expect("commit失敗");

// 2回目: cursor=Some(500)
let (guard2, cursor2) = CursorLockGuard::acquire(session_id).expect("acquire失敗");
assert_eq!(cursor2, Some(500));
guard2.commit(1000).expect("commit失敗");

// 3回目: cursor=Some(1000)
let (_, cursor3) = CursorLockGuard::acquire(session_id).expect("acquire失敗");
assert_eq!(cursor3, Some(1000));
});
}
}
69 changes: 69 additions & 0 deletions src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ pub fn format_assistant_message(message: &str, ctx: &SessionContext) -> Value {
})
}

pub fn format_subagent_message(agent_type: &str, message: &str, ctx: &SessionContext) -> Value {
let text = truncate(message, MAX_BLOCK_TEXT_LEN);
json!({
"username": ctx.username(),
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(":gear: *Agent ({})*\n{}", agent_type, text)
}
}
]
})
}

pub fn format_notification_message(message: &str, ctx: &SessionContext) -> Value {
let text = truncate(message, MAX_BLOCK_TEXT_LEN);
json!({
"username": ctx.username(),
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(":speech_balloon: *Claude → User*\n{}", text)
}
}
]
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -138,6 +170,43 @@ mod tests {
assert!(text.len() <= MAX_BLOCK_TEXT_LEN + 50);
}

#[test]
fn test_format_subagent_message_structure() {
let ctx = test_ctx();
let payload = format_subagent_message("Explore", "Found relevant files.", &ctx);

let blocks = payload["blocks"].as_array().unwrap();
let text = &blocks[0]["text"]["text"];
assert!(text.as_str().unwrap().contains(":gear:"));
assert!(text.as_str().unwrap().contains("Agent (Explore)"));
assert!(text.as_str().unwrap().contains("Found relevant files."));
}

#[test]
fn test_format_subagent_message_truncation() {
let ctx = test_ctx();
let long_text = "x".repeat(4000);
let payload = format_subagent_message("Agent", &long_text, &ctx);

let text = payload["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(text.len() <= MAX_BLOCK_TEXT_LEN + 50);
}

#[test]
fn test_format_notification_message_structure() {
let ctx = test_ctx();
let payload = format_notification_message("Which approach do you prefer?", &ctx);

let blocks = payload["blocks"].as_array().unwrap();
let text = &blocks[0]["text"]["text"];
assert!(text.as_str().unwrap().contains(":speech_balloon:"));
assert!(text.as_str().unwrap().contains("Claude → User"));
assert!(text
.as_str()
.unwrap()
.contains("Which approach do you prefer?"));
}

#[test]
fn test_slack_emoji_codes_not_unicode() {
let ctx = test_ctx();
Expand Down
Loading