diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index c66ffa61b82..3c2d9d67126 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -94,6 +94,24 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: Reproduce reviewed discovery permission drift + shell: bash + run: | + set -euo pipefail + reviewed_root="tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery" + for artifact in \ + BUNDLED_PACKAGES.json \ + THIRD_PARTY_LICENSES.txt \ + mcp-tool-discovery.bundle + do + artifact_path="${reviewed_root}/${artifact}" + if [ ! -f "$artifact_path" ] || [ -L "$artifact_path" ]; then + echo "ERROR: reviewed discovery permission fixture must be a regular non-symlink: ${artifact_path}" >&2 + exit 1 + fi + chmod 0664 "$artifact_path" + done + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -241,6 +259,79 @@ jobs: exit 1 fi + discovery_runtime="/usr/local/lib/nemoclaw/mcp-tool-discovery-runtime" + if ! actual_discovery_contract="$( + docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$image_id" -c ' + set -eu + discovery_runtime="$1" + if ! entry_owner_unsafe="$( + find -P "$discovery_runtime" ! -user root -print -quit + )"; then + echo "ERROR: final PR managed image discovery entry ownership inspection failed." >&2 + exit 1 + fi + if [ -n "$entry_owner_unsafe" ]; then + echo "ERROR: final PR managed image discovery entry is not root-owned: $entry_owner_unsafe" >&2 + exit 1 + fi + if ! owner_or_write_unsafe="$( + find -L "$discovery_runtime" \ + \( ! -user root -o -perm /022 \) -print -quit + )"; then + echo "ERROR: final PR managed image discovery tree inspection failed." >&2 + exit 1 + fi + if [ -n "$owner_or_write_unsafe" ]; then + echo "ERROR: final PR managed image discovery tree is not root-owned and read-only: $owner_or_write_unsafe" >&2 + exit 1 + fi + if ! mode_unsafe="$( + find -P "$discovery_runtime" \ + \( \( -type d ! -perm 0555 \) -o \( -type f ! -perm 0444 \) \) \ + -print -quit + )"; then + echo "ERROR: final PR managed image discovery mode inspection failed." >&2 + exit 1 + fi + if [ -n "$mode_unsafe" ]; then + echo "ERROR: final PR managed image discovery path has an unexpected mode: $mode_unsafe" >&2 + exit 1 + fi + node "$discovery_runtime/mcp-tool-discovery.mjs" + ' -- "$discovery_runtime" + )"; then + echo "ERROR: final PR managed image discovery contract probe failed." >&2 + exit 1 + fi + if ! node -e ' + const expected = { + protocol: 1, + ok: false, + detail: "tool discovery received invalid runtime arguments", + }; + let result; + try { + result = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + } catch { + process.exit(1); + } + const record = + result !== null && typeof result === "object" && !Array.isArray(result) + ? result + : undefined; + if ( + !record || + record.protocol !== expected.protocol || + record.ok !== expected.ok || + record.detail !== expected.detail + ) { + process.exit(1); + } + ' <<< "$actual_discovery_contract" >/dev/null; then + echo "ERROR: final PR managed image discovery bundle contract mismatch." >&2 + exit 1 + fi + printf 'reference=%s\n' "$image_id" >> "$GITHUB_OUTPUT" printf '### %s exact PR managed image\n\n`%s`\n' "$AGENT" "$image_id" \ >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index fcd6c3bfcab..d4b5bfc0a08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1513,6 +1513,13 @@ RUN managed_runtime_assertion_failed() { \ "$nemoclaw_command_assertion" "$nemoclaw_command_status" >&2; \ exit 1; \ }; \ + if find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -exec chown -h root:root '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type d -exec chmod 0555 '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type f -exec chmod 0444 '{}' +; then \ + :; \ + else \ + managed_image_command_failed mcp-tool-discovery-tree-permission-replay "$?"; \ + fi; \ discovery_contract="$(node /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/mcp-tool-discovery.mjs)" \ || managed_image_command_failed mcp-tool-discovery-bundle-execution "$?"; \ node -e 'const expected = { protocol: 1, ok: false, detail: "tool discovery received invalid runtime arguments" }; const standaloneSecretPatterns = [/(?:nvapi-|nvcf-|gh[pousr]_|sk-proj-|sk-ant-|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,}/gu, /github_pat_[A-Za-z0-9_]{30,}/gu, /sk-[A-Za-z0-9_-]{20,}/gu, /(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/gu, /A(?:K|S)IA[A-Z0-9]{16}/gu, /\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/gu, /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/gu, /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/gu, /\b[A-Za-z0-9_=-]{32,}\b/gu]; const redactContextSecrets = (value) => value.replace(/\b(?:Bearer|Basic)\s+\S+/giu, "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/giu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])KEY["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + ""); const sanitize = (value) => { if (value === undefined) return ""; if (value === null || typeof value === "boolean" || typeof value === "number") return value; if (typeof value !== "string") return "<" + (Array.isArray(value) ? "array" : typeof value) + ">"; let printable = value.replace(/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*/gu, "").replace(/[^\x20-\x7e]/gu, "?"); for (const pattern of standaloneSecretPatterns) printable = printable.replace(pattern, ""); printable = redactContextSecrets(printable); return printable.length <= 240 ? printable : printable.slice(0, 237) + "..."; }; let result; let parsed = true; try { result = JSON.parse(process.argv[1]); } catch { parsed = false; } const record = parsed && result !== null && typeof result === "object" && !Array.isArray(result) ? result : undefined; if (record && record.protocol === expected.protocol && record.ok === expected.ok && record.detail === expected.detail) process.exit(0); const actual = record ? { protocol: sanitize(record.protocol), ok: sanitize(record.ok), detail: sanitize(record.detail) } : parsed ? { type: result === null ? "null" : Array.isArray(result) ? "array" : typeof result, value: sanitize(result) } : { type: "invalid-json", preview: sanitize(process.argv[1]) }; console.error("ERROR: managed image assertion failed: mcp-tool-discovery-json-contract actual=%s expected=%s", JSON.stringify(actual), JSON.stringify(expected)); process.exit(1);' "$discovery_contract" \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3e4cefe3e3b..f13fdc5b000 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -375,6 +375,13 @@ RUN managed_runtime_assertion_failed() { \ "$nemoclaw_command_assertion" "$nemoclaw_command_status" >&2; \ exit 1; \ }; \ + if find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -exec chown -h root:root '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type d -exec chmod 0555 '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type f -exec chmod 0444 '{}' +; then \ + :; \ + else \ + managed_image_command_failed mcp-tool-discovery-tree-permission-replay "$?"; \ + fi; \ discovery_contract="$(node /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/mcp-tool-discovery.mjs)" \ || managed_image_command_failed mcp-tool-discovery-bundle-execution "$?"; \ node -e 'const expected = { protocol: 1, ok: false, detail: "tool discovery received invalid runtime arguments" }; const standaloneSecretPatterns = [/(?:nvapi-|nvcf-|gh[pousr]_|sk-proj-|sk-ant-|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,}/gu, /github_pat_[A-Za-z0-9_]{30,}/gu, /sk-[A-Za-z0-9_-]{20,}/gu, /(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/gu, /A(?:K|S)IA[A-Z0-9]{16}/gu, /\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/gu, /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/gu, /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/gu, /\b[A-Za-z0-9_=-]{32,}\b/gu]; const redactContextSecrets = (value) => value.replace(/\b(?:Bearer|Basic)\s+\S+/giu, "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/giu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])KEY["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + ""); const sanitize = (value) => { if (value === undefined) return ""; if (value === null || typeof value === "boolean" || typeof value === "number") return value; if (typeof value !== "string") return "<" + (Array.isArray(value) ? "array" : typeof value) + ">"; let printable = value.replace(/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*/gu, "").replace(/[^\x20-\x7e]/gu, "?"); for (const pattern of standaloneSecretPatterns) printable = printable.replace(pattern, ""); printable = redactContextSecrets(printable); return printable.length <= 240 ? printable : printable.slice(0, 237) + "..."; }; let result; let parsed = true; try { result = JSON.parse(process.argv[1]); } catch { parsed = false; } const record = parsed && result !== null && typeof result === "object" && !Array.isArray(result) ? result : undefined; if (record && record.protocol === expected.protocol && record.ok === expected.ok && record.detail === expected.detail) process.exit(0); const actual = record ? { protocol: sanitize(record.protocol), ok: sanitize(record.ok), detail: sanitize(record.detail) } : parsed ? { type: result === null ? "null" : Array.isArray(result) ? "array" : typeof result, value: sanitize(result) } : { type: "invalid-json", preview: sanitize(process.argv[1]) }; console.error("ERROR: managed image assertion failed: mcp-tool-discovery-json-contract actual=%s expected=%s", JSON.stringify(actual), JSON.stringify(expected)); process.exit(1);' "$discovery_contract" \ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index b6c97012657..0d0c95dda0e 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -125,6 +125,13 @@ RUN managed_runtime_assertion_failed() { \ "$nemoclaw_command_assertion" "$nemoclaw_command_status" >&2; \ exit 1; \ }; \ + if find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -exec chown -h root:root '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type d -exec chmod 0555 '{}' + \ + && find -P /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime -type f -exec chmod 0444 '{}' +; then \ + :; \ + else \ + managed_image_command_failed mcp-tool-discovery-tree-permission-replay "$?"; \ + fi; \ discovery_contract="$(node /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/mcp-tool-discovery.mjs)" \ || managed_image_command_failed mcp-tool-discovery-bundle-execution "$?"; \ node -e 'const expected = { protocol: 1, ok: false, detail: "tool discovery received invalid runtime arguments" }; const standaloneSecretPatterns = [/(?:nvapi-|nvcf-|gh[pousr]_|sk-proj-|sk-ant-|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,}/gu, /github_pat_[A-Za-z0-9_]{30,}/gu, /sk-[A-Za-z0-9_-]{20,}/gu, /(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/gu, /A(?:K|S)IA[A-Z0-9]{16}/gu, /\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/gu, /\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/gu, /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/gu, /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/gu, /\b[A-Za-z0-9_=-]{32,}\b/gu]; const redactContextSecrets = (value) => value.replace(/\b(?:Bearer|Basic)\s+\S+/giu, "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/giu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + "").replace(/((?:^|[^A-Za-z0-9])KEY["\x27]?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["\x27]?)[^\s"\x27]{10,}/gu, (_match, prefix) => prefix + ""); const sanitize = (value) => { if (value === undefined) return ""; if (value === null || typeof value === "boolean" || typeof value === "number") return value; if (typeof value !== "string") return "<" + (Array.isArray(value) ? "array" : typeof value) + ">"; let printable = value.replace(/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*/gu, "").replace(/[^\x20-\x7e]/gu, "?"); for (const pattern of standaloneSecretPatterns) printable = printable.replace(pattern, ""); printable = redactContextSecrets(printable); return printable.length <= 240 ? printable : printable.slice(0, 237) + "..."; }; let result; let parsed = true; try { result = JSON.parse(process.argv[1]); } catch { parsed = false; } const record = parsed && result !== null && typeof result === "object" && !Array.isArray(result) ? result : undefined; if (record && record.protocol === expected.protocol && record.ok === expected.ok && record.detail === expected.detail) process.exit(0); const actual = record ? { protocol: sanitize(record.protocol), ok: sanitize(record.ok), detail: sanitize(record.detail) } : parsed ? { type: result === null ? "null" : Array.isArray(result) ? "array" : typeof result, value: sanitize(result) } : { type: "invalid-json", preview: sanitize(process.argv[1]) }; console.error("ERROR: managed image assertion failed: mcp-tool-discovery-json-contract actual=%s expected=%s", JSON.stringify(actual), JSON.stringify(expected)); process.exit(1);' "$discovery_contract" \ diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 4778e43db75..56b8884cf4c 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -440,6 +440,9 @@ describe("complete managed-image publication workflow", () => { const prBuilder = managedPrBuilder(workflow); const matrix = prBuilder.strategy?.matrix?.include ?? []; const steps = prBuilder.steps ?? []; + const permissionDrift = step(prBuilder, "Reproduce reviewed discovery permission drift"); + const build = step(prBuilder, "Build PR managed image locally"); + const contract = step(prBuilder, "Validate exact PR managed image contract"); expect(prBuilder.if).toBe("github.event_name == 'pull_request'"); expect(prBuilder["runs-on"]).toBe("ubuntu-24.04"); @@ -453,6 +456,10 @@ describe("complete managed-image publication workflow", () => { "langchain-deepagents-code", ]); expect(matrix.every(({ base_alias }) => base_alias?.endsWith(":latest"))).toBe(true); + expect(steps.indexOf(permissionDrift)).toBeGreaterThan( + steps.indexOf(step(prBuilder, "Checkout")), + ); + expect(steps.indexOf(permissionDrift)).toBeLessThan(steps.indexOf(build)); for (const action of steps.filter((candidate) => candidate.uses)) { expect(action.uses, action.name).toMatch(fullShaAction); @@ -466,11 +473,136 @@ describe("complete managed-image publication workflow", () => { expect(resolveBase).toContain('reference="${BASE_REPOSITORY}@${digest}"'); expect(resolveBase).toContain('actual="sha256:$(sha256sum "$exact_raw"'); - expect(step(prBuilder, "Build PR managed image locally").with).toMatchObject({ + expect(build.with).toMatchObject({ platforms: "linux/amd64", load: true, push: false, }); + const contractSource = required(contract.run, "PR managed image contract is missing"); + expect(contractSource).toContain( + 'docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$image_id"', + ); + expect(contractSource).toContain('find -L "$discovery_runtime"'); + expect(contractSource).toContain('find -P "$discovery_runtime" ! -user root'); + expect(contractSource).toContain("\\( ! -user root -o -perm /022 \\) -print -quit"); + expect(contractSource).toContain("-type d ! -perm 0555"); + expect(contractSource).toContain("-type f ! -perm 0444"); + expect(contractSource).toContain('node "$discovery_runtime/mcp-tool-discovery.mjs"'); + expect(contractSource).toContain('result = JSON.parse(require("node:fs").readFileSync(0'); + expect(contractSource).toContain("record.protocol !== expected.protocol"); + expect(contractSource).toContain("record.ok !== expected.ok"); + expect(contractSource).toContain("record.detail !== expected.detail"); + expect(contractSource).not.toContain( + '[ "$actual_discovery_contract" != "$expected_discovery_contract" ]', + ); + const contractValidatorPrefix = "if ! node -e '"; + const contractValidatorMarker = contractSource.indexOf(contractValidatorPrefix); + expect(contractValidatorMarker).toBeGreaterThan(-1); + const contractValidatorStart = contractValidatorMarker + contractValidatorPrefix.length; + const contractValidatorEnd = contractSource.indexOf( + `' <<< "$actual_discovery_contract"`, + contractValidatorStart, + ); + expect(contractValidatorEnd).toBeGreaterThan(contractValidatorStart); + const contractValidator = contractSource + .slice(contractValidatorStart, contractValidatorEnd) + .trim(); + + const permissionDriftSource = required( + permissionDrift.run, + "reviewed discovery permission drift fixture is missing", + ); + const permissionFixture = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-discovery-permission-drift-"), + ); + const reviewedRoot = path.join( + permissionFixture, + "tools", + "mcp-tool-discovery-runtime", + "reviewed-runtime-bundle", + "mcp-tool-discovery", + ); + const reviewedArtifacts = [ + "BUNDLED_PACKAGES.json", + "THIRD_PARTY_LICENSES.txt", + "mcp-tool-discovery.bundle", + ]; + const permissionDriftScript = path.join(permissionFixture, "permission-drift.sh"); + fs.writeFileSync(permissionDriftScript, permissionDriftSource, { mode: 0o700 }); + fs.mkdirSync(reviewedRoot, { recursive: true }); + for (const artifact of reviewedArtifacts) { + const source = path.join( + repoRoot, + "tools", + "mcp-tool-discovery-runtime", + "reviewed-runtime-bundle", + "mcp-tool-discovery", + artifact, + ); + const fixture = path.join(reviewedRoot, artifact); + fs.copyFileSync(source, fixture); + fs.chmodSync(fixture, 0o444); + } + try { + const drifted = spawnSync("bash", [permissionDriftScript], { + cwd: permissionFixture, + encoding: "utf8", + }); + expect(drifted.status, drifted.stderr).toBe(0); + expect(drifted.stdout).toBe(""); + expect(drifted.stderr).toBe(""); + for (const artifact of reviewedArtifacts) { + expect(fs.statSync(path.join(reviewedRoot, artifact)).mode & 0o777).toBe(0o664); + } + + const executableBundle = path.join(permissionFixture, "mcp-tool-discovery.mjs"); + fs.copyFileSync(path.join(reviewedRoot, "mcp-tool-discovery.bundle"), executableBundle); + const bundleResult = spawnSync(process.execPath, [executableBundle], { encoding: "utf8" }); + expect(bundleResult.status, bundleResult.stderr).toBe(0); + expect(JSON.parse(bundleResult.stdout)).toMatchObject({ + protocol: 1, + ok: false, + count: 0, + tools: [], + truncated: false, + detail: "tool discovery received invalid runtime arguments", + }); + const acceptedContract = spawnSync(process.execPath, ["-e", contractValidator], { + encoding: "utf8", + input: bundleResult.stdout, + }); + expect(acceptedContract.status, acceptedContract.stderr).toBe(0); + for (const rejectedOutput of [ + '{"protocol":1,"ok":false,"detail":"wrong"}\n', + '{"protocol":1,"ok":false,"detail":"tool discovery received invalid runtime arguments","extra":NaN}\n', + '\uFEFF{"protocol":1,"ok":false,"detail":"tool discovery received invalid runtime arguments"}\n', + ]) { + const rejectedContract = spawnSync(process.execPath, ["-e", contractValidator], { + encoding: "utf8", + input: rejectedOutput, + }); + expect(rejectedContract.status).not.toBe(0); + } + + const linkedArtifact = path.join(reviewedRoot, reviewedArtifacts[0]); + const externalArtifact = path.join(permissionFixture, "external-reviewed-artifact.json"); + fs.unlinkSync(linkedArtifact); + fs.writeFileSync(externalArtifact, "{}\n", { mode: 0o444 }); + fs.symlinkSync(externalArtifact, linkedArtifact); + const rejected = spawnSync("bash", [permissionDriftScript], { + cwd: permissionFixture, + encoding: "utf8", + }); + expect(rejected.status).not.toBe(0); + expect(rejected.stdout).toBe(""); + expect(rejected.stderr).toContain( + "ERROR: reviewed discovery permission fixture must be a regular non-symlink:", + ); + expect(fs.statSync(externalArtifact).mode & 0o777).toBe(0o444); + } finally { + fs.rmSync(permissionFixture, { recursive: true, force: true }); + } + expect(step(prBuilder, "Exercise managed startup root stdin and hold").run).toContain( "run-managed-image-direct-e2e.ts", ); diff --git a/test/support/managed-bootstrap-image-contract.ts b/test/support/managed-bootstrap-image-contract.ts index cb9507658aa..1d3afec163a 100644 --- a/test/support/managed-bootstrap-image-contract.ts +++ b/test/support/managed-bootstrap-image-contract.ts @@ -35,6 +35,20 @@ const DISCOVERY_RUNTIME_ROOT = "/usr/local/lib/nemoclaw/mcp-tool-discovery-runti const DISCOVERY_RUNTIME_PATH = `${DISCOVERY_RUNTIME_ROOT}/mcp-tool-discovery.mjs`; const DISCOVERY_EXPECTED_CONTRACT = '{"protocol":1,"ok":false,"detail":"tool discovery received invalid runtime arguments"}'; +const REVIEWED_DISCOVERY_RUNTIME_ROOT = path.join( + import.meta.dirname, + "..", + "..", + "tools", + "mcp-tool-discovery-runtime", + "reviewed-runtime-bundle", + "mcp-tool-discovery", +); +const REVIEWED_DISCOVERY_RUNTIME_FILES = [ + ["BUNDLED_PACKAGES.json", "BUNDLED_PACKAGES.json"], + ["THIRD_PARTY_LICENSES.txt", "THIRD_PARTY_LICENSES.txt"], + ["mcp-tool-discovery.bundle", "mcp-tool-discovery.mjs"], +] as const; const MANAGED_STARTUP_RUNTIME_PATH = "/usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs"; function expectManagedRuntimeDiagnostic(dockerfile: string): void { @@ -54,7 +68,17 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { discoveryStart, ); expect(managedRuntimeStart).toBeGreaterThan(discoveryStart); - const functionSource = logicalInstruction.slice(0, discoveryStart).trim(); + const permissionReplayStart = logicalInstruction.indexOf( + `if find -P ${DISCOVERY_RUNTIME_ROOT} -exec chown -h root:root '{}' +`, + ); + expect(permissionReplayStart).toBeGreaterThan(0); + expect(permissionReplayStart).toBeLessThan(discoveryStart); + const functionSource = logicalInstruction.slice(0, permissionReplayStart).trim(); + const permissionReplaySource = logicalInstruction + .slice(permissionReplayStart, discoveryStart) + .trim(); + const treeSafetyStart = logicalInstruction.indexOf("discovery_unsafe=", discoveryStart); + expect(treeSafetyStart).toBeGreaterThan(discoveryStart); const discoverySource = logicalInstruction.slice(discoveryStart, managedRuntimeStart).trim(); for (const fragment of [ @@ -68,6 +92,10 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { } for (const assertion of [ + `find -P ${DISCOVERY_RUNTIME_ROOT} -exec chown -h root:root '{}' +`, + `find -P ${DISCOVERY_RUNTIME_ROOT} -type d -exec chmod 0555 '{}' +`, + `find -P ${DISCOVERY_RUNTIME_ROOT} -type f -exec chmod 0444 '{}' +`, + 'managed_image_command_failed mcp-tool-discovery-tree-permission-replay "$?"', `discovery_contract="$(node ${DISCOVERY_RUNTIME_PATH})" || managed_image_command_failed mcp-tool-discovery-bundle-execution "$?"`, "ERROR: managed image assertion failed: mcp-tool-discovery-json-contract actual=%s expected=%s", `discovery_unsafe="$(find -L ${DISCOVERY_RUNTIME_ROOT} \\( ! -user root -o -perm /022 \\) -print -quit)" || managed_image_command_failed mcp-tool-discovery-tree-find-execution "$?"`, @@ -85,8 +113,36 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { const missingPath = path.join(tmp, "missing-runtime.cjs"); const targetPath = path.join(tmp, "runtime-target.cjs"); const linkPath = path.join(tmp, "runtime-link.cjs"); + const permissionReplayRoot = path.join(tmp, "mcp-tool-discovery-runtime"); + const permissionReplayPaths = REVIEWED_DISCOVERY_RUNTIME_FILES.map(([, installedName]) => + path.join(permissionReplayRoot, installedName), + ); + const permissionReplayBundlePath = path.join(permissionReplayRoot, "mcp-tool-discovery.mjs"); + const externalPermissionTarget = path.join(tmp, "external-reviewed-artifact.json"); + const externalPermissionLink = path.join(permissionReplayRoot, "external-reviewed-artifact.json"); fs.writeFileSync(targetPath, "fixture\n", { mode: 0o444 }); fs.symlinkSync(targetPath, linkPath); + fs.mkdirSync(permissionReplayRoot, { mode: 0o775 }); + for (const [reviewedName, installedName] of REVIEWED_DISCOVERY_RUNTIME_FILES) { + const installedPath = path.join(permissionReplayRoot, installedName); + fs.copyFileSync(path.join(REVIEWED_DISCOVERY_RUNTIME_ROOT, reviewedName), installedPath); + fs.chmodSync(installedPath, 0o664); + } + fs.chmodSync(permissionReplayRoot, 0o775); + fs.copyFileSync( + path.join(REVIEWED_DISCOVERY_RUNTIME_ROOT, "BUNDLED_PACKAGES.json"), + externalPermissionTarget, + ); + fs.chmodSync(externalPermissionTarget, 0o664); + fs.symlinkSync(externalPermissionTarget, externalPermissionLink); + const permissionReplayContents = new Map( + permissionReplayPaths.map((artifactPath) => [artifactPath, fs.readFileSync(artifactPath)]), + ); + const permissionReplayForHost = permissionReplaySource + .replaceAll(DISCOVERY_RUNTIME_ROOT, '"$NEMOCLAW_TEST_DISCOVERY_ROOT"') + // The source contract pins root:root. Use the current identity so this + // extracted production command can also run on an unprivileged test host. + .replaceAll("root:root", '"$(id -u):$(id -g)"'); const runDiscoveryChecks = ({ discoveryOutput = DISCOVERY_EXPECTED_CONTRACT, discoveryStatus = 0, @@ -166,8 +222,71 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { }, }, ); + const runPermissionReplay = (findStatus?: number) => + spawnSync( + "sh", + [ + "-c", + [ + ...(findStatus === undefined ? [] : [`find() { return ${findStatus}; }`]), + functionSource, + permissionReplayForHost, + '"$NEMOCLAW_TEST_NODE" "$NEMOCLAW_TEST_DISCOVERY_BUNDLE"', + ].join("\n"), + ], + { + encoding: "utf-8", + env: { + NEMOCLAW_TEST_DISCOVERY_BUNDLE: permissionReplayBundlePath, + NEMOCLAW_TEST_DISCOVERY_ROOT: permissionReplayRoot, + NEMOCLAW_TEST_NODE: process.execPath, + PATH: process.env.PATH ?? "", + }, + }, + ); try { + const permissionReplayFailure = runPermissionReplay(43); + expect(permissionReplayFailure.status).toBe(1); + expect(permissionReplayFailure.stdout).toBe(""); + expect(permissionReplayFailure.stderr).toBe( + "ERROR: managed image assertion failed: mcp-tool-discovery-tree-permission-replay exit-status=43\n", + ); + expect(fs.statSync(path.join(permissionReplayRoot, "BUNDLED_PACKAGES.json")).mode & 0o777).toBe( + 0o664, + ); + + const permissionReplay = runPermissionReplay(); + expect(permissionReplay.status, permissionReplay.stderr).toBe(0); + expect(permissionReplay.stderr).toBe(""); + expect(JSON.parse(permissionReplay.stdout)).toMatchObject({ + protocol: 1, + ok: false, + detail: "tool discovery received invalid runtime arguments", + }); + const expectedUid = process.getuid?.() ?? 0; + const expectedGid = process.getgid?.() ?? 0; + const permissionReplayDirectory = fs.statSync(permissionReplayRoot); + expect(permissionReplayDirectory.uid).toBe(expectedUid); + expect(permissionReplayDirectory.gid).toBe(expectedGid); + expect(permissionReplayDirectory.mode & 0o022).toBe(0); + expect(permissionReplayDirectory.mode & 0o777).toBe(0o555); + for (const artifactPath of permissionReplayPaths) { + const artifactHandle = fs.openSync(artifactPath, "r"); + try { + const artifact = fs.fstatSync(artifactHandle); + expect(artifact.uid).toBe(expectedUid); + expect(artifact.gid).toBe(expectedGid); + expect(artifact.mode & 0o022).toBe(0); + expect(artifact.mode & 0o777).toBe(0o444); + expect(fs.readFileSync(artifactHandle)).toEqual(permissionReplayContents.get(artifactPath)); + } finally { + fs.closeSync(artifactHandle); + } + } + expect(fs.lstatSync(externalPermissionLink).isSymbolicLink()).toBe(true); + expect(fs.statSync(externalPermissionTarget).mode & 0o777).toBe(0o664); + const bundleFailure = runDiscoveryChecks({ discoveryOutput: "output must remain private", discoveryStatus: 23, @@ -312,6 +431,7 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { `ERROR: managed image assertion failed: non-symlink path=${linkPath} uid=0 gid=0 type=symbolic link mode=777 symlink=yes\n`, ); } finally { + fs.chmodSync(permissionReplayRoot, 0o755); fs.rmSync(tmp, { force: true, recursive: true }); } }