aizuchi 0.5.0 → 0.7.2

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.
@@ -1,7 +1,28 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, resolveKanbanReal, safeWriteFile, sanitizeConfigDirEntries, } from './fs-safe.js';
4
- import { snapshotToTrash, DEFAULT_TRASH_KEEP } from './trash.js';
3
+ import { DOC_BASENAME_RE, isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, resolveKanbanReal, safeWriteFile, sanitizeConfigDirEntries, } from './fs-safe.js';
4
+ import { snapshotToTrash, snapshotBestEffort, DEFAULT_TRASH_KEEP } from './trash.js';
5
+ /**
6
+ * deleteDoc の fail-closed 用エラー。退避 (.kanban/.trash へのコピー) に失敗したため削除を中止したことを表す。
7
+ * 呼び出し側 (DELETE /api/docs) はこれを 500 にマップする (PathValidationError=403 / ENOENT=404 と区別するため別型)。
8
+ */
9
+ export class TrashSnapshotError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'TrashSnapshotError';
13
+ }
14
+ }
15
+ /**
16
+ * docsWriteDir 相対の canonical からごみ箱退避 ID を組み立てる。
17
+ * rootReal からの相対パスをサニタイズ (`[^A-Za-z0-9._-]` → `_`) して含めることで、
18
+ * 別ディレクトリの同名資料 (docs/a/notes.md と docs/b/notes.md) が同 trashId に潰れて
19
+ * 世代が混在 (pruneTrash の巻き添え削除 / 復元取り違え) するのを防ぐ。
20
+ * `.md` 拡張子は除いてから組み立てる。writeDoc(overwrite) と deleteDoc で同一ロジックを共有する。
21
+ */
22
+ function docTrashId(canonical, rootReal) {
23
+ const relNoExt = path.relative(rootReal, canonical).replace(/\.md$/i, '');
24
+ return `doc-${relNoExt.replace(/[^A-Za-z0-9._-]/g, '_')}`;
25
+ }
5
26
  /**
6
27
  * 関連資料 (波 VIII) の読取専用ファイル I/O。
7
28
  * config.docsDirs に列挙されたディレクトリ配下の .md のみを対象にする。
@@ -211,6 +232,14 @@ export async function resolveDocWritePath(projectRoot, config, requestedRelPath)
211
232
  // 親 (や中間 dir) が未作成なら「最も近い実在祖先」で containment を判定する (config-paths.ts と同方式)。
212
233
  const rootReal = await fs.realpath(root).catch(() => root);
213
234
  const { real: ancestorReal, missing } = await resolveNearestExistingAncestor(path.dirname(resolved));
235
+ // 新規作成される中間フォルダ名も basename と同じ規則 (DOC_BASENAME_RE) で検証する (I2)。
236
+ // containment (下の realpath 判定) とは別軸の「名前」検査で、これを通さないと mkdir recursive で
237
+ // `.hidden` や bidi 偽装のフォルダ名が docsWriteDirs 配下に作られうる。
238
+ for (const segment of missing) {
239
+ if (!DOC_BASENAME_RE.test(segment)) {
240
+ throw new PathValidationError('作成するフォルダ名に使用できない文字が含まれています');
241
+ }
242
+ }
214
243
  if (ancestorReal !== rootReal && !ancestorReal.startsWith(rootReal + path.sep)) {
215
244
  throw new PathValidationError('symlink 経由でプロジェクトルート外を指しています');
216
245
  }
@@ -271,42 +300,47 @@ export async function writeDoc(projectRoot, config, relPath, content, mode, base
271
300
  return { conflict: { serverContent: serverContent ?? '', serverMtimeMs } };
272
301
  }
273
302
  // 上書き前に旧版を .kanban/.trash へ退避する (ベストエフォート安全網・deleteDoc と同方式)。
303
+ // M9 (LEARN#8b): 実際の退避握り潰しは snapshotBestEffort (trash.ts) に集約。resolveKanbanReal は
304
+ // throw しうるため従来どおり try/catch で包む。
274
305
  try {
275
306
  const kanbanDir = path.join(rootReal, '.kanban');
276
307
  const kanbanReal = await resolveKanbanReal(path.resolve(projectRoot));
277
- const stem = path.basename(canonical).replace(/\.md$/i, '');
278
- const trashId = `doc-${stem.replace(/[^A-Za-z0-9._-]/g, '_')}`;
279
- await snapshotToTrash(trashId, serverContent ?? '', kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
308
+ const trashId = docTrashId(canonical, rootReal);
309
+ await snapshotBestEffort(trashId, serverContent ?? '', kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
280
310
  }
281
311
  catch {
282
312
  // 退避できなくても上書きは進める (安全網のベストエフォート)
283
313
  }
284
- await safeWriteFile(canonical, content, writeDirs);
314
+ // I2: docsWriteDirs は一般ドキュメント用途のため DOC_BASENAME_RE (Unicode 許可) で basename を検証する
315
+ // (`.kanban/` システム生成用の ASCII 限定 SAFE_BASENAME_RE とは分離)。
316
+ await safeWriteFile(canonical, content, writeDirs, {}, DOC_BASENAME_RE);
285
317
  }
286
318
  else {
287
319
  await ensureDir(canonical);
288
- await safeWriteFile(canonical, content, writeDirs, { exclusive: true });
320
+ await safeWriteFile(canonical, content, writeDirs, { exclusive: true }, DOC_BASENAME_RE);
289
321
  }
290
322
  const mtimeMs = (await fs.lstat(canonical)).mtimeMs;
291
323
  return { path: path.relative(rootReal, canonical), mtimeMs };
292
324
  }
293
325
  /**
294
326
  * docsWriteDirs 配下の資料を削除する。unlink の前に現内容を .kanban/.trash へコピー退避する (誤削除の安全網)。
295
- * 退避はベストエフォート (失敗しても削除は進む)。対象不在は ENOENT を投げる (呼び出し側で 404)。
327
+ * 削除は **fail-closed** (DELETE /api/tasks と対称): 退避が「唯一の生き残りコピー」になるため、退避に失敗したら
328
+ * TrashSnapshotError を投げて削除を中止する (呼び出し側で 500・静かな完全消失を防ぐ)。対象不在は ENOENT (404)。
296
329
  */
297
330
  export async function deleteDoc(projectRoot, config, relPath) {
298
331
  const { canonical, rootReal } = await resolveDocWritePath(projectRoot, config, relPath);
299
332
  // 現内容を読む (不在なら ENOENT → 呼び出し側 404)。symlink は resolveDocWritePath が既に拒否済み。
300
333
  const content = await readFileNoFollow(canonical);
334
+ // 退避に成功したときだけ削除する (退避失敗は TrashSnapshotError で中止)。
301
335
  try {
302
336
  const kanbanDir = path.join(rootReal, '.kanban');
303
337
  const kanbanReal = await resolveKanbanReal(projectRoot);
304
- const stem = path.basename(canonical).replace(/\.md$/i, '');
305
- const trashId = `doc-${stem.replace(/[^A-Za-z0-9._-]/g, '_')}`;
338
+ const trashId = docTrashId(canonical, rootReal);
306
339
  await snapshotToTrash(trashId, content, kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
307
340
  }
308
341
  catch {
309
- // 退避できなくても削除は進める (安全網のベストエフォート)
342
+ // .kanban 未作成 / .trash 退避不能等で退避できなかった場合は削除を中止する (fail-closed)
343
+ throw new TrashSnapshotError('ごみ箱への退避に失敗したため削除を中止しました');
310
344
  }
311
345
  await fs.rm(canonical, { force: false }); // 不在なら ENOENT (404)。symlink は事前拒否済み
312
346
  return { path: path.relative(rootReal, canonical) };
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import matter from 'gray-matter';
3
3
  import path from 'node:path';
4
4
  import { GLOBAL_BROWSE_DIRS, resolveConfigPath, resolveGlobalConfigPath } from './config-paths.js';
5
- import { hasSafeConfigExt, isSecretPath, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, writeFileAtomic, } from './fs-safe.js';
5
+ import { hasSafeConfigExt, isSecretPath, PathValidationError, readFileNoFollow, resolveAllowedReals, sanitizeConfigDirEntries, writeFileAtomic, } from './fs-safe.js';
6
6
  /**
7
7
  * AI 設定ファイル管理 (F-4) のファイル I/O。
8
8
  * 全ての読み書きは resolveConfigPath で aiConfigPaths 許可リストを通す (N-2)。
@@ -41,21 +41,35 @@ export async function readGlobalConfigFile(homeDir, relPath) {
41
41
  * - 既存があれば `.bak` を作成 (上書き前のバックアップ・mode 保全 R8-2)
42
42
  * - 本体も mode 保全 (秘匿ファイルの権限が緩まないように、N-8 / Round 1 #7)
43
43
  * mtime 不一致は呼び出し側で 409 にする (この関数は検証済み前提で書く)。
44
+ *
45
+ * 呼び出し側 (saveConfigFile / saveGlobalConfigFile) は resolveConfigPath / resolveGlobalConfigPath で
46
+ * 検証済みの absPath と、その検証に用いた containment root (projectRoot / homeDir) を渡す。
47
+ * export は containment 再検査の白箱回帰テスト用 (app-files の API 統合テストと併せて固定する)。
44
48
  */
45
- async function writeResolved(root, absPath, content) {
49
+ export async function writeResolved(root, absPath, content) {
46
50
  // absPath は resolveConfigPath / resolveGlobalConfigPath で検証済み (root 配下・許可リスト内・symlink 非経由)
47
51
  await fs.mkdir(path.dirname(absPath), { recursive: true });
52
+ // resolveConfigPath / resolveGlobalConfigPath は親 realpath が root の realpath 配下であることを検証する
53
+ // (config-paths.ts: ancestorReal が rootReal 配下か)。検証〜rename の窓で親 dir を root 外 symlink へ
54
+ // 差し替えると root 外へ着地しうるため、同じ root 境界を containmentReals として writeFileAtomic に渡し、
55
+ // rename / link 直前の containment 再検査を効かせる (Task 1 の writeFileAtomic 再検査を config 書込経路にも適用)。
56
+ // .bak (同一親 dir 配下) も同じ境界を渡す。
57
+ // root の realpath が解決できない (理論上の init 前など) ときは [] になる。空配列を渡すと再検査が全 reject に
58
+ // 倒れてしまうため、その場合は containmentReals を渡さず (undefined) hard-deny のみの従来挙動に戻す (後方互換)。
59
+ // 実運用では projectRoot / homeDir は常に存在し非空配列になる。
60
+ const reals = await resolveAllowedReals([root]);
61
+ const containmentReals = reals.length > 0 ? reals : undefined;
48
62
  try {
49
63
  const prevStat = await fs.stat(absPath);
50
64
  // R14-4 再修正 (#5): prev 読取も O_NOFOLLOW に統一 (config 読取経路の TOCTOU を docs と対称に塞ぐ)
51
65
  const prev = await readFileNoFollow(absPath);
52
- await writeFileAtomic(`${absPath}.bak`, prev, { mode: prevStat.mode & 0o777 });
66
+ await writeFileAtomic(`${absPath}.bak`, prev, { mode: prevStat.mode & 0o777, containmentReals });
53
67
  }
54
68
  catch (error) {
55
69
  if (error.code !== 'ENOENT')
56
70
  throw error;
57
71
  }
58
- await writeFileAtomic(absPath, content, { preserveMode: true });
72
+ await writeFileAtomic(absPath, content, { preserveMode: true, containmentReals });
59
73
  const stat = await fs.stat(absPath);
60
74
  return { path: await relPathFromRealRoot(root, absPath), content, mtimeMs: stat.mtimeMs };
61
75
  }
@@ -21,8 +21,28 @@ export class FileExistsError extends Error {
21
21
  this.name = 'FileExistsError';
22
22
  }
23
23
  }
24
- // パス区切り・制御文字・先頭ドットを含むファイル名を拒否する (ファイル名サニタイズ)
24
+ // パス区切り・制御文字・先頭ドットを含むファイル名を拒否する (ファイル名サニタイズ)
25
+ // `.kanban/` 配下のシステム生成ファイル名 (task/config/trash) 専用。ASCII 限定。
25
26
  const SAFE_BASENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ /**
28
+ * 一般ドキュメント (docsWriteDirs) 用の basename 検証 (I2)。SAFE_BASENAME_RE と異なり
29
+ * 日本語・全角等の Unicode 文字を許可する (このリポ自身が日本語ファイル名を運用しているため)。
30
+ * 拒否するのは危険側のみ (LEARN#13: 不可視 unicode は regex literal に直書きせず \uXXXX escape で書く):
31
+ * - 先頭ドット (`(?!\.)`): 隠しファイル化・`..` 系を防ぐ
32
+ * - パス区切り (`/` `\`): POSIX の `path.basename` は `\` を区切りと解釈せず素通りしうるため、
33
+ * Windows 由来の混乱・ツール間差異を避けるため明示的に拒否する
34
+ * - 制御・書式・区切りを Unicode プロパティで網羅拒否 (列挙 denylist では \p{Cf} 全体を塞げず
35
+ * tag 文字等が素通りしたため切替。敵対的検証 wej3c4m14):
36
+ * \p{Cc} (C0/C1/DEL) / \p{Zl} (U+2028) / \p{Zp} (U+2029) /
37
+ * \p{Cf} (bidi 制御 U+202A-202E・U+2066-2069、方向マーク、zero-width、WORD JOINER、
38
+ * tag 文字 U+E0000-E007F、SOFT HYPHEN U+00AD、interlinear 等の不可視 format 全般)
39
+ * - Hangul filler (U+115F/U+1160/U+3164/U+FFA0): \p{Lo} だが不可視スペース状のため明示拒否
40
+ * - `u` フラグで supplementary plane (tag 文字) も 1 code point として判定する
41
+ * - 許可の非対称: 単体絵文字・国旗・VS16 付き絵文字は許可するが、ZWJ 合成絵文字は結合子
42
+ * U+200D (\p{Cf}) を含むため拒否 (spoofing 耐性優先の許容トレードオフ)
43
+ * - 空文字 (`+` により 1 文字以上を要求)
44
+ */
45
+ export const DOC_BASENAME_RE = /^(?!\.)[^/\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115f\u1160\u3164\uffa0]+$/u;
26
46
  /**
27
47
  * 解決済み絶対パスが root の「真の配下」かを判定する (root 自身は含めない)。
28
48
  * config 由来の許可リスト (browseDirs / docsDirs / settingsFiles / rootFiles) の各エントリが
@@ -126,14 +146,37 @@ export function sanitizeConfigDirEntries(root, entries) {
126
146
  }
127
147
  return result;
128
148
  }
149
+ /**
150
+ * 許可ディレクトリ群を realpath 解決した配列を返す (assertWritablePath と writeFileAtomic の
151
+ * containment 再検査で共有するヘルパ)。realpath に失敗するもの (init 前で未存在など) は
152
+ * 候補から外す。返り値は writeFileAtomic に containmentReals として渡し、rename / link 直前の
153
+ * 再検査でも同じ許可境界を使うことで、検証〜書込間の親 dir 差し替え (TOCTOU) を遮断する。
154
+ *
155
+ * files.ts:writeResolved (config 書込経路) も resolveConfigPath が validate した containment 境界
156
+ * (= root の realpath) を渡すために本ヘルパを再利用する (realpath 解決の単一ソース化)。
157
+ */
158
+ export async function resolveAllowedReals(allowedDirs) {
159
+ const allowedReals = [];
160
+ for (const dir of allowedDirs) {
161
+ try {
162
+ allowedReals.push(await fs.realpath(path.resolve(dir)));
163
+ }
164
+ catch {
165
+ // 許可ディレクトリ自体が無い場合は候補から外す (init 前など)
166
+ }
167
+ }
168
+ return allowedReals;
169
+ }
129
170
  /**
130
171
  * 書込先パスを検証し、正規化済みの絶対パスを返す。
131
172
  * allowedDirs は書込を許可するディレクトリの一覧 (Phase 1 は `.kanban/` のみ)。
173
+ * basenameRe は basename 検証に使う正規表現 (I2: 既定は `.kanban/` システム生成用の ASCII 限定
174
+ * `SAFE_BASENAME_RE`。docsWriteDirs 書込 (docs.ts) は日本語等を許可する `DOC_BASENAME_RE` を渡す)。
132
175
  */
133
- export async function assertWritablePath(targetPath, allowedDirs) {
176
+ export async function assertWritablePath(targetPath, allowedDirs, basenameRe = SAFE_BASENAME_RE) {
134
177
  const resolved = path.resolve(targetPath);
135
178
  const base = path.basename(resolved);
136
- if (!SAFE_BASENAME_RE.test(base)) {
179
+ if (!basenameRe.test(base)) {
137
180
  throw new PathValidationError(`不正なファイル名のため書込を拒否: ${JSON.stringify(base)}`);
138
181
  }
139
182
  let parentReal;
@@ -144,15 +187,7 @@ export async function assertWritablePath(targetPath, allowedDirs) {
144
187
  // R17-F (M3): エラー文に絶対パスを出さない (home/username の info leak 防止)
145
188
  throw new PathValidationError('書込先の親ディレクトリが存在しません');
146
189
  }
147
- const allowedReals = [];
148
- for (const dir of allowedDirs) {
149
- try {
150
- allowedReals.push(await fs.realpath(path.resolve(dir)));
151
- }
152
- catch {
153
- // 許可ディレクトリ自体が無い場合は候補から外す (init 前など)
154
- }
155
- }
190
+ const allowedReals = await resolveAllowedReals(allowedDirs);
156
191
  const inside = allowedReals.some((dir) => parentReal === dir || parentReal.startsWith(dir + path.sep));
157
192
  if (!inside) {
158
193
  // R17-F (M3): 絶対パス (targetPath) を出さない
@@ -185,10 +220,14 @@ export async function writeFileAtomic(filePath, content, options = {}) {
185
220
  const dir = path.dirname(filePath);
186
221
  // .tmp 拡張子にして watcher の .md フィルタに掛からないようにする
187
222
  const tmpPath = path.join(dir, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
188
- // R17-C (#C-1): rename / link 直前に親 realpath を再解決し、書込先 canonical を秘匿 deny で再評価する。
189
- // resolveConfigPath の検証〜ここまでの窓で親ディレクトリが .git/hooks への symlink へ差し替えられると、
190
- // canonical 文字列に `.git` が現れず deny を素通りしたまま rename が .git/hooks/pre-commit に着地しうる (RCE)。
191
- // 直前再検証で「秘匿パスへの差し替え」を検知して中止する。
223
+ // R17-C (#C-1) + Task 1: rename / link 直前に親 realpath を再解決し、書込先 canonical
224
+ // (1) 秘匿 deny (hard-deny) (2) 許可リスト containment の両方で再評価する。
225
+ // resolveConfigPath / assertWritablePath の検証〜ここまでの窓で親ディレクトリが差し替えられると、
226
+ // - .git/hooks への symlink: canonical 文字列に `.git` が現れず deny を素通りし rename が
227
+ // .git/hooks/pre-commit に着地しうる (RCE)。→ hard-deny 再検査で遮断。
228
+ // - 許可リスト外 dir への symlink: assertWritablePath が確認した親が rename 時には root 外を指し、
229
+ // root 外へ書込が着地しうる。→ containmentReals 再検査で遮断 (containmentReals 指定時のみ)。
230
+ // 直前再検証で「秘匿パス / 許可リスト外への差し替え」を検知して中止する。
192
231
  // micro-window: 再 realpath〜rename の間は残存する (Node に renameat (fd ベース atomic rename) が無く、
193
232
  // パス文字列ベースの rename しか使えないため完全封じは標準 API 外)。実害は極小だが受容する。
194
233
  const reCheckHardDeny = async () => {
@@ -203,6 +242,15 @@ export async function writeFileAtomic(filePath, content, options = {}) {
203
242
  if (isHardDeniedConfigPath(reCanonical)) {
204
243
  throw new PathValidationError('書込直前に秘匿パスへの差し替えを検知したため中止しました');
205
244
  }
245
+ // containmentReals が渡されていれば許可リスト containment も再検査する (Task 1)。
246
+ // 未指定 (CLI 固定パス・テスト直叩き) では従来どおり hard-deny のみで後方互換。
247
+ const containmentReals = options.containmentReals;
248
+ if (containmentReals !== undefined) {
249
+ const stillInside = containmentReals.some((d) => reParent === d || reParent.startsWith(d + path.sep));
250
+ if (!stillInside) {
251
+ throw new PathValidationError('書込直前に許可リスト外への差し替えを検知したため中止しました');
252
+ }
253
+ }
206
254
  };
207
255
  try {
208
256
  await fs.writeFile(tmpPath, content, 'utf8');
@@ -249,10 +297,17 @@ export async function writeFileAtomic(filePath, content, options = {}) {
249
297
  throw error;
250
298
  }
251
299
  }
252
- /** 許可リスト検証 + アトミック書込。書き込んだ正規化済みパスを返す */
253
- export async function safeWriteFile(targetPath, content, allowedDirs, options = {}) {
254
- const canonical = await assertWritablePath(targetPath, allowedDirs);
255
- await writeFileAtomic(canonical, content, options);
300
+ /**
301
+ * 許可リスト検証 + アトミック書込。書き込んだ正規化済みパスを返す。
302
+ * basenameRe assertWritablePath へそのまま渡す (I2: 既定は SAFE_BASENAME_RE。docs.ts は
303
+ * DOC_BASENAME_RE を渡して日本語等の basename を許可する)
304
+ */
305
+ export async function safeWriteFile(targetPath, content, allowedDirs, options = {}, basenameRe = SAFE_BASENAME_RE) {
306
+ const canonical = await assertWritablePath(targetPath, allowedDirs, basenameRe);
307
+ // Task 1: 検証で使った許可境界 (realpath) を writeFileAtomic に渡し、rename / link 直前の
308
+ // containment 再検査で同じ境界を使う (検証〜書込間の親 dir 差し替え TOCTOU を遮断)。
309
+ const allowedReals = await resolveAllowedReals(allowedDirs);
310
+ await writeFileAtomic(canonical, content, { ...options, containmentReals: allowedReals });
256
311
  return canonical;
257
312
  }
258
313
  /**
@@ -352,13 +407,18 @@ export async function statKanbanFile(absPath, kanbanReal) {
352
407
  return stat;
353
408
  }
354
409
  /**
355
- * `.kanban/` 配下のファイルを symlink を辿らずに読む (R17-D・#A-1/#D-2)。
410
+ * `.kanban/` 配下のファイルを symlink を辿らずに読む (R17-D・#A-1/#D-2 + Task 1: 親 symlink TOCTOU 緩和)。
356
411
  * statKanbanFile (親 realpath containment で中間 dir symlink を捕捉 + lstat で最終成分 symlink を
357
412
  * PathValidationError 拒否) を通してから readFileNoFollow (O_NOFOLLOW) で読む。最終成分は lstat と
358
413
  * O_NOFOLLOW の両方で弾き、lstat→open の窓の TOCTOU も二重化する。
414
+ * さらに open 直前に assertInsideKanban で親 realpath を再確認し、statKanbanFile〜open の窓で
415
+ * 中間 dir が外部 symlink へ差し替えられても root 外を読まないようにする (書込側 reCheckHardDeny と
416
+ * 同じ micro-window 強度)。残存 micro-window は受容する (Node に openat (fd ベース atomic open) が
417
+ * 無く、パス文字列ベースの open しか使えないため完全封じは標準 API 外)。
359
418
  */
360
419
  export async function readKanbanFile(absPath, kanbanReal) {
361
420
  await statKanbanFile(absPath, kanbanReal); // 中間 dir symlink + 最終成分 symlink を捕捉 (PathValidationError)
421
+ await assertInsideKanban(absPath, kanbanReal); // open 直前に親 realpath を再確認 (中間 dir 差し替えの TOCTOU 緩和)
362
422
  return readFileNoFollow(absPath); // 最終成分 symlink を O_NOFOLLOW でも捕捉 (TOCTOU 二重化)
363
423
  }
364
424
  /**
@@ -3,14 +3,45 @@
3
3
  */
4
4
  // 作業ログ見出し: 日本語テンプレ (`## 作業ログ`) と英語 (`## Work log`) の両方を許容する
5
5
  const WORK_LOG_HEADING_RE = /^#{1,6}\s*(?:作業ログ|work log)\s*$/i;
6
- // 承認痕跡: approve API が作業ログに残す `[approve]` タグ行 (F-3-4 誤検知防止 R6-1)
7
- const APPROVE_TRACE_RE = /^\s*-\s*\S.*\[approve\]/m;
6
+ // 任意の Markdown 見出し行 (作業ログセクションの終端検出に使う)
7
+ const ANY_HEADING_RE = /^#{1,6}\s/;
8
+ // 承認痕跡: approve API (app.ts) が作業ログに残す固定文言のみにマッチする (I1: `## やりとり` の
9
+ // 自由記述コメントや CLI `aizuchi log` の任意メッセージによる誤検知バイパスを防ぐため、行頭
10
+ // `- <timestamp>` 直後のアクター角括弧位置が `[approve]` で、続くメッセージが承認 API の固定文言
11
+ // `承認: proposed → todo` (app.ts:549) と完全一致する場合のみマッチする)
12
+ const APPROVE_TRACE_RE = /^-\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\[approve\]\s+承認: proposed → todo\s*$/m;
8
13
  /**
9
- * 作業ログに承認痕跡 (`[approve]` を含むログ行) があるかを判定する。
14
+ * body から `## 作業ログ` セクション (見出し直後 〜 次の見出し行 or EOF) だけを切り出す。
15
+ * 見出しが無ければ空文字列を返す (承認痕跡は正規の approve API 書込みでのみ作業ログ内に残るため、
16
+ * セクション自体が無いタスクに承認痕跡はあり得ない = 空扱いで安全側に倒す)。
17
+ */
18
+ function workLogSection(body) {
19
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
20
+ const lines = body.split(eol);
21
+ let start = -1;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ if (WORK_LOG_HEADING_RE.test(lines[i].replace(/\r$/, '').trim())) {
24
+ start = i + 1;
25
+ break;
26
+ }
27
+ }
28
+ if (start === -1)
29
+ return '';
30
+ const out = [];
31
+ for (let i = start; i < lines.length; i++) {
32
+ if (ANY_HEADING_RE.test(lines[i]))
33
+ break;
34
+ out.push(lines[i]);
35
+ }
36
+ return out.join('\n');
37
+ }
38
+ /**
39
+ * 作業ログセクションに承認痕跡 (approve API 固定文言のログ行) があるかを判定する。
10
40
  * proposed→approve した正規タスクと、AI が proposed を経ず直接 todo 起票した違反を区別するために使う。
41
+ * I1: 検索対象を `## 作業ログ` セクション内に限定する (`## やりとり` コメント経由のバイパスを防ぐ)。
11
42
  */
12
43
  export function hasApprovalTrace(body) {
13
- return APPROVE_TRACE_RE.test(body);
44
+ return APPROVE_TRACE_RE.test(workLogSection(body));
14
45
  }
15
46
  /**
16
47
  * frontmatter ブロック (先頭 `---` 〜 2 つ目の `---` の行) を header として保持したまま、
@@ -1,14 +1,14 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import path from 'node:path';
3
2
  import { findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../../shared/schema.js';
4
3
  import { loadConfig, loadTaskView } from '../board.js';
5
4
  import { allocateNextId, createSerialQueue } from './id.js';
6
- import { FileExistsError, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
5
+ import { FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
7
6
  import { applyPinpointPatch, verifyPinpointResult } from './pinpoint.js';
8
7
  import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './proposal.js';
8
+ import { assertRelatedDocsAllowed } from './related-docs-validate.js';
9
9
  import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './task-file.js';
10
10
  import { nowIso, workLogTimestamp } from './time.js';
11
- import { DEFAULT_TRASH_KEEP, snapshotToTrash } from './trash.js';
11
+ import { DEFAULT_TRASH_KEEP, snapshotBestEffort } from './trash.js';
12
12
  /**
13
13
  * タスク操作のコアロジック (AI 用 CLI F-6-3 が使う)。
14
14
  *
@@ -60,13 +60,16 @@ export class TaskOps {
60
60
  * 破壊的書込 (move / revise) の直前に現内容を `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する
61
61
  * 安全網 (ごみ箱)。HTTP ルート (app.ts) の snapshot ヘルパーと同型・同 trashKeep。
62
62
  * ベストエフォート (退避失敗は本処理を止めない)。退避は元を消さない「コピー」。
63
+ *
64
+ * M9 (LEARN#8b): 実際の退避握り潰しは snapshotBestEffort (trash.ts) に集約。resolveKanbanReal は
65
+ * ここでも throw しうるため (呼び出し元で既に成功済みだが再解決する) 従来どおり try/catch を維持する。
63
66
  */
64
67
  async snapshot(id, currentContent) {
65
68
  try {
66
69
  const kanbanReal = await resolveKanbanReal(this.projectRoot);
67
70
  const { config } = await loadConfig(this.projectRoot);
68
71
  const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
69
- await snapshotToTrash(id, currentContent, this.kanbanDir, kanbanReal, keep);
72
+ await snapshotBestEffort(id, currentContent, this.kanbanDir, kanbanReal, keep);
70
73
  }
71
74
  catch {
72
75
  // 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
@@ -87,6 +90,17 @@ export class TaskOps {
87
90
  if (violations.length > 0) {
88
91
  throw new TaskOpError('unregistered_member', violations.join(' / '));
89
92
  }
93
+ // 波 VIII: related_docs は docsDirs / docsWriteDirs 配下の妥当な .md 相対パスのみ受け付ける
94
+ // (I3: app.ts の POST /api/tasks と同型の検証。HTTP 経路と非対称な許可リスト検証欠落を解消する)
95
+ if (parsed.data.related_docs !== undefined && parsed.data.related_docs.length > 0) {
96
+ try {
97
+ assertRelatedDocsAllowed(this.projectRoot, config, parsed.data.related_docs);
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof PathValidationError ? error.message : 'related_docs が不正です';
101
+ throw new TaskOpError('invalid_input', message);
102
+ }
103
+ }
90
104
  return this.enqueue(async () => {
91
105
  const MAX_RETRY = 5;
92
106
  for (let attempt = 0;; attempt++) {
@@ -139,7 +153,10 @@ export class TaskOps {
139
153
  return this.enqueue(async () => {
140
154
  let raw;
141
155
  try {
142
- raw = await fs.readFile(filePath, 'utf8');
156
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
157
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
158
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
159
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
143
160
  }
144
161
  catch {
145
162
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -205,7 +222,10 @@ export class TaskOps {
205
222
  return this.enqueue(async () => {
206
223
  let raw;
207
224
  try {
208
- raw = await fs.readFile(filePath, 'utf8');
225
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
226
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
227
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
228
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
209
229
  }
210
230
  catch {
211
231
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -274,7 +294,10 @@ export class TaskOps {
274
294
  return this.enqueue(async () => {
275
295
  let raw;
276
296
  try {
277
- raw = await fs.readFile(filePath, 'utf8');
297
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
298
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
299
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
300
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
278
301
  }
279
302
  catch {
280
303
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -100,6 +100,27 @@ export async function snapshotToTrash(id, currentContent, kanbanDir, kanbanReal,
100
100
  await pruneTrash(id, kanbanDir, kanbanReal, trashKeep);
101
101
  return written;
102
102
  }
103
+ /**
104
+ * 破壊的書込 (move/revise/append/overwrite 等) の直前に呼ぶ、ベストエフォートな退避ラッパー
105
+ * (LEARN#8b N=3・app.ts の snapshot クロージャ / TaskOps.snapshot / docs.ts writeDoc overwrite 分岐の
106
+ * 3 箇所で重複していたロジックを集約。過去に片側ハードコード drift が実際に起きた実績あり)。
107
+ *
108
+ * `snapshotToTrash` を呼び、失敗 (containment 違反・.trash 書込不能等) は try/catch で握り潰して
109
+ * 呼び出し側の本処理 (破壊的書込) を止めない。**kanbanReal / trashKeep は呼び出し側で解決済みの値を渡すこと**
110
+ * (このヘルパー自体は config 読込や realpath 解決の失敗を吸収しない。呼び出し側がそれらを行うタイミング・
111
+ * try/catch 範囲を変えないことで既存 3 箇所の挙動を一切変えずに委譲できる)。
112
+ *
113
+ * fail-closed な削除経路 (DELETE /api/tasks・docs.ts の deleteDoc) はこのヘルパーの対象外
114
+ * (退避失敗を呼び出し側へ伝搬させて処理を中止する必要があるため、直接 `snapshotToTrash` を呼ぶ)。
115
+ */
116
+ export async function snapshotBestEffort(id, content, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
117
+ try {
118
+ await snapshotToTrash(id, content, kanbanDir, kanbanReal, trashKeep);
119
+ }
120
+ catch {
121
+ // 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
122
+ }
123
+ }
103
124
  /**
104
125
  * 同 id の退避世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
105
126
  * trashKeep 以下なら何もしない。削除失敗 (残骸) はスキップして続行する (ベストエフォート)。
@@ -0,0 +1,39 @@
1
+ /**
2
+ * AI 稼働ヒートマップ (F-8) の派生ロジック。
3
+ * 全タスクの作業ログ行の時刻を「曜日 × 時間帯」で集計する純関数。
4
+ * read-only・新データ源・新依存・新 API はゼロ。server/web の双方が型を共有する。
5
+ *
6
+ * 作業ログ行の parse は shared/digest.ts の parseWorkLogLines を流用する (再実装しない)。
7
+ * タイムゾーン近似: 作業ログ時刻は offset 無し (ローカル解釈)。stats.ts R10-4 の近似を継承する。
8
+ * member 別の絞りは不要 (全体集計でよい)。
9
+ */
10
+ import { parseWorkLogLines } from './digest.js';
11
+ /**
12
+ * 全タスクの作業ログ行の時刻を「曜日 × 時間帯」で集計する純関数。
13
+ * - 空のグリッド (7×24 の全ゼロ) を初期値にし、各ログ行の `YYYY-MM-DD HH:mm` を
14
+ * Date.parse でパースして曜日 (getDay) と時間 (getHours) を取得する。
15
+ * - parse 不能な行は静かにスキップ (誤計上を避ける)。
16
+ * - タイムゾーン近似: 作業ログは offset 無し (= ローカル / Date 組込の解釈)。
17
+ * R10-4 の既知近似を継承しコメントで明示する (JST 運用なら実害小)。
18
+ */
19
+ export function deriveActivityHeatmap(tasks) {
20
+ // 7 行 (dow) × 24 列 (hour) の全ゼログリッド
21
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
22
+ let total = 0;
23
+ for (const task of tasks) {
24
+ for (const { date, time } of parseWorkLogLines(task.body)) {
25
+ // タイムゾーン近似 (R10-4 継承): `YYYY-MM-DDThh:mm` は offset 無しのためローカル解釈される。
26
+ // 非 JST ブラウザでは実際の入力時刻とズレる可能性があるが、JST 運用なら実害小。
27
+ const ms = Date.parse(`${date}T${time}`);
28
+ if (Number.isNaN(ms))
29
+ continue;
30
+ const d = new Date(ms);
31
+ const dow = d.getDay(); // 0=日曜 … 6=土曜
32
+ const hour = d.getHours(); // 0 … 23
33
+ grid[dow][hour]++;
34
+ total++;
35
+ }
36
+ }
37
+ const maxCount = grid.reduce((m, row) => Math.max(m, ...row), 0);
38
+ return { grid, maxCount, total };
39
+ }
@@ -1,3 +1,4 @@
1
+ import { setFrontmatterField } from './task-setters.js';
1
2
  /** 指定資料パスを related_docs に含むタスクを ID 昇順で返す (完全一致・正規化は呼び出し側責務) */
2
3
  export function tasksForDoc(tasks, docPath) {
3
4
  return tasks
@@ -5,15 +6,9 @@ export function tasksForDoc(tasks, docPath) {
5
6
  .map(({ id, title, status }) => ({ id, title, status }))
6
7
  .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
7
8
  }
8
- // frontmatter 境界: 先頭の `---` 行 (行末空白を許容し parseTaskFile の判定と揃える、R13-1)
9
- const FM_OPEN = /^---[ \t]*\r?\n/;
10
- // 閉じ `---` 行 (行末空白許容)
11
- const FM_CLOSE = /^---[ \t]*$/;
12
- // BOM は literal 禁止 (LEARN#13: invisible unicode は \uXXXX escape で書く)
13
- const BOM = '\uFEFF';
14
9
  /**
15
10
  * raw (frontmatter + 本文) の related_docs 行を docs で差し替えた raw を返す。
16
- * - 先頭 BOM は剥がして処理し、戻り値で復元する (parseTaskFile / applyPinpointPatch と同じ規約、R13-1)
11
+ * - 先頭 BOM は剥がして処理し、戻り値で復元する (R13-1)
17
12
  * - docs が空: related_docs 行 (flow 形式 1 行 or block 形式の key 行 + 続く `- ` 行) を除去
18
13
  * - docs が非空: JSON 文字列化した flow 形式 1 行 (`related_docs: ["a", "b"]`) に置換。
19
14
  * 既存行が無ければ `created:` 行の直前 (無ければ閉じ `---` の直前) に挿入する。
@@ -22,56 +17,7 @@ const BOM = '\uFEFF';
22
17
  * frontmatter が見つからない raw はそのまま返す (呼び出し側は「変化なし」を検出してエラー表示する)。
23
18
  */
24
19
  export function setRelatedDocsInRaw(raw, docs) {
25
- // BOM を剥がして処理し、最後に復元する (R13-1: BOM 付きタスクで silent no-op にしない)
26
- const hasBom = raw.startsWith(BOM);
27
- const body = hasBom ? raw.slice(1) : raw;
28
- const result = setRelatedDocsInner(body, docs);
29
- return hasBom ? BOM + result : result;
30
- }
31
- function setRelatedDocsInner(raw, docs) {
32
- if (!FM_OPEN.test(raw))
33
- return raw;
34
- const lines = raw.split('\n');
35
- // 閉じ `---` を探す (行 0 は開き `---`)
36
- let closeIdx = -1;
37
- for (let i = 1; i < lines.length; i++) {
38
- if (FM_CLOSE.test(lines[i].replace(/\r$/, ''))) {
39
- closeIdx = i;
40
- break;
41
- }
42
- }
43
- if (closeIdx === -1)
44
- return raw;
45
- // 既存 related_docs 行 (flow 1 行 or block 形式) を frontmatter 内から除去
46
- const kept = [];
47
- let i = 1;
48
- while (i < closeIdx) {
49
- const line = lines[i].replace(/\r$/, '');
50
- if (/^related_docs\s*:/.test(line)) {
51
- i++;
52
- // block 形式: 続く `- ` 行も取り除く。ゼロインデント (PyYAML 既定・valid YAML) も対象 (R13-4)。
53
- // 次のトップレベル key 行は `- ` で始まらないため、この条件で安全に打ち切れる
54
- while (i < closeIdx && /^\s*-\s/.test(lines[i].replace(/\r$/, '')))
55
- i++;
56
- continue;
57
- }
58
- kept.push(lines[i]);
59
- i++;
60
- }
61
- // 挿入する新しい行 (docs 非空のときのみ)。JSON.stringify は YAML double-quoted として valid
62
- const newLine = docs.length > 0 ? `related_docs: [${docs.map((d) => JSON.stringify(d)).join(', ')}]` : null;
63
- if (newLine !== null) {
64
- const createdIdx = kept.findIndex((l) => /^created\s*:/.test(l.replace(/\r$/, '')));
65
- if (createdIdx !== -1) {
66
- kept.splice(createdIdx, 0, newLine);
67
- }
68
- else {
69
- kept.push(newLine);
70
- }
71
- }
72
- const head = [lines[0], ...kept].join('\n');
73
- const tail = lines.slice(closeIdx).join('\n');
74
- // 改行コードは元 raw のものが保たれる (split('\n') で \r が各行末に残るため CRLF はそのまま。
75
- // 新規挿入行のみ LF になるが、混在しても YAML としては valid)
76
- return `${head}\n${tail}`;
20
+ // JSON.stringify flow 形式: YAML double-quoted として常に valid (特殊文字を含むパスも安全)
21
+ const serialized = docs.length > 0 ? `[${docs.map((d) => JSON.stringify(d)).join(', ')}]` : null;
22
+ return setFrontmatterField(raw, 'related_docs', serialized);
77
23
  }