Repository navigation
fix(policy): restrict Hermes Discord binary access #10682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 41 commits
39f8b77
69630ff
7bd41ca
6b3c817
f03fdec
154699d
86f89a8
2bd37f0
7961bc8
9ba480c
b8813ca
74b0e67
e51270f
7b174c4
19e3815
98baa26
eb102f5
928df08
e34b1e0
e59ea72
e25b802
1e6b8c0
e12caf5
4b621c8
b4981ce
2228126
67b53ed
1b2a896
531642e
864e37d
b3fd418
e84a83f
db2377f
ebdcc63
dd7b229
388e0fb
21a97b0
df1f627
c9199a0
7cfd627
3985a1d
d858d2c
087024c
106f727
9e79653
05bca26
2b7ede2
260cf29
1d0fb71
46127b3
eb36cf7
5e6bf13
1503226
2e3e64c
5a84c21
355b7ea
f4249cd
e46edd4
d29047c
178c820
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,7 +58,6 @@ network_policies: | |
| rules: | ||
| - allow: { method: GET, path: "/**" } | ||
| binaries: | ||
| - { path: /usr/local/bin/node } | ||
| - { path: /usr/bin/python3* } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1][Security testing] The shipped Hermes Discord policy is Python-only here, but
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit 2bd37f0. The Hermes fake Discord policy no longer grants either Node binary. The WebSocket and credential-bound REST proofs both run through /opt/hermes/.venv/bin/python, and the live lane reads the effective policy and fails if Node appears in either the production or fake Discord policy. Focused policy/onboarding tests (125), Hermes E2E-support tests (4), CLI type-check, docs, and npm run validate:pr pass. Fresh exact-head hermes-discord E2E will be dispatched after normal candidate checks and reviews stabilize.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Migrate the durable live policy, not only the preset source. Impact: existing Hermes Discord sandboxes retain the old
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed on exact head |
||
| - { path: /usr/bin/python3.13 } | ||
| - { path: /opt/hermes/.venv/bin/python3 } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| #!/usr/bin/env -S node --experimental-strip-types | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import fs from "node:fs"; | ||
| import http from "node:http"; | ||
|
|
||
| const host = process.env.FAKE_DISCORD_MESSAGE_API_HOST || "0.0.0.0"; | ||
| const rawPort = process.env.FAKE_DISCORD_MESSAGE_API_PORT || "0"; | ||
| const port = Number(rawPort); | ||
| const portFile = process.env.FAKE_DISCORD_MESSAGE_API_PORT_FILE || ""; | ||
| const captureFile = process.env.FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE || ""; | ||
| const expectedToken = process.env.FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN || ""; | ||
|
|
||
| if (!Number.isInteger(port) || port < 0 || port > 65_535) { | ||
| throw new Error( | ||
| `FAKE_DISCORD_MESSAGE_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`, | ||
| ); | ||
| } | ||
|
|
||
| if (!expectedToken) { | ||
| throw new Error("FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN is required"); | ||
| } | ||
|
|
||
| function record(event: Record<string, unknown>): void { | ||
| if (captureFile) { | ||
| fs.appendFileSync(captureFile, `${JSON.stringify({ at: Date.now(), ...event })}\n`); | ||
| } | ||
| } | ||
|
|
||
| function tokenFromAuthorization(value: string | undefined): string { | ||
| const raw = String(value || ""); | ||
| if (raw.length < 4 || raw.slice(0, 3).toLowerCase() !== "bot") return raw; | ||
| const next = raw.charCodeAt(3); | ||
| if (next !== 0x20 && next !== 0x09) return raw; | ||
| let index = 4; | ||
| while (index < raw.length) { | ||
| const code = raw.charCodeAt(index); | ||
| if (code !== 0x20 && code !== 0x09) break; | ||
| index += 1; | ||
| } | ||
| return raw.slice(index); | ||
| } | ||
|
|
||
| function tokenLooksPlaceholder(value: string): boolean { | ||
| return value.includes("openshell:resolve:env:"); | ||
| } | ||
|
|
||
| function writeJson(response: http.ServerResponse, status: number, body: unknown): void { | ||
| response.writeHead(status, { "content-type": "application/json" }); | ||
| response.end(JSON.stringify(body)); | ||
| } | ||
|
|
||
| const server = http.createServer((request, response) => { | ||
| const url = new URL(request.url || "/", "http://fake-discord.local"); | ||
| const token = tokenFromAuthorization(request.headers.authorization); | ||
| const tokenMatchesExpected = token === expectedToken; | ||
|
|
||
| record({ | ||
| event: "request", | ||
| method: request.method, | ||
| path: url.pathname, | ||
| tokenMatchesExpected, | ||
| tokenLooksPlaceholder: tokenLooksPlaceholder(token), | ||
| authorizationPresent: Boolean(request.headers.authorization), | ||
| authorizationRedacted: true, | ||
| }); | ||
|
|
||
| if (!tokenMatchesExpected) { | ||
| writeJson(response, 401, { message: "401: Unauthorized", code: 0 }); | ||
| return; | ||
| } | ||
|
|
||
| if (request.method === "GET" && url.pathname === "/api/v10/users/@me") { | ||
| writeJson(response, 200, { | ||
| id: "420000000000000000", | ||
| username: "NemoClaw E2E", | ||
| bot: true, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| writeJson(response, 404, { message: "Unknown Endpoint", code: 10_001 }); | ||
| }); | ||
|
|
||
| server.on("error", (error) => { | ||
| record({ event: "server_error", error: error.message }); | ||
| console.error(error.stack || error.message); | ||
| }); | ||
|
|
||
| server.listen(port, host, () => { | ||
| const address = server.address(); | ||
| if (!address || typeof address === "string") { | ||
| throw new Error("fake Discord message API did not bind a TCP port"); | ||
| } | ||
| if (portFile) { | ||
| fs.writeFileSync(portFile, `${address.port}\n`, { mode: 0o600 }); | ||
| } | ||
| record({ event: "listening", host, port: address.port }); | ||
| }); | ||
|
|
||
| for (const signal of ["SIGTERM", "SIGINT"] as const) { | ||
| process.on(signal, () => { | ||
| server.close(() => process.exit(0)); | ||
| setTimeout(() => process.exit(0), 1000).unref(); | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.