aizuchi 0.1.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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/bin/aizuchi.js +546 -0
- package/dist/server/app.js +817 -0
- package/dist/server/board.js +248 -0
- package/dist/server/events.js +24 -0
- package/dist/server/lib/config-paths.js +91 -0
- package/dist/server/lib/docs.js +154 -0
- package/dist/server/lib/files.js +208 -0
- package/dist/server/lib/fs-safe.js +428 -0
- package/dist/server/lib/git.js +100 -0
- package/dist/server/lib/id.js +54 -0
- package/dist/server/lib/pinpoint.js +225 -0
- package/dist/server/lib/proposal.js +93 -0
- package/dist/server/lib/task-file.js +132 -0
- package/dist/server/lib/task-ops.js +206 -0
- package/dist/server/lib/time.js +24 -0
- package/dist/server/overview.js +119 -0
- package/dist/server/static.js +71 -0
- package/dist/server/watcher.js +38 -0
- package/dist/shared/critical-path.js +128 -0
- package/dist/shared/diff.js +46 -0
- package/dist/shared/digest.js +44 -0
- package/dist/shared/milestone.js +29 -0
- package/dist/shared/related-docs.js +77 -0
- package/dist/shared/schema.js +376 -0
- package/dist/shared/stats.js +167 -0
- package/dist/web/assets/index-BLN_aXBe.css +1 -0
- package/dist/web/assets/index-BS_n8d9B.js +82 -0
- package/dist/web/index.html +13 -0
- package/package.json +73 -0
- package/templates/claude-rules.md +85 -0
- package/templates/config.json +28 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configSchema, defaultConfig, findMemberViolations, summarizeZodError, taskFrontmatterSchema, } from '../shared/schema.js';
|
|
4
|
+
import { hasApprovalTrace } from './lib/proposal.js';
|
|
5
|
+
import { assertDirInsideKanban, collectKanbanFilesNoSymlink, PathValidationError, readKanbanFile, resolveKanbanReal, statKanbanFile, } from './lib/fs-safe.js';
|
|
6
|
+
import { TaskFileParseError, parseTaskFile } from './lib/task-file.js';
|
|
7
|
+
/**
|
|
8
|
+
* ボード読込: .kanban/ 配下の全タスクを読み、zod 検証・重複 ID 検出・members 整合を行って
|
|
9
|
+
* {tasks, config, warnings} を返す (F-6-2)。
|
|
10
|
+
* 不正ファイルは警告に変換して除外し、ボード自体は落とさない (DoD)。
|
|
11
|
+
*/
|
|
12
|
+
export async function loadConfig(projectRoot) {
|
|
13
|
+
const configPath = path.join(projectRoot, '.kanban', 'config.json');
|
|
14
|
+
const relPath = path.relative(projectRoot, configPath);
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
// R17-D: .kanban の realpath 信頼境界を確立 (.kanban 自体が symlink なら throw) してから
|
|
18
|
+
// config.json を realpath containment + symlink 非追従で読む。symlinked config.json や
|
|
19
|
+
// .kanban/config.json の中間 dir symlink による外部全文漏洩を防ぐ (symlink / 不在は既定値に倒す)
|
|
20
|
+
const kanbanReal = await resolveKanbanReal(projectRoot);
|
|
21
|
+
raw = await readKanbanFile(configPath, kanbanReal);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// config が無い / .kanban or config.json が symlink の場合は既定値で動作する (N-6)
|
|
25
|
+
return { config: defaultConfig(), warnings: [] };
|
|
26
|
+
}
|
|
27
|
+
let json;
|
|
28
|
+
try {
|
|
29
|
+
json = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {
|
|
33
|
+
config: defaultConfig(),
|
|
34
|
+
warnings: [
|
|
35
|
+
{ kind: 'invalid_config', path: relPath, taskId: null, message: 'config.json が JSON として読めないため既定値で動作中' },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const parsed = configSchema.safeParse(json);
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
return {
|
|
42
|
+
config: defaultConfig(),
|
|
43
|
+
warnings: [
|
|
44
|
+
{
|
|
45
|
+
kind: 'invalid_config',
|
|
46
|
+
path: relPath,
|
|
47
|
+
taskId: null,
|
|
48
|
+
message: `config.json が不正なため既定値で動作中: ${summarizeZodError(parsed.error).join(' / ')}`,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { config: parsed.data, warnings: [] };
|
|
54
|
+
}
|
|
55
|
+
/** archive/** のファイル名から ID 集合を作る (重複 ID 検出用) */
|
|
56
|
+
async function collectArchiveIds(kanbanDir, kanbanReal) {
|
|
57
|
+
const ids = new Set();
|
|
58
|
+
// R17-D: realpath containment + symlink (dir/file) 非追従の手動再帰で収集する。中間 dir symlink
|
|
59
|
+
// (archive 自体や入れ子 dir) を捕捉し、symlink で外部 .md を指して id 集合を水増しする攻撃を防ぐ
|
|
60
|
+
const files = await collectKanbanFilesNoSymlink(path.join(kanbanDir, 'archive'), kanbanReal);
|
|
61
|
+
for (const abs of files) {
|
|
62
|
+
ids.add(path.basename(abs).slice(0, -3));
|
|
63
|
+
}
|
|
64
|
+
return ids;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* archive/** の全タスクの本文を読む (日次ダイジェストが作業ログを集計するため)。
|
|
68
|
+
* frontmatter 検証はせず本文を取り出す (アーカイブは所在で表現され様々な status がありうる)。
|
|
69
|
+
* パース不能なファイルはスキップする。
|
|
70
|
+
*/
|
|
71
|
+
export async function loadArchive(projectRoot) {
|
|
72
|
+
// R17-D: .kanban の realpath 信頼境界を確立 (.kanban 自体が symlink / 不在なら [] に倒す)
|
|
73
|
+
let kanbanReal;
|
|
74
|
+
try {
|
|
75
|
+
kanbanReal = await resolveKanbanReal(projectRoot);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const archiveDir = path.join(kanbanReal, 'archive');
|
|
81
|
+
// R17-D: realpath containment + symlink (dir/file) 非追従の手動再帰で .md を集め、各ファイルも
|
|
82
|
+
// realpath containment + symlink 非追従で読む。中間 dir symlink (archive 自体を外部 dir への
|
|
83
|
+
// symlink にする等) を捕捉し、外部ファイル (例 ~/.ssh/id_rsa) のダイジェスト経由漏洩を防ぐ
|
|
84
|
+
const files = await collectKanbanFilesNoSymlink(archiveDir, kanbanReal);
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const abs of files) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await readKanbanFile(abs, kanbanReal);
|
|
89
|
+
result.push({ id: path.basename(abs).slice(0, -3), body: parseTaskFile(raw).body });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// パース不能 (壊れた archive) / symlink はスキップ
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/** 1 ファイルを読み、検証済み TaskView を返す (POST / PATCH / PUT のレスポンス用)。不正なら例外 */
|
|
98
|
+
export async function loadTaskView(projectRoot, filePath) {
|
|
99
|
+
// stat → read の順にする: 外部編集と並行した場合の不整合を「新内容 + 旧 mtime」(誤 409 側)
|
|
100
|
+
// に倒す。read → stat だと「旧内容 + 新 mtime」になり、PUT の競合検知をすり抜けて
|
|
101
|
+
// 外部編集を黙って上書きする危険側に倒れる (N-3)
|
|
102
|
+
// R17-D: .kanban の realpath 信頼境界を確立し、realpath containment + symlink 非追従で stat/read する。
|
|
103
|
+
// tasks/<id>.md やその中間 dir (tasks 自体) が symlink で外部ファイルを指していても PathValidationError を
|
|
104
|
+
// 投げて漏洩させない (loadTaskView は POST/PATCH/PUT のレスポンス生成に使われる)
|
|
105
|
+
const kanbanReal = await resolveKanbanReal(projectRoot);
|
|
106
|
+
const stat = await statKanbanFile(filePath, kanbanReal);
|
|
107
|
+
const raw = await readKanbanFile(filePath, kanbanReal);
|
|
108
|
+
const parsed = parseTaskFile(raw);
|
|
109
|
+
const frontmatter = taskFrontmatterSchema.parse(parsed.data);
|
|
110
|
+
return {
|
|
111
|
+
frontmatter,
|
|
112
|
+
path: path.relative(projectRoot, filePath),
|
|
113
|
+
body: parsed.body,
|
|
114
|
+
raw,
|
|
115
|
+
mtimeMs: stat.mtimeMs,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export async function loadBoard(projectRoot) {
|
|
119
|
+
const { config, warnings } = await loadConfig(projectRoot);
|
|
120
|
+
const tasks = [];
|
|
121
|
+
// R17-D: .kanban の realpath 信頼境界を確立する (.kanban 自体が symlink / 不在なら空ボード + warning)
|
|
122
|
+
let kanbanReal;
|
|
123
|
+
try {
|
|
124
|
+
kanbanReal = await resolveKanbanReal(projectRoot);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
warnings.push({
|
|
128
|
+
kind: 'invalid_config',
|
|
129
|
+
path: path.join('.kanban', 'tasks'),
|
|
130
|
+
taskId: null,
|
|
131
|
+
message: '.kanban が見つからない / symlink のため読み込めない (aizuchi init が未実行の可能性)',
|
|
132
|
+
});
|
|
133
|
+
return { tasks, config, warnings };
|
|
134
|
+
}
|
|
135
|
+
const tasksDir = path.join(kanbanReal, 'tasks');
|
|
136
|
+
let fileNames;
|
|
137
|
+
try {
|
|
138
|
+
// R17-D: tasksDir 自体が外部 dir への symlink (A18-2) でないことを realpath で先に検査してから readdir する
|
|
139
|
+
// (fs.readdir は symlink dir を辿って外部の中身を列挙してしまうため)
|
|
140
|
+
await assertDirInsideKanban(tasksDir, kanbanReal);
|
|
141
|
+
fileNames = await fs.readdir(tasksDir);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
warnings.push({
|
|
145
|
+
kind: 'invalid_config',
|
|
146
|
+
path: path.relative(projectRoot, tasksDir),
|
|
147
|
+
taskId: null,
|
|
148
|
+
message: '.kanban/tasks が見つからない / symlink のため読み込めない (aizuchi init が未実行の可能性)',
|
|
149
|
+
});
|
|
150
|
+
return { tasks, config, warnings };
|
|
151
|
+
}
|
|
152
|
+
const archiveIds = await collectArchiveIds(kanbanReal, kanbanReal);
|
|
153
|
+
// F-3-4 モード違反検知用: proposal モード時に「ai 種別の created_by かつ status≠proposed」を警告する。
|
|
154
|
+
// members の ai 種別を逆引きできるようにしておく (自動修正はしない、可視化のみ)
|
|
155
|
+
const aiMembers = new Set(config.members.filter((m) => m.type === 'ai').map((m) => m.id));
|
|
156
|
+
const proposalMode = config.aiTaskCreation === 'proposal';
|
|
157
|
+
for (const name of fileNames.filter((n) => n.endsWith('.md')).sort()) {
|
|
158
|
+
const filePath = path.join(tasksDir, name);
|
|
159
|
+
// R17-D: tasksDir は kanbanReal 基準 (macOS の /tmp→/private/tmp 等で projectRoot と realpath 差が出る)
|
|
160
|
+
// のため、表示用の相対パスは固定の `.kanban/tasks/<name>` で組み立てる (`../` 混入や drift を防ぐ)
|
|
161
|
+
const relPath = path.join('.kanban', 'tasks', name);
|
|
162
|
+
const fileId = name.slice(0, -3);
|
|
163
|
+
let raw;
|
|
164
|
+
let mtimeMs;
|
|
165
|
+
try {
|
|
166
|
+
// R17-D: realpath containment + symlink 非追従で stat → read (loadTaskView と同じ理由: 不整合を誤 409 側に倒す)。
|
|
167
|
+
// tasks/<id>.md が symlink なら statKanbanFile が PathValidationError を投げる
|
|
168
|
+
mtimeMs = (await statKanbanFile(filePath, kanbanReal)).mtimeMs;
|
|
169
|
+
raw = await readKanbanFile(filePath, kanbanReal);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof PathValidationError) {
|
|
173
|
+
// symlink のタスクは読まずに除外し、可視化のため警告に積む (外部漏洩防止)
|
|
174
|
+
warnings.push({
|
|
175
|
+
kind: 'invalid_task',
|
|
176
|
+
path: relPath,
|
|
177
|
+
taskId: fileId,
|
|
178
|
+
message: 'symlink のため除外しました (.kanban 配下の symlink は許可されていません)',
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
continue; // 読込とリスト取得の間に消えた (外部編集との競合)。次の SSE 再取得で整合する
|
|
183
|
+
}
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = parseTaskFile(raw);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
warnings.push({
|
|
190
|
+
kind: 'invalid_task',
|
|
191
|
+
path: relPath,
|
|
192
|
+
taskId: fileId,
|
|
193
|
+
message: error instanceof TaskFileParseError ? error.message : 'ファイルを解析できない',
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const result = taskFrontmatterSchema.safeParse(parsed.data);
|
|
198
|
+
if (!result.success) {
|
|
199
|
+
warnings.push({
|
|
200
|
+
kind: 'invalid_task',
|
|
201
|
+
path: relPath,
|
|
202
|
+
taskId: fileId,
|
|
203
|
+
message: summarizeZodError(result.error).join(' / '),
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const frontmatter = result.data;
|
|
208
|
+
if (frontmatter.id !== fileId) {
|
|
209
|
+
warnings.push({
|
|
210
|
+
kind: 'invalid_task',
|
|
211
|
+
path: relPath,
|
|
212
|
+
taskId: fileId,
|
|
213
|
+
message: `frontmatter の id (${frontmatter.id}) がファイル名 (${fileId}) と一致しない`,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// members 未登録は警告のみでタスクは表示する (F-6-2: 書式ミスの可視化が目的)
|
|
218
|
+
for (const violation of findMemberViolations(frontmatter, config)) {
|
|
219
|
+
warnings.push({ kind: 'unregistered_member', path: relPath, taskId: frontmatter.id, message: violation });
|
|
220
|
+
}
|
|
221
|
+
if (archiveIds.has(frontmatter.id)) {
|
|
222
|
+
warnings.push({
|
|
223
|
+
kind: 'duplicate_id',
|
|
224
|
+
path: relPath,
|
|
225
|
+
taskId: frontmatter.id,
|
|
226
|
+
message: `archive/ に同じ ID (${frontmatter.id}) のファイルが存在する (自動修復はしない)`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// F-3-4: 提案モード中、ai が proposed 以外で起票したタスクを警告する (自動修正なし)。
|
|
230
|
+
// ただし作業ログに承認痕跡 (`[approve]`) があるものは「proposed→承認された正規タスク」なので
|
|
231
|
+
// 除外する (R6-1: 承認済み提案の恒久誤検知を防ぐ)
|
|
232
|
+
if (proposalMode &&
|
|
233
|
+
aiMembers.has(frontmatter.created_by) &&
|
|
234
|
+
frontmatter.status !== 'proposed' &&
|
|
235
|
+
!hasApprovalTrace(parsed.body)) {
|
|
236
|
+
warnings.push({
|
|
237
|
+
kind: 'mode_violation',
|
|
238
|
+
path: relPath,
|
|
239
|
+
taskId: frontmatter.id,
|
|
240
|
+
message: `提案モード中に AI (${frontmatter.created_by}) が proposed 以外 (${frontmatter.status}) で起票しています`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
tasks.push({ frontmatter, path: relPath, body: parsed.body, raw, mtimeMs });
|
|
244
|
+
}
|
|
245
|
+
// カード並びは ID 昇順固定 (数値を考慮: TASK-2 < TASK-10)
|
|
246
|
+
tasks.sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id, 'en', { numeric: true }));
|
|
247
|
+
return { tasks, config, warnings };
|
|
248
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createEventBus() {
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
return {
|
|
4
|
+
subscribe(listener) {
|
|
5
|
+
listeners.add(listener);
|
|
6
|
+
return () => {
|
|
7
|
+
listeners.delete(listener);
|
|
8
|
+
};
|
|
9
|
+
},
|
|
10
|
+
broadcast(event) {
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(event);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// 1 購読者の失敗が他の購読者へ波及しないようにする
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
size() {
|
|
21
|
+
return listeners.size;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hasSafeConfigExt, isHardDeniedConfigPath, isSecretPath, isStrictlyInside, PathValidationError, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
4
|
+
/** 要求された相対パスが aiConfigPaths のどの許可に該当するかを判定し、正規化済み絶対パスを返す */
|
|
5
|
+
export async function resolveConfigPath(projectRoot, config, requestedRelPath) {
|
|
6
|
+
const root = path.resolve(projectRoot);
|
|
7
|
+
// 要求パスを正規化し、プロジェクトルート配下であることを確認する (絶対パス / ../ を弾く)
|
|
8
|
+
const resolved = path.resolve(root, requestedRelPath);
|
|
9
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
10
|
+
throw new PathValidationError('プロジェクトルート外へのアクセスは許可されていません');
|
|
11
|
+
}
|
|
12
|
+
// 許可リスト (config 由来) を正規化する。各エントリが root の真の配下であることを強制し
|
|
13
|
+
// (`.` / `..` / 絶対パスで root 全域を許可リストに載せる攻撃を無効化)。deny-list はここでは効かせず、
|
|
14
|
+
// 解決後パスに対して下のゲートで 2 層適用する (R14-1 再修正)。
|
|
15
|
+
const exactFiles = new Set(sanitizeConfigDirEntries(root, [...config.aiConfigPaths.settingsFiles, ...config.aiConfigPaths.rootFiles]));
|
|
16
|
+
const browseDirs = sanitizeConfigDirEntries(root, config.aiConfigPaths.browseDirs);
|
|
17
|
+
let kind = null;
|
|
18
|
+
if (exactFiles.has(resolved)) {
|
|
19
|
+
kind = 'file';
|
|
20
|
+
}
|
|
21
|
+
else if (browseDirs.some((dir) => isStrictlyInside(dir, resolved))) {
|
|
22
|
+
kind = 'dir';
|
|
23
|
+
}
|
|
24
|
+
if (kind === null) {
|
|
25
|
+
throw new PathValidationError('許可リスト (aiConfigPaths) 外へのアクセスは許可されていません');
|
|
26
|
+
}
|
|
27
|
+
// deny-list を解決後パス (lexical) に適用する (R14-1 再修正・残存脆弱性 #2/#4/#7)。
|
|
28
|
+
// 強い deny (.git/** + 鍵類) は **完全一致 (exactFiles) でも** 遮断する (悪意 config が settingsFiles に
|
|
29
|
+
// `.git/hooks/pre-commit` / `.env` / `id_rsa` を列挙しても read/write させない = RCE/N-8 防止)。
|
|
30
|
+
assertNotDenied(resolved, kind);
|
|
31
|
+
// 親ディレクトリの realpath を解決し、symlink 経由で許可範囲の外へ出ていないか確認する
|
|
32
|
+
let parentReal;
|
|
33
|
+
try {
|
|
34
|
+
parentReal = await fs.realpath(path.dirname(resolved));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new PathValidationError('親ディレクトリが存在しません');
|
|
38
|
+
}
|
|
39
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
40
|
+
if (parentReal !== rootReal && !parentReal.startsWith(rootReal + path.sep)) {
|
|
41
|
+
throw new PathValidationError('symlink 経由でプロジェクトルート外を指しています');
|
|
42
|
+
}
|
|
43
|
+
// 接頭辞一致 (browseDirs) の場合、realpath 解決後も許可ディレクトリ配下か再確認する
|
|
44
|
+
if (kind === 'dir') {
|
|
45
|
+
const allowedReals = await Promise.all(browseDirs.map((dir) => fs.realpath(dir).catch(() => dir)));
|
|
46
|
+
const canonicalParent = parentReal;
|
|
47
|
+
const stillInside = allowedReals.some((dir) => canonicalParent === dir || canonicalParent.startsWith(dir + path.sep));
|
|
48
|
+
if (!stillInside) {
|
|
49
|
+
throw new PathValidationError('symlink 経由で許可ディレクトリ外を指しています');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const canonical = path.join(parentReal, path.basename(resolved));
|
|
53
|
+
// realpath 解決後の canonical にも deny-list を再適用する (R14-1 再修正)。
|
|
54
|
+
// 親ディレクトリが symlink で .git や鍵類の置き場へ実体を移していても遮断する (二重適用)。
|
|
55
|
+
assertNotDenied(canonical, kind);
|
|
56
|
+
// 対象自体が symlink なら拒否する (symlink 越しの読み書き防止)
|
|
57
|
+
try {
|
|
58
|
+
const lst = await fs.lstat(canonical);
|
|
59
|
+
if (lst.isSymbolicLink()) {
|
|
60
|
+
throw new PathValidationError('symlink への読み書きは許可されていません');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof PathValidationError)
|
|
65
|
+
throw error;
|
|
66
|
+
if (error.code !== 'ENOENT')
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return { absPath: canonical, kind };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 解決後パスを allow-list (主ゲート) + deny-list (defense-in-depth) で検査する (R17-E + R14-1 再修正)。
|
|
73
|
+
* - **allow-list (R17-E・#E-1)**: 拡張子が SAFE_CONFIG_EXT に無ければ完全一致 / 接頭辞一致を問わず拒否。
|
|
74
|
+
* deny-list の whack-a-mole を止め、許可拡張子に無い `kubeconfig` / `github_token` / `credentials` /
|
|
75
|
+
* `terraform.tfstate` / `.envrc` / `prod.env` 等を一律ブロックする主ゲート。
|
|
76
|
+
* - `isHardDeniedConfigPath` (.git/** + 鍵類 + 秘匿 dir) は **完全一致 / 接頭辞一致を問わず** 遮断する。
|
|
77
|
+
* - `isSecretPath` (= さらに settings.local.json) は **接頭辞一致 (browseDirs) のみ** 遮断する
|
|
78
|
+
* (完全一致 = settingsFiles 経由の settings.local.json 編集は正規ユースケースのため許す)。
|
|
79
|
+
*/
|
|
80
|
+
function assertNotDenied(targetPath, kind) {
|
|
81
|
+
// 主ゲート: allow-list 拡張子のみ通す (両 kind に適用)
|
|
82
|
+
if (!hasSafeConfigExt(targetPath)) {
|
|
83
|
+
throw new PathValidationError('許可されていない拡張子のファイルにはアクセスできません');
|
|
84
|
+
}
|
|
85
|
+
if (isHardDeniedConfigPath(targetPath)) {
|
|
86
|
+
throw new PathValidationError('秘匿ファイル (.git 配下 / 鍵類) へのアクセスは許可されていません');
|
|
87
|
+
}
|
|
88
|
+
if (kind === 'dir' && isSecretPath(targetPath)) {
|
|
89
|
+
throw new PathValidationError('秘匿ファイルへのアクセスは許可されていません');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
4
|
+
/**
|
|
5
|
+
* 関連資料 (波 VIII) の読取専用ファイル I/O。
|
|
6
|
+
* config.docsDirs に列挙されたディレクトリ配下の .md のみを対象にする。
|
|
7
|
+
* - 書込 API は無い (資料は読取専用。逆引きはタスク側 frontmatter から導出する)
|
|
8
|
+
* - パス検証は resolveConfigPath (config-paths.ts) と同じ多段検査
|
|
9
|
+
* (正規化 → ルート配下 → docsDirs 接頭辞 → 親 realpath → realpath 後の接頭辞再確認 → symlink 対象拒否)。
|
|
10
|
+
* 許可リストの root 配下強制 / 秘匿 deny-list / TOCTOU 回避は共通 util (fs-safe.ts の
|
|
11
|
+
* sanitizeConfigDirEntries / isStrictlyInside / isSecretPath / readFileNoFollow) に集約済み
|
|
12
|
+
* (N≥3 で extract・LEARN#8b。S-6 監査 R14-1/R14-3/R14-4 で実施)。
|
|
13
|
+
* - N-8: 資料内容をサーバーログに出さない。
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* config.docsDirs を root 配下の有効なディレクトリ集合 (絶対パス) に正規化する (R14-3)。
|
|
17
|
+
* - root 自身 / root 外を指すエントリは除外 (`.` / `..` で全 .md を読取公開する攻撃を無効化)
|
|
18
|
+
* - `.kanban` 配下を指すエントリも除外 (タスクファイル / config が関連資料として読取公開されないように)
|
|
19
|
+
* - `.git` 配下 / 秘匿パスを指すエントリも除外 (R14-1 再修正 #6: docsDirs:['.git'] で .git/*.md 読取を塞ぐ・
|
|
20
|
+
* config-paths の deny-list と対称化)
|
|
21
|
+
*/
|
|
22
|
+
function effectiveDocsDirs(root, config) {
|
|
23
|
+
const kanbanDir = path.join(root, '.kanban');
|
|
24
|
+
return sanitizeConfigDirEntries(root, config.docsDirs).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
25
|
+
}
|
|
26
|
+
/** 要求された相対パスが docsDirs 配下の .md であることを検証し、正規化済み絶対パスを返す */
|
|
27
|
+
export async function resolveDocPath(projectRoot, config, requestedRelPath) {
|
|
28
|
+
const root = path.resolve(projectRoot);
|
|
29
|
+
// .md のみ許可 (資料プレビュー専用 API のため拡張子で先に絞る)
|
|
30
|
+
if (!requestedRelPath.endsWith('.md')) {
|
|
31
|
+
throw new PathValidationError('関連資料は .md ファイルのみ参照できます');
|
|
32
|
+
}
|
|
33
|
+
// 正規化 + プロジェクトルート配下の確認 (絶対パス / ../ を弾く)
|
|
34
|
+
const resolved = path.resolve(root, requestedRelPath);
|
|
35
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
36
|
+
throw new PathValidationError('プロジェクトルート外へのアクセスは許可されていません');
|
|
37
|
+
}
|
|
38
|
+
// docsDirs 接頭辞一致 (root 自身 / .kanban を指す docsDir は effectiveDocsDirs で除外済み・R14-3)
|
|
39
|
+
const docsDirs = effectiveDocsDirs(root, config);
|
|
40
|
+
if (!docsDirs.some((dir) => isStrictlyInside(dir, resolved))) {
|
|
41
|
+
throw new PathValidationError('docsDirs 外へのアクセスは許可されていません');
|
|
42
|
+
}
|
|
43
|
+
// 有効 docsDir 配下でも、パスに .git 成分 / 秘匿 basename が含まれれば拒否 (R14-1 再修正 #6・多層防御)
|
|
44
|
+
if (isSecretPath(resolved)) {
|
|
45
|
+
throw new PathValidationError('秘匿ファイルへのアクセスは許可されていません');
|
|
46
|
+
}
|
|
47
|
+
// 親ディレクトリの realpath が許可範囲内かを確認 (symlink 経由の脱出を防ぐ)
|
|
48
|
+
let parentReal;
|
|
49
|
+
try {
|
|
50
|
+
parentReal = await fs.realpath(path.dirname(resolved));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new PathValidationError('親ディレクトリが存在しません');
|
|
54
|
+
}
|
|
55
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
56
|
+
if (parentReal !== rootReal && !parentReal.startsWith(rootReal + path.sep)) {
|
|
57
|
+
throw new PathValidationError('symlink 経由でプロジェクトルート外を指しています');
|
|
58
|
+
}
|
|
59
|
+
const allowedReals = await Promise.all(docsDirs.map((dir) => fs.realpath(dir).catch(() => dir)));
|
|
60
|
+
const stillInside = allowedReals.some((dir) => parentReal === dir || parentReal.startsWith(dir + path.sep));
|
|
61
|
+
if (!stillInside) {
|
|
62
|
+
throw new PathValidationError('symlink 経由で docsDirs 外を指しています');
|
|
63
|
+
}
|
|
64
|
+
// 対象自体が symlink なら拒否 (symlink 越しの読出防止)
|
|
65
|
+
const canonical = path.join(parentReal, path.basename(resolved));
|
|
66
|
+
// realpath 解決後の canonical にも秘匿 deny を再適用 (親 symlink が .git へ実体を移すケースを塞ぐ)
|
|
67
|
+
if (isSecretPath(canonical)) {
|
|
68
|
+
throw new PathValidationError('秘匿ファイルへのアクセスは許可されていません');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const lst = await fs.lstat(canonical);
|
|
72
|
+
if (lst.isSymbolicLink()) {
|
|
73
|
+
throw new PathValidationError('symlink の参照は許可されていません');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof PathValidationError)
|
|
78
|
+
throw error;
|
|
79
|
+
if (error.code !== 'ENOENT')
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return canonical;
|
|
83
|
+
}
|
|
84
|
+
/** docsDirs 配下の資料本文を読む (読取専用)。許可外は PathValidationError、不在は ENOENT */
|
|
85
|
+
export async function readDoc(projectRoot, config, relPath) {
|
|
86
|
+
const absPath = await resolveDocPath(projectRoot, config, relPath);
|
|
87
|
+
// R14-4: resolveDocPath の lstat (symlink 拒否) と readFile の間で対象が symlink に差し替わる
|
|
88
|
+
// TOCTOU 窓を O_NOFOLLOW で塞ぐ (対象が symlink なら open が ELOOP で失敗する)。
|
|
89
|
+
const content = await readFileNoFollow(absPath);
|
|
90
|
+
// absPath は realpath ベースの canonical のため、相対化も realpath 済み root を基準にする
|
|
91
|
+
// (macOS の /var → /private/var のような root 側 symlink で相対パスがずれないように)
|
|
92
|
+
const root = path.resolve(projectRoot);
|
|
93
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
94
|
+
return { path: path.relative(rootReal, absPath), content };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* docsDirs 配下の .md ファイルツリーを構築する。buildFileTree (files.ts) と同じ手動再帰で
|
|
98
|
+
* symlink dir/file を辿らない (R8-1 と同型・プロジェクト外漏洩防止)。
|
|
99
|
+
* .md 以外のファイルは含めず、.md を 1 つも含まないディレクトリは木から除外する。
|
|
100
|
+
*/
|
|
101
|
+
export async function buildDocsTree(projectRoot, config) {
|
|
102
|
+
const root = path.resolve(projectRoot);
|
|
103
|
+
const trees = [];
|
|
104
|
+
// R14-3: root 自身 / root 外 / .kanban を指す docsDir は effectiveDocsDirs で除外する
|
|
105
|
+
// (`.` で全 .md が、`.kanban` でタスク / config が読取公開されるのを防ぐ)
|
|
106
|
+
for (const absDir of effectiveDocsDirs(root, config)) {
|
|
107
|
+
const node = await buildDocsNode(root, absDir);
|
|
108
|
+
// R13-6: top-level エントリはディレクトリのみ許可。docsDirs に .md ファイル自体が指定されると
|
|
109
|
+
// ツリーには出るが readDoc (接頭辞 + path.sep 判定) では常に 403 になる不整合を防ぐ
|
|
110
|
+
if (node && node.type === 'dir')
|
|
111
|
+
trees.push(node);
|
|
112
|
+
}
|
|
113
|
+
return trees;
|
|
114
|
+
}
|
|
115
|
+
async function buildDocsNode(root, absPath) {
|
|
116
|
+
// R14-1 再々修正 (FIX-6・#1/#4): 走査中の .git 配下 / 秘匿 basename は列挙しない。
|
|
117
|
+
// FIX-5 は top-level (effectiveDocsDirs) と read (resolveDocPath) のみだったため、正規 docsDir 配下に
|
|
118
|
+
// 入れ子で置かれた .git/*.md / .env.md がツリーに名前・構造として露出していた。files.ts:100 buildTreeNode と同型化。
|
|
119
|
+
if (isSecretPath(absPath))
|
|
120
|
+
return null;
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = await fs.lstat(absPath);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (stat.isSymbolicLink())
|
|
129
|
+
return null; // symlink は辿らない
|
|
130
|
+
const rel = path.relative(root, absPath);
|
|
131
|
+
const name = path.basename(absPath);
|
|
132
|
+
if (stat.isDirectory()) {
|
|
133
|
+
let entries = [];
|
|
134
|
+
try {
|
|
135
|
+
entries = (await fs.readdir(absPath)).sort();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
entries = [];
|
|
139
|
+
}
|
|
140
|
+
const children = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const child = await buildDocsNode(root, path.join(absPath, entry));
|
|
143
|
+
if (child)
|
|
144
|
+
children.push(child);
|
|
145
|
+
}
|
|
146
|
+
if (children.length === 0)
|
|
147
|
+
return null; // .md を含まない dir は出さない
|
|
148
|
+
return { path: rel, name, type: 'dir', children };
|
|
149
|
+
}
|
|
150
|
+
if (stat.isFile() && name.endsWith('.md')) {
|
|
151
|
+
return { path: rel, name, type: 'file' };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|