claude-controller 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
@@ -0,0 +1,31 @@
1
+ 당신은 코드베이스 분석 전문 에이전트입니다. 코드를 읽기만 하고, 수정하지 않습니다.
2
+
3
+ ## 분석 관점
4
+
5
+ 1. **구조 파악**: 디렉토리 구조, 모듈 간 의존성, 진입점
6
+ 2. **영향 범위**: 특정 변경이 어떤 파일/함수에 영향을 미치는지
7
+ 3. **기술 부채**: 중복 코드, 미사용 코드, 과도한 복잡도
8
+ 4. **패턴 식별**: 프로젝트에서 사용하는 디자인 패턴, 코딩 관례
9
+
10
+ ## 출력 형식
11
+
12
+ ```json
13
+ {
14
+ "summary": "분석 요약 (3문장 이내)",
15
+ "findings": [
16
+ {
17
+ "category": "structure|dependency|tech_debt|pattern|risk",
18
+ "description": "발견 내용",
19
+ "files": ["관련 파일"],
20
+ "impact": "high|medium|low"
21
+ }
22
+ ],
23
+ "recommendations": ["구체적 권고사항"]
24
+ }
25
+ ```
26
+
27
+ ## 원칙
28
+
29
+ - 추측하지 않는다 — 코드에서 확인된 사실만 보고한다
30
+ - 파일 경로와 줄 번호를 포함한다
31
+ - 코드를 수정하지 않는다 (읽기 전용)
@@ -0,0 +1,22 @@
1
+ 당신은 소프트웨어 개발 전문 에이전트입니다.
2
+
3
+ ## 원칙
4
+
5
+ 1. **최소 변경**: 요청된 부분만 정확히 수정. 관련 없는 코드를 건드리지 않는다.
6
+ 2. **테스트 포함**: 코드 변경 시 해당 변경을 검증하는 테스트를 함께 작성한다.
7
+ 3. **기존 관례 준수**: 프로젝트의 코딩 스타일, 네이밍, 구조를 따른다.
8
+ 4. **안전한 코드**: OWASP Top 10 취약점을 도입하지 않는다.
9
+
10
+ ## 작업 방식
11
+
12
+ 1. 먼저 관련 파일을 읽고 구조를 파악한다
13
+ 2. 영향 범위를 확인한다 (이 함수를 누가 호출하는지, 타입이 바뀌면 어디가 깨지는지)
14
+ 3. 변경을 수행한다
15
+ 4. 변경이 올바른지 테스트로 확인한다
16
+
17
+ ## 출력
18
+
19
+ 작업 완료 후 변경 요약을 간결하게 작성한다:
20
+ - 변경한 파일 목록
21
+ - 각 변경의 이유 (한 줄)
22
+ - 주의사항 (있을 경우)
@@ -0,0 +1,33 @@
1
+ 당신은 시니어 코드 리뷰어입니다.
2
+
3
+ ## 리뷰 관점
4
+
5
+ 1. **정확성**: 로직이 의도대로 동작하는가?
6
+ 2. **보안**: 인젝션, XSS, 인증 우회 등 취약점은 없는가?
7
+ 3. **성능**: 불필요한 연산, N+1 쿼리, 메모리 누수는 없는가?
8
+ 4. **가독성**: 코드가 자명한가? 불필요한 복잡도는 없는가?
9
+ 5. **테스트**: 핵심 경로와 엣지 케이스가 테스트되었는가?
10
+
11
+ ## 출력 형식
12
+
13
+ ```json
14
+ {
15
+ "verdict": "approve" | "request_changes",
16
+ "issues": [
17
+ {
18
+ "severity": "critical" | "major" | "minor" | "nit",
19
+ "file": "파일 경로",
20
+ "line": 42,
21
+ "description": "문제 설명",
22
+ "suggestion": "구체적 개선 제안"
23
+ }
24
+ ],
25
+ "summary": "전반적 평가 (2-3문장)"
26
+ }
27
+ ```
28
+
29
+ ## 원칙
30
+
31
+ - critical/major 이슈가 하나라도 있으면 반드시 request_changes
32
+ - 구체적인 코드 제안을 포함한다 (추상적 조언 금지)
33
+ - 잘된 부분도 언급한다
@@ -0,0 +1,21 @@
1
+ 당신은 QA/테스트 전문 에이전트입니다.
2
+
3
+ ## 원칙
4
+
5
+ 1. **엣지 케이스 우선**: 정상 경로보다 경계값, 빈 입력, 동시성 문제를 집중 테스트
6
+ 2. **독립성**: 각 테스트는 다른 테스트에 의존하지 않아야 한다
7
+ 3. **빠른 실행**: 불필요한 I/O, sleep, 네트워크 호출을 피한다
8
+ 4. **명확한 실패 메시지**: 테스트 실패 시 원인을 바로 알 수 있는 assert 메시지
9
+
10
+ ## 작업 방식
11
+
12
+ 1. 대상 코드를 읽고 입/출력, 사이드이펙트를 파악
13
+ 2. 테스트 케이스 목록을 설계 (정상, 경계, 에러, 동시성)
14
+ 3. 테스트를 작성하고 실행
15
+ 4. 실패하는 테스트가 있으면 원인을 분석하여 보고
16
+
17
+ ## 출력
18
+
19
+ - 작성한 테스트 파일 경로
20
+ - 테스트 실행 결과 요약
21
+ - 커버리지 정보 (측정 가능한 경우)
@@ -0,0 +1,25 @@
1
+ 당신은 기술 문서 작성 전문 에이전트입니다.
2
+
3
+ ## 원칙
4
+
5
+ 1. **코드 기반 정확성**: 코드를 직접 읽고 동작을 확인한 내용만 문서화
6
+ 2. **실행 가능한 예제**: 모든 코드 예제는 실제로 동작해야 한다
7
+ 3. **대상 독자 고려**: 문서의 대상 독자 수준에 맞춰 작성
8
+ 4. **최신성**: 현재 코드 상태를 반영하여 작성
9
+
10
+ ## 문서 유형별 구조
11
+
12
+ ### API 문서
13
+ - 엔드포인트, 메서드, 요청/응답 스키마, 에러 코드, 예제
14
+
15
+ ### 가이드
16
+ - 개요, 사전 요구사항, 단계별 설명, 문제 해결(FAQ)
17
+
18
+ ### 아키텍처 문서
19
+ - 전체 구조, 컴포넌트 역할, 데이터 흐름, 설계 결정과 이유
20
+
21
+ ## 원칙
22
+
23
+ - 존재하지 않는 기능이나 파라미터를 만들어내지 않는다
24
+ - 코드를 먼저 읽고 문서를 작성한다
25
+ - 마크다운 형식을 사용한다
package/config.sh CHANGED
@@ -33,6 +33,9 @@ PID_FILE="${CONTROLLER_DIR}/service/controller.pid"
33
33
  # 최대 동시 백그라운드 작업 수
34
34
  MAX_BACKGROUND_JOBS="${MAX_BACKGROUND_JOBS:-10}"
35
35
 
36
+ # 완료/실패 작업 파일 보존 기간 (일) — 이 기간이 지난 job_*.out/.meta 자동 삭제
37
+ LOG_RETENTION_DAYS="${LOG_RETENTION_DAYS:-30}"
38
+
36
39
  # 시스템 프롬프트 추가
37
40
  APPEND_SYSTEM_PROMPT="${APPEND_SYSTEM_PROMPT:-}"
38
41
 
@@ -51,7 +54,8 @@ WORKTREES_DIR="${CONTROLLER_DIR}/worktrees"
51
54
 
52
55
  # ── 권한 설정 ──────────────────────────────────────────────
53
56
  # true로 설정 시 --dangerously-skip-permissions 사용 (모든 도구 무제한 허용)
54
- SKIP_PERMISSIONS="${SKIP_PERMISSIONS:-true}"
57
+ # 보안상 기본값은 false — 필요 시 환경변수 또는 settings.json에서 명시적으로 활성화
58
+ SKIP_PERMISSIONS="${SKIP_PERMISSIONS:-false}"
55
59
 
56
60
  # ── Checkpoint 설정 ────────────────────────────────────────
57
61
  # 체크포인트 감시 주기 (초) — 이 간격으로 worktree 변경을 확인
@@ -70,5 +74,6 @@ if [[ -f "$SETTINGS_FILE" ]] && command -v jq &>/dev/null; then
70
74
  _v=$(_s '.target_repo'); [[ -n "$_v" ]] && TARGET_REPO="$_v"
71
75
  _v=$(_s '.base_branch'); [[ -n "$_v" ]] && BASE_BRANCH="$_v"
72
76
  _v=$(_s '.checkpoint_interval'); [[ -n "$_v" ]] && CHECKPOINT_INTERVAL="$_v"
77
+ _v=$(_s '.log_retention_days'); [[ -n "$_v" ]] && LOG_RETENTION_DAYS="$_v"
73
78
  unset -f _s; unset _v
74
79
  fi
@@ -0,0 +1,5 @@
1
+ """DAG — 태스크 의존성 그래프 엔진."""
2
+
3
+ from dag.graph import TaskDAG, TaskNode
4
+
5
+ __all__ = ["TaskDAG", "TaskNode"]
package/dag/graph.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ DAG (Directed Acyclic Graph) — 태스크 의존성 그래프
3
+ Planner가 생성한 태스크들의 선행/후행 관계를 관리하고,
4
+ 토폴로지 정렬 기반으로 실행 순서를 결정한다.
5
+ """
6
+
7
+ from collections import defaultdict, deque
8
+ from typing import Optional
9
+
10
+
11
+ class TaskNode:
12
+ """DAG 내 개별 태스크 노드."""
13
+
14
+ __slots__ = (
15
+ "id", "name", "worker_type", "prompt", "depends_on",
16
+ "status", "cost_usd", "duration_ms", "result", "retries",
17
+ )
18
+
19
+ def __init__(
20
+ self,
21
+ task_id: str,
22
+ name: str,
23
+ worker_type: str,
24
+ prompt: str,
25
+ depends_on: Optional[list[str]] = None,
26
+ ):
27
+ self.id = task_id
28
+ self.name = name
29
+ self.worker_type = worker_type # coder, reviewer, tester, analyst, writer
30
+ self.prompt = prompt
31
+ self.depends_on = depends_on or []
32
+ self.status = "pending" # pending, running, completed, failed
33
+ self.cost_usd = 0.0
34
+ self.duration_ms = 0
35
+ self.result = None
36
+ self.retries = 0
37
+
38
+ def to_dict(self) -> dict:
39
+ return {
40
+ "id": self.id,
41
+ "name": self.name,
42
+ "worker_type": self.worker_type,
43
+ "prompt": self.prompt,
44
+ "depends_on": self.depends_on,
45
+ "status": self.status,
46
+ "cost_usd": self.cost_usd,
47
+ "duration_ms": self.duration_ms,
48
+ "retries": self.retries,
49
+ }
50
+
51
+ @classmethod
52
+ def from_dict(cls, d: dict) -> "TaskNode":
53
+ node = cls(
54
+ task_id=d["id"],
55
+ name=d["name"],
56
+ worker_type=d["worker_type"],
57
+ prompt=d["prompt"],
58
+ depends_on=d.get("depends_on", []),
59
+ )
60
+ node.status = d.get("status", "pending")
61
+ node.cost_usd = d.get("cost_usd", 0.0)
62
+ node.duration_ms = d.get("duration_ms", 0)
63
+ node.retries = d.get("retries", 0)
64
+ return node
65
+
66
+
67
+ class TaskDAG:
68
+ """태스크 방향성 비순환 그래프.
69
+
70
+ 핵심 기능:
71
+ - 토폴로지 정렬: 실행 순서 결정
72
+ - 실행 가능 태스크: 의존성이 모두 완료된 태스크 반환
73
+ - 병렬 그룹: 동시 실행 가능한 태스크 집합 반환
74
+ - 순환 감지: DAG 유효성 검증
75
+ """
76
+
77
+ def __init__(self):
78
+ self.nodes: dict[str, TaskNode] = {}
79
+ self._adj: dict[str, list[str]] = defaultdict(list) # 정방향 간선
80
+ self._rev: dict[str, list[str]] = defaultdict(list) # 역방향 간선
81
+
82
+ def add_task(self, node: TaskNode):
83
+ """태스크를 DAG에 추가한다."""
84
+ self.nodes[node.id] = node
85
+ for dep in node.depends_on:
86
+ self._adj[dep].append(node.id)
87
+ self._rev[node.id].append(dep)
88
+
89
+ def validate(self) -> tuple[bool, str]:
90
+ """DAG의 유효성을 검증한다 (순환 감지, 누락 의존성)."""
91
+ # 누락된 의존성 확인
92
+ for node in self.nodes.values():
93
+ for dep in node.depends_on:
94
+ if dep not in self.nodes:
95
+ return False, f"Task '{node.id}' depends on unknown task '{dep}'"
96
+
97
+ # 순환 감지 (Kahn's algorithm)
98
+ in_degree = {nid: 0 for nid in self.nodes}
99
+ for nid, node in self.nodes.items():
100
+ for dep in node.depends_on:
101
+ in_degree[nid] += 1 # 아닌, 이미 위에서 계산
102
+
103
+ # 재계산
104
+ in_degree = {nid: len(self._rev.get(nid, [])) for nid in self.nodes}
105
+ queue = deque(nid for nid, deg in in_degree.items() if deg == 0)
106
+ visited = 0
107
+
108
+ while queue:
109
+ nid = queue.popleft()
110
+ visited += 1
111
+ for child in self._adj.get(nid, []):
112
+ in_degree[child] -= 1
113
+ if in_degree[child] == 0:
114
+ queue.append(child)
115
+
116
+ if visited != len(self.nodes):
117
+ return False, "Cycle detected in task DAG"
118
+ return True, "OK"
119
+
120
+ def topological_sort(self) -> list[str]:
121
+ """토폴로지 정렬 순서를 반환한다."""
122
+ in_degree = {nid: len(self._rev.get(nid, [])) for nid in self.nodes}
123
+ queue = deque(
124
+ sorted(nid for nid, deg in in_degree.items() if deg == 0)
125
+ )
126
+ order = []
127
+
128
+ while queue:
129
+ nid = queue.popleft()
130
+ order.append(nid)
131
+ for child in sorted(self._adj.get(nid, [])):
132
+ in_degree[child] -= 1
133
+ if in_degree[child] == 0:
134
+ queue.append(child)
135
+
136
+ return order
137
+
138
+ def get_ready_tasks(self) -> list[TaskNode]:
139
+ """현재 실행 가능한 태스크들을 반환한다 (의존성 충족 + pending 상태)."""
140
+ ready = []
141
+ for node in self.nodes.values():
142
+ if node.status != "pending":
143
+ continue
144
+ deps_met = all(
145
+ self.nodes[d].status == "completed"
146
+ for d in node.depends_on
147
+ if d in self.nodes
148
+ )
149
+ if deps_met:
150
+ ready.append(node)
151
+ return ready
152
+
153
+ def get_parallel_groups(self) -> list[list[str]]:
154
+ """병렬 실행 가능한 태스크 그룹을 계층별로 반환한다.
155
+
156
+ Returns:
157
+ [[t1, t2], [t3, t4, t5], [t6]] — 같은 리스트 내 태스크는 동시 실행 가능
158
+ """
159
+ in_degree = {nid: len(self._rev.get(nid, [])) for nid in self.nodes}
160
+ groups = []
161
+ remaining = set(self.nodes.keys())
162
+
163
+ while remaining:
164
+ # in-degree가 0인 노드 = 현재 레벨에서 실행 가능
165
+ level = [nid for nid in remaining if in_degree.get(nid, 0) == 0]
166
+ if not level:
167
+ break # 순환이 있으면 중단 (validate에서 이미 검사)
168
+ groups.append(sorted(level))
169
+ for nid in level:
170
+ remaining.discard(nid)
171
+ for child in self._adj.get(nid, []):
172
+ in_degree[child] -= 1
173
+
174
+ return groups
175
+
176
+ def is_complete(self) -> bool:
177
+ """모든 태스크가 완료되었는지 확인한다."""
178
+ return all(n.status == "completed" for n in self.nodes.values())
179
+
180
+ def has_failures(self) -> bool:
181
+ """실패한 태스크가 있는지 확인한다."""
182
+ return any(n.status == "failed" for n in self.nodes.values())
183
+
184
+ def to_dict(self) -> dict:
185
+ """DAG를 직렬화한다."""
186
+ return {
187
+ "tasks": [node.to_dict() for node in self.nodes.values()],
188
+ "parallel_groups": self.get_parallel_groups(),
189
+ }
190
+
191
+ @classmethod
192
+ def from_dict(cls, data: dict) -> "TaskDAG":
193
+ """직렬화된 DAG를 복원한다."""
194
+ dag = cls()
195
+ for td in data.get("tasks", []):
196
+ dag.add_task(TaskNode.from_dict(td))
197
+ return dag
198
+
199
+ def to_mermaid(self) -> str:
200
+ """DAG를 Mermaid 다이어그램 문법으로 변환한다 (UI 시각화용)."""
201
+ lines = ["graph TD"]
202
+ status_style = {
203
+ "pending": ":::pending",
204
+ "running": ":::running",
205
+ "completed": ":::completed",
206
+ "failed": ":::failed",
207
+ }
208
+ for node in self.nodes.values():
209
+ label = f'{node.id}["{node.name}<br/>{node.worker_type}"]'
210
+ style = status_style.get(node.status, "")
211
+ lines.append(f" {label}{style}")
212
+ for node in self.nodes.values():
213
+ for dep in node.depends_on:
214
+ lines.append(f" {dep} --> {node.id}")
215
+ # 스타일 정의
216
+ lines.extend([
217
+ " classDef pending fill:#e2e8f0,stroke:#94a3b8",
218
+ " classDef running fill:#bfdbfe,stroke:#3b82f6",
219
+ " classDef completed fill:#bbf7d0,stroke:#22c55e",
220
+ " classDef failed fill:#fecaca,stroke:#ef4444",
221
+ ])
222
+ return "\n".join(lines)
package/lib/jobs.sh CHANGED
@@ -66,7 +66,7 @@ job_register() {
66
66
  local meta_file="${LOGS_DIR}/job_${job_id}.meta"
67
67
  # 프롬프트 내의 특수문자를 이스케이프하여 안전하게 저장
68
68
  local safe_prompt
69
- safe_prompt=$(printf '%s' "$prompt" | head -c 500 | sed "s/'/'\\\\''/g")
69
+ safe_prompt=$(printf '%s' "$prompt" | head -c 500 | tr -d '\000-\037\177' | sed "s/'/'\\\\''/g")
70
70
  cat > "$meta_file" <<EOF
71
71
  JOB_ID=${job_id}
72
72
  STATUS=running
@@ -105,12 +105,23 @@ job_mark_done() {
105
105
  local job_id="$1"
106
106
  local meta_file="${LOGS_DIR}/job_${job_id}.meta"
107
107
  _meta_set_field "$meta_file" "STATUS" "done"
108
+ _fire_webhook "$job_id" "done"
108
109
  }
109
110
 
110
111
  job_mark_failed() {
111
112
  local job_id="$1"
112
113
  local meta_file="${LOGS_DIR}/job_${job_id}.meta"
113
114
  _meta_set_field "$meta_file" "STATUS" "failed"
115
+ _fire_webhook "$job_id" "failed"
116
+ }
117
+
118
+ # ── 웹훅 전달 (백그라운드) ─────────────────────────────────
119
+ _fire_webhook() {
120
+ local job_id="$1" status="$2"
121
+ local webhook_script="${CONTROLLER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/web/webhook.py"
122
+ [[ -f "$webhook_script" ]] || return 0
123
+ # 백그라운드에서 실행 — 작업 완료 흐름을 차단하지 않는다
124
+ python3 "$webhook_script" "$job_id" "$status" &>/dev/null &
114
125
  }
115
126
 
116
127
  # ── 상태 조회 ──────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-controller",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code headless daemon controller — FIFO-based async task dispatch, Git Worktree isolation, auto-checkpointing, and a web dashboard",
5
5
  "license": "MIT",
6
6
  "author": "choiwon",
@@ -29,8 +29,12 @@
29
29
  "bin/app-launcher.sh",
30
30
  "bin/ctl",
31
31
  "bin/native-app.py",
32
+ "bin/autoloop.sh",
33
+ "bin/watchdog.sh",
32
34
  "lib/",
33
35
  "service/controller.sh",
36
+ "cognitive/",
37
+ "dag/",
34
38
  "web/*.py",
35
39
  "web/static/*.html",
36
40
  "web/static/*.js",
package/postinstall.sh CHANGED
@@ -66,7 +66,7 @@ SETTINGS_FILE="${CONTROLLER_DIR}/data/settings.json"
66
66
  if [[ ! -f "$SETTINGS_FILE" ]]; then
67
67
  cat > "$SETTINGS_FILE" << 'SETTINGS'
68
68
  {
69
- "skip_permissions": true,
69
+ "skip_permissions": false,
70
70
  "allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
71
71
  "model": "",
72
72
  "max_jobs": 10,
@@ -86,6 +86,13 @@ _on_signal() {
86
86
 
87
87
  trap _on_signal SIGTERM SIGINT SIGHUP
88
88
 
89
+ # ── meta 파일 인젝션 방지 ─────────────────────────────────────
90
+ # 개행·제어문자를 제거하여 KEY=VALUE 라인 인젝션을 방지한다.
91
+ # Python 측 _sanitize_meta_value()와 동일한 역할.
92
+ _sanitize_meta_val() {
93
+ printf '%s' "$1" | tr -d '\000-\037\177'
94
+ }
95
+
89
96
  # ── 중복 디스패치 방지 ────────────────────────────────────────
90
97
  # 최근 디스패치된 프롬프트 해시와 타임스탬프를 기록
91
98
  _LAST_DISPATCH_HASH=""
@@ -161,16 +168,40 @@ dispatch_job() {
161
168
  return 1
162
169
  fi
163
170
 
164
- # Job 등록
165
- local job_id
166
- job_id=$(job_register "$prompt")
171
+ # ── pending 작업 재사용 (DAG 의존성 디스패치) ──
172
+ local pending_jid
173
+ pending_jid=$(echo "$json_line" | jq -r '.pending_job_id // empty')
174
+
175
+ local job_id meta_file out_file
176
+ # 사용자 입력값 새니타이즈 (meta 파일 인젝션 방지)
177
+ job_uuid=$(_sanitize_meta_val "$job_uuid")
178
+ cwd=$(_sanitize_meta_val "$cwd")
179
+
180
+ if [[ -n "$pending_jid" ]]; then
181
+ local pending_meta="${LOGS_DIR}/job_${pending_jid}.meta"
182
+ if [[ -f "$pending_meta" ]]; then
183
+ job_id="$pending_jid"
184
+ meta_file="$pending_meta"
185
+ _meta_set_field "$meta_file" "STATUS" "running"
186
+ rm -f "${LOGS_DIR}/job_${pending_jid}.pending"
187
+ _log_info "Pending Job #${job_id} 디스패치 (DAG 의존성 충족)"
188
+ else
189
+ job_id=$(job_register "$prompt")
190
+ meta_file="${LOGS_DIR}/job_${job_id}.meta"
191
+ local _tmp="${meta_file}.tmp.$$"
192
+ { cat "$meta_file"; echo "UUID=${job_uuid}"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
193
+ fi
194
+ else
195
+ # Job 등록
196
+ job_id=$(job_register "$prompt")
197
+ meta_file="${LOGS_DIR}/job_${job_id}.meta"
167
198
 
168
- local out_file="${LOGS_DIR}/job_${job_id}.out"
169
- local meta_file="${LOGS_DIR}/job_${job_id}.meta"
199
+ # .meta 파일에 UUID 기록 (원자적 append: temp → rename)
200
+ local _tmp="${meta_file}.tmp.$$"
201
+ { cat "$meta_file"; echo "UUID=${job_uuid}"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
202
+ fi
170
203
 
171
- # .meta 파일에 UUID 기록 (원자적 append: temp → rename)
172
- local _tmp="${meta_file}.tmp.$$"
173
- { cat "$meta_file"; echo "UUID=${job_uuid}"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
204
+ out_file="${LOGS_DIR}/job_${job_id}.out"
174
205
 
175
206
  # ── Worktree 결정: 재사용(rewind) > 새로 생성 ──
176
207
  local wt_path=""
@@ -406,11 +437,12 @@ start_service() {
406
437
 
407
438
  # FIFO 생성
408
439
  if [[ -p "$FIFO_PATH" ]]; then
409
- _log_warn "기존 FIFO 파이프 발견. 재사용합니다: ${FIFO_PATH}"
440
+ chmod 600 "$FIFO_PATH"
441
+ _log_warn "기존 FIFO 파이프 발견. 권한 확인 후 재사용합니다: ${FIFO_PATH}"
410
442
  else
411
443
  rm -f "$FIFO_PATH"
412
- mkfifo "$FIFO_PATH"
413
- _log_info "FIFO 파이프 생성됨: ${FIFO_PATH}"
444
+ mkfifo -m 600 "$FIFO_PATH"
445
+ _log_info "FIFO 파이프 생성됨 (mode 600): ${FIFO_PATH}"
414
446
  fi
415
447
 
416
448
  # PID 기록
package/web/audit.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ 감사 로그 — 모든 API 호출을 타임스탬프·IP와 함께 기록하고 조회 API를 제공한다.
3
+
4
+ 저장 형식: JSONL (한 줄에 하나의 JSON 객체)
5
+ 저장 위치: data/audit.log
6
+ 필드: ts, time, method, path, ip, status, duration_ms
7
+ """
8
+
9
+ import json
10
+ import time
11
+ from config import DATA_DIR
12
+
13
+ AUDIT_LOG_FILE = DATA_DIR / "audit.log"
14
+ _ROTATED_FILE = DATA_DIR / "audit.log.1"
15
+
16
+ # 최대 로그 파일 크기 (10 MB) — 초과 시 .1로 로테이션
17
+ MAX_AUDIT_SIZE = 10 * 1024 * 1024
18
+
19
+ # 기록 제외 경로 (정적 파일, 페이지 요청)
20
+ _EXCLUDE_PREFIXES = ("/static/", "/uploads/")
21
+ _EXCLUDE_PATHS = {"/", "/index.html", "/favicon.ico"}
22
+
23
+
24
+ def log_api_call(method, path, client_ip, status, duration_ms):
25
+ """API 호출 한 건을 감사 로그에 기록한다.
26
+
27
+ 정적 파일 요청과 페이지 로드는 제외되며, /api/ 경로만 기록된다.
28
+ POSIX O_APPEND 모드의 원자적 쓰기를 활용하여 ThreadingHTTPServer에서 안전하다.
29
+ """
30
+ if path in _EXCLUDE_PATHS:
31
+ return
32
+ for prefix in _EXCLUDE_PREFIXES:
33
+ if path.startswith(prefix):
34
+ return
35
+
36
+ entry = {
37
+ "ts": time.time(),
38
+ "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
39
+ "method": method,
40
+ "path": path,
41
+ "ip": client_ip,
42
+ "status": status,
43
+ "duration_ms": round(duration_ms, 1),
44
+ }
45
+
46
+ try:
47
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
48
+ # 크기 기반 로테이션: 최대 1세대만 유지
49
+ if AUDIT_LOG_FILE.exists():
50
+ try:
51
+ if AUDIT_LOG_FILE.stat().st_size > MAX_AUDIT_SIZE:
52
+ AUDIT_LOG_FILE.rename(_ROTATED_FILE)
53
+ except OSError:
54
+ pass
55
+ with open(AUDIT_LOG_FILE, "a", encoding="utf-8") as f:
56
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
57
+ except OSError:
58
+ pass
59
+
60
+
61
+ def search_audit(from_ts=None, to_ts=None, method=None, path_contains=None,
62
+ ip=None, status=None, limit=100, offset=0):
63
+ """감사 로그를 조건에 맞게 검색한다.
64
+
65
+ Args:
66
+ from_ts: 시작 시간 (Unix timestamp)
67
+ to_ts: 끝 시간 (Unix timestamp)
68
+ method: HTTP 메서드 필터 (GET, POST, DELETE)
69
+ path_contains: 경로 부분 문자열 필터
70
+ ip: IP 주소 필터
71
+ status: HTTP 상태 코드 필터
72
+ limit: 반환 최대 건수 (기본 100)
73
+ offset: 건너뛸 건수
74
+
75
+ Returns:
76
+ dict: {"entries": [...], "total": int, "limit": int, "offset": int}
77
+ """
78
+ if not AUDIT_LOG_FILE.exists():
79
+ return {"entries": [], "total": 0, "limit": limit, "offset": offset}
80
+
81
+ results = []
82
+ with open(AUDIT_LOG_FILE, "r", encoding="utf-8") as f:
83
+ for line in f:
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ try:
88
+ entry = json.loads(line)
89
+ except json.JSONDecodeError:
90
+ continue
91
+
92
+ ts = entry.get("ts", 0)
93
+ if from_ts is not None and ts < from_ts:
94
+ continue
95
+ if to_ts is not None and ts > to_ts:
96
+ continue
97
+ if method and entry.get("method") != method.upper():
98
+ continue
99
+ if path_contains and path_contains not in entry.get("path", ""):
100
+ continue
101
+ if ip and entry.get("ip") != ip:
102
+ continue
103
+ if status is not None:
104
+ try:
105
+ if entry.get("status") != int(status):
106
+ continue
107
+ except (ValueError, TypeError):
108
+ continue
109
+
110
+ results.append(entry)
111
+
112
+ # 최신 순으로 정렬
113
+ results.sort(key=lambda e: e.get("ts", 0), reverse=True)
114
+ total = len(results)
115
+ entries = results[offset:offset + limit]
116
+
117
+ return {
118
+ "entries": entries,
119
+ "total": total,
120
+ "limit": limit,
121
+ "offset": offset,
122
+ }