Skip to content

Commit 7feb1d9

Browse files
solzipclaude
andcommitted
feat: Sprint C-1 — 팀 보안 강화
lib/team_security.py: - 경로 마스킹: glob 패턴으로 민감 경로 자동 [MASKED] - 콘텐츠 필터: 키워드 기반 redact/skip 모드 - 세션 opt-out: CLAUDE_DIARY_SKIP 환경변수 + skip_projects - 접근 제어: member/lead/admin 3단계 역할 + 권한 필터링 core.py: - opt-out 체크 (파이프라인 최초) - 경로 마스킹 + 콘텐츠 필터 (시크릿 스캔 후 적용) cli.py: - delete --last / --session 명령어 (세션 삭제) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c42a0ff commit 7feb1d9

File tree

3 files changed

+308
-0
lines changed

3 files changed

+308
-0
lines changed

src/claude_diary/cli.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ def main():
7474
p_audit.add_argument("--verify", action="store_true", help="Verify source code checksum")
7575
p_audit.add_argument("-n", type=int, default=10, help="Number of entries (default: 10)")
7676

77+
# delete
78+
p_delete = sub.add_parser("delete", help="Delete a diary session entry")
79+
p_delete.add_argument("--last", action="store_true", help="Delete the last session entry")
80+
p_delete.add_argument("--session", help="Delete by session ID prefix")
81+
7782
# dashboard
7883
p_dashboard = sub.add_parser("dashboard", help="Generate HTML dashboard")
7984
p_dashboard.add_argument("--serve", action="store_true", help="Start local server")
@@ -97,6 +102,7 @@ def main():
97102
"migrate": cmd_migrate,
98103
"reindex": cmd_reindex,
99104
"audit": cmd_audit,
105+
"delete": cmd_delete,
100106
"dashboard": cmd_dashboard,
101107
}
102108

@@ -645,6 +651,73 @@ def cmd_audit(args):
645651
print(line)
646652

647653

654+
# ── Delete ──
655+
656+
def cmd_delete(args):
657+
config = load_config()
658+
diary_dir = os.path.expanduser(config["diary_dir"])
659+
tz_offset = config.get("timezone_offset", 9)
660+
local_tz = timezone(timedelta(hours=tz_offset))
661+
662+
if args.last:
663+
# Find today's file and remove last entry
664+
today = datetime.now(local_tz).strftime("%Y-%m-%d")
665+
filepath = os.path.join(diary_dir, "%s.md" % today)
666+
if not os.path.exists(filepath):
667+
print("No diary file for today (%s)" % today)
668+
return
669+
670+
with open(filepath, "r", encoding="utf-8") as f:
671+
content = f.read()
672+
673+
# Split by session markers and remove last
674+
parts = content.split("### ⏰")
675+
if len(parts) <= 1:
676+
print("No session entries found in today's diary.")
677+
return
678+
679+
# Remove last entry (everything after last "### ⏰")
680+
new_content = "### ⏰".join(parts[:-1])
681+
# Remove trailing "---\n\n" if present
682+
new_content = new_content.rstrip()
683+
if new_content.endswith("---"):
684+
new_content = new_content[:-3].rstrip()
685+
new_content += "\n\n"
686+
687+
with open(filepath, "w", encoding="utf-8") as f:
688+
f.write(new_content)
689+
690+
print("Last session entry deleted from %s" % filepath)
691+
return
692+
693+
if args.session:
694+
# Search all files for session ID and remove that entry
695+
found = False
696+
for f in sorted(Path(diary_dir).glob("*.md")):
697+
try:
698+
content = f.read_text(encoding="utf-8")
699+
except Exception:
700+
continue
701+
if args.session in content:
702+
parts = content.split("### ⏰")
703+
new_parts = [parts[0]]
704+
for part in parts[1:]:
705+
if args.session not in part:
706+
new_parts.append(part)
707+
else:
708+
found = True
709+
new_content = "### ⏰".join(new_parts).rstrip() + "\n"
710+
f.write_text(new_content, encoding="utf-8")
711+
print("Session %s deleted from %s" % (args.session, f.name))
712+
break
713+
714+
if not found:
715+
print("Session '%s' not found." % args.session)
716+
return
717+
718+
print("Specify --last or --session <id>")
719+
720+
648721
# ── Dashboard ──
649722

650723
def cmd_dashboard(args):

src/claude_diary/core.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def process_session(session_id, transcript_path, cwd):
3232
diary_dir = os.path.expanduser(config.get("diary_dir", "~/working-diary"))
3333
enrichment = config.get("enrichment", {})
3434

35+
# 0. Session opt-out check
36+
from claude_diary.lib.team_security import should_skip_session
37+
if should_skip_session(cwd, config):
38+
sys.stderr.write("[diary] Session skipped (opt-out)\n")
39+
return False
40+
3541
local_tz = timezone(timedelta(hours=tz_offset))
3642
now = datetime.now(local_tz)
3743
date_str = now.strftime("%Y-%m-%d")
@@ -101,6 +107,25 @@ def process_session(session_id, transcript_path, cwd):
101107
except Exception:
102108
pass
103109

110+
# 5.5 Team security filters (path masking + content filter)
111+
try:
112+
from claude_diary.lib.team_security import mask_paths, filter_entry_data
113+
security = config.get("security", {})
114+
mask_patterns = security.get("mask_paths", [])
115+
if mask_patterns:
116+
entry_data["files_created"] = mask_paths(entry_data["files_created"], mask_patterns)
117+
entry_data["files_modified"] = mask_paths(entry_data["files_modified"], mask_patterns)
118+
119+
content_filters = security.get("content_filters", [])
120+
filter_mode = security.get("filter_mode", "redact")
121+
if content_filters:
122+
should_record = filter_entry_data(entry_data, content_filters, filter_mode)
123+
if not should_record:
124+
sys.stderr.write("[diary] Session skipped (content filter)\n")
125+
return False
126+
except Exception:
127+
pass
128+
104129
# 6. Format and write (CRITICAL — exit 1 on failure)
105130
try:
106131
entry_text = format_entry(entry_data, lang)
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Team security module — path masking, content filtering, access control, opt-out."""
2+
3+
import fnmatch
4+
import os
5+
import re
6+
import sys
7+
8+
9+
# ── Path Masking ──
10+
11+
def mask_paths(file_list, mask_patterns):
12+
"""Mask sensitive file paths using glob patterns.
13+
14+
Args:
15+
file_list: List of file path strings
16+
mask_patterns: List of glob patterns (e.g., "**/credentials/**", "**/.env*")
17+
18+
Returns:
19+
New list with matched paths replaced by "[MASKED]"
20+
"""
21+
if not mask_patterns:
22+
return file_list
23+
24+
masked = []
25+
for fp in file_list:
26+
normalized = fp.replace("\\", "/")
27+
if _path_matches(normalized, mask_patterns):
28+
masked.append("[MASKED]")
29+
else:
30+
masked.append(fp)
31+
return masked
32+
33+
34+
def _path_matches(path, patterns):
35+
"""Check if path matches any glob pattern."""
36+
for pattern in patterns:
37+
# Normalize pattern
38+
p = pattern.replace("\\", "/")
39+
# Direct fnmatch
40+
if fnmatch.fnmatch(path, p):
41+
return True
42+
# Check if any path component matches a keyword pattern
43+
parts = path.lower().split("/")
44+
p_parts = p.lower().replace("**/", "").replace("/**", "").split("/")
45+
for keyword in p_parts:
46+
if not keyword or keyword == "*":
47+
continue
48+
for part in parts:
49+
if fnmatch.fnmatch(part, keyword):
50+
return True
51+
return False
52+
53+
54+
# ── Content Filtering ──
55+
56+
def filter_content(text, filter_keywords, mode="redact"):
57+
"""Filter sensitive content from text.
58+
59+
Args:
60+
text: Text to filter
61+
filter_keywords: List of keywords to detect
62+
mode: "redact" = replace sentence with [REDACTED], "skip" = return None
63+
64+
Returns:
65+
Filtered text, or None if mode=skip and keyword found
66+
"""
67+
if not text or not filter_keywords:
68+
return text
69+
70+
text_lower = text.lower()
71+
for kw in filter_keywords:
72+
if kw.lower() in text_lower:
73+
if mode == "skip":
74+
return None
75+
elif mode == "redact":
76+
# Replace the sentence containing the keyword
77+
sentences = re.split(r'([.!?\n])', text)
78+
result = []
79+
for i in range(0, len(sentences), 2):
80+
sent = sentences[i] if i < len(sentences) else ""
81+
sep = sentences[i + 1] if i + 1 < len(sentences) else ""
82+
if kw.lower() in sent.lower():
83+
result.append("[REDACTED]" + sep)
84+
else:
85+
result.append(sent + sep)
86+
return "".join(result)
87+
88+
return text
89+
90+
91+
def filter_entry_data(entry_data, filter_keywords, mode="redact"):
92+
"""Apply content filtering to all text fields of entry_data.
93+
94+
If mode=skip and keyword found, returns False (skip entire session).
95+
Otherwise modifies entry_data in-place and returns True.
96+
"""
97+
if not filter_keywords:
98+
return True
99+
100+
# Check if session should be skipped entirely
101+
if mode == "skip":
102+
all_text = " ".join(
103+
entry_data.get("user_prompts", []) +
104+
entry_data.get("summary_hints", []) +
105+
entry_data.get("commands_run", [])
106+
)
107+
for kw in filter_keywords:
108+
if kw.lower() in all_text.lower():
109+
return False
110+
111+
# Redact mode
112+
for field in ("user_prompts", "summary_hints", "commands_run"):
113+
items = entry_data.get(field, [])
114+
filtered = []
115+
for item in items:
116+
result = filter_content(item, filter_keywords, mode)
117+
if result is not None:
118+
filtered.append(result)
119+
entry_data[field] = filtered
120+
121+
return True
122+
123+
124+
# ── Session Opt-out ──
125+
126+
def should_skip_session(cwd, config):
127+
"""Check if the current session should be skipped.
128+
129+
Checks:
130+
1. CLAUDE_DIARY_SKIP=1 environment variable
131+
2. Project name in config.skip_projects list
132+
133+
Returns:
134+
True if session should be skipped
135+
"""
136+
# Env var check
137+
if os.environ.get("CLAUDE_DIARY_SKIP", "").strip() in ("1", "true", "yes"):
138+
return True
139+
140+
# Project skip list
141+
skip_projects = config.get("skip_projects", [])
142+
if skip_projects and cwd:
143+
project = os.path.basename(cwd.replace("\\", "/").rstrip("/"))
144+
if project in skip_projects:
145+
return True
146+
147+
return False
148+
149+
150+
# ── Access Control ──
151+
152+
ROLE_PERMISSIONS = {
153+
"member": {"own_diary": "full", "others_diary": "summary", "others_detail": False, "team_stats": True},
154+
"lead": {"own_diary": "full", "others_diary": "full", "others_detail": "same_project", "team_stats": True},
155+
"admin": {"own_diary": "full", "others_diary": "full", "others_detail": True, "team_stats": True},
156+
}
157+
158+
159+
def check_access(viewer_role, viewer_name, target_name, target_project=None, viewer_projects=None):
160+
"""Check if viewer has access to target's diary.
161+
162+
Args:
163+
viewer_role: "member", "lead", or "admin"
164+
viewer_name: Name of the person viewing
165+
target_name: Name of the diary owner
166+
target_project: Project of the diary entry
167+
viewer_projects: Projects the viewer is involved in
168+
169+
Returns:
170+
"full", "summary", or "none"
171+
"""
172+
if viewer_name == target_name:
173+
return "full"
174+
175+
perms = ROLE_PERMISSIONS.get(viewer_role, ROLE_PERMISSIONS["member"])
176+
177+
if viewer_role == "admin":
178+
return "full"
179+
180+
if viewer_role == "lead":
181+
if target_project and viewer_projects and target_project in viewer_projects:
182+
return "full"
183+
return perms["others_diary"]
184+
185+
# member
186+
return "summary"
187+
188+
189+
def apply_access_filter(entry_data, access_level):
190+
"""Filter entry_data based on access level.
191+
192+
"full": no filtering
193+
"summary": only project, categories, code_stats (no prompts, files, commands)
194+
"none": empty dict
195+
"""
196+
if access_level == "full":
197+
return entry_data
198+
199+
if access_level == "none":
200+
return {}
201+
202+
# summary mode
203+
return {
204+
"date": entry_data.get("date", ""),
205+
"time": entry_data.get("time", ""),
206+
"project": entry_data.get("project", ""),
207+
"categories": entry_data.get("categories", []),
208+
"code_stats": entry_data.get("code_stats"),
209+
"git_info": {"branch": entry_data.get("git_info", {}).get("branch", "")} if entry_data.get("git_info") else None,
210+
}

0 commit comments

Comments
 (0)