aizuchi 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.
- package/CHANGELOG.md +121 -0
- package/README.md +76 -17
- package/dist/bin/aizuchi.js +9 -1
- package/dist/server/app.js +314 -9
- package/dist/server/board.js +35 -5
- package/dist/server/lib/config-paths.js +60 -19
- package/dist/server/lib/files.js +56 -21
- package/dist/server/lib/proposal.js +10 -0
- package/dist/server/lib/task-ops.js +102 -2
- package/dist/server/lib/trash.js +122 -0
- package/dist/server/watcher.js +17 -4
- package/dist/shared/board-diff.js +48 -0
- package/dist/shared/diff.js +16 -0
- package/dist/shared/schema.js +82 -0
- package/dist/shared/stats.js +117 -1
- package/dist/web/assets/index-B4RPR1mO.css +1 -0
- package/dist/web/assets/index-DG0paXSh.js +93 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/templates/config.json +2 -1
- package/dist/web/assets/index-C15nwft7.js +0 -82
- package/dist/web/assets/index-qSD20HmA.css +0 -1
package/dist/server/app.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import { promises as fs, existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { Hono } from 'hono';
|
|
4
5
|
import { bodyLimit } from 'hono/body-limit';
|
|
5
6
|
import { HTTPException } from 'hono/http-exception';
|
|
6
7
|
import { streamSSE } from 'hono/streaming';
|
|
7
|
-
import { commentInputSchema, configSchema, fileSaveInputSchema, memberRenameInputSchema, renameInputSchema, findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, rejectInputSchema, saveInputSchema, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../shared/schema.js';
|
|
8
|
+
import { commentInputSchema, configSchema, fileSaveInputSchema, memberRenameInputSchema, renameInputSchema, findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, rejectInputSchema, restoreInputSchema, reviseInputSchema, saveInputSchema, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../shared/schema.js';
|
|
8
9
|
import { loadArchive, loadBoard, loadConfig, loadTaskView } from './board.js';
|
|
9
10
|
import { createEventBus } from './events.js';
|
|
10
11
|
import { allocateNextId, createSerialQueue } from './lib/id.js';
|
|
11
|
-
import { assertDirInsideKanban, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
12
|
+
import { assertDirInsideKanban, collectKanbanFilesNoSymlink, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
12
13
|
import { buildDocsTree, readDoc } from './lib/docs.js';
|
|
13
14
|
import { findCommitsForTask } from './lib/git.js';
|
|
14
|
-
import { buildCatalog, buildFileTree, fileMtime, readConfigFile, saveConfigFile } from './lib/files.js';
|
|
15
|
+
import { buildCatalog, buildFileTree, buildGlobalCatalog, fileMtime, globalFileMtime, readConfigFile, readGlobalConfigFile, saveConfigFile, saveGlobalConfigFile, } from './lib/files.js';
|
|
15
16
|
import { applyPinpointPatch, renameTaskId, replaceDependsOnId, replaceMemberKeys, verifyMemberRename, verifyPinpointResult, verifyRename, } from './lib/pinpoint.js';
|
|
16
|
-
import { appendCommentLine, appendWorkLogLine, replaceBody } from './lib/proposal.js';
|
|
17
|
+
import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './lib/proposal.js';
|
|
18
|
+
import { REVISABLE_STATUSES } from './lib/task-ops.js';
|
|
17
19
|
import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './lib/task-file.js';
|
|
18
20
|
import { archiveMonth, nowIso, workLogTimestamp } from './lib/time.js';
|
|
21
|
+
import { DEFAULT_TRASH_KEEP, listTrash, snapshotToTrash } from './lib/trash.js';
|
|
19
22
|
/**
|
|
20
23
|
* Hono アプリ (REST API + SSE)。要件 §6.7 のうち Phase 1 スコープの
|
|
21
24
|
* board / tasks / events のみを実装する (アーカイブ・承認・却下 API は Phase 2)。
|
|
@@ -125,6 +128,54 @@ export function createApp(options) {
|
|
|
125
128
|
await fs.rm(path.join(tasksDir, `${id}.md`), { force: true });
|
|
126
129
|
return path.relative(projectRoot, archivePath);
|
|
127
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* archive/**\/<id>.md を tasks/<id>.md へ復元する (moveToArchive の対称・unarchive)。
|
|
133
|
+
* 「復元先 (tasks/) を排他作成 → 成功後に archive 元を rm」順序でタスク消失を防ぐ (archive と対称)。
|
|
134
|
+
* archive 配下に id 一致が無ければ null を返す (呼び出し側で 404)。tasks/ に既存があれば
|
|
135
|
+
* safeWriteFile の exclusive が FileExistsError を投げる (呼び出し側で 409)。
|
|
136
|
+
*
|
|
137
|
+
* パス安全 (N-2): archive ルートにも resolveKanbanReal で .kanban の realpath 信頼境界を確立し、
|
|
138
|
+
* collectKanbanFilesNoSymlink (realpath containment + symlink 非追従) で探す。読出は readKanbanFile、
|
|
139
|
+
* 書込は safeWriteFile (許可リスト = .kanban) で、symlink / 中間 dir symlink 経由の外部読書を遮断する。
|
|
140
|
+
*/
|
|
141
|
+
const restoreFromArchive = async (id) => {
|
|
142
|
+
const kanbanReal = await getKanbanReal(); // .kanban 自体が symlink なら throw
|
|
143
|
+
const archiveDir = path.join(kanbanReal, 'archive');
|
|
144
|
+
// archive 配下を symlink 非追従 + realpath containment で走査し、id 一致 (<id>.md) を探す
|
|
145
|
+
const files = await collectKanbanFilesNoSymlink(archiveDir, kanbanReal);
|
|
146
|
+
const archivePath = files.find((abs) => path.basename(abs).slice(0, -3) === id);
|
|
147
|
+
if (archivePath === undefined)
|
|
148
|
+
return false; // 不在 → 404
|
|
149
|
+
const content = await readKanbanFile(archivePath, kanbanReal); // 中間 dir / 最終 symlink を遮断
|
|
150
|
+
const destPath = path.join(tasksDir, `${id}.md`);
|
|
151
|
+
// 復元先を排他作成 (既存があれば FileExistsError → 409)。成功後に archive 元を消す (消失防止)
|
|
152
|
+
await safeWriteFile(destPath, content, [kanbanDir], { exclusive: true });
|
|
153
|
+
await fs.rm(archivePath, { force: true });
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* 破壊的書込 (PATCH/PUT/approve/revise/rename/member rename/comment) の直前に、現内容を
|
|
158
|
+
* `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する安全網 (ごみ箱)。誤上書き / 誤操作からの復元用。
|
|
159
|
+
*
|
|
160
|
+
* - **ベストエフォート**: 退避失敗 (try/catch) は本処理を止めない。退避は元を消さない「コピー」なので、
|
|
161
|
+
* 退避が出来なくても従来どおり書込は進む (機能追加で既存挙動を壊さない)。
|
|
162
|
+
* - **comment / log 追記も退避する (SEC-N3・2026-06-16)**: append 系も実装は read→全文上書きのため、
|
|
163
|
+
* 外部編集と交錯して上書きで失われた場合に復元できるよう、他の破壊的書込と同様に直前退避する。
|
|
164
|
+
* - trashKeep は config 由来 (既定 DEFAULT_TRASH_KEEP)。世代刈り取りは snapshotToTrash 内で行う。
|
|
165
|
+
* - id は退避ファイル名 (basename) に使う。連鎖更新 (rename の readdir 由来 name.slice) や member rename
|
|
166
|
+
* 経由では taskIdSchema 未検証の値が渡るが、退避先 basename は safeWriteFile が SAFE_BASENAME_RE +
|
|
167
|
+
* containment で再検証するためパス安全 (検証済み前提に依存しない)。
|
|
168
|
+
*/
|
|
169
|
+
const snapshot = async (id, currentContent, kanbanReal) => {
|
|
170
|
+
try {
|
|
171
|
+
const { config } = await loadConfig(projectRoot);
|
|
172
|
+
const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
|
|
173
|
+
await snapshotToTrash(id, currentContent, kanbanDir, kanbanReal, keep);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
|
|
177
|
+
}
|
|
178
|
+
};
|
|
128
179
|
// セキュリティミドルウェア (CSP / Host / CSRF / bodyLimit) は overview と共通 (R14-5)
|
|
129
180
|
applySecurityMiddleware(app);
|
|
130
181
|
app.onError((error, c) => {
|
|
@@ -284,6 +335,7 @@ export function createApp(options) {
|
|
|
284
335
|
if (!check.success) {
|
|
285
336
|
return c.json({ error: 'ステータス変更後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
286
337
|
}
|
|
338
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
287
339
|
await safeWriteFile(filePath, patched, [kanbanDir]);
|
|
288
340
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
289
341
|
});
|
|
@@ -335,6 +387,7 @@ export function createApp(options) {
|
|
|
335
387
|
if (check.data.id !== idResult.data) {
|
|
336
388
|
return c.json({ error: `frontmatter の id (${check.data.id}) は変更できない` }, 400);
|
|
337
389
|
}
|
|
390
|
+
await snapshot(idResult.data, serverContent, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
338
391
|
await safeWriteFile(filePath, input.data.content, [kanbanDir]);
|
|
339
392
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
340
393
|
});
|
|
@@ -393,6 +446,7 @@ export function createApp(options) {
|
|
|
393
446
|
// モード違反検知 (board.ts) の対象から外れる
|
|
394
447
|
const logLine = `${workLogTimestamp()} [approve] 承認: proposed → todo`;
|
|
395
448
|
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
449
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
396
450
|
await safeWriteFile(filePath, finalRaw, [kanbanDir]);
|
|
397
451
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
398
452
|
});
|
|
@@ -489,6 +543,237 @@ export function createApp(options) {
|
|
|
489
543
|
});
|
|
490
544
|
});
|
|
491
545
|
// ---------------------------------------------------------------------
|
|
546
|
+
// POST /api/tasks/:id/unarchive — archive のタスクを board (tasks/) へ復元 (moveToArchive の対称)
|
|
547
|
+
// archive/**/<id>.md を探して tasks/<id>.md へ排他作成し、成功後に archive 元を rm する (消失防止)。
|
|
548
|
+
// 不在は 404 / 復元先 (tasks/) 衝突は 409。本文は変更せずそのまま戻す (所在のみ board へ移す)。
|
|
549
|
+
// ---------------------------------------------------------------------
|
|
550
|
+
app.post('/api/tasks/:id/unarchive', async (c) => {
|
|
551
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
552
|
+
if (!idResult.success) {
|
|
553
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
554
|
+
}
|
|
555
|
+
return enqueue(async () => {
|
|
556
|
+
try {
|
|
557
|
+
const found = await restoreFromArchive(idResult.data);
|
|
558
|
+
if (!found) {
|
|
559
|
+
return c.json({ error: `archive にタスクが見つからない: ${idResult.data}` }, 404);
|
|
560
|
+
}
|
|
561
|
+
// 復元 (ファイル移動) は完了済み。応答用の loadTaskView は壊れた frontmatter で throw しうるが、
|
|
562
|
+
// その場合でも復元自体は成功しているので 500 にせず軽量応答を返す (board は lenient parse で警告表示)。
|
|
563
|
+
try {
|
|
564
|
+
return c.json(await loadTaskView(projectRoot, path.join(tasksDir, `${idResult.data}.md`)));
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return c.json({ id: idResult.data, restored: true });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (error instanceof FileExistsError) {
|
|
572
|
+
return c.json({ error: `復元先に同じ ID が既に存在します: ${idResult.data}` }, 409);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
// ---------------------------------------------------------------------
|
|
579
|
+
// GET /api/trash — ごみ箱の退避一覧 (read-only)。破壊的書込の直前に .trash へコピー退避した旧版を
|
|
580
|
+
// 軽量メタ (id / trashedAt / title / status) + プレビュー用本文で返す。新しい退避が先頭。
|
|
581
|
+
// N-2: .trash も resolveKanbanReal + collectKanbanFilesNoSymlink + readKanbanFile (containment + symlink 非追従)。
|
|
582
|
+
// .kanban が symlink / .trash 不在は空一覧に倒す (graceful)。
|
|
583
|
+
// ---------------------------------------------------------------------
|
|
584
|
+
app.get('/api/trash', async (c) => {
|
|
585
|
+
let kanbanReal;
|
|
586
|
+
try {
|
|
587
|
+
kanbanReal = await getKanbanReal(); // .kanban 自体が symlink / 不在なら throw → 空一覧
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return c.json({ items: [] });
|
|
591
|
+
}
|
|
592
|
+
const entries = await listTrash(kanbanDir, kanbanReal);
|
|
593
|
+
// 新しい退避が先頭 (trashedAt 降順)。同 epoch は id で安定化
|
|
594
|
+
entries.sort((a, b) => b.trashedAt - a.trashedAt || a.id.localeCompare(b.id));
|
|
595
|
+
const items = [];
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
let raw;
|
|
598
|
+
try {
|
|
599
|
+
raw = await readKanbanFile(entry.absPath, kanbanReal); // containment + symlink 非追従
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
continue; // 走査と読出の間に消えた / symlink はスキップ
|
|
603
|
+
}
|
|
604
|
+
// frontmatter が parse できれば title / status を出す。壊れていても本文は出せる限り出す
|
|
605
|
+
let title = null;
|
|
606
|
+
let status = null;
|
|
607
|
+
let body = raw;
|
|
608
|
+
try {
|
|
609
|
+
const parsed = parseTaskFile(raw);
|
|
610
|
+
body = parsed.body;
|
|
611
|
+
const fm = taskFrontmatterSchema.safeParse(parsed.data);
|
|
612
|
+
if (fm.success) {
|
|
613
|
+
title = fm.data.title;
|
|
614
|
+
status = fm.data.status;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// parse 不能 (YAML 構文エラー等) は title/status=null・body=全文
|
|
619
|
+
}
|
|
620
|
+
items.push({ id: entry.id, trashedAt: entry.trashedAt, title, status, body });
|
|
621
|
+
}
|
|
622
|
+
return c.json({ items });
|
|
623
|
+
});
|
|
624
|
+
// ---------------------------------------------------------------------
|
|
625
|
+
// POST /api/tasks/:id/restore — ごみ箱の退避版を tasks/<id>.md へ書き戻す (誤上書きからの復元)。
|
|
626
|
+
// body.trashedAt 指定でその世代、省略時は当該 id の最新世代を復元する。
|
|
627
|
+
// tasks/<id>.md が既存のとき: overwrite=true なら現行を .trash へ退避してから上書き復元、
|
|
628
|
+
// overwrite 未指定 / false なら 409 (上書きしない・unarchive と対称)。退避が無ければ 404。
|
|
629
|
+
// N-2: .trash 走査 (collectKanbanFilesNoSymlink) + 読出 (readKanbanFile) + 書込 (safeWriteFile)。
|
|
630
|
+
// 復元は退避版を消さない (コピー・世代として残す。本処理が再び snapshot を積むのとも整合)。
|
|
631
|
+
// ---------------------------------------------------------------------
|
|
632
|
+
app.post('/api/tasks/:id/restore', async (c) => {
|
|
633
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
634
|
+
if (!idResult.success) {
|
|
635
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
636
|
+
}
|
|
637
|
+
const input = restoreInputSchema.safeParse(await c.req.json().catch(() => ({})));
|
|
638
|
+
if (!input.success) {
|
|
639
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
640
|
+
}
|
|
641
|
+
const id = idResult.data;
|
|
642
|
+
return enqueue(async () => {
|
|
643
|
+
const kanbanReal = await getKanbanReal(); // .kanban 自体が symlink なら throw → onError で 400
|
|
644
|
+
// 当該 id の退避世代を集め、指定 (trashedAt) or 最新 (epoch 最大) を選ぶ
|
|
645
|
+
const generations = (await listTrash(kanbanDir, kanbanReal, id)).sort((a, b) => b.trashedAt - a.trashedAt);
|
|
646
|
+
const target = input.data.trashedAt !== undefined
|
|
647
|
+
? generations.find((g) => g.trashedAt === input.data.trashedAt)
|
|
648
|
+
: generations[0];
|
|
649
|
+
if (target === undefined) {
|
|
650
|
+
return c.json({ error: `ごみ箱に退避が見つからない: ${id}` }, 404);
|
|
651
|
+
}
|
|
652
|
+
let content;
|
|
653
|
+
try {
|
|
654
|
+
content = await readKanbanFile(target.absPath, kanbanReal); // containment + symlink 非追従
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return c.json({ error: `退避を読み込めない: ${id}` }, 404);
|
|
658
|
+
}
|
|
659
|
+
const destPath = path.join(tasksDir, `${id}.md`);
|
|
660
|
+
if (input.data.overwrite === true) {
|
|
661
|
+
// 上書き復元: 現行があれば破壊的書込なので、まず現行を .trash へ退避してから非排他で上書き。
|
|
662
|
+
// 退避版は消さず世代として残す (コピー復元) + 現行も新たな世代として退避され取り戻せる。
|
|
663
|
+
let current = null;
|
|
664
|
+
try {
|
|
665
|
+
current = await readKanbanFile(destPath, kanbanReal);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
current = null; // 現行が無い / 読めない (壊れ) は退避不要でそのまま上書き復元
|
|
669
|
+
}
|
|
670
|
+
if (current !== null) {
|
|
671
|
+
await snapshot(id, current, kanbanReal); // 現行を .trash へ退避 (ベストエフォート)
|
|
672
|
+
}
|
|
673
|
+
await safeWriteFile(destPath, content, [kanbanDir]);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
try {
|
|
677
|
+
// 復元先を排他作成 (既存があれば 409)。退避版は消さず世代として残す (コピー復元)
|
|
678
|
+
await safeWriteFile(destPath, content, [kanbanDir], { exclusive: true });
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (error instanceof FileExistsError) {
|
|
682
|
+
return c.json({ error: `復元先に同じ ID が既に存在します: ${id}` }, 409);
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// 復元自体は完了済み。応答用 loadTaskView は壊れた frontmatter で throw しうるので軽量応答に倒す
|
|
688
|
+
try {
|
|
689
|
+
return c.json(await loadTaskView(projectRoot, destPath));
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return c.json({ id, restored: true });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
// ---------------------------------------------------------------------
|
|
697
|
+
// POST /api/tasks/:id/revise — やり直し依頼 (差し戻し)。todo/in_progress/pending/done → todo へ戻し、
|
|
698
|
+
// 理由を作業ログに残す。pending から戻す場合は blocked_* を null へ (reject の blocked クリア相当)。
|
|
699
|
+
// 既に todo の場合も理由ログだけは追記する (status 変更なしでも差し戻し意図を残せる)。
|
|
700
|
+
// proposed / backlog は対象外 (approve 承認フロー迂回防止のため 409。REVISABLE_STATUSES 参照)。
|
|
701
|
+
// ---------------------------------------------------------------------
|
|
702
|
+
app.post('/api/tasks/:id/revise', async (c) => {
|
|
703
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
704
|
+
if (!idResult.success) {
|
|
705
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
706
|
+
}
|
|
707
|
+
const input = reviseInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
708
|
+
if (!input.success) {
|
|
709
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
710
|
+
}
|
|
711
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
712
|
+
return enqueue(async () => {
|
|
713
|
+
let stat;
|
|
714
|
+
let raw;
|
|
715
|
+
try {
|
|
716
|
+
// R17-D: realpath containment + symlink 非追従で stat/read する (symlink/中間dirは 404)。
|
|
717
|
+
// mtime 競合検知のため stat も取得する (PUT /api/tasks と同方式)
|
|
718
|
+
const kanbanReal = await getKanbanReal();
|
|
719
|
+
stat = await statKanbanFile(filePath, kanbanReal);
|
|
720
|
+
raw = await readKanbanFile(filePath, kanbanReal);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
724
|
+
}
|
|
725
|
+
// baseMtimeMs があれば mtime 厳密比較 (N-3、PATCH/PUT と同じく外部変更を 409 で弾く)
|
|
726
|
+
if (input.data.baseMtimeMs !== undefined && stat.mtimeMs !== input.data.baseMtimeMs) {
|
|
727
|
+
return c.json({ error: '外部で変更されています', serverContent: raw, serverMtimeMs: stat.mtimeMs }, 409);
|
|
728
|
+
}
|
|
729
|
+
let current;
|
|
730
|
+
try {
|
|
731
|
+
current = parseTaskFile(raw);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
735
|
+
return c.json({ error: `差し戻しできない (${message})` }, 409);
|
|
736
|
+
}
|
|
737
|
+
// 源 status ガード: 差し戻しは「進行中・保留・完了・既に todo」のタスクのみを対象にする。
|
|
738
|
+
// proposed / backlog から todo へ直接行けると approve 承認フロー (proposed→todo) を迂回し、
|
|
739
|
+
// 恒久 mode_violation を生む。approve/reject が status==='proposed' を必須化するのと対称。
|
|
740
|
+
if (!REVISABLE_STATUSES.has(current.data.status)) {
|
|
741
|
+
return c.json({ error: 'この状態のタスクは差し戻せません' }, 409);
|
|
742
|
+
}
|
|
743
|
+
// status を todo へ行置換 (本文・他フィールドは保全)。pending から戻す場合は
|
|
744
|
+
// blocked_* も null へ戻す (reject ハンドラの blocked クリア相当)。verify-after-patch を通す
|
|
745
|
+
const patch = { status: 'todo', updated: nowIso() };
|
|
746
|
+
if (current.data.blocked_type != null || current.data.blocked_reason != null) {
|
|
747
|
+
patch.blocked_type = null;
|
|
748
|
+
patch.blocked_reason = null;
|
|
749
|
+
}
|
|
750
|
+
let patched;
|
|
751
|
+
let patchedParsed;
|
|
752
|
+
try {
|
|
753
|
+
patched = applyPinpointPatch(raw, patch);
|
|
754
|
+
patchedParsed = parseTaskFile(patched);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
const message = error instanceof TaskFileParseError ? error.message : error instanceof Error ? error.message : '差し戻しに失敗';
|
|
758
|
+
return c.json({ error: `差し戻しできない (${message})` }, 409);
|
|
759
|
+
}
|
|
760
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
761
|
+
if (drift !== null) {
|
|
762
|
+
return c.json({ error: `差し戻しできない (${drift})` }, 409);
|
|
763
|
+
}
|
|
764
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
765
|
+
if (!check.success) {
|
|
766
|
+
return c.json({ error: '差し戻し後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
767
|
+
}
|
|
768
|
+
// 差し戻し理由を作業ログに残す (frontmatter は pinpoint 済みなので body だけ更新する。approve と同型)
|
|
769
|
+
const logLine = `${workLogTimestamp()} [human] ${reviseWorkLogText(input.data.reason)}`;
|
|
770
|
+
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
771
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
772
|
+
await safeWriteFile(filePath, finalRaw, [kanbanDir]);
|
|
773
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
// ---------------------------------------------------------------------
|
|
492
777
|
// POST /api/tasks/:id/rename — 重複 ID を空き番号へリネーム (F-6-5)
|
|
493
778
|
// ---------------------------------------------------------------------
|
|
494
779
|
app.post('/api/tasks/:id/rename', async (c) => {
|
|
@@ -571,6 +856,9 @@ export function createApp(options) {
|
|
|
571
856
|
}
|
|
572
857
|
const updated = replaceDependsOnId(content, oldId, newId);
|
|
573
858
|
if (updated !== content) {
|
|
859
|
+
// 連鎖更新は他タスクの depends_on を破壊的に書き換えるので、その旧版も退避する
|
|
860
|
+
// (旧 id のリネーム自体は newPath に同内容が残るため退避不要・audit 参照)
|
|
861
|
+
await snapshot(name.slice(0, -3), content, chainKanbanReal);
|
|
574
862
|
await safeWriteFile(p, updated, [kanbanDir]);
|
|
575
863
|
}
|
|
576
864
|
}
|
|
@@ -644,6 +932,7 @@ export function createApp(options) {
|
|
|
644
932
|
// 書込失敗 (fs エラー等) も全体を中断させず skipped に積んで続行する —
|
|
645
933
|
// 途中失敗で renamed/skipped の進捗が失われると部分適用の把握ができなくなる
|
|
646
934
|
try {
|
|
935
|
+
await snapshot(name.slice(0, -3), content, kanbanReal); // 旧版 (created_by/assignee 改変前) を .trash へ退避
|
|
647
936
|
await safeWriteFile(p, patched, [kanbanDir]);
|
|
648
937
|
renamed += 1;
|
|
649
938
|
}
|
|
@@ -747,8 +1036,12 @@ export function createApp(options) {
|
|
|
747
1036
|
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
748
1037
|
}
|
|
749
1038
|
const { config } = await loadConfig(projectRoot);
|
|
1039
|
+
const isGlobal = c.req.query('scope') === 'global';
|
|
750
1040
|
try {
|
|
751
|
-
|
|
1041
|
+
const result = isGlobal
|
|
1042
|
+
? await readGlobalConfigFile(os.homedir(), relPath)
|
|
1043
|
+
: await readConfigFile(projectRoot, config, relPath);
|
|
1044
|
+
return c.json(result);
|
|
752
1045
|
}
|
|
753
1046
|
catch (error) {
|
|
754
1047
|
if (error instanceof PathValidationError)
|
|
@@ -765,15 +1058,20 @@ export function createApp(options) {
|
|
|
765
1058
|
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
766
1059
|
}
|
|
767
1060
|
const { config } = await loadConfig(projectRoot);
|
|
1061
|
+
const isGlobal = input.data.scope === 'global';
|
|
768
1062
|
// 直列化キューで mtime 検査 → 保存 (プロセス内 write-write 競合を閉じる)
|
|
769
1063
|
return enqueue(async () => {
|
|
770
1064
|
try {
|
|
771
|
-
const current =
|
|
1065
|
+
const current = isGlobal
|
|
1066
|
+
? await globalFileMtime(os.homedir(), input.data.path)
|
|
1067
|
+
: await fileMtime(projectRoot, config, input.data.path);
|
|
772
1068
|
// 既存ファイルの場合のみ mtime 厳密比較 (新規作成は current=null で素通り)
|
|
773
1069
|
if (current !== null && current !== input.data.baseMtimeMs) {
|
|
774
1070
|
return c.json({ error: '外部で変更されています', serverMtimeMs: current }, 409);
|
|
775
1071
|
}
|
|
776
|
-
const saved =
|
|
1072
|
+
const saved = isGlobal
|
|
1073
|
+
? await saveGlobalConfigFile(os.homedir(), input.data.path, input.data.content)
|
|
1074
|
+
: await saveConfigFile(projectRoot, config, input.data.path, input.data.content);
|
|
777
1075
|
return c.json(saved);
|
|
778
1076
|
}
|
|
779
1077
|
catch (error) {
|
|
@@ -788,8 +1086,11 @@ export function createApp(options) {
|
|
|
788
1086
|
const { config } = await loadConfig(projectRoot);
|
|
789
1087
|
return c.json({ trees: await buildFileTree(projectRoot, config) });
|
|
790
1088
|
});
|
|
791
|
-
// GET /api/files/catalog — skill / agent の frontmatter カタログ (F-4-4)
|
|
1089
|
+
// GET /api/files/catalog — skill / agent の frontmatter カタログ (F-4-4)。scope=global で ~/.claude/skills,agents
|
|
792
1090
|
app.get('/api/files/catalog', async (c) => {
|
|
1091
|
+
if (c.req.query('scope') === 'global') {
|
|
1092
|
+
return c.json({ items: await buildGlobalCatalog(os.homedir()) });
|
|
1093
|
+
}
|
|
793
1094
|
const { config } = await loadConfig(projectRoot);
|
|
794
1095
|
return c.json({ items: await buildCatalog(projectRoot, config) });
|
|
795
1096
|
});
|
|
@@ -851,9 +1152,10 @@ export function createApp(options) {
|
|
|
851
1152
|
}
|
|
852
1153
|
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
853
1154
|
return enqueue(async () => {
|
|
1155
|
+
const kanbanReal = await getKanbanReal();
|
|
854
1156
|
let raw;
|
|
855
1157
|
try {
|
|
856
|
-
raw = await readKanbanFile(filePath,
|
|
1158
|
+
raw = await readKanbanFile(filePath, kanbanReal); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
857
1159
|
}
|
|
858
1160
|
catch {
|
|
859
1161
|
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
@@ -869,6 +1171,9 @@ export function createApp(options) {
|
|
|
869
1171
|
// 本文の ## やりとり セクションに `- 時刻 [member] msg` を追記する (frontmatter はバイト保全)
|
|
870
1172
|
const line = `${workLogTimestamp()} [${input.data.member}] ${input.data.message}`;
|
|
871
1173
|
const newBody = appendCommentLine(current.body, line);
|
|
1174
|
+
// SEC-N3: コメント追記も read→全文上書きのため、上書き直前に現内容を .trash へ退避する
|
|
1175
|
+
// (他の破壊的書込経路と同様)。外部編集と交錯して失われても復元できるようにする (ベストエフォート)。
|
|
1176
|
+
await snapshot(idResult.data, raw, kanbanReal);
|
|
872
1177
|
await safeWriteFile(filePath, replaceBody(raw, newBody), [kanbanDir]);
|
|
873
1178
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
874
1179
|
});
|
package/dist/server/board.js
CHANGED
|
@@ -63,10 +63,19 @@ async function collectArchiveIds(kanbanDir, kanbanReal) {
|
|
|
63
63
|
}
|
|
64
64
|
return ids;
|
|
65
65
|
}
|
|
66
|
+
/** archive/<...>/<id>.md の絶対パス + kanbanReal から所在月 <YYYY-MM> を抽出する (無ければ null) */
|
|
67
|
+
function extractArchiveMonth(abs, kanbanReal) {
|
|
68
|
+
const archiveRoot = path.join(kanbanReal, 'archive');
|
|
69
|
+
const rel = path.relative(archiveRoot, abs); // 例: 2026-06/TASK-001.md
|
|
70
|
+
const first = rel.split(path.sep)[0];
|
|
71
|
+
return /^\d{4}-\d{2}$/.test(first) ? first : null;
|
|
72
|
+
}
|
|
66
73
|
/**
|
|
67
|
-
* archive/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* archive/** の全タスクを読む。`id` / `body` は常に返し (後方互換)、frontmatter が parse でき検証を
|
|
75
|
+
* 通れば表示用フィールド (title / status / assignee / labels / updated) と所在月 month を追加する。
|
|
76
|
+
* frontmatter が壊れていても本文は取り出せる限り返す (id + body + month のみ・title 等は欠落許容)。
|
|
77
|
+
* 本文も parse できない (YAML 構文エラー等) ファイルや symlink はスキップする。
|
|
78
|
+
* 検証はせず所在で表現する点は従来通り (アーカイブは様々な status がありうる)。
|
|
70
79
|
*/
|
|
71
80
|
export async function loadArchive(projectRoot) {
|
|
72
81
|
// R17-D: .kanban の realpath 信頼境界を確立 (.kanban 自体が symlink / 不在なら [] に倒す)
|
|
@@ -84,12 +93,33 @@ export async function loadArchive(projectRoot) {
|
|
|
84
93
|
const files = await collectKanbanFilesNoSymlink(archiveDir, kanbanReal);
|
|
85
94
|
const result = [];
|
|
86
95
|
for (const abs of files) {
|
|
96
|
+
let parsed;
|
|
87
97
|
try {
|
|
88
98
|
const raw = await readKanbanFile(abs, kanbanReal);
|
|
89
|
-
|
|
99
|
+
parsed = parseTaskFile(raw);
|
|
90
100
|
}
|
|
91
101
|
catch {
|
|
92
|
-
//
|
|
102
|
+
// 本文すら取り出せない (YAML 構文エラー等) / symlink はスキップ
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const id = path.basename(abs).slice(0, -3);
|
|
106
|
+
const month = extractArchiveMonth(abs, kanbanReal);
|
|
107
|
+
// frontmatter が検証を通れば表示用フィールドを付ける。通らなければ id + body のみ (壊れタスク許容)
|
|
108
|
+
const fm = taskFrontmatterSchema.safeParse(parsed.data);
|
|
109
|
+
if (fm.success) {
|
|
110
|
+
result.push({
|
|
111
|
+
id,
|
|
112
|
+
body: parsed.body,
|
|
113
|
+
month,
|
|
114
|
+
title: fm.data.title,
|
|
115
|
+
status: fm.data.status,
|
|
116
|
+
assignee: fm.data.assignee,
|
|
117
|
+
labels: fm.data.labels,
|
|
118
|
+
updated: fm.data.updated,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
result.push({ id, body: parsed.body, month });
|
|
93
123
|
}
|
|
94
124
|
}
|
|
95
125
|
return result;
|
|
@@ -1,19 +1,49 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { hasSafeConfigExt, isHardDeniedConfigPath, isSecretPath, isStrictlyInside, PathValidationError, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/**
|
|
5
|
+
* target の祖先のうち realpath できる最も近いものと、そこからの未作成 segment 列を返す。
|
|
6
|
+
* 未作成 segment は実体が無く symlink になり得ないため、containment 判定は実在祖先の realpath だけで足りる。
|
|
7
|
+
* これにより「許可リスト内だが親ディレクトリが未作成」のパスを、403 (PathValidationError) ではなく
|
|
8
|
+
* 呼び出し側の stat ENOENT → 404 として扱える (symlink トラバーサルは実在祖先の realpath で従来どおり検知)。
|
|
9
|
+
*/
|
|
10
|
+
async function resolveNearestExistingAncestor(target) {
|
|
11
|
+
const missing = [];
|
|
12
|
+
let cur = target;
|
|
13
|
+
for (;;) {
|
|
14
|
+
try {
|
|
15
|
+
return { real: await fs.realpath(cur), missing };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code !== 'ENOENT')
|
|
19
|
+
throw error;
|
|
20
|
+
const parent = path.dirname(cur);
|
|
21
|
+
if (parent === cur)
|
|
22
|
+
throw new PathValidationError('親ディレクトリが解決できません');
|
|
23
|
+
missing.unshift(path.basename(cur));
|
|
24
|
+
cur = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** 既定のグローバル browseDirs (~ からの相対)。グローバル skill/agent の閲覧・編集対象 (N-2 をホーム配下へ限定拡張) */
|
|
29
|
+
export const GLOBAL_BROWSE_DIRS = ['.claude/skills', '.claude/agents'];
|
|
30
|
+
/**
|
|
31
|
+
* root を基準に「完全一致ファイル + 接頭辞一致 browseDirs」の許可リスト検証を行う共通コア (N-2 の本丸)。
|
|
32
|
+
* プロジェクト (resolveConfigPath) とグローバル (resolveGlobalConfigPath) が同一のハードン済みロジックを共有する。
|
|
33
|
+
* 正規化 → root 配下か → 許可リスト該当か → deny-list (2 層) → 親 realpath containment → 対象 symlink 拒否。
|
|
34
|
+
*/
|
|
35
|
+
async function resolveAllowlistedPath(rootInput, exactFilesRel, browseDirsRel, requestedRelPath) {
|
|
36
|
+
const root = path.resolve(rootInput);
|
|
37
|
+
// 要求パスを正規化し、root 配下であることを確認する (絶対パス / ../ を弾く)
|
|
8
38
|
const resolved = path.resolve(root, requestedRelPath);
|
|
9
39
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
10
|
-
throw new PathValidationError('
|
|
40
|
+
throw new PathValidationError('許可範囲外へのアクセスは許可されていません');
|
|
11
41
|
}
|
|
12
|
-
//
|
|
42
|
+
// 許可リストを正規化する。各エントリが root の真の配下であることを強制し
|
|
13
43
|
// (`.` / `..` / 絶対パスで root 全域を許可リストに載せる攻撃を無効化)。deny-list はここでは効かせず、
|
|
14
44
|
// 解決後パスに対して下のゲートで 2 層適用する (R14-1 再修正)。
|
|
15
|
-
const exactFiles = new Set(sanitizeConfigDirEntries(root,
|
|
16
|
-
const browseDirs = sanitizeConfigDirEntries(root,
|
|
45
|
+
const exactFiles = new Set(sanitizeConfigDirEntries(root, exactFilesRel));
|
|
46
|
+
const browseDirs = sanitizeConfigDirEntries(root, browseDirsRel);
|
|
17
47
|
let kind = null;
|
|
18
48
|
if (exactFiles.has(resolved)) {
|
|
19
49
|
kind = 'file';
|
|
@@ -22,24 +52,23 @@ export async function resolveConfigPath(projectRoot, config, requestedRelPath) {
|
|
|
22
52
|
kind = 'dir';
|
|
23
53
|
}
|
|
24
54
|
if (kind === null) {
|
|
25
|
-
throw new PathValidationError('
|
|
55
|
+
throw new PathValidationError('許可リスト外へのアクセスは許可されていません');
|
|
26
56
|
}
|
|
27
57
|
// deny-list を解決後パス (lexical) に適用する (R14-1 再修正・残存脆弱性 #2/#4/#7)。
|
|
28
58
|
// 強い deny (.git/** + 鍵類) は **完全一致 (exactFiles) でも** 遮断する (悪意 config が settingsFiles に
|
|
29
59
|
// `.git/hooks/pre-commit` / `.env` / `id_rsa` を列挙しても read/write させない = RCE/N-8 防止)。
|
|
30
60
|
assertNotDenied(resolved, kind);
|
|
31
|
-
// 親ディレクトリの realpath を解決し、symlink
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new PathValidationError('親ディレクトリが存在しません');
|
|
38
|
-
}
|
|
61
|
+
// 親ディレクトリの realpath を解決し、symlink 経由で許可範囲の外へ出ていないか確認する。
|
|
62
|
+
// 親 (や中間) が未作成なら「最も近い実在祖先」で containment を判定する: 実在しない segment は
|
|
63
|
+
// symlink になり得ないので安全で、かつ未作成ファイルを 403 でなく 404 (呼び出し側の stat ENOENT) に
|
|
64
|
+
// できる (許可リスト内なのに親未作成だけで 403 を返していた不整合の解消)。
|
|
39
65
|
const rootReal = await fs.realpath(root).catch(() => root);
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
const { real: ancestorReal, missing } = await resolveNearestExistingAncestor(path.dirname(resolved));
|
|
67
|
+
if (ancestorReal !== rootReal && !ancestorReal.startsWith(rootReal + path.sep)) {
|
|
68
|
+
throw new PathValidationError('symlink 経由で許可範囲外を指しています');
|
|
42
69
|
}
|
|
70
|
+
// 親 canonical = 実在祖先 realpath + 未作成 segment (未作成部分は symlink 不可なので lexical 連結で安全)
|
|
71
|
+
const parentReal = missing.length > 0 ? path.join(ancestorReal, ...missing) : ancestorReal;
|
|
43
72
|
// 接頭辞一致 (browseDirs) の場合、realpath 解決後も許可ディレクトリ配下か再確認する
|
|
44
73
|
if (kind === 'dir') {
|
|
45
74
|
const allowedReals = await Promise.all(browseDirs.map((dir) => fs.realpath(dir).catch(() => dir)));
|
|
@@ -68,6 +97,18 @@ export async function resolveConfigPath(projectRoot, config, requestedRelPath) {
|
|
|
68
97
|
}
|
|
69
98
|
return { absPath: canonical, kind };
|
|
70
99
|
}
|
|
100
|
+
/** プロジェクトの aiConfigPaths 許可リスト検証 (N-2)。完全一致 = settingsFiles/rootFiles、接頭辞 = browseDirs */
|
|
101
|
+
export async function resolveConfigPath(projectRoot, config, requestedRelPath) {
|
|
102
|
+
return resolveAllowlistedPath(projectRoot, [...config.aiConfigPaths.settingsFiles, ...config.aiConfigPaths.rootFiles], config.aiConfigPaths.browseDirs, requestedRelPath);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* グローバル (~/.claude) の skill/agent 許可リスト検証。プロジェクトと同一コアを root=home で再利用する。
|
|
106
|
+
* 対象は GLOBAL_BROWSE_DIRS (~/.claude/skills, ~/.claude/agents) の配下のみ。deny-list / 拡張子許可 / symlink 拒否は同一。
|
|
107
|
+
* requestedRelPath は home からの相対 (例: `.claude/skills/foo.md`)。
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveGlobalConfigPath(homeDir, requestedRelPath) {
|
|
110
|
+
return resolveAllowlistedPath(homeDir, [], [...GLOBAL_BROWSE_DIRS], requestedRelPath);
|
|
111
|
+
}
|
|
71
112
|
/**
|
|
72
113
|
* 解決後パスを allow-list (主ゲート) + deny-list (defense-in-depth) で検査する (R17-E + R14-1 再修正)。
|
|
73
114
|
* - **allow-list (R17-E・#E-1)**: 拡張子が SAFE_CONFIG_EXT に無ければ完全一致 / 接頭辞一致を問わず拒否。
|