| 1 | // Ambient Node file access from inside a host plugin. Under the host sandbox |
| 2 | // the read of a Codewhale secret must fail even though the plugin is trusted. |
| 3 | import { readFile, writeFile, readlink } from 'node:fs/promises' |
| 4 | import { connect } from 'node:net' |
| 5 | import { createSocket } from 'node:dgram' |
| 6 | |
| 7 | export const name = 'secret-probe' |
| 8 | export const inject = ['tools'] |
| 9 | |
| 10 | export function apply(ctx) { |
| 11 | ctx.tools.register({ |
| 12 | name: 'probe_read', |
| 13 | description: 'Read a file with node:fs and report what happened.', |
| 14 | parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, |
| 15 | async execute(args) { |
| 16 | try { |
| 17 | return { ok: true, text: await readFile(args.path, 'utf8') } |
| 18 | } catch (error) { |
| 19 | return { ok: false, code: error.code ?? String(error) } |
| 20 | } |
| 21 | }, |
| 22 | }) |
| 23 | ctx.tools.register({ |
| 24 | name: 'probe_write', |
| 25 | description: 'Write a file with node:fs and report what happened.', |
| 26 | parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, |
| 27 | async execute(args) { |
| 28 | try { |
| 29 | await writeFile(args.path, 'probe') |
| 30 | return { ok: true } |
| 31 | } catch (error) { |
| 32 | return { ok: false, code: error.code ?? String(error) } |
| 33 | } |
| 34 | }, |
| 35 | }) |
| 36 | ctx.tools.register({ |
| 37 | name: 'probe_connect', |
| 38 | description: 'Connect to the test controller loopback listener and report the OS result.', |
| 39 | parameters: { type: 'object', properties: { port: { type: 'integer', minimum: 1, maximum: 65535 } }, required: ['port'] }, |
| 40 | async execute(args) { |
| 41 | // Linux's private loopback cannot reach the controller's listener. |
| 42 | // Report the actual kernel namespace, rather than infer isolation from errno. |
| 43 | const networkNamespace = process.platform === 'linux' |
| 44 | ? await readlink('/proc/self/ns/net') |
| 45 | : undefined |
| 46 | return new Promise((resolve) => { |
| 47 | const socket = connect({ host: '127.0.0.1', port: args.port }) |
| 48 | const finish = (result) => { |
| 49 | socket.destroy() |
| 50 | resolve(networkNamespace === undefined ? result : { ...result, network_namespace: networkNamespace }) |
| 51 | } |
| 52 | socket.once('connect', () => finish({ ok: true })) |
| 53 | socket.once('error', (error) => finish({ ok: false, code: error.code ?? String(error) })) |
| 54 | socket.setTimeout(1000, () => finish({ ok: false, code: 'ETIMEDOUT' })) |
| 55 | }) |
| 56 | }, |
| 57 | }) |
| 58 | ctx.tools.register({ |
| 59 | name: 'probe_send', |
| 60 | description: 'Send a datagram to the test controller and report the unmodified OS result.', |
| 61 | parameters: { type: 'object', properties: { port: { type: 'integer', minimum: 1, maximum: 65535 } }, required: ['port'] }, |
| 62 | async execute(args) { |
| 63 | return new Promise((resolve) => { |
| 64 | const socket = createSocket('udp4') |
| 65 | let finished = false |
| 66 | const finish = (result) => { |
| 67 | if (finished) return |
| 68 | finished = true |
| 69 | clearTimeout(timer) |
| 70 | // A refused bind leaves the socket unopened; cleanup must not replace |
| 71 | // the original access-denied result with ERR_SOCKET_DGRAM_NOT_RUNNING. |
| 72 | try { socket.close() } catch {} |
| 73 | resolve(result) |
| 74 | } |
| 75 | const failure = (error) => finish({ ok: false, code: error.code ?? String(error), syscall: error.syscall ?? null }) |
| 76 | const timer = setTimeout(() => finish({ ok: false, code: 'ETIMEDOUT' }), 1000) |
| 77 | socket.once('error', failure) |
| 78 | try { |
| 79 | socket.send('native-network-probe', args.port, '127.0.0.1', (error) => { |
| 80 | if (error) failure(error) |
| 81 | else finish({ ok: true }) |
| 82 | }) |
| 83 | } catch (error) { failure(error) } |
| 84 | }) |
| 85 | }, |
| 86 | }) |
| 87 | } |
| 88 |