diff --git a/docs/design/.npmignore b/docs/design/.npmignore new file mode 100644 index 0000000..5617527 --- /dev/null +++ b/docs/design/.npmignore @@ -0,0 +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 7e9a7b4..5c9e677 100644 --- a/docs/design/remote-deployment.md +++ b/docs/design/remote-deployment.md @@ -10,6 +10,14 @@ | 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](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 PR review; it does not authorize provisioning or remediation. + ## 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..2abb77d --- /dev/null +++ b/docs/design/remote-qualification-checklist.md @@ -0,0 +1,206 @@ +# Proposal: operator checklist for remote qualification + +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. + +## 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 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; +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. +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 +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. + 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. + +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, 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 +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 +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. | +| 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 + +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. | +| 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 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 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, +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 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` | 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 extension decision and review gate + +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. + +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..51e359a --- /dev/null +++ b/docs/design/remote-qualification-reporter.md @@ -0,0 +1,146 @@ +# 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 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 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 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 + 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 (`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 + +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` +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. 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 +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. +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, +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..8a158ff --- /dev/null +++ b/scripts/remote-qualification.mjs @@ -0,0 +1,238 @@ +import * as fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path/posix'; +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'); }; +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(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; + } +} + +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(); } + 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) || + !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' }; + 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 expectedSize = Number(opened.size); + const buffer = Buffer.alloc(expectedSize + 1); + let total = 0; + while (total < buffer.length) { + const count = fsImpl.readSync(fd, buffer, total, Math.min(LIMITS.chunkBytes, buffer.length - total), null); + if (count === 0) break; + 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 (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.subarray(0, 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 buffer = Buffer.alloc(LIMITS.inputBytes + 1); + let size = 0; + 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(); +} + +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 = validate(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. +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/unavailable input. +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 (error) { + process.stdout.write(error?.code === 'INPUT_UNAVAILABLE' ? '{"error":"input_unavailable"}\n' : '{"error":"invalid_assessment_input"}\n'); + process.exitCode = 2; + } +} + +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/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); diff --git a/test/remote-qualification.test.ts b/test/remote-qualification.test.ts new file mode 100644 index 0000000..1e17b78 --- /dev/null +++ b/test/remote-qualification.test.ts @@ -0,0 +1,321 @@ +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, 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++; + 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) => { 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) => { + 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) => { record(fd === 91, 'close descriptor'); 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, 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.deepEqual(calls.errors, []); + } +}); + +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); 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.deepEqual(calls.errors, []); + } + 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.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); + 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 }); + 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, []); +}); + +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(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'); }; + 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 => { + 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, ''); + } + 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, ''); +}); + + +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; + const buffers = new Set(), errors: string[] = []; + const io = { + readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number | null) { + 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() { 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, 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 consumed = false, closed = false; + 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'); + 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) => { + 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.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); +}); + +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(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); + } +}); + +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, ''); +}); + +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'); + } +});