Merge remote-tracking branch 'origin/master' into codex/send-one-turn

This commit is contained in:
pku-xht
2026-07-20 12:52:17 +08:00
59 changed files with 2853 additions and 55 deletions
@@ -62,9 +62,7 @@ Left open, for the phase that needs them: whether network restriction arrives as
The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro <path>` / `--rw <path>` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing.
The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.
FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together.
The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.
Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement.
@@ -10,13 +10,13 @@ Search output also has two distinct budgets. The tool needs enough raw `rg` outp
## Decision
`glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. The package registers model-facing filesystem discovery tools, but execution uses `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations.
`glob` and `grep` are conditional model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. At plugin load, the package checks `command -v rg >/dev/null 2>&1` through `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)`; if the command exits nonzero, the package logs a warning and registers neither tools nor prompt sections. A probe that cannot start, times out, aborts, is killed, or produces no exit code fails plugin load loudly because that is a broken bash executor rather than an absent optional binary. When registered, execution uses the same `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` flow with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations.
The tools do not use `ctx.bash.start()` and do not create model-visible background tasks. They run as ordinary foreground tools from the agent loop's perspective: the tool call returns only after the `rg` command exits, times out, is aborted, or fails. `defineTool({ timeoutMs })` declares the cooperative tool-call budget, `@deepseek-ai/dsh-timeout-policy` enforces it through `exec.signal`, and the tool forwards that signal into the bash request before `resolve()` / `run()`. The bash backend's own timeout remains a second safety cap; whichever aborts first wins.
The tools align `path` with Claude Code's search tools while binding resolution to the bash workdir, not to `ctx.fs`. The tool derives the bash request workdir from `exec.agent?.session.header.cwd`, mirroring `dsh-tool-bash` and `dsh-tool-fs`; when no session cwd exists, it omits `request.workdir` so the bash implementation applies its configured cwd or process cwd through `resolve()`. For `grep`, `path` is an optional ripgrep target and may be a file or directory; omitted means the resolved bash workdir. For `glob`, `path` is an optional directory search root; omitted means the resolved bash workdir. Relative `path` values resolve against that workdir. Returned paths are displayed relative to the resolved bash workdir when possible and are intended to be follow-up-readable only in co-located deployments where the bash workdir and filesystem `read` root are the same workspace. v1 documents that deployment requirement but does not perform runtime cross-service validation. Remote or virtual filesystem search is deferred until there is a shared workspace/root contract or a provider-specific search backend.
The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash.
The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash. Deployments that load search need `rg` available in the bash executor environment for the tools to enter the model-visible schema.
### Package shape
@@ -79,9 +79,9 @@ The `path` field follows the same split as Claude Code: `grep.path` is a file-or
Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model.
Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, missing `rg`, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`.
Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, runtime `rg` disappearance after registration, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`.
If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / missing `rg` / inaccessible search workdir are failures.
If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / runtime `rg` disappearance / inaccessible search workdir are failures.
Search failures use a package-owned `HarnessError` subclass with `SEARCH_*` codes, not `FsErrorCode`, because these tools are not `ctx.fs` provider operations. The v1 vocabulary is `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, and `SEARCH_ABORTED`. Model argument validation failures such as missing required fields, blank strings, or unsupported negated/list `include` values remain ordinary tool argument errors.
@@ -116,7 +116,7 @@ Line 12: ...
(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.)
```
If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields.
If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, runtime `rg` disappearance, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields.
## Alternatives considered
@@ -138,22 +138,24 @@ If the complete logical result fits under the inline cap, no formatted spill art
**Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly.
**Always register and report missing `rg` only at execution time.** Rejected: a model-visible tool schema is a promise that the deployment can attempt that capability. If the bash executor cannot find ripgrep at load, the safer surface is no `glob` / `grep` tools or prompt guidance. Execution-time missing-`rg` classification remains as a defensive fallback for environments that change after registration.
## Testing
- Tests prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant.
- Tests cover registration-time `rg` probing (probe success registers both tools and prompt sections, nonzero probe skips both tools and prompt sections with a warning, infrastructure probe failures reject plugin load), prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant.
- The first-party tool-owned spill precedent is covered directly: spill backend present, spill backend absent, `saveText()` failure, and missing spill owner.
- The package has real Loader-path coverage for the namespace plugin export shape (`name`, `inject`, `Config`, and `apply`, with no default export).
- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite alone carries the per-file 100% coverage gate.
- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on the test process PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite carries registration and execution coverage for missing `rg`, plus the per-file 100% coverage gate.
- Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every expected output would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session.
## Consequences
- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillStore` stays optional via `ctx.get('spillStore')`.
- `glob` and `grep` are conditional model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. They register only when the bash executor can find `rg`; the package injects `tools`, `systemPrompt`, and `bash`, does not inject `fs`, and keeps `ctx.spillStore` optional via `ctx.get('spillStore')`.
- The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`).
- The tools execute through `ctx.bash.resolve(request)``ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display.
- The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model.
- Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`.
- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement.
- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the conditional tool plugin (the acp-agent tree waits on the snapshot re-record above); the fs group README records the `rg` availability and co-located bash/filesystem deployment requirements.
## Risks
+127
View File
@@ -0,0 +1,127 @@
# Manually-dispatched CI for the landlock-run source of record
# (native/landlock-run). A separate workflow from ci.yml on purpose: the
# subtree is a self-contained pnpm workspace with its own gates, exercised on
# demand — per-architecture native legs (build + behavioral tests + pack
# rehearsal on real kernels) plus one darwin leg proving the documented
# degradation on hosts without a platform package. Legs derive from the
# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing
# for npm happens in the release mirror (node-addon-landlock-run) after an
# export — see native/README.md; this workflow never packs for release.
name: Landlock Run
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
defaults:
run:
working-directory: native/landlock-run
jobs:
matrix:
name: Matrix
runs-on: ubuntu-24.04
outputs:
ci: ${{ steps.matrix.outputs.ci }}
steps:
- uses: actions/checkout@v4
- id: matrix
run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
native:
name: ${{ matrix.platform }}
needs: matrix
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.matrix.outputs.ci) }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: native/landlock-run/package.json
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: native/landlock-run/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install musl toolchain
run: |
sudo apt-get update -q
sudo apt-get install -yq musl-tools
- name: Build TypeScript
run: pnpm build:ts
- name: Typecheck
run: pnpm typecheck
- name: Build native binaries (this architecture is the builder of record)
run: pnpm build:native
- name: Entry tests (keyless)
run: node ./test/entry.test.js
# NALR_REQUIRE_LANDLOCK: a self-skip on the very platform that exists to
# prove enforcement would be a false green, so an unenforcing kernel
# fails the leg instead of skipping.
- name: Launcher tests (real kernel enforcement)
run: node ./test/launcher.test.js
env:
NALR_REQUIRE_LANDLOCK: 1
- name: Pack rehearsal (pack → install → confine, this platform only)
run: |
node ./scripts/pack-release.mjs .release/npm --current-platform-only
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
env:
NALR_REQUIRE_LANDLOCK: 1
darwin:
name: darwin (no platform package — degradation proof)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: native/landlock-run/package.json
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: native/landlock-run/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build TypeScript
run: pnpm build:ts
- name: Typecheck
run: pnpm typecheck
- name: Entry tests (keyless)
run: node ./test/entry.test.js
- name: Launcher tests (must self-skip cleanly)
run: node ./test/launcher.test.js
- name: Pack rehearsal (entry only — fallback resolution + unusable probe)
run: |
node ./scripts/pack-release.mjs .release/npm --current-platform-only
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
+1
View File
@@ -32,6 +32,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
support/ dev/test infrastructure packages
util/ zero-dependency utilities
python/ Python SDK and bundled runtime (see python/README.md)
native/ node-addon-landlock-run source of record (see native/README.md)
examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md)
.agents/ Agent workflows and Agent Notes (`notes/`)
docs/ architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md)
+1 -1
View File
@@ -1088,7 +1088,7 @@ export interface Config {
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts)
Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../packages/fs/tool-fs-search/src/index.ts)
## `@deepseek-ai/dsh-tool-skill`
+2 -2
View File
@@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
@@ -391,7 +391,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
## `@deepseek-ai/dsh-tool-skill`
+1
View File
@@ -13,6 +13,7 @@ export default tseslint.config(
'.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources
'**/.doc-typecheck-*/**',
'vendor/**', // vendored source keeps upstream style and idioms
'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md)
'**/*.js',
'**/*.mjs',
'*.config.ts', // root tool configs (vitest, tsdown) — no project service
+6
View File
@@ -27,6 +27,10 @@ flowchart LR
bundle_agent_core --> spine_sessions["ctx.sessions"]
bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
plugin_acp_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
cfg --> plugin_acp_token_meter
plugin_acp_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_acp_compact_basic
plugin_acp_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_acp_subagent
plugin_acp_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
@@ -59,6 +63,8 @@ flowchart LR
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
+17
View File
@@ -48,6 +48,23 @@
Verify your work by running the code or tests. Keep answers brief and factual.
# Replay-aware request pressure with one service-wide context window.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
config:
# FIXME: Resolve compaction config per model; this capacity assumes a 256k context window.
contextWindow: 256000
# Summarize an older range after measured pressure or a canonical provider overflow.
# Service-wide policy provides pressure, retention, and one overflow-retry default.
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:
thresholdRatio: 0.8
retainTokens: 20480
maxTokens: 8192
compactionRetries: 1
# Expose fresh-child `spawn` and completed-prefix `fork` through separate tool
# names so multi-child scenarios exercise both transports. These leaves follow
# the app because it provides `ctx.agents` and `ctx.tools`.
+4 -3
View File
@@ -111,9 +111,10 @@
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the
# local bash executor above — not ctx.fs. Capped results save the complete
# formatted list through the spill backend below (ctx.spillStore, optional).
# Bash-backed discovery tools (glob/grep): if the local bash executor above
# can find rg, register fixed ripgrep commands — not ctx.fs. Capped results
# save the complete formatted list through the spill backend below
# (ctx.spillStore, optional).
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
+20
View File
@@ -0,0 +1,20 @@
# native/
Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family.
## Release mirror
| Directory | Mirror repo | Last exported release | Commit |
|---|---|---|---|
| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` |
The subtree is a self-contained pnpm workspace with its own `AGENTS.md`, docs, gates, and lockfile; it is NOT part of the harness workspace (`pnpm-workspace.yaml` does not include it), so harness installs, builds, and CI gates never touch it. The mirror's `.github/` stays out of the subtree — [.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml) (manual dispatch) runs the subtree's CI legs here, and a change to those legs is mirrored into the mirror's `ci.yml` at the next export.
## Export procedure (cutting a release)
1. Land the launcher change here through a normal harness PR; dispatch the `Landlock Run` workflow and get its legs green.
2. In the mirror checkout, replace everything except `.github/`: `git -C <mirror> rm -rq -- . ':!.github'`, then `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`, then `git -C <mirror> add -A` and commit.
3. In the mirror, follow its release checklist (`docs/release.md`): `pnpm release:commit <version>` → merge → tag `vX.Y.Z` → two-phase `Release` workflow (`publish=false` rehearsal, then `publish=true` from the tag).
4. Update the manifest table above with the released tag/commit, and bump the harness consumers' dependency range in the same change.
The mirror must not diverge: a change committed there directly (hotfix during a release) is ported back here before the next export.
+13
View File
@@ -0,0 +1,13 @@
# Built native binaries ride npm tarballs via each package's `files` list,
# never git. Root-level rules on purpose: a package-nested ignore file would
# also steer `pnpm pack` and has silently dropped payload from tarballs before.
packages/*/bin/
packages/*/lib/
/.claude/
/.release/
dist/
node_modules/
/package-lock.json
*.log
*.tsbuildinfo
+50
View File
@@ -0,0 +1,50 @@
# AGENTS.md
This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror.
## Pre-release stance
The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them.
## Runtime safety rules
- Every tool must fail closed. If a ruleset cannot be created or the kernel does not enforce it, exit non-zero WITHOUT exec'ing the wrapped command. Never run unconfined as a fallback.
- Runtime binaries and the entry packages take NO environment-variable overrides: which binary confines a process must never be decidable by the ambient environment. Test injection is by function parameter; the `NALR_*` prefix is for build/test orchestration only.
- Kernel UAPI is self-defined in the C source (verbatim from the kernel headers), keeping builds independent of toolchain header vintage and making the definitions part of the audit record.
- No libraries beyond libc, linked statically against musl. The audit surface of a tool is its C source plus the kernel's stable syscall contract.
- The CLI contract of each tool ([docs/cli-contract.md](docs/cli-contract.md)) is the cross-repo compatibility surface: argv grammar, exit codes, and report lines change only with a version bump and a changelog entry, and consumers parse them only through the entry package.
- There is deliberately NO install-time build fallback: a host without a matching platform package gets a nonexistent launcher path, the consumer's probe fails, and the consumer falls closed — that degradation is part of the design, not a gap to fill with node-gyp.
## Repository layout
```text
packages/entry/ Published entry package: JS seam (resolve/probe/grants) + the C source.
packages/linux-*/ Published per-platform packages: one prebuilt static binary, no JavaScript.
scripts/ Build, matrix derivation, prepack gates, and release orchestration.
test/ Plain-node behavioral tests (entry seam + real-kernel launcher proofs).
docs/ Architecture, packaging, CLI contract, release, support matrix, naming.
```
## Commands
```sh
pnpm install
pnpm build:ts # entry packages → lib/
pnpm build:native # this Linux architecture's binaries (needs musl-tools); fails fast elsewhere
pnpm typecheck
pnpm test # entry tests everywhere; launcher tests need linux + built binary
```
## Packaging invariants
- The package matrix is explicit, checked-in metadata: `packages/<name>/package.json` (`os`, `cpu`), `packages/<name>/prebuilds.json` (the binaries that may exist there), and [docs/support-matrix.md](docs/support-matrix.md) stay synchronized when the matrix changes. `scripts/github-matrix.mjs` derives CI and release matrices from it; nothing else enumerates platforms.
- Platform package names contain platform only (`-linux-x64`), never tool variants — those stay inside `prebuilds.json`. Static musl linking is why there is no libc suffix: one binary serves glibc and musl distros.
- Platform packages ship no JavaScript; the entry package resolves them to file paths. Backends prove themselves at runtime through the functional probe, never through metadata trust.
- Builds are native-only: each architecture compiles its own binary on its own runner (CI is the builder of record); no cross toolchain enters the repo.
- Every tarball is gated at pack time: platform packages refuse to pack without their declared binaries present, executable, and in the right ELF architecture (`verify-launcher-binary.mjs`), entry packages without built `lib/` (`verify-entry-lib.mjs`), and the release pipeline byte-pins installed binaries against the workspace builds (`verify-packed-install.mjs`).
- Platform tarballs are packed with `npm pack`, never `pnpm pack`: pnpm's pack path strips the executable bit (observed on 11.7.0), shipping a launcher no consumer can spawn. `pack-release.mjs` encodes the split; the rehearsal asserts executability of the installed copy so a regression fails loudly instead of masquerading as a non-enforcing kernel.
- Generated artifacts stay out of git: `packages/*/bin/`, `packages/*/lib/`, `dist/`, `.release/`, `*.tsbuildinfo`. Ignore rules live in the ROOT `.gitignore` only — a package-nested ignore file can silently drop payload from tarballs.
## Documentation
User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md).
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, node-addon-landlock-run contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+58
View File
@@ -0,0 +1,58 @@
# node-addon-landlock-run
A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves.
The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command.
## Install
```sh
npm install node-addon-landlock-run
```
Published packages use an entry package plus platform optional packages:
```text
node-addon-landlock-run
node-addon-landlock-run-linux-x64
node-addon-landlock-run-linux-arm64
```
npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed.
## Usage
```js
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
const launcher = launcherPath();
if (probe(launcher) !== 'unusable') {
const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command];
// spawn argv with your process runner of choice
}
```
The public API is intentionally small:
- `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal).
- `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`.
- `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied.
- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125): contract constants.
The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md).
## Support
linux-x64 and linux-arm64, kernel with Landlock enabled (5.13+; ABI level determines `full` vs `partial` enforcement — see [docs/support-matrix.md](docs/support-matrix.md)). Other platforms deliberately have no package: consumers run different confinement backends there.
## Development
```sh
corepack enable
pnpm install
pnpm build:ts # entry packages → lib/
pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools)
pnpm test
```
Binaries are git-ignored and built natively per architecture — locally for your own machine, by CI's per-arch runners as the builders of record. Release flow: [docs/release.md](docs/release.md).
+34
View File
@@ -0,0 +1,34 @@
# Architecture
This repository owns confinement *mechanism*, not policy: consumers (agent harnesses, sandbox seams) decide which paths a run may read or write; this package family provides the launcher that enforces those grants and the JS seam that resolves and speaks to it. The packaging follows the per-platform-package model of [`node-addon-require-builtin`](https://www.npmjs.com/package/@esplus/node-addon-require-builtin) (and esbuild), adapted from Node addons to standalone static executables.
## Two-layer package family
The family is one entry package plus per-platform binary packages:
- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`.
- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import.
Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent.
There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively.
## Resolution and availability
`launcherPath()` resolves `node-addon-landlock-run-<platform>-<arch>` and returns `<package>/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two.
The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement.
## Fail-closed everywhere
The launcher exits `125` without exec'ing the command on any launcher-level failure: usage error, unenforcing kernel, unopenable grant root, failed exec. Partial enforcement (an older Landlock ABI governing only a subset of accesses) is accepted, reported on stderr, and surfaced by the probe as `partial` — the consumer decides what its mode vocabulary promises at each level. Neither the binary nor the entry package reads environment variables: which binary confines a process is never decidable by the ambient environment.
## Build and release model
Builds are native-only. `scripts/build.ts` compiles the running architecture's binaries with the distro `musl-gcc` (static: no loader or libc expectations on consumers, one binary for glibc and musl distros); CI's per-architecture runners are the builders of record, and no cross toolchain exists in the repo. The audit surface of a tool is its reviewed C source plus CI provenance, enforced by three gates: platform prepack refuses missing/wrong-ELF binaries, entry prepack refuses unbuilt `lib/`, and the release pipeline byte-pins installed binaries against the workspace builds they were packed from.
The package matrix is checked-in metadata (`prebuilds.json` + `os`/`cpu` fields); `scripts/github-matrix.mjs` derives the CI and Release matrices from it, so adding a platform extends automation without editing workflows.
## Adding a platform
A new platform adds one `packages/<platform>/` package (`package.json` with `os`/`cpu`, `prebuilds.json`, README, LICENSE), a runner entry in `scripts/github-matrix.mjs`, and a row in [support-matrix.md](support-matrix.md) — added only together with a native GitHub runner that builds and proves it (the no-cross-toolchain rule). Sibling launchers for other confinement mechanisms belong in their own repositories on this same template, not as second tools here.
+34
View File
@@ -0,0 +1,34 @@
# CLI contract: landlock-run
This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs`); changing anything below requires a version bump for the whole package family and a note in the release notes.
## Invocation grammar
```text
landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
landlock-run --probe
```
- `--ro <path>`: grant read + execute beneath `<path>`.
- `--rw <path>`: grant full filesystem access beneath `<path>` (every access the negotiated kernel ABI can govern).
- Everything not granted is denied — Landlock rulesets are allow-lists.
- A grant on a non-directory keeps only its file-compatible access bits (this is how a `--rw /dev/null` grant works).
- `--`: mandatory separator; everything after it is the command argv, exec'd via `execvp` with the launcher's environment unchanged.
- `--probe`: mutually exclusive with grants and a command.
- No other flags, no environment-variable inputs.
## Exit codes
- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run (fail-closed; the one exception is `exec` itself failing after restriction, which by definition never ran the command either).
- Any other status: the wrapped command's own exit status, passed through unchanged.
- `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise.
## Report lines
- Probe success prints exactly one stdout line: `landlock: fully enforced` or `landlock: partially enforced (older ABI)`. The entry package's `probe()` maps these to `full`/`partial`; a non-zero probe exit maps to `unusable`.
- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` and proceeds — still confined for everything the kernel supports.
- Every fatal error prints one stderr line prefixed `landlock-run: ` before exiting `125`.
## Confinement semantics
The launcher sets `no_new_privs`, installs the ruleset on itself, and `exec`s the command; the ruleset is inherited across `execve`, so every descendant process is equally confined. The ruleset governs the filesystem accesses of the kernel's negotiated Landlock ABI (up to ABI 5); accesses newer than the running ABI are not governed and are the difference between `full` and `partial`.
+30
View File
@@ -0,0 +1,30 @@
# Naming
## npm packages
The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only:
```text
node-addon-landlock-run
node-addon-landlock-run-<platform>
```
Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames.
## Binaries
The launcher executable is `landlock-run`, shipped at `bin/landlock-run` inside each platform package.
## Environment variables
The `NALR_` prefix (Node Addon Landlock Run) is reserved for build/test orchestration:
```text
NALR_REQUIRE_LANDLOCK test-only: an unenforcing kernel fails instead of skipping
```
Runtime binaries and entry packages read NO environment variables — a runtime safety rule ([AGENTS.md](../AGENTS.md)), not a naming convention. Do not include the npm scope in environment variable names.
## C symbols
The launcher is a single C file with static linkage; there is no exported symbol namespace. Kernel UAPI constants keep their kernel names prefixed `LL_` where locally defined.
+45
View File
@@ -0,0 +1,45 @@
# Packaging
The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares.
## Published packages
```text
node-addon-landlock-run
node-addon-landlock-run-linux-x64
node-addon-landlock-run-linux-arm64
```
Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md).
## Package matrix
The matrix is explicit in checked-in metadata:
- `packages/entry/package.json` lists the platform packages as `optionalDependencies`.
- `packages/<name>/package.json` declares `os` and `cpu`. There is no `libc` field on purpose: the binaries are statically linked against musl and run on glibc and musl distros alike.
- `packages/<name>/prebuilds.json` declares the binaries that may exist in that package (`tool`, `kind`, `path`).
- [support-matrix.md](support-matrix.md) explains why unsupported platform packages are not published.
`scripts/github-matrix.mjs` derives the CI and Release matrices from these files. `scripts/build.ts` builds only the current host's targets, into `packages/<name>/bin/`; it is not a matrix generator. When changing the matrix, update package metadata, `prebuilds.json`, the lockfile, and the support/release docs in the same change.
## Runtime selection
1. npm's `os`/`cpu` fields make installers fetch only the matching platform package.
2. The entry package's `launcherPath()` resolves it to `<package>/bin/landlock-run`; unresolvable packages yield a deterministic, never-existing fallback path.
3. `probe()` is the single availability signal: missing binary and unenforcing kernel are deliberately indistinguishable (`unusable`), so consumers have one fail-closed path.
## No install fallback
The entry package has NO install script and never compiles on the consumer host. A compile fallback would require a musl toolchain everywhere and turn a clean fail-closed degradation into an environment-dependent maybe. The packed-manifest check in `verify-packed-install.mjs` enforces the absence of install lifecycle scripts.
## Pack gates
Platform tarballs are produced by `npm pack`, entry tarballs by `pnpm pack` — deliberately split: `pnpm pack` (observed on 11.7.0) normalizes file modes and strips the executable bit, which would ship a launcher no consumer can spawn, while platform packages have no dependencies and so need none of pnpm's workspace-protocol conversion; entry packages need that conversion and carry no executables. `scripts/pack-release.mjs` encodes the split — never hand-pack a platform package with pnpm.
Both pack paths produce the exact publish bytes behind a `prepack` gate:
- Platform packages: `scripts/verify-launcher-binary.mjs` — every declared binary present, executable, ELF `e_machine` matching the declared `cpu`, nothing undeclared in `bin/`.
- Entry packages: `scripts/verify-entry-lib.mjs` — built `lib/` present.
`scripts/verify-packed-install.mjs` then rehearses the consumer path from the packed tarballs: payload checks, a throwaway install, a byte-pin of the installed binary against the workspace build, an executability check on the installed copy, and a real confinement world-proof through the installed launcher. A non-executable or missing binary fails loudly here instead of masquerading as a non-enforcing kernel.
+58
View File
@@ -0,0 +1,58 @@
# Release
Pre-1.0: treat this as a release checklist, not a stability policy.
## Versioning
One version across every package in the repo. Use the bump helper:
```sh
pnpm release:bump patch # or minor / major / x.y.z
```
It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack.
Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version.
```sh
pnpm release:commit patch # bump + stage + commit in one command
git tag v0.0.2
```
## Preflight
```sh
pnpm install --frozen-lockfile
pnpm build:ts
pnpm typecheck
pnpm test:entry
```
On a Linux host, also rehearse the pack path locally:
```sh
pnpm build:native
pnpm test:launcher
node ./scripts/pack-release.mjs .release/npm --current-platform-only
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
```
## Publish
Use the `Release` workflow so every binary is built on its matching native runner:
1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection.
2. Create and push the `vX.Y.Z` tag matching the package versions.
3. Run the same workflow from that tag with `publish=true`.
The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`.
Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)):
```sh
node ./scripts/pack-release.mjs dist/npm --current-platform-only
node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only
while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt
```
Do not commit `.npmrc` files with tokens or registry overrides.
@@ -0,0 +1,18 @@
# Support matrix
## Supported
| Platform package | GitHub runner (builder of record) | Notes |
|---|---|---|
| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike |
| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike |
Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version.
## Deliberately unsupported
- **darwin**: macOS consumers typically confine through `sandbox-exec`/Seatbelt, which ships with the OS — there is no binary to distribute.
- **win32**: a Windows confinement launcher would be a different mechanism in its own repository, not a port of this one.
- **Other Linux architectures** (riscv64, s390x, …): no native CI builder of record yet. The no-cross-toolchain rule means a platform package is added only together with a native runner that builds and proves it.
A consumer on an unsupported platform resolves a nonexistent launcher path, probes `unusable`, and falls closed — the documented degradation, exercised by CI's darwin leg.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "node-addon-landlock-run-workspace",
"version": "0.0.1",
"private": true,
"type": "module",
"license": "BSD-3-Clause",
"packageManager": "pnpm@11.7.0",
"scripts": {
"build": "pnpm build:ts",
"build:ts": "tsc -b",
"build:native": "tsx ./scripts/build.ts",
"typecheck": "tsc --noEmit && tsc -b --dry",
"test": "node ./test/entry.test.js && node ./test/launcher.test.js",
"test:entry": "node ./test/entry.test.js",
"test:launcher": "node ./test/launcher.test.js",
"gha:matrix": "node ./scripts/github-matrix.mjs",
"release:bump": "node ./scripts/bump-release.mjs",
"release:commit": "node ./scripts/commit-release.mjs",
"release:assemble-prebuilds": "node ./scripts/assemble-prebuilds.mjs",
"release:verify": "node ./scripts/verify-release.mjs",
"release:pack": "node ./scripts/pack-release.mjs",
"release:verify-packed-install": "node ./scripts/verify-packed-install.mjs"
},
"devDependencies": {
"node-addon-landlock-run": "workspace:*",
"@types/node": "^24.10.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,16 @@
# node-addon-landlock-run
Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves.
```js
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
const launcher = launcherPath();
if (probe(launcher) !== 'unusable') {
const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command];
}
```
The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit.
Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback.
@@ -0,0 +1,36 @@
{
"name": "node-addon-landlock-run",
"version": "0.0.1",
"type": "module",
"description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./package.json": "./package.json"
},
"files": [
"README.md",
"lib/",
"!lib/*.tsbuildinfo",
"src/main.c"
],
"scripts": {
"build:js": "tsc -b",
"prepack": "node ../../scripts/verify-entry-lib.mjs"
},
"engines": {
"node": ">=20"
},
"license": "BSD-3-Clause",
"publishConfig": {
"access": "public"
},
"optionalDependencies": {
"node-addon-landlock-run-linux-arm64": "workspace:*",
"node-addon-landlock-run-linux-x64": "workspace:*"
}
}
@@ -0,0 +1,126 @@
/**
* The JS seam over the prebuilt `landlock-run` launcher: resolve the
* binary for this host, build its grant argv, and run its functional probe.
*
* This module owns the launcher's CLI contract (`docs/cli-contract.md`) so
* consumers never parse launcher output or spell launcher flags themselves —
* the contract and the binaries version together in one package family,
* which makes probe-parsing drift against the binary structurally
* impossible. Policy stays with the consumer: this package does not know
* what a "sandbox mode" is, only which paths are granted read or write.
*
* Deliberately no environment-variable overrides anywhere in this module:
* which binary confines a process must never be decidable by the ambient
* environment. Test injection is by function parameter.
*/
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
/** The launcher binary's file name inside each platform package's `bin/`. */
export const LAUNCHER_BIN = 'landlock-run'
/**
* The exit code for every launcher-level failure (usage error, unenforcing
* kernel, unopenable grant root, failed exec) — chosen because the wrapped
* command itself is unlikely to use it, so a consumer can tell launcher
* failures from command failures. Part of the CLI contract.
*/
export const LAUNCHER_FAILURE_EXIT = 125
/**
* The probe's verdict on this host: `full` when the running kernel enforces
* every access the launcher can govern, `partial` when an older Landlock ABI
* governs only a subset (still confined for everything it supports), and
* `unusable` when nothing can be enforced — a kernel without Landlock, a
* disabled LSM, or a missing binary, all indistinguishable on purpose
* because the consumer's answer is the same: do not trust this launcher.
*/
export type LandlockEnforcement = 'full' | 'partial' | 'unusable'
/**
* Filesystem grants for one confined run. Everything not granted is denied —
* Landlock rulesets are allow-lists.
*/
export interface LauncherGrants {
/** Roots granted read + execute beneath (the launcher's `--ro`). */
readonly readOnly?: readonly string[]
/** Roots granted full filesystem access beneath (the launcher's `--rw`). */
readonly readWrite?: readonly string[]
}
/**
* Path of the launcher binary for this host: resolved from the per-platform
* npm package `node-addon-landlock-run-<platform>-<arch>` (npm's
* `os`/`cpu` fields make installers fetch only the matching one). When the
* package is not resolvable — a platform without one, or an install that
* skipped the optional dependency — the returned fallback path points inside
* this package's own `node_modules` and simply never exists. Existence is
* deliberately not checked either way: {@link probe} is the single
* availability signal (a missing binary probes `unusable` the same way an
* unenforcing kernel does).
* @param resolvePackageJson - test seam over `require.resolve` (the default
* covers real installs); receives the platform package's `package.json`
* specifier and returns its absolute path, throwing when unresolvable.
* @returns the absolute launcher path to probe and exec.
*/
export function launcherPath(
resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve,
): string {
const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`
try {
return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN)
} catch {
// Unresolvable platform package: no such package exists for this host, or
// it was not installed. Fall back to the path pnpm's layout WOULD use —
// absolute, inside this package's boundary (never cwd-relative: a
// spawnable relative path here would hand cwd control over which binary
// confines), and nonexistent exactly when the package is absent.
return fileURLToPath(new URL(`../node_modules/${platformPackage}/bin/${LAUNCHER_BIN}`, import.meta.url))
}
}
/**
* The launcher grant arguments for one set of filesystem grants — everything
* before the `--` argv separator. A caller spawns
* `[launcherPath(), ...grantArgs(grants), '--', ...command]`; the flag
* spellings stay private to this package.
* @param grants - the read-only and read-write roots to allow.
* @returns the `--ro <path>` / `--rw <path>` argument list, read-only roots
* first, in the caller's order.
*/
export function grantArgs(grants: LauncherGrants): string[] {
return [
...(grants.readOnly ?? []).flatMap(root => ['--ro', root]),
...(grants.readWrite ?? []).flatMap(root => ['--rw', root]),
]
}
/**
* Functional probe: `landlock-run --probe` builds and enforces a maximal
* ruleset in a short-lived child and exits 0 only when the running kernel
* actually enforces it — `--version`-style checks would miss a kernel that
* has the syscalls but refuses enforcement. The probe's one report line is
* part of the CLI contract and distinguishes complete from per-ABI-subset
* enforcement; a zero exit without the partial marker reads as `full`. A
* failed or timed-out spawn (missing binary, wrong architecture, unenforcing
* kernel) probes `unusable`. Synchronous by design: consumers run it once
* and cache the verdict.
* @param launcher - the launcher path to probe; defaults to
* {@link launcherPath}'s resolution for this host.
* @param options - `timeoutMs` bounds the probe child (default 2000).
* @returns the enforcement verdict for this host.
*/
export function probe(
launcher: string = launcherPath(),
options: { timeoutMs?: number } = {},
): LandlockEnforcement {
const result = spawnSync(launcher, ['--probe'], {
timeout: options.timeoutMs ?? 2000,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
if (result.status !== 0) return 'unusable'
return /partially enforced/.test(result.stdout) ? 'partial' : 'full'
}
@@ -0,0 +1,301 @@
/*
* landlock-run: self-restrict-then-exec Landlock launcher.
*
* The Landlock rung of a consuming sandbox seam, for Linux hosts where
* `bwrap` is
* unusable (not installed, unprivileged user namespaces disabled, or an LSM
* profile that denies mount — Landlock is an independent syscall family and
* needs none of those). The launcher installs a Landlock
* ruleset on itself and `exec`s the wrapped command; the ruleset is inherited
* across `execve`, so the command (and every process it spawns) runs confined
* while the invoking process stays unrestricted.
*
* CLI contract (mirrors the `bwrap` runner argv shape the executor wraps):
*
* landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
* landlock-run --probe
*
* `--ro` grants read+execute beneath the path; `--rw` grants full filesystem
* access beneath the path. Everything else is denied (Landlock is an
* allow-list). `--probe` builds a maximal ruleset and reports whether the
* running kernel actually enforces it — the executor's functional probe.
*
* Fail-closed: if the ruleset cannot be created or is NOT enforced by the
* kernel, the launcher exits non-zero WITHOUT exec'ing the command. A partial
* (best-effort) enforcement on an older ABI is accepted and reported on
* stderr; the consumer's mode vocabulary keeps its file-effect promises
* honest per ABI level (surfaced as `full` vs `partial` by the entry
* package's probe).
*
* Plain C11 over the raw Landlock UAPI — no libraries beyond libc (musl,
* linked statically), so the whole audit surface is this file plus the
* kernel's stable syscall contract. Built natively per architecture by
* `scripts/build.ts` into the per-platform npm packages
* (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar,
* exit codes, and report lines are pinned in `docs/cli-contract.md`.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
/*
* The Landlock UAPI, defined locally instead of via <linux/landlock.h>: the
* kernel's user-space ABI is stable by contract, self-defining it keeps the
* build independent of the toolchain's header vintage, and the definitions
* double as the audit record of exactly which kernel surface this launcher
* touches. Layouts and values are verbatim from the kernel header (the
* path-beneath struct is packed there, so it must be packed here).
*/
struct landlock_ruleset_attr {
uint64_t handled_access_fs;
};
struct landlock_path_beneath_attr {
uint64_t allowed_access;
int32_t parent_fd;
} __attribute__((packed));
#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
#define LANDLOCK_RULE_PATH_BENEATH 1
/* Filesystem access bits, grouped by the Landlock ABI that introduced them. */
#define LL_FS_EXECUTE (UINT64_C(1) << 0) /* ABI 1 */
#define LL_FS_WRITE_FILE (UINT64_C(1) << 1)
#define LL_FS_READ_FILE (UINT64_C(1) << 2)
#define LL_FS_READ_DIR (UINT64_C(1) << 3)
#define LL_FS_REMOVE_DIR (UINT64_C(1) << 4)
#define LL_FS_REMOVE_FILE (UINT64_C(1) << 5)
#define LL_FS_MAKE_CHAR (UINT64_C(1) << 6)
#define LL_FS_MAKE_DIR (UINT64_C(1) << 7)
#define LL_FS_MAKE_REG (UINT64_C(1) << 8)
#define LL_FS_MAKE_SOCK (UINT64_C(1) << 9)
#define LL_FS_MAKE_FIFO (UINT64_C(1) << 10)
#define LL_FS_MAKE_BLOCK (UINT64_C(1) << 11)
#define LL_FS_MAKE_SYM (UINT64_C(1) << 12)
#define LL_FS_REFER (UINT64_C(1) << 13) /* ABI 2 */
#define LL_FS_TRUNCATE (UINT64_C(1) << 14) /* ABI 3 (ABI 4 added TCP bits only) */
#define LL_FS_IOCTL_DEV (UINT64_C(1) << 15) /* ABI 5 */
#define LL_ABI1_MASK (LL_FS_REFER - 1) /* bits 0..12: every ABI-1 access, nothing newer */
/*
* Newest ABI this build knows; the negotiation below scales the actual
* ruleset down to what the running kernel supports (the best-effort compat
* stance of the previous Rust launcher, made explicit).
*/
#define MAX_ABI 5L
/*
* Landlock has no libc wrappers; these are the raw syscalls. The numbers are
* identical on every architecture (the post-2011 unified table) — the
* fallbacks only matter to a libc older than the feature.
*/
#ifndef __NR_landlock_create_ruleset
#define __NR_landlock_create_ruleset 444
#define __NR_landlock_add_rule 445
#define __NR_landlock_restrict_self 446
#endif
/*
* Every fatal launcher error prints `landlock-run: <message>` to stderr
* and exits 125 — a code the wrapped command itself is unlikely to use, so
* the executor can tell launcher failures from command failures.
*/
#define EXIT_LAUNCHER_FAILURE 125
static const char NOT_ENFORCED_MESSAGE[] =
"landlock is not enforced by this kernel (ABI unsupported or disabled)";
/* Print one fatal `landlock-run: ...` line; returns the fatal exit code. */
static int fail(const char *prefix, const char *detail) {
if (detail == NULL) {
fprintf(stderr, "landlock-run: %s\n", prefix);
} else {
fprintf(stderr, "landlock-run: %s: %s\n", prefix, detail);
}
return EXIT_LAUNCHER_FAILURE;
}
static int fail_usage(const char *message, const char *detail) {
fprintf(stderr, "landlock-run: usage error: %s%s\n", message, detail == NULL ? "" : detail);
return EXIT_LAUNCHER_FAILURE;
}
/* Parsed CLI: either a probe, or grants plus the command argv after `--`. */
struct cli {
int probe;
const char **ro;
size_t ro_count;
const char **rw;
size_t rw_count;
char **command; /* NULL-terminated tail of main's argv */
};
/*
* Hand-rolled argv parsing — four flags do not justify a parsing library,
* and the previous Rust launcher made the same call for the same reason.
* Returns 0 on success, else the process exit code (message already printed).
*/
static int parse(int argc, char **argv, struct cli *cli) {
/* argc bounds each grant list; the launcher execs or exits, so no free. */
cli->ro = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->ro);
cli->rw = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->rw);
if (cli->ro == NULL || cli->rw == NULL) return fail("out of memory", NULL);
int index = 1;
while (index < argc) {
const char *arg = argv[index];
if (strcmp(arg, "--probe") == 0) {
if (argc != 2) {
return fail_usage("--probe takes no other arguments", NULL);
}
cli->probe = 1;
index += 1;
} else if (strcmp(arg, "--ro") == 0 || strcmp(arg, "--rw") == 0) {
if (index + 1 >= argc) {
return fail_usage(arg, " requires a path");
}
if (strcmp(arg, "--ro") == 0) {
cli->ro[cli->ro_count++] = argv[index + 1];
} else {
cli->rw[cli->rw_count++] = argv[index + 1];
}
index += 2;
} else if (strcmp(arg, "--") == 0) {
cli->command = &argv[index + 1];
break;
} else {
return fail_usage("unknown argument: ", arg);
}
}
if (!cli->probe && (cli->command == NULL || cli->command[0] == NULL)) {
return fail_usage("missing `-- <argv>...` command", NULL);
}
return 0;
}
/* The filesystem accesses the running kernel's ABI can govern. */
static uint64_t fs_mask_for_abi(long abi) {
uint64_t mask = LL_ABI1_MASK;
if (abi >= 2) mask |= LL_FS_REFER;
if (abi >= 3) mask |= LL_FS_TRUNCATE;
if (abi >= 5) mask |= LL_FS_IOCTL_DEV;
return mask;
}
/* Add one path-beneath rule; 0 on success, else the exit code. */
static int add_rule(int ruleset_fd, const char *path, uint64_t access) {
int path_fd = open(path, O_PATH | O_CLOEXEC);
if (path_fd < 0) {
/* Fail closed on an unopenable grant root: silently narrowing the
* granted set would be safe, but running with a profile the caller did
* not get is not worth the ambiguity. */
fprintf(stderr, "landlock-run: cannot open rule path: %s: %s\n", path, strerror(errno));
return EXIT_LAUNCHER_FAILURE;
}
/* The kernel rejects directory-only accesses on a non-directory rule
* (EINVAL), so a file grant keeps only the file-compatible bits — how the
* `--rw /dev/null` grant works. Same clamp the Rust crate's
* path_beneath_rules helper applied. */
struct stat st;
if (fstat(path_fd, &st) == 0 && !S_ISDIR(st.st_mode)) {
access &= LL_FS_EXECUTE | LL_FS_WRITE_FILE | LL_FS_READ_FILE | LL_FS_TRUNCATE | LL_FS_IOCTL_DEV;
}
struct landlock_path_beneath_attr attr = { .allowed_access = access, .parent_fd = path_fd };
if (syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &attr, 0) != 0) {
int saved = errno;
close(path_fd);
return fail("landlock ruleset error", strerror(saved));
}
close(path_fd);
return 0;
}
/*
* Install the ruleset on the current thread, negotiating the kernel's ABI
* down from MAX_ABI. `--ro` paths get the read side of the vocabulary (read
* file/dir + execute — the wrapped `bash` and everything it spawns must
* remain executable); `--rw` paths get every filesystem access the
* negotiated ABI can grant. Sets `no_new_privs` first (mandatory for an
* unprivileged restrict, and it neutralizes setuid/setgid escalation inside
* the sandbox). On success `*partial` reports whether the kernel governs
* only a subset of MAX_ABI's accesses. Returns 0, else the exit code.
*/
static int restrict_self(const struct cli *cli, int *partial) {
long abi = syscall(__NR_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) {
/* ENOSYS: kernel built without Landlock; EOPNOTSUPP: built but disabled.
* Either way: not enforceable — fail CLOSED, never exec unconfined. */
return fail(NOT_ENFORCED_MESSAGE, NULL);
}
*partial = abi < MAX_ABI;
uint64_t handled = fs_mask_for_abi(abi < MAX_ABI ? abi : MAX_ABI);
struct landlock_ruleset_attr attr = { .handled_access_fs = handled };
int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof attr, 0);
if (ruleset_fd < 0) return fail("landlock ruleset error", strerror(errno));
const uint64_t read_side = LL_FS_EXECUTE | LL_FS_READ_FILE | LL_FS_READ_DIR;
for (size_t i = 0; i < cli->ro_count; i++) {
int code = add_rule(ruleset_fd, cli->ro[i], read_side & handled);
if (code != 0) return code;
}
for (size_t i = 0; i < cli->rw_count; i++) {
int code = add_rule(ruleset_fd, cli->rw[i], handled);
if (code != 0) return code;
}
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
return fail("landlock ruleset error", strerror(errno));
}
if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {
return fail("landlock ruleset error", strerror(errno));
}
close(ruleset_fd);
return 0;
}
int main(int argc, char **argv) {
struct cli cli = { 0 };
int code = parse(argc, argv, &cli);
if (code != 0) return code;
if (cli.probe) {
/* The functional probe: build and enforce a maximal ruleset in THIS
* short-lived process (the probe run exits right after). `--version`
* style checks would miss a kernel that has the syscalls but refuses
* enforcement; actually restricting is the only honest signal. The one
* report line is part of the launcher CLI contract — the executor reads
* enforcement completeness from it. */
static const char *probe_root = "/";
struct cli probe = { .ro = &probe_root, .ro_count = 1 };
int partial = 0;
code = restrict_self(&probe, &partial);
if (code != 0) return code;
printf("landlock: %s\n", partial ? "partially enforced (older ABI)" : "fully enforced");
return 0;
}
int partial = 0;
code = restrict_self(&cli, &partial);
if (code != 0) return code;
if (partial) {
/* Older ABI: some handled accesses are not governed (e.g. truncate
* before ABI 3). Still confined for everything the kernel supports —
* report, do not refuse. */
fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n");
}
execvp(cli.command[0], cli.command);
/* exec only returns on failure. */
return fail("exec failed", strerror(errno));
}
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "lib",
"rootDir": "src",
"tsBuildInfoFile": "lib/.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, node-addon-landlock-run contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,7 @@
# node-addon-landlock-run-linux-arm64
Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported.
The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name.
Sibling: `node-addon-landlock-run-linux-x64`.
@@ -0,0 +1,26 @@
{
"name": "node-addon-landlock-run-linux-arm64",
"version": "0.0.1",
"description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported",
"os": [
"linux"
],
"cpu": [
"arm64"
],
"files": [
"README.md",
"bin/",
"prebuilds.json"
],
"scripts": {
"prepack": "node ../../scripts/verify-launcher-binary.mjs"
},
"engines": {
"node": ">=20"
},
"license": "BSD-3-Clause",
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,10 @@
{
"platform": "linux-arm64",
"binaries": [
{
"tool": "landlock-run",
"kind": "static-musl",
"path": "bin/landlock-run"
}
]
}
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, node-addon-landlock-run contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,7 @@
# node-addon-landlock-run-linux-x64
Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported.
The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name.
Sibling: `node-addon-landlock-run-linux-arm64`.
@@ -0,0 +1,26 @@
{
"name": "node-addon-landlock-run-linux-x64",
"version": "0.0.1",
"description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported",
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"README.md",
"bin/",
"prebuilds.json"
],
"scripts": {
"prepack": "node ../../scripts/verify-launcher-binary.mjs"
},
"engines": {
"node": ">=20"
},
"license": "BSD-3-Clause",
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,10 @@
{
"platform": "linux-x64",
"binaries": [
{
"tool": "landlock-run",
"kind": "static-musl",
"path": "bin/landlock-run"
}
]
}
+345
View File
@@ -0,0 +1,345 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^24.10.0
version: 24.13.2
node-addon-landlock-run:
specifier: workspace:*
version: link:packages/entry
tsx:
specifier: ^4.20.6
version: 4.23.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/entry:
optionalDependencies:
node-addon-landlock-run-linux-arm64:
specifier: workspace:*
version: link:../linux-arm64
node-addon-landlock-run-linux-x64:
specifier: workspace:*
version: link:../linux-x64
packages/linux-arm64: {}
packages/linux-x64: {}
packages:
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@24.13.2':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
tsx@4.23.0:
resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
snapshots:
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
'@types/node@24.13.2':
dependencies:
undici-types: 7.18.2
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
fsevents@2.3.3:
optional: true
tsx@4.23.0:
dependencies:
esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@7.18.2: {}
+8
View File
@@ -0,0 +1,8 @@
packages:
- packages/*
# pnpm 10+ blocks any dependency shipping an install/build script until it is
# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native
# binary) genuinely needs its script.
allowBuilds:
esbuild: true
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Assemble downloaded release artifacts into the platform packages and
* verify the result. The Release workflow's build legs upload one
* `prebuild-<package>` artifact per platform package (its `bin/` payload);
* this script copies each into `packages/<package>/bin/` and then checks
* every declared binary for presence and ELF architecture.
*
* Usage: `node scripts/assemble-prebuilds.mjs <artifact-root>`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { platformDirs, root, verifyPlatformBinaries } from './repo.mjs';
const artifactRoot = path.resolve(process.argv[2] || '.release/prebuild-artifacts');
if (!fs.existsSync(artifactRoot)) {
throw new Error(`prebuild artifact directory does not exist: ${artifactRoot}`);
}
const platforms = platformDirs().map((dir) => path.basename(dir));
for (const name of platforms) {
const binDir = path.join(root, 'packages', name, 'bin');
fs.rmSync(binDir, { recursive: true, force: true });
fs.mkdirSync(binDir, { recursive: true });
}
for (const artifactName of fs.readdirSync(artifactRoot)) {
const artifactDir = path.join(artifactRoot, artifactName);
if (!fs.statSync(artifactDir).isDirectory()) continue;
const name = platforms.find((candidate) => artifactName === `prebuild-${candidate}`);
if (!name) {
throw new Error(`cannot map artifact to a platform package: ${artifactName}`);
}
for (const file of fs.readdirSync(artifactDir)) {
const source = path.join(artifactDir, file);
const destination = path.join(root, 'packages', name, 'bin', file);
fs.copyFileSync(source, destination);
fs.chmodSync(destination, 0o755);
console.log(`Copied ${path.relative(root, source)} -> ${path.relative(root, destination)}`);
}
}
for (const dir of platformDirs()) {
const { name, count } = verifyPlatformBinaries(path.join(root, dir));
console.log(`Verified ${name}: ${count} binaries`);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Build every native tool this host can build, into its per-platform
* package.
*
* Targets are derived from the checked-in matrix: each
* `packages/<name>/prebuilds.json` whose `platform` matches this host names
* the binaries to produce; the TOOLS table below maps each `tool` to its C
* source. Builds are NATIVE-ONLY — each Linux architecture compiles its own
* binary with the distro's `musl-gcc` (static musl: runs on glibc and musl
* distros alike, no loader or libc expectations on the consumer host), and
* CI's per-arch runners are the builders of record. No cross toolchain
* exists here on purpose: native runners replace it, and the audit surface
* is the reviewed C source plus CI provenance.
*
* Binaries land in `packages/<name>/bin/` — git-ignored (root
* `.gitignore`), packed into the platform package's npm tarball behind its
* `prepack` gate (`scripts/verify-launcher-binary.mjs`).
*
* Run: `pnpm run build:native` (Linux with musl-gcc on PATH:
* `apt-get install musl-tools`). Non-Linux hosts fail fast — no platform
* package exists for them to build.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
/** Each native tool's C source, keyed by the `tool` field in prebuilds.json. */
const TOOLS: Record<string, { source: string }> = {
'landlock-run': { source: 'packages/entry/src/main.c' },
}
const repoRoot = resolve(import.meta.dirname, '..')
if (process.platform !== 'linux') {
console.error(`build: native tools are built natively per Linux architecture (no cross toolchain) — nothing to build on ${process.platform}. CI's per-arch runners build and rehearse every platform package.`)
process.exit(1)
}
const hostPlatform = `linux-${process.arch}`
/** This host's platform packages, from the checked-in matrix. */
const targets: { packageDir: string; tool: string; binaryPath: string; kind: string }[] = []
const packagesRoot = join(repoRoot, 'packages')
for (const name of readdirSync(packagesRoot).sort()) {
const prebuildsFile = join(packagesRoot, name, 'prebuilds.json')
if (!existsSync(prebuildsFile)) continue
const prebuilds = JSON.parse(readFileSync(prebuildsFile, 'utf8')) as {
platform: string
binaries: { tool: string; kind: string; path: string }[]
}
if (prebuilds.platform !== hostPlatform) continue
for (const binary of prebuilds.binaries) {
targets.push({ packageDir: join(packagesRoot, name), tool: binary.tool, binaryPath: binary.path, kind: binary.kind })
}
}
if (targets.length === 0) {
console.error(`build: no platform package declares binaries for ${hostPlatform} — supported platforms are the packages/*/prebuilds.json "platform" values.`)
process.exit(1)
}
for (const target of targets) {
const tool = TOOLS[target.tool]
if (tool === undefined) {
console.error(`build: prebuilds.json names unknown tool "${target.tool}" — add it to the TOOLS table in scripts/build.ts.`)
process.exit(1)
}
if (target.kind !== 'static-musl') {
console.error(`build: unknown binary kind "${target.kind}" — the only toolchain here is static musl.`)
process.exit(1)
}
const binary = join(target.packageDir, target.binaryPath)
mkdirSync(dirname(binary), { recursive: true })
// -static against musl: self-contained, no loader/libc expectations on the
// consumer host. -Werror is safe to keep hard: CI pins the builder images,
// and a new warning on a toolchain bump deserves a look, not a pass.
const result = spawnSync('musl-gcc', [
'-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s',
'-o', binary, join(repoRoot, tool.source),
], { stdio: ['ignore', 'inherit', 'inherit'] })
if (result.error !== undefined || result.status !== 0) {
console.error('build: musl-gcc failed' +
(result.error ? ` (${result.error.message} — is musl-tools installed?)` : ''))
process.exit(1)
}
console.log(`build: built ${basename(target.packageDir)}/${target.binaryPath}`)
}
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Bump every package (workspace root + packages/*) to one version, refresh
* the lockfile, and verify. Usage: `pnpm release:bump <major|minor|patch|x.y.z>`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { packageDirs, readJson, root } from './repo.mjs';
const bump = process.argv[2];
const releaseTypes = new Set(['major', 'minor', 'patch']);
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function run(command, args) {
const result = spawnSync(command, args, {
cwd: root,
stdio: 'inherit',
env: { ...process.env, CI: 'true' },
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function packageFiles() {
return ['package.json', ...packageDirs().map((dir) => path.join(dir, 'package.json'))];
}
function parseVersion(version) {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
if (!match) {
throw new Error(`increment types need a plain x.y.z current version (current: ${version}) — pass an explicit target version instead`);
}
return match.slice(1).map((part) => Number(part));
}
/** Explicit target versions accept full semver, prereleases included (test publishes). */
const EXPLICIT_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
function nextVersion(current, release) {
if (EXPLICIT_VERSION.test(release)) return release;
if (!releaseTypes.has(release)) {
throw new Error('Usage: pnpm release:bump <major|minor|patch|x.y.z>');
}
const [major, minor, patch] = parseVersion(current);
if (release === 'major') return `${major + 1}.0.0`;
if (release === 'minor') return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function currentPublishedVersion(files) {
const versions = new Set(
files
.filter((file) => file.startsWith('packages/'))
.map((file) => readJson(path.join(root, file)).version),
);
if (versions.size !== 1) {
throw new Error(`published package versions differ: ${[...versions].join(', ')}`);
}
return [...versions][0];
}
if (!bump) {
console.error('Usage: pnpm release:bump <major|minor|patch|x.y.z>');
process.exit(1);
}
const files = packageFiles();
const targetVersion = nextVersion(currentPublishedVersion(files), bump);
for (const file of files) {
const fullPath = path.join(root, file);
const json = readJson(fullPath);
json.version = targetVersion;
writeJson(fullPath, json);
console.log(`${file}: ${targetVersion}`);
}
run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']);
run('node', ['./scripts/verify-release.mjs']);
console.log(`Release version bumped to ${targetVersion}`);
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Bump, stage, and commit a release in one command:
* `pnpm release:commit <major|minor|patch|x.y.z>`. The tag stays manual —
* create it from the merged release commit.
*/
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { packageDirs, readJson, root } from './repo.mjs';
const bump = process.argv[2];
function run(command, args) {
const result = spawnSync(command, args, {
cwd: root,
stdio: 'inherit',
env: { ...process.env, CI: 'true' },
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
if (!bump) {
console.error('Usage: pnpm release:commit <major|minor|patch|x.y.z>');
process.exit(1);
}
run('node', ['./scripts/bump-release.mjs', bump]);
const version = readJson(path.join(root, packageDirs()[0], 'package.json')).version;
run('git', [
'add',
'package.json',
'packages/*/package.json',
'pnpm-lock.yaml',
]);
run('git', ['commit', '-m', `release: ${version}`]);
console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`);
@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Derive the GitHub Actions matrices from the checked-in package matrix
* (`packages/<name>/prebuilds.json`). Single source: adding a platform
* package extends CI and Release without editing a workflow.
*
* node scripts/github-matrix.mjs ci → one leg per distinct platform
* node scripts/github-matrix.mjs release-prebuild → one leg per platform package
*/
import path from 'node:path';
import { platformDirs, readJson, root } from './repo.mjs';
/** GitHub runner per prebuilds.json `platform` value — native builders only, no cross toolchain. */
const RUNNERS = {
'linux-x64': 'ubuntu-24.04',
'linux-arm64': 'ubuntu-24.04-arm',
};
function runnerFor(platform) {
const runner = RUNNERS[platform];
if (!runner) {
throw new Error(`missing GitHub runner for platform: ${platform}`);
}
return runner;
}
function platformManifests() {
return platformDirs().map((dir) => ({
dir,
name: path.basename(dir),
prebuilds: readJson(path.join(root, dir, 'prebuilds.json')),
}));
}
function ciMatrix() {
const platforms = [...new Set(platformManifests().map(({ prebuilds }) => prebuilds.platform))].sort();
return {
include: platforms.map((platform) => ({ platform, runner: runnerFor(platform) })),
};
}
function releasePrebuildMatrix() {
return {
include: platformManifests().map(({ dir, name, prebuilds }) => ({
platform: prebuilds.platform,
package: name,
dir,
runner: runnerFor(prebuilds.platform),
artifact: `prebuild-${name}`,
})),
};
}
const target = process.argv[2];
const matrices = {
ci: ciMatrix,
'release-prebuild': releasePrebuildMatrix,
};
if (!target || !matrices[target]) {
console.error(`Usage: node scripts/github-matrix.mjs <${Object.keys(matrices).join('|')}>`);
process.exit(1);
}
process.stdout.write(JSON.stringify(matrices[target]()));
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Pack every published package into release tarballs, in publish order
* (platform packages first, then the entries that optionally depend on
* them), and write `publish-order.txt` next to them. `pnpm pack` produces
* the EXACT bytes `pnpm publish` would upload and runs each package's
* `prepack` gate, so a missing binary or unbuilt `lib/` refuses here.
*
* Usage: `node scripts/pack-release.mjs [dest] [--current-platform-only]`.
* The flag packs only THIS host's platform package plus the entries — for
* per-architecture CI legs, where the other architecture's binary does not
* exist (the exact refusal its prepack gate exists for).
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { entryDirs, platformDirs, readJson, root } from './repo.mjs';
const args = process.argv.slice(2);
const currentPlatformOnly = args.includes('--current-platform-only');
const destination = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm'));
function hostPlatformDirs() {
const hostPlatform = `${process.platform}-${process.arch}`;
return platformDirs().filter((dir) => readJson(path.join(root, dir, 'prebuilds.json')).platform === hostPlatform);
}
function run(command, args) {
const result = spawnSync(command, args, {
cwd: root,
stdio: 'inherit',
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function tarballName(manifest) {
if (manifest.name.startsWith('@')) {
return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`;
}
return `${manifest.name}-${manifest.version}.tgz`;
}
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
const dirs = [...(currentPlatformOnly ? hostPlatformDirs() : platformDirs()), ...entryDirs()];
const platformSet = new Set(platformDirs());
const publishOrder = [];
for (const dir of dirs) {
const manifest = readJson(path.join(root, dir, 'package.json'));
// Platform packages are packed with npm: pnpm pack (observed on 11.7.0)
// normalizes file modes and STRIPS the executable bit, which ships a
// launcher no consumer can spawn; npm pack preserves it. Platform packages
// have no dependencies by construction, so they need none of pnpm's
// workspace-protocol conversion — the entry packages do, and carry no
// executables, so they keep pnpm pack.
if (platformSet.has(dir)) {
run('npm', ['pack', `./${dir}`, '--pack-destination', destination]);
} else {
run('pnpm', ['--dir', dir, 'pack', '--pack-destination', destination]);
}
const tarball = tarballName(manifest);
const tarballPath = path.join(destination, tarball);
if (!fs.existsSync(tarballPath)) {
throw new Error(`expected pack output not found: ${tarballPath}`);
}
publishOrder.push(tarball);
}
fs.writeFileSync(path.join(destination, 'publish-order.txt'), `${publishOrder.join('\n')}\n`);
console.log(`Packed ${publishOrder.length} packages into ${path.relative(root, destination)}`);
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Shared helpers for the repo scripts: package discovery, the checked-in
* prebuild matrix, and binary verification. The package matrix is explicit
* metadata — `packages/<name>/prebuilds.json` marks a platform package and
* declares its binaries; everything else under `packages/` is an entry
* package. Scripts derive from these files and never guess.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const root = fileURLToPath(new URL('..', import.meta.url));
export const packagesRoot = path.join(root, 'packages');
/** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */
export const E_MACHINE = { x64: 62, arm64: 183 };
export function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/** Platform packages: every `packages/<name>` carrying a `prebuilds.json`. */
export function platformDirs() {
return fs.readdirSync(packagesRoot)
.filter((name) => fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json')))
.sort()
.map((name) => path.join('packages', name));
}
/** Entry packages: every other `packages/<name>` with a `package.json`. */
export function entryDirs() {
return fs.readdirSync(packagesRoot)
.filter((name) => !fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json')))
.filter((name) => fs.existsSync(path.join(packagesRoot, name, 'package.json')))
.sort()
.map((name) => path.join('packages', name));
}
/** All published packages in publish order: platform packages before the entries that optionally depend on them. */
export function packageDirs() {
return [...platformDirs(), ...entryDirs()];
}
/**
* Verify one platform package's binaries against its `prebuilds.json`:
* every declared binary exists, nothing undeclared sits in `bin/`, and each
* file's ELF `e_machine` matches the package's declared `cpu`. Throws with
* a remediation message on the first mismatch.
*/
export function verifyPlatformBinaries(packageDir) {
const manifest = readJson(path.join(packageDir, 'package.json'));
const prebuilds = readJson(path.join(packageDir, 'prebuilds.json'));
const cpu = manifest.cpu?.[0];
if (cpu === undefined || !(cpu in E_MACHINE)) {
throw new Error(`${manifest.name}: unsupported or missing "cpu" in package.json (expected one of: ${Object.keys(E_MACHINE).join(', ')})`);
}
for (const binary of prebuilds.binaries) {
const file = path.join(packageDir, binary.path);
if (!fs.existsSync(file)) {
throw new Error(`${manifest.name}: missing ${binary.path} — run \`pnpm build:native\` on a ${prebuilds.platform} host (or assemble release artifacts) before packing.`);
}
try {
fs.accessSync(file, fs.constants.X_OK);
} catch {
// Only reachable when the mode was mangled somewhere between build and
// here (e.g. an archive step that normalized permissions) — the build
// itself always produces 755.
throw new Error(`${manifest.name}: ${binary.path} is not executable — a pack/extract step stripped the mode bit.`);
}
const machine = fs.readFileSync(file).readUInt16LE(18);
if (machine !== E_MACHINE[cpu]) {
throw new Error(`${manifest.name}: ${binary.path} has ELF e_machine ${machine}, expected ${E_MACHINE[cpu]} for ${cpu} — the binary was built for a different architecture.`);
}
}
const declared = prebuilds.binaries.map((binary) => path.basename(binary.path)).sort();
const binDir = path.join(packageDir, 'bin');
const actual = fs.existsSync(binDir) ? fs.readdirSync(binDir).sort() : [];
const extra = actual.filter((name) => !declared.includes(name));
if (extra.length) {
throw new Error(`${manifest.name}: bin/ contains files not declared in prebuilds.json: ${extra.join(', ')}`);
}
return { name: manifest.name, count: prebuilds.binaries.length };
}
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/**
* Prepack gate for entry packages: refuse to pack a tarball whose built
* `lib/` is missing. Entry `files` lists use globs, and a glob matching
* nothing packs a silently JS-less tarball instead of failing — this gate
* turns that into a loud refusal on a checkout that never ran
* `pnpm build:ts`.
*
* Runs from each entry package's `prepack` hook (pnpm sets the script cwd
* to the package directory).
*/
import fs from 'node:fs';
import path from 'node:path';
const packageDir = process.cwd();
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
for (const file of ['lib/index.js', 'lib/index.d.ts']) {
if (!fs.existsSync(path.join(packageDir, file))) {
console.error(`verify-entry-lib: ${manifest.name} has no ${file} — run \`pnpm build:ts\` before packing.`);
process.exit(1);
}
}
console.log(`verify-entry-lib: ${manifest.name} built lib/ present.`);
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Prepack gate for platform packages: refuse to pack a tarball whose
* declared binaries are missing or built for the wrong architecture.
*
* Without it, `pnpm pack` on a checkout that never ran
* `pnpm run build:native` would ship an EMPTY platform package — the
* binary's absence surfacing only at runtime as a failed probe on every
* consumer — and a binary copied across packages would advertise an
* architecture it cannot execute. The check is presence + ELF `e_machine`
* against the package's declared `cpu`; byte provenance is
* `verify-packed-install.mjs`'s concern (it pins the installed tarball
* against the workspace build).
*
* Runs from each platform package's `prepack` hook (pnpm sets the script
* cwd to the package directory). Also callable directly with an explicit
* package directory: `node scripts/verify-launcher-binary.mjs packages/<name>`.
*/
import path from 'node:path';
import { root, verifyPlatformBinaries } from './repo.mjs';
const packageDir = process.argv[2] ? path.resolve(root, process.argv[2]) : process.cwd();
try {
const { name, count } = verifyPlatformBinaries(packageDir);
console.log(`verify-launcher-binary: ${name}${count} binaries present with the right ELF architecture.`);
} catch (error) {
console.error(`verify-launcher-binary: ${error instanceof Error ? error.message : error}`);
process.exit(1);
}
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* Publish-path rehearsal without publishing: verify the packed tarballs are
* exactly what a consumer install needs. `pnpm pack` already produced the
* bytes `pnpm publish` would upload; this script checks the payload
* (coverage, concrete dependency versions, NO lifecycle install scripts —
* this family has no install fallback on purpose), unpacks the entry plus
* THIS host's platform tarball into a throwaway consumer OUTSIDE the repo,
* byte-pins the installed binary against the workspace build it was packed
* from, and drives the INSTALLED entry under plain `node` — resolution,
* probe, and a real confinement world-proof through the installed launcher.
*
* On non-Linux hosts (no platform package exists) it instead proves the
* documented degradation: resolution falls back to a nonexistent path and
* the probe reports `unusable`.
*
* Usage: `node scripts/verify-packed-install.mjs [tarball-dir] [--current-platform-only]`.
* The flag skips the all-platforms tarball-presence check for
* per-architecture CI legs. `NALR_REQUIRE_LANDLOCK=1` makes an unenforcing
* kernel a failure instead of a skipped world-proof (set on CI, where the
* kernel is known).
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs';
const args = process.argv.slice(2);
const currentPlatformOnly = args.includes('--current-platform-only');
const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm'));
const entryPackageName = 'node-addon-landlock-run';
function tarballName(manifest) {
if (manifest.name.startsWith('@')) {
return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`;
}
return `${manifest.name}-${manifest.version}.tgz`;
}
function tarballPath(manifest) {
const tarball = path.join(tarballDir, tarballName(manifest));
if (!fs.existsSync(tarball)) {
throw new Error(`missing packed tarball: ${tarball}`);
}
return tarball;
}
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, {
cwd: options.cwd || root,
stdio: 'inherit',
env: { ...process.env, ...options.env },
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runCapture(command, commandArgs) {
const result = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (result.error) throw result.error;
if (result.status !== 0) {
process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
return result.stdout;
}
function readPackedManifest(manifest) {
return JSON.parse(runCapture('tar', ['-xOf', tarballPath(manifest), 'package/package.json']));
}
function verifyPackedManifest(packed) {
const lifecycle = ['preinstall', 'install', 'postinstall', 'prepare'];
for (const script of lifecycle) {
if (packed.scripts?.[script]) {
throw new Error(`${packed.name}: packed manifest carries a "${script}" lifecycle script — this family has no install fallback`);
}
}
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, version] of Object.entries(packed[field] ?? {})) {
if (version.includes('workspace:')) {
throw new Error(`${packed.name}: packed ${field} still uses the workspace protocol: ${name}@${version}`);
}
}
}
}
function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
function packageInstallDir(packageName) {
return path.join(tempRoot, 'node_modules', ...packageName.split('/'));
}
function unpackTarball(manifest) {
const extractRoot = fs.mkdtempSync(path.join(tempRoot, 'extract-'));
run('tar', ['-xzf', tarballPath(manifest), '-C', extractRoot]);
const source = path.join(extractRoot, 'package');
const destination = packageInstallDir(manifest.name);
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.renameSync(source, destination);
fs.rmSync(extractRoot, { recursive: true, force: true });
console.log(`Unpacked ${manifest.name} -> ${path.relative(tempRoot, destination)}`);
}
const manifests = packageDirs().map((dir) => ({ dir, manifest: readJson(path.join(root, dir, 'package.json')) }));
const entryManifest = manifests.find(({ manifest }) => manifest.name === entryPackageName)?.manifest;
if (!entryManifest) throw new Error(`missing source manifest for ${entryPackageName}`);
const hostPlatform = `${process.platform}-${process.arch}`;
const currentPlatformEntry = manifests.find(
({ dir, manifest }) => platformDirs().includes(dir) && manifest.name === `${entryPackageName}-${hostPlatform}`,
);
// Payload checks: every expected tarball exists (full mode), the packed
// entry's optional-dependency set names exactly the platform packages, and
// no packed manifest carries workspace versions or install lifecycle.
const expectedTarballs = currentPlatformOnly
? manifests.filter(({ dir }) => entryDirs().includes(dir) || dir === currentPlatformEntry?.dir)
: manifests;
for (const { manifest } of expectedTarballs) {
tarballPath(manifest);
}
const packedEntry = readPackedManifest(entryManifest);
const platformPackageNames = manifests
.filter(({ dir }) => platformDirs().includes(dir))
.map(({ manifest }) => manifest.name)
.sort();
const optionalNames = Object.keys(packedEntry.optionalDependencies || {}).sort();
if (optionalNames.join('\n') !== platformPackageNames.join('\n')) {
throw new Error(`packed entry optionalDependencies mismatch\nactual:\n${optionalNames.join('\n')}\nexpected:\n${platformPackageNames.join('\n')}`);
}
for (const { manifest } of expectedTarballs) {
verifyPackedManifest(readPackedManifest(manifest));
}
// Throwaway ESM consumer, built from local tarballs only — no registry.
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-packed-install-'));
fs.writeFileSync(
path.join(tempRoot, 'package.json'),
`${JSON.stringify({ name: 'nalr-packed-install-check', version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`,
);
console.log(`Verifying packed install in ${tempRoot}`);
unpackTarball(entryManifest);
if (currentPlatformEntry) {
unpackTarball(currentPlatformEntry.manifest);
// Byte-pin: the installed binary must be the workspace build it was packed
// from — any divergence means the tarball did not carry the built bytes.
const prebuilds = readJson(path.join(root, currentPlatformEntry.dir, 'prebuilds.json'));
for (const binary of prebuilds.binaries) {
const workspaceFile = path.join(root, currentPlatformEntry.dir, binary.path);
const installedFile = path.join(packageInstallDir(currentPlatformEntry.manifest.name), binary.path);
if (sha256(workspaceFile) !== sha256(installedFile)) {
throw new Error(`installed ${binary.path} differs from the workspace build it was packed from`);
}
console.log(`Byte-pinned ${binary.path} against the workspace build`);
}
} else if (process.platform === 'linux') {
throw new Error(`linux host without a platform package in the matrix: ${hostPlatform}`);
}
// Drive the INSTALLED entry under plain node: resolution, probe, and (on an
// enforcing kernel) a real confinement world-proof through the installed
// launcher.
const driver = path.join(tempRoot, 'driver.mjs');
fs.writeFileSync(driver, `
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1';
const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch;
const resolved = launcherPath();
assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute');
assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved);
if (process.platform === 'linux') {
assert.ok(fs.existsSync(resolved), 'installed launcher missing at ' + resolved);
try {
fs.accessSync(resolved, fs.constants.X_OK);
} catch {
throw new Error('installed launcher is not executable — the pack path stripped the mode bit: ' + resolved);
}
const enforcement = probe(resolved);
console.log('probe through the installed launcher: ' + enforcement);
if (enforcement === 'unusable') {
if (requireLandlock) throw new Error('NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable');
console.log('kernel does not enforce Landlock — skipping the confinement world-proof');
} else {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-confine-'));
const denied = path.join(work, 'denied.txt');
const deniedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo x > ' + denied], { encoding: 'utf8' });
assert.notEqual(deniedRun.status, 0, 'write outside the grants must fail');
assert.ok(!fs.existsSync(denied), 'denied write must not land on disk');
const granted = path.join(work, 'granted.txt');
const grantedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', 'echo ok > ' + granted], { encoding: 'utf8' });
assert.equal(grantedRun.status, 0, 'granted write must succeed: ' + grantedRun.stderr);
assert.equal(fs.readFileSync(granted, 'utf8').trim(), 'ok');
console.log('confinement world-proof passed through the installed launcher');
}
} else {
assert.ok(!fs.existsSync(resolved), 'no platform package exists for this host — the fallback path must not exist');
assert.equal(probe(resolved), 'unusable');
console.log('non-linux host: fallback resolution and unusable probe verified');
}
`);
run(process.execPath, [driver], { cwd: tempRoot });
console.log('Packed install verification passed.');
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Release verification. Always: every published package carries one shared
* version, and — when running from a tag or publishing — the `vX.Y.Z` tag
* matches it. With `--prebuilds`: every platform package's declared
* binaries exist with the right ELF architecture (run after
* `assemble-prebuilds.mjs` or a local `build:native`).
*/
import path from 'node:path';
import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs';
function verifyVersions() {
const packages = packageDirs().map((dir) => ({
dir,
manifest: readJson(path.join(root, dir, 'package.json')),
}));
const versions = new Set(packages.map((pkg) => pkg.manifest.version));
if (versions.size !== 1) {
throw new Error([
'published package versions must match:',
...packages.map((pkg) => `${pkg.dir}: ${pkg.manifest.version}`),
].join('\n'));
}
const version = packages[0].manifest.version;
const ref = process.env.GITHUB_REF || '';
const publish = process.env.RELEASE_PUBLISH === 'true';
if (publish && !ref.startsWith('refs/tags/v')) {
throw new Error('publishing requires running the workflow from a v* tag');
}
if (ref.startsWith('refs/tags/v')) {
const tagVersion = ref.slice('refs/tags/v'.length);
if (tagVersion !== version) {
throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`);
}
}
console.log(`Verified release version ${version}`);
}
function verifyPrebuilds() {
for (const dir of platformDirs()) {
const { name, count } = verifyPlatformBinaries(path.join(root, dir));
console.log(`Verified ${name}: ${count} binaries`);
}
}
verifyVersions();
if (process.argv.includes('--prebuilds')) {
verifyPrebuilds();
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Keyless entry-package tests — run on every host, no kernel or binary
* required. Cover the JS seam's pure surface: grant-argv construction, the
* resolution contract (platform package → fallback), and probe verdicts over
* fake launchers. Requires built `lib/` (`pnpm build:ts`).
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
LAUNCHER_BIN,
LAUNCHER_FAILURE_EXIT,
grantArgs,
launcherPath,
probe,
} from 'node-addon-landlock-run';
// --- constants are part of the CLI contract ---
assert.equal(LAUNCHER_BIN, 'landlock-run');
assert.equal(LAUNCHER_FAILURE_EXIT, 125);
// --- grantArgs: flag spelling, ordering, and empty grants ---
assert.deepEqual(grantArgs({}), []);
assert.deepEqual(grantArgs({ readOnly: ['/'] }), ['--ro', '/']);
assert.deepEqual(
grantArgs({ readOnly: ['/', '/opt'], readWrite: ['/tmp/work'] }),
['--ro', '/', '--ro', '/opt', '--rw', '/tmp/work'],
);
assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']);
// --- launcherPath: resolves the platform package next to its package.json ---
const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`;
const resolvedViaSeam = launcherPath((specifier) => {
assert.equal(specifier, `${platformPackage}/package.json`);
return path.join('/fake-install', specifier);
});
assert.equal(resolvedViaSeam, path.join('/fake-install', platformPackage, 'bin', LAUNCHER_BIN));
// --- launcherPath: unresolvable package falls back to an absolute, package-boundary path ---
const fallback = launcherPath(() => {
throw new Error('not installed');
});
assert.ok(path.isAbsolute(fallback), 'fallback path must be absolute');
assert.ok(
fallback.includes(path.join('node_modules', ...platformPackage.split('/'), 'bin', LAUNCHER_BIN)),
`fallback must point at the platform package layout: ${fallback}`,
);
// --- launcherPath: default resolution agrees with this workspace's layout ---
const defaultPath = launcherPath();
assert.ok(path.isAbsolute(defaultPath));
assert.ok(defaultPath.endsWith(path.join('bin', LAUNCHER_BIN)), defaultPath);
// --- probe: a missing launcher is unusable, indistinguishable from an unenforcing kernel ---
assert.equal(probe(path.join(os.tmpdir(), 'nalr-no-such-launcher')), 'unusable');
// --- probe: verdict parsing over fake launchers (POSIX shells only) ---
if (process.platform !== 'win32') {
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-fake-launcher-'));
const fake = (name, script) => {
const file = path.join(fakeDir, name);
fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
return file;
};
assert.equal(probe(fake('full', 'echo "landlock: fully enforced"; exit 0')), 'full');
assert.equal(probe(fake('partial', 'echo "landlock: partially enforced (older ABI)"; exit 0')), 'partial');
assert.equal(probe(fake('failing', `exit ${LAUNCHER_FAILURE_EXIT}`)), 'unusable');
assert.equal(probe(fake('hanging', 'sleep 10'), { timeoutMs: 200 }), 'unusable');
fs.rmSync(fakeDir, { recursive: true, force: true });
}
console.log('entry.test: ok');
+127
View File
@@ -0,0 +1,127 @@
/**
* Behavioral tests against the REAL launcher binary on a real kernel: the
* CLI contract (usage errors, exit codes, argv passthrough) and the
* confinement world-proofs (denied writes stay off disk, grants land).
*
* Preconditions and their skip semantics:
* - Non-Linux host: skips entirely (exit 0) — there is nothing to build here.
* - Linux without the built binary: FAILS — run `pnpm build:native` first.
* - Linux whose kernel does not enforce Landlock: skips the enforcement
* half, unless `NALR_REQUIRE_LANDLOCK=1` (set on CI, where a silent skip on
* the very platform that exists to prove enforcement would be a false
* green).
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import {
LAUNCHER_FAILURE_EXIT,
grantArgs,
launcherPath,
probe,
} from 'node-addon-landlock-run';
const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1';
if (process.platform !== 'linux') {
console.log(`launcher.test: SKIP — the launcher only exists on linux (host: ${process.platform})`);
process.exit(0);
}
const launcher = launcherPath();
assert.ok(
fs.existsSync(launcher),
`launcher.test: no built launcher at ${launcher} — run \`pnpm build:native\` (apt-get install musl-tools) first`,
);
const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8', ...options });
// --- usage errors: parse failures exit LAUNCHER_FAILURE_EXIT before any restriction ---
{
const noCommand = run([]);
assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT);
assert.match(noCommand.stderr, /usage error: missing `-- <argv>\.\.\.` command/);
const unknownFlag = run(['--bogus', '--', 'true']);
assert.equal(unknownFlag.status, LAUNCHER_FAILURE_EXIT);
assert.match(unknownFlag.stderr, /usage error: unknown argument: --bogus/);
const danglingPath = run(['--ro']);
assert.equal(danglingPath.status, LAUNCHER_FAILURE_EXIT);
assert.match(danglingPath.stderr, /--ro requires a path/);
for (const args of [
['--probe', '--ro', '/'],
['--probe', '--'],
['--probe', '--probe'],
]) {
const probeWithExtras = run(args);
assert.equal(probeWithExtras.status, LAUNCHER_FAILURE_EXIT);
assert.match(probeWithExtras.stderr, /--probe takes no other arguments/);
}
}
// --- probe: the functional availability signal ---
const enforcement = probe(launcher);
console.log(`launcher.test: probe → ${enforcement}`);
if (enforcement === 'unusable') {
if (requireLandlock) {
console.error('launcher.test: NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable — this kernel cannot prove enforcement');
process.exit(1);
}
console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock');
process.exit(0);
}
{
const probeRun = run(['--probe']);
assert.equal(probeRun.status, 0);
assert.match(probeRun.stdout, /^landlock: (fully enforced|partially enforced \(older ABI\))\n$/);
}
// --- confined exec: the command runs, its exit code passes through ---
{
const echo = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo confined-ok']);
assert.equal(echo.status, 0, echo.stderr);
assert.equal(echo.stdout, 'confined-ok\n');
const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']);
assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged');
}
// --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec ---
{
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-launcher-test-'));
const denied = path.join(work, 'denied.txt');
const deniedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `echo x > ${denied}`]);
assert.notEqual(deniedRun.status, 0, 'a write outside the grants must fail');
assert.ok(!fs.existsSync(denied), 'the denied write must not land on disk');
const granted = path.join(work, 'granted.txt');
const grantedRun = run([...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', `echo ok > ${granted}`]);
assert.equal(grantedRun.status, 0, grantedRun.stderr);
assert.equal(fs.readFileSync(granted, 'utf8'), 'ok\n');
// The ruleset is inherited across execve: a CHILD of the wrapped command
// is confined too, not just the direct exec target.
const nested = path.join(work, 'nested.txt');
const nestedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `/bin/sh -c 'echo x > ${nested}'; true`]);
assert.equal(nestedRun.status, 0, nestedRun.stderr);
assert.ok(!fs.existsSync(nested), 'a denied write from a nested child must not land either');
fs.rmSync(work, { recursive: true, force: true });
}
// --- fail closed: an unopenable grant root refuses to exec at all ---
{
const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`);
const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]);
assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT);
assert.match(badGrant.stderr, /cannot open rule path/);
assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails');
}
console.log('launcher.test: ok');
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"files": [],
"include": ["scripts/**/*.ts"],
"references": [
{ "path": "./packages/entry" }
]
}
@@ -49,6 +49,19 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
})
it('harvests search tools without depending on the generator process PATH', async () => {
const oldPath = process.env.PATH
try {
process.env.PATH = ''
const catalog = await collectToolCatalog()
const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
} finally {
if (oldPath === undefined) delete process.env.PATH
else process.env.PATH = oldPath
}
})
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
// agents surface this one package as both `subagent` and `subagent_fork`.
+2 -2
View File
@@ -8,9 +8,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO
+10 -10
View File
@@ -1,20 +1,20 @@
# @deepseek-ai/dsh-tool-fs-search
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor, then the discovery tools.
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
## Deployment requirement: co-located bash + filesystem
## Deployment requirement: rg + co-located bash/filesystem
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
## Config
@@ -43,7 +43,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
## Model Experience
@@ -51,7 +51,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
#### What the model sees
Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
##### Glob guidance
@@ -67,7 +67,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token effect
Fixed guidance cost per request while the plugin is active.
Fixed guidance cost per request while the tools are registered.
#### KV Cache effect
@@ -77,7 +77,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Activation
#### What the model sees
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
#### Token effect
@@ -118,5 +118,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
+46 -6
View File
@@ -1,6 +1,7 @@
/**
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
* bash executor seam (`ctx.bash`). This single plugin registers both tools
* only when the mounted bash executor can find `rg` on its `PATH`.
*
* ## Bash-backed, not a `ctx.fs` provider method
*
@@ -12,9 +13,11 @@
* parsing, retention, formatted-result spill, and timeout declaration; the
* bash executor owns request defaulting/capping, subprocess execution,
* process-group termination, environment scrubbing, raw output capture, and
* backend substitution. The package injects `tools`, `systemPrompt`, and
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
* with `ctx.get()` because formatted-result spill is optional.
* backend substitution. At load, the package probes `command -v rg` through the
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
* sections are not registered. The package injects `tools`, `systemPrompt`,
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
* opportunistically with `ctx.get()` because formatted-result spill is optional.
*
* Returned paths are displayed relative to the resolved bash workdir and are
* follow-up-readable only in co-located deployments where the bash workdir and
@@ -80,6 +83,9 @@ export const Config: z<Config> = z.object({
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
@@ -87,8 +93,38 @@ function assertPositiveInteger(name: string, value: number): void {
}
}
/** Register the `glob`/`grep` filesystem discovery tool suite. */
export function apply(ctx: Context, config: Config): void {
/**
* Check whether the mounted bash executor can find `rg`.
*
* Nonzero exit means "not available" and disables this optional tool suite.
* Infrastructure failures stay loud: a deployment with a broken bash executor
* should not silently lose tools in a way that looks like a deliberate skip.
*
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
*/
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
let result
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
}
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
}
return result.exitCode === 0
}
/**
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
*
* @param ctx - plugin context; registrations are effects scoped to this plugin.
* @param config - resolved plugin configuration from schemastery.
* @returns when ripgrep is unavailable, resolves without registering any tools.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
@@ -96,6 +132,10 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
return
}
applyGlobTool(ctx, {
maxResults: resolved.globMaxResults,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
@@ -18,9 +18,48 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/**
* Deterministic bash service for this Loader guard: the test wants to exercise
* the real unwrap/inject path, not depend on whether the host image has rg.
*/
class ProbeSuccessBashExecutor extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command !== RG_PROBE_COMMAND) {
throw new Error(`unexpected command in load-path guard: ${spec.command}`)
}
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('load-path guard must not start background processes')
}
}
describe('dsh-tool-fs-search real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolFsSearch).toBe(false)
@@ -38,7 +77,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ProbeSuccessBashExecutor)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
+66 -12
View File
@@ -2,12 +2,12 @@
* Consumer-surface tests for the search tools over a FAKE bash executor and a
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
* bypasses the tool registry. The fake executor makes every seam outcome
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
* validation, shell-safe command construction, workdir derivation, signal
* forwarding, `SEARCH_*` error classification, retention, formatted-result
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
* pinned separately in integration.spec.ts.
* scriptable — registration-time `rg` probing, truncated stdout with/without a
* raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these
* tests verify schemas, argument validation, shell-safe command construction,
* workdir derivation, signal forwarding, `SEARCH_*` error classification,
* retention, formatted-result spill handoff, and the no-background-task
* invariant. Real-`rg` behavior is pinned separately in integration.spec.ts.
*/
import { describe, expect, it } from 'vitest'
@@ -31,6 +31,8 @@ import {
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** A successful run result over the given stdout; overrides script the failure shapes. */
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
return {
@@ -52,13 +54,18 @@ function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunR
* create a background task.
*/
class FakeBash extends BashExecutor {
probeRequests: BashExecRequest[] = []
probeSpecs: BashExecSpec[] = []
requests: BashExecRequest[] = []
specs: BashExecSpec[] = []
startCalls = 0
probeResult: BashRunResult = runResult('')
probeError?: Error
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
override resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request)
else this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? '/work',
@@ -68,9 +75,14 @@ class FakeBash extends BashExecutor {
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
override async run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command === RG_PROBE_COMMAND) {
this.probeSpecs.push(spec)
if (this.probeError) throw this.probeError
return this.probeResult
}
this.specs.push(spec)
return Promise.resolve(this.handler(spec))
return this.handler(spec)
}
override start(): BashProcess {
this.startCalls++
@@ -97,18 +109,36 @@ class FakeSpill extends SpillStore {
interface SetupOptions {
config?: ToolFsSearch.Config
spill?: boolean
probeError?: Error
probeResult?: BashRunResult
}
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
const bash = ctx.bash as FakeBash
if (options.probeResult) bash.probeResult = options.probeResult
if (options.probeError) bash.probeError = options.probeError
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const bash = ctx.bash as FakeBash
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber }
return { ctx, bash, spill, fiber, warnings }
}
/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */
async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise<void> {
let thrown: string | undefined
try {
const loaded = await setup(options)
await loaded.fiber.dispose()
} catch (error: unknown) {
thrown = error instanceof Error ? error.message : String(error)
}
expect(thrown).toMatch(message)
}
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
@@ -136,13 +166,37 @@ function matchLine(path: string, lineNumber: number, lineText: string): string {
describe('registration', () => {
it('registers glob and grep with their prompt sections', async () => {
const { ctx } = await setup()
const { ctx, bash } = await setup()
expect(bash.probeRequests).toHaveLength(1)
expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND)
expect(bash.probeRequests[0]).not.toHaveProperty('workdir')
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
})
it('does not register glob or grep when the bash executor cannot find rg', async () => {
const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) })
expect(ctx.tools.schemas()).toHaveLength(0)
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
expect(sections).not.toContain('tool:glob')
expect(sections).not.toContain('tool:grep')
expect(warnings).toEqual([
'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered',
])
})
it('rejects plugin load when the rg availability probe cannot run', async () => {
await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/)
})
it('rejects plugin load when the rg availability probe is aborted or killed', async () => {
await expectSetupRejects({
probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }),
}, /tool-fs-search: ripgrep availability probe did not complete/)
})
it('stays pending until ctx.bash exists (inject)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+46 -5
View File
@@ -12,6 +12,8 @@ import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -38,6 +40,44 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog.md'
const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/**
* Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search
* plugin now probes `rg` at registration time, but the generated catalog must
* remain independent of the host PATH and never execute a real search.
*/
class CatalogSearchBashExecutor extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? root,
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command !== CATALOG_RG_PROBE_COMMAND) {
throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`)
}
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('gen-tool-catalog: search schema harvest must not start background processes')
}
}
/** Register the descriptor needed to mount schema-producing consumers. */
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
@@ -169,14 +209,15 @@ const TOOL_PACKAGES: ToolPackage[] = [
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tools inject `bash` (search executes fixed `rg` commands through
// the executor seam, not ctx.fs); boot the local executor to satisfy it.
// `ctx.spillStore` is optional (read via ctx.get) and does not affect the
// schemas, so no spill backend is mounted.
await ctx.plugin(LocalBashExecutor)
// the executor seam, not ctx.fs). Use a catalog-only executor so the
// registration-time `rg` probe stays deterministic and the generator
// never depends on the host PATH. `ctx.spillStore` is optional (read via
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
await ctx.plugin(CatalogSearchBashExecutor)
await ctx.plugin(ToolFsSearch)
},
note:
'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',