aizuchi 0.4.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.
- package/CHANGELOG.md +139 -0
- package/README.ja.md +213 -0
- package/README.md +232 -170
- package/dist/bin/aizuchi.js +48 -11
- package/dist/server/app.js +139 -11
- package/dist/server/lib/docs.js +209 -2
- package/dist/server/lib/files.js +18 -4
- package/dist/server/lib/fs-safe.js +81 -21
- package/dist/server/lib/proposal.js +35 -4
- package/dist/server/lib/related-docs-validate.js +37 -0
- package/dist/server/lib/task-ops.js +31 -7
- package/dist/server/lib/trash.js +21 -0
- package/dist/shared/activity-heatmap.js +39 -0
- package/dist/shared/ai-health.js +78 -0
- package/dist/shared/related-docs.js +5 -59
- package/dist/shared/schema.js +27 -5
- package/dist/shared/stats.js +17 -8
- package/dist/shared/task-setters.js +144 -0
- package/dist/web/assets/index-CiZ-pdUZ.css +1 -0
- package/dist/web/assets/index-U2se0dzu.js +99 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -3
- package/templates/claude-rules.md +5 -0
- package/dist/web/assets/index-BYfu8Hdw.js +0 -93
- package/dist/web/assets/index-DiMXsTSP.css +0 -1
package/dist/server/app.js
CHANGED
|
@@ -10,7 +10,8 @@ import { loadArchive, loadBoard, loadConfig, loadTaskView } from './board.js';
|
|
|
10
10
|
import { createEventBus } from './events.js';
|
|
11
11
|
import { allocateNextId, createSerialQueue } from './lib/id.js';
|
|
12
12
|
import { assertDirInsideKanban, collectKanbanFilesNoSymlink, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
13
|
-
import { buildDocsTree, readDoc } from './lib/docs.js';
|
|
13
|
+
import { buildDocsTree, buildDocsWriteTree, deleteDoc, readDoc, TrashSnapshotError, writeDoc } from './lib/docs.js';
|
|
14
|
+
import { assertRelatedDocsAllowed } from './lib/related-docs-validate.js';
|
|
14
15
|
import { findCommitsForTask } from './lib/git.js';
|
|
15
16
|
import { buildCatalog, buildFileTree, buildGlobalCatalog, fileMtime, globalFileMtime, readConfigFile, readGlobalConfigFile, saveConfigFile, saveGlobalConfigFile, } from './lib/files.js';
|
|
16
17
|
import { applyPinpointPatch, renameTaskId, replaceDependsOnId, replaceMemberKeys, verifyMemberRename, verifyPinpointResult, verifyRename, } from './lib/pinpoint.js';
|
|
@@ -18,7 +19,7 @@ import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText }
|
|
|
18
19
|
import { REVISABLE_STATUSES } from './lib/task-ops.js';
|
|
19
20
|
import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './lib/task-file.js';
|
|
20
21
|
import { archiveMonth, nowIso, workLogTimestamp } from './lib/time.js';
|
|
21
|
-
import { DEFAULT_TRASH_KEEP, listTrash, snapshotToTrash } from './lib/trash.js';
|
|
22
|
+
import { DEFAULT_TRASH_KEEP, listTrash, snapshotBestEffort, snapshotToTrash } from './lib/trash.js';
|
|
22
23
|
/**
|
|
23
24
|
* Hono アプリ (REST API + SSE)。要件 §6.7 のうち Phase 1 スコープの
|
|
24
25
|
* board / tasks / events のみを実装する (アーカイブ・承認・却下 API は Phase 2)。
|
|
@@ -75,7 +76,7 @@ export function applySecurityMiddleware(app) {
|
|
|
75
76
|
// GET は読出専用 (CORS ヘッダ無しでクロスオリジン読出不可) なので対象外。
|
|
76
77
|
app.use('/api/*', async (c, next) => {
|
|
77
78
|
const method = c.req.method;
|
|
78
|
-
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
79
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') {
|
|
79
80
|
const contentType = c.req.header('content-type') ?? '';
|
|
80
81
|
// "application/json" / "application/json; charset=utf-8" を許可。それ以外は 415
|
|
81
82
|
const mediaType = contentType.split(';')[0].trim().toLowerCase();
|
|
@@ -167,14 +168,11 @@ export function createApp(options) {
|
|
|
167
168
|
* containment で再検証するためパス安全 (検証済み前提に依存しない)。
|
|
168
169
|
*/
|
|
169
170
|
const snapshot = async (id, currentContent, kanbanReal) => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
catch {
|
|
176
|
-
// 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
|
|
177
|
-
}
|
|
171
|
+
// M9 (LEARN#8b): 退避のベストエフォート握り潰し自体は snapshotBestEffort (trash.ts) に集約。
|
|
172
|
+
// loadConfig は失敗しない (常に既定値へフォールバック) ため try/catch の外に出しても挙動は不変。
|
|
173
|
+
const { config } = await loadConfig(projectRoot);
|
|
174
|
+
const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
|
|
175
|
+
await snapshotBestEffort(id, currentContent, kanbanDir, kanbanReal, keep);
|
|
178
176
|
};
|
|
179
177
|
// セキュリティミドルウェア (CSP / Host / CSRF / bodyLimit) は overview と共通 (R14-5)
|
|
180
178
|
applySecurityMiddleware(app);
|
|
@@ -223,6 +221,17 @@ export function createApp(options) {
|
|
|
223
221
|
if (violations.length > 0) {
|
|
224
222
|
return c.json({ error: violations.join(' / ') }, 400);
|
|
225
223
|
}
|
|
224
|
+
// 波 VIII: related_docs は docsDirs / docsWriteDirs 配下の妥当な .md 相対パスのみ受け付ける
|
|
225
|
+
// (frontmatter に保存して良い参照値かの検証。読取時の symlink/TOCTOU 防御は docs.ts が担保)
|
|
226
|
+
if (input.data.related_docs !== undefined && input.data.related_docs.length > 0) {
|
|
227
|
+
try {
|
|
228
|
+
assertRelatedDocsAllowed(projectRoot, config, input.data.related_docs);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
const message = error instanceof PathValidationError ? error.message : 'related_docs が不正です';
|
|
232
|
+
return c.json({ error: message }, 400);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
226
235
|
// 走査 → 採番 → 書込 をアトミックに直列化する (同時起票の ID 重複防止)。
|
|
227
236
|
// 書込は排他作成 (exclusive) にし、採番後の窓で AI が同 ID を直書きしていた場合は
|
|
228
237
|
// 上書きせず再採番 retry する (N-3: POST が外部データを silent 上書きするのを防ぐ)
|
|
@@ -239,6 +248,7 @@ export function createApp(options) {
|
|
|
239
248
|
created_by: input.data.created_by,
|
|
240
249
|
priority: input.data.priority ?? 'medium',
|
|
241
250
|
labels: input.data.labels ?? [],
|
|
251
|
+
related_docs: input.data.related_docs,
|
|
242
252
|
blocked_type: null,
|
|
243
253
|
blocked_reason: null,
|
|
244
254
|
created: now,
|
|
@@ -1120,6 +1130,124 @@ export function createApp(options) {
|
|
|
1120
1130
|
throw error;
|
|
1121
1131
|
}
|
|
1122
1132
|
});
|
|
1133
|
+
// GET /api/docs/write-tree — docsWriteDirs 配下の .md ツリー (保存先フォルダ選択 UI 用・symlink 非追従)
|
|
1134
|
+
app.get('/api/docs/write-tree', async (c) => {
|
|
1135
|
+
const { config } = await loadConfig(projectRoot);
|
|
1136
|
+
return c.json({ trees: await buildDocsWriteTree(projectRoot, config) });
|
|
1137
|
+
});
|
|
1138
|
+
// POST /api/docs — 資料を新規作成 (exclusive)。親フォルダ未作成なら作成する。既存は 409。
|
|
1139
|
+
app.post('/api/docs', async (c) => {
|
|
1140
|
+
const body = (await c.req.json().catch(() => null));
|
|
1141
|
+
if (body === null || typeof body.path !== 'string' || body.path === '' || typeof body.content !== 'string') {
|
|
1142
|
+
return c.json({ error: 'path (文字列) と content (文字列) が必要です' }, 400);
|
|
1143
|
+
}
|
|
1144
|
+
const relPath = body.path;
|
|
1145
|
+
const content = body.content;
|
|
1146
|
+
const { config } = await loadConfig(projectRoot);
|
|
1147
|
+
return enqueue(async () => {
|
|
1148
|
+
try {
|
|
1149
|
+
const result = await writeDoc(projectRoot, config, relPath, content, 'create');
|
|
1150
|
+
return c.json(result, 201);
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
if (error instanceof FileExistsError)
|
|
1154
|
+
return c.json({ error: '同じパスの資料が既に存在します' }, 409);
|
|
1155
|
+
if (error instanceof PathValidationError)
|
|
1156
|
+
return c.json({ error: error.message }, 403);
|
|
1157
|
+
throw error;
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
// PUT /api/docs?path=... — 資料を上書き保存 (mtime 厳密比較で競合検知)。範囲外 403 / drift 409。
|
|
1162
|
+
app.put('/api/docs', async (c) => {
|
|
1163
|
+
const relPath = c.req.query('path');
|
|
1164
|
+
if (relPath === undefined || relPath === '') {
|
|
1165
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
1166
|
+
}
|
|
1167
|
+
const body = (await c.req.json().catch(() => null));
|
|
1168
|
+
if (body === null || typeof body.content !== 'string' || typeof body.baseMtimeMs !== 'number') {
|
|
1169
|
+
return c.json({ error: 'content (文字列) と baseMtimeMs (数値) が必要です' }, 400);
|
|
1170
|
+
}
|
|
1171
|
+
const content = body.content;
|
|
1172
|
+
const baseMtimeMs = body.baseMtimeMs;
|
|
1173
|
+
const { config } = await loadConfig(projectRoot);
|
|
1174
|
+
return enqueue(async () => {
|
|
1175
|
+
try {
|
|
1176
|
+
const result = await writeDoc(projectRoot, config, relPath, content, 'overwrite', baseMtimeMs);
|
|
1177
|
+
if ('conflict' in result) {
|
|
1178
|
+
return c.json({ error: '外部で変更されています', serverContent: result.conflict.serverContent, serverMtimeMs: result.conflict.serverMtimeMs }, 409);
|
|
1179
|
+
}
|
|
1180
|
+
return c.json(result);
|
|
1181
|
+
}
|
|
1182
|
+
catch (error) {
|
|
1183
|
+
if (error instanceof PathValidationError)
|
|
1184
|
+
return c.json({ error: error.message }, 403);
|
|
1185
|
+
if (error.code === 'ENOENT')
|
|
1186
|
+
return c.json({ error: '資料が見つかりません' }, 404);
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
// DELETE /api/docs?path=... — 資料を削除 (unlink 前に .kanban/.trash へ退避)。不在 404 / 範囲外 403。
|
|
1192
|
+
app.delete('/api/docs', async (c) => {
|
|
1193
|
+
const relPath = c.req.query('path');
|
|
1194
|
+
if (relPath === undefined || relPath === '') {
|
|
1195
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
1196
|
+
}
|
|
1197
|
+
const { config } = await loadConfig(projectRoot);
|
|
1198
|
+
return enqueue(async () => {
|
|
1199
|
+
try {
|
|
1200
|
+
const result = await deleteDoc(projectRoot, config, relPath);
|
|
1201
|
+
return c.json({ path: result.path, deleted: true });
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
if (error instanceof PathValidationError)
|
|
1205
|
+
return c.json({ error: error.message }, 403);
|
|
1206
|
+
// fail-closed: 退避できなかったので削除は中止済み (本体は消えていない)。DELETE /api/tasks と対称に 500。
|
|
1207
|
+
if (error instanceof TrashSnapshotError)
|
|
1208
|
+
return c.json({ error: error.message }, 500);
|
|
1209
|
+
if (error.code === 'ENOENT')
|
|
1210
|
+
return c.json({ error: '資料が見つかりません' }, 404);
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
// ---------------------------------------------------------------------
|
|
1216
|
+
// DELETE /api/tasks/:id — タスク本体を削除 (unlink 前に .kanban/.trash へ退避)
|
|
1217
|
+
// archive (moveToArchive) とは別物: archive=復元前提の所在移動、delete=本体削除+ごみ箱退避。
|
|
1218
|
+
// N-2: taskIdSchema で検証 + readKanbanFile (containment + symlink 非追従)。
|
|
1219
|
+
// 存在しない ID は 404。CSRF ミドルウェアが DELETE を対象化済み (資料削除で追加済)。
|
|
1220
|
+
// ---------------------------------------------------------------------
|
|
1221
|
+
app.delete('/api/tasks/:id', async (c) => {
|
|
1222
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
1223
|
+
if (!idResult.success) {
|
|
1224
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
1225
|
+
}
|
|
1226
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
1227
|
+
return enqueue(async () => {
|
|
1228
|
+
const kanbanReal = await getKanbanReal();
|
|
1229
|
+
let raw;
|
|
1230
|
+
try {
|
|
1231
|
+
raw = await readKanbanFile(filePath, kanbanReal); // N-2: containment + symlink 非追従
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
1235
|
+
}
|
|
1236
|
+
// unlink 前に .trash へ退避。DELETE は他の破壊的書込と違い、退避が「唯一の生き残りコピー」のため
|
|
1237
|
+
// fail-closed にする: 退避に失敗したら 500 で中止し fs.rm はしない (静かな完全消失を防ぐ)。
|
|
1238
|
+
// 他経路の snapshot() はベストエフォート (本体が別の形で残る) だが、削除だけは退避成功を必須とする。
|
|
1239
|
+
try {
|
|
1240
|
+
const { config } = await loadConfig(projectRoot);
|
|
1241
|
+
await snapshotToTrash(idResult.data, raw, kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
return c.json({ error: 'ごみ箱への退避に失敗したため削除を中止しました' }, 500);
|
|
1245
|
+
}
|
|
1246
|
+
// 退避できたときだけ tasks/<id>.md を削除
|
|
1247
|
+
await fs.rm(filePath, { force: true });
|
|
1248
|
+
return c.json({ id: idResult.data, deleted: true });
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1123
1251
|
// ---------------------------------------------------------------------
|
|
1124
1252
|
// GET /api/tasks/:id/commits — タスク ID を含む git コミット (S-1・read-only)
|
|
1125
1253
|
// ID をパスから取得 → taskIdSchema で検証 → git.ts が更に厳格バリデーション + execFile (シェル非経由)。
|
package/dist/server/lib/docs.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, } from './fs-safe.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
|
+
}
|
|
4
26
|
/**
|
|
5
27
|
* 関連資料 (波 VIII) の読取専用ファイル I/O。
|
|
6
28
|
* config.docsDirs に列挙されたディレクトリ配下の .md のみを対象にする。
|
|
@@ -23,6 +45,15 @@ function effectiveDocsDirs(root, config) {
|
|
|
23
45
|
const kanbanDir = path.join(root, '.kanban');
|
|
24
46
|
return sanitizeConfigDirEntries(root, config.docsDirs).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
25
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* config.docsWriteDirs を root 配下の有効な書込先ディレクトリ集合 (絶対パス) に正規化する。
|
|
50
|
+
* effectiveDocsDirs (読取) と同一ロジック: root 自身 / root 外 / .kanban 配下 / 秘匿パスを除外する
|
|
51
|
+
* (読取と書込で許可リストを分離するため別関数。docsWriteDirs 既定は空 = 書込オフ)。
|
|
52
|
+
*/
|
|
53
|
+
function effectiveDocsWriteDirs(root, config) {
|
|
54
|
+
const kanbanDir = path.join(root, '.kanban');
|
|
55
|
+
return sanitizeConfigDirEntries(root, config.docsWriteDirs).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
56
|
+
}
|
|
26
57
|
/** 要求された相対パスが docsDirs 配下の .md であることを検証し、正規化済み絶対パスを返す */
|
|
27
58
|
export async function resolveDocPath(projectRoot, config, requestedRelPath) {
|
|
28
59
|
const root = path.resolve(projectRoot);
|
|
@@ -87,11 +118,12 @@ export async function readDoc(projectRoot, config, relPath) {
|
|
|
87
118
|
// R14-4: resolveDocPath の lstat (symlink 拒否) と readFile の間で対象が symlink に差し替わる
|
|
88
119
|
// TOCTOU 窓を O_NOFOLLOW で塞ぐ (対象が symlink なら open が ELOOP で失敗する)。
|
|
89
120
|
const content = await readFileNoFollow(absPath);
|
|
121
|
+
const mtimeMs = (await fs.lstat(absPath)).mtimeMs;
|
|
90
122
|
// absPath は realpath ベースの canonical のため、相対化も realpath 済み root を基準にする
|
|
91
123
|
// (macOS の /var → /private/var のような root 側 symlink で相対パスがずれないように)
|
|
92
124
|
const root = path.resolve(projectRoot);
|
|
93
125
|
const rootReal = await fs.realpath(root).catch(() => root);
|
|
94
|
-
return { path: path.relative(rootReal, absPath), content };
|
|
126
|
+
return { path: path.relative(rootReal, absPath), content, mtimeMs };
|
|
95
127
|
}
|
|
96
128
|
/**
|
|
97
129
|
* docsDirs 配下の .md ファイルツリーを構築する。buildFileTree (files.ts) と同じ手動再帰で
|
|
@@ -152,3 +184,178 @@ async function buildDocsNode(root, absPath) {
|
|
|
152
184
|
}
|
|
153
185
|
return null;
|
|
154
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* target の祖先のうち realpath できる最も近いものと、未作成 segment 列を返す
|
|
189
|
+
* (config-paths.ts の同名非 export 関数と同型。docs 書込でも親未作成を許すために複製・LEARN#8b N=2 で defer)。
|
|
190
|
+
* 未作成 segment は実体が無く symlink になり得ないため、containment 判定は実在祖先の realpath で足りる。
|
|
191
|
+
*/
|
|
192
|
+
async function resolveNearestExistingAncestor(target) {
|
|
193
|
+
const missing = [];
|
|
194
|
+
let cur = target;
|
|
195
|
+
for (;;) {
|
|
196
|
+
try {
|
|
197
|
+
return { real: await fs.realpath(cur), missing };
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code !== 'ENOENT')
|
|
201
|
+
throw error;
|
|
202
|
+
const parent = path.dirname(cur);
|
|
203
|
+
if (parent === cur)
|
|
204
|
+
throw new PathValidationError('親ディレクトリが解決できません');
|
|
205
|
+
missing.unshift(path.basename(cur));
|
|
206
|
+
cur = parent;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 書込要求の相対パスが docsWriteDirs 配下の .md であることを検証し、正規化済み canonical と rootReal を返す
|
|
212
|
+
* (resolveDocPath の write 版)。読取と異なり「親ディレクトリが未作成」を許す (新規フォルダ作成 + ファイル新規作成のため)。
|
|
213
|
+
* 多段検査: .md 拡張子 → root 配下 → docsWriteDirs 接頭辞 → isSecretPath → 実在祖先 realpath containment
|
|
214
|
+
* → docsWriteDirs realpath 再確認 → 対象 symlink 拒否。
|
|
215
|
+
*/
|
|
216
|
+
export async function resolveDocWritePath(projectRoot, config, requestedRelPath) {
|
|
217
|
+
const root = path.resolve(projectRoot);
|
|
218
|
+
if (!requestedRelPath.endsWith('.md')) {
|
|
219
|
+
throw new PathValidationError('資料は .md ファイルのみ書き込めます');
|
|
220
|
+
}
|
|
221
|
+
const resolved = path.resolve(root, requestedRelPath);
|
|
222
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
223
|
+
throw new PathValidationError('プロジェクトルート外への書込は許可されていません');
|
|
224
|
+
}
|
|
225
|
+
const writeDirs = effectiveDocsWriteDirs(root, config);
|
|
226
|
+
if (!writeDirs.some((dir) => isStrictlyInside(dir, resolved))) {
|
|
227
|
+
throw new PathValidationError('docsWriteDirs 外への書込は許可されていません');
|
|
228
|
+
}
|
|
229
|
+
if (isSecretPath(resolved)) {
|
|
230
|
+
throw new PathValidationError('秘匿ファイルへの書込は許可されていません');
|
|
231
|
+
}
|
|
232
|
+
// 親 (や中間 dir) が未作成なら「最も近い実在祖先」で containment を判定する (config-paths.ts と同方式)。
|
|
233
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
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
|
+
}
|
|
243
|
+
if (ancestorReal !== rootReal && !ancestorReal.startsWith(rootReal + path.sep)) {
|
|
244
|
+
throw new PathValidationError('symlink 経由でプロジェクトルート外を指しています');
|
|
245
|
+
}
|
|
246
|
+
const parentReal = missing.length > 0 ? path.join(ancestorReal, ...missing) : ancestorReal;
|
|
247
|
+
// docsWriteDirs realpath 解決後も配下か再確認する
|
|
248
|
+
const allowedReals = await Promise.all(writeDirs.map((dir) => fs.realpath(dir).catch(() => dir)));
|
|
249
|
+
const stillInside = allowedReals.some((dir) => parentReal === dir || parentReal.startsWith(dir + path.sep));
|
|
250
|
+
if (!stillInside) {
|
|
251
|
+
throw new PathValidationError('symlink 経由で docsWriteDirs 外を指しています');
|
|
252
|
+
}
|
|
253
|
+
const canonical = path.join(parentReal, path.basename(resolved));
|
|
254
|
+
if (isSecretPath(canonical)) {
|
|
255
|
+
throw new PathValidationError('秘匿ファイルへの書込は許可されていません');
|
|
256
|
+
}
|
|
257
|
+
// 対象が既存 symlink なら拒否 (symlink 越しの書込防止)。未存在は ENOENT で素通り (新規作成)。
|
|
258
|
+
try {
|
|
259
|
+
const lst = await fs.lstat(canonical);
|
|
260
|
+
if (lst.isSymbolicLink()) {
|
|
261
|
+
throw new PathValidationError('symlink への書込は許可されていません');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof PathValidationError)
|
|
266
|
+
throw error;
|
|
267
|
+
if (error.code !== 'ENOENT')
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
return { canonical, rootReal };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* canonical の親ディレクトリを作成する (新規フォルダ込みの保存用)。
|
|
274
|
+
* resolveDocWritePath が親の realpath containment を済ませた canonical を渡す前提なので、
|
|
275
|
+
* ここでは未作成 segment を recursive mkdir するだけ (containment 再検査は呼び出し前提条件)。
|
|
276
|
+
*/
|
|
277
|
+
async function ensureDir(canonical) {
|
|
278
|
+
await fs.mkdir(path.dirname(canonical), { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
export async function writeDoc(projectRoot, config, relPath, content, mode, baseMtimeMs) {
|
|
281
|
+
const { canonical, rootReal } = await resolveDocWritePath(projectRoot, config, relPath);
|
|
282
|
+
const writeDirs = effectiveDocsWriteDirs(path.resolve(projectRoot), config);
|
|
283
|
+
if (mode === 'overwrite') {
|
|
284
|
+
let serverContent = null;
|
|
285
|
+
let serverMtimeMs = null;
|
|
286
|
+
try {
|
|
287
|
+
serverContent = await readFileNoFollow(canonical);
|
|
288
|
+
serverMtimeMs = (await fs.lstat(canonical)).mtimeMs;
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error.code !== 'ENOENT')
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
if (serverMtimeMs === null) {
|
|
295
|
+
const err = new Error('資料が見つかりません');
|
|
296
|
+
err.code = 'ENOENT';
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
if (serverMtimeMs !== null && serverMtimeMs !== baseMtimeMs) {
|
|
300
|
+
return { conflict: { serverContent: serverContent ?? '', serverMtimeMs } };
|
|
301
|
+
}
|
|
302
|
+
// 上書き前に旧版を .kanban/.trash へ退避する (ベストエフォート安全網・deleteDoc と同方式)。
|
|
303
|
+
// M9 (LEARN#8b): 実際の退避握り潰しは snapshotBestEffort (trash.ts) に集約。resolveKanbanReal は
|
|
304
|
+
// throw しうるため従来どおり try/catch で包む。
|
|
305
|
+
try {
|
|
306
|
+
const kanbanDir = path.join(rootReal, '.kanban');
|
|
307
|
+
const kanbanReal = await resolveKanbanReal(path.resolve(projectRoot));
|
|
308
|
+
const trashId = docTrashId(canonical, rootReal);
|
|
309
|
+
await snapshotBestEffort(trashId, serverContent ?? '', kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// 退避できなくても上書きは進める (安全網のベストエフォート)
|
|
313
|
+
}
|
|
314
|
+
// I2: docsWriteDirs は一般ドキュメント用途のため DOC_BASENAME_RE (Unicode 許可) で basename を検証する
|
|
315
|
+
// (`.kanban/` システム生成用の ASCII 限定 SAFE_BASENAME_RE とは分離)。
|
|
316
|
+
await safeWriteFile(canonical, content, writeDirs, {}, DOC_BASENAME_RE);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
await ensureDir(canonical);
|
|
320
|
+
await safeWriteFile(canonical, content, writeDirs, { exclusive: true }, DOC_BASENAME_RE);
|
|
321
|
+
}
|
|
322
|
+
const mtimeMs = (await fs.lstat(canonical)).mtimeMs;
|
|
323
|
+
return { path: path.relative(rootReal, canonical), mtimeMs };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* docsWriteDirs 配下の資料を削除する。unlink の前に現内容を .kanban/.trash へコピー退避する (誤削除の安全網)。
|
|
327
|
+
* 削除は **fail-closed** (DELETE /api/tasks と対称): 退避が「唯一の生き残りコピー」になるため、退避に失敗したら
|
|
328
|
+
* TrashSnapshotError を投げて削除を中止する (呼び出し側で 500・静かな完全消失を防ぐ)。対象不在は ENOENT (404)。
|
|
329
|
+
*/
|
|
330
|
+
export async function deleteDoc(projectRoot, config, relPath) {
|
|
331
|
+
const { canonical, rootReal } = await resolveDocWritePath(projectRoot, config, relPath);
|
|
332
|
+
// 現内容を読む (不在なら ENOENT → 呼び出し側 404)。symlink は resolveDocWritePath が既に拒否済み。
|
|
333
|
+
const content = await readFileNoFollow(canonical);
|
|
334
|
+
// 退避に成功したときだけ削除する (退避失敗は TrashSnapshotError で中止)。
|
|
335
|
+
try {
|
|
336
|
+
const kanbanDir = path.join(rootReal, '.kanban');
|
|
337
|
+
const kanbanReal = await resolveKanbanReal(projectRoot);
|
|
338
|
+
const trashId = docTrashId(canonical, rootReal);
|
|
339
|
+
await snapshotToTrash(trashId, content, kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// .kanban 未作成 / .trash 退避不能等で退避できなかった場合は削除を中止する (fail-closed)
|
|
343
|
+
throw new TrashSnapshotError('ごみ箱への退避に失敗したため削除を中止しました');
|
|
344
|
+
}
|
|
345
|
+
await fs.rm(canonical, { force: false }); // 不在なら ENOENT (404)。symlink は事前拒否済み
|
|
346
|
+
return { path: path.relative(rootReal, canonical) };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* docsWriteDirs 配下の .md ファイルツリーを構築する (buildDocsTree の write 版・保存先選択 UI 用)。
|
|
350
|
+
* buildDocsNode を共有し symlink 非追従・秘匿除外・空 dir 除外は同一。
|
|
351
|
+
*/
|
|
352
|
+
export async function buildDocsWriteTree(projectRoot, config) {
|
|
353
|
+
const root = path.resolve(projectRoot);
|
|
354
|
+
const trees = [];
|
|
355
|
+
for (const absDir of effectiveDocsWriteDirs(root, config)) {
|
|
356
|
+
const node = await buildDocsNode(root, absDir);
|
|
357
|
+
if (node && node.type === 'dir')
|
|
358
|
+
trees.push(node);
|
|
359
|
+
}
|
|
360
|
+
return trees;
|
|
361
|
+
}
|
package/dist/server/lib/files.js
CHANGED
|
@@ -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 (!
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
/**
|