diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cf2b28..8d24ee16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.75.0 — 2026-10-03 + +### runtime errorの収集と送信がWindowsで動く(ADR 0194) + +- Windowsでは、Latticeの故障を1件も記録できなかった(0.73.0からは `collection: "unsupported"` と答えていた)。 + storeを本人だけが触れる形で置けると確かめる方法が無かったためである。Windowsでも記録し、送れるようにした。 +- 確かめ方: `icacls` でDACLを読み、本人・SYSTEM・Administratorsへの許可だけで出来ている時だけ使う。 + それ以外は、POSIXと同じく `store_unsafe` で止める。 +- 置き場(Windows): + - store: `%LOCALAPPDATA%\Lattice\runtime-errors\`。このフォルダは継承を切って本人・SYSTEM・Administrators + だけに絞る。他のaccountが触れる形で中身があるフォルダは、絞らずに止める。 + - 送信の設定: `%LOCALAPPDATA%\Lattice\runtime-error-reporting.json` + - 合鍵: `%LOCALAPPDATA%\bughub\product-credentials\lattice.json`。他のaccountが読める形なら + `credential_unsafe`(理由 `acl_not_owner_only`)で、送らない。 +- Windowsで収集を有効にするのは `lattice runtime-errors reporting enable --json` である。有効にするまでは + `collection: "disabled"` を返す。`unsupported` は、macOS・Linux・Windows以外のOSの答えとして残る。 +- macOS・Linuxの動きは変わらない。 +- 所有者は確かめない(`icacls` は所有者を返さない)。理由と範囲はADR 0194に書いた。 + ## 0.74.0 — 2026-10-03 ### runtime errorの送信をLattice自身が持つ(ADR 0193) diff --git a/docs/01_integration-package.md b/docs/01_integration-package.md index b3df97aa..212af217 100644 --- a/docs/01_integration-package.md +++ b/docs/01_integration-package.md @@ -223,8 +223,14 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 (schema `lattice.runtime_errors.v1`。Caveat同型の工場契約)。**opt-in**=工場共有config `~/.config/dotagents/factory-reporter.json`の`collection.enabled`か、Lattice自身の送信設定(下)の どちらかが有効な時だけ収集する。storeそのものは外部送信しない。 - 固定catalog 5 code・fingerprint集約・cursor/ack・resolved+ack済み30日compact・POSIX owner-only検査で + 固定catalog 5 code・fingerprint集約・cursor/ack・resolved+ack済み30日compact・owner-only検査で fail closed。正典は`src/runtime-errors.mjs` + - owner-only検査: storeは本人だけが触れる形でしか使わない。確かめられなければ`store_unsafe`で止める。 + POSIXはフォルダ0700・file 0600・所有者が本人。Windows(ADR 0194)は、DACLが本人・SYSTEM・Administratorsへの + 許可だけで出来ていること(`icacls /save`のSDDLで読む。正典は`src/windows-owner-only.mjs`)。 + - 置き場: `${XDG_STATE_HOME:-~/.local/state}/lattice/runtime-errors.json`。Windowsは + `%LOCALAPPDATA%\Lattice\runtime-errors\runtime-errors.json`で、このフォルダは継承を切って本人・SYSTEM・ + Administratorsだけに絞る。他のaccountが触れる形で中身があるフォルダは、絞らずに止める。 - runtime errorの送信(ADR 0193): `lattice runtime-errors report --json`と `lattice runtime-errors reporting --json`。Lattice自身が、未受領の記録を BugHubの製品報告の受け口へ送る。正典は`src/runtime-error-reporting.mjs` @@ -232,6 +238,10 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 `${XDG_CONFIG_HOME:-~/.config}/lattice/runtime-error-reporting.json`)で、BugHubの持ち主が置いた合鍵のfile (`~/.config/bughub/product-credentials/lattice.json`、本人所有・0600・symlinkでない)がある時だけ送る。 dotagentsの設定は読まない。宛先は合鍵のfileの`url`。 + - Windowsの置き場は、設定が`%LOCALAPPDATA%\Lattice\runtime-error-reporting.json`、合鍵が + `%LOCALAPPDATA%\bughub\product-credentials\lattice.json`(symlinkでなく、DACLが本人・SYSTEM・ + Administratorsだけ。他のaccountが読めれば`credential_unsafe`・理由`acl_not_owner_only`)。 + Windowsで収集を有効にするのはこの送信設定で、dotagentsがWindowsで使う設定の置き場は読まない。 - 秘密は通信に載せない。`Authorization: BugHub-HMAC-SHA256 key_id=…, ts=…, sig=…` (`sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列))`)。 - 本文は`schema_version`・`report_id`・`product_id`・`installed_version`・`observed_at`・`runtime_errors`・ @@ -241,11 +251,10 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 - 送る時機: 故障を記録した直後と、以後のCLI実行(`hooks`を除く)の終わりに、切り離した子processで送る。 1分に1回まで、同じ中身の送り直しは1時間に1回まで。手で打つ`report`はこの制限を見ない。 - `LATTICE_RUNTIME_ERROR_REPORTING=0`は、既定の置き場の送信設定を読まない(試験と自動化の口)。 - - Windowsは収集に対応しないので、送信も`unsupported`と答える。 - `diagnostics.collection`は`enabled`・`disabled`・`unsupported`の3値。`unsupported`は「このOSでは収集に - 対応しない」という製品の答えで、Windowsが返す(storeの所有者と権限をPOSIXの形で確かめられない)。 - 設定が有効でも記録は作らない。`status`・`cursor`・配列・`diagnostics`のキーは`disabled`の時と同じ形 - (`not_applicable`・すべて0・空)。受け側の前提はdotagents `49709de`以降。 + 対応しない」という製品の答えで、storeを本人だけに絞る方法を持たないOS(macOS・Linux・Windows以外)が返す。 + 送信も`unsupported`と答える。設定が有効でも記録は作らない。`status`・`cursor`・配列・`diagnostics`の + キーは`disabled`の時と同じ形(`not_applicable`・すべて0・空)。受け側の前提はdotagents `49709de`以降。 - 各記録は任意の`safe_context`を持つ: `command_kind`(落ちたCLIの面。`run.list`・`todo.start`等、 Latticeが持つ一覧の語だけ。無ければ`other`)、`error_kind`(例外の種類。一覧に無ければ`other`)、 `cause_code`(Nodeが付けるerror code。無ければ`none`)。付ける時は3つを必ずそろえる。 diff --git a/docs/adr/0193-product-owned-runtime-error-reporting.md b/docs/adr/0193-product-owned-runtime-error-reporting.md index 5baf85f6..1a694c1b 100644 --- a/docs/adr/0193-product-owned-runtime-error-reporting.md +++ b/docs/adr/0193-product-owned-runtime-error-reporting.md @@ -1,6 +1,7 @@ # ADR 0193: runtime errorの送信をLattice自身が持つ -- Status: accepted +- Status: accepted(Decision 9 と、Consequences の「Windowsの端末からは送れない」は + [ADR 0194](0194-runtime-error-store-on-windows.md) が置き換える) - Date: 2026-10-03 - Supersedes: runtime error storeの「reporting(BugHub送信)はdotagents adapter所有」 (`docs/01_integration-package.md` 5.5、`src/runtime-errors.mjs`冒頭) diff --git a/docs/adr/0194-runtime-error-store-on-windows.md b/docs/adr/0194-runtime-error-store-on-windows.md new file mode 100644 index 00000000..d7110ea2 --- /dev/null +++ b/docs/adr/0194-runtime-error-store-on-windows.md @@ -0,0 +1,83 @@ +# ADR 0194: runtime error storeをWindowsでも本人だけが触れる形で置く + +- Status: accepted +- Date: 2026-10-03 +- Supersedes: [ADR 0193](0193-product-owned-runtime-error-reporting.md) の Decision 9 + (Windowsは収集に対応しない)と、`src/runtime-errors.mjs`冒頭の「POSIX専用」 + +## Context + +runtime error storeは、本人だけが触れる形でしか使わない。POSIXではフォルダが0700、fileが0600、所有者が +本人であることを確かめ、確かめられなければ`store_unsafe`で止める。Windowsにはmodeもuidも無く、 +確かめる方法を持たなかったので、Windowsでは記録を1件も作らなかった(0.73.0からは`unsupported`と答える)。 +オーナーの端末にはWindowsが1台あり、そこで起きたLatticeの故障は誰にも届かなかった。オーナーはこれを +残っている不具合として扱うと裁定した。 + +Windowsで権限を表すのはDACL(誰に何を許すかの一覧)である。実機(Windows 11)で確かめたこと: + +- `icacls /save `は、DACLをSDDLで書き出す。SDDLは表示言語に依らない。 + `icacls `の画面表示は、accountの名前が表示言語で変わる。 +- `whoami /user /fo csv /nh`は、自分のSIDを返す。 +- 既定の`%LOCALAPPDATA%`の下に作ったフォルダは、親の権限を継ぐ。確かめた端末では、親が別のローカル + accountへ読み取りを継承で許していた。置くだけでは本人だけにならない。 +- フォルダの継承を切って本人・SYSTEM・Administratorsだけに絞ると、中に作るfileは同じ権限を引き継ぎ、 + renameで置き換えた後も保たれる。後から他のaccountへ権限を足すと、SDDLに現れる。 +- どちらのcommandも20ms前後で返る。PowerShellの`Get-Acl`は400ms前後かかり、起動のしかたによっては失敗した。 + +## Decision + +1. Windowsを収集の対象にする。`diagnostics.collection`は、Windowsでも設定に従って`enabled`か`disabled`を + 返す。`unsupported`は、storeを本人だけに絞る方法を持たないOS(macOS・Linux・Windows以外)の答えとして残す。 +2. Windowsの「本人だけ」は、**DACLが本人・SYSTEM・Administratorsへの許可だけで出来ていること**とする。 + SYSTEMとAdministratorsは、その端末のどのfileも読める立場なので、数に入れる。拒否・条件つき・object用の + ACE、DACLの無い形、SDDLとして読めない出力は、意味を確かめずにすべて通さない。 +3. DACLは`icacls /save`で、自分のSIDは`whoami /user`で読む。どちらも`%SystemRoot%\System32`の実物を + 絶対pathで呼ぶ。`icacls`はfileへしか書き出せないので、出力はstoreのフォルダへ置いてすぐ消す。 + 他のaccountが書ける場所(利用者の一時フォルダ等)へは置かない。 +4. storeは`%LOCALAPPDATA%\Lattice\runtime-errors\`という専用のフォルダへ置く。`%LOCALAPPDATA%\Lattice`には + 他の機能のfileがあり、フォルダごと絞れない。 + - フォルダは、継承を切り、本人・SYSTEM・Administratorsだけに絞る。空のフォルダ(作った直後)を絞る時は、 + 所有者も本人にする。 + - 他のaccountが触れる形なのに中身があるフォルダは、絞らずに`store_unsafe`で止める。中身を信用できない。 + - 絞った後に、誰かがフォルダやfileへ権限を足した時も`store_unsafe`で止める。Latticeは足された権限を消さない。 + - 確認は、POSIXと同じく読む時と書く時の毎回行う。 +5. 設定は`%LOCALAPPDATA%\Lattice\runtime-error-reporting.json`、合鍵はBugHubの契約どおり + `%LOCALAPPDATA%\bughub\product-credentials\lattice.json`から読む。`XDG_CONFIG_HOME`・`XDG_STATE_HOME`を + 明示した時は、どのOSでもそちらを使う。 +6. 合鍵のfileも同じ判定で確かめる。他のaccountが読める形なら`credential_unsafe`(理由`acl_not_owner_only`)、 + 確かめる場所(storeのフォルダ)を用意できなければ理由`acl_unverifiable`を返す。合鍵のフォルダへは何も書かない。 +7. Windowsで収集を有効にするのは、Lattice自身の送信設定(`runtime-errors reporting enable`)である。 + 工場の設定は`${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json`だけを読み、dotagentsが + Windowsで使う置き場(`%LOCALAPPDATA%\dotagents\factory-reporter\config.json`)は読まない。 + エラーを上げるのは製品の責務(ADR 0193)なので、工場の設定への依存を新しく足さない。 +8. 自動送信の判定は、送るものがあるかを先に見て、合鍵は最後に確かめる。Windowsでは合鍵の確認が + 外のprogramを起こすので、送るものが無い時のCLI実行に載せない。 + +## Consequences + +- **所有者は確かめない。** `icacls`は所有者を返さない。所有者は、DACLが本人だけでも、後から自分へ権限を + 足せる。これが効くのは、別のaccountが`%LOCALAPPDATA%\Lattice`へ書けて、storeのフォルダを先に作れる + 端末だけである。そういう端末では、同じaccountがその利用者のprogramを差し替えられるので、所有者を + 確かめても守れるものが増えない。足された権限は、次の確認で`store_unsafe`になる。 +- storeのfileが在る端末では、送信を有効にしている間、送るものが無い時でもCLIの実行ごとに`icacls`が2回と + `whoami`が1回走る(確かめた端末で合わせて50ms前後)。故障を1件も記録していない端末では走らない。 +- `icacls`か`whoami`が無い・失敗する端末では`store_unsafe`になり、記録を作らない。`diagnostics`は + `status: unavailable`を返す。本人のSIDがSDDLで別名(組み込みのAdministratorの`LA`等)で書かれる + accountも、本人と見分けられないので同じ扱いになる。 +- 0.73.0で`unsupported`を返していたWindowsの端末は、送信を有効にするまで`disabled`を返す。 + 受け側(dotagents)は3値とも受ける。 +- 「CLIが契約の外で落ちると記録される」試験は、Windowsでは走らない。試験が使う入力(`.lattice`がfile)は、 + Windowsでは`lstat`がENOENTを返し、契約内のerrorで返る。記録・置き場・権限は、Windowsの実機の試験が確かめる。 + +## Acceptance + +Windowsの実機(CIの`windows-native`)で確かめる。 + +- 他のaccountへ継承で読み取りを許すフォルダの下で記録すると、storeのフォルダは継承を切られ、 + フォルダもfileも本人・SYSTEM・Administratorsだけになる。記録は読め、置き換えの後も権限が保たれる。 +- storeのfileかフォルダへ他のaccountの権限を足すと、読むのも書くのも`store_unsafe`で止まる。 +- 親の権限を継いだままの空のフォルダは絞って使い、中身のあるものは使わない。 +- 合鍵へ他のaccountの読み取りを足すと`credential_unsafe`(`acl_not_owner_only`)になり、送らない。 +- 既定の置き場(`%LOCALAPPDATA%`)で、CLIから送信を有効にし、記録を読み、解決にできる。 +- 送信を有効にした端末で記録が出来ると、次のCLI実行の後に子processが届け、ackが進む。 +- SDDLの判定は、どのOSでも走る試験で固定する(`test/windows-owner-only.test.mjs`)。 diff --git a/package-lock.json b/package-lock.json index 5f3392a9..f4d764f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quolu/lattice", - "version": "0.74.0", + "version": "0.75.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quolu/lattice", - "version": "0.74.0", + "version": "0.75.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index c3f501ed..389ef39f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quolu/lattice", - "version": "0.74.0", + "version": "0.75.0", "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution", "author": { "name": "Quo / クオ at kitepon.dev", diff --git a/scripts/run-product-tests.mjs b/scripts/run-product-tests.mjs index ede63e38..add7cd3a 100644 --- a/scripts/run-product-tests.mjs +++ b/scripts/run-product-tests.mjs @@ -87,6 +87,10 @@ async function collectTests(directory, prefix = '') { return files; } +// 端末の本物のhost設定の置き場を指す変数(`src/setup-hosts.mjs`が読む)。試験は一時のHOMEを渡すが、 +// これらが残っていると、CLIはHOMEでなくそちらを使い、利用者の本物の設定を書き換える。 +export const HOST_CONFIG_ENV = Object.freeze(['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'GROK_HOME']); + export function productTestEnvironment(parentEnv = process.env) { // product gateはsuite単位ですでに全CPU並列である。各integration fixtureが // sensor init用WASM poolまで最大8本prewarmするとnested oversubscriptionになり、 @@ -96,6 +100,7 @@ export function productTestEnvironment(parentEnv = process.env) { const env = { ...parentEnv, LATTICE_DASHBOARD_AUTOSTART: '0', LATTICE_SENSOR_PARSE_WORKERS: '1', LATTICE_RUNTIME_ERROR_REPORTING: '0' }; delete env.FORCE_COLOR; + for (const name of HOST_CONFIG_ENV) delete env[name]; return env; } diff --git a/src/runtime-error-reporting.mjs b/src/runtime-error-reporting.mjs index dc6f3c33..854b84c9 100644 --- a/src/runtime-error-reporting.mjs +++ b/src/runtime-error-reporting.mjs @@ -1,10 +1,10 @@ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { - existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import http from 'node:http'; import https from 'node:https'; -import { homedir } from 'node:os'; +import { homedir, platform as hostPlatform } from 'node:os'; import { dirname, join } from 'node:path'; import { @@ -12,12 +12,14 @@ import { acknowledgeRuntimeErrors, ensureSafeDir, ensureSafeFile, + replaceStoreFile, runtimeErrorCollectionSupported, runtimeErrorReportingConfigPath, runtimeErrorReportingEnabled, runtimeErrorsDiagnostics, runtimeErrorsSnapshot, runtimeErrorsStatePath, + windowsLocalAppData, } from './runtime-errors.mjs'; /** @@ -51,13 +53,34 @@ const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'uns const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); -export function productCredentialPath(env = process.env) { - return join(env.HOME || homedir(), '.config', 'bughub', 'product-credentials', `${PRODUCT_ID}.json`); +/** 合鍵の置き場(BugHubの契約)。Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`。 */ +export function productCredentialPath(env = process.env, platform = hostPlatform()) { + const base = platform === 'win32' ? join(windowsLocalAppData(env), 'bughub') : join(env.HOME || homedir(), '.config', 'bughub'); + return join(base, 'product-credentials', `${PRODUCT_ID}.json`); } /** - * 合鍵のfileを読む。BugHubの持ち主が置く形(本人所有・0600・symlinkでない)以外は使わない。 - * 秘密は戻り値の中だけに留め、結果や記録へ写さない。 + * Windowsの合鍵が本人・SYSTEM・Administratorsだけのものか。DACLの読み取りは出力fileを置く場所が要るので、 + * 絞ったstoreのフォルダを使う(無ければ作る)。合鍵のフォルダへは何も書かない。 + */ +function windowsCredentialUnsafeReason(path, options) { + const storeDir = dirname(options.storePath ?? runtimeErrorsStatePath(options.env ?? process.env)); + try { + ensureSafeDir(storeDir); + } catch { + return 'acl_unverifiable'; + } + try { + ensureSafeFile(path, storeDir); + return null; + } catch { + return 'acl_not_owner_only'; + } +} + +/** + * 合鍵のfileを読む。BugHubの持ち主が置く形(symlinkでなく、POSIXは本人所有・0600、Windowsは + * 本人・SYSTEM・Administratorsだけに権限)以外は使わない。秘密は戻り値の中だけに留め、結果や記録へ写さない。 */ export function readProductCredential(options = {}) { const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env); @@ -68,8 +91,13 @@ export function readProductCredential(options = {}) { return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' }; } if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' }; - if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' }; - if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' }; + if (hostPlatform() === 'win32') { + const reason = windowsCredentialUnsafeReason(path, options); + if (reason !== null) return { status: 'unsafe', reason }; + } else { + if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' }; + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' }; + } let value; try { value = JSON.parse(readFileSync(path, 'utf8')); @@ -151,7 +179,7 @@ function writeDelivery(options, state) { const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`); try { writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' }); - renameSync(temporary, path); + replaceStoreFile(temporary, path); } finally { rmSync(temporary, { force: true }); } @@ -206,7 +234,7 @@ export function setRuntimeErrorReporting(enabled, options = {}) { const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`); try { writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' }); - renameSync(temporary, path); + replaceStoreFile(temporary, path); } finally { rmSync(temporary, { force: true }); } @@ -321,15 +349,16 @@ export async function reportRuntimeErrors(options = {}) { /** * 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、 - * 設定fileの有無を1回見るだけで終わる。 + * 設定fileの有無を1回見るだけで終わる。合鍵は、送るものがある時だけ確かめる——Windowsでは + * 合鍵の確認が外のprogramを起こすので、毎回のCLI実行には載せない。 */ export function runtimeErrorAutoReportDue(options = {}) { if (!runtimeErrorReportingEnabled(options)) return false; - if (readProductCredential(options).status !== 'ok') return false; try { const diagnostics = runtimeErrorsDiagnostics(options); if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false; - return throttleReason(options, readDelivery(options), diagnostics.high_watermark) === null; + if (throttleReason(options, readDelivery(options), diagnostics.high_watermark) !== null) return false; + return readProductCredential(options).status === 'ok'; } catch { return false; } diff --git a/src/runtime-errors.mjs b/src/runtime-errors.mjs index 3f4204ce..ca52c2af 100644 --- a/src/runtime-errors.mjs +++ b/src/runtime-errors.mjs @@ -1,10 +1,14 @@ import { createHash, randomBytes } from 'node:crypto'; import { - existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs'; import { homedir, platform as hostPlatform, arch as hostArch } from 'node:os'; import { dirname, join } from 'node:path'; +import { + daclIsOwnerOnly, daclIsProtected, isAclScratchName, readWindowsDacl, restrictWindowsDirToOwner, windowsSelfSid, +} from './windows-owner-only.mjs'; + /** * opt-in runtime error store(親plan L6要件。Caveat `caveat.runtime_errors.v1` と同型の工場契約)。 * @@ -15,8 +19,9 @@ import { dirname, join } from 'node:path'; * - privacy by design: 保存するのは固定catalogの `error_code` / `message_template` のみ。 * 生message・path・引数を保存しない。 * - retention: fingerprint集約(同一原因はcount/last_seen更新)+ack済みresolvedの30日compact。 - * - POSIX専用: Lattice runtimeはWindows nativeでunsupported(親plan L6)。owner-onlyを証明できない - * 環境では `store_unsafe` でfail closedする。 + * - owner-only: storeは本人だけが触れる形でしか使わない。POSIXはmode(0700・0600)と所有者、Windowsは + * DACL(本人・SYSTEM・Administratorsだけ、ADR 0194)で確かめ、確かめられなければ `store_unsafe` で + * fail closedする。 */ const RUNTIME_ERRORS_SCHEMA = 'lattice.runtime_errors.v1'; @@ -54,7 +59,9 @@ const plain = (value) => typeof value === 'object' && value !== null && !Array.i const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; const validVersion = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); -const validOs = (value) => typeof value === 'string' && ['darwin', 'linux'].includes(value); +// 収集に対応するOS。storeを本人だけが触れる形で置けると確かめられるものだけを載せる。 +const SUPPORTED_OS = Object.freeze(['darwin', 'linux', 'win32']); +const validOs = (value) => typeof value === 'string' && SUPPORTED_OS.includes(value); const validArch = (value) => typeof value === 'string' && ['x64', 'arm64', 'arm'].includes(value); export function defaultFactoryReporterConfigPath(env = process.env) { @@ -62,9 +69,19 @@ export function defaultFactoryReporterConfigPath(env = process.env) { return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json'); } -export function runtimeErrorsStatePath(env = process.env) { - const home = env.HOME || homedir(); - return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'lattice', 'runtime-errors.json'); +/** Windowsで利用者ごとのdataを置く場所(`%LOCALAPPDATA%`)。 */ +export function windowsLocalAppData(env = process.env) { + return env.LOCALAPPDATA || join(env.USERPROFILE || homedir(), 'AppData', 'Local'); +} + +/** + * storeの置き場。Windowsは`%LOCALAPPDATA%\Lattice\runtime-errors\`という専用のフォルダへ置く + * ——`%LOCALAPPDATA%\Lattice`には他の機能のfileがあり、フォルダごと本人だけに絞れない。 + */ +export function runtimeErrorsStatePath(env = process.env, platform = hostPlatform()) { + if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'lattice', 'runtime-errors.json'); + if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-errors', 'runtime-errors.json'); + return join(env.HOME || homedir(), '.local', 'state', 'lattice', 'runtime-errors.json'); } function canonicalReporting(value) { @@ -77,19 +94,19 @@ function canonicalReporting(value) { return !value.enabled || (value.endpoint !== undefined && value.credential_file !== undefined); } -// このOSで収集に対応するか。Windowsはstoreの所有者と権限をPOSIXの形で確かめられない(`ensureSafeDir`)ので -// 対応しない。設定が有効でも記録は作らず、工場へは`disabled`でなく`unsupported`と答える——設定は有効なのに -// 製品が黙って無効と答えると、受け側は故障と区別できない。 -const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32'; +// このOSで収集に対応するか。対応しないOSでは、設定が有効でも記録は作らず、`disabled`でなく`unsupported`と +// 答える——設定は有効なのに製品が黙って無効と答えると、受け側は故障と区別できない。 +const collectionSupported = (options = {}) => SUPPORTED_OS.includes(options.platform ?? hostPlatform()); const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported'); export function runtimeErrorCollectionSupported(options = {}) { return collectionSupported(options); } -export function runtimeErrorReportingConfigPath(env = process.env) { - const home = env.HOME || homedir(); - return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'lattice', 'runtime-error-reporting.json'); +export function runtimeErrorReportingConfigPath(env = process.env, platform = hostPlatform()) { + if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'lattice', 'runtime-error-reporting.json'); + if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-error-reporting.json'); + return join(env.HOME || homedir(), '.config', 'lattice', 'runtime-error-reporting.json'); } /** @@ -175,18 +192,63 @@ function assertPosix(info, mode) { if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe'); } +const windows = () => hostPlatform() === 'win32'; + +/** + * Windowsのフォルダが本人・SYSTEM・Administratorsだけのもので、親からの継承を切ってあるか確かめる。 + * 作ったばかりのフォルダは親の権限を継いでいるので、絞る。ただし、他のaccountが触れる形なのに中身がある + * フォルダは、その中身を信用できないので、絞らずに止める。 + */ +function ensureWindowsDirOwnerOnly(dir) { + const sid = windowsSelfSid(); + const settled = (sddl) => daclIsOwnerOnly(sddl, sid) && daclIsProtected(sddl); + const current = readWindowsDacl(dir, dir); + if (settled(current)) return; + if (!daclIsOwnerOnly(current, sid) && readdirSync(dir).some((name) => !isAclScratchName(name))) throw Error('store_unsafe'); + restrictWindowsDirToOwner(dir, sid); + if (!settled(readWindowsDacl(dir, dir))) throw Error('store_unsafe'); +} + export function ensureSafeDir(dir) { - if (hostPlatform() === 'win32') throw Error('store_unsafe'); mkdirSync(dir, { recursive: true, mode: 0o700 }); const stats = lstatSync(dir); + // Windowsのjunctionも`isSymbolicLink`で落ちる。 if (!stats.isDirectory() || stats.isSymbolicLink()) throw Error('store_unsafe'); - assertPosix(stats, 0o700); + if (windows()) ensureWindowsDirOwnerOnly(dir); + else assertPosix(stats, 0o700); } -export function ensureSafeFile(path) { +/** + * `scratchDir`はWindowsだけが使う: DACLの読み取りが出力fileを置くフォルダ。既定はそのfileのフォルダ。 + * storeの外のfile(合鍵)を確かめる時は、絞ってあるstoreのフォルダを渡す。 + */ +export function ensureSafeFile(path, scratchDir = dirname(path)) { const stats = lstatSync(path); if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe'); - assertPosix(statSync(path), 0o600); + if (!windows()) { + assertPosix(statSync(path), 0o600); + return; + } + const sid = windowsSelfSid(); + // 出力fileを置くフォルダを他のaccountが書けるなら、読んだDACLを信用できない。先にそれを確かめる。 + if (!daclIsOwnerOnly(readWindowsDacl(scratchDir, scratchDir), sid) + || !daclIsOwnerOnly(readWindowsDacl(path, scratchDir), sid)) throw Error('store_unsafe'); +} + +/** + * 一時fileを本番の名前へ置き換える。Windowsは、読み手が開いている宛先へのrenameを一時的に断るので、 + * 少し待って繰り返す(`fs-publish.mjs`と同じ事情)。 + */ +export function replaceStoreFile(source, destination) { + for (let attempt = 0; ; attempt++) { + try { + renameSync(source, destination); + return; + } catch (error) { + if (!windows() || !['EPERM', 'EACCES', 'EBUSY'].includes(error?.code) || attempt >= 7) throw error; + sleepSync(Math.min(64, 2 ** attempt)); + } + } } const RECORD_KEYS = Object.freeze(['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence']); @@ -235,8 +297,9 @@ function writeStore(path, store) { const temporary = join(dirname(path), `.runtime-errors-${process.pid}-${randomBytes(6).toString('hex')}`); try { writeFileSync(temporary, `${JSON.stringify(store)}\n`, { mode: 0o600, flag: 'wx' }); - assertPosix(statSync(temporary), 0o600); - renameSync(temporary, path); + // Windowsの一時fileはフォルダの権限を引き継ぐ。置き換えた後の`ensureSafeFile`が確かめる。 + if (!windows()) assertPosix(statSync(temporary), 0o600); + replaceStoreFile(temporary, path); ensureSafeFile(path); } finally { rmSync(temporary, { force: true }); @@ -256,9 +319,15 @@ function lock(path, fn) { writeFileSync(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }); break; } catch (error) { - if (!plain(error) || error.code !== 'EEXIST') throw error; + // Windowsは、消している最中のlockと同じ名前の作成を`EEXIST`でなく`EPERM`で断る。 + if (!plain(error) || !(error.code === 'EEXIST' || (windows() && ['EPERM', 'EACCES'].includes(error.code)))) throw error; let age = 0; - try { age = Date.now() - lstatSync(lockPath).mtimeMs; } catch { continue; } + try { + age = Date.now() - lstatSync(lockPath).mtimeMs; + } catch { + if (Date.now() >= deadline) throw Error('store_locked'); + continue; + } // crash残置lockの恒久ロックを避ける唯一の明示救済。閾値未満は正当な並行writerとして待つ。 if (age > LOCK_STALE_MS) { try { unlinkSync(lockPath); } catch {} continue; } if (Date.now() >= deadline) throw Error('store_locked'); diff --git a/src/windows-owner-only.mjs b/src/windows-owner-only.mjs new file mode 100644 index 00000000..ba68c388 --- /dev/null +++ b/src/windows-owner-only.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Windowsで「本人・SYSTEM・Administratorsだけが触れる」ことを確かめる(ADR 0194)。 + * + * POSIXのmode・uidに当たるものがWindowsには無いので、DACL(誰に何を許すかの一覧)を読む。 + * - 読み方は`icacls /save `。SDDLという、表示言語に依らない形で返る。 + * `icacls `の画面表示は名前が表示言語で変わるので使わない。 + * - 自分のSIDは`whoami /user`で得る。どちらも`%SystemRoot%\System32`の実物を絶対pathで呼ぶ + * ——PATHに置かれた同名のprogramへ答えを作らせない。 + * - 所有者は読まない(`icacls`は返さない)。空のフォルダを絞る時に所有者を本人へ替える。 + */ + +const SELF_SID = /^S-1-[0-9]+(?:-[0-9]+)+$/; +const SYSTEM_SID = 'S-1-5-18'; +const ADMINISTRATORS_SID = 'S-1-5-32-544'; +const COMMAND_TIMEOUT_MS = 10_000; +// `icacls /save`の出力fileの名前。フォルダが空かを見る時、同時に走る別processのこのfileは数えない。 +const SCRATCH_NAME = /^\.acl-\d+-[0-9a-f]{12}$/; + +let cachedSelfSid = null; + +function systemTool(name, env = process.env) { + return join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', name); +} + +function run(tool, args) { + const result = spawnSync(systemTool(tool), args, { encoding: 'utf8', windowsHide: true, timeout: COMMAND_TIMEOUT_MS }); + if (result.error || result.status !== 0) throw Error('store_unsafe'); + return result.stdout; +} + +/** このprocessを動かしている利用者のSID。process中は変わらないので1回だけ聞く。 */ +export function windowsSelfSid() { + if (cachedSelfSid === null) { + const sid = run('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim().split(',').at(-1).replaceAll('"', ''); + if (!SELF_SID.test(sid)) throw Error('store_unsafe'); + cachedSelfSid = sid; + } + return cachedSelfSid; +} + +/** + * SDDLのDACLが、本人・SYSTEM・Administratorsへの許可だけで出来ているか。 + * 読めない形(DACL無し・拒否・条件つき・object用のACE)はすべて「確かめられない」として通さない。 + */ +export function daclIsOwnerOnly(sddl, selfSid) { + if (typeof sddl !== 'string' || !SELF_SID.test(selfSid)) return false; + const match = /^D:(?:P|AR|AI)*((?:\([^()]*\))+)$/.exec(sddl); + if (match === null) return false; + const trusted = new Set([selfSid, 'SY', SYSTEM_SID, 'BA', ADMINISTRATORS_SID]); + return match[1].slice(1, -1).split(')(').every((ace) => { + const fields = ace.split(';'); + return fields.length === 6 && fields[0] === 'A' && fields[3] === '' && fields[4] === '' && trusted.has(fields[5]); + }); +} + +/** 親からの継承を切ってあるか(`P`)。切ってあれば、親の権限が後から変わっても降りてこない。 */ +export function daclIsProtected(sddl) { + return typeof sddl === 'string' && /^D:(?:AR|AI)*P/.test(sddl); +} + +export const isAclScratchName = (name) => SCRATCH_NAME.test(name); + +/** + * `path`のDACLをSDDLで読む。`icacls`はfileへしか書き出せないので、出力は`scratchDir`へ置いてすぐ消す。 + * `scratchDir`には、他のaccountが書けないフォルダ(絞ったstoreのフォルダ)を渡す——他のaccountが書ける + * 場所へ置くと、読む前に中身を書き換えられる。 + */ +export function readWindowsDacl(path, scratchDir) { + const scratch = join(scratchDir, `.acl-${process.pid}-${randomBytes(6).toString('hex')}`); + try { + run('icacls.exe', [path, '/save', scratch]); + const lines = readFileSync(scratch, 'utf16le').replaceAll('\ufeff', '').split(/\r?\n/).filter((line) => line !== ''); + // 1行目が名前、2行目がSDDL。それ以外の形は読めなかったものとして扱う。 + if (lines.length !== 2) throw Error('store_unsafe'); + return lines[1]; + } finally { + rmSync(scratch, { force: true }); + } +} + +/** + * フォルダを本人・SYSTEM・Administratorsだけに絞る。親からの継承を切り、所有者を本人にする。 + * 中に作るfileはこの権限を引き継ぎ、renameで置き換えた後も保たれる。 + * 誰かがそのフォルダへ直接足した権限は消さない——呼び出し側が絞った後のDACLを読み直して、残っていれば止める。 + */ +export function restrictWindowsDirToOwner(dir, selfSid) { + const sids = [...new Set([selfSid, SYSTEM_SID, ADMINISTRATORS_SID])]; + run('icacls.exe', [dir, '/inheritance:r', '/grant:r', ...sids.map((sid) => `*${sid}:(OI)(CI)F`)]); + run('icacls.exe', [dir, '/setowner', `*${selfSid}`]); +} diff --git a/test/hooks-cli.test.mjs b/test/hooks-cli.test.mjs index f290d42e..899b2002 100644 --- a/test/hooks-cli.test.mjs +++ b/test/hooks-cli.test.mjs @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import nodeTest, { after, before } from 'node:test'; +import { HOST_CONFIG_ENV } from '../scripts/run-product-tests.mjs'; import { resolveStableNodePath, runHooksCli } from '../src/hooks-cli.mjs'; const test = process.platform === 'win32' ? nodeTest.skip : nodeTest; @@ -79,8 +80,12 @@ after(async () => { function isolatedEnv({ home = suiteHome, stateHome = suiteState, configHome = suiteConfig, extraEnv = {} } = {}) { + // 端末の本物のhost設定の置き場を指す変数は渡さない。残っていると、CLIは下のHOMEでなくそちらを使い、 + // 利用者の本物の設定を書き換える。 + const inherited = { ...process.env }; + for (const name of HOST_CONFIG_ENV) delete inherited[name]; const env = { - ...process.env, + ...inherited, ...extraEnv, HOME: home, XDG_STATE_HOME: stateHome, @@ -598,6 +603,38 @@ test('P4 F6: pending receipt回復はlock取得後にconfigを再読してcommit }); // C3-1: host home dir不在はHOST_NOT_PRESENT exit 1で、dirを作らない。 +// 試験を起こした端末が`CODEX_HOME`等を持っていても、CLIへ渡すのは試験のHOMEだけである。 +// 2026-10-03: `CODEX_HOME`を持つ端末でこの試験を走らせ、共有の本物のCodex hooks.jsonを書き換えた。 +test('試験のCLIは、端末のCODEX_HOME・CLAUDE_CONFIG_DIR・GROK_HOMEが指す本物の設定へ触れない', async (t) => { + const decoy = await mkdtemp(path.join(tmpdir(), 'lattice-hooks-decoy-')); + t.after(() => rm(decoy, { recursive: true, force: true })); + const saved = Object.fromEntries(HOST_CONFIG_ENV.map((name) => [name, process.env[name]])); + t.after(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + // 本物の代わりのフォルダを指させる。中へ設定を置き、試験の後も1 byteも変わっていないことを見る。 + const decoyHooks = path.join(decoy, 'hooks.json'); + const decoySettings = path.join(decoy, 'settings.json'); + await writeFile(decoyHooks, FOREIGN_CODEX_HOOKS, { mode: 0o600 }); + await writeFile(decoySettings, '{}\n', { mode: 0o600 }); + for (const name of HOST_CONFIG_ENV) process.env[name] = decoy; + const before = [await snapshotFile(decoyHooks), await snapshotFile(decoySettings)]; + + for (const host of ['codex', 'claude']) { + const fixture = await hooksFixture(t, host, { config: host === 'codex' ? FOREIGN_CODEX_HOOKS : '{}\n' }); + assert.equal(runCli(['hooks', 'install', '--host', host], options(fixture)).status, 0, host); + assert.equal(handlers(await readJson(fixture.configPath)) + .filter((item) => commandArgv(item.command).includes('hooks')).length, 1, host); + assert.equal(runCli(['hooks', 'uninstall', '--host', host], options(fixture)).status, 0, host); + } + + assert.deepEqual([await snapshotFile(decoyHooks), await snapshotFile(decoySettings)], before); + assert.deepEqual((await readdir(decoy)).sort(), ['hooks.json', 'settings.json']); +}); + test('P3 C3-1: host home dir不在は HOST_NOT_PRESENT exit 1、設定dirを作らずに終了する', async (t) => { for (const host of ['claude', 'codex', 'cursor']) { const fixture = await hooksFixture(t, host, { createHost: false }); diff --git a/test/product-test-runner.test.mjs b/test/product-test-runner.test.mjs index 31069a1b..7853901a 100644 --- a/test/product-test-runner.test.mjs +++ b/test/product-test-runner.test.mjs @@ -32,6 +32,16 @@ test('product test child envはFORCE_COLORを除去しdashboard autostartを無 }); }); +test('product test child envは、端末の本物のhost設定を指す変数を渡さない', () => { + // これらが残ると、一時のHOMEを渡した試験のCLIが、利用者の本物のCodex・Claude・Grokの設定を書き換える。 + const parentEnv = { PATH: '/fixture/bin', CODEX_HOME: '/real/.codex', CLAUDE_CONFIG_DIR: '/real/.claude', + GROK_HOME: '/real/.grok' }; + const childEnv = productTestEnvironment(parentEnv); + for (const name of ['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'GROK_HOME']) assert.equal(Object.hasOwn(childEnv, name), false, name); + assert.equal(childEnv.PATH, '/fixture/bin'); + assert.equal(parentEnv.CODEX_HOME, '/real/.codex'); +}); + test('環境別profileはfocused再現用のsuiteだけを選びcoreの総当たりを複製しない', () => { const retired = 'control-compiler.test.mjs'; const all = [ diff --git a/test/runtime-error-reporting.test.mjs b/test/runtime-error-reporting.test.mjs index bca96692..53f35a36 100644 --- a/test/runtime-error-reporting.test.mjs +++ b/test/runtime-error-reporting.test.mjs @@ -16,6 +16,7 @@ import { setRuntimeErrorStatus, } from '../src/runtime-errors.mjs'; import { + productCredentialPath, receiptSignatureMatches, reportRuntimeErrors, runtimeErrorAutoReportDue, @@ -23,9 +24,9 @@ import { setRuntimeErrorReporting, signReport, } from '../src/runtime-error-reporting.mjs'; +import { restrictWindowsDirToOwner, windowsSelfSid } from '../src/windows-owner-only.mjs'; -// storeはPOSIX専用(Windowsは`unsupported`)。送信の試験はPOSIXだけで走らせ、署名と対象外の答えはどのOSでも確かめる。 -const posixTest = process.platform === 'win32' ? nodeTest.skip : nodeTest; +const windows = process.platform === 'win32'; const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'lattice.mjs'); const CLI_FAILED = 'LATTICE.CLI_INTERNAL_FAILED'; const SECRET = 'bughub-test-secret-do-not-use-0123456789abcdef'; @@ -49,7 +50,7 @@ nodeTest('署名と応答の署名は、BugHubの契約の試験値と一致す nodeTest('収集に対応しないOSでは、送信も対象外と答え、設定を書かない', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-win-')); try { - const options = { platform: 'win32', reportingConfigPath: path.join(root, 'reporting.json'), + const options = { platform: 'freebsd', reportingConfigPath: path.join(root, 'reporting.json'), credentialPath: path.join(root, 'lattice.json'), storePath: path.join(root, 'state', 'runtime-errors.json') }; assert.equal((await reportRuntimeErrors(options)).outcome, 'unsupported'); assert.equal(runtimeErrorReportingStatus(options).reporting, 'unsupported'); @@ -88,6 +89,13 @@ const signedReceipt = (entry, extra = {}) => { received_at: receivedAt, sig: hmac(`${entry.body.report_id}\n${receivedAt}`), ...extra } }; }; +/** BugHubの持ち主が合鍵を置く形で置く: POSIXは0600、Windowsは本人・SYSTEM・Administratorsだけのフォルダの中。 */ +async function placeCredential(credentialPath, content) { + await mkdir(path.dirname(credentialPath), { recursive: true }); + if (windows) restrictWindowsDirToOwner(path.dirname(credentialPath), windowsSelfSid()); + await writeFile(credentialPath, JSON.stringify(content), { mode: 0o600 }); +} + async function makeWorkspace(intakeUrl, { enabled = true, credential = true } = {}) { const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-')); const reportingConfigPath = path.join(root, 'config', 'runtime-error-reporting.json'); @@ -96,17 +104,14 @@ async function makeWorkspace(intakeUrl, { enabled = true, credential = true } = configPath: path.join(root, 'config', 'factory-reporter.json'), storePath: path.join(root, 'state', 'runtime-errors.json') }; if (enabled) setRuntimeErrorReporting(true, options); - if (credential) { - await mkdir(path.dirname(credentialPath), { recursive: true }); - await writeFile(credentialPath, JSON.stringify({ url: intakeUrl, key_id: KEY_ID, secret: SECRET }), { mode: 0o600 }); - } + if (credential) await placeCredential(credentialPath, { url: intakeUrl, key_id: KEY_ID, secret: SECRET }); return { root, options, credentialPath }; } const fail = (options, commandKind, code) => recordRuntimeError(CLI_FAILED, { ...options, safeContext: runtimeErrorSafeContext({ commandKind, error: Object.assign(new Error('x'), { code }) }) }); -posixTest('既定では通信しない: 送信を有効にしていない端末と、合鍵の無い端末は送らない', async () => { +nodeTest('既定では通信しない: 送信を有効にしていない端末と、合鍵の無い端末は送らない', async () => { const intake = await startIntake(signedReceipt); const disabled = await makeWorkspace(intake.url, { enabled: false }); const noCredential = await makeWorkspace(intake.url, { credential: false }); @@ -130,19 +135,28 @@ posixTest('既定では通信しない: 送信を有効にしていない端末 } }); -posixTest('合鍵のfileは、本人所有・0600・symlinkでない形だけを使う', async () => { +nodeTest('合鍵のfileは、本人だけが読める形(POSIXは本人所有・0600)でsymlinkでないものだけを使う', async () => { const intake = await startIntake(signedReceipt); const workspace = await makeWorkspace(intake.url); try { fail(workspace.options, 'run.list', 'ENOENT'); - await chmod(workspace.credentialPath, 0o644); + // 他のaccountが読める形にする: POSIXはmodeを広げ、WindowsはUsers(S-1-5-32-545)へ読み取りを足す。 + const icacls = (...args) => assert.equal(spawnSync('icacls', [workspace.credentialPath, ...args]).status, 0); + if (windows) icacls('/grant', '*S-1-5-32-545:R'); + else await chmod(workspace.credentialPath, 0o644); assert.deepEqual([(await reportRuntimeErrors(workspace.options)).outcome, - runtimeErrorReportingStatus(workspace.options).credential_reason], ['credential_unsafe', 'mode_not_0600']); + runtimeErrorReportingStatus(workspace.options).credential_reason], + ['credential_unsafe', windows ? 'acl_not_owner_only' : 'mode_not_0600']); + assert.equal(runtimeErrorAutoReportDue(workspace.options), false); - await chmod(workspace.credentialPath, 0o600); + if (windows) icacls('/remove', '*S-1-5-32-545'); + else await chmod(workspace.credentialPath, 0o600); + assert.equal(runtimeErrorReportingStatus(workspace.options).credential, 'present'); const linked = path.join(workspace.root, 'credentials', 'linked.json'); - await symlink(workspace.credentialPath, linked); - assert.equal((await reportRuntimeErrors({ ...workspace.options, credentialPath: linked })).reason, 'not_regular_file'); + // Windowsは、権限の無いaccountにsymlinkを作らせない。作れた時だけ確かめる。 + const made = await symlink(workspace.credentialPath, linked).then(() => true, + (error) => { if (!windows || error.code !== 'EPERM') throw error; return false; }); + if (made) assert.equal((await reportRuntimeErrors({ ...workspace.options, credentialPath: linked })).reason, 'not_regular_file'); for (const broken of [ { url: intake.url, key_id: KEY_ID, secret: 'short' }, @@ -160,7 +174,7 @@ posixTest('合鍵のfileは、本人所有・0600・symlinkでない形だけを } }); -posixTest('未受領の記録を署名つきで送り、署名つきの受領でだけ受領済みにする', async () => { +nodeTest('未受領の記録を署名つきで送り、署名つきの受領でだけ受領済みにする', async () => { const intake = await startIntake(signedReceipt); const workspace = await makeWorkspace(intake.url); try { @@ -218,7 +232,7 @@ posixTest('未受領の記録を署名つきで送り、署名つきの受領で } }); -posixTest('受領を確かめられない応答では受領済みにせず、後から今の累計を送り直す', async () => { +nodeTest('受領を確かめられない応答では受領済みにせず、後から今の累計を送り直す', async () => { const answers = [ (entry) => ({ status: 200, body: { accepted: true, report_id: entry.body.report_id, received_at: '2026-10-03T09:00:01.000Z', sig: 'f'.repeat(64) } }), (entry) => signedReceipt({ body: { report_id: '00000000-0000-4000-8000-000000000009' } }), @@ -262,7 +276,7 @@ posixTest('受領を確かめられない応答では受領済みにせず、後 } }); -posixTest('自動送信は1分に1回まで、同じ中身の送り直しは1時間に1回まで', async () => { +nodeTest('自動送信は1分に1回まで、同じ中身の送り直しは1時間に1回まで', async () => { let accept = false; const intake = await startIntake((entry) => (accept ? signedReceipt(entry) : { status: 503, body: { error: 'unavailable' } })); const workspace = await makeWorkspace(intake.url); @@ -297,11 +311,12 @@ posixTest('自動送信は1分に1回まで、同じ中身の送り直しは1時 } }); -posixTest('CLI: 送信を有効にした端末では、故障を記録した直後に切り離した子processが送る', async () => { +nodeTest('CLI: 送信を有効にした端末では、故障を記録した直後に切り離した子processが送る', async () => { const intake = await startIntake(signedReceipt); const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-cli-')); try { const env = { ...process.env, NO_COLOR: '1', HOME: root, LATTICE_DASHBOARD_AUTOSTART: '0', + USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData', 'Local'), XDG_CONFIG_HOME: path.join(root, '.config'), XDG_STATE_HOME: path.join(root, 'xdg-state') }; delete env.LATTICE_RUNTIME_ERROR_REPORTING; const cli = (args, cwd = root) => spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: 'utf8', env }); @@ -316,10 +331,8 @@ posixTest('CLI: 送信を有効にした端末では、故障を記録した直 assert.equal(cli(['todo', 'status', '--json'], repo).status, 1); assert.equal(json(cli(['runtime-errors', 'diagnostics', '--json'])).collection, 'disabled'); - const credentialDir = path.join(root, '.config', 'bughub', 'product-credentials'); - await mkdir(credentialDir, { recursive: true }); - await writeFile(path.join(credentialDir, 'lattice.json'), - JSON.stringify({ url: intake.url, key_id: KEY_ID, secret: SECRET }), { mode: 0o600 }); + // 合鍵はBugHubの契約の置き場へ置く(Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`)。 + await placeCredential(productCredentialPath(env), { url: intake.url, key_id: KEY_ID, secret: SECRET }); const enabled = json(cli(['runtime-errors', 'reporting', 'enable', '--json'])); assert.deepEqual([enabled.reporting, enabled.credential, enabled.collection], ['enabled', 'present', 'enabled']); assert.equal((await readFile(path.join(root, '.config', 'lattice', 'runtime-error-reporting.json'), 'utf8')).trim(), @@ -327,8 +340,15 @@ posixTest('CLI: 送信を有効にした端末では、故障を記録した直 assert.equal(intake.requests.length, 0); // 故障を1件起こす。CLIは送信を待たずに返り、子processが届ける。 - const failed = cli(['todo', 'status', '--json'], repo); - assert.equal(failed.status, 1, failed.stderr); + // Windowsでは、この入力は契約内のerrorで返り記録にならない(`lstat`がENOTDIRでなくENOENTを返す)。 + // 記録だけ同じ分類で直に作り、次のCLI実行が子processを起こすことを確かめる。 + if (windows) { + fail({ env }, 'todo.status', 'ENOTDIR'); + assert.equal(cli(['runtime-errors', 'diagnostics', '--json']).status, 0); + } else { + const failed = cli(['todo', 'status', '--json'], repo); + assert.equal(failed.status, 1, failed.stderr); + } const deadline = Date.now() + 20_000; while (Date.now() < deadline && json(cli(['runtime-errors', 'reporting', 'status', '--json'])).last_outcome !== 'delivered') { diff --git a/test/runtime-error-safe-context.test.mjs b/test/runtime-error-safe-context.test.mjs index 3773f15e..30a13d19 100644 --- a/test/runtime-error-safe-context.test.mjs +++ b/test/runtime-error-safe-context.test.mjs @@ -9,13 +9,13 @@ import { fileURLToPath } from 'node:url'; import { cliCommandKind } from '../src/cli-command-kind.mjs'; import { + ensureSafeDir, recordRuntimeError, runtimeErrorSafeContext, runtimeErrorsSnapshot, } from '../src/runtime-errors.mjs'; -// runtime error storeはPOSIX専用(Windows nativeはstore_unsafeでfail closed)。 -const test = process.platform === 'win32' ? nodeTest.skip : nodeTest; +const test = nodeTest; const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'lattice.mjs'); const VALID_CONFIG = { @@ -121,7 +121,8 @@ test('旧記録(分類無し)は旧い式のまま読み書きでき、新 const workspace = await makeWorkspace(); try { const legacyFingerprint = sha256(CLI_PARTS); - await mkdir(path.dirname(workspace.storePath), { recursive: true, mode: 0o700 }); + // 旧い版が作ったstoreを手で置く。フォルダは製品と同じ形(本人だけ)で作る。 + ensureSafeDir(path.dirname(workspace.storePath)); await writeFile(workspace.storePath, `${JSON.stringify({ schema: 'lattice.runtime_errors.v1', next_sequence: 2, acknowledged_through: 0, records: [{ @@ -154,7 +155,12 @@ test('旧記録(分類無し)は旧い式のまま読み書きでき、新 } }); -test('CLIがtyped契約の外で落ちると、面と例外の分類つきで記録する', async () => { +// Windowsでは、下の入力は契約内のerror(STORE_INCONSISTENT)で返る——`.lattice`がfileの時、`lstat`が +// ENOTDIRでなくENOENTを返す。Windowsで契約の外へ落とせる入力は今は無いので、この1件はPOSIXで確かめる。 +// 記録と置き場のWindowsの実機での確認は`runtime-errors-platform.test.mjs`が持つ。 +const posixTest = process.platform === 'win32' ? nodeTest.skip : nodeTest; + +posixTest('CLIがtyped契約の外で落ちると、面と例外の分類つきで記録する', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-context-cli-')); try { const configDir = path.join(root, 'xdg-config', 'dotagents'); diff --git a/test/runtime-errors-platform.test.mjs b/test/runtime-errors-platform.test.mjs index eeaf43a6..cc0e06d1 100644 --- a/test/runtime-errors-platform.test.mjs +++ b/test/runtime-errors-platform.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; +import { execFile, spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import os from 'node:os'; @@ -8,11 +8,24 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -// `runtime-errors.test.mjs`はWindowsで全件skipする。この試験はどのOSでも走り、そのOSで実際に -// CLIが返す答えを確かめる——Windowsは`unsupported`、ほかは設定に従う。 +import { + recordRuntimeError, + runtimeErrorReportingConfigPath, + runtimeErrorsDiagnostics, + runtimeErrorsSnapshot, + runtimeErrorsStatePath, +} from '../src/runtime-errors.mjs'; +import { productCredentialPath } from '../src/runtime-error-reporting.mjs'; +import { daclIsOwnerOnly, readWindowsDacl, windowsSelfSid } from '../src/windows-owner-only.mjs'; + +// このOSで実際に起きることを確かめる試験。storeを本人だけに絞る方法がOSごとに違う(POSIXはmode、 +// WindowsはDACL)ので、Windowsの実機でしか走らない試験をここへ置く。 const execFileAsync = promisify(execFile); const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'lattice.mjs'); const windows = process.platform === 'win32'; +const windowsTest = windows ? test : test.skip; +const CLI_FAILED = 'LATTICE.CLI_INTERNAL_FAILED'; +const USERS_SID = 'S-1-5-32-545'; const VALID_CONFIG = { schema_version: '1.0', host: { id: 'test-host', profile: windows ? 'windows-native' : 'linux' }, @@ -25,31 +38,169 @@ async function runJson(args, env) { return JSON.parse(stdout.trim()); } -test('CLIは収集に対応しないOSでunsupported、対応するOSでは設定どおりに答える', async () => { +async function makeWorkspace() { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-platform-')); + const configPath = path.join(root, 'config', 'factory-reporter.json'); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify(VALID_CONFIG)); + // Windowsでは、他のaccount(Users)へ継承で読み取りを許すフォルダの下へstoreを置く——既定の + // `%LOCALAPPDATA%`が別のaccountへ継承で許している端末と同じ形を、どの端末の試験でも作る。 + const parent = path.join(root, 'shared'); + await mkdir(parent); + if (windows) icacls(parent, '/grant', `*${USERS_SID}:(OI)(CI)R`); + const storePath = path.join(parent, 'state', 'runtime-errors.json'); + return { root, parent, storePath, storeDir: path.dirname(storePath), + options: { configPath, storePath, reportingConfigPath: path.join(root, 'config', 'runtime-error-reporting.json'), version: '0.75.0' } }; +} + +const icacls = (...args) => assert.equal(spawnSync('icacls', args, { windowsHide: true }).status, 0, args.join(' ')); + +test('CLIは、設定が無ければdisabled、有効ならenabledと答える(どのOSでも同じ形)', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-platform-')); try { - const env = { ...process.env, HOME: root, USERPROFILE: root, + const env = { ...process.env, HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData', 'Local'), XDG_CONFIG_HOME: path.join(root, 'xdg-config'), XDG_STATE_HOME: path.join(root, 'xdg-state') }; - // 設定が無い時: 対応するOSは`disabled`、対応しないOSは`unsupported`。 - const inactive = windows ? 'unsupported' : 'disabled'; const bare = await runJson(['snapshot'], env); assert.deepEqual(bare.diagnostics, - { collection: inactive, status: 'not_applicable', total_count: 0, pending_count: 0, truncated: false }); - assert.equal((await runJson(['diagnostics'], env)).collection, inactive); + { collection: 'disabled', status: 'not_applicable', total_count: 0, pending_count: 0, truncated: false }); + assert.equal((await runJson(['diagnostics'], env)).collection, 'disabled'); + assert.equal(existsSync(path.join(root, 'xdg-state', 'lattice')), false); - // 設定が有効な時: 対応しないOSは、それでも`unsupported`のまま記録を作らない。 await mkdir(path.join(root, 'xdg-config', 'dotagents'), { recursive: true }); await writeFile(path.join(root, 'xdg-config', 'dotagents', 'factory-reporter.json'), JSON.stringify(VALID_CONFIG)); const configured = await runJson(['snapshot'], env); - assert.equal(configured.diagnostics.collection, windows ? 'unsupported' : 'enabled'); - assert.equal(configured.diagnostics.status, windows ? 'not_applicable' : 'ready'); + assert.deepEqual(configured.diagnostics, + { collection: 'enabled', status: 'ready', total_count: 0, pending_count: 0, truncated: false }); assert.deepEqual(configured.cursor, { high_watermark: 0, acknowledged_through: 0, next: 0 }); assert.deepEqual(configured.runtime_errors, []); assert.deepEqual(configured.resolutions, []); - assert.deepEqual(Object.keys(configured.diagnostics), - ['collection', 'status', 'total_count', 'pending_count', 'truncated']); - if (windows) assert.equal(existsSync(path.join(root, 'xdg-state', 'lattice')), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('置き場: Windowsは%LOCALAPPDATA%の下、ほかはXDGの場所。XDGの変数はどのOSでも優先する', () => { + const env = { HOME: '/home/u', USERPROFILE: 'C:\\Users\\u', LOCALAPPDATA: 'C:\\Users\\u\\AppData\\Local' }; + const local = path.join(env.LOCALAPPDATA, 'Lattice'); + assert.equal(runtimeErrorsStatePath(env, 'win32'), path.join(local, 'runtime-errors', 'runtime-errors.json')); + assert.equal(runtimeErrorReportingConfigPath(env, 'win32'), path.join(local, 'runtime-error-reporting.json')); + assert.equal(productCredentialPath(env, 'win32'), path.join(env.LOCALAPPDATA, 'bughub', 'product-credentials', 'lattice.json')); + // `LOCALAPPDATA`が無い時は、利用者のフォルダから組む。 + assert.equal(runtimeErrorsStatePath({ USERPROFILE: env.USERPROFILE }, 'win32'), + path.join(env.USERPROFILE, 'AppData', 'Local', 'Lattice', 'runtime-errors', 'runtime-errors.json')); + + assert.equal(runtimeErrorsStatePath(env, 'linux'), path.join('/home/u', '.local', 'state', 'lattice', 'runtime-errors.json')); + assert.equal(runtimeErrorReportingConfigPath(env, 'darwin'), path.join('/home/u', '.config', 'lattice', 'runtime-error-reporting.json')); + assert.equal(productCredentialPath(env, 'linux'), path.join('/home/u', '.config', 'bughub', 'product-credentials', 'lattice.json')); + + for (const platform of ['win32', 'linux']) { + const xdg = { ...env, XDG_STATE_HOME: path.join('x', 'state'), XDG_CONFIG_HOME: path.join('x', 'config') }; + assert.equal(runtimeErrorsStatePath(xdg, platform), path.join('x', 'state', 'lattice', 'runtime-errors.json')); + assert.equal(runtimeErrorReportingConfigPath(xdg, platform), path.join('x', 'config', 'lattice', 'runtime-error-reporting.json')); + } +}); + +windowsTest('Windows: storeのフォルダを本人・SYSTEM・Administratorsだけに絞り、中のfileも同じ権限になる', async () => { + const workspace = await makeWorkspace(); + try { + const sid = windowsSelfSid(); + // 同じ親の下に作っただけのフォルダは、親の権限を継ぎ、他のaccountが読める。 + const plain = path.join(workspace.parent, 'plain'); + await mkdir(plain); + assert.equal(daclIsOwnerOnly(readWindowsDacl(plain, plain), sid), false); + + const recorded = recordRuntimeError(CLI_FAILED, workspace.options); + assert.equal(recorded.status, 'recorded'); + const dirSddl = readWindowsDacl(workspace.storeDir, workspace.storeDir); + assert.equal(daclIsOwnerOnly(dirSddl, sid), true, dirSddl); + // 継承を切ってある(`P`)。親の権限が後から変わっても、storeへは降りてこない。 + assert.match(dirSddl, /^D:P/); + assert.equal(daclIsOwnerOnly(readWindowsDacl(workspace.storePath, workspace.storeDir), sid), true); + + // 記録は読めて、OSは`win32`で残る。置き換え(rename)の後も権限は保たれる。 + recordRuntimeError(CLI_FAILED, workspace.options); + const snapshot = runtimeErrorsSnapshot(0, 256, workspace.options); + assert.deepEqual([snapshot.diagnostics.collection, snapshot.diagnostics.status, snapshot.runtime_errors[0].occurrence_count], + ['enabled', 'ready', 2]); + assert.equal(daclIsOwnerOnly(readWindowsDacl(workspace.storePath, workspace.storeDir), sid), true); + } finally { + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +windowsTest('Windows: 他のaccountが触れる形になったstoreは使わない', async () => { + const workspace = await makeWorkspace(); + try { + recordRuntimeError(CLI_FAILED, workspace.options); + + // fileへ他のaccount(Users)の読み取りを足す。読むのも書くのも止める。 + icacls(workspace.storePath, '/grant', `*${USERS_SID}:R`); + assert.throws(() => runtimeErrorsSnapshot(0, 256, workspace.options), /store_unsafe/); + assert.throws(() => recordRuntimeError(CLI_FAILED, workspace.options), /store_unsafe/); + assert.equal(runtimeErrorsDiagnostics(workspace.options).status, 'unavailable'); + icacls(workspace.storePath, '/remove', `*${USERS_SID}`); + assert.equal(runtimeErrorsDiagnostics(workspace.options).status, 'ready'); + + // フォルダへ直接足した時: 中身があるフォルダは絞り直さない(中身を信用できない)。 + icacls(workspace.storeDir, '/grant', `*${USERS_SID}:(OI)(CI)R`); + assert.throws(() => recordRuntimeError(CLI_FAILED, workspace.options), /store_unsafe/); + assert.throws(() => runtimeErrorsSnapshot(0, 256, workspace.options), /store_unsafe/); + } finally { + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +windowsTest('Windows: 先に在る空のフォルダは絞って使い、中身のある絞られていないフォルダは使わない', async () => { + const empty = await makeWorkspace(); + const occupied = await makeWorkspace(); + try { + const sid = windowsSelfSid(); + // 親の権限を継いだままの空のフォルダ(作った直後に止まった時の形)。 + await mkdir(empty.storeDir); + assert.equal(daclIsOwnerOnly(readWindowsDacl(empty.storeDir, empty.storeDir), sid), false); + assert.equal(recordRuntimeError(CLI_FAILED, empty.options).status, 'recorded'); + assert.equal(daclIsOwnerOnly(readWindowsDacl(empty.storeDir, empty.storeDir), sid), true); + + await mkdir(occupied.storeDir); + await writeFile(path.join(occupied.storeDir, 'someone-elses.txt'), 'x'); + assert.throws(() => recordRuntimeError(CLI_FAILED, occupied.options), /store_unsafe/); + assert.equal(existsSync(occupied.storePath), false); + } finally { + await rm(empty.root, { recursive: true, force: true }); + await rm(occupied.root, { recursive: true, force: true }); + } +}); + +windowsTest('Windows: CLIは既定の置き場(%LOCALAPPDATA%\\Lattice)へ設定とstoreを置き、storeだけを絞る', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-platform-')); + try { + const localAppData = path.join(root, 'AppData', 'Local'); + const env = { ...process.env, USERPROFILE: root, LOCALAPPDATA: localAppData }; + for (const name of ['HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'LATTICE_RUNTIME_ERROR_REPORTING']) delete env[name]; + const lattice = path.join(localAppData, 'Lattice'); + const storeDir = path.join(lattice, 'runtime-errors'); + + // 既定では無効で、何も作らない。Windowsはdotagentsの設定を読まず、Lattice自身の送信設定だけで有効になる。 + assert.equal((await runJson(['diagnostics'], env)).collection, 'disabled'); + assert.equal(existsSync(lattice), false); + + const enabled = await runJson(['reporting', 'enable'], env); + assert.deepEqual([enabled.reporting, enabled.collection, enabled.store_status, enabled.credential], + ['enabled', 'enabled', 'ready', 'missing']); + assert.equal(existsSync(path.join(lattice, 'runtime-error-reporting.json')), true); + + const recorded = recordRuntimeError(CLI_FAILED, { env, version: '0.75.0' }); + assert.equal(recorded.status, 'recorded'); + assert.equal(existsSync(path.join(storeDir, 'runtime-errors.json')), true); + const sid = windowsSelfSid(); + assert.equal(daclIsOwnerOnly(readWindowsDacl(storeDir, storeDir), sid), true); + + const snapshot = await runJson(['snapshot'], env); + assert.deepEqual([snapshot.diagnostics.collection, snapshot.diagnostics.status, snapshot.runtime_errors.length], + ['enabled', 'ready', 1]); + const resolved = await runJson(['resolve', recorded.fingerprint], env); + assert.deepEqual([resolved.runtime_errors.length, resolved.resolutions.length], [0, 1]); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/test/runtime-errors.test.mjs b/test/runtime-errors.test.mjs index 5328af3b..33447af9 100644 --- a/test/runtime-errors.test.mjs +++ b/test/runtime-errors.test.mjs @@ -6,7 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import nodeTest from 'node:test'; -const test = process.platform === 'win32' ? nodeTest.skip : nodeTest; +const test = nodeTest; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -98,7 +98,7 @@ test('工場が名乗るhost profileはすべて収集を有効にし、未知 test('収集に対応しないOSでは、設定が有効でもunsupportedと答えstateへ触れない', async () => { const workspace = await makeWorkspace(); - const options = { ...workspace.options, platform: 'win32' }; + const options = { ...workspace.options, platform: 'freebsd' }; try { assert.deepEqual(recordRuntimeError('LATTICE.CLI_INTERNAL_FAILED', options), { status: 'unsupported' }); const snapshot = runtimeErrorsSnapshot(0, 256, options); @@ -185,8 +185,10 @@ test('store改ざん・symlinkはfail closedし、未知codeを拒否する', as await rm(workspace.storePath); await writeFile(`${workspace.storePath}.real`, '', { mode: 0o600 }); - await symlink(`${workspace.storePath}.real`, workspace.storePath); - assert.throws(() => recordRuntimeError('LATTICE.CLI_INTERNAL_FAILED', workspace.options), /store_unsafe/); + // Windowsは、権限の無いaccountにsymlinkを作らせない。作れた時だけ確かめる。 + const linked = await symlink(`${workspace.storePath}.real`, workspace.storePath).then(() => true, + (error) => { if (process.platform !== 'win32' || error.code !== 'EPERM') throw error; return false; }); + if (linked) assert.throws(() => recordRuntimeError('LATTICE.CLI_INTERNAL_FAILED', workspace.options), /store_unsafe/); assert.throws(() => recordRuntimeError('LATTICE.UNKNOWN', workspace.options), /unknown_runtime_code/); } finally { diff --git a/test/windows-owner-only.test.mjs b/test/windows-owner-only.test.mjs new file mode 100644 index 00000000..d3463387 --- /dev/null +++ b/test/windows-owner-only.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { daclIsOwnerOnly, daclIsProtected, isAclScratchName } from '../src/windows-owner-only.mjs'; + +// `icacls /save`が返すSDDLの判定。実物の値はfox(Windows 11)で採った形で、SIDの機械部分だけ置き換えてある。 +const SELF = 'S-1-5-21-1111111111-2222222222-3333333333-1001'; +const OTHER = 'S-1-5-21-1111111111-2222222222-3333333333-1002'; + +test('本人・SYSTEM・Administratorsへの許可だけのDACLを通す', () => { + for (const sddl of [ + // 継承を切って絞ったフォルダ、その中のfile、BugHubの持ち主が置いた合鍵。 + `D:PAI(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;${SELF})`, + `D:AI(A;ID;FA;;;BA)(A;ID;FA;;;SY)(A;ID;FA;;;${SELF})`, + `D:PAI(A;;FA;;;${SELF})`, + // SYSTEMとAdministratorsは、別名でなくSIDで書かれていても同じ。 + 'D:P(A;;FA;;;S-1-5-18)(A;;FA;;;S-1-5-32-544)', + ]) assert.equal(daclIsOwnerOnly(sddl, SELF), true, sddl); + // SYSTEMとして動く時(CIのrunner)は、本人がSYSTEMになる。 + assert.equal(daclIsOwnerOnly('D:PAI(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)', 'S-1-5-18'), true); +}); + +test('他のaccountが現れるDACLと、読めない形のDACLは通さない', () => { + for (const sddl of [ + // 既定の`%LOCALAPPDATA%`の下: 別のローカルaccountへ継承で許している。 + `D:AI(A;OICIID;0x1200a9;;;${OTHER})(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)(A;OICIID;FA;;;${SELF})`, + // 絞った後に、Users・Everyone・Authenticated Usersへ足された。 + `D:AI(A;;FR;;;BU)(A;ID;FA;;;BA)(A;ID;FA;;;SY)(A;ID;FA;;;${SELF})`, + `D:PAI(A;;FA;;;${SELF})(A;;FR;;;WD)`, + `D:PAI(A;;FA;;;${SELF})(A;;FR;;;AU)`, + // 拒否・条件つき・object用のACEは、意味を確かめないので通さない。 + `D:AI(D;;0x100116;;;WD)(A;ID;FA;;;${SELF})`, + `D:PAI(XA;;FA;;;${SELF};(Member_of {SID(BA)}))`, + `D:PAI(OA;;FA;00000000-0000-0000-0000-000000000000;;${SELF})`, + // 許可が1つも無い・DACLそのものが無い(誰でも触れる)・SDDLでない。 + 'D:', 'D:P', 'D:NO_ACCESS_CONTROL', `O:BAD:PAI(A;;FA;;;${SELF})`, '', `D:PAI(A;;FA;;;${SELF}`, `D:PAI(A;;FA;;;${SELF})x`, + ]) assert.equal(daclIsOwnerOnly(sddl, SELF), false, sddl); + // 本人のSIDは完全一致で見る。別の利用者のSIDや、SIDでない値を本人として渡しても通さない。 + assert.equal(daclIsOwnerOnly(`D:PAI(A;;FA;;;${SELF})`, OTHER), false); + assert.equal(daclIsOwnerOnly('D:PAI(A;;FA;;;BU)', 'BU'), false); + assert.equal(daclIsOwnerOnly(undefined, SELF), false); +}); + +test('親からの継承を切ってあるDACLを見分ける', () => { + for (const sddl of ['D:P(A;;FA;;;SY)', 'D:PAI(A;;FA;;;SY)', 'D:ARP(A;;FA;;;SY)']) assert.equal(daclIsProtected(sddl), true, sddl); + for (const sddl of ['D:AI(A;ID;FA;;;SY)', 'D:(A;;FA;;;SY)', 'D:', '', undefined]) assert.equal(daclIsProtected(sddl), false, String(sddl)); +}); + +test('空かどうかを見る時に数えないのは、DACLの読み取りが置く出力fileの名前だけ', () => { + assert.equal(isAclScratchName('.acl-1234-0123456789ab'), true); + for (const name of ['runtime-errors.json', '.acl-1234-0123456789ab.json', '.acl-x-0123456789ab', 'x.acl-1-0123456789ab']) { + assert.equal(isAclScratchName(name), false, name); + } +});