aizuchi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/bin/aizuchi.js +546 -0
- package/dist/server/app.js +817 -0
- package/dist/server/board.js +248 -0
- package/dist/server/events.js +24 -0
- package/dist/server/lib/config-paths.js +91 -0
- package/dist/server/lib/docs.js +154 -0
- package/dist/server/lib/files.js +208 -0
- package/dist/server/lib/fs-safe.js +428 -0
- package/dist/server/lib/git.js +100 -0
- package/dist/server/lib/id.js +54 -0
- package/dist/server/lib/pinpoint.js +225 -0
- package/dist/server/lib/proposal.js +93 -0
- package/dist/server/lib/task-file.js +132 -0
- package/dist/server/lib/task-ops.js +206 -0
- package/dist/server/lib/time.js +24 -0
- package/dist/server/overview.js +119 -0
- package/dist/server/static.js +71 -0
- package/dist/server/watcher.js +38 -0
- package/dist/shared/critical-path.js +128 -0
- package/dist/shared/diff.js +46 -0
- package/dist/shared/digest.js +44 -0
- package/dist/shared/milestone.js +29 -0
- package/dist/shared/related-docs.js +77 -0
- package/dist/shared/schema.js +376 -0
- package/dist/shared/stats.js +167 -0
- package/dist/web/assets/index-BLN_aXBe.css +1 -0
- package/dist/web/assets/index-BS_n8d9B.js +82 -0
- package/dist/web/index.html +13 -0
- package/package.json +73 -0
- package/templates/claude-rules.md +85 -0
- package/templates/config.json +28 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveConfigPath } from './config-paths.js';
|
|
5
|
+
import { hasSafeConfigExt, isSecretPath, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, writeFileAtomic, } from './fs-safe.js';
|
|
6
|
+
/**
|
|
7
|
+
* AI 設定ファイル管理 (F-4) のファイル I/O。
|
|
8
|
+
* 全ての読み書きは resolveConfigPath で aiConfigPaths 許可リストを通す (N-2)。
|
|
9
|
+
* 秘匿ファイル (settings.local.json 等) の内容はサーバーログに出さない (N-8)。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 許可リスト内ファイルの相対パスを realpath 済み root 基準で算出する (R14-7 / docs.ts:79-81 と同型)。
|
|
13
|
+
* absPath は resolveConfigPath で realpath ベースの canonical のため、非正規化 projectRoot で相対化すると
|
|
14
|
+
* root 側 symlink (macOS /var → /private/var) で `../` 連鎖になり絶対パスを露出しうる。realpath 済み root を
|
|
15
|
+
* 基準にしてレスポンスに絶対パス / 余計な `../` が混ざらないようにする (N-8)。
|
|
16
|
+
*/
|
|
17
|
+
async function relPathFromRealRoot(projectRoot, absPath) {
|
|
18
|
+
const root = path.resolve(projectRoot);
|
|
19
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
20
|
+
return path.relative(rootReal, absPath);
|
|
21
|
+
}
|
|
22
|
+
/** 許可リスト内ファイルを読む (F-4-1 / F-4-6)。許可外は PathValidationError */
|
|
23
|
+
export async function readConfigFile(projectRoot, config, relPath) {
|
|
24
|
+
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
25
|
+
const stat = await fs.stat(absPath);
|
|
26
|
+
// R14-4 再修正 (#5): docs だけでなく config 読取も O_NOFOLLOW で TOCTOU を塞ぐ (resolveConfigPath の
|
|
27
|
+
// lstat symlink 拒否と読取の間で対象が symlink へ差し替わる窓を閉じる)。config はより秘匿性が高い。
|
|
28
|
+
const content = await readFileNoFollow(absPath);
|
|
29
|
+
return { path: await relPathFromRealRoot(projectRoot, absPath), content, mtimeMs: stat.mtimeMs };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 許可リスト内ファイルを保存する (F-4-1/F-4-3/F-4-6)。
|
|
33
|
+
* - 既存があれば `.bak` を作成 (上書き前のバックアップ)
|
|
34
|
+
* - mode を保全 (秘匿ファイルの権限が緩まないように、N-8 / Round 1 #7)
|
|
35
|
+
* mtime 不一致は呼び出し側で 409 にする (この関数は検証済み前提で書く)。
|
|
36
|
+
*/
|
|
37
|
+
export async function saveConfigFile(projectRoot, config, relPath, content) {
|
|
38
|
+
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
39
|
+
// 既存があれば .bak を作る (内容をそのままコピー)。新規作成時はバックアップ不要。
|
|
40
|
+
// .bak はコピー元 (秘匿ファイル) の mode を継がせる (R8-2: settings.local.json 0o600 の .bak が
|
|
41
|
+
// 0o644 で漏れないように)。preserveMode は .bak の既存 mode を見るため未存在だと効かない → mode を明示
|
|
42
|
+
try {
|
|
43
|
+
const prevStat = await fs.stat(absPath);
|
|
44
|
+
// R14-4 再修正 (#5): prev 読取も O_NOFOLLOW に統一 (config 読取経路の TOCTOU を docs と対称に塞ぐ)
|
|
45
|
+
const prev = await readFileNoFollow(absPath);
|
|
46
|
+
await writeFileAtomic(`${absPath}.bak`, prev, { mode: prevStat.mode & 0o777 });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== 'ENOENT')
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
await writeFileAtomic(absPath, content, { preserveMode: true });
|
|
53
|
+
const stat = await fs.stat(absPath);
|
|
54
|
+
return { path: await relPathFromRealRoot(projectRoot, absPath), content, mtimeMs: stat.mtimeMs };
|
|
55
|
+
}
|
|
56
|
+
/** ファイルの現在 mtime を返す (PUT の競合検知用)。存在しなければ null */
|
|
57
|
+
export async function fileMtime(projectRoot, config, relPath) {
|
|
58
|
+
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
59
|
+
try {
|
|
60
|
+
return (await fs.stat(absPath)).mtimeMs;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** browseDirs 配下のファイルツリーを構築する (F-4-2)。各 browseDir をルートにした木の配列 */
|
|
67
|
+
export async function buildFileTree(projectRoot, config) {
|
|
68
|
+
const root = path.resolve(projectRoot);
|
|
69
|
+
const trees = [];
|
|
70
|
+
// R14-1: browseDir が `.` 等で root 自身を指すと木全体が root 配下を列挙し秘匿ファイルが見える。
|
|
71
|
+
// sanitizeConfigDirEntries で root の真の配下のエントリだけに正規化する (root 自身 / 外を除外)。
|
|
72
|
+
for (const absDir of sanitizeConfigDirEntries(root, config.aiConfigPaths.browseDirs)) {
|
|
73
|
+
// R14-1 再修正 (#3): browseDir 自体が .git 等の秘匿パスを指す場合は丸ごと列挙しない (発見面に deny を適用)
|
|
74
|
+
if (isSecretPath(absDir))
|
|
75
|
+
continue;
|
|
76
|
+
const node = await buildTreeNode(root, absDir);
|
|
77
|
+
if (node)
|
|
78
|
+
trees.push(node);
|
|
79
|
+
}
|
|
80
|
+
return trees;
|
|
81
|
+
}
|
|
82
|
+
async function buildTreeNode(root, absPath) {
|
|
83
|
+
// R14-1 再修正 (#3): 走査中の .git 配下 / 秘匿 basename は列挙しない (read API の deny と発見面を揃える)
|
|
84
|
+
if (isSecretPath(absPath))
|
|
85
|
+
return null;
|
|
86
|
+
let stat;
|
|
87
|
+
try {
|
|
88
|
+
stat = await fs.lstat(absPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (stat.isSymbolicLink())
|
|
94
|
+
return null; // symlink は辿らない (N-2)
|
|
95
|
+
const rel = path.relative(root, absPath);
|
|
96
|
+
const name = path.basename(absPath);
|
|
97
|
+
if (stat.isDirectory()) {
|
|
98
|
+
let entries = [];
|
|
99
|
+
try {
|
|
100
|
+
entries = (await fs.readdir(absPath)).sort();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
entries = [];
|
|
104
|
+
}
|
|
105
|
+
const children = [];
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const child = await buildTreeNode(root, path.join(absPath, entry));
|
|
108
|
+
if (child)
|
|
109
|
+
children.push(child);
|
|
110
|
+
}
|
|
111
|
+
return { path: rel, name, type: 'dir', children };
|
|
112
|
+
}
|
|
113
|
+
if (stat.isFile()) {
|
|
114
|
+
// R17-F (M4/M5): read できる拡張子 (allow-list) のファイルだけツリーに出す。read API (resolveConfigPath)
|
|
115
|
+
// は allow-list 外を 403 にするため、出しても開けないファイルを列挙するのは名前露出のみで益がない。
|
|
116
|
+
// tree と readability を整合させ、allow 拡張子以外 (例 .env / .sh) の名前露出を削減する。
|
|
117
|
+
// ディレクトリは従来通り表示する (中の .md 等に辿り着くため)。
|
|
118
|
+
if (!hasSafeConfigExt(name))
|
|
119
|
+
return null;
|
|
120
|
+
return { path: rel, name, type: 'file' };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* ディレクトリを手動再帰し .md ファイルの絶対パスを集める。
|
|
126
|
+
* symlink ディレクトリ・symlink ファイルは辿らない (R8-1: fs.readdir(recursive) は中間 symlink を
|
|
127
|
+
* 辿ってしまい N-2 境界を抜けるため、buildFileTree と同じ手動再帰で symlink を skip する)。
|
|
128
|
+
*
|
|
129
|
+
* R14-1 再々修正 (FIX-7・#2): **先頭 absDir 自体にも lstat を掛けて symlink を拒否する**。
|
|
130
|
+
* sanitizeConfigDirEntries / isSecretPath は lexical 判定のため、root 内に作った symlink browseDir
|
|
131
|
+
* (`evilbrowse` → root 外 or `.git`) を見逃し、readdir が辿って frontmatter を漏洩していた
|
|
132
|
+
* (buildTreeNode / buildDocsNode は top-level lstat で symlink を弾くのに catalog だけ非対称だった)。
|
|
133
|
+
*/
|
|
134
|
+
async function collectMarkdownFilesNoSymlink(absDir) {
|
|
135
|
+
// FIX-7 (#2): 先頭ディレクトリ自体が symlink なら辿らない (top-level の N-2 containment 破りを塞ぐ)。
|
|
136
|
+
// 再帰下層の dir は per-entry lstat で symlink でないことが保証済みのため、実質 top-level 呼出で効く。
|
|
137
|
+
let topLst;
|
|
138
|
+
try {
|
|
139
|
+
topLst = await fs.lstat(absDir);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
if (topLst.isSymbolicLink())
|
|
145
|
+
return [];
|
|
146
|
+
let entries = [];
|
|
147
|
+
try {
|
|
148
|
+
entries = await fs.readdir(absDir);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const entry of entries.sort()) {
|
|
155
|
+
const abs = path.join(absDir, entry);
|
|
156
|
+
// R14-1 再修正 (#3): .git 配下 / 秘匿 basename は catalog 読取対象から除外 (発見面に deny を適用)
|
|
157
|
+
if (isSecretPath(abs))
|
|
158
|
+
continue;
|
|
159
|
+
let lst;
|
|
160
|
+
try {
|
|
161
|
+
lst = await fs.lstat(abs);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (lst.isSymbolicLink())
|
|
167
|
+
continue; // symlink (dir/file 問わず) は辿らない
|
|
168
|
+
if (lst.isDirectory()) {
|
|
169
|
+
results.push(...(await collectMarkdownFilesNoSymlink(abs)));
|
|
170
|
+
}
|
|
171
|
+
else if (lst.isFile() && abs.endsWith('.md')) {
|
|
172
|
+
results.push(abs);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* browseDirs 配下の skill / agent の frontmatter (name / description) を抽出してカタログ化する (F-4-4)。
|
|
179
|
+
* .md ファイルのみ対象。frontmatter が無い / パース不能ならそれぞれ null。
|
|
180
|
+
* symlink 中間ディレクトリ・symlink ファイルは辿らない (R8-1: プロジェクト外漏洩を防ぐ)。
|
|
181
|
+
*/
|
|
182
|
+
export async function buildCatalog(projectRoot, config) {
|
|
183
|
+
const root = path.resolve(projectRoot);
|
|
184
|
+
const items = [];
|
|
185
|
+
// R14-1: buildFileTree と同じく root 自身 / 外を指す browseDir を除外する
|
|
186
|
+
for (const absDir of sanitizeConfigDirEntries(root, config.aiConfigPaths.browseDirs)) {
|
|
187
|
+
// R14-1 再修正 (#3): browseDir 自体が .git 等の秘匿パスなら catalog 化しない
|
|
188
|
+
if (isSecretPath(absDir))
|
|
189
|
+
continue;
|
|
190
|
+
for (const abs of await collectMarkdownFilesNoSymlink(absDir)) {
|
|
191
|
+
try {
|
|
192
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
193
|
+
const data = matter(raw).data;
|
|
194
|
+
items.push({
|
|
195
|
+
path: path.relative(root, abs),
|
|
196
|
+
name: typeof data.name === 'string' ? data.name : null,
|
|
197
|
+
description: typeof data.description === 'string' ? data.description : null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// 読めない / パース不能はスキップ
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
206
|
+
return items;
|
|
207
|
+
}
|
|
208
|
+
export { PathValidationError };
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* 書込の安全装置 (N-2 / N-3):
|
|
7
|
+
* - assertWritablePath: ①パス正規化 → ②親ディレクトリの realpath 解決 (対象ファイルは
|
|
8
|
+
* 未存在のことがある) → ③許可ディレクトリの接頭辞検査。`../` も symlink 経由もここで拒否する
|
|
9
|
+
* - writeFileAtomic: 同一ディレクトリの一時ファイル → rename によるアトミック書込
|
|
10
|
+
*/
|
|
11
|
+
export class PathValidationError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'PathValidationError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** 排他書込 (exclusive) で書込先が既に存在したときに投げる (呼び出し側で再採番 retry する) */
|
|
18
|
+
export class FileExistsError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'FileExistsError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// パス区切り・制御文字・先頭ドットを含むファイル名を拒否する (ファイル名サニタイズ)
|
|
25
|
+
const SAFE_BASENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
26
|
+
/**
|
|
27
|
+
* 解決済み絶対パスが root の「真の配下」かを判定する (root 自身は含めない)。
|
|
28
|
+
* config 由来の許可リスト (browseDirs / docsDirs / settingsFiles / rootFiles) の各エントリが
|
|
29
|
+
* `.` / 末尾スラッシュ等で root 自身を指すと、接頭辞検査が root 配下全件に一致してしまう
|
|
30
|
+
* (R14-1: PUT /api/files で任意上書き = RCE、GET /api/files で .env / settings.local.json 読取 = N-8 破り)。
|
|
31
|
+
* `files.ts:30` / `docs.ts:27` の既存ゲートは「root 自身は許可」だが、許可リストの「エントリ自身」は
|
|
32
|
+
* root より真に下でなければならないため、ここでは root 自身を弾く。
|
|
33
|
+
*/
|
|
34
|
+
export function isStrictlyInside(root, resolved) {
|
|
35
|
+
return resolved !== root && resolved.startsWith(root + path.sep);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 秘匿 deny-list は 2 層に分ける (R14-1 再修正・PM Round 14 残存脆弱性 #1〜#7)。
|
|
39
|
+
*
|
|
40
|
+
* - {@link isHardDeniedConfigPath}: **いかなる経路 (完全一致 exactFiles 含む) でも遮断する** 強い deny。
|
|
41
|
+
* `.git/**` (case-insensitive) + 鍵 / 認証情報類 (`.env`/`.env.*` / `*.pem` / `*.key` / `*.p12` / `*.pfx` /
|
|
42
|
+
* `id_rsa` / `id_dsa` / `id_ecdsa` / `id_ed25519` / `.npmrc` / `.netrc`)。
|
|
43
|
+
* `settings.local.json` は **含めない** (設定ファイル管理の正規編集ユースケースのため)。
|
|
44
|
+
* `.git/hooks/*` 書込は RCE、鍵類は N-8 漏洩に直結するため、悪意 config が settingsFiles/rootFiles に
|
|
45
|
+
* 列挙しても完全一致で素通りさせない。
|
|
46
|
+
* - {@link isSecretPath}: 上記の **superset** = `isHardDeniedConfigPath` + `settings.local.json`。
|
|
47
|
+
* browse / tree / catalog / docs の「発見」面に適用し、settings.local.json を一覧・ツリーで晒さない。
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* パス成分に秘匿ディレクトリ (case-insensitive) を含むか。
|
|
51
|
+
* macOS の case-insensitive FS で `.GIT` / `.SSH` 等の大文字回避も防ぐ。
|
|
52
|
+
* R17-B (#B-1): `.git` 単独判定を一般化し、認証情報・鍵を格納する標準ディレクトリ
|
|
53
|
+
* (`.ssh` / `.aws` / `.gnupg` / `.kube` / `.docker`) を basename 非依存で全遮断する。
|
|
54
|
+
* これにより `.ssh/known_hosts` / `.aws/credentials` / `.docker/config.json` / `.kube/config` を
|
|
55
|
+
* 個別 basename に頼らず遮断できる (`.git/hooks/*` 書込 = RCE、鍵類 = N-8 漏洩を全経路で防ぐ)。
|
|
56
|
+
* R17-E (#E-2): `.config` / `.composer` を追加 (allow 拡張子を持つ秘密 `.config/gh/hosts.yml` (.yml) /
|
|
57
|
+
* `.composer/auth.json` (.json) を dir 単位で遮断・allow-list と defense-in-depth で二重化)。
|
|
58
|
+
*/
|
|
59
|
+
const SECRET_DIR_SEGMENTS = new Set(['.git', '.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config', '.composer']);
|
|
60
|
+
function hasSecretDirComponent(resolved) {
|
|
61
|
+
return resolved.split(path.sep).some((seg) => SECRET_DIR_SEGMENTS.has(seg.toLowerCase()));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 強い deny の basename (settings.local.json を含まない・鍵 / 認証情報類のみ)。case-insensitive。
|
|
65
|
+
* FIX-8 (#3): SSH 鍵の他形式 (id_dsa / id_ecdsa) / 認証情報 (.netrc) / PKCS#12 鍵束 (*.p12 / *.pfx) を追加。
|
|
66
|
+
* R17-B (#B-2): 認証情報系 basename を網羅追加 (`.git-credentials` / `.gitconfig` (core.hooksPath 経由 RCE) /
|
|
67
|
+
* `.pgpass` / `.htpasswd` / `.dockercfg` / `.s3cfg` / `known_hosts` / `authorized_keys`) と
|
|
68
|
+
* 証明書・鍵束の拡張子 (crt|cer|der|keystore|jks|ovpn|asc|gpg|pgp|p7b|p8) を追加。
|
|
69
|
+
* **`.gitignore` / `.gitattributes` / `.gitkeep` は誤遮断しない** (`\.gitconfig` / `\.git-credentials` は
|
|
70
|
+
* 完全一致なので衝突しない。テスト F22 で ALLOW を pinning)。
|
|
71
|
+
*/
|
|
72
|
+
const HARD_DENY_BASENAME_RE = /^(\.env(\..*)?|.*\.(pem|key|p12|pfx|crt|cer|der|keystore|jks|ovpn|asc|gpg|pgp|p7b|p8)|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.npmrc|\.netrc|\.git-credentials|\.gitconfig|\.pgpass|\.htpasswd|\.dockercfg|\.s3cfg|known_hosts|authorized_keys)$/i;
|
|
73
|
+
/**
|
|
74
|
+
* いかなる経路 (完全一致 exactFiles 含む) でも遮断する強い deny (R14-1 再修正)。
|
|
75
|
+
* 秘匿 dir 成分 (`.git`/`.ssh`/`.aws`/`.gnupg`/`.kube`/`.docker`、case-insensitive) + 鍵 / 認証情報類。
|
|
76
|
+
* settings.local.json は含めない (正規編集を許す)。
|
|
77
|
+
*/
|
|
78
|
+
export function isHardDeniedConfigPath(resolved) {
|
|
79
|
+
if (hasSecretDirComponent(resolved))
|
|
80
|
+
return true; // .git/.ssh/.aws/... 配下を全経路で拒否 (RCE / N-8)
|
|
81
|
+
return HARD_DENY_BASENAME_RE.test(path.basename(resolved));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 「発見」面 (browse / tree / catalog / docs) に適用する deny-list (isHardDeniedConfigPath の superset)。
|
|
85
|
+
* 強い deny に settings.local.json を加える (一覧・ツリーで秘匿ファイルを晒さない)。
|
|
86
|
+
*/
|
|
87
|
+
export function isSecretPath(resolved) {
|
|
88
|
+
if (isHardDeniedConfigPath(resolved))
|
|
89
|
+
return true;
|
|
90
|
+
return /^settings\.local\.json$/i.test(path.basename(resolved));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* F-4 設定ファイル管理で read / write を許可する拡張子の allow-list (R17-E: 主ゲート・#E-1)。
|
|
94
|
+
*
|
|
95
|
+
* deny-list は列挙のため収束しない (認証情報・鍵の新種が毎ラウンド出る whack-a-mole)。read/write/browse の
|
|
96
|
+
* **主ゲートを allow-list に切替える**。既定の F-4 対象 (settings.json/settings.local.json = .json、CLAUDE.md /
|
|
97
|
+
* skill / rule / agent = .md) は全通過する。許可拡張子に無い `kubeconfig` / `github_token` / `credentials` /
|
|
98
|
+
* `terraform.tfstate` / `.envrc` / `.pypirc` / `prod.env` / `.flaskenv` 等は一律ブロックされる。
|
|
99
|
+
* deny-list は defense-in-depth で残す (両方が独立に効く)。
|
|
100
|
+
*/
|
|
101
|
+
export const SAFE_CONFIG_EXT = new Set(['.json', '.jsonc', '.md', '.markdown', '.txt', '.toml', '.yaml', '.yml']);
|
|
102
|
+
/** パスの拡張子が SAFE_CONFIG_EXT に含まれるか (case-insensitive)。allow-list の判定 (R17-E・#E-1) */
|
|
103
|
+
export function hasSafeConfigExt(p) {
|
|
104
|
+
return SAFE_CONFIG_EXT.has(path.extname(p).toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* config 由来のパスエントリ配列を root 配下に正規化・フィルタする (R14-1 / R14-3)。
|
|
108
|
+
* 各エントリを `path.resolve(root, entry)` し、root の真の配下 (isStrictlyInside) のものだけを残す。
|
|
109
|
+
* `.` / `..` / 絶対パスで root 自身 / root 外を許可リストに載せる攻撃は黙って除外する
|
|
110
|
+
* (config は trusted だが、悪意ある config を含むリポを開いた場合の被害を局所化する)。
|
|
111
|
+
*
|
|
112
|
+
* 秘匿 deny-list は **ここでは適用しない** (root 封じ込めのみ責務)。deny は解決後パスに対し
|
|
113
|
+
* 呼び出し側が 2 層で適用する (R14-1 再修正):
|
|
114
|
+
* - resolveConfigPath: 完全一致 / 接頭辞一致を問わず {@link isHardDeniedConfigPath} (.git/鍵類) を遮断。
|
|
115
|
+
* 接頭辞一致 (browseDirs) のみ追加で {@link isSecretPath} (= settings.local.json も) を遮断。
|
|
116
|
+
* 完全一致 (settingsFiles) の settings.local.json は正規編集ユースケースのため許す。
|
|
117
|
+
* - buildFileTree / buildCatalog / docs: 発見面なので isSecretPath を走査時に適用。
|
|
118
|
+
*/
|
|
119
|
+
export function sanitizeConfigDirEntries(root, entries) {
|
|
120
|
+
const result = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const resolved = path.resolve(root, entry);
|
|
123
|
+
if (!isStrictlyInside(root, resolved))
|
|
124
|
+
continue; // root 自身 / root 外を除外
|
|
125
|
+
result.push(resolved);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 書込先パスを検証し、正規化済みの絶対パスを返す。
|
|
131
|
+
* allowedDirs は書込を許可するディレクトリの一覧 (Phase 1 は `.kanban/` のみ)。
|
|
132
|
+
*/
|
|
133
|
+
export async function assertWritablePath(targetPath, allowedDirs) {
|
|
134
|
+
const resolved = path.resolve(targetPath);
|
|
135
|
+
const base = path.basename(resolved);
|
|
136
|
+
if (!SAFE_BASENAME_RE.test(base)) {
|
|
137
|
+
throw new PathValidationError(`不正なファイル名のため書込を拒否: ${JSON.stringify(base)}`);
|
|
138
|
+
}
|
|
139
|
+
let parentReal;
|
|
140
|
+
try {
|
|
141
|
+
parentReal = await fs.realpath(path.dirname(resolved));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// R17-F (M3): エラー文に絶対パスを出さない (home/username の info leak 防止)
|
|
145
|
+
throw new PathValidationError('書込先の親ディレクトリが存在しません');
|
|
146
|
+
}
|
|
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
|
+
}
|
|
156
|
+
const inside = allowedReals.some((dir) => parentReal === dir || parentReal.startsWith(dir + path.sep));
|
|
157
|
+
if (!inside) {
|
|
158
|
+
// R17-F (M3): 絶対パス (targetPath) を出さない
|
|
159
|
+
throw new PathValidationError('許可リスト外への書込は許可されていません');
|
|
160
|
+
}
|
|
161
|
+
// 対象自体が symlink の場合も拒否する (symlink 越しの書込防止)
|
|
162
|
+
const canonical = path.join(parentReal, base);
|
|
163
|
+
try {
|
|
164
|
+
const stat = await fs.lstat(canonical);
|
|
165
|
+
if (stat.isSymbolicLink()) {
|
|
166
|
+
// R17-F (M3): 絶対パス (targetPath) を出さない
|
|
167
|
+
throw new PathValidationError('symlink への書込は許可されていません');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof PathValidationError)
|
|
172
|
+
throw error;
|
|
173
|
+
if (error.code !== 'ENOENT')
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
return canonical;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 同一ディレクトリの一時ファイルに書いてから rename するアトミック書込 (N-3)。
|
|
180
|
+
* 許可リスト検証を含まないため、サーバーコードからは safeWriteFile を使うこと。
|
|
181
|
+
* 直接呼出は CLI の固定パス書込 (init の CLAUDE.md 追記等) とテスト専用。
|
|
182
|
+
* exclusive 指定時は上書きせず、既存があれば FileExistsError を投げる。
|
|
183
|
+
*/
|
|
184
|
+
export async function writeFileAtomic(filePath, content, options = {}) {
|
|
185
|
+
const dir = path.dirname(filePath);
|
|
186
|
+
// .tmp 拡張子にして watcher の .md フィルタに掛からないようにする
|
|
187
|
+
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
|
+
// 直前再検証で「秘匿パスへの差し替え」を検知して中止する。
|
|
192
|
+
// micro-window: 再 realpath〜rename の間は残存する (Node に renameat (fd ベース atomic rename) が無く、
|
|
193
|
+
// パス文字列ベースの rename しか使えないため完全封じは標準 API 外)。実害は極小だが受容する。
|
|
194
|
+
const reCheckHardDeny = async () => {
|
|
195
|
+
let reParent;
|
|
196
|
+
try {
|
|
197
|
+
reParent = await fs.realpath(dir);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return; // 親が消えていれば rename 自体が失敗するので、ここでは追加判定しない
|
|
201
|
+
}
|
|
202
|
+
const reCanonical = path.join(reParent, path.basename(filePath));
|
|
203
|
+
if (isHardDeniedConfigPath(reCanonical)) {
|
|
204
|
+
throw new PathValidationError('書込直前に秘匿パスへの差し替えを検知したため中止しました');
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
await fs.writeFile(tmpPath, content, 'utf8');
|
|
209
|
+
// 明示 mode 指定 (.bak がコピー元の権限を継ぐ等、R8-2) を優先する
|
|
210
|
+
if (options.mode !== undefined) {
|
|
211
|
+
await fs.chmod(tmpPath, options.mode & 0o777);
|
|
212
|
+
}
|
|
213
|
+
else if (options.preserveMode) {
|
|
214
|
+
// 既存ファイルの mode を保全する (秘匿ファイルの権限が rename で緩まないように、N-8 / Round 1 #7)
|
|
215
|
+
try {
|
|
216
|
+
const existing = await fs.stat(filePath);
|
|
217
|
+
await fs.chmod(tmpPath, existing.mode & 0o777);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// 既存が無い (新規作成) 場合は保全対象なし。それ以外のエラーは無視せず投げる
|
|
221
|
+
if (error.code !== 'ENOENT')
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (options.exclusive) {
|
|
226
|
+
await reCheckHardDeny(); // R17-C: link 直前にも秘匿パス差し替えを再検知
|
|
227
|
+
// link は対象が存在すると EEXIST で原子的に失敗する (rename と違い上書きしない)
|
|
228
|
+
try {
|
|
229
|
+
await fs.link(tmpPath, filePath);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error.code === 'EEXIST') {
|
|
233
|
+
throw new FileExistsError(`書込先が既に存在するため上書きしない: ${path.basename(filePath)}`);
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
// link 成否に関わらず tmp は不要 (成功時は hardlink が本体、失敗時は破棄)
|
|
239
|
+
await fs.rm(tmpPath, { force: true }).catch(() => { });
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await reCheckHardDeny(); // R17-C: rename 直前に秘匿パス差し替えを再検知 (.git/hooks への着地を防ぐ)
|
|
244
|
+
await fs.rename(tmpPath, filePath);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// クリーンアップ自体の失敗で元の書込エラーを隠さない
|
|
248
|
+
await fs.rm(tmpPath, { force: true }).catch(() => { });
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** 許可リスト検証 + アトミック書込。書き込んだ正規化済みパスを返す */
|
|
253
|
+
export async function safeWriteFile(targetPath, content, allowedDirs, options = {}) {
|
|
254
|
+
const canonical = await assertWritablePath(targetPath, allowedDirs);
|
|
255
|
+
await writeFileAtomic(canonical, content, options);
|
|
256
|
+
return canonical;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* symlink を辿らずにファイルを読む (R14-4: TOCTOU 緩和)。
|
|
260
|
+
* lstat (symlink 拒否) → readFile の間に対象が symlink へ差し替わる窓を、O_NOFOLLOW で fd を
|
|
261
|
+
* 開くことで閉じる (対象が symlink なら open 自体が ELOOP で失敗する)。読取専用 (O_RDONLY)。
|
|
262
|
+
* O_NOFOLLOW は対象パスの最終要素のみに効く (親ディレクトリの symlink は resolveDocPath /
|
|
263
|
+
* resolveConfigPath 側の parent realpath 検査で既に弾いている)。
|
|
264
|
+
*/
|
|
265
|
+
export async function readFileNoFollow(absPath) {
|
|
266
|
+
// O_NOFOLLOW が未定義の環境 (理論上 Windows) では通常 readFile にフォールバックする
|
|
267
|
+
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
|
268
|
+
const fh = await fs.open(absPath, fsConstants.O_RDONLY | noFollow);
|
|
269
|
+
try {
|
|
270
|
+
return await fh.readFile('utf8');
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await fh.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* `.kanban/` の canonical realpath (信頼境界) を確立する (R17-D: 中間 dir symlink containment・#D-1)。
|
|
278
|
+
*
|
|
279
|
+
* 旧 R17-A の read ヘルパは「最終成分 lstat / O_NOFOLLOW」だけで symlink を弾いていたが、これらは
|
|
280
|
+
* **最終成分しか見ない**ため、中間ディレクトリ (`.kanban` 自体 / `.kanban/tasks` / `.kanban/archive`) を
|
|
281
|
+
* 外部 dir への symlink に差し替えると素通りし、root 外ファイルが漏洩 / 削除されうる
|
|
282
|
+
* (兄弟ヘルパ config-paths.ts / docs.ts / files.ts は親 realpath containment をやっており、ここだけ非対称・LEARN#6)。
|
|
283
|
+
*
|
|
284
|
+
* projectRoot を realpath 解決 (macOS の /tmp→/private/tmp も吸収) し、その直下 `.kanban` を realpath 解決した
|
|
285
|
+
* 結果が「親 + '.kanban'」と一致するか (= `.kanban` 自体が symlink でないか) を検査する。一致しなければ拒否。
|
|
286
|
+
* `.kanban` が無ければ realpath が ENOENT を投げる (呼び出し側で graceful: 空ボード / 既定 config に倒す)。
|
|
287
|
+
*/
|
|
288
|
+
export async function resolveKanbanReal(projectRoot) {
|
|
289
|
+
const rootReal = await fs.realpath(path.resolve(projectRoot));
|
|
290
|
+
const kanbanDir = path.join(rootReal, '.kanban');
|
|
291
|
+
const kanbanReal = await fs.realpath(kanbanDir); // .kanban 不在なら ENOENT
|
|
292
|
+
if (kanbanReal !== kanbanDir) {
|
|
293
|
+
throw new PathValidationError('.kanban が symlink のためアクセスを拒否しました');
|
|
294
|
+
}
|
|
295
|
+
return kanbanReal;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 対象パスの**親ディレクトリ realpath**が信頼境界 (kanbanReal) 配下かを検査する (R17-D・#D-1)。
|
|
299
|
+
* realpath は path 全成分の symlink を解決するため、中間ディレクトリ symlink (.kanban/tasks → 外部) も
|
|
300
|
+
* ここで捕捉できる (最終成分は呼び出し側の lstat / O_NOFOLLOW が二重に弾く)。
|
|
301
|
+
*/
|
|
302
|
+
export async function assertInsideKanban(absPath, kanbanReal) {
|
|
303
|
+
let parentReal;
|
|
304
|
+
try {
|
|
305
|
+
parentReal = await fs.realpath(path.dirname(absPath));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
throw new PathValidationError('親ディレクトリが存在しません');
|
|
309
|
+
}
|
|
310
|
+
if (parentReal !== kanbanReal && !parentReal.startsWith(kanbanReal + path.sep)) {
|
|
311
|
+
throw new PathValidationError('.kanban 外 (symlink 経由を含む) へのアクセスを拒否しました');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* ディレクトリ自体の realpath が信頼境界 (kanbanReal) 配下かを検査する (R17-D: tasksDir 等の中間 dir symlink 用)。
|
|
316
|
+
* `assertInsideKanban` は「親」realpath を見るが、これは「対象 dir 自身」の realpath を見るため、
|
|
317
|
+
* tasksDir 自体が外部 dir への symlink のケース (A18-2) を readdir の前に捕捉できる。
|
|
318
|
+
*/
|
|
319
|
+
export async function assertDirInsideKanban(dir, kanbanReal) {
|
|
320
|
+
if ((await realpathInsideKanban(dir, kanbanReal)) === null) {
|
|
321
|
+
throw new PathValidationError('.kanban 外 (symlink 経由を含む) のディレクトリへのアクセスを拒否しました');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 信頼境界 (kanbanReal) 配下のディレクトリであることを realpath で検査する (walk の中間 dir 用)。
|
|
326
|
+
* 配下なら realpath を返し、外 (symlink 経由含む) なら null (呼び出し側で skip)。
|
|
327
|
+
*/
|
|
328
|
+
async function realpathInsideKanban(dir, kanbanReal) {
|
|
329
|
+
let real;
|
|
330
|
+
try {
|
|
331
|
+
real = await fs.realpath(dir);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (real !== kanbanReal && !real.startsWith(kanbanReal + path.sep))
|
|
337
|
+
return null;
|
|
338
|
+
return real;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* `.kanban/` 配下のファイルを stat する (R17-D: realpath containment + 最終成分 symlink 拒否・#A-1/#D-2)。
|
|
342
|
+
* 親 realpath が kanbanReal 配下かを先に検査し (中間 dir symlink を捕捉)、続けて最終成分を lstat で
|
|
343
|
+
* symlink 拒否する。通常ファイルは lstat と stat の mtime が一致するため mtimeMs もここで取れる。
|
|
344
|
+
* 存在しなければ ENOENT をそのまま投げる (呼び出し側の catch で 404 / skip に倒す)。
|
|
345
|
+
*/
|
|
346
|
+
export async function statKanbanFile(absPath, kanbanReal) {
|
|
347
|
+
await assertInsideKanban(absPath, kanbanReal); // 中間 dir symlink を捕捉
|
|
348
|
+
const stat = await fs.lstat(absPath);
|
|
349
|
+
if (stat.isSymbolicLink()) {
|
|
350
|
+
throw new PathValidationError('.kanban 配下の symlink は許可されていません');
|
|
351
|
+
}
|
|
352
|
+
return stat;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* `.kanban/` 配下のファイルを symlink を辿らずに読む (R17-D・#A-1/#D-2)。
|
|
356
|
+
* statKanbanFile (親 realpath containment で中間 dir symlink を捕捉 + lstat で最終成分 symlink を
|
|
357
|
+
* PathValidationError 拒否) を通してから readFileNoFollow (O_NOFOLLOW) で読む。最終成分は lstat と
|
|
358
|
+
* O_NOFOLLOW の両方で弾き、lstat→open の窓の TOCTOU も二重化する。
|
|
359
|
+
*/
|
|
360
|
+
export async function readKanbanFile(absPath, kanbanReal) {
|
|
361
|
+
await statKanbanFile(absPath, kanbanReal); // 中間 dir symlink + 最終成分 symlink を捕捉 (PathValidationError)
|
|
362
|
+
return readFileNoFollow(absPath); // 最終成分 symlink を O_NOFOLLOW でも捕捉 (TOCTOU 二重化)
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* `.kanban/` 配下を手動再帰し、predicate に該当するファイルの絶対パスを集める (R17-D・#A-1/#D-3)。
|
|
366
|
+
* top-level `dir` と各再帰 dir の **realpath が kanbanReal 配下か**を検査し、外 (symlink 経由含む) なら skip。
|
|
367
|
+
* entry の symlink (dir/file 問わず) も skip。`fs.readdir({recursive:true})` は中間 symlink dir を辿るため使わない。
|
|
368
|
+
* predicate 既定は「`.md` 末尾のファイル」。返すのは絶対パス (呼び出し側で basename / id を扱いやすい)。
|
|
369
|
+
*
|
|
370
|
+
* files.ts の collectMarkdownFilesNoSymlink とは「isSecretPath を走査時に適用するか (browse 面は適用 /
|
|
371
|
+
* .kanban 面は不要)」「predicate を引数化するか」「containment 基準 (kanbanReal vs browseDir)」で分かれるため
|
|
372
|
+
* 別関数にする (LEARN#8b: 型・フィルタ・境界が揃わないため統合せず別関数。decisions.md に記録)。
|
|
373
|
+
*/
|
|
374
|
+
export async function collectKanbanFilesNoSymlink(dir, kanbanReal, predicate = (p) => p.endsWith('.md')) {
|
|
375
|
+
// top-level / 再帰下層の dir realpath が kanbanReal 配下でなければ辿らない (中間 dir symlink 防止)
|
|
376
|
+
if ((await realpathInsideKanban(dir, kanbanReal)) === null)
|
|
377
|
+
return [];
|
|
378
|
+
let entries;
|
|
379
|
+
try {
|
|
380
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
const results = [];
|
|
386
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
387
|
+
if (entry.isSymbolicLink())
|
|
388
|
+
continue; // symlink (dir/file 問わず) は辿らない
|
|
389
|
+
const abs = path.join(dir, entry.name);
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
results.push(...(await collectKanbanFilesNoSymlink(abs, kanbanReal, predicate)));
|
|
392
|
+
}
|
|
393
|
+
else if (entry.isFile() && predicate(abs)) {
|
|
394
|
+
results.push(abs);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return results;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* プロセスクラッシュ時に残った一時ファイル (.<name>.<uuid>.tmp) を削除する (R17-D: containment 限定削除・#D-4)。
|
|
401
|
+
* serve 起動時に .kanban/ に対して呼ぶ。削除した件数を返す。
|
|
402
|
+
*
|
|
403
|
+
* 旧実装は `fs.readdir({recursive:true})` が中間 symlink dir を辿るため、`.kanban/sub → 外部dir` の symlink 下に
|
|
404
|
+
* `.x.tmp` を置かれると **root 外の任意ファイルを削除**しうる脆弱性があった。collectKanbanFilesNoSymlink で
|
|
405
|
+
* symlink 非追従 + realpath containment 済みのファイルだけを集め、各削除対象が kanbanReal 配下であることを
|
|
406
|
+
* 再確認してから削除する。kanbanReal は呼び出し側で resolveKanbanReal して渡す (失敗時は cleanup skip)。
|
|
407
|
+
*/
|
|
408
|
+
export async function cleanupStaleTmpFiles(rootDir, kanbanReal) {
|
|
409
|
+
const tmpFiles = await collectKanbanFilesNoSymlink(rootDir, kanbanReal, (p) => path.basename(p).startsWith('.') && path.basename(p).endsWith('.tmp'));
|
|
410
|
+
let removed = 0;
|
|
411
|
+
for (const abs of tmpFiles) {
|
|
412
|
+
// 削除直前にも親 realpath が kanbanReal 配下であることを再確認する (二重 guard)
|
|
413
|
+
try {
|
|
414
|
+
await assertInsideKanban(abs, kanbanReal);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
continue; // containment 外は削除しない
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
await fs.rm(abs, { force: true });
|
|
421
|
+
removed += 1;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// 消せない残骸はスキップして続行する
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return removed;
|
|
428
|
+
}
|