@byokit/openclaw 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.2 (2026-09-30)
6
+
7
+ - Dependency update: pins @byokit/secrets 0.4.0.
8
+
9
+ - SECURITY: View-only devices can no longer resolve approvals through relay push actions.
10
+ - FIX: Existing authSeal calls using osKeyringSeal automatically pick up persistent headless sealing, including locked and unresponsive keyring fallback, without host changes.
11
+ - Document the credential, migration and sealed-approval threat model in SECURITY.md, shipped with the package.
12
+ - Run the shared engine isolation contract in npm test with an offline child; keep the pinned-engine check in test:engine.
13
+
14
+ ## 0.3.1 (2026-09-30)
15
+
16
+
17
+
18
+ - FIX: `authSeal` no longer rejects retained-login migration when engine state contains symlinks or runtime entries. File symlinks whose fully resolved targets are regular files inside the isolated engine root are sealed and restored as regular files at the link paths; outside-root, dangling and directory symlinks (including loops), sockets, FIFOs and devices are skipped without reading their contents.
19
+ - Depends on @byokit/ui-core 0.4.0.
20
+
5
21
  ## 0.3.0 (2026-09-30)
6
22
 
7
23
 
package/README.md CHANGED
@@ -171,7 +171,9 @@ person's back (memory search is never a paid provider).
171
171
  The pinned engine has no supported hook for sealing OAuth profile writes. Its `auth-profiles` loader stores
172
172
  credential JSON in agent SQLite databases and a shared state database, and doctor imports leave migration
173
173
  archives. `authSeal` therefore protects the complete isolated `state` and `home` directories, including SQLite
174
- journals. It uses the same injected `SealingAdapter` as `@byokit/secrets`; the kit creates no sealing key.
174
+ journals. It uses the injected `SealingAdapter` from `@byokit/secrets`. `osKeyringSeal()` automatically
175
+ falls back to a persistent private file key when no non-interactive keyring is available, including
176
+ locked or unresponsive Linux keyrings; existing `authSeal` calls pick up the chain unchanged.
175
177
 
176
178
  ```ts
177
179
  import { osKeyringSeal, hostKeySeal } from '@byokit/secrets';
@@ -185,7 +187,8 @@ await kit.prepare(); // seals an existing plaintext store and migration archives
185
187
  await kit.start(); // authenticates and restores the isolated files for the engine
186
188
  await kit.stop(); // waits for exit, verifies an atomic sealed snapshot, then removes plaintext
187
189
 
188
- // A headless server supplies its own 32-byte key, held outside stateDir and its backups:
190
+ // Inspect the adapter's mode for host UI: 'keyring' or 'host-key-file'.
191
+ // Hosts can also explicitly supply a 32-byte key kept outside stateDir and its backups:
189
192
  declare const hostKey: Uint8Array;
190
193
  const server = new OpenClawKit({
191
194
  stateDir: './server-state',
@@ -193,10 +196,18 @@ const server = new OpenClawKit({
193
196
  });
194
197
  ```
195
198
 
199
+ The automatic key lives in the platform state directory, separate from the engine store. A key
200
+ on the same disk protects copied stores/backups only when the key directory is excluded; it does
201
+ not protect against code running as the same OS user. See [secrets' rotation and threat model](../secrets/README.md#servers-and-headless-node).
202
+ Stop the kit, acquire the host writer lock and call `seal.rotate()` with `auth-store.sealed` plus
203
+ every retained sealed migration archive before retiring an automatic key.
204
+
196
205
  Without `authSeal`, engine credentials remain plaintext. With it, successful `prepare()` and `stop()` leave
197
206
  only a sealed snapshot, `auth-store.sealed`, for those directories. The adapter authenticates the snapshot
198
207
  before any restoration; a wrong key, tampering, or a missing adapter rejects. Files restored for the engine
199
- have mode 0600 and directories 0700. Symlinks and special files inside the sealed directories are refused.
208
+ have mode 0600 and directories 0700. File symlinks are sealed only when their fully resolved targets are
209
+ regular files inside the isolated engine root; they restore as regular files at the link paths. Outside-root,
210
+ dangling and directory symlinks (including loops), sockets, FIFOs and devices are skipped.
200
211
  One kit owns the store at a time; a live owner or orphan gateway blocks preparation instead of racing its writes.
201
212
  Sealing copies the whole store through memory, so startup and stop cost grows with session history.
202
213
 
@@ -222,6 +233,9 @@ install/cache files and app workspaces are outside this adapter's sealing scope;
222
233
  `doctorContext()` is an advanced escape hatch: launching an external doctor bypasses this lifecycle; use the
223
234
  kit's migration method for sealed stores.
224
235
 
236
+ See [SECURITY.md](SECURITY.md) for the credential and approval threat model, private-directory requirements,
237
+ retained-login cleanup and sealed approval limits.
238
+
225
239
  ## Status
226
240
 
227
241
  Pinned to OpenClaw `2026.8.1` (protocol 4); `ENGINE_VERSION` and `PROTOCOL_VERSION` carry the pin. Signatures are
package/SECURITY.md ADDED
@@ -0,0 +1,196 @@
1
+ # OpenClaw kit security
2
+
3
+ ## Scope and trust boundaries
4
+
5
+ This is the credential and approval threat model for `@byokit/openclaw`, reviewed
6
+ against engine **2026.8.1** on **2026-09-30**. The authoritative runtime design is
7
+ [runtime-kits.md](../../docs/runtime-kits.md), especially §§4.2, 5.5–5.9 and 7.1–7.3.
8
+ This review covers the kit's supervisor, migration, transport and sealed approval
9
+ path, including the credential-store lifecycle in kit 0.3.0; it is not an independent audit of the engine or its transitive dependencies.
10
+
11
+ The host app, its member mapping, configuration, plugin allowlist, tools and chosen
12
+ executables are trusted. The app must give each installation a private, app-owned
13
+ `stateDir`, with one writer, and protect its parent directory from other users.
14
+ An explicitly supplied `engineDir`, retained-login path and install-policy roots
15
+ are also trusted app choices. Untrusted inputs include model output, tool requests,
16
+ paired devices and relay/push payloads. Members separate application accounts;
17
+ they are not operating-system sandboxes within the shared engine process.
18
+
19
+ The kit protects against accidental discovery of another tool's credentials,
20
+ unauthorized device operations, wrong-member approvals and disclosure of approval
21
+ content to the relay. It cannot protect credentials from root, another process
22
+ running as the app's user, a compromised host/engine/plugin, or readable backups.
23
+ The isolated environment and tool gate are not an OS filesystem/network sandbox.
24
+ The app must sandbox `ToolHost.call` and judge requests in `ToolHost.gate`.
25
+
26
+ ## Secrets at rest and in memory
27
+
28
+ | Asset | Location relative to `stateDir` | Protection and consequence of theft |
29
+ |---|---|---|
30
+ | Gateway token | `openclaw/token` | Random 32-byte bearer secret; newly created file is 0600. Treat it as operator access. |
31
+ | Device identity | `openclaw/device.json` | Ed25519 private key stored as **unencrypted PKCS#8 PEM** in JSON, newly created 0600. The transport accepts both kit and legacy paired shapes without rewriting. Theft can impersonate the operator identity. |
32
+ | Provider profiles | `openclaw/state/` and `openclaw/home/`, including agent/shared SQLite databases, journals and migration JSON | Access/refresh tokens or API keys are **plaintext while running**; without `authSeal` they also remain plaintext while stopped. Migration stages `auth-profiles.json` at 0600 in a newly created 0700 directory. Theft may authorize provider calls and refresh. |
33
+ | Sealed credential snapshot | `openclaw/auth-store.sealed` and retired `*.sealed` archives | With host-injected `authSeal`, authenticates and encrypts the complete state/home trees while stopped. Key security belongs to the adapter/host; an older authentic snapshot can be replayed. |
34
+ | Saved configuration, logs and sessions | `openclaw/openclaw.json`, `logs/`, engine state | May contain app-supplied keys, prompts, tool inputs, sign-in URLs/codes or provider diagnostics. Treat the whole tree as secret. Engine stdout/stderr is appended to a newly created 0600 log; it is not a guaranteed credential-redaction layer. |
35
+ | Notice seed and decrypted approvals | Device app storage and memory | The app stores the 32-byte seed; the kit receives it only for key derivation/decryption. Seed theft exposes notices for its box key. |
36
+
37
+ New supervisor directories use 0700 and new secret files use 0600. These creation
38
+ modes alone do **not** repair all pre-existing permissions, reject every symlink,
39
+ encrypt disk contents, or enforce Windows ACLs. The optional credential snapshot
40
+ collects regular files and file symlinks whose fully resolved targets are regular
41
+ files within the isolated engine root; links restore as regular files at the link
42
+ paths. Outside-root, dangling and directory links, sockets, FIFOs and devices are
43
+ skipped without reading their contents. Collected POSIX modes are normalized;
44
+ other paths still need host protection. Skipped entries are not preserved in the
45
+ snapshot and are removed with the live trees after successful sealing. Never use a shared/writable tree or point
46
+ the kit at another product's state. Review existing permissions before adopting a
47
+ tree; use an OS-protected app directory, encrypted disk and restricted backups.
48
+ Do not log `doctorContext().env`, transport arguments, auth records or sign-in
49
+ callbacks. JavaScript strings and engine memory are not reliably zeroized.
50
+
51
+ The engine needs plaintext credentials and PEM during operation. With `authSeal`
52
+ (a host-injected `SealingAdapter` from `@byokit/secrets`), `src/auth-store.ts` seals
53
+ all of state/home, including SQLite journals, on successful `prepare()` and after
54
+ the engine exits on `stop()`. It verifies the adapter round-trip, writes via an
55
+ exclusive temporary file, fsyncs/renames the snapshot and verifies it again before
56
+ removing live plaintext. `start()` authenticates and validates snapshot paths before
57
+ restoring files. Missing/wrong keys or tampering reject without plaintext fallback.
58
+ A live store owner or orphan gateway prevents a second kit from racing its writer.
59
+
60
+ This protects stopped stores, not a running engine or doctor. Await `stop()` on
61
+ orderly shutdown. An abrupt host exit can leave live plaintext; the next prepare
62
+ seals leftovers only after the orphan writer has exited. Interrupted restore or
63
+ removal recovers from the authenticated snapshot. A sealing failure retains
64
+ recoverable files and rejects; the host must resolve it and retry. Snapshot size
65
+ and memory cost grow with the complete state/home trees. Keys, gateway token,
66
+ device PEM, inline config secrets, logs, install/cache files and app workspaces
67
+ are outside this seal. OS-keyring or host-key adapters must keep the key separate
68
+ from state/backups. There is no rollback protection. File removal cannot erase old
69
+ blocks, snapshots, swap or backups. A sealed approval provides separate transit
70
+ confidentiality and does not seal the credential tree.
71
+ After suspected compromise, revoke provider credentials with the provider, stop
72
+ the app's own engine, revoke device grants and recreate its operator identity and
73
+ token in a fresh private state directory. Deleting files alone does not revoke
74
+ already copied credentials or erase snapshots/backups.
75
+
76
+ ## Engine isolation and installation
77
+
78
+ `src/engine.ts` builds the gateway/doctor environment explicitly: HOME, XDG,
79
+ provider-tool homes, tmp, config and state paths point into the app's tree. It does
80
+ not forward shell API keys, `NODE_OPTIONS` or shell environment snapshots. The
81
+ gateway binds `127.0.0.1` on its own port, uses token authentication and a signed
82
+ device identity, and disables Control UI, discovery, channels, automatic updates
83
+ and default remote memory search. Loopback is a reachability restriction, not a
84
+ substitute for authentication against other local processes.
85
+
86
+ Installation is a separate boundary: `npm ci --ignore-scripts` consumes the
87
+ committed engine lockfile, verifies the exact engine version, and uses an isolated
88
+ install HOME/cache. It needs the registry/network once and inherits the host PATH
89
+ to find npm; `npmPath` and `enginePath` must resolve to trusted executables. Version
90
+ and integrity pins reduce drift, but do not prove dependency safety. Trusted app
91
+ config can add plugins, providers and other destinations. The kit is not a
92
+ general egress filter. Builtin tools are gated by default; `gateBuiltins: false`
93
+ weakens that boundary deliberately. The install policy refuses dependency
94
+ installs, and trusted skills/roots remain the app's responsibility.
95
+
96
+ ## Retained-login migration
97
+
98
+ `src/migrate.ts` reads only the explicit `{ path }` or `{ record }` supplied by the
99
+ app. It never searches HOME for credentials. Member ids are validated before
100
+ writing. Before the gateway starts, it stages legacy profiles in the member's
101
+ own profile store, then runs the offline doctor to canonicalize them. An existing
102
+ profile store is not overwritten; a failed doctor removes only staging this call
103
+ wrote. Tests exercise these conditions and path traversal refusal.
104
+
105
+ With `authSeal`, offline migration restores the credential snapshot and reseals
106
+ the staged/imported store even after a failed doctor. The public `doctorContext()`
107
+ escape hatch does not enforce that lifecycle; use the kit migration method.
108
+ A zero doctor exit is insufficient to retire a login. Only a gateway status report
109
+ that every expected provider is signed in confirms migration. Confirmation removes
110
+ the explicitly supplied path and its retained plaintext/sealed copies, then writes
111
+ an empty `<path>.moved-to-engine.canonicalized` marker. No new plaintext retained
112
+ archive is created. A record source remains the app's responsibility to delete.
113
+
114
+ Prepare scans only the app-owned tree for engine migration archives and old retained
115
+ copies, excluding install/cache/tmp/plugin/workspace roots and symlinks. With an
116
+ adapter these become verified `*.sealed` archives; without one, engine archives and
117
+ confirmed retained copies are removed. An unconfirmed retained copy stays available
118
+ for migration unless sealed. A sealed archive is not restored as live engine input;
119
+ an unconfirmed sealed retained source can be read by explicitly passing its original
120
+ path and the matching adapter. External sources are handled only when the host
121
+ explicitly supplies them. Archive log events contain no credential bytes.
122
+
123
+ Protect the original directory and backups; cleanup neither revokes credentials nor
124
+ erases historical copies. Never pass an owner's unrelated auth file or run concurrent
125
+ migrations. Staging uses a predictable pid suffix and a rename; hostile directories
126
+ remain outside the trusted-directory contract. The sealing store's ownership and
127
+ symlink checks do not establish an OS sandbox or general protection for other paths.
128
+
129
+ ## Approvals, devices and relay notices
130
+
131
+ The bridge denies unknown runs and gates every tool by default. Registered app
132
+ tools use single-use permits or tickets bound to session, tool and exact input;
133
+ run teardown discards their authorizations. Missing/failed gates deny. Parked
134
+ asks expire, deny when the caller disconnects, and are removed on shutdown.
135
+ Native exec/plugin decisions use `allow-once`, not a persistent allowlist.
136
+ The engine remains authoritative for native approval expiration and resolution.
137
+
138
+ `openclawLink` checks the app's member mapping and session prefix before member
139
+ operations, checks pending approval ownership before deciding, and denies mutation
140
+ to view grants. Raw `oc.call` is denied unless the app's `passThrough` explicitly
141
+ allows the method; that callback must also enforce parameter/member scope. The
142
+ local `kit.call`/`kit.decide` surface has operator authority and is trusted host
143
+ code. Unattributed approvals never match a valid member id.
144
+
145
+ Notice registration sends only a 32-byte box public key over the authenticated
146
+ device link. `sealNotice` encrypts the complete approval with `@byokit/seal`'s
147
+ sealed box; each device receives separate ciphertext. A device with no registered
148
+ box key gets only the generic title. The relay sees destination, approval id,
149
+ generic title, action labels, timing, urgency and ciphertext size; it does not
150
+ need approval plaintext. Wrong keys, altered ciphertext, invalid JSON, versions
151
+ and approval shapes are rejected by `openNotice`.
152
+
153
+ Sealed boxes do **not** authenticate the sender: anyone with the public key can
154
+ encrypt a notice. They also provide no freshness/replay check; timestamps are
155
+ data, and a notice is never authorization. Treat it as a prompt to reload current
156
+ approvals over the authenticated link. `onAction` checks a remembered grant,
157
+ current member mapping, control authority, allow/deny action and pending member
158
+ approval. The relay action transport must authenticate the device and reject
159
+ replays before invoking it; a caller-supplied device id is not proof. Grant
160
+ revocation must make `memberOf` return undefined for revoked grants, since the
161
+ adapter retains grants/box keys seen earlier. Push failures do not resolve asks.
162
+
163
+ ## Review record and regression evidence
164
+
165
+ Review completed 2026-09-30 by source inspection and offline regression coverage:
166
+
167
+ - [x] Plaintext identity/profiles, creation modes, logs, backup and same-user risks documented.
168
+ - [x] Shared isolation contract (`test/isolation-contract.ts`) runs in `npm test`
169
+ through `test/isolation.test.ts` with an offline child, exercising the actual
170
+ Engine spawn, its received environment/cwd, loopback listener, creation modes,
171
+ safe config and unchanged decoy HOME files. It checks its log for the token,
172
+ private PEM and inherited key canary; this is not a universal log-redaction proof.
173
+ - [x] `test/engine/isolation.test.ts` runs the same contract against the pinned
174
+ engine in `npm run test:engine` / CI's `openclaw-engine` job. It requires a
175
+ registry install; the default test run remains offline. Neither test claims
176
+ complete syscall tracing or an OS sandbox.
177
+ - [x] Migration failure, existing profiles, private staging, member validation,
178
+ confirmation and verified-source removal covered by `test/migrate.test.ts`.
179
+ - [x] Optional credential sealing, wrong keys, tampering, concurrent owners, safe file-link
180
+ materialization and skipped runtime/unsafe entries, interrupted transitions and archive cleanup covered by
181
+ `test/engine-unit.test.ts`. The seal excludes gateway/device secrets and live state.
182
+ - [x] Bridge fail-closed, timeout/disconnect and one-use input binding covered by
183
+ `test/bridge.test.ts` and `test/plugin.test.ts`.
184
+ - [x] Member/role checks, sealed/generic relay content, wrong-member push refusal
185
+ covered by `test/link.test.ts`; wrong-key and ciphertext tampering rejection
186
+ covered by `test/device.test.ts`.
187
+
188
+ The review found that a remembered **view** grant could resolve an approval via
189
+ `onAction`, although `oc.decide` already refused it. The push path now applies the
190
+ same role restriction, with a same-member view-grant regression case. The risks
191
+ above remain explicit limits; optional stopped-store encryption does not claim
192
+ live credential confidentiality or general hostile-directory safety.
193
+
194
+ Report a suspected vulnerability privately through the repository's GitHub
195
+ security advisory channel when available. Do not put tokens, private keys,
196
+ retained login files, pairing grants or unredacted logs in a public issue.
@@ -1,7 +1,7 @@
1
1
  // The pin persists OAuth JSON in both shared and agent SQLite. There is no public persistence hook.
2
2
  // Seal the complete isolated stores, including journals, only after their writer has exited.
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
4
- import { dirname, join, relative, resolve } from 'node:path';
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  const encoder = new TextEncoder();
6
6
  const archive = (name) => /^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
7
7
  || name.endsWith('.moved-to-engine');
@@ -18,7 +18,7 @@ const live = (pid) => {
18
18
  };
19
19
  function regular(path) {
20
20
  if (!lstatSync(path).isFile())
21
- throw new Error('credential store requires regular files');
21
+ throw new Error(`credential store requires regular files: ${path}`);
22
22
  }
23
23
  function syncDir(path) {
24
24
  if (process.platform === 'win32')
@@ -153,6 +153,7 @@ export class AuthStore {
153
153
  }
154
154
  collect() {
155
155
  const s = { v: 1, dirs: [], files: [] };
156
+ const root = realpathSync(this.o.root);
156
157
  const walk = (path) => {
157
158
  const name = relative(this.o.root, path).split('\\').join('/');
158
159
  const stat = lstatSync(path);
@@ -163,9 +164,27 @@ export class AuthStore {
163
164
  walk(join(path, child));
164
165
  return;
165
166
  }
166
- regular(path);
167
- chmodSync(path, 0o600);
168
- s.files.push([name, readFileSync(path).toString('base64')]);
167
+ let file = path;
168
+ if (stat.isSymbolicLink()) {
169
+ try {
170
+ file = realpathSync(path);
171
+ }
172
+ catch (error) {
173
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''))
174
+ return;
175
+ throw error;
176
+ }
177
+ const target = relative(root, file);
178
+ if (isAbsolute(target) || target === '..' || target.startsWith(`..${sep}`))
179
+ return;
180
+ if (!lstatSync(file).isFile())
181
+ return;
182
+ }
183
+ else if (!stat.isFile())
184
+ return;
185
+ // Read the checked target, but save the link's path: restore materializes a regular file there.
186
+ chmodSync(file, 0o600);
187
+ s.files.push([name, readFileSync(file).toString('base64')]);
169
188
  };
170
189
  for (const dir of ['state', 'home'])
171
190
  if (existsSync(join(this.o.root, dir)))
package/dist/link.js CHANGED
@@ -86,7 +86,7 @@ export function openclawLink(kit, o) {
86
86
  const onAction = async (a) => {
87
87
  const grant = seen.get(a.device);
88
88
  const member = grant ? o.memberOf(grant) : undefined;
89
- if (member === undefined)
89
+ if (member === undefined || grant?.role === 'view')
90
90
  throw refused();
91
91
  if (a.action !== 'allow' && a.action !== 'deny')
92
92
  throw refused();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -45,7 +45,8 @@
45
45
  "policy",
46
46
  "README.md",
47
47
  "LICENSE",
48
- "CHANGELOG.md"
48
+ "CHANGELOG.md",
49
+ "SECURITY.md"
49
50
  ],
50
51
  "scripts": {
51
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
@@ -55,13 +56,13 @@
55
56
  "@byokit/relay": "0.4.2",
56
57
  "@byokit/reach": "0.4.0",
57
58
  "@byokit/seal": "0.2.0",
58
- "@byokit/secrets": "0.3.0",
59
+ "@byokit/secrets": "0.4.0",
59
60
  "@openclaw/gateway-client": "2026.8.1",
60
61
  "@openclaw/gateway-protocol": "2026.8.1",
61
62
  "ws": "8.21.3"
62
63
  },
63
64
  "devDependencies": {
64
- "@byokit/ui-core": "0.3.0"
65
+ "@byokit/ui-core": "0.4.0"
65
66
  },
66
67
  "publishConfig": {
67
68
  "access": "public"