aizuchi 0.1.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 +79 -18
- package/dist/bin/aizuchi.js +9 -1
- package/dist/server/app.js +392 -10
- 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/pinpoint.js +50 -0
- 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 +125 -10
- 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 +8 -7
- package/dist/web/assets/index-BLN_aXBe.css +0 -1
- package/dist/web/assets/index-BS_n8d9B.js +0 -82
|
@@ -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 に無ければ完全一致 / 接頭辞一致を問わず拒否。
|
package/dist/server/lib/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import matter from 'gray-matter';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { resolveConfigPath } from './config-paths.js';
|
|
4
|
+
import { GLOBAL_BROWSE_DIRS, resolveConfigPath, resolveGlobalConfigPath } from './config-paths.js';
|
|
5
5
|
import { hasSafeConfigExt, isSecretPath, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, writeFileAtomic, } from './fs-safe.js';
|
|
6
6
|
/**
|
|
7
7
|
* AI 設定ファイル管理 (F-4) のファイル I/O。
|
|
@@ -19,26 +19,32 @@ async function relPathFromRealRoot(projectRoot, absPath) {
|
|
|
19
19
|
const rootReal = await fs.realpath(root).catch(() => root);
|
|
20
20
|
return path.relative(rootReal, absPath);
|
|
21
21
|
}
|
|
22
|
+
/** root に正規化済み absPath を読む共通コア (O_NOFOLLOW で検証〜読取間の symlink 差し替えを防ぐ・N-8) */
|
|
23
|
+
async function readResolved(root, absPath) {
|
|
24
|
+
const stat = await fs.stat(absPath);
|
|
25
|
+
const content = await readFileNoFollow(absPath);
|
|
26
|
+
return { path: await relPathFromRealRoot(root, absPath), content, mtimeMs: stat.mtimeMs };
|
|
27
|
+
}
|
|
22
28
|
/** 許可リスト内ファイルを読む (F-4-1 / F-4-6)。許可外は PathValidationError */
|
|
23
29
|
export async function readConfigFile(projectRoot, config, relPath) {
|
|
24
30
|
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
return readResolved(projectRoot, absPath);
|
|
32
|
+
}
|
|
33
|
+
/** グローバル (~/.claude/skills,agents) ファイルを読む。同じ検証コアを root=home で通す */
|
|
34
|
+
export async function readGlobalConfigFile(homeDir, relPath) {
|
|
35
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
36
|
+
return readResolved(homeDir, absPath);
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
32
|
-
*
|
|
33
|
-
* -
|
|
34
|
-
* -
|
|
39
|
+
* root に正規化済み absPath を保存する共通コア。
|
|
40
|
+
* - 親未作成なら作成 (新規ファイル作成の導線)
|
|
41
|
+
* - 既存があれば `.bak` を作成 (上書き前のバックアップ・mode 保全 R8-2)
|
|
42
|
+
* - 本体も mode 保全 (秘匿ファイルの権限が緩まないように、N-8 / Round 1 #7)
|
|
35
43
|
* mtime 不一致は呼び出し側で 409 にする (この関数は検証済み前提で書く)。
|
|
36
44
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// .bak はコピー元 (秘匿ファイル) の mode を継がせる (R8-2: settings.local.json 0o600 の .bak が
|
|
41
|
-
// 0o644 で漏れないように)。preserveMode は .bak の既存 mode を見るため未存在だと効かない → mode を明示
|
|
45
|
+
async function writeResolved(root, absPath, content) {
|
|
46
|
+
// absPath は resolveConfigPath / resolveGlobalConfigPath で検証済み (root 配下・許可リスト内・symlink 非経由)
|
|
47
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
42
48
|
try {
|
|
43
49
|
const prevStat = await fs.stat(absPath);
|
|
44
50
|
// R14-4 再修正 (#5): prev 読取も O_NOFOLLOW に統一 (config 読取経路の TOCTOU を docs と対称に塞ぐ)
|
|
@@ -51,11 +57,20 @@ export async function saveConfigFile(projectRoot, config, relPath, content) {
|
|
|
51
57
|
}
|
|
52
58
|
await writeFileAtomic(absPath, content, { preserveMode: true });
|
|
53
59
|
const stat = await fs.stat(absPath);
|
|
54
|
-
return { path: await relPathFromRealRoot(
|
|
60
|
+
return { path: await relPathFromRealRoot(root, absPath), content, mtimeMs: stat.mtimeMs };
|
|
55
61
|
}
|
|
56
|
-
/**
|
|
57
|
-
export async function
|
|
62
|
+
/** 許可リスト内ファイルを保存する (F-4-1/F-4-3/F-4-6)。許可外は PathValidationError */
|
|
63
|
+
export async function saveConfigFile(projectRoot, config, relPath, content) {
|
|
58
64
|
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
65
|
+
return writeResolved(projectRoot, absPath, content);
|
|
66
|
+
}
|
|
67
|
+
/** グローバル (~/.claude/skills,agents) ファイルを保存する。同じ検証コアを root=home で通す */
|
|
68
|
+
export async function saveGlobalConfigFile(homeDir, relPath, content) {
|
|
69
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
70
|
+
return writeResolved(homeDir, absPath, content);
|
|
71
|
+
}
|
|
72
|
+
/** ファイルの現在 mtime を返す (PUT の競合検知用)。存在しなければ null */
|
|
73
|
+
async function statMtime(absPath) {
|
|
59
74
|
try {
|
|
60
75
|
return (await fs.stat(absPath)).mtimeMs;
|
|
61
76
|
}
|
|
@@ -63,6 +78,15 @@ export async function fileMtime(projectRoot, config, relPath) {
|
|
|
63
78
|
return null;
|
|
64
79
|
}
|
|
65
80
|
}
|
|
81
|
+
export async function fileMtime(projectRoot, config, relPath) {
|
|
82
|
+
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
83
|
+
return statMtime(absPath);
|
|
84
|
+
}
|
|
85
|
+
/** グローバルファイルの現在 mtime (PUT 競合検知用)。許可外は PathValidationError */
|
|
86
|
+
export async function globalFileMtime(homeDir, relPath) {
|
|
87
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
88
|
+
return statMtime(absPath);
|
|
89
|
+
}
|
|
66
90
|
/** browseDirs 配下のファイルツリーを構築する (F-4-2)。各 browseDir をルートにした木の配列 */
|
|
67
91
|
export async function buildFileTree(projectRoot, config) {
|
|
68
92
|
const root = path.resolve(projectRoot);
|
|
@@ -179,17 +203,20 @@ async function collectMarkdownFilesNoSymlink(absDir) {
|
|
|
179
203
|
* .md ファイルのみ対象。frontmatter が無い / パース不能ならそれぞれ null。
|
|
180
204
|
* symlink 中間ディレクトリ・symlink ファイルは辿らない (R8-1: プロジェクト外漏洩を防ぐ)。
|
|
181
205
|
*/
|
|
182
|
-
|
|
183
|
-
const root = path.resolve(
|
|
206
|
+
async function buildCatalogFrom(rootInput, browseDirsRel) {
|
|
207
|
+
const root = path.resolve(rootInput);
|
|
184
208
|
const items = [];
|
|
185
209
|
// R14-1: buildFileTree と同じく root 自身 / 外を指す browseDir を除外する
|
|
186
|
-
for (const absDir of sanitizeConfigDirEntries(root,
|
|
210
|
+
for (const absDir of sanitizeConfigDirEntries(root, [...browseDirsRel])) {
|
|
187
211
|
// R14-1 再修正 (#3): browseDir 自体が .git 等の秘匿パスなら catalog 化しない
|
|
188
212
|
if (isSecretPath(absDir))
|
|
189
213
|
continue;
|
|
190
214
|
for (const abs of await collectMarkdownFilesNoSymlink(absDir)) {
|
|
191
215
|
try {
|
|
192
|
-
|
|
216
|
+
// readConfigFile / readDoc と同じく O_NOFOLLOW 読込に統一し、collectMarkdownFilesNoSymlink の
|
|
217
|
+
// lstat → 読込の間に最終成分が symlink へ差し替わる TOCTOU 窓を塞ぐ (R14-4 #5 の defense-in-depth)。
|
|
218
|
+
// symlink は throw → 下の catch でスキップされる (通常ファイルの挙動は不変)。
|
|
219
|
+
const raw = await readFileNoFollow(abs);
|
|
193
220
|
const data = matter(raw).data;
|
|
194
221
|
items.push({
|
|
195
222
|
path: path.relative(root, abs),
|
|
@@ -205,4 +232,12 @@ export async function buildCatalog(projectRoot, config) {
|
|
|
205
232
|
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
206
233
|
return items;
|
|
207
234
|
}
|
|
235
|
+
/** プロジェクトの skill / agent カタログ (F-4-4)。aiConfigPaths.browseDirs の frontmatter name/description 一覧 */
|
|
236
|
+
export async function buildCatalog(projectRoot, config) {
|
|
237
|
+
return buildCatalogFrom(projectRoot, config.aiConfigPaths.browseDirs);
|
|
238
|
+
}
|
|
239
|
+
/** グローバル (~/.claude/skills,agents) の skill / agent カタログ。同じ走査ロジックを root=home で通す */
|
|
240
|
+
export async function buildGlobalCatalog(homeDir) {
|
|
241
|
+
return buildCatalogFrom(homeDir, GLOBAL_BROWSE_DIRS);
|
|
242
|
+
}
|
|
208
243
|
export { PathValidationError };
|
|
@@ -137,6 +137,56 @@ export function replaceDependsOnId(raw, oldId, newId) {
|
|
|
137
137
|
}
|
|
138
138
|
return changed ? bom + lines.join('\n') : raw;
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* member 移譲: 指定キーの frontmatter 行を `key: <to>` に置き換える (本文・他フィールドは保全)。
|
|
142
|
+
* どのキーを書き換えるかは呼び出し側が parse 済みの値 (=== from) で判定する (行置換は値を読まない)。
|
|
143
|
+
* 対象行が見つからない場合はそのキーを変更しない — 呼び出し側の verifyMemberRename が不一致を検出し
|
|
144
|
+
* 書込を拒否する (壊れた表記で silent に取りこぼさない)
|
|
145
|
+
*/
|
|
146
|
+
export function replaceMemberKeys(raw, keys, to) {
|
|
147
|
+
const bom = raw.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
148
|
+
const text = bom ? raw.slice(1) : raw;
|
|
149
|
+
const fm = frontmatterRange(text);
|
|
150
|
+
if (!fm)
|
|
151
|
+
return raw;
|
|
152
|
+
const { lines, close } = fm;
|
|
153
|
+
for (const key of keys) {
|
|
154
|
+
for (let i = 1; i < close; i++) {
|
|
155
|
+
const line = (lines[i] ?? '').replace(/\r$/, '');
|
|
156
|
+
if (line === `${key}:` || line.startsWith(`${key}: `) || line.startsWith(`${key}:\t`)) {
|
|
157
|
+
const rendered = `${key}: ${dumpScalar(to)}`;
|
|
158
|
+
lines[i] = (lines[i] ?? '').endsWith('\r') ? `${rendered}\r` : rendered;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return bom + lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* verify-after-member-rename ガード (verifyPinpointResult と同じ思想)。
|
|
167
|
+
* 「指定キーだけが to に変わり、他フィールドと本文は不変」を patch 前後の再 parse で検証する。
|
|
168
|
+
* 不一致なら理由文字列 (呼び出し側で書込を拒否)、問題なければ null。
|
|
169
|
+
*/
|
|
170
|
+
export function verifyMemberRename(before, after, keys, to) {
|
|
171
|
+
const keySet = new Set(keys);
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
if (after.data[key] !== to) {
|
|
174
|
+
return `行置換が ${key} を正しく書き換えられなかった (この表記はピンポイント書換に未対応)`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const allKeys = new Set([...Object.keys(before.data), ...Object.keys(after.data)]);
|
|
178
|
+
for (const key of allKeys) {
|
|
179
|
+
if (keySet.has(key))
|
|
180
|
+
continue;
|
|
181
|
+
if (!valuesEqual(before.data[key], after.data[key])) {
|
|
182
|
+
return `行置換が意図しないフィールド ${key} を変更した (この表記はピンポイント書換に未対応)`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (before.body !== after.body) {
|
|
186
|
+
return '行置換が本文を変更した (この表記はピンポイント書換に未対応)';
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
140
190
|
/**
|
|
141
191
|
* verify-after-patch ガード (PM Round 1 #2/#3/#6 への包括対処)。
|
|
142
192
|
*
|
|
@@ -42,6 +42,16 @@ export function replaceBody(raw, newBody) {
|
|
|
42
42
|
// gray-matter の content は閉じ --- 直後の 1 改行を取り除いた形なので、ここで 1 つ補う
|
|
43
43
|
return `${bom}${header}\n${newBody}`;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 差し戻し (やり直し依頼) の作業ログ行の本文を組み立てる純関数。
|
|
47
|
+
* 作業ログは行ベース (`- 時刻 [member] <text>`) なので、reason に含まれる改行は半角スペースへ畳み、
|
|
48
|
+
* 前後の空白を除く。reason が空 (未指定 / 空白のみ) なら理由なしの「差し戻し」だけを返す。
|
|
49
|
+
* (reject の `却下: ${reason}` パターンに対応する revise 版。reason は任意なのでこの分岐を持つ)
|
|
50
|
+
*/
|
|
51
|
+
export function reviseWorkLogText(reason) {
|
|
52
|
+
const normalized = (reason ?? '').replace(/[\r\n]+/g, ' ').trim();
|
|
53
|
+
return normalized.length > 0 ? `差し戻し: ${normalized}` : '差し戻し';
|
|
54
|
+
}
|
|
45
55
|
/**
|
|
46
56
|
* タスク本文の `## 作業ログ` セクション末尾に 1 行追記する。
|
|
47
57
|
* - セクションがあれば、その内容ブロックの末尾 (次見出し or EOF 直前) に挿入
|
|
@@ -3,11 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import { findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../../shared/schema.js';
|
|
4
4
|
import { loadConfig, loadTaskView } from '../board.js';
|
|
5
5
|
import { allocateNextId, createSerialQueue } from './id.js';
|
|
6
|
-
import { FileExistsError, safeWriteFile } from './fs-safe.js';
|
|
6
|
+
import { FileExistsError, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
|
|
7
7
|
import { applyPinpointPatch, verifyPinpointResult } from './pinpoint.js';
|
|
8
|
-
import { appendCommentLine, appendWorkLogLine, replaceBody } from './proposal.js';
|
|
8
|
+
import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './proposal.js';
|
|
9
9
|
import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './task-file.js';
|
|
10
10
|
import { nowIso, workLogTimestamp } from './time.js';
|
|
11
|
+
import { DEFAULT_TRASH_KEEP, snapshotToTrash } from './trash.js';
|
|
11
12
|
/**
|
|
12
13
|
* タスク操作のコアロジック (AI 用 CLI F-6-3 が使う)。
|
|
13
14
|
*
|
|
@@ -18,6 +19,18 @@ import { nowIso, workLogTimestamp } from './time.js';
|
|
|
18
19
|
* エラーは TaskOpError (code 付き) で投げる。N-8: メッセージにファイル本文・設定内容を含めない。
|
|
19
20
|
*/
|
|
20
21
|
const NEW_TASK_STATUS_DEFAULT = 'todo';
|
|
22
|
+
/**
|
|
23
|
+
* 差し戻し (revise) を許可する源 status の集合。proposed / backlog は除外する。
|
|
24
|
+
* proposed→todo を revise で迂回されると approve 承認フロー (proposed→todo) を骨抜きにし恒久
|
|
25
|
+
* mode_violation を生むため、CLI (TaskOps.revise) と HTTP ルート (app.ts) で同じ集合を共有する。
|
|
26
|
+
* 軽量モジュール (HTTP 非依存) であるここを唯一の源にし、app.ts はこれを import して drift を防ぐ。
|
|
27
|
+
*/
|
|
28
|
+
export const REVISABLE_STATUSES = new Set([
|
|
29
|
+
'todo',
|
|
30
|
+
'in_progress',
|
|
31
|
+
'pending',
|
|
32
|
+
'done',
|
|
33
|
+
]);
|
|
21
34
|
export class TaskOpError extends Error {
|
|
22
35
|
code;
|
|
23
36
|
/** zod 由来の検証メッセージ (フィールド名 + 制約。本文は含まない) */
|
|
@@ -43,6 +56,22 @@ export class TaskOps {
|
|
|
43
56
|
taskPath(id) {
|
|
44
57
|
return path.join(this.tasksDir, `${id}.md`);
|
|
45
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 破壊的書込 (move / revise) の直前に現内容を `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する
|
|
61
|
+
* 安全網 (ごみ箱)。HTTP ルート (app.ts) の snapshot ヘルパーと同型・同 trashKeep。
|
|
62
|
+
* ベストエフォート (退避失敗は本処理を止めない)。退避は元を消さない「コピー」。
|
|
63
|
+
*/
|
|
64
|
+
async snapshot(id, currentContent) {
|
|
65
|
+
try {
|
|
66
|
+
const kanbanReal = await resolveKanbanReal(this.projectRoot);
|
|
67
|
+
const { config } = await loadConfig(this.projectRoot);
|
|
68
|
+
const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
|
|
69
|
+
await snapshotToTrash(id, currentContent, this.kanbanDir, kanbanReal, keep);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
|
|
73
|
+
}
|
|
74
|
+
}
|
|
46
75
|
/** 起票 (採番直列化・schema 検証・members 整合)。POST /api/tasks と同じロジック */
|
|
47
76
|
async create(input) {
|
|
48
77
|
// CLI は起票時の status を任意指定できる (HTTP POST は todo 固定だが、AI が backlog/proposed 等で
|
|
@@ -149,6 +178,7 @@ export class TaskOps {
|
|
|
149
178
|
if (!check.success) {
|
|
150
179
|
throw new TaskOpError('invalid_input', 'ステータス変更後のタスクが不正', summarizeZodError(check.error));
|
|
151
180
|
}
|
|
181
|
+
await this.snapshot(id, raw); // 旧版を .trash へ退避 (ベストエフォート)
|
|
152
182
|
await safeWriteFile(filePath, patched, [this.kanbanDir]);
|
|
153
183
|
return loadTaskView(this.projectRoot, filePath);
|
|
154
184
|
});
|
|
@@ -157,6 +187,73 @@ export class TaskOps {
|
|
|
157
187
|
async appendLog(id, member, message) {
|
|
158
188
|
return this.appendToSection(id, member, message, 'log');
|
|
159
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* やり直し依頼 (差し戻し)。todo/in_progress/pending/done → todo へ戻し、理由を作業ログに残す。
|
|
192
|
+
* HTTP の POST /api/tasks/:id/revise と同型の単一書込でアトミックに行う:
|
|
193
|
+
* read → status を todo へ pinpoint (+ pending なら blocked_* を null へ) + 差し戻し理由を
|
|
194
|
+
* 作業ログへ追記 → 1 回の safeWriteFile。
|
|
195
|
+
* 旧実装は move(todo) + appendLog の 2 段書込 (別 enqueue) で、move 成功後 appendLog 失敗時に
|
|
196
|
+
* 「理由を失った差し戻し」部分状態を生みえた。HTTP 版と結果整合させ非アトミック性を解消する。
|
|
197
|
+
* proposed / backlog からの差し戻しは approve 承認フロー迂回を防ぐため conflict で弾く (app.ts と対称)。
|
|
198
|
+
*/
|
|
199
|
+
async revise(id, input = {}) {
|
|
200
|
+
if (!taskIdSchema.safeParse(id).success) {
|
|
201
|
+
throw new TaskOpError('invalid_input', '不正なタスク ID');
|
|
202
|
+
}
|
|
203
|
+
const filePath = this.taskPath(id);
|
|
204
|
+
return this.enqueue(async () => {
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
|
|
211
|
+
}
|
|
212
|
+
let current;
|
|
213
|
+
try {
|
|
214
|
+
current = parseTaskFile(raw);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
218
|
+
throw new TaskOpError('conflict', `差し戻しできない (${message})`);
|
|
219
|
+
}
|
|
220
|
+
// 源 status ガード: proposed / backlog からの差し戻しは承認フロー迂回になるため弾く (app.ts と対称)
|
|
221
|
+
if (!REVISABLE_STATUSES.has(current.data.status)) {
|
|
222
|
+
throw new TaskOpError('conflict', 'この状態のタスクは差し戻せません');
|
|
223
|
+
}
|
|
224
|
+
// status を todo へ pinpoint (本文・他フィールドは保全)。pending から戻す場合は blocked_* も null へ
|
|
225
|
+
const patch = { status: 'todo', updated: nowIso() };
|
|
226
|
+
if (current.data.blocked_type != null || current.data.blocked_reason != null) {
|
|
227
|
+
patch.blocked_type = null;
|
|
228
|
+
patch.blocked_reason = null;
|
|
229
|
+
}
|
|
230
|
+
let patched;
|
|
231
|
+
let patchedParsed;
|
|
232
|
+
try {
|
|
233
|
+
patched = applyPinpointPatch(raw, patch);
|
|
234
|
+
patchedParsed = parseTaskFile(patched);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const message = error instanceof Error ? error.message : '差し戻しに失敗';
|
|
238
|
+
throw new TaskOpError('conflict', `差し戻しできない (${message})`);
|
|
239
|
+
}
|
|
240
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
241
|
+
if (drift !== null) {
|
|
242
|
+
throw new TaskOpError('conflict', `差し戻しできない (${drift})`);
|
|
243
|
+
}
|
|
244
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
245
|
+
if (!check.success) {
|
|
246
|
+
throw new TaskOpError('invalid_input', '差し戻し後のタスクが不正', summarizeZodError(check.error));
|
|
247
|
+
}
|
|
248
|
+
// 差し戻し理由を作業ログへ追記 (frontmatter は pinpoint 済みなので body だけ更新する)。
|
|
249
|
+
// status 変更と理由ログを 1 回の safeWriteFile で書く = アトミック (HTTP 版と同型)
|
|
250
|
+
const logLine = `${workLogTimestamp()} [human] ${reviseWorkLogText(input.reason)}`;
|
|
251
|
+
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
252
|
+
await this.snapshot(id, raw); // 旧版を .trash へ退避 (ベストエフォート)
|
|
253
|
+
await safeWriteFile(filePath, finalRaw, [this.kanbanDir]);
|
|
254
|
+
return loadTaskView(this.projectRoot, filePath);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
160
257
|
/** やりとり追記 (## やりとり に 1 行)。member は config.members 検証 */
|
|
161
258
|
async appendComment(id, member, message) {
|
|
162
259
|
const { config } = await loadConfig(this.projectRoot);
|
|
@@ -199,6 +296,9 @@ export class TaskOps {
|
|
|
199
296
|
}
|
|
200
297
|
const line = `${workLogTimestamp()} [${member}] ${message}`;
|
|
201
298
|
const newBody = section === 'log' ? appendWorkLogLine(current.body, line) : appendCommentLine(current.body, line);
|
|
299
|
+
// SEC-N3: 追記も read→全文上書きのため、直前にディスク現内容を .trash へ退避する (move/revise と同様)。
|
|
300
|
+
// 外部編集と交錯して上書きで失われても復元できるようにする (ベストエフォート)。
|
|
301
|
+
await this.snapshot(id, raw);
|
|
202
302
|
await safeWriteFile(filePath, replaceBody(raw, newBody), [this.kanbanDir]);
|
|
203
303
|
return loadTaskView(this.projectRoot, filePath);
|
|
204
304
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectKanbanFilesNoSymlink, FileExistsError, safeWriteFile } from './fs-safe.js';
|
|
4
|
+
/**
|
|
5
|
+
* ごみ箱 (`.kanban/.trash/`): タスク本体を破壊的に書き換える前に旧版を `.trash/<id>.<epoch_ms>.md` へ
|
|
6
|
+
* コピー退避し、誤上書き / 誤操作からワンクリック復元できる安全網 (git commit していない限り
|
|
7
|
+
* 取り戻せない問題を塞ぐ。`.bak` は F-4 設定ファイル管理のみでタスクは無防備だった)。
|
|
8
|
+
*
|
|
9
|
+
* 設計の核:
|
|
10
|
+
* - **コピー退避 (rename でなく書込)**: 退避が失敗しても原本を失わない (ベストエフォート安全網)。
|
|
11
|
+
* - **N-2 厳守**: 退避先 (`.trash`) も `safeWriteFile([kanbanDir])` 経由で許可リスト封じ込め + symlink 非追従。
|
|
12
|
+
* 退避一覧・復元時の走査も `collectKanbanFilesNoSymlink` (realpath containment + symlink 非追従) を使う。
|
|
13
|
+
* - **世代の刈り取り**: 同 id の退避が trashKeep 件を超えたら古い順 (epoch 昇順) に削除する。
|
|
14
|
+
*
|
|
15
|
+
* `<epoch_ms>` は退避時刻のエポックミリ秒 (Date.now())。世代の新旧判定とファイル名の一意化を兼ねる。
|
|
16
|
+
*/
|
|
17
|
+
/** ごみ箱ディレクトリ名 (1 箇所集約) */
|
|
18
|
+
export const TRASH_DIR = '.trash';
|
|
19
|
+
/** 同 id あたりの退避世代の既定保持件数 (config.trashKeep で上書き可・コード 1 箇所集約) */
|
|
20
|
+
export const DEFAULT_TRASH_KEEP = 20;
|
|
21
|
+
/**
|
|
22
|
+
* 退避ファイル名 `<id>.<epoch_ms>.md` を {id, trashedAt} に分解する。
|
|
23
|
+
* 形式に合致しない (epoch が数字でない / .md でない) ファイルは null を返す (走査時に無視する)。
|
|
24
|
+
* id 自体に `.` を含みうる形式ではないが、保険として「末尾の <数字>.md」を epoch とみなし、
|
|
25
|
+
* その手前全てを id とする (id は `<prefix>-<digits>` なので `.` を含まない前提だが robust に切り出す)。
|
|
26
|
+
*/
|
|
27
|
+
export function parseTrashName(basename) {
|
|
28
|
+
if (!basename.endsWith('.md'))
|
|
29
|
+
return null;
|
|
30
|
+
const stem = basename.slice(0, -3); // ".md" を外す
|
|
31
|
+
const lastDot = stem.lastIndexOf('.');
|
|
32
|
+
if (lastDot <= 0 || lastDot >= stem.length - 1)
|
|
33
|
+
return null; // 区切りが無い / 端にある
|
|
34
|
+
const id = stem.slice(0, lastDot);
|
|
35
|
+
const epochStr = stem.slice(lastDot + 1);
|
|
36
|
+
if (!/^\d+$/.test(epochStr))
|
|
37
|
+
return null;
|
|
38
|
+
const trashedAt = Number(epochStr);
|
|
39
|
+
if (!Number.isFinite(trashedAt))
|
|
40
|
+
return null;
|
|
41
|
+
return { id, trashedAt };
|
|
42
|
+
}
|
|
43
|
+
/** 退避ファイル名を組み立てる (`<id>.<epoch_ms>.md`) */
|
|
44
|
+
function trashName(id, trashedAt) {
|
|
45
|
+
return `${id}.${trashedAt}.md`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `.trash/` 配下の退避ファイルを {id, trashedAt, absPath} の配列で列挙する (N-2 適用)。
|
|
49
|
+
* `collectKanbanFilesNoSymlink` で realpath containment + symlink 非追従に走査し、形式に合致する
|
|
50
|
+
* ファイルだけを返す。`.trash` 不在 / 空なら空配列。id 指定があればその id の世代だけに絞る。
|
|
51
|
+
*/
|
|
52
|
+
export async function listTrash(kanbanDir, kanbanReal, filterId) {
|
|
53
|
+
const trashRoot = path.join(kanbanDir, TRASH_DIR);
|
|
54
|
+
const files = await collectKanbanFilesNoSymlink(trashRoot, kanbanReal);
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const abs of files) {
|
|
57
|
+
const parsed = parseTrashName(path.basename(abs));
|
|
58
|
+
if (parsed === null)
|
|
59
|
+
continue;
|
|
60
|
+
if (filterId !== undefined && parsed.id !== filterId)
|
|
61
|
+
continue;
|
|
62
|
+
entries.push({ id: parsed.id, trashedAt: parsed.trashedAt, absPath: abs });
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* タスクの現内容を `.trash/<id>.<epoch_ms>.md` へコピー退避する (破壊的書込の直前に呼ぶ)。
|
|
68
|
+
* - 退避は **コピー** (`safeWriteFile`)。rename でないので退避失敗で原本を失わない。
|
|
69
|
+
* - N-2: 書込は `safeWriteFile([kanbanDir])` (許可リスト封じ込め + symlink 非追従)。`.trash` は mkdir で用意する。
|
|
70
|
+
* - 退避後、同 id の世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
|
|
71
|
+
*
|
|
72
|
+
* 失敗時は呼び出し側 (try/catch) で握りつぶし本処理を止めない想定 (ベストエフォート安全網)。
|
|
73
|
+
* 退避できた絶対パスを返す。
|
|
74
|
+
*/
|
|
75
|
+
export async function snapshotToTrash(id, currentContent, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
|
|
76
|
+
const trashRoot = path.join(kanbanDir, TRASH_DIR);
|
|
77
|
+
await fs.mkdir(trashRoot, { recursive: true });
|
|
78
|
+
// 同一ミリ秒・同 id で連続退避すると epoch_ms が衝突する。exclusive 書込が EEXIST (FileExistsError) なら
|
|
79
|
+
// epoch を 1 ずつ進めて空きを探す (退避は消失防止が目的なので既存世代を上書きしない)。
|
|
80
|
+
const base = Date.now();
|
|
81
|
+
let written = null;
|
|
82
|
+
for (let offset = 0; offset < 1000; offset++) {
|
|
83
|
+
const candidate = base + offset;
|
|
84
|
+
try {
|
|
85
|
+
written = await safeWriteFile(path.join(trashRoot, trashName(id, candidate)), currentContent, [kanbanDir], {
|
|
86
|
+
exclusive: true,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof FileExistsError)
|
|
92
|
+
continue; // 衝突 → 次の epoch を試す
|
|
93
|
+
throw error; // それ以外 (containment 違反等) は呼び出し側 (try/catch) へ
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (written === null) {
|
|
97
|
+
// 1000 連続衝突は現実的に起きないが、保険として通常書込で 1 回だけ救済する
|
|
98
|
+
written = await safeWriteFile(path.join(trashRoot, trashName(id, base + Date.now())), currentContent, [kanbanDir]);
|
|
99
|
+
}
|
|
100
|
+
await pruneTrash(id, kanbanDir, kanbanReal, trashKeep);
|
|
101
|
+
return written;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 同 id の退避世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
|
|
105
|
+
* trashKeep 以下なら何もしない。削除失敗 (残骸) はスキップして続行する (ベストエフォート)。
|
|
106
|
+
*/
|
|
107
|
+
export async function pruneTrash(id, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
|
|
108
|
+
if (!Number.isFinite(trashKeep) || trashKeep < 1)
|
|
109
|
+
return; // 不正値は刈り取りしない (退避を消さない安全側)
|
|
110
|
+
const entries = (await listTrash(kanbanDir, kanbanReal, id)).sort((a, b) => a.trashedAt - b.trashedAt);
|
|
111
|
+
const excess = entries.length - trashKeep;
|
|
112
|
+
if (excess <= 0)
|
|
113
|
+
return;
|
|
114
|
+
for (const entry of entries.slice(0, excess)) {
|
|
115
|
+
try {
|
|
116
|
+
await fs.rm(entry.absPath, { force: true });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// 消せない残骸はスキップ
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/server/watcher.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { watch } from 'chokidar';
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* 監視対象: タスクファイル (.md) と config.json のみ。アトミック書込の .tmp は無視する。
|
|
5
|
+
* watch ルート (.kanban) から見てドット始まりのディレクトリ配下 (例: ごみ箱 `.trash/<id>.<epoch>.md`)
|
|
6
|
+
* も無視する。退避コピーや世代刈り取りの unlink で SSE が誤発火し全量再取得が無駄に走るのを防ぐ
|
|
7
|
+
* (将来の内部 dir も同様に除外)。watch ルート自身が `.kanban` のため、ルート相対で判定する
|
|
8
|
+
* (絶対パスをそのまま見るとルートの `.kanban` 成分に引っかかり全ファイルが除外される)。
|
|
9
|
+
*/
|
|
10
|
+
export function isWatchTarget(rootDir, filePath) {
|
|
5
11
|
const base = path.basename(filePath);
|
|
6
|
-
|
|
12
|
+
if (!(base.endsWith('.md') || base === 'config.json'))
|
|
13
|
+
return false;
|
|
14
|
+
// ルート相対パスの中間ディレクトリ成分にドット始まりがあれば内部用とみなし除外する
|
|
15
|
+
const rel = path.relative(rootDir, filePath);
|
|
16
|
+
const dirSegments = rel.split(/[\\/]+/).slice(0, -1);
|
|
17
|
+
if (dirSegments.some((seg) => seg.startsWith('.')))
|
|
18
|
+
return false;
|
|
19
|
+
return true;
|
|
7
20
|
}
|
|
8
21
|
export function startWatcher(options) {
|
|
9
22
|
const { kanbanDir, bus } = options;
|
|
@@ -11,7 +24,7 @@ export function startWatcher(options) {
|
|
|
11
24
|
const watcher = watch(kanbanDir, { ignoreInitial: true });
|
|
12
25
|
let timer = null;
|
|
13
26
|
const onFsEvent = (filePath) => {
|
|
14
|
-
if (!isWatchTarget(filePath))
|
|
27
|
+
if (!isWatchTarget(kanbanDir, filePath))
|
|
15
28
|
return;
|
|
16
29
|
if (timer !== null)
|
|
17
30
|
clearTimeout(timer);
|