diff --git a/examples/README.md b/examples/README.md index b3bf2f09..78ab9090 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ | --- | --- | --- | | [Stable v1 GitHub Action consumer](github-action-consumer-v1.yml) | **Current v1** (`AETHERXGLOBAL/execsurface@v1`) | Copy-ready Rust CI integration after explicit baseline and policy custody setup | | [Five-Minute Start](../docs/QUICKSTART_5_MIN.md) | **Current v1** | Test the published v1 binary against controlled PASS/REVIEW scenarios | +| [Node.js/npm PASS→REVIEW](node-npm/README.md) | **Current v1** | Deterministic Node.js fixture showing an unchanged PASS and one controlled-drift REVIEW on stable v1.0.0 | | [Python/pytest PASS→REVIEW](python-pytest/README.md) | **Historical Alpha.5** | Replay preserved third-party contributed evidence only; not v1 onboarding | | [Minimal controlled drift](hello-drift.sh) | Check selected release/CLI contract | Small local demonstration | diff --git a/examples/node-npm/README.md b/examples/node-npm/README.md new file mode 100644 index 00000000..cb02e59b --- /dev/null +++ b/examples/node-npm/README.md @@ -0,0 +1,81 @@ +# Node.js/npm: unchanged PASS and deliberate-write REVIEW + +Current example, aligned with stable ExecSurface **v1.0.0** on Linux x86_64 with +native `ptrace`; `doctor` must succeed on the host. This recipe learns and +checks the **same real Node.js command** against a tiny dependency-free +fixture, mirroring the [Python/pytest recipe](../python-pytest/README.md). It is +a reproducible integration example, not independent external validation or a +safety certification. + +## What it demonstrates + +The observed target is a one-file Node script that is deterministic: it reads a +public `fixture.json`, asserts a sum invariant, and — only when the fixture's +`write_extra` flag is set — writes a harmless `drift.txt` into its working +directory. That single new file write is the controlled runtime effect that +ExecSurface should surface as `REVIEW`. + +From the repository root, first obtain the stable v1 binary using the +installation notes of the +[v1.0.0 release](https://github.com/AETHERXGLOBAL/execsurface/releases/tag/v1.0.0). +Keep its checksum-verified path in `EXEC_SURFACE_BIN`; this recipe records its +digest and checks its version. Then run the runner with Node.js 18 or later. +The runner uses only `node:` built-ins and downloads nothing. + +```sh +EXEC_SURFACE_BIN=/absolute/path/to/checksum-verified/execsurface +node examples/node-npm/run.js \ + --execsurface "$EXEC_SURFACE_BIN" \ + --output /tmp/execsurface-node-npm-first-run +``` + +Use a new output directory each time. The runner refuses to overwrite a +retained run, copies the three public fixture files, and retains the native +command receipts, stdout/stderr, baseline and verdict JSON. A failed +expectation writes `failure.json` and exits nonzero, retaining the failure. +`--node` may explicitly select another Node executable; otherwise the runner +uses its own interpreter. + +The observed command is: + +```sh +node test.js +``` + +The runner performs the ordinary `learn --output ... --label ... --workspace ...` +and `check --baseline ... --workspace ... --json-output ...` flows: + +| Step | Node result | ExecSurface result | Asserted exit | +| --- | --- | --- | --- | +| Learn `write_extra: false` | `node test.js` exits 0 | Baseline written | 0 | +| Check unchanged fixture | `node test.js` exits 0 | PASS; no drift | 0 | +| Set public fixture `write_extra: true`, check again | `node test.js` exits 0 | REVIEW; added `$WORKSPACE/drift.txt` write | 10 | + +The same assertion, command, Node interpreter and baseline remain selected. +Only the public fixture's boolean changes. The test then writes the harmless +`drift.txt` inside its fresh workspace. The runner verifies that a matching +`file_create`/`file_write` finding appears and that the baseline bytes did not +change. + +The **baseline** records the selected observed execution surface. The **policy** +interprets differences; this recipe uses the unchanged built-in REVIEW policy +for unmatched drift. It does not relearn after drift or weaken policy to get +PASS. ERROR, BLOCK, an unexpected native exit, a lost report or an unavailable +observer fails the recipe rather than counting as either expected outcome. + +## Support boundary + +Stable v1.0.0 supports **Linux x86_64** with native `ptrace` (see +[`docs/STATUS.md`](../../docs/STATUS.md)). Windows and ARM64 are not claimed. +The observed `node test.js` command is a familiar Node.js/npm developer +workload; ExecSurface observes the resulting Linux process/file/network +effects, not Node.js-specific semantics. + +## What this example is and is not + +The fixture contains no secrets. It adds no environment enumeration or +secret/file-content collection. ExecSurface's existing metadata observer may +record local executable and filesystem paths; review retained receipts before +sharing them. A PASS here establishes this selected observed fixture +comparison; it does not establish containment, malware safety, complete +observation of every possible effect or independent custody. diff --git a/examples/node-npm/fixture/fixture.json b/examples/node-npm/fixture/fixture.json new file mode 100644 index 00000000..d3ce7175 --- /dev/null +++ b/examples/node-npm/fixture/fixture.json @@ -0,0 +1 @@ +{"left":2,"right":3,"write_extra":false} diff --git a/examples/node-npm/fixture/package.json b/examples/node-npm/fixture/package.json new file mode 100644 index 00000000..ff4eedf4 --- /dev/null +++ b/examples/node-npm/fixture/package.json @@ -0,0 +1,10 @@ +{ + "name": "execsurface-node-npm-fixture", + "private": true, + "version": "0.0.0", + "description": "Deterministic Node.js fixture for the ExecSurface stable-v1 node-npm integration recipe", + "scripts": { + "test": "node test.js" + }, + "license": "Apache-2.0" +} diff --git a/examples/node-npm/fixture/test.js b/examples/node-npm/fixture/test.js new file mode 100644 index 00000000..b2cfa590 --- /dev/null +++ b/examples/node-npm/fixture/test.js @@ -0,0 +1,22 @@ +'use strict'; +/* One deterministic node test; the optional extra write is deliberate runtime drift. */ +const fs = require('fs'); +const path = require('path'); + +// The public fixture is a two-value selection plus an optional drift switch. +const selection = JSON.parse(fs.readFileSync('fixture.json', 'utf8')); + +// Deterministic invariant — holds on the unchanged fixture. +if (selection.left + selection.right !== 5) { + console.error('fixture invariant broken: left + right !== 5'); + process.exit(1); +} + +// Deliberate controlled runtime effect: write a file only when the fixture +// enables it. This is the single NEW execution-surface effect that ExecSurface +// should surface as REVIEW when the fixture is flipped. +if (selection.write_extra) { + fs.writeFileSync(path.join(process.cwd(), 'drift.txt'), 'controlled fixture drift\n'); +} + +console.log('node fixture passed'); diff --git a/examples/node-npm/run.js b/examples/node-npm/run.js new file mode 100644 index 00000000..3d505b17 --- /dev/null +++ b/examples/node-npm/run.js @@ -0,0 +1,230 @@ +'use strict'; +/* + * Learn and check a real, deterministic Node.js command, retaining an + * unchanged PASS and a deliberate-write REVIEW against stable ExecSurface v1. + * + * Mirrors examples/python-pytest/run.py: the observed target command is + * executed BY ExecSurface (learn/check), not by this runner, and the same + * command + fixture stay selected across every step. Only the public fixture's + * boolean flips between the unchanged check and the drift check. + * + * Node.js 18+, zero dependencies, node: built-ins only. No network during the + * measured run. + */ +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const process = require('node:process'); + +class RecipeError extends Error {} + +function parseArgs(argv) { + const args = { execsurface: null, node: process.execPath, output: null }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '--execsurface') args.execsurface = argv[++i]; + else if (a === '--node') args.node = argv[++i]; + else if (a === '--output') args.output = argv[++i]; + else throw new RecipeError(`unknown argument: ${a}`); + } + if (!args.execsurface || !args.output) { + throw new RecipeError('both --execsurface and --output are required'); + } + return args; +} + +function invoke(command, cwd, name, expected) { + // Retain command, both streams and actual exit before checking expectation. + const result = spawnSync(command[0], command.slice(1), { + cwd, + encoding: 'buffer', + timeout: 60000, + }); + const outPath = path.join(cwd, `${name}.stdout`); + const errPath = path.join(cwd, `${name}.stderr`); + const receiptPath = path.join(cwd, `${name}-command.json`); + fs.writeFileSync(outPath, result.stdout || Buffer.alloc(0)); + fs.writeFileSync(errPath, result.stderr || Buffer.alloc(0)); + if (result.error) { + fs.writeFileSync( + receiptPath, + JSON.stringify( + { command, returncode: null, expectedReturncode: expected, outcome: 'spawn-error', error: String(result.error) }, + null, + 2, + ) + '\n', + ); + throw new RecipeError(`${name}: could not spawn ${command[0]}: ${result.error}`); + } + fs.writeFileSync( + receiptPath, + JSON.stringify({ command, returncode: result.status, expectedReturncode: expected }, null, 2) + '\n', + ); + if (result.status !== expected) { + throw new RecipeError( + `${name}: expected exit ${expected}, got ${result.status}; retained receipts: ${cwd}`, + ); + } +} + +function check(binary, node, root, name, expected, verdict) { + const command = [ + binary, + 'check', + '--baseline', + path.join(root, 'execsurface.lock.json'), + '--workspace', + root, + '--json-output', + path.join(root, `${name}-verdict.json`), + '--', + node, + 'test.js', + ]; + invoke(command, root, name, expected); + const report = JSON.parse(fs.readFileSync(path.join(root, `${name}-verdict.json`), 'utf8')); + if ( + !report.target || + report.target.exit_code !== 0 || + report.target.signal !== null + ) { + throw new RecipeError(`${name}: node test.js did not exit successfully; retained ${root}`); + } + if (report.policy && report.policy.source !== 'builtin:review-unmatched-drift') { + throw new RecipeError(`${name}: selected built-in policy differs; retained ${root}`); + } + if (verdict === 'REVIEW') { + // A deliberate new file write under the declared workspace is the single + // expected drift finding. fs.writeFileSync on a new file may be recorded + // as file_create or file_write; either is the documented effect here. + const hasDriftWrite = (report.findings || []).some( + (f) => + f.change === 'added' && + (f.effect_kind === 'file_write' || f.effect_kind === 'file_create') && + f.evidence && + f.evidence.effect && + f.evidence.effect.target && + f.evidence.effect.target.value === '$WORKSPACE/drift.txt', + ); + if (!hasDriftWrite) { + throw new RecipeError(`${name}: deliberate $WORKSPACE/drift.txt write finding absent; retained ${root}`); + } + } + if (report.verdict !== verdict.toLowerCase()) { + throw new RecipeError(`${name}: expected ${verdict} verdict, got ${report.verdict}; retained ${root}`); + } +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function run(args) { + const binary = path.resolve(args.execsurface); + const node = path.resolve(args.node); + if (!fs.existsSync(binary)) throw new RecipeError(`execsurface binary not found: ${binary}`); + if (!fs.existsSync(node)) throw new RecipeError(`node executable not found: ${node}`); + + const out = path.resolve(args.output); + if (fs.existsSync(out)) { + throw new RecipeError( + `output directory already exists; use a new path each run: ${out}`, + ); + } + fs.mkdirSync(out, { recursive: true }); + + // Copy the public, dependency-free fixture into the retained run directory. + const fixtureDir = path.join(__dirname, 'fixture'); + for (const name of ['test.js', 'fixture.json', 'package.json']) { + fs.copyFileSync(path.join(fixtureDir, name), path.join(out, name)); + } + + const selection = { + binarySha256: sha256(binary), + nodeExecutable: node, + fixtureSha256: sha256(path.join(fixtureDir, 'test.js')), + policy: 'unchanged built-in REVIEW for unmatched drift', + privacy: + 'public synthetic fixture; no environment or secret/file-content collection added', + }; + fs.writeFileSync(path.join(out, 'selection.json'), JSON.stringify(selection, null, 2) + '\n'); + + try { + // Identity: this recipe targets the stable v1 binary, not a historical pin. + invoke([binary, '--version'], out, 'version', 0); + const versionLine = fs.readFileSync(path.join(out, 'version.stdout'), 'utf8').trim(); + if (versionLine !== 'execsurface 1.0.0') { + throw new RecipeError(`recipe requires stable ExecSurface 1.0.0, got: ${versionLine}`); + } + // Native readiness within the declared Linux x86_64 / ptrace boundary. + invoke([binary, 'doctor'], out, 'doctor', 0); + + const learn = [ + binary, + 'learn', + '--output', + path.join(out, 'execsurface.lock.json'), + '--label', + 'node-npm-fixture', + '--workspace', + out, + '--', + node, + 'test.js', + ]; + invoke(learn, out, 'learn', 0); + const before = fs.readFileSync(path.join(out, 'execsurface.lock.json')); + + check(binary, node, out, 'unchanged', 0, 'PASS'); + + // Only this public fixture value changes; command and fixture stay the same. + fs.writeFileSync(path.join(out, 'fixture.json'), '{"left":2,"right":3,"write_extra":true}\n'); + check(binary, node, out, 'drift', 10, 'REVIEW'); + + if (!bufferEqual(fs.readFileSync(path.join(out, 'execsurface.lock.json')), before)) { + throw new RecipeError('baseline changed while checking drift'); + } + if (fs.readFileSync(path.join(out, 'drift.txt'), 'utf8') !== 'controlled fixture drift\n') { + throw new RecipeError('deliberate native write is missing'); + } + + const report = { + status: 'verified-recipe', + unchanged: { verdict: 'PASS', exit: 0 }, + drift: { verdict: 'REVIEW', exit: 10 }, + baselineSha256: crypto.createHash('sha256').update(before).digest('hex'), + scope: + 'stable-v1 Node.js/npm fixture; runtime drift example, not independent security validation', + receipts: out, + }; + fs.writeFileSync(path.join(out, 'recipe-report.json'), JSON.stringify(report, null, 2) + '\n'); + return report; + } catch (error) { + fs.writeFileSync( + path.join(out, 'failure.json'), + JSON.stringify({ status: 'failed', reason: error.message }, null, 2) + '\n', + ); + throw error; + } +} + +function bufferEqual(a, b) { + if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +if (require.main === module) { + try { + const args = parseArgs(process.argv.slice(2)); + console.log(JSON.stringify(run(args), null, 2)); + } catch (error) { + if (error instanceof RecipeError) { + console.error(`ExecSurface node-npm recipe: ERROR: ${error.message}`); + process.exitCode = 1; + } else { + throw error; + } + } +}