From 958783398da62828ff92f7203d50ce6d616a7e94 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 09:50:55 +0900 Subject: [PATCH 1/8] docs: propose a checklist-first remote qualification contract Define assessment-only states, evidence limits and the remaining qualification work without introducing a reporter, installer or authority/recovery engine. Preserve the historical ADR and defer implementation until human design review. Refs #158 --- docs/design/remote-deployment.md | 7 + docs/design/remote-qualification-checklist.md | 180 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docs/design/remote-qualification-checklist.md diff --git a/docs/design/remote-deployment.md b/docs/design/remote-deployment.md index 7e9a7b4..c53a2d2 100644 --- a/docs/design/remote-deployment.md +++ b/docs/design/remote-deployment.md @@ -10,6 +10,13 @@ | Decision owner | Repository maintainer | | Supersedes | Nothing. The exact-version preflight stays in force until the follow-ups below are implemented and verified | +**Qualification follow-up (proposal, 2026-10-21):** +[Issue #158's checklist-first design](remote-qualification-checklist.md) proposes +an assessment workflow without a new authority or recovery engine. Draft #155 +is deferred and excluded from 0.5.0. The historical workflow and evidence below +remain context; the new proposal implements none of the F2–F6 helpers and +requires human design review before any replacement implementation. + ## Contents - [Context](#context) diff --git a/docs/design/remote-qualification-checklist.md b/docs/design/remote-qualification-checklist.md new file mode 100644 index 0000000..c990d75 --- /dev/null +++ b/docs/design/remote-qualification-checklist.md @@ -0,0 +1,180 @@ +# Proposal: operator checklist for remote qualification + +Status: **Design only; human review required.** Issue [#158](https://github.com/abruption/session-peer-ts/issues/158), +following [#23](https://github.com/abruption/session-peer-ts/issues/23). +Draft date: 2026-10-21. No release version is assigned; priority is P2. + +## Purpose and boundary + +Help an operator assess one explicitly provisioned, exact-version TypeScript +installation using a small evidence worksheet. The result describes checks; +it neither activates an installation nor authorizes another action. + +The [remote deployment ADR](remote-deployment.md) remains a historical proposal +with dated experiments. This checklist does not implement its F2–F6 follow-ups +or adopt its journal, receipt, lock, activation or recovery machinery. The +2026-10-20 decision in #23 supersedes its earlier release planning: Draft #155 +is deferred and excluded from 0.5.0. Its commits and evidence remain historical; +they are not the implementation starting point for this proposal. + +Installation, rollout, repair, activation, rollback and retention belong to the +operator and the installation's owning manager. No manager takeover, installer, +runtime update, source-streaming fallback, distributed lock, caller accounting, +receipt ownership, recovery/resume protocol or automated cleanup is introduced. +No runtime, CLI flag, schema or program is proposed here. + +Existing exact-version SSH preflight, host-key checks, submission/unknown and +no-resend behavior remain requirements. Passing this checklist proves neither +message delivery nor ACK and grants no permission to switch callers, delete +files, break locks, publish receipts or clean up storage. Existing runtime +handoff and ACK contracts are outside this design. + +## Operator workflow + +1. Select the destination, account, installation path, owning manager and exact + intended local/remote version. Record the intended OS/architecture and runtime + cell. If selection or ownership is uncertain, record that uncertainty and stop + checks that depend on it; do not infer ownership from a marker or directory name. +2. Start a new worksheet with every check `not_checked`. Inspect only explicitly + selected metadata and record its source and observation time. Reading a file + never entails executing a discovered wrapper, Node, npm or agent executable. +3. Assess the static and operator-attestation checks below. Preserve missing and + conflicting evidence. Stop dependent checks on `fail`, `unknown` or + `unsupported`; remediation is a separate human decision. +4. If separately authorized, the operator may execute a specifically selected + existing bounded diagnostic, then record its exact command, scope and result. + Skipping that step leaves its check `not_checked`, without borrowing old results. +5. Review the individual results and evidence limits. Record unresolved cells and + the next human decision. End the assessment without changing the installation. + +This document grants no live experiment authorization. There is no default SSH +connection, registry request, executable discovery/run or agent contact. A future +diagnostic choice must name the destination, inputs, effects and stopping bound +before execution. `doctor --host` invokes SSH and the remote CLI; +`doctor --check-return-route` can initiate a return SSH probe. `send --dry-run` +means validation without submission, not a guarantee of zero writes, process +launches or network activity across its selected route. Diagnostic labels alone +do not establish an effect boundary; installed behavior and options need review. +No real send, ACK request or peer notification is part of this checklist. + +## Check states + +Each check is local to one assessment invocation and one stated predicate: +`not_checked` → `pass`, `fail`, `unknown` or `unsupported`. + +| State | Meaning | +| --- | --- | +| `not_checked` | No assessment of this predicate was attempted in this invocation, including a deliberately skipped diagnostic. | +| `pass` | Sufficient current evidence supports the precise predicate, using its recorded evidence kind and scope. | +| `fail` | Sufficient current evidence demonstrates the predicate is false, such as an established version mismatch. | +| `unknown` | Attempted assessment cannot establish the predicate: missing, unreadable, stale, ambiguous or conflicting evidence, including interrupted diagnostics. | +| `unsupported` | The selected check method cannot assess this platform/runtime/path case; record the specific limitation. This is neither a pass nor a compatibility claim. | + +An absent selected file is `fail` for “the selected file exists” but `unknown` +for “this installation uses the intended runtime.” A genuinely unsupported +runtime is `fail` for a supported-runtime requirement when established; it is +not hidden as an unsupported inspection method. Contradictory sources cannot +be resolved by choosing the favorable result. Record the conflict as `unknown` +unless authoritative scoped evidence establishes the predicate is false. + +A later inspection is a new observation, recorded alongside the earlier one; +it does not erase a contradiction without explaining its resolution. There is +no aggregate `pass`, automatic “qualified” designation or authorization token. +All checks passing still describes only their individual predicates and scopes. + +## Evidence worksheet + +Use ordinary text or a table; no machine format or persistence protocol is +required. For each check record the predicate, state, evidence kind, exact source, +observed time, destination/account/path and relevant version/runtime identity, +plus the result and limits. Keep sensitive command output out of shared notes. + +Distinguish these evidence kinds: + +- **Static metadata:** inspected artifact/version text, path, wrapper contents, + lockfile or computed hash. It proves only what was read at that time; it cannot + prove the actually running version, current caller use, storage ownership or + that a declared runtime path is the one a process uses. +- **Operator attestation:** a named operator's statement about manager ownership, + intended caller selection or a command result. Record when and where it was + observed and whether output is available; do not relabel it as executed evidence. +- **Executed diagnostic:** the exact command actually run in this invocation, + installed CLI/runtime version evidence, destination/account, time, exit status, + relevant result and observed limits. An observed banner identifies that command + invocation, not every process, caller or storage owner on the host. +- **Historical or synthetic evidence:** dated previous runs or fixtures, kept as + references only. Native destination execution must be identified separately + from fake SSH, local fixtures and CI. None supplies a skipped current check. + +Freshness is scoped to the observation, not a reusable time-to-live. A changed +artifact, runtime, destination, account, path, manager or invocation context +invalidates conclusions that depend on it; reinspect or record `unknown`. Record +both the declared identity and what was observed. Do not infer a source commit +from an equal version banner: current wire preflight checks the version line. + +Hash equality means matching computed digests under the recorded algorithm and +scope at the observation times. It is not an unconditional proof of byte identity. It authenticates no code and proves no current +use or ownership. A missing/uncomputed hash (`null`, if so transcribed) is unknown, +never “unchanged”; two null hashes are not a successful comparison. + +| Minimal check | Evidence sufficient for its limited predicate | +| --- | --- | +| Installation and manager are explicitly selected | Operator attestation naming destination/account/path and manager; uncertain ownership remains unknown. | +| Selected artifact metadata matches the intended version | Current static inspection of the named artifact; independently distinguish any executed banner result. | +| Declared runtime/path/native dependency match the intended cell | Explicit metadata inspection, with missing facts unknown; native loading and actual runtime selection require separately scoped executed evidence. | +| Existing other-manager executables were preserved | Explicit before/after byte measurements of named files, if available; unmeasured entries remain unknown. This does not establish storage ownership. | +| Selected existing diagnostic returned the stated result | Actual authorized invocation and output; `doctor` exit 0 alone does not mean `ready=true`, native lock verified or send authorized. | +| Remaining destination cell has bounded native evidence | Exact dated OS/arch/account/runtime/package/native-check scope; no expansion to another cell or complete platform support. | + +## Remaining #23 work and evidence limits + +This is a classification of open work, not a new completion claim. “Live” below +means separately scoped native destination execution; none was run for this design. + +| Remaining criterion | Appropriate evidence/work | Current disposition | +| --- | --- | --- | +| Explicit same-version provisioning and supported absolute runtime/native dependency | Docs + static metadata + operator selection; live invocation/native load for actual execution | Preserve exact-version refusal. Source-build commit alignment is operator review, not wire enforcement. | +| Owning manager controls rollout/repair/rollback/retention; preserve Python/npm/uv/pipx installations | Docs + operator attestation; named before/after static measurements where needed | No new authority; a marker/hash cannot establish ownership or current callers. | +| Linux requalification under the ADR's later receipt/recovery rules and operator-check → receipt → unlock order | Separate live experiment proposal against the specifically chosen historical contract | Unrun under those later rules. This checklist neither supplies nor requires a replacement recovery engine. | +| Windows destination matrix and other account/architecture/runtime/concurrency cells | Separate live experiment proposals per cell | Bounded historical evidence only; other cells remain unrun. | +| F2–F6 capability/error/identity/provisioning follow-ups | Separate design/implementation review and validation | Proposals, not commands delivered here. No Windows-client jump qualification. | + +Historical anchors remain dated: the 2026-10-02 Debian 13 x86_64/glibc 2.41 +run used Node 24.21.0/22.23.3 under then-current ADR rules, with incomplete +operator ordering and later recovery rules unvalidated. The 2026-10-07 Windows +10 x64 administrator/PowerShell 5.1 run used published 0.3.1 and Node 24.16.0: +isolated offline installation, wrapper/native lock, operator SSH doctor and +nonexistent-target dry-run, plus 31 native/local fixtures. Windows Node 22.23.3 +banner/native-lock smoke is separate from full installation qualification. +See the ADR's dated validation sections and their [validation records](../../VALIDATION.md). +CI/admin-only evidence and these checkpoints do not qualify complete platforms, +current installations, Windows-client jump, delivery or ACK. + +## Design acceptance scenarios (review only) + +These are bounded paper scenarios, not commands or tests to execute. + +| Scenario | Required checklist behavior | +| --- | --- | +| Operator reviews metadata and declines diagnostics | Static predicates may pass; skipped diagnostics stay `not_checked`; no connection or executable run. | +| Metadata version differs from the intended version | Version-match check fails; stop dependent diagnostics pending a human decision. | +| Wrapper declares Node A; available command evidence reports Node B | Runtime agreement is unknown until the discrepancy is resolved; never select the favorable source. | +| Hash cannot be read, or both recorded hashes are null | Comparison is unknown; never report unchanged or authorize deletion. | +| Inspection method cannot interpret the selected native path/platform | Relevant check is unsupported with its limit; unaffected check results remain recorded. | +| Authorized doctor exits 0 with `ready=false` | Command completion may pass; readiness fails if that is its predicate; no inferred native-lock, delivery or ACK success. | +| Authorized diagnostic times out or returns an unparseable result | Record unknown and stop; do not infer completion, submission or permission to retry. | +| Old Windows success is offered for a different account/runtime or a current Linux cell | Keep the dated historical reference; current cell stays unassessed or unknown, according to whether assessment was attempted. | + +## Reporter decision and review gate + +Choose **checklist only** for this stage. There is no demonstrated need for a +reporter, and no implementation is authorized by this proposal. Revisit only if +operator use exposes repeated, concrete static transcription errors or effort +that a bounded regular-file inspection tool would materially reduce. That would +require a separate accepted design covering native paths, limits, explicit unknown +results and preservation of unaffected entries, without executing discovered tools. + +Human review must accept the workflow, evidence limits, state meanings and #23 +mapping before any implementation proposal. Any later implementation or live +experiment needs its own explicit scope and authorization. This review does not +close #23, accept #155 or commit work to a release. From fe441a7d66d99ff1cef9fa3c92fe0c3cb8bb877f Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 09:55:25 +0900 Subject: [PATCH 2/8] docs: bind checklist observations and distinguish baseline inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require explicit selected command paths for diagnostic attribution, scope current digest comparisons without inheriting historical pass results, and clarify human decisions for open qualification debt. Keep every F1–F6 follow-up outside this checklist-only proposal. Refs #158 --- docs/design/remote-deployment.md | 2 +- docs/design/remote-qualification-checklist.md | 37 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/design/remote-deployment.md b/docs/design/remote-deployment.md index c53a2d2..c186f33 100644 --- a/docs/design/remote-deployment.md +++ b/docs/design/remote-deployment.md @@ -14,7 +14,7 @@ [Issue #158's checklist-first design](remote-qualification-checklist.md) proposes an assessment workflow without a new authority or recovery engine. Draft #155 is deferred and excluded from 0.5.0. The historical workflow and evidence below -remain context; the new proposal implements none of the F2–F6 helpers and +remain context; the new proposal implements none of the F1–F6 follow-ups and requires human design review before any replacement implementation. ## Contents diff --git a/docs/design/remote-qualification-checklist.md b/docs/design/remote-qualification-checklist.md index c990d75..e93ae2f 100644 --- a/docs/design/remote-qualification-checklist.md +++ b/docs/design/remote-qualification-checklist.md @@ -11,7 +11,7 @@ installation using a small evidence worksheet. The result describes checks; it neither activates an installation nor authorizes another action. The [remote deployment ADR](remote-deployment.md) remains a historical proposal -with dated experiments. This checklist does not implement its F2–F6 follow-ups +with dated experiments. This checklist does not implement its F1–F6 follow-ups or adopt its journal, receipt, lock, activation or recovery machinery. The 2026-10-20 decision in #23 supersedes its earlier release planning: Draft #155 is deferred and excluded from 0.5.0. Its commits and evidence remain historical; @@ -43,6 +43,13 @@ handoff and ACK contracts are outside this design. `unsupported`; remediation is a separate human decision. 4. If separately authorized, the operator may execute a specifically selected existing bounded diagnostic, then record its exact command, scope and result. + Select the local CLI by its explicit absolute path. For a remote diagnostic, + include the selected absolute wrapper in `--remote-bin` and specify + `--remote-platform win32` for Windows, using paths accepted by the existing + CLI. Do not choose a default PATH-based invocation for this assessment. + Missing path/platform binding makes attribution to the selected installation + `unknown`, even if some same-version CLI returned success. An absolute path + is an invocation choice, not authentication or proof against later replacement. Skipping that step leaves its check `not_checked`, without borrowing old results. 5. Review the individual results and evidence limits. Record unresolved cells and the next human decision. End the assessment without changing the installation. @@ -103,7 +110,12 @@ Distinguish these evidence kinds: relevant result and observed limits. An observed banner identifies that command invocation, not every process, caller or storage owner on the host. - **Historical or synthetic evidence:** dated previous runs or fixtures, kept as - references only. Native destination execution must be identified separately + references, not replacement current results. An explicitly selected historical + digest may be an input to a new comparison predicate: record its source, path, + algorithm and original observation time, then measure the current input in this + assessment. The historical check's `pass` is never inherited. Without that + baseline identity or a current measurement, comparison is `unknown`. + Native destination execution must be identified separately from fake SSH, local fixtures and CI. None supplies a skipped current check. Freshness is scoped to the observation, not a reusable time-to-live. A changed @@ -111,6 +123,9 @@ artifact, runtime, destination, account, path, manager or invocation context invalidates conclusions that depend on it; reinspect or record `unknown`. Record both the declared identity and what was observed. Do not infer a source commit from an equal version banner: current wire preflight checks the version line. +Combined conclusions depend on observations referring to the same installation +and context. A path can change between observations; repeat an applicable +inspection or state the continuity limit. No atomic snapshot is promised. Hash equality means matching computed digests under the recorded algorithm and scope at the observation times. It is not an unconditional proof of byte identity. It authenticates no code and proves no current @@ -122,8 +137,8 @@ never “unchanged”; two null hashes are not a successful comparison. | Installation and manager are explicitly selected | Operator attestation naming destination/account/path and manager; uncertain ownership remains unknown. | | Selected artifact metadata matches the intended version | Current static inspection of the named artifact; independently distinguish any executed banner result. | | Declared runtime/path/native dependency match the intended cell | Explicit metadata inspection, with missing facts unknown; native loading and actual runtime selection require separately scoped executed evidence. | -| Existing other-manager executables were preserved | Explicit before/after byte measurements of named files, if available; unmeasured entries remain unknown. This does not establish storage ownership. | -| Selected existing diagnostic returned the stated result | Actual authorized invocation and output; `doctor` exit 0 alone does not mean `ready=true`, native lock verified or send authorized. | +| Current measured digest matches an explicitly selected baseline for a named file | Record baseline source/path/algorithm/time and a current measurement in this assessment. Matching digests do not prove continuous preservation between observations, current use or ownership. Missing either input is unknown. | +| Selected existing diagnostic returned the stated result | Actual authorized invocation/output with the explicit local CLI path, remote wrapper/platform where applicable, and selected agent scope. PATH-only results cannot establish attribution. `doctor` completion alone proves no readiness, native lock or send authorization. | | Remaining destination cell has bounded native evidence | Exact dated OS/arch/account/runtime/package/native-check scope; no expansion to another cell or complete platform support. | ## Remaining #23 work and evidence limits @@ -135,16 +150,18 @@ means separately scoped native destination execution; none was run for this desi | --- | --- | --- | | Explicit same-version provisioning and supported absolute runtime/native dependency | Docs + static metadata + operator selection; live invocation/native load for actual execution | Preserve exact-version refusal. Source-build commit alignment is operator review, not wire enforcement. | | Owning manager controls rollout/repair/rollback/retention; preserve Python/npm/uv/pipx installations | Docs + operator attestation; named before/after static measurements where needed | No new authority; a marker/hash cannot establish ownership or current callers. | -| Linux requalification under the ADR's later receipt/recovery rules and operator-check → receipt → unlock order | Separate live experiment proposal against the specifically chosen historical contract | Unrun under those later rules. This checklist neither supplies nor requires a replacement recovery engine. | +| Original #23 criterion: validate the selected workflow on isolated POSIX and Windows destinations | Separately scoped experiments after choosing the workflow and exact evidence cells | Remains open. Human review must decide how the checklist workflow addresses this criterion; no cell is completed here. | +| Linux requalification under the ADR's later receipt/recovery rules and operator-check → receipt → unlock order | Inventory the historical debt before selecting any live experiment | Human decision required: retain the named historical checks, rescope them to checklist qualification, or record why a criterion is no longer needed. This proposal neither silently drops the debt nor requires a recovery engine. | | Windows destination matrix and other account/architecture/runtime/concurrency cells | Separate live experiment proposals per cell | Bounded historical evidence only; other cells remain unrun. | | F2–F6 capability/error/identity/provisioning follow-ups | Separate design/implementation review and validation | Proposals, not commands delivered here. No Windows-client jump qualification. | Historical anchors remain dated: the 2026-10-02 Debian 13 x86_64/glibc 2.41 -run used Node 24.21.0/22.23.3 under then-current ADR rules, with incomplete +run used published TS 0.2.1 and Node 24.21.0/22.23.3 under then-current ADR rules, with incomplete operator ordering and later recovery rules unvalidated. The 2026-10-07 Windows 10 x64 administrator/PowerShell 5.1 run used published 0.3.1 and Node 24.16.0: isolated offline installation, wrapper/native lock, operator SSH doctor and -nonexistent-target dry-run, plus 31 native/local fixtures. Windows Node 22.23.3 +nonexistent-target dry-run, plus 31 native storage/local-wrapper fixtures. Only +the primary installation had operator SSH evidence. Windows Node 22.23.3 banner/native-lock smoke is separate from full installation qualification. See the ADR's dated validation sections and their [validation records](../../VALIDATION.md). CI/admin-only evidence and these checkpoints do not qualify complete platforms, @@ -158,11 +175,13 @@ These are bounded paper scenarios, not commands or tests to execute. | --- | --- | | Operator reviews metadata and declines diagnostics | Static predicates may pass; skipped diagnostics stay `not_checked`; no connection or executable run. | | Metadata version differs from the intended version | Version-match check fails; stop dependent diagnostics pending a human decision. | -| Wrapper declares Node A; available command evidence reports Node B | Runtime agreement is unknown until the discrepancy is resolved; never select the favorable source. | +| Wrapper metadata declares Node A; a command reports Node B | Record the declaration and observed command as separate predicates. Fail agreement only if runtime B is bound to the selected wrapper; an unrelated PATH Node does not establish a conflict or the selected runtime. | | Hash cannot be read, or both recorded hashes are null | Comparison is unknown; never report unchanged or authorize deletion. | | Inspection method cannot interpret the selected native path/platform | Relevant check is unsupported with its limit; unaffected check results remain recorded. | -| Authorized doctor exits 0 with `ready=false` | Command completion may pass; readiness fails if that is its predicate; no inferred native-lock, delivery or ACK success. | +| Authorized doctor exits 0 with `ready=false` | Completion may pass; the predicate “reported ready=true” fails for the recorded `--agent` selection. This flag is not installation integrity or proof of actual sendability, native locking, delivery or ACK. | | Authorized diagnostic times out or returns an unparseable result | Record unknown and stop; do not infer completion, submission or permission to retry. | +| A remote command omits the selected wrapper path or Windows platform mode | Attribution is unknown; a same-version banner does not bind the selected installation. | +| An old digest is explicitly selected and compared with a current measurement | Record a new comparison result and both observations' scope; never inherit the old check's pass or claim preservation throughout the interval. | | Old Windows success is offered for a different account/runtime or a current Linux cell | Keep the dated historical reference; current cell stays unassessed or unknown, according to whether assessment was attempted. | ## Reporter decision and review gate From 353686d78ea3961de3718b7fa4592c0e4a091f79 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 10:02:14 +0900 Subject: [PATCH 3/8] docs: keep the review-only checklist out of npm artifacts Exclude only the unapproved checklist proposal with a nested npm ignore rule and use its stable PR URL in the installed ADR. A dry-run file-list comparison confirms no other package selection changes. The reviewed checklist contract text is unchanged. Refs #158 --- docs/design/.npmignore | 2 ++ docs/design/remote-deployment.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/design/.npmignore diff --git a/docs/design/.npmignore b/docs/design/.npmignore new file mode 100644 index 0000000..0f75641 --- /dev/null +++ b/docs/design/.npmignore @@ -0,0 +1,2 @@ +# Human-review proposal; not an installed runtime document. +remote-qualification-checklist.md diff --git a/docs/design/remote-deployment.md b/docs/design/remote-deployment.md index c186f33..d43cd84 100644 --- a/docs/design/remote-deployment.md +++ b/docs/design/remote-deployment.md @@ -11,7 +11,7 @@ | Supersedes | Nothing. The exact-version preflight stays in force until the follow-ups below are implemented and verified | **Qualification follow-up (proposal, 2026-10-21):** -[Issue #158's checklist-first design](remote-qualification-checklist.md) proposes +[Issue #158's checklist-first design](https://github.com/abruption/session-peer-ts/pull/159) proposes an assessment workflow without a new authority or recovery engine. Draft #155 is deferred and excluded from 0.5.0. The historical workflow and evidence below remain context; the new proposal implements none of the F1–F6 follow-ups and From 5a5145826ab19bfeb047733627f4761397d69958 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 10:23:31 +0900 Subject: [PATCH 4/8] feat: add a repository-only static qualification reporter Extend the reviewed checklist with bounded local JSON field and digest observations. Keep execution, networking, ownership, recovery and readiness outside the reporter; preserve per-predicate uncertainty and test input/read budgets and descriptor cleanup. Refs #158 --- docs/design/.npmignore | 1 + docs/design/remote-deployment.md | 7 +- docs/design/remote-qualification-checklist.md | 33 ++- docs/design/remote-qualification-reporter.md | 138 ++++++++++ scripts/remote-qualification.mjs | 227 ++++++++++++++++ test/remote-qualification.test.ts | 244 ++++++++++++++++++ 6 files changed, 634 insertions(+), 16 deletions(-) create mode 100644 docs/design/remote-qualification-reporter.md create mode 100644 scripts/remote-qualification.mjs create mode 100644 test/remote-qualification.test.ts diff --git a/docs/design/.npmignore b/docs/design/.npmignore index 0f75641..5617527 100644 --- a/docs/design/.npmignore +++ b/docs/design/.npmignore @@ -1,2 +1,3 @@ # Human-review proposal; not an installed runtime document. remote-qualification-checklist.md +remote-qualification-reporter.md diff --git a/docs/design/remote-deployment.md b/docs/design/remote-deployment.md index d43cd84..5c9e677 100644 --- a/docs/design/remote-deployment.md +++ b/docs/design/remote-deployment.md @@ -11,11 +11,12 @@ | Supersedes | Nothing. The exact-version preflight stays in force until the follow-ups below are implemented and verified | **Qualification follow-up (proposal, 2026-10-21):** -[Issue #158's checklist-first design](https://github.com/abruption/session-peer-ts/pull/159) proposes -an assessment workflow without a new authority or recovery engine. Draft #155 +[Issue #158's checklist-first design](https://github.com/abruption/session-peer-ts/issues/158) proposes +an assessment workflow with a separately requested, repository-only read-only +reporter, without a new authority or recovery engine. Draft #155 is deferred and excluded from 0.5.0. The historical workflow and evidence below remain context; the new proposal implements none of the F1–F6 follow-ups and -requires human design review before any replacement implementation. +requires human PR review; it does not authorize provisioning or remediation. ## Contents diff --git a/docs/design/remote-qualification-checklist.md b/docs/design/remote-qualification-checklist.md index e93ae2f..2abb77d 100644 --- a/docs/design/remote-qualification-checklist.md +++ b/docs/design/remote-qualification-checklist.md @@ -1,6 +1,6 @@ # Proposal: operator checklist for remote qualification -Status: **Design only; human review required.** Issue [#158](https://github.com/abruption/session-peer-ts/issues/158), +Status: **Implementation proposal; human PR review required.** Issue [#158](https://github.com/abruption/session-peer-ts/issues/158), following [#23](https://github.com/abruption/session-peer-ts/issues/23). Draft date: 2026-10-21. No release version is assigned; priority is P2. @@ -21,7 +21,11 @@ Installation, rollout, repair, activation, rollback and retention belong to the operator and the installation's owning manager. No manager takeover, installer, runtime update, source-streaming fallback, distributed lock, caller accounting, receipt ownership, recovery/resume protocol or automated cleanup is introduced. -No runtime, CLI flag, schema or program is proposed here. +The owner explicitly requested a repository-only read-only reporter extension on +2026-10-21 after reviewing the checklist. Its limited input/output and reader +contract is specified in [the reporter design](remote-qualification-reporter.md). +It automates selected local metadata observations, not this whole workflow. +The production CLI, wire schema and qualification/authority boundaries do not change. Existing exact-version SSH preflight, host-key checks, submission/unknown and no-resend behavior remain requirements. Passing this checklist proves neither @@ -184,16 +188,19 @@ These are bounded paper scenarios, not commands or tests to execute. | An old digest is explicitly selected and compared with a current measurement | Record a new comparison result and both observations' scope; never inherit the old check's pass or claim preservation throughout the interval. | | Old Windows success is offered for a different account/runtime or a current Linux cell | Keep the dated historical reference; current cell stays unassessed or unknown, according to whether assessment was attempted. | -## Reporter decision and review gate +## Reporter extension decision and review gate -Choose **checklist only** for this stage. There is no demonstrated need for a -reporter, and no implementation is authorized by this proposal. Revisit only if -operator use exposes repeated, concrete static transcription errors or effort -that a bounded regular-file inspection tool would materially reduce. That would -require a separate accepted design covering native paths, limits, explicit unknown -results and preservation of unaffected entries, without executing discovered tools. +The initial decision was checklist only. On 2026-10-21 the owner explicitly +chose to extend it with a repository-only read-only reporter. The practical +rationale is to reduce transcription mistakes in selected package-field and +digest comparisons. No measured workflow pilot or time-saving claim is made. -Human review must accept the workflow, evidence limits, state meanings and #23 -mapping before any implementation proposal. Any later implementation or live -experiment needs its own explicit scope and authorization. This review does not -close #23, accept #155 or commit work to a release. +The [reporter contract](remote-qualification-reporter.md) is limited to explicit +local file observations. Diagnostics, remote access, runtime/native execution, +ownership, caller use and remediation remain outside the program. There is no +new journal, receipt, lock, recovery engine or production CLI command. + +This authorizes the requested isolated implementation and review, not a merge, +release, live experiment or closure of #23. Human PR review must still assess +the implementation and remaining design choices. Additional functionality +needs its own explicit scope; #155 remains deferred. diff --git a/docs/design/remote-qualification-reporter.md b/docs/design/remote-qualification-reporter.md new file mode 100644 index 0000000..86d695d --- /dev/null +++ b/docs/design/remote-qualification-reporter.md @@ -0,0 +1,138 @@ +# Repository-only static qualification reporter + +Status: implementation proposal for [#158](https://github.com/abruption/session-peer-ts/issues/158). +The owner requested this limited code extension on 2026-10-21 after reviewing +[the checklist](remote-qualification-checklist.md). It supersedes only the +checklist-only deferral of a reporter; it does not revive #155 or adopt F1–F6. + +## Purpose and invocation + +From a repository checkout with the existing supported Node runtime: + +```sh +node scripts/remote-qualification.mjs < assessment.json +node scripts/remote-qualification.mjs --help +``` + +The script uses Node built-ins and requires no build. It is not installed by +npm, exported by the runtime SDK or integrated into `session-peer` commands. +These review documents and the script are not added to the npm archive. +Its source is [scripts/remote-qualification.mjs](../../scripts/remote-qualification.mjs). + +It records only declared context, selected local package JSON name/version +fields and optional selected-file SHA-256 measurements/comparisons. Metadata +copied from a remote host is still just a local file: the reporter does not +verify its remote origin or attribution to an installed/running program. + +## Input + +One complete UTF-8 JSON document is read from stdin. For example, after +replacing paths and labels with the operator's explicit selections: + +```json +{ + "context": { + "installation": "/selected/installation", + "manager": "npm", + "destination": "operator-selected label", + "account": "operator-selected account" + }, + "packageJson": "/selected/installation/node_modules/session-peer/package.json", + "expectedVersion": "0.5.0", + "files": [] +} +``` + +- Top-level keys are exactly `context`, `packageJson`, `expectedVersion` and + optional `files`. Unknown keys and invalid shapes are rejected before file reads. +- Context requires nonempty `installation` (at most 2,048 characters) and + `manager` (128); optional `destination` and `account` are limited to 256 each. + These are operator declarations, not authenticated ownership or host identity. +- `packageJson` is a nonempty path string (2,048 characters maximum). + `expectedVersion` is nonempty text of at most 128 characters. Comparison is + exact text; this is not SemVer-range, implementation or runtime compatibility. +- `files` has at most 16 entries, each with `path` (same length bound) and an + optional `baseline`. No globbing, PATH discovery or recursive inventory occurs. +- A usable baseline has only `sha256` (64 hexadecimal digits), `source` (nonempty, + at most 512 characters) and `observedAt` (nonempty, at most 128). Source/time + labels are recorded as unverified operator attestation, not authenticated + history or clock validation. The comparison proves no interval-wide preservation. +- No baseline means the comparison is `not_checked`; malformed/incomplete + baseline evidence makes that comparison `unknown`. Unknown object keys are + input errors. Invalid evidence does not promote null hashes or old pass results. +- JSON grammar is parsed by `JSON.parse`; duplicate decoded object keys, + including escaped spellings, are rejected. Metadata with duplicate keys also + yields unknown field observations, even when duplicates are in ignored fields. + +Input is limited to 64 KiB of raw UTF-8, plus at most one byte to detect overflow. +Invalid input, oversized input or invalid UTF-8 emits a fixed generic error and +exit 2, without echoing input, filesystem exception details or stack traces. + +## Report and states + +Valid input emits one JSON report and exits 0 even if checks fail or are unknown. +**Exit 0 means processed, never ready, qualified or authorized.** The report's +`reportVersion: 1` identifies this repository-only format, not the runtime wire +schema or a stable SDK. There is no aggregate pass, qualification token or action. + +The report has scope `local_static_assessment`, `assessmentOnly: true`, supplied +context labelled `operator_attestation`, per-predicate `checks` and `completedAt`. +Each check has an id, predicate, `state`, evidence kind, source, recording time +`observedAt` and bounded relevant values/reason. Times are the reporting machine's +clock when predicates are recorded; they are not atomic observation instants, +trusted chronology or a reusable freshness window. + +- `selection` passes only the predicate that installation/manager were declared. +- `package.name` compares the selected JSON's parsed name with `session-peer`; + `package.version` compares its parsed version text with `expectedVersion`. + Missing/non-string fields, invalid JSON/UTF-8 or unavailable evidence are unknown. + Only these fields are exposed; other metadata contents are not printed. +- Each selected file has a digest observation and a separate baseline comparison. + A usable current digest and baseline can pass/fail the comparison; absent, + unusable or conflicting observations remain unknown/unsupported as appropriate. + Matching digests do not authenticate code, establish ownership/current use, + bind a remote installation or prove preservation between measurements. +- Runtime execution, diagnostics, native qualification, ownership and caller use + remain `not_checked`, even when every static comparison passes. + +The checklist's predicate-local states apply. An unsupported inspection method +is not a platform compatibility result. One failed/unknown entry does not discard +healthy sibling results. Reports contain selected paths/context and belong in +operator-controlled notes; do not provide credentials as context or metadata. + +## File reader and effect limits + +The initial reader supports local POSIX paths only. Windows or unavailable safe +open flags reports `unsupported`; this does not add Windows/native qualification. +Relative paths, final symlinks, directories, FIFOs and devices are unsupported. +The program does not follow a discovered tool or execute a selected file. +Parent directory symlinks are not excluded and no canonical ownership is claimed. + +A selected regular file is opened read-only with `O_NOFOLLOW | O_NONBLOCK`. +Descriptor type/identity and observable path/descriptor size/timestamps are checked +before and after reading. Each file has a 1 MiB raw-byte limit plus one overflow +byte; chunks are at most 64 KiB. Closing every owned descriptor is attempted in `finally`, including on +read/error paths; a close error makes the observation unknown, without claiming +that cleanup was confirmed. Growth, replacement, read/close errors and observable +instability cannot produce a verified measurement. Inherited stdin is not an +owned descriptor and is not explicitly closed by the reader. + +These are byte bounds, not a kernel-I/O deadline, atomic snapshot, protection +against an undetectable same-user rewrite or code authentication. The program +intentionally writes only stdout; it creates no state/cache/journal files. +Ordinary OS read effects, such as access-time changes, are not promised absent. +It executes no discovered program, diagnostic, subprocess, SSH or registry request. +Installation, activation, caller switching, deletion, rollback, retention, receipt +cleanup, lock breaking, retries and recovery authority remain outside the tool. + +## Verification and review boundary + +Isolated tests cover per-predicate states, invalid/duplicate inputs, metadata +limits, baseline uncertainty, special/path cases, observable file races, read and +allocation bounds, descriptor cleanup, safe imports and CLI error/nonexecution +behavior. Mock POSIX evidence is separate from native platform qualification. +There are no live-host or agent tests in this implementation request. + +The expanded contract and implementation undergo fresh noninteractive Opus 5.5 +review before PR handoff. Human merge review is required; no release, publication, +installation-manager change or closure of the original #23 criteria is implied. diff --git a/scripts/remote-qualification.mjs b/scripts/remote-qualification.mjs new file mode 100644 index 0000000..3f44762 --- /dev/null +++ b/scripts/remote-qualification.mjs @@ -0,0 +1,227 @@ +import * as fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path/posix'; +import { pathToFileURL } from 'node:url'; + +export const LIMITS = Object.freeze({ inputBytes: 64 * 1024, fileBytes: 1024 * 1024, chunkBytes: 64 * 1024, files: 16 }); +const invalid = () => { throw new Error('invalid_assessment_input'); }; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const label = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max; +const keys = (value, allowed) => { + if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) invalid(); +}; + +// JSON.parse owns the grammar. This iterative token walk only finds duplicate +// decoded object keys, including escaped spellings, without recursive parsing. +function uniqueKeys(text) { + const stack = []; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"') { + const start = i; + while (++i < text.length) { + if (text[i] === '\\') i++; + else if (text[i] === '"') break; + } + const top = stack.at(-1); + if (top?.kind === '{' && top.key) { + const key = JSON.parse(text.slice(start, i + 1)); + if (top.seen.has(key)) invalid(); + top.seen.add(key); + top.key = false; + } + } else if (char === '{' || char === '[') { + stack.push({ kind: char, key: char === '{', seen: new Set() }); + } else if (char === '}' || char === ']') stack.pop(); + else if (char === ',' && stack.at(-1)?.kind === '{') stack.at(-1).key = true; + } +} + +function parseJson(text) { + const value = JSON.parse(text); + uniqueKeys(text); + return value; +} + +/** Parse the complete bounded UTF-8 assessment specification; never reads files. */ +export function parseInput(text) { + if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > LIMITS.inputBytes) invalid(); + let value; + try { value = parseJson(text); } catch { invalid(); } + keys(value, ['context', 'packageJson', 'expectedVersion', 'files']); + keys(value.context, ['installation', 'manager', 'destination', 'account']); + if (!label(value.context.installation, 2048) || !label(value.context.manager, 128) || + !label(value.packageJson, 2048) || !label(value.expectedVersion, 128)) invalid(); + for (const key of ['destination', 'account']) { + if (Object.hasOwn(value.context, key) && !label(value.context[key], 256)) invalid(); + } + if (Object.hasOwn(value, 'files')) { + if (!Array.isArray(value.files) || value.files.length > LIMITS.files) invalid(); + for (const file of value.files) { + keys(file, ['path', 'baseline']); + if (!label(file.path, 2048)) invalid(); + // Incomplete/malformed baseline evidence remains a per-file unknown. + if (object(file.baseline)) keys(file.baseline, ['sha256', 'source', 'observedAt']); + } + } + return value; +} + +const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino; +const sameObservation = (a, b) => sameIdentity(a, b) && a.size === b.size && + (a.mtimeNs ?? a.mtimeMs) === (b.mtimeNs ?? b.mtimeMs) && (a.ctimeNs ?? a.ctimeMs) === (b.ctimeNs ?? b.ctimeMs); + +/** Bounded POSIX regular-file inspection. Parent symlinks are not excluded. + * The fsImpl seam supports deterministic tests; it is never selected from env. + */ +export function readRegularFile(path, { fsImpl = fs, platform = process.platform } = {}) { + const constants = fsImpl.constants ?? fs.constants; + if (platform === 'win32' || !Number.isInteger(constants.O_NOFOLLOW) || constants.O_NOFOLLOW === 0 || + !Number.isInteger(constants.O_NONBLOCK) || constants.O_NONBLOCK === 0) { + return { state: 'unsupported', reason: 'safe_posix_reader_unavailable' }; + } + if (typeof path !== 'string' || !isAbsolute(path)) return { state: 'unsupported', reason: 'native_absolute_path_required' }; + let fd; + let result; + try { + const before = fsImpl.lstatSync(path, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink()) return { state: 'unsupported', reason: 'not_regular_file' }; + if (before.size > LIMITS.fileBytes) return { state: 'unknown', reason: 'file_byte_limit' }; + fd = fsImpl.openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const opened = fsImpl.fstatSync(fd, { bigint: true }); + if (!opened.isFile()) result = { state: 'unsupported', reason: 'not_regular_file' }; + else if (!sameObservation(before, opened)) result = { state: 'unknown', reason: 'file_changed' }; + else { + const chunks = []; + let total = 0; + while (total <= LIMITS.fileBytes) { + const chunk = Buffer.alloc(Math.min(LIMITS.chunkBytes, LIMITS.fileBytes + 1 - total)); + const count = fsImpl.readSync(fd, chunk, 0, chunk.length, null); + if (count === 0) break; + chunks.push(chunk.subarray(0, count)); + total += count; + } + const after = fsImpl.fstatSync(fd, { bigint: true }); + const named = fsImpl.lstatSync(path, { bigint: true }); + if (total > LIMITS.fileBytes) result = { state: 'unknown', reason: 'file_byte_limit' }; + else if (!after.isFile() || !named.isFile() || named.isSymbolicLink() || + !sameObservation(opened, after) || !sameObservation(after, named) || BigInt(total) !== BigInt(after.size)) { + result = { state: 'unknown', reason: 'file_changed' }; + } else result = { state: 'pass', bytes: Buffer.concat(chunks, total) }; + } + } catch (error) { + result = ['ELOOP', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EINVAL'].includes(error?.code) + ? { state: 'unsupported', reason: 'safe_read_unsupported' } + : { state: 'unknown', reason: 'file_read_unavailable' }; + } finally { + if (fd !== undefined) { + try { fsImpl.closeSync(fd); } + catch { result = { state: 'unknown', reason: 'file_close_unverified' }; } + } + } + return result; +} + +const decode = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes); +/** Read at most the stdin byte limit plus one overflow byte. fd 0 is not owned. */ +export function readInput({ fsImpl = fs } = {}) { + const chunks = []; + let size = 0; + while (size <= LIMITS.inputBytes) { + const chunk = Buffer.alloc(Math.min(LIMITS.chunkBytes, LIMITS.inputBytes + 1 - size)); + const count = fsImpl.readSync(0, chunk, 0, chunk.length, null); + if (count === 0) return decode(Buffer.concat(chunks, size)); + chunks.push(chunk.subarray(0, count)); + size += count; + } + invalid(); +} + +const baselineValid = baseline => object(baseline) && typeof baseline.sha256 === 'string' && + /^[a-fA-F0-9]{64}$/.test(baseline.sha256) && label(baseline.source, 512) && label(baseline.observedAt, 128); + +/** Record independent predicates. States are evidence, never an operational gate. */ +export function assess(input, { fsImpl = fs, platform = process.platform, now = () => new Date().toISOString() } = {}) { + // Validate all entries before any filesystem inspection, also for imported use. + const value = parseInput(JSON.stringify(input)); + const checks = []; + const add = (id, predicate, state, evidenceKind, source, details = {}) => + checks.push({ id, predicate, state, evidenceKind, source, observedAt: now(), ...details }); + add('selection', 'Operator explicitly declares an installation and manager', 'pass', + 'operator_attestation', 'input.context', { reason: 'declaration_only_not_ownership_proof' }); + const inspected = readRegularFile(value.packageJson, { fsImpl, platform }); + let metadata; + if (inspected.state === 'pass') { + try { + metadata = parseJson(decode(inspected.bytes)); + if (!object(metadata)) metadata = undefined; + } catch { metadata = undefined; } + } + for (const [field, expected] of [['name', 'session-peer'], ['version', value.expectedVersion]]) { + const observed = metadata?.[field]; + const readable = label(observed, 128); + add(`package.${field}`, `Selected package JSON ${field} equals the expected text`, + inspected.state !== 'pass' ? inspected.state : !readable ? 'unknown' : observed === expected ? 'pass' : 'fail', + 'static_metadata', value.packageJson, { expected, ...(readable ? { observed } : {}), + reason: inspected.state !== 'pass' ? inspected.reason : !readable ? 'metadata_field_unverified' : 'selected_file_field_only' }); + } + for (const [index, file] of (value.files ?? []).entries()) { + const read = readRegularFile(file.path, { fsImpl, platform }); + const sha256 = read.state === 'pass' ? createHash('sha256').update(read.bytes).digest('hex') : undefined; + add(`files.${index}.digest`, 'Selected regular file has a current bounded SHA-256 measurement', + read.state, 'static_metadata', file.path, sha256 ? { observed: sha256, reason: 'measured_bytes_only' } : { reason: read.reason }); + if (!Object.hasOwn(file, 'baseline')) { + add(`files.${index}.comparison`, 'Current digest equals the operator-labelled baseline digest', + 'not_checked', 'static_metadata', file.path, { reason: 'baseline_not_provided' }); + } else if (!baselineValid(file.baseline)) { + add(`files.${index}.comparison`, 'Current digest equals the operator-labelled baseline digest', + 'unknown', 'static_metadata', file.path, { reason: 'baseline_evidence_incomplete' }); + } else { + const expected = file.baseline.sha256.toLowerCase(); + add(`files.${index}.comparison`, 'Current digest equals the operator-labelled baseline digest', + sha256 ? (sha256 === expected ? 'pass' : 'fail') : read.state, 'static_metadata', file.path, + { expected, ...(sha256 ? { observed: sha256 } : {}), + baseline: { source: file.baseline.source, observedAt: file.baseline.observedAt, evidenceKind: 'operator_attestation' }, + reason: sha256 ? 'digest_equality_only_not_authentication_use_or_ownership' : read.reason }); + } + } + for (const [id, predicate] of [ + ['runtime_execution', 'Selected runtime and CLI were actually executed'], + ['diagnostics', 'Separately authorized diagnostics were executed'], + ['native_qualification', 'The destination cell has current native qualification'], + ['ownership', 'Storage ownership is established'], + ['caller_use', 'Current callers use the selected installation'] + ]) add(id, predicate, 'not_checked', 'none', 'none', { reason: 'outside_local_static_assessment' }); + return { reportVersion: 1, scope: 'local_static_assessment', assessmentOnly: true, + context: { evidenceKind: 'operator_attestation', provided: value.context }, completedAt: now(), checks, + limits: { byteBoundsOnly: true, atomicSnapshot: false, contentAuthentication: false, parentSymlinksExcluded: false } }; +} + +const HELP = `Usage: node scripts/remote-qualification.mjs < assessment.json + node scripts/remote-qualification.mjs --help +Local POSIX static inspection; no tool execution or network connections. +No intentional file/state writes except stdout. +Required: context {installation, manager}, packageJson (absolute path), expectedVersion. +Optional: context.destination/account; files [{path, baseline?: {sha256, source, observedAt}}]. +Limits: 64 KiB UTF-8 input; 16 files; 1 MiB per selected regular file. +Baseline source/time are operator labels, not authenticated history. +Exit 0: report processed regardless of check states. Exit 2: invalid specification. +No report grants qualification, readiness, ownership or permission to act. +`; + +function main() { + try { + if (process.argv.length === 3 && process.argv[2] === '--help') { + process.stdout.write(HELP); + return; + } + if (process.argv.length !== 2) invalid(); + const input = parseInput(readInput()); + process.stdout.write(JSON.stringify(assess(input)) + '\n'); + } catch { + process.stdout.write('{"error":"invalid_assessment_input"}\n'); + process.exitCode = 2; + } +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main(); diff --git a/test/remote-qualification.test.ts b/test/remote-qualification.test.ts new file mode 100644 index 0000000..893ef7d --- /dev/null +++ b/test/remote-qualification.test.ts @@ -0,0 +1,244 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +// This repository-only tool deliberately has no runtime/agent dependency. +import { LIMITS, parseInput, readInput, readRegularFile, assess } from '../scripts/remote-qualification.mjs'; + +// The production seam defaults to node:fs; tests supply only the methods exercised. +const read = (path: string, options: any) => readRegularFile(path, options); +const stdin = (options: any) => readInput(options); +const script = resolve('scripts/remote-qualification.mjs'); +const digest = (bytes: string | Buffer) => createHash('sha256').update(bytes).digest('hex'); +const observedAt = '2026-10-21T01:02:03.000Z'; +function fixture(t: TestContext) { + const root = fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'tests-qualification-')); + fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const pkg = join(root, 'package.json'); + fs.writeFileSync(pkg, '{"name":"session-peer","version":"0.5.0"}'); + const input = { context: { installation: root, manager: 'operator', destination: 'declared-host', account: 'declared-user' }, packageJson: pkg, expectedVersion: '0.5.0' }; + const invoke = (text: string | Buffer, args: string[] = []) => { + const child = spawnSync(process.execPath, [script, ...args], { input: text, encoding: 'utf8', timeout: 10000 }); + assert.ifError(child.error); assert.equal(child.signal, null); + return { code: child.status, stdout: child.stdout, stderr: child.stderr }; + }; + return { root, pkg, input, invoke }; +} + +// Deterministic owned descriptors: never touch a platform's real special files. +function fakeFile(bytes: Buffer, options: { kind?: string; size?: number; fail?: string; changed?: string } = {}) { + const calls = { opens: 0, closes: 0, reads: 0, maxBuffer: 0, flags: 0, consumed: 0 }; + let statCount = 0; + const stat = () => { + statCount++; + return { dev: 1, ino: options.changed === 'replace' && statCount > 2 ? 2 : 1, + size: options.size ?? bytes.length, mtimeMs: options.changed === 'time' && statCount > 2 ? 2 : 1, + ctimeMs: 1, isFile: () => !options.kind || options.kind === 'file', + isSymbolicLink: () => options.kind === 'symlink' }; + }; + const io = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x100, O_NONBLOCK: 0x200 }, + lstatSync: () => { if (options.fail === 'lstat') throw new Error('PRIVATE-FAILURE'); return stat(); }, + openSync: (_path: string, flags: number) => { calls.flags = flags; calls.opens++; if (options.fail === 'open') throw new Error('PRIVATE-FAILURE'); return 91; }, + fstatSync: (fd: number) => { assert.equal(fd, 91); if (options.fail === 'fstat') throw new Error('PRIVATE-FAILURE'); return stat(); }, + readSync: (fd: number, buffer: Buffer, offset: number, length: number, position: number | null) => { + assert.equal(fd, 91); calls.reads++; calls.maxBuffer = Math.max(calls.maxBuffer, buffer.length); + if (options.fail === 'read') throw new Error('PRIVATE-FAILURE'); + const start = position ?? calls.consumed; + const count = Math.min(length, Math.max(0, bytes.length - start)); + bytes.copy(buffer, offset, start, start + count); calls.consumed += count; return count; + }, + closeSync: (fd: number) => { assert.equal(fd, 91); calls.closes++; } + }; + return { io, calls }; +} + +const check = (report: any, id: string) => { + const found = report.checks.find((item: any) => item.id === id); + assert.ok(found, `missing predicate ${id}`); return found; +}; +const evaluate = (input: unknown, options: any = {}) => assess(input, { now: () => observedAt, ...options }); + +test('current package predicates and unexecuted checks have separate evidence scopes', t => { + const f = fixture(t), before = fs.readFileSync(f.pkg); + const report = evaluate(f.input); + assert.equal(report.reportVersion, 1); assert.equal(report.scope, 'local_static_assessment'); + assert.equal(report.completedAt, observedAt); + assert.ok(report.checks.every((item: any) => item.observedAt === observedAt)); assert.equal(check(report, 'selection').state, 'pass'); + const expected = process.platform === 'win32' ? 'unsupported' : 'pass'; + assert.equal(check(report, 'package.name').state, expected); assert.equal(check(report, 'package.version').state, expected); + for (const id of ['runtime_execution', 'diagnostics', 'native_qualification', 'ownership', 'caller_use']) { + assert.equal(check(report, id).state, 'not_checked'); + } + for (const key of ['qualified', 'authorized', 'authorization', 'ready', 'status']) assert.equal(Object.hasOwn(report, key), false); + assert.deepEqual(fs.readFileSync(f.pkg), before); assert.deepEqual(fs.readdirSync(f.root), ['package.json']); +}); + +test('metadata is exact, bounded, fatal UTF-8 and duplicate-key aware without exposing other fields', () => { + const input = { context: { installation: '/chosen', manager: 'operator' }, packageJson: '/chosen/package.json', expectedVersion: '0.5.0' }; + const cases: [string | Buffer, string, string][] = [ + ['{"name":"other","version":"0.5.0"}', 'fail', 'pass'], + ['{"name":"session-peer","version":"0.5.1"}', 'pass', 'fail'], + ['{"name":"session-peer"}', 'pass', 'unknown'], + ['{"name":"session-peer","version":5}', 'pass', 'unknown'], + ['{"name":"session-peer","version":"0.5.0","version":"0.5.0"}', 'unknown', 'unknown'], + ['{"name":"session-peer","version":"0.5.0","ver\\u0073ion":"0.5.0"}', 'unknown', 'unknown'], + ['{"name":"session-peer","version":"0.5.0","private":{"token":"HIDDEN-METADATA"}}', 'pass', 'pass'], + [Buffer.from([0x7b, 0xff, 0x7d]), 'unknown', 'unknown'], + ['[]', 'unknown', 'unknown'], ['{broken', 'unknown', 'unknown'] + ]; + for (const [data, name, version] of cases) { + const { io } = fakeFile(typeof data === 'string' ? Buffer.from(data) : data); + const report = evaluate(input, { fsImpl: io, platform: 'linux' }); + assert.equal(check(report, 'package.name').state, name); + assert.equal(check(report, 'package.version').state, version); + assert.equal(JSON.stringify(report).includes('HIDDEN-METADATA'), false); + } +}); + +test('invalid specifications fail before filesystem inspection and never echo input', t => { + const f = fixture(t); + const bad: unknown[] = [null, [], {}, { ...f.input, secret: 'DO-NOT-ECHO' }, + { ...f.input, context: { ...f.input.context, unknown: true } }, { ...f.input, expectedVersion: '' }, + { ...f.input, expectedVersion: 'a'.repeat(129) }, { ...f.input, files: Array(17).fill({ path: f.pkg }) }, + { ...f.input, files: [{ path: f.pkg, execute: true }] }, + { ...f.input, files: [{ path: f.pkg, baseline: { sha256: null, unknown: true } }] }]; + const noReads = new Proxy({}, { get() { assert.fail('invalid input touched filesystem'); } }); + for (const input of bad) assert.throws(() => evaluate(input, { fsImpl: noReads }), /invalid_assessment_input/); + const valid = JSON.stringify(f.input); + const exactInput = valid + ' '.repeat(LIMITS.inputBytes - Buffer.byteLength(valid)); + assert.deepEqual(parseInput(exactInput), f.input); + assert.equal(f.invoke(exactInput).code, 0); + assert.throws(() => parseInput(valid + ' '.repeat(LIMITS.inputBytes)), /invalid_assessment_input/); + assert.throws(() => parseInput(valid.replace('"expectedVersion":', '"expectedVersion":"x","expectedVersion":')), /invalid_assessment_input/); + assert.throws(() => parseInput(valid.replace('"expectedVersion":', '"expectedVersion":"x","expectedVer\\u0073ion":')), /invalid_assessment_input/); + const result = f.invoke(JSON.stringify({ ...f.input, secret: 'DO-NOT-ECHO' })); + assert.equal(result.code, 2); assert.deepEqual(JSON.parse(result.stdout), { error: 'invalid_assessment_input' }); + assert.equal(result.stderr, ''); +}); + +test('baseline comparisons never inherit success and keep unrelated healthy measurements', t => { + const f = fixture(t), healthy = join(f.root, 'healthy'), missing = join(f.root, 'missing'); + fs.writeFileSync(healthy, 'current file'); + const baseline = { sha256: digest('current file'), source: 'dated operator record', observedAt: '2026-10-01T00:00:00Z' }; + const files = [ + { path: healthy, baseline }, { path: healthy, baseline: { ...baseline, sha256: '0'.repeat(64) } }, + { path: healthy }, { path: healthy, baseline: null }, + { path: healthy, baseline: { sha256: null } }, { path: healthy, baseline: { sha256: baseline.sha256 } }, + { path: missing, baseline }, { path: healthy, baseline: { ...baseline, sha256: baseline.sha256.toUpperCase() } } + ]; + const report = evaluate({ ...f.input, files }); + if (process.platform === 'win32') { + assert.equal(check(report, 'files.0.digest').state, 'unsupported'); + assert.equal(check(report, 'files.0.comparison').state, 'unsupported'); return; + } + assert.deepEqual(files.map((_, i) => check(report, `files.${i}.comparison`).state), ['pass', 'fail', 'not_checked', 'unknown', 'unknown', 'unknown', 'unknown', 'pass']); + assert.equal(check(report, 'files.2.digest').observed, baseline.sha256); + assert.equal(check(report, 'files.6.digest').state, 'unknown'); + assert.equal(check(report, 'files.7.digest').state, 'pass'); + assert.deepEqual(check(report, 'files.0.comparison').baseline, { source: baseline.source, observedAt: baseline.observedAt, evidenceKind: 'operator_attestation' }); + assert.equal(check(report, 'ownership').state, 'not_checked'); assert.equal(check(report, 'caller_use').state, 'not_checked'); +}); + +test('reader refuses special files and unsupported methods before opening or reading', () => { + for (const kind of ['symlink', 'fifo', 'directory', 'device']) { + const { io, calls } = fakeFile(Buffer.from('private'), { kind }); + assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unsupported'); + assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); + } + for (const path of ['relative', 'C:\\owned\\file', '\\\\server\\share', '']) { + const { io, calls } = fakeFile(Buffer.from('private')); + assert.equal(read(path, { fsImpl: io, platform: 'linux' }).state, 'unsupported'); assert.equal(calls.opens, 0); + } + const { io, calls } = fakeFile(Buffer.from('private')); + assert.equal(read('/owned/file', { fsImpl: io, platform: 'win32' }).state, 'unsupported'); + assert.equal(read('/owned/file', { fsImpl: { ...io, constants: {} }, platform: 'linux' }).state, 'unsupported'); + assert.equal(calls.opens, 0); +}); + +test('reader enforces byte budgets with bounded allocations and closes its descriptor', () => { + for (const extra of [0, 1]) { + // The extra byte simulates growth after lstat; no huge allocation from claimed size. + const { io, calls } = fakeFile(Buffer.alloc(LIMITS.fileBytes + extra, 65), { size: LIMITS.fileBytes }); + const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); + assert.equal(result.state, extra ? 'unknown' : 'pass'); + if (!extra) { assert.ok(result.bytes); assert.equal(result.bytes.length, LIMITS.fileBytes); } + assert.equal(calls.consumed, LIMITS.fileBytes + extra); assert.equal(calls.reads, 17); + assert.ok(calls.maxBuffer <= LIMITS.chunkBytes); assert.equal(calls.closes, 1); + assert.equal(calls.flags, io.constants.O_RDONLY | io.constants.O_NOFOLLOW | io.constants.O_NONBLOCK); + } + const { io, calls } = fakeFile(Buffer.alloc(0), { size: LIMITS.fileBytes + 1 }); + assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); + assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); +}); + +test('replacement, observable changes and I/O failures produce unknown and close owned fds', () => { + for (const options of [{ changed: 'replace' }, { changed: 'time' }, { fail: 'fstat' }, { fail: 'read' }, { fail: 'open' }, { fail: 'lstat' }]) { + const { io, calls } = fakeFile(Buffer.from('abc'), options); + const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); + assert.equal(result.state, 'unknown'); assert.equal(calls.closes, options.fail === 'open' || options.fail === 'lstat' ? 0 : 1); + assert.equal(JSON.stringify(result).includes('PRIVATE-FAILURE'), false); + } + const { io, calls } = fakeFile(Buffer.from('abc')); + io.closeSync = () => { calls.closes++; throw new Error('PRIVATE-CLOSE'); }; + assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); assert.equal(calls.closes, 1); +}); + +test('CLI handles bounds, options and import without consuming stdin or executing selected content', t => { + const f = fixture(t), marker = join(f.root, 'must-not-exist'), selected = join(f.root, 'selected.js'); + fs.writeFileSync(selected, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'executed')`); + const before = fs.readdirSync(f.root), bytes = fs.readFileSync(selected); + const result = f.invoke(JSON.stringify({ ...f.input, expectedVersion: 'mismatch', files: [{ path: selected }] })); + assert.equal(result.code, 0); assert.equal(check(JSON.parse(result.stdout), 'package.version').state, process.platform === 'win32' ? 'unsupported' : 'fail'); + assert.equal(fs.existsSync(marker), false); assert.deepEqual(fs.readFileSync(selected), bytes); assert.deepEqual(fs.readdirSync(f.root), before); + for (const [text, args] of [[Buffer.from([0xff]), []], [' '.repeat(LIMITS.inputBytes + 1), []], ['', ['--host', 'NEVER-CONNECT']], ['', ['--unknown']]] as [string | Buffer, string[]][]) { + const failure = f.invoke(text, args); assert.equal(failure.code, 2); assert.deepEqual(JSON.parse(failure.stdout), { error: 'invalid_assessment_input' }); assert.equal(failure.stderr, ''); + } + assert.match(f.invoke('', ['--help']).stdout, /static/i); + const imported = spawnSync(process.execPath, ['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(script).href)}); console.log('imported')`], { input: 'INVALID-STDIN', encoding: 'utf8', timeout: 10000 }); + assert.ifError(imported.error); assert.equal(imported.status, 0); assert.equal(imported.stdout.trim(), 'imported'); assert.equal(imported.stderr, ''); +}); + + +test('stdin reader caps actual reads at input limit plus one and does not own fd zero', () => { + for (const overflow of [false, true]) { + const bytes = Buffer.alloc(LIMITS.inputBytes + (overflow ? 100000 : 0), 32); + let consumed = 0, reads = 0, maxBuffer = 0; + const io = { + readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number | null) { + assert.equal(fd, 0); assert.equal(position, null); reads++; maxBuffer = Math.max(maxBuffer, buffer.length); + const count = Math.min(length, bytes.length - consumed); + bytes.copy(buffer, offset, consumed, consumed + count); consumed += count; return count; + }, + closeSync() { assert.fail('stdin is not an owned descriptor'); } + }; + if (overflow) assert.throws(() => stdin({ fsImpl: io }), /invalid_assessment_input/); + else assert.equal(stdin({ fsImpl: io }), bytes.toString('utf8')); + assert.equal(consumed, LIMITS.inputBytes + (overflow ? 1 : 0)); + assert.equal(reads, 2); assert.ok(maxBuffer <= LIMITS.chunkBytes); + } + let read = false; + assert.throws(() => stdin({ fsImpl: { + readSync(fd: number, buffer: Buffer) { assert.equal(fd, 0); if (read) return 0; read = true; buffer[0] = 255; return 1; }, + closeSync() { assert.fail('stdin is not an owned descriptor'); } + } })); +}); + + +test('reader retains exact large inode identity with bigint stats', () => { + const { io } = fakeFile(Buffer.from('abc')); + let observations = 0; + const stat = (_: unknown, options?: { bigint?: boolean }) => { + assert.equal(options?.bigint, true); + observations++; + return { dev: 1n, ino: observations > 2 ? 9007199254740993n : 9007199254740992n, + size: 3n, mtimeNs: 1n, ctimeNs: 1n, isFile: () => true, isSymbolicLink: () => false }; + }; + const exact = { ...io, lstatSync: stat, fstatSync: stat }; + assert.equal(read('/owned/file', { fsImpl: exact, platform: 'linux' }).state, 'unknown'); +}); From 22244e40eb6cb9b5e066dc8af2a351517d9b9b48 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 10:31:34 +0900 Subject: [PATCH 5/8] fix: bound reporter buffers and resolve symlink entry paths Refs #158. Address independent Opus review and strengthen regression assertions. --- docs/design/remote-qualification-reporter.md | 26 ++-- scripts/remote-qualification.mjs | 47 ++++---- test/remote-qualification.test.ts | 118 +++++++++++++------ 3 files changed, 129 insertions(+), 62 deletions(-) diff --git a/docs/design/remote-qualification-reporter.md b/docs/design/remote-qualification-reporter.md index 86d695d..51e359a 100644 --- a/docs/design/remote-qualification-reporter.md +++ b/docs/design/remote-qualification-reporter.md @@ -45,16 +45,17 @@ replacing paths and labels with the operator's explicit selections: - Top-level keys are exactly `context`, `packageJson`, `expectedVersion` and optional `files`. Unknown keys and invalid shapes are rejected before file reads. -- Context requires nonempty `installation` (at most 2,048 characters) and +- Context requires nonempty `installation` (at most 2,048 UTF-16 code units) and `manager` (128); optional `destination` and `account` are limited to 256 each. + All string length bounds use UTF-16 code units, not Unicode code points. These are operator declarations, not authenticated ownership or host identity. -- `packageJson` is a nonempty path string (2,048 characters maximum). - `expectedVersion` is nonempty text of at most 128 characters. Comparison is +- `packageJson` is a nonempty path string (2,048 UTF-16 code units maximum). + `expectedVersion` is nonempty text of at most 128 UTF-16 code units. Comparison is exact text; this is not SemVer-range, implementation or runtime compatibility. - `files` has at most 16 entries, each with `path` (same length bound) and an optional `baseline`. No globbing, PATH discovery or recursive inventory occurs. - A usable baseline has only `sha256` (64 hexadecimal digits), `source` (nonempty, - at most 512 characters) and `observedAt` (nonempty, at most 128). Source/time + at most 512 UTF-16 code units) and `observedAt` (nonempty, at most 128). Source/time labels are recorded as unverified operator attestation, not authenticated history or clock validation. The comparison proves no interval-wide preservation. - No baseline means the comparison is `not_checked`; malformed/incomplete @@ -66,7 +67,9 @@ replacing paths and labels with the operator's explicit selections: Input is limited to 64 KiB of raw UTF-8, plus at most one byte to detect overflow. Invalid input, oversized input or invalid UTF-8 emits a fixed generic error and -exit 2, without echoing input, filesystem exception details or stack traces. +exit 2 (`invalid_assessment_input`), without echoing input, filesystem exception +details or stack traces. A stdin I/O failure instead emits `input_unavailable` +and exit 2. Output transport failures are outside these input/report exit rules. ## Report and states @@ -108,10 +111,13 @@ Relative paths, final symlinks, directories, FIFOs and devices are unsupported. The program does not follow a discovered tool or execute a selected file. Parent directory symlinks are not excluded and no canonical ownership is claimed. -A selected regular file is opened read-only with `O_NOFOLLOW | O_NONBLOCK`. +A selected regular file is opened read-only with `O_NOFOLLOW | O_NONBLOCK` +and `O_NOCTTY` when available. Descriptor type/identity and observable path/descriptor size/timestamps are checked -before and after reading. Each file has a 1 MiB raw-byte limit plus one overflow -byte; chunks are at most 64 KiB. Closing every owned descriptor is attempted in `finally`, including on +before and after reading. Each file has a 1 MiB raw-byte limit. One buffer sized to the observed file +size plus one overflow byte is allocated; each read requests at most 64 KiB. +Stdin similarly uses one 64 KiB-plus-one buffer. Short reads do not retain a new +buffer per read. Closing every owned descriptor is attempted in `finally`, including on read/error paths; a close error makes the observation unknown, without claiming that cleanup was confirmed. Growth, replacement, read/close errors and observable instability cannot produce a verified measurement. Inherited stdin is not an @@ -131,7 +137,9 @@ Isolated tests cover per-predicate states, invalid/duplicate inputs, metadata limits, baseline uncertainty, special/path cases, observable file races, read and allocation bounds, descriptor cleanup, safe imports and CLI error/nonexecution behavior. Mock POSIX evidence is separate from native platform qualification. -There are no live-host or agent tests in this implementation request. +Native special-file/race evidence and Windows stdin/CLI behavior have not been +qualified; deterministic mocks do not replace those checks. There are no live-host +or agent tests in this implementation request. The expanded contract and implementation undergo fresh noninteractive Opus 5.5 review before PR handoff. Human merge review is required; no release, publication, diff --git a/scripts/remote-qualification.mjs b/scripts/remote-qualification.mjs index 3f44762..45644f5 100644 --- a/scripts/remote-qualification.mjs +++ b/scripts/remote-qualification.mjs @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import { createHash } from 'node:crypto'; import { isAbsolute } from 'node:path/posix'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; export const LIMITS = Object.freeze({ inputBytes: 64 * 1024, fileBytes: 1024 * 1024, chunkBytes: 64 * 1024, files: 16 }); const invalid = () => { throw new Error('invalid_assessment_input'); }; @@ -31,7 +31,7 @@ function uniqueKeys(text) { top.key = false; } } else if (char === '{' || char === '[') { - stack.push({ kind: char, key: char === '{', seen: new Set() }); + stack.push(char === '{' ? { kind: char, key: true, seen: new Set() } : { kind: char }); } else if (char === '}' || char === ']') stack.pop(); else if (char === ',' && stack.at(-1)?.kind === '{') stack.at(-1).key = true; } @@ -87,27 +87,27 @@ export function readRegularFile(path, { fsImpl = fs, platform = process.platform const before = fsImpl.lstatSync(path, { bigint: true }); if (!before.isFile() || before.isSymbolicLink()) return { state: 'unsupported', reason: 'not_regular_file' }; if (before.size > LIMITS.fileBytes) return { state: 'unknown', reason: 'file_byte_limit' }; - fd = fsImpl.openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const noControllingTerminal = Number.isInteger(constants.O_NOCTTY) ? constants.O_NOCTTY : 0; + fd = fsImpl.openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | noControllingTerminal); const opened = fsImpl.fstatSync(fd, { bigint: true }); if (!opened.isFile()) result = { state: 'unsupported', reason: 'not_regular_file' }; else if (!sameObservation(before, opened)) result = { state: 'unknown', reason: 'file_changed' }; else { - const chunks = []; + const expectedSize = Number(opened.size); + const buffer = Buffer.alloc(expectedSize + 1); let total = 0; - while (total <= LIMITS.fileBytes) { - const chunk = Buffer.alloc(Math.min(LIMITS.chunkBytes, LIMITS.fileBytes + 1 - total)); - const count = fsImpl.readSync(fd, chunk, 0, chunk.length, null); + while (total < buffer.length) { + const count = fsImpl.readSync(fd, buffer, total, Math.min(LIMITS.chunkBytes, buffer.length - total), null); if (count === 0) break; - chunks.push(chunk.subarray(0, count)); total += count; } const after = fsImpl.fstatSync(fd, { bigint: true }); const named = fsImpl.lstatSync(path, { bigint: true }); if (total > LIMITS.fileBytes) result = { state: 'unknown', reason: 'file_byte_limit' }; - else if (!after.isFile() || !named.isFile() || named.isSymbolicLink() || + else if (total > expectedSize || !after.isFile() || !named.isFile() || named.isSymbolicLink() || !sameObservation(opened, after) || !sameObservation(after, named) || BigInt(total) !== BigInt(after.size)) { result = { state: 'unknown', reason: 'file_changed' }; - } else result = { state: 'pass', bytes: Buffer.concat(chunks, total) }; + } else result = { state: 'pass', bytes: buffer.subarray(0, total) }; } } catch (error) { result = ['ELOOP', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EINVAL'].includes(error?.code) @@ -125,13 +125,13 @@ export function readRegularFile(path, { fsImpl = fs, platform = process.platform const decode = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes); /** Read at most the stdin byte limit plus one overflow byte. fd 0 is not owned. */ export function readInput({ fsImpl = fs } = {}) { - const chunks = []; + const buffer = Buffer.alloc(LIMITS.inputBytes + 1); let size = 0; - while (size <= LIMITS.inputBytes) { - const chunk = Buffer.alloc(Math.min(LIMITS.chunkBytes, LIMITS.inputBytes + 1 - size)); - const count = fsImpl.readSync(0, chunk, 0, chunk.length, null); - if (count === 0) return decode(Buffer.concat(chunks, size)); - chunks.push(chunk.subarray(0, count)); + while (size < buffer.length) { + let count; + try { count = fsImpl.readSync(0, buffer, size, Math.min(LIMITS.chunkBytes, buffer.length - size), null); } + catch { throw Object.assign(new Error('input_unavailable'), { code: 'INPUT_UNAVAILABLE' }); } + if (count === 0) return decode(buffer.subarray(0, size)); size += count; } invalid(); @@ -204,8 +204,10 @@ No intentional file/state writes except stdout. Required: context {installation, manager}, packageJson (absolute path), expectedVersion. Optional: context.destination/account; files [{path, baseline?: {sha256, source, observedAt}}]. Limits: 64 KiB UTF-8 input; 16 files; 1 MiB per selected regular file. +Relative/foreign paths yield unsupported checks. Label lengths use UTF-16 units. +Read limits bound bytes, not I/O time; they are not deadlines. Baseline source/time are operator labels, not authenticated history. -Exit 0: report processed regardless of check states. Exit 2: invalid specification. +Exit 0: report processed regardless of check states. Exit 2: invalid/unavailable input. No report grants qualification, readiness, ownership or permission to act. `; @@ -218,10 +220,15 @@ function main() { if (process.argv.length !== 2) invalid(); const input = parseInput(readInput()); process.stdout.write(JSON.stringify(assess(input)) + '\n'); - } catch { - process.stdout.write('{"error":"invalid_assessment_input"}\n'); + } catch (error) { + process.stdout.write(error?.code === 'INPUT_UNAVAILABLE' ? '{"error":"input_unavailable"}\n' : '{"error":"invalid_assessment_input"}\n'); process.exitCode = 2; } } -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main(); +function isEntryPoint() { + try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } + catch { return false; } +} + +if (isEntryPoint()) main(); diff --git a/test/remote-qualification.test.ts b/test/remote-qualification.test.ts index 893ef7d..cde6702 100644 --- a/test/remote-qualification.test.ts +++ b/test/remote-qualification.test.ts @@ -32,7 +32,8 @@ function fixture(t: TestContext) { // Deterministic owned descriptors: never touch a platform's real special files. function fakeFile(bytes: Buffer, options: { kind?: string; size?: number; fail?: string; changed?: string } = {}) { - const calls = { opens: 0, closes: 0, reads: 0, maxBuffer: 0, flags: 0, consumed: 0 }; + const calls = { opens: 0, closes: 0, reads: 0, maxBuffer: 0, maxRead: 0, flags: 0, consumed: 0, errors: [] as string[], buffers: new Set() }; + const record = (condition: boolean, detail: string) => { if (!condition) calls.errors.push(detail); }; let statCount = 0; const stat = () => { statCount++; @@ -45,15 +46,17 @@ function fakeFile(bytes: Buffer, options: { kind?: string; size?: number; fail?: constants: { O_RDONLY: 0, O_NOFOLLOW: 0x100, O_NONBLOCK: 0x200 }, lstatSync: () => { if (options.fail === 'lstat') throw new Error('PRIVATE-FAILURE'); return stat(); }, openSync: (_path: string, flags: number) => { calls.flags = flags; calls.opens++; if (options.fail === 'open') throw new Error('PRIVATE-FAILURE'); return 91; }, - fstatSync: (fd: number) => { assert.equal(fd, 91); if (options.fail === 'fstat') throw new Error('PRIVATE-FAILURE'); return stat(); }, + fstatSync: (fd: number) => { record(fd === 91, 'fstat descriptor'); if (options.fail === 'fstat') throw new Error('PRIVATE-FAILURE'); return stat(); }, readSync: (fd: number, buffer: Buffer, offset: number, length: number, position: number | null) => { - assert.equal(fd, 91); calls.reads++; calls.maxBuffer = Math.max(calls.maxBuffer, buffer.length); + record(fd === 91, 'read descriptor'); record(position === null, 'read position'); + record(offset >= 0 && length >= 0 && offset + length <= buffer.length, 'read bounds'); + calls.reads++; calls.maxBuffer = Math.max(calls.maxBuffer, buffer.length); calls.maxRead = Math.max(calls.maxRead, length); calls.buffers.add(buffer); if (options.fail === 'read') throw new Error('PRIVATE-FAILURE'); const start = position ?? calls.consumed; const count = Math.min(length, Math.max(0, bytes.length - start)); bytes.copy(buffer, offset, start, start + count); calls.consumed += count; return count; }, - closeSync: (fd: number) => { assert.equal(fd, 91); calls.closes++; } + closeSync: (fd: number) => { record(fd === 91, 'close descriptor'); calls.closes++; } }; return { io, calls }; } @@ -93,11 +96,11 @@ test('metadata is exact, bounded, fatal UTF-8 and duplicate-key aware without ex ['[]', 'unknown', 'unknown'], ['{broken', 'unknown', 'unknown'] ]; for (const [data, name, version] of cases) { - const { io } = fakeFile(typeof data === 'string' ? Buffer.from(data) : data); + const { io, calls } = fakeFile(typeof data === 'string' ? Buffer.from(data) : data); const report = evaluate(input, { fsImpl: io, platform: 'linux' }); assert.equal(check(report, 'package.name').state, name); assert.equal(check(report, 'package.version').state, version); - assert.equal(JSON.stringify(report).includes('HIDDEN-METADATA'), false); + assert.equal(JSON.stringify(report).includes('HIDDEN-METADATA'), false); assert.deepEqual(calls.errors, []); } }); @@ -149,11 +152,11 @@ test('reader refuses special files and unsupported methods before opening or rea for (const kind of ['symlink', 'fifo', 'directory', 'device']) { const { io, calls } = fakeFile(Buffer.from('private'), { kind }); assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unsupported'); - assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); + assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); assert.deepEqual(calls.errors, []); } for (const path of ['relative', 'C:\\owned\\file', '\\\\server\\share', '']) { const { io, calls } = fakeFile(Buffer.from('private')); - assert.equal(read(path, { fsImpl: io, platform: 'linux' }).state, 'unsupported'); assert.equal(calls.opens, 0); + assert.equal(read(path, { fsImpl: io, platform: 'linux' }).state, 'unsupported'); assert.equal(calls.opens, 0); assert.deepEqual(calls.errors, []); } const { io, calls } = fakeFile(Buffer.from('private')); assert.equal(read('/owned/file', { fsImpl: io, platform: 'win32' }).state, 'unsupported'); @@ -169,12 +172,13 @@ test('reader enforces byte budgets with bounded allocations and closes its descr assert.equal(result.state, extra ? 'unknown' : 'pass'); if (!extra) { assert.ok(result.bytes); assert.equal(result.bytes.length, LIMITS.fileBytes); } assert.equal(calls.consumed, LIMITS.fileBytes + extra); assert.equal(calls.reads, 17); - assert.ok(calls.maxBuffer <= LIMITS.chunkBytes); assert.equal(calls.closes, 1); + assert.ok(calls.maxBuffer <= LIMITS.fileBytes + 1); assert.ok(calls.maxRead <= LIMITS.chunkBytes); + assert.equal(calls.buffers.size, 1); assert.deepEqual(calls.errors, []); assert.equal(calls.closes, 1); assert.equal(calls.flags, io.constants.O_RDONLY | io.constants.O_NOFOLLOW | io.constants.O_NONBLOCK); } const { io, calls } = fakeFile(Buffer.alloc(0), { size: LIMITS.fileBytes + 1 }); assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); - assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); + assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); assert.deepEqual(calls.errors, []); }); test('replacement, observable changes and I/O failures produce unknown and close owned fds', () => { @@ -182,11 +186,11 @@ test('replacement, observable changes and I/O failures produce unknown and close const { io, calls } = fakeFile(Buffer.from('abc'), options); const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); assert.equal(result.state, 'unknown'); assert.equal(calls.closes, options.fail === 'open' || options.fail === 'lstat' ? 0 : 1); - assert.equal(JSON.stringify(result).includes('PRIVATE-FAILURE'), false); + assert.equal(JSON.stringify(result).includes('PRIVATE-FAILURE'), false); assert.deepEqual(calls.errors, []); } const { io, calls } = fakeFile(Buffer.from('abc')); io.closeSync = () => { calls.closes++; throw new Error('PRIVATE-CLOSE'); }; - assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); assert.equal(calls.closes, 1); + assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); assert.equal(calls.closes, 1); assert.deepEqual(calls.errors, []); }); test('CLI handles bounds, options and import without consuming stdin or executing selected content', t => { @@ -205,40 +209,88 @@ test('CLI handles bounds, options and import without consuming stdin or executin }); -test('stdin reader caps actual reads at input limit plus one and does not own fd zero', () => { - for (const overflow of [false, true]) { +test('stdin bounds actual reads and one-buffer storage even with one-byte short reads', () => { + for (const short of [false, true]) for (const overflow of [false, true]) { const bytes = Buffer.alloc(LIMITS.inputBytes + (overflow ? 100000 : 0), 32); - let consumed = 0, reads = 0, maxBuffer = 0; + let consumed = 0, reads = 0; + const buffers = new Set(), errors: string[] = []; const io = { readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number | null) { - assert.equal(fd, 0); assert.equal(position, null); reads++; maxBuffer = Math.max(maxBuffer, buffer.length); - const count = Math.min(length, bytes.length - consumed); + if (fd !== 0 || position !== null || length > LIMITS.chunkBytes || offset + length > buffer.length) errors.push('invalid read'); + reads++; buffers.add(buffer); + const count = Math.min(short ? 1 : length, bytes.length - consumed); bytes.copy(buffer, offset, consumed, consumed + count); consumed += count; return count; }, - closeSync() { assert.fail('stdin is not an owned descriptor'); } + closeSync() { errors.push('closed unowned stdin'); } }; if (overflow) assert.throws(() => stdin({ fsImpl: io }), /invalid_assessment_input/); else assert.equal(stdin({ fsImpl: io }), bytes.toString('utf8')); assert.equal(consumed, LIMITS.inputBytes + (overflow ? 1 : 0)); - assert.equal(reads, 2); assert.ok(maxBuffer <= LIMITS.chunkBytes); + assert.equal(reads, short ? LIMITS.inputBytes + 1 : 2); + assert.equal(buffers.size, 1); assert.ok([...buffers].reduce((sum, b) => sum + b.length, 0) <= LIMITS.inputBytes + 1); + assert.deepEqual(errors, []); } - let read = false; + let consumed = false, closed = false; assert.throws(() => stdin({ fsImpl: { - readSync(fd: number, buffer: Buffer) { assert.equal(fd, 0); if (read) return 0; read = true; buffer[0] = 255; return 1; }, - closeSync() { assert.fail('stdin is not an owned descriptor'); } + readSync(_fd: number, buffer: Buffer) { if (consumed) return 0; consumed = true; buffer[0] = 255; return 1; }, + closeSync() { closed = true; } } })); + assert.equal(closed, false); + assert.throws(() => stdin({ fsImpl: { readSync() { throw new Error('PRIVATE-STDIN-ERROR'); }, closeSync() { closed = true; } } }), + error => error instanceof Error && error.message === 'input_unavailable' && (error as NodeJS.ErrnoException).code === 'INPUT_UNAVAILABLE'); + assert.equal(closed, false); }); +test('file one-byte short reads retain one bounded buffer and preserve measured bytes', () => { + const bytes = Buffer.alloc(4096, 65), { io, calls } = fakeFile(bytes); + const original = io.readSync; + io.readSync = (fd, buffer, offset, length, position) => original(fd, buffer, offset, Math.min(1, length), position); + const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); + assert.equal(result.state, 'pass'); assert.deepEqual(result.bytes, bytes); + assert.equal(calls.reads, bytes.length + 1); assert.equal(calls.buffers.size, 1); + assert.ok([...calls.buffers].reduce((sum, b) => sum + b.length, 0) <= bytes.length + 1); + assert.ok(calls.maxRead <= LIMITS.chunkBytes); assert.deepEqual(calls.errors, []); assert.equal(calls.closes, 1); +}); -test('reader retains exact large inode identity with bigint stats', () => { - const { io } = fakeFile(Buffer.from('abc')); - let observations = 0; - const stat = (_: unknown, options?: { bigint?: boolean }) => { - assert.equal(options?.bigint, true); - observations++; - return { dev: 1n, ino: observations > 2 ? 9007199254740993n : 9007199254740992n, - size: 3n, mtimeNs: 1n, ctimeNs: 1n, isFile: () => true, isSymbolicLink: () => false }; - }; - const exact = { ...io, lstatSync: stat, fstatSync: stat }; - assert.equal(read('/owned/file', { fsImpl: exact, platform: 'linux' }).state, 'unknown'); +test('large inode identity requests bigint stats and detects values that alias as Number', () => { + for (const replacement of [false, true]) { + const { io, calls } = fakeFile(Buffer.from('abc')); + let observations = 0; + const requested: (boolean | undefined)[] = []; + const stat = (_: unknown, options?: { bigint?: boolean }) => { + requested.push(options?.bigint); observations++; + const inode = replacement && observations > 2 ? 9007199254740993n : 9007199254740992n; + if (options?.bigint) return { dev: 1n, ino: inode, size: 3n, mtimeNs: 1n, ctimeNs: 1n, isFile: () => true, isSymbolicLink: () => false }; + // The fallback deliberately aliases the two inode values, as Number Stats can. + return { dev: 1, ino: Number(inode), size: 3, mtimeMs: 1, ctimeMs: 1, isFile: () => true, isSymbolicLink: () => false }; + }; + const result = read('/owned/file', { fsImpl: { ...io, lstatSync: stat, fstatSync: stat }, platform: 'linux' }); + assert.equal(result.state, replacement ? 'unknown' : 'pass'); + assert.ok(requested.length >= 4); assert.ok(requested.every(value => value === true)); + assert.deepEqual(calls.errors, []); assert.equal(calls.closes, 1); + } +}); + +test('CLI works through a symlink entry with default and preserved main resolution', t => { + const f = fixture(t), link = join(f.root, 'qualification.mjs'); + try { fs.symlinkSync(script, link, 'file'); } + catch (error) { + if (['EPERM', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) { t.skip('symlink creation permission denied'); return; } + throw error; + } + for (const flags of [[], ['--preserve-symlinks-main']]) { + const child = spawnSync(process.execPath, [...flags, link], { input: JSON.stringify(f.input), encoding: 'utf8', timeout: 10000 }); + assert.ifError(child.error); assert.equal(child.status, 0); assert.equal(child.stderr, ''); + assert.equal(JSON.parse(child.stdout).scope, 'local_static_assessment'); + } +}); + +test('CLI reports stdin I/O failure with a fixed sanitized token', () => { + const code = `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module'; + process.argv = [process.execPath, ${JSON.stringify(script)}]; + const original = fs.readSync; fs.readSync = (fd, ...args) => { if (fd === 0) throw new Error('PRIVATE-STDIN-ERROR'); return original(fd, ...args); }; syncBuiltinESMExports(); + await import(${JSON.stringify(pathToFileURL(script).href)});`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', timeout: 10000 }); + assert.ifError(child.error); assert.equal(child.status, 2); + assert.deepEqual(JSON.parse(child.stdout), { error: 'input_unavailable' }); assert.equal(child.stderr, ''); }); From 4396c78b6dc17e82c1cb12b3323ff5955a6a4577 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 10:35:48 +0900 Subject: [PATCH 6/8] fix: preserve raw input bounds without reserializing assessments Refs #158. Keep malformed baseline evidence predicate-local and assert exact reader refusal reasons. --- scripts/remote-qualification.mjs | 6 +++++- test/remote-qualification.test.ts | 36 +++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/remote-qualification.mjs b/scripts/remote-qualification.mjs index 45644f5..8a158ff 100644 --- a/scripts/remote-qualification.mjs +++ b/scripts/remote-qualification.mjs @@ -48,6 +48,10 @@ export function parseInput(text) { if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > LIMITS.inputBytes) invalid(); let value; try { value = parseJson(text); } catch { invalid(); } + return validate(value); +} + +function validate(value) { keys(value, ['context', 'packageJson', 'expectedVersion', 'files']); keys(value.context, ['installation', 'manager', 'destination', 'account']); if (!label(value.context.installation, 2048) || !label(value.context.manager, 128) || @@ -143,7 +147,7 @@ const baselineValid = baseline => object(baseline) && typeof baseline.sha256 === /** Record independent predicates. States are evidence, never an operational gate. */ export function assess(input, { fsImpl = fs, platform = process.platform, now = () => new Date().toISOString() } = {}) { // Validate all entries before any filesystem inspection, also for imported use. - const value = parseInput(JSON.stringify(input)); + const value = validate(input); const checks = []; const add = (id, predicate, state, evidenceKind, source, details = {}) => checks.push({ id, predicate, state, evidenceKind, source, observedAt: now(), ...details }); diff --git a/test/remote-qualification.test.ts b/test/remote-qualification.test.ts index cde6702..764e990 100644 --- a/test/remote-qualification.test.ts +++ b/test/remote-qualification.test.ts @@ -170,6 +170,7 @@ test('reader enforces byte budgets with bounded allocations and closes its descr const { io, calls } = fakeFile(Buffer.alloc(LIMITS.fileBytes + extra, 65), { size: LIMITS.fileBytes }); const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); assert.equal(result.state, extra ? 'unknown' : 'pass'); + if (extra) assert.equal(result.reason, 'file_byte_limit'); if (!extra) { assert.ok(result.bytes); assert.equal(result.bytes.length, LIMITS.fileBytes); } assert.equal(calls.consumed, LIMITS.fileBytes + extra); assert.equal(calls.reads, 17); assert.ok(calls.maxBuffer <= LIMITS.fileBytes + 1); assert.ok(calls.maxRead <= LIMITS.chunkBytes); @@ -185,12 +186,13 @@ test('replacement, observable changes and I/O failures produce unknown and close for (const options of [{ changed: 'replace' }, { changed: 'time' }, { fail: 'fstat' }, { fail: 'read' }, { fail: 'open' }, { fail: 'lstat' }]) { const { io, calls } = fakeFile(Buffer.from('abc'), options); const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); - assert.equal(result.state, 'unknown'); assert.equal(calls.closes, options.fail === 'open' || options.fail === 'lstat' ? 0 : 1); + assert.equal(result.state, 'unknown'); assert.equal(result.reason, options.changed ? 'file_changed' : 'file_read_unavailable'); assert.equal(calls.closes, options.fail === 'open' || options.fail === 'lstat' ? 0 : 1); assert.equal(JSON.stringify(result).includes('PRIVATE-FAILURE'), false); assert.deepEqual(calls.errors, []); } const { io, calls } = fakeFile(Buffer.from('abc')); io.closeSync = () => { calls.closes++; throw new Error('PRIVATE-CLOSE'); }; - assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); assert.equal(calls.closes, 1); assert.deepEqual(calls.errors, []); + const closeResult = read('/owned/file', { fsImpl: io, platform: 'linux' }); + assert.equal(closeResult.state, 'unknown'); assert.equal(closeResult.reason, 'file_close_unverified'); assert.equal(calls.closes, 1); assert.deepEqual(calls.errors, []); }); test('CLI handles bounds, options and import without consuming stdin or executing selected content', t => { @@ -203,7 +205,7 @@ test('CLI handles bounds, options and import without consuming stdin or executin for (const [text, args] of [[Buffer.from([0xff]), []], [' '.repeat(LIMITS.inputBytes + 1), []], ['', ['--host', 'NEVER-CONNECT']], ['', ['--unknown']]] as [string | Buffer, string[]][]) { const failure = f.invoke(text, args); assert.equal(failure.code, 2); assert.deepEqual(JSON.parse(failure.stdout), { error: 'invalid_assessment_input' }); assert.equal(failure.stderr, ''); } - assert.match(f.invoke('', ['--help']).stdout, /static/i); + const help = f.invoke('', ['--help']); assert.equal(help.code, 0); assert.equal(help.stderr, ''); assert.match(help.stdout, /static/i); const imported = spawnSync(process.execPath, ['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(script).href)}); console.log('imported')`], { input: 'INVALID-STDIN', encoding: 'utf8', timeout: 10000 }); assert.ifError(imported.error); assert.equal(imported.status, 0); assert.equal(imported.stdout.trim(), 'imported'); assert.equal(imported.stderr, ''); }); @@ -244,9 +246,13 @@ test('stdin bounds actual reads and one-buffer storage even with one-byte short test('file one-byte short reads retain one bounded buffer and preserve measured bytes', () => { const bytes = Buffer.alloc(4096, 65), { io, calls } = fakeFile(bytes); const original = io.readSync; - io.readSync = (fd, buffer, offset, length, position) => original(fd, buffer, offset, Math.min(1, length), position); + io.readSync = (fd, buffer, offset, length, position) => { + if (length > LIMITS.chunkBytes) calls.errors.push('requested read exceeds chunk limit'); + return original(fd, buffer, offset, Math.min(1, length), position); + }; const result = read('/owned/file', { fsImpl: io, platform: 'linux' }); - assert.equal(result.state, 'pass'); assert.deepEqual(result.bytes, bytes); + assert.equal(result.state, 'pass'); assert.deepEqual(result.bytes, bytes); assert.ok(result.bytes); + assert.ok(result.bytes.buffer.byteLength <= bytes.length + 1); assert.equal(calls.reads, bytes.length + 1); assert.equal(calls.buffers.size, 1); assert.ok([...calls.buffers].reduce((sum, b) => sum + b.length, 0) <= bytes.length + 1); assert.ok(calls.maxRead <= LIMITS.chunkBytes); assert.deepEqual(calls.errors, []); assert.equal(calls.closes, 1); @@ -264,8 +270,9 @@ test('large inode identity requests bigint stats and detects values that alias a // The fallback deliberately aliases the two inode values, as Number Stats can. return { dev: 1, ino: Number(inode), size: 3, mtimeMs: 1, ctimeMs: 1, isFile: () => true, isSymbolicLink: () => false }; }; - const result = read('/owned/file', { fsImpl: { ...io, lstatSync: stat, fstatSync: stat }, platform: 'linux' }); + const result = read('/owned/file', { fsImpl: { ...io, lstatSync: stat, fstatSync(fd: number, options: any) { if (fd !== 91) calls.errors.push('bigint fstat descriptor'); return stat(fd, options); } }, platform: 'linux' }); assert.equal(result.state, replacement ? 'unknown' : 'pass'); + if (replacement) assert.equal(result.reason, 'file_changed'); assert.ok(requested.length >= 4); assert.ok(requested.every(value => value === true)); assert.deepEqual(calls.errors, []); assert.equal(calls.closes, 1); } @@ -294,3 +301,20 @@ test('CLI reports stdin I/O failure with a fixed sanitized token', () => { assert.ifError(child.error); assert.equal(child.status, 2); assert.deepEqual(JSON.parse(child.stdout), { error: 'input_unavailable' }); assert.equal(child.stderr, ''); }); + +test('CLI keeps bounded raw specifications with expansive or deeply nested malformed baselines as per-file unknown', t => { + const f = fixture(t); + const prefix = JSON.stringify({ ...f.input, files: [{ path: f.pkg, baseline: 'BASELINE' }] }); + const expansive = prefix.replace('"BASELINE"', '[' + Array(11000).fill('1e21').join(',') + ']'); + assert.ok(Buffer.byteLength(expansive) < LIMITS.inputBytes); + assert.ok(Buffer.byteLength(JSON.stringify(JSON.parse(expansive))) > LIMITS.inputBytes); + const deep = prefix.replace('"BASELINE"', '['.repeat(12000) + '0' + ']'.repeat(12000)); + assert.ok(Buffer.byteLength(deep) < LIMITS.inputBytes); + for (const raw of [expansive, deep]) { + const result = f.invoke(raw); assert.equal(result.code, 0); assert.equal(result.stderr, ''); + const report = JSON.parse(result.stdout); + assert.equal(check(report, 'files.0.comparison').state, 'unknown'); + assert.equal(check(report, 'files.0.comparison').reason, 'baseline_evidence_incomplete'); + assert.equal(check(report, 'files.0.digest').state, process.platform === 'win32' ? 'unsupported' : 'pass'); + } +}); From 4f1b8f2d0841326395c375b699fabe1c601e6fa9 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 21 Oct 2026 10:38:13 +0900 Subject: [PATCH 7/8] test: assert exact reporter limit and decoding errors Refs #158. Apply the final Opus test-quality findings without changing reporter behavior. --- test/remote-qualification.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/remote-qualification.test.ts b/test/remote-qualification.test.ts index 764e990..1e17b78 100644 --- a/test/remote-qualification.test.ts +++ b/test/remote-qualification.test.ts @@ -178,7 +178,8 @@ test('reader enforces byte budgets with bounded allocations and closes its descr assert.equal(calls.flags, io.constants.O_RDONLY | io.constants.O_NOFOLLOW | io.constants.O_NONBLOCK); } const { io, calls } = fakeFile(Buffer.alloc(0), { size: LIMITS.fileBytes + 1 }); - assert.equal(read('/owned/file', { fsImpl: io, platform: 'linux' }).state, 'unknown'); + const oversized = read('/owned/file', { fsImpl: io, platform: 'linux' }); + assert.equal(oversized.state, 'unknown'); assert.equal(oversized.reason, 'file_byte_limit'); assert.equal(calls.opens, 0); assert.equal(calls.reads, 0); assert.deepEqual(calls.errors, []); }); @@ -236,7 +237,7 @@ test('stdin bounds actual reads and one-buffer storage even with one-byte short assert.throws(() => stdin({ fsImpl: { readSync(_fd: number, buffer: Buffer) { if (consumed) return 0; consumed = true; buffer[0] = 255; return 1; }, closeSync() { closed = true; } - } })); + } }), error => error instanceof TypeError && (error as NodeJS.ErrnoException).code === 'ERR_ENCODING_INVALID_ENCODED_DATA'); assert.equal(closed, false); assert.throws(() => stdin({ fsImpl: { readSync() { throw new Error('PRIVATE-STDIN-ERROR'); }, closeSync() { closed = true; } } }), error => error instanceof Error && error.message === 'input_unavailable' && (error as NodeJS.ErrnoException).code === 'INPUT_UNAVAILABLE'); From d2760958ebf5f1f160fb716af54e63dabcce8862 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 09:33:34 +0900 Subject: [PATCH 8/8] test: expose observation qualification on handoff fixture failures --- test/handoff-delivery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index 5a61010..966893f 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -308,7 +308,7 @@ test('concurrent wait before effect cannot hide the original pending operation f test('independent wait resumes exact submitted scope and observes later injection without native calls',qualified,async t=>{ const f=await fixture(t,'none');const sent=await f.invoke([...f.args,'--observe-delivery']); assert.equal(sent.code,0,sent.stdout);assert.equal(sent.value.status,'queued');assert.equal(sent.value.submitted,true); - const original=validateHandoff(sent.value.handoff);assert.equal(original.observation.status,'pending'); + const original=validateHandoff(sent.value.handoff);assert.equal(original.observation.status,'pending',JSON.stringify({result:sent.value,calls:f.calls(),nativeCalls:f.nativeCalls(),qualification:f.ledger().data.records[original.correlationId].observationQualification??null})); const before=f.ledger().data.records[original.correlationId],nativeBefore=f.nativeCalls(); const contextBefore=new HandoffLedger(f.env.SESSION_PEER_HANDOFF_HOME!).observationContext(original.correlationId);assert.ok(contextBefore); assert.ok(before.observationQualification);assert.equal(f.calls().length,1);