Derive one identity and one wallet from one seed in the CLI

69cd68c1e78c · AtlantisPleb · · parent 2a631cf63107

Derive one identity and one wallet from one seed in the CLI

`openagents identity` holds a single BIP-39 seed phrase and derives both the
Nostr identity and the wallet from it, so there is one thing to back up and
nothing that can drift apart.

The derivation is the frozen OpenAgents profile, not a new one: the identity at
m/44'/1237'/0'/0/0 and the wallet at m/44'/0'/0'/0/0, English word list, empty
passphrase — what `packages/sovereign-identity` froze from Pylon. It is pinned
by fixed vectors rather than by review, because a derivation change is silent:
the CLI keeps working and hands everyone a different npub and a different
address. `test/seed-identity.test.ts` asserts what the published BIP-39 test
phrase must always produce and re-derives it through sovereign-identity so the
two surfaces cannot move apart. The address it pins,
1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA, has an independent published witness.

The derivation lives in the CLI rather than importing sovereign-identity at
runtime: the CLI publishes as a plain tsc build, and that package ships
TypeScript sources over a Git-tarball nostr-effect. It is a devDependency and a
test-time cross-check. The crypto underneath is the pinned stack both sides use.

Secrets stay out of output. The seed is written 0600 inside a 0700 directory,
`create` and `import` never echo the phrase, `backup` is the one command that
prints it and refuses --json, and `trace redact` now removes seed phrases
(word list-gated, so prose survives) and nsec/xprv-family keys.

The wallet receives; it does not spend. Issue #29 makes the rail an explicit
owner decision and none is recorded, so `show` reports it unselected rather than
implying a spend path, and NEEDS_OWNER.md carries the choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified NEEDS_OWNER.md
  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/package.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/identity-command.ts
  • added packages/openagents-cli/src/seed-identity.ts
  • modified packages/openagents-cli/src/trace-store.ts
  • added packages/openagents-cli/test/identity-command.test.ts
  • added packages/openagents-cli/test/seed-identity.test.ts
  • modified packages/openagents-cli/test/trace-store.test.ts
  • modified pnpm-lock.yaml

Diff

14 files changed, +1008 -4

INVARIANTS.md modified +25

@@ -1058,6 +1058,31 @@ come from the Freerange teardown

1058 1058
  raw provider material in context/history/logs, missing app-owned idempotency,
1059 1059
  generic provider tools, and platform-authority widening.
1060 1060
1061
## One Seed, One Identity, One Wallet
1062
1063
- The CLI derives both the Nostr identity and the wallet from one BIP-39 seed
1064
  under the frozen profile `openagents.legacy_unified_nostr_spark.v1`: the
1065
  identity at `m/44'/1237'/0'/0/0` (NIP-06 account zero), the wallet at
1066
  `m/44'/0'/0'/0/0` (BIP-44 account zero), both on the English word list with an
1067
  EMPTY BIP-39 passphrase. That profile is the same one
1068
  `packages/sovereign-identity/src/contract/derivation.ts` froze, and no surface
1069
  may add a passphrase, change an index, or introduce a second profile. A change
1070
  here reissues every existing identity and orphans every address people were
1071
  told to receive at.
1072
- The derivation is pinned by fixed vectors, not by review.
1073
  `packages/openagents-cli/test/seed-identity.test.ts` asserts the `npub`, the
1074
  public keys, the fingerprint, and the receive address that the published
1075
  BIP-39 test phrase must always produce, and re-derives them through
1076
  `@openagentsinc/sovereign-identity` so the two surfaces cannot drift apart.
1077
- Secret material stays out of every output. The seed file is written `0600`
1078
  inside a `0700` directory, `openagents identity backup` is the only command
1079
  that prints the phrase and it refuses `--json`, no command prints an `nsec` or
1080
  a raw private key, and `openagents trace redact` removes seed phrases (word
1081
  list-gated) and `nsec`/`xprv`-family keys from a redacted export.
1082
- The wallet receives; it does not spend. The spending rail is an owner decision
1083
  that is not recorded, so no CLI surface may imply a spend path exists until it
1084
  is.
1085
1061 1086
## Retired Verse World Service
1062 1087
1063 1088
- The Verse world service is retired and `apps/openagents-world/` is deleted.
NEEDS_OWNER.md modified +22

@@ -233,3 +233,25 @@ State 2026-07-09: per the owner decision on #8544, public review submissions are

233 233
- Submit the Play release and record the Play Console release/submission ID and review state.
234 234
235 235
Consoles: https://appstoreconnect.apple.com/apps and https://play.google.com/console
236
237
## OPEN — Choose the CLI wallet's spending rail (#29)
238
239
`openagents identity` now derives one Nostr identity and one wallet from one
240
BIP-39 seed (issue #29). Receiving works under either candidate rail, so the
241
derivation landed without waiting. Spending did not: issue #29 states the rail
242
is an explicit owner decision recorded before implementation, and no decision is
243
recorded, so `openagents identity show` reports the rail as unselected and no
244
CLI surface implies a spend path.
245
246
Decide between:
247
248
1. **Self-custodial MDK/LDK.** Keys stay on the machine and the CLI holds a
249
   Lightning node's responsibilities: channel liquidity, backups, and being
250
   online to receive.
251
2. **The deterministic Spark rail preserved from Pylon v1.0.** The proven shape
252
   the frozen derivation profile was written for, at the cost of the rail's own
253
   trust and availability assumptions.
254
255
Record the choice as a comment on issue #29. Confirm-before-spend, tips, and
256
bounty payouts are blocked on it; the derivation, storage, and receive
257
identifiers are not.
docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2474,
7
    "filesScanned": 2476,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:9c762fe37db50019a875a89c30c5bcb2a21ebc1d5e3d8ab94ea8eaa538bc7b0f",
4
  "sourceDigest": "sha256:1d9036ecce5eea778b592bc94744162f06c5593790e4f7735114cec9f5d19c3a",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (69 tracked test files)"
1879
          "ref": "packages/openagents-cli (71 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/README.md modified +39

@@ -303,6 +303,45 @@ passes to the child as `OPENCODE_CONFIG` and nothing else. A child approves its

303 303
own tool use, because a delegated child has nobody to ask; `--child-ask` stops
304 304
it at its first edit for a dry run.
305 305
306
## Hold one identity and one wallet
307
308
One BIP-39 seed phrase produces both your Nostr identity and your wallet, so
309
there is one thing to back up and nothing that can drift apart:
310
311
```sh
312
openagents identity create
313
openagents identity backup
314
openagents identity show
315
```
316
317
`create` writes the phrase to `~/.openagents/identity/seed` with mode `0600` and
318
does not print it. `backup` is the one command that prints it, and it refuses
319
`--json` so the phrase cannot be captured by a caller collecting machine output.
320
`show` prints public identifiers only — the `npub`, the receive address, and the
321
derivation paths — and never the phrase, an `nsec`, or a private key.
322
323
To restore an existing seed, pipe the phrase in. It is validated before anything
324
is written, and it is never echoed:
325
326
```sh
327
openagents identity import < phrase.txt
328
```
329
330
Both branches come from the frozen OpenAgents profile
331
`openagents.legacy_unified_nostr_spark.v1`: the identity from `m/44'/1237'/0'/0/0`
332
(NIP-06 account zero) and the wallet from `m/44'/0'/0'/0/0` (BIP-44 account
333
zero), both under the English word list and an empty BIP-39 passphrase. The same
334
phrase gives the same `npub` and the same address on every machine and every
335
version, and `test/seed-identity.test.ts` pins that with fixed vectors.
336
337
The wallet receives; it does not spend yet. Which rail it spends over is an open
338
owner decision, and a receive address is the same under either candidate, so
339
`show` reports the rail as unselected rather than implying a spend path exists.
340
341
Set `OPENAGENTS_IDENTITY_DIR` to keep a seed somewhere other than
342
`~/.openagents/identity`. `openagents identity forget --force` deletes it;
343
without the phrase, the identity and wallet are unrecoverable.
344
306 345
## Manage issues
307 346
308 347
```sh
packages/openagents-cli/package.json modified +5 -1

@@ -55,13 +55,17 @@

55 55
  "dependencies": {
56 56
    "@effect/platform-node": "catalog:",
57 57
    "@effect/platform-node-shared": "4.0.0-beta.94",
58
    "effect": "catalog:",
59 58
    "@noble/curves": "1.8.1",
60 59
    "@noble/hashes": "1.7.1",
60
    "@scure/base": "1.2.4",
61
    "@scure/bip32": "1.6.2",
62
    "@scure/bip39": "1.5.4",
63
    "effect": "catalog:",
61 64
    "ollama": "^0.6.3",
62 65
    "ws": "8.21.1"
63 66
  },
64 67
  "devDependencies": {
68
    "@openagentsinc/sovereign-identity": "workspace:*",
65 69
    "@types/node": "catalog:",
66 70
    "@types/ws": "8.18.1",
67 71
    "typescript": "catalog:",
packages/openagents-cli/src/cli.ts modified +4

@@ -150,6 +150,7 @@ import { DeviceClient } from "./device-client.js";

150 150
import { type EndpointOverrides, Profile } from "./endpoint.js";
151 151
import { ForumClient } from "./forum-client.js";
152 152
import { GitRunner } from "./git-runner.js";
153
import { makeIdentityCommand } from "./identity-command.js";
153 154
import { IssueClient } from "./issue-client.js";
154 155
import { runGitCredentialHelper } from "./git-credential-helper.js";
155 156
import { Output, type OutputMode } from "./output.js";

@@ -3952,6 +3953,8 @@ const projectCommand = Command.make("project").pipe(

3952 3953
  ]),
3953 3954
);
3954 3955
3956
const identityCommand = makeIdentityCommand(rootCommand);
3957
3955 3958
const traceCommand = makeTraceCommand(rootCommand);
3956 3959
3957 3960
// The deploy command group: named operator deployment commands over the

@@ -4338,6 +4341,7 @@ export const openagentsCommand = rootCommand.pipe(

4338 4341
    computerCommand,
4339 4342
    deployCommand,
4340 4343
    forumCommand,
4344
    identityCommand,
4341 4345
    issueCommand,
4342 4346
    projectCommand,
4343 4347
    repoCommand,
packages/openagents-cli/src/identity-command.ts added +280

@@ -0,0 +1,280 @@

1
/**
2
 * The `openagents identity` command family.
3
 *
4
 * One seed phrase is the whole of a user's local sovereignty here: it produces
5
 * the Nostr identity the CLI signs with and the wallet branch that receives, and
6
 * it is the only thing worth backing up. These commands create it, import one,
7
 * show what it derives, hand it back for backup, and forget it.
8
 *
9
 * The split between the public and the secret path is the point of the family.
10
 * `show` prints identifiers that are safe anywhere — an `npub`, a receive
11
 * address, the derivation paths — and is the command every other surface and
12
 * script should call. `backup` is the single command that prints the phrase, it
13
 * says so in its own name, and it refuses `--json` so the phrase cannot be
14
 * captured by a caller that was collecting machine output. `create` and
15
 * `import` never echo the phrase at all.
16
 *
17
 * Spending is absent on purpose. The wallet's rail — self-custodial MDK/LDK, or
18
 * the deterministic Spark rail Pylon v1.0 used — is an owner decision that is
19
 * not recorded yet (monorepo issue #29), and a receive address is the same under
20
 * either. `show` names the gap rather than implying a spend path exists.
21
 */
22
23
import { Effect } from "effect";
24
import { Command, Flag } from "effect/unstable/cli";
25
26
import { InputError } from "./errors.js";
27
import { Output, type OutputMode } from "./output.js";
28
import { SecretInput } from "./secret-input.js";
29
import {
30
  deriveSeedIdentity,
31
  forgetSeedPhrase,
32
  generateSeedPhrase,
33
  isValidSeedPhrase,
34
  readSeedPhrase,
35
  seedPath,
36
  seedPresent,
37
  writeSeedPhrase,
38
  type SeedIdentity,
39
} from "./seed-identity.js";
40
41
/** The shared flags a handler reads back off the root command. */
42
interface SharedFlags {
43
  readonly json: boolean;
44
}
45
46
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
47
48
/** The one sentence that says where the rail decision stands. */
49
const RAIL_NOTE =
50
  "Spending rail: not selected. The wallet receives under either candidate rail; " +
51
  "which one it spends over is an owner decision (monorepo issue #29).";
52
53
const NO_IDENTITY =
54
  "No seed is stored. Run openagents identity create to make one, or " +
55
  "openagents identity import to restore an existing seed phrase.";
56
57
const identityValue = (identity: SeedIdentity) => ({
58
  schema: "openagents.cli_identity.v1",
59
  profile: identity.profile,
60
  npub: identity.npub,
61
  nostr_public_key: identity.nostrPublicKeyHex,
62
  nostr_derivation_path: identity.nostrDerivationPath,
63
  wallet_address: identity.walletAddress,
64
  wallet_public_key: identity.walletPublicKeyHex,
65
  wallet_fingerprint: identity.walletFingerprintHex,
66
  wallet_derivation_path: identity.walletDerivationPath,
67
  spending_rail: null,
68
});
69
70
const identityHuman = (identity: SeedIdentity): ReadonlyArray<string> => [
71
  `Identity: ${identity.npub}`,
72
  `  public key   ${identity.nostrPublicKeyHex}`,
73
  `  path         ${identity.nostrDerivationPath}`,
74
  `Wallet:   ${identity.walletAddress}`,
75
  `  public key   ${identity.walletPublicKeyHex}`,
76
  `  fingerprint  ${identity.walletFingerprintHex}`,
77
  `  path         ${identity.walletDerivationPath}`,
78
  `Profile:  ${identity.profile}`,
79
  RAIL_NOTE,
80
];
81
82
/** Derive from the stored seed, or fail with the sentence that says what to do. */
83
const storedIdentity = Effect.fn("Identity.storedIdentity")(function* () {
84
  const phrase = yield* Effect.sync(readSeedPhrase);
85
  if (phrase === undefined) return yield* new InputError({ message: NO_IDENTITY });
86
  return yield* Effect.try({
87
    try: () => deriveSeedIdentity(phrase),
88
    catch: () =>
89
      new InputError({
90
        message: `The seed stored at ${seedPath()} is not a valid English BIP-39 mnemonic. Re-import the correct phrase with openagents identity import.`,
91
      }),
92
  });
93
});
94
95
const wordsFlag = Flag.integer("words").pipe(
96
  Flag.withDefault(12),
97
  Flag.withDescription("Words in the new seed phrase: 12 for 128 bits, 24 for 256"),
98
);
99
100
const forceFlag = Flag.boolean("force").pipe(
101
  Flag.withDescription("Replace the stored seed. The identity and wallet it derives are lost"),
102
);
103
104
export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) => {
105
  const identityShowCommand = Command.make("show", {}, () =>
106
    Effect.gen(function* () {
107
      const flags = yield* root;
108
      const output = yield* Output;
109
      const identity = yield* storedIdentity();
110
      yield* output.write(
111
        { value: identityValue(identity), human: identityHuman(identity) },
112
        outputMode(flags.json),
113
      );
114
    }),
115
  ).pipe(
116
    Command.withDescription(
117
      "Show the identity and wallet this machine's seed derives. Public identifiers only: the seed phrase, the nsec, and the private keys are never printed.",
118
    ),
119
  );
120
121
  const identityCreateCommand = Command.make(
122
    "create",
123
    { words: wordsFlag, force: forceFlag },
124
    ({ force, words }) =>
125
      Effect.gen(function* () {
126
        const flags = yield* root;
127
        const output = yield* Output;
128
        if (words !== 12 && words !== 24) {
129
          return yield* new InputError({ message: "--words must be 12 or 24." });
130
        }
131
        if (seedPresent() && !force) {
132
          return yield* new InputError({
133
            message: `A seed is already stored at ${seedPath()}. Back it up with openagents identity backup first, then pass --force to replace it.`,
134
          });
135
        }
136
        const identity = yield* Effect.try({
137
          try: () => {
138
            const phrase = generateSeedPhrase(words);
139
            const derived = deriveSeedIdentity(phrase);
140
            writeSeedPhrase(phrase);
141
            return derived;
142
          },
143
          catch: (cause) =>
144
            new InputError({
145
              message: `The new seed could not be stored at ${seedPath()}: ${String(cause)}`,
146
            }),
147
        });
148
        yield* output.write(
149
          {
150
            value: { ...identityValue(identity), created: true, seed_path: seedPath() },
151
            human: [
152
              `Wrote a new ${words}-word seed to ${seedPath()} (mode 0600).`,
153
              "Back it up now with openagents identity backup. Nothing else on this machine can recover it.",
154
              ...identityHuman(identity),
155
            ],
156
          },
157
          outputMode(flags.json),
158
        );
159
      }),
160
  ).pipe(
161
    Command.withDescription(
162
      "Generate a seed phrase and store it 0600. The phrase itself is not printed; run openagents identity backup to see it.",
163
    ),
164
  );
165
166
  const identityImportCommand = Command.make("import", { force: forceFlag }, ({ force }) =>
167
    Effect.gen(function* () {
168
      const flags = yield* root;
169
      const output = yield* Output;
170
      const input = yield* SecretInput;
171
      if (seedPresent() && !force) {
172
        return yield* new InputError({
173
          message: `A seed is already stored at ${seedPath()}. Back it up with openagents identity backup first, then pass --force to replace it.`,
174
        });
175
      }
176
      const phrase = yield* input.readToken();
177
      if (!isValidSeedPhrase(phrase)) {
178
        // The phrase is never echoed back, not even the part that parsed.
179
        return yield* new InputError({
180
          message:
181
            "That is not a valid English BIP-39 seed phrase. Check the word count (12, 15, 18, 21, or 24) and the spelling of each word.",
182
        });
183
      }
184
      const identity = yield* Effect.try({
185
        try: () => {
186
          const derived = deriveSeedIdentity(phrase);
187
          writeSeedPhrase(phrase);
188
          return derived;
189
        },
190
        catch: (cause) =>
191
          new InputError({
192
            message: `The seed could not be stored at ${seedPath()}: ${String(cause)}`,
193
          }),
194
      });
195
      yield* output.write(
196
        {
197
          value: { ...identityValue(identity), imported: true, seed_path: seedPath() },
198
          human: [`Stored the seed at ${seedPath()} (mode 0600).`, ...identityHuman(identity)],
199
        },
200
        outputMode(flags.json),
201
      );
202
    }),
203
  ).pipe(
204
    Command.withDescription(
205
      "Read a seed phrase from standard input and store it 0600. The phrase is never echoed, and an invalid phrase is rejected before anything is written.",
206
    ),
207
  );
208
209
  const identityBackupCommand = Command.make("backup", {}, () =>
210
    Effect.gen(function* () {
211
      const flags = yield* root;
212
      const output = yield* Output;
213
      if (flags.json) {
214
        return yield* new InputError({
215
          message:
216
            "openagents identity backup does not support --json. The seed phrase must not land in machine-collected output; run it without --json and copy the phrase yourself.",
217
        });
218
      }
219
      const phrase = yield* Effect.sync(readSeedPhrase);
220
      if (phrase === undefined) return yield* new InputError({ message: NO_IDENTITY });
221
      yield* output.write(
222
        {
223
          value: { schema: "openagents.cli_identity_backup.v1" },
224
          human: [
225
            "This is the only secret on this machine. Anyone holding it holds the identity and the wallet.",
226
            phrase,
227
          ],
228
        },
229
        "human",
230
      );
231
    }),
232
  ).pipe(
233
    Command.withDescription(
234
      "Print the stored seed phrase so you can write it down. This is the one command that shows the secret; it refuses --json.",
235
    ),
236
  );
237
238
  const identityForgetCommand = Command.make("forget", { force: forceFlag }, ({ force }) =>
239
    Effect.gen(function* () {
240
      const flags = yield* root;
241
      const output = yield* Output;
242
      if (!force) {
243
        return yield* new InputError({
244
          message: `Deleting ${seedPath()} destroys the identity and the wallet it derives. Back the phrase up with openagents identity backup, then pass --force.`,
245
        });
246
      }
247
      const removed = yield* Effect.try({
248
        try: forgetSeedPhrase,
249
        catch: (cause) =>
250
          new InputError({
251
            message: `The seed at ${seedPath()} could not be removed: ${String(cause)}`,
252
          }),
253
      });
254
      yield* output.write(
255
        {
256
          value: { schema: "openagents.cli_identity_forget.v1", removed, seed_path: seedPath() },
257
          human: [removed ? `Removed ${seedPath()}.` : `No seed was stored at ${seedPath()}.`],
258
        },
259
        outputMode(flags.json),
260
      );
261
    }),
262
  ).pipe(
263
    Command.withDescription(
264
      "Delete the stored seed. Requires --force, because the identity and wallet are unrecoverable without the phrase.",
265
    ),
266
  );
267
268
  return Command.make("identity").pipe(
269
    Command.withDescription(
270
      "One seed phrase, one Nostr identity, one wallet. The seed is stored 0600 on this machine and everything else is derived from it.",
271
    ),
272
    Command.withSubcommands([
273
      identityShowCommand,
274
      identityCreateCommand,
275
      identityImportCommand,
276
      identityBackupCommand,
277
      identityForgetCommand,
278
    ]),
279
  );
280
};
packages/openagents-cli/src/seed-identity.ts added +200

@@ -0,0 +1,200 @@

1
/**
2
 * One seed, one identity, one wallet.
3
 *
4
 * The CLI holds a single BIP-39 mnemonic and derives everything else from it:
5
 * the Nostr identity that signs, and the wallet branch that receives. There is
6
 * no second secret to back up and no way for the two to drift apart, because
7
 * neither is stored — only the seed is, and both are recomputed from it.
8
 *
9
 * The derivation is the frozen OpenAgents profile, not a new one. The paths,
10
 * the English word list, and the empty BIP-39 passphrase come from
11
 * `packages/sovereign-identity/src/contract/derivation.ts`, which froze what
12
 * Pylon shipped (`docs/sol/2026-07-20-pylon-bip39-nostr-spark-identity-recovery-audit.md`).
13
 * `test/seed-identity.test.ts` re-derives that package's public vectors through
14
 * this module, so a refactor here that changes anyone's `npub` fails the build
15
 * rather than silently reissuing every identity.
16
 *
17
 * Why the derivation lives here rather than importing that package: the CLI
18
 * publishes as a plain `tsc` build with no bundler, so every runtime import has
19
 * to resolve from npm for an installed user. `@openagentsinc/sovereign-identity`
20
 * ships TypeScript sources and depends on a Git-tarball `nostr-effect`, so it is
21
 * a test-time cross-check here, not a runtime dependency. The crypto underneath
22
 * is the same pinned stack both sides use: `@noble/curves` 1.8.1,
23
 * `@noble/hashes` 1.7.1, `@scure/bip32` 1.6.2, `@scure/bip39` 1.5.4.
24
 *
25
 * SECRETS. This module returns the mnemonic from exactly one function,
26
 * {@link readSeedPhrase}, and derives from it in memory. It never logs, never
27
 * returns an `nsec` or a raw private key, and writes the seed file `0600` inside
28
 * a `0700` directory. The public manifest {@link SeedIdentity} carries public
29
 * identifiers only and is safe to print, store, and export.
30
 *
31
 * The spending rail is deliberately absent. Which rail the wallet spends over —
32
 * self-custodial MDK/LDK, or the deterministic Spark rail Pylon v1.0 used — is
33
 * an owner decision that is not recorded yet, so this module derives the wallet
34
 * branch and stops. Receiving identifiers are rail-independent; spending is not.
35
 */
36
37
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
38
import { homedir } from "node:os";
39
import { join } from "node:path";
40
41
import { secp256k1 } from "@noble/curves/secp256k1";
42
import { ripemd160 } from "@noble/hashes/ripemd160";
43
import { sha256 } from "@noble/hashes/sha256";
44
import { bytesToHex } from "@noble/hashes/utils";
45
import { bech32, createBase58check } from "@scure/base";
46
import { HDKey } from "@scure/bip32";
47
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
48
import { wordlist } from "@scure/bip39/wordlists/english";
49
50
/** The frozen shared-root profile both the CLI and Pylon derive under. */
51
export const DERIVATION_PROFILE_ID = "openagents.legacy_unified_nostr_spark.v1";
52
53
/** Nostr identity path: NIP-06 account zero. */
54
export const NOSTR_DERIVATION_PATH = "m/44'/1237'/0'/0/0";
55
56
/** Wallet path: BIP-44 Bitcoin account zero, first external key. */
57
export const WALLET_DERIVATION_PATH = "m/44'/0'/0'/0/0";
58
59
/**
60
 * The frozen BIP-39 passphrase. It is empty, and a non-empty one produces a
61
 * different identity, so it is a constant here rather than an option.
62
 */
63
const BIP39_PASSPHRASE = "";
64
65
const base58check = createBase58check(sha256);
66
67
/** Mainnet pay-to-public-key-hash version byte, the standard BIP-44 pairing. */
68
const P2PKH_VERSION = 0x00;
69
70
/**
71
 * The public half of one seed: what a manifest, a receipt, or a tip request may
72
 * carry. Nothing here can spend, sign, or reconstruct the seed.
73
 */
74
export interface SeedIdentity {
75
  /** The frozen derivation profile these identifiers were produced under. */
76
  readonly profile: typeof DERIVATION_PROFILE_ID;
77
  /** The NIP-19 `npub`, and the one cross-surface name for this identity. */
78
  readonly npub: string;
79
  /** The x-only 32-byte Nostr public key as hex. */
80
  readonly nostrPublicKeyHex: string;
81
  readonly nostrDerivationPath: typeof NOSTR_DERIVATION_PATH;
82
  /** The compressed 33-byte wallet public key as hex. */
83
  readonly walletPublicKeyHex: string;
84
  /** The BIP-32 key fingerprint, `HASH160(pubkey)[0..4]`, as hex. */
85
  readonly walletFingerprintHex: string;
86
  /** The mainnet P2PKH receive address for the wallet path. */
87
  readonly walletAddress: string;
88
  readonly walletDerivationPath: typeof WALLET_DERIVATION_PATH;
89
}
90
91
/** Trim and collapse whitespace without changing the words themselves. */
92
const normalize = (phrase: string): string => phrase.trim().split(/\s+/).join(" ");
93
94
/** True when the phrase is a valid English BIP-39 mnemonic with a good checksum. */
95
export const isValidSeedPhrase = (phrase: string): boolean =>
96
  validateMnemonic(normalize(phrase), wordlist);
97
98
/** Generate a fresh mnemonic. 12 words is 128 bits of entropy, 24 words is 256. */
99
export const generateSeedPhrase = (words: 12 | 24 = 12): string =>
100
  generateMnemonic(wordlist, words === 24 ? 256 : 128);
101
102
/**
103
 * Derive the public identity and wallet from one mnemonic.
104
 *
105
 * Deterministic and side-effect free: the same phrase always yields the same
106
 * `npub` and the same wallet address, on every machine and every version.
107
 * Throws when the phrase is not valid BIP-39 English, because deriving from a
108
 * mistyped phrase would hand back a plausible identity nobody can recover.
109
 */
110
export const deriveSeedIdentity = (phrase: string): SeedIdentity => {
111
  const normalized = normalize(phrase);
112
  if (!validateMnemonic(normalized, wordlist)) {
113
    throw new Error("The seed phrase is not a valid English BIP-39 mnemonic.");
114
  }
115
  const master = HDKey.fromMasterSeed(mnemonicToSeedSync(normalized, BIP39_PASSPHRASE));
116
  try {
117
    const nostrNode = master.derive(NOSTR_DERIVATION_PATH);
118
    if (nostrNode.privateKey === null) throw new Error("The Nostr key could not be derived.");
119
    const nostrPublicKey = secp256k1.getPublicKey(nostrNode.privateKey, true).slice(1);
120
121
    const walletNode = master.derive(WALLET_DERIVATION_PATH);
122
    if (walletNode.privateKey === null) throw new Error("The wallet key could not be derived.");
123
    const walletPublicKey = secp256k1.getPublicKey(walletNode.privateKey, true);
124
    const walletHash160 = ripemd160(sha256(walletPublicKey));
125
    const addressPayload = new Uint8Array(21);
126
    addressPayload[0] = P2PKH_VERSION;
127
    addressPayload.set(walletHash160, 1);
128
129
    nostrNode.wipePrivateData();
130
    walletNode.wipePrivateData();
131
132
    return {
133
      profile: DERIVATION_PROFILE_ID,
134
      npub: bech32.encode("npub", bech32.toWords(nostrPublicKey)),
135
      nostrPublicKeyHex: bytesToHex(nostrPublicKey),
136
      nostrDerivationPath: NOSTR_DERIVATION_PATH,
137
      walletPublicKeyHex: bytesToHex(walletPublicKey),
138
      walletFingerprintHex: bytesToHex(walletHash160.slice(0, 4)),
139
      walletAddress: base58check.encode(addressPayload),
140
      walletDerivationPath: WALLET_DERIVATION_PATH,
141
    };
142
  } finally {
143
    master.wipePrivateData();
144
  }
145
};
146
147
/**
148
 * Where the seed lives. `OPENAGENTS_IDENTITY_DIR` moves it, which is how tests
149
 * get an isolated identity without touching the developer's own.
150
 */
151
export const seedDirectory = (): string => {
152
  const override = process.env["OPENAGENTS_IDENTITY_DIR"];
153
  return override !== undefined && override.trim().length > 0
154
    ? override
155
    : join(homedir(), ".openagents", "identity");
156
};
157
158
/** The seed file itself: one line, the mnemonic, mode `0600`. */
159
export const seedPath = (): string => join(seedDirectory(), "seed");
160
161
/** True when a seed is already stored. Presence only; the bytes stay on disk. */
162
export const seedPresent = (): boolean => existsSync(seedPath());
163
164
/**
165
 * Read the stored mnemonic. This is the only function that returns secret
166
 * material, and every caller of it either derives from it or hands it to the
167
 * reader who asked for a backup. Returns `undefined` when no seed is stored.
168
 */
169
export const readSeedPhrase = (): string | undefined => {
170
  const path = seedPath();
171
  if (!existsSync(path)) return undefined;
172
  const phrase = normalize(readFileSync(path, "utf8"));
173
  return phrase.length === 0 ? undefined : phrase;
174
};
175
176
/**
177
 * Write the mnemonic, `0600` inside a `0700` directory, after validating it.
178
 * The validation is not politeness: an unwritable-back phrase stored here would
179
 * be an identity that cannot be recovered from its own backup.
180
 */
181
export const writeSeedPhrase = (phrase: string): string => {
182
  const normalized = normalize(phrase);
183
  if (!validateMnemonic(normalized, wordlist)) {
184
    throw new Error("The seed phrase is not a valid English BIP-39 mnemonic.");
185
  }
186
  const directory = seedDirectory();
187
  mkdirSync(directory, { recursive: true, mode: 0o700 });
188
  const path = seedPath();
189
  writeFileSync(path, `${normalized}\n`, { mode: 0o600 });
190
  chmodSync(path, 0o600);
191
  return path;
192
};
193
194
/** Remove the stored seed. Idempotent, and it deletes nothing else. */
195
export const forgetSeedPhrase = (): boolean => {
196
  const path = seedPath();
197
  if (!existsSync(path)) return false;
198
  rmSync(path);
199
  return true;
200
};
packages/openagents-cli/src/trace-store.ts modified +75

@@ -16,6 +16,8 @@

16 16
import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17 17
import { join } from "node:path";
18 18
19
import { BIP39_ENGLISH_WORDS } from "./memory/bip39-wordlist.js";
20
19 21
/** Where a candidate trace came from. */
20 22
export type TraceSourceKind =
21 23
  | "openagents_export"

@@ -322,12 +324,77 @@ export interface RedactionRule {

322 324
  readonly category: string;
323 325
  readonly pattern: RegExp;
324 326
  readonly replacement: string;
327
  /**
328
   * A rule that has to look at what it matched before deciding. It returns the
329
   * text to substitute, or the match unchanged to decline. Only the seed-phrase
330
   * rule needs this: a 12-word run is a cheap shape to find and an expensive one
331
   * to guess at, so the wordlist decides rather than the regex.
332
   */
333
  readonly resolve?: (match: string) => string;
325 334
}
326 335
327 336
const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
328 337
338
/** Word counts a BIP-39 mnemonic can have; below twelve is prose, not a seed. */
339
const MIN_SEED_WORDS = 12;
340
341
/**
342
 * A run of short lowercase words the length of a mnemonic. This finds candidates
343
 * cheaply; {@link seedPhraseResolve} confirms against the word list before
344
 * anything is removed, so ordinary English is left alone.
345
 */
346
const SEED_PHRASE_SHAPE = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g;
347
348
/**
349
 * Redact the longest run of consecutive BIP-39 words inside a shape match, and
350
 * only when that run is a whole mnemonic. Surrounding prose survives, which is
351
 * what keeps this rule usable on a real session log.
352
 */
353
const seedPhraseResolve = (match: string): string => {
354
  const words = match.split(" ");
355
  let bestStart = -1;
356
  let bestLength = 0;
357
  let runStart = 0;
358
  let runLength = 0;
359
  for (let index = 0; index < words.length; index += 1) {
360
    if (BIP39_ENGLISH_WORDS.has(words[index] as string)) {
361
      if (runLength === 0) runStart = index;
362
      runLength += 1;
363
      if (runLength > bestLength) {
364
        bestLength = runLength;
365
        bestStart = runStart;
366
      }
367
    } else {
368
      runLength = 0;
369
    }
370
  }
371
  if (bestLength < MIN_SEED_WORDS) return match;
372
  return [
373
    words.slice(0, bestStart).join(" "),
374
    "[REDACTED:seed_phrase]",
375
    words.slice(bestStart + bestLength).join(" "),
376
  ]
377
    .filter((part) => part !== "")
378
    .join(" ");
379
};
380
329 381
/** The conservative rule set. `home` scopes the path rules to this machine. */
330 382
export const redactionRules = (home: string): ReadonlyArray<RedactionRule> => [
383
  {
384
    // The CLI now keeps one seed phrase per machine and tells people to write
385
    // it down, so a phrase pasted into a session is a shape this promise has to
386
    // cover. `npub` is deliberately not here: it is the public name.
387
    category: "seed_phrase",
388
    pattern: SEED_PHRASE_SHAPE,
389
    replacement: "[REDACTED:seed_phrase]",
390
    resolve: seedPhraseResolve,
391
  },
392
  {
393
    category: "private_key",
394
    pattern:
395
      /\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b/g,
396
    replacement: "[REDACTED:private_key]",
397
  },
331 398
  {
332 399
    category: "bearer_token",
333 400
    pattern: /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}/g,

@@ -387,6 +454,14 @@ export const redactText = (text: string, rules: ReadonlyArray<RedactionRule>): R

387 454
  for (const rule of rules) {
388 455
    let matched = 0;
389 456
    output = output.replace(rule.pattern, (...args) => {
457
      const match = args[0] as string;
458
      if (rule.resolve !== undefined) {
459
        const resolved = rule.resolve(match);
460
        // A rule that declines has not redacted anything, so it must not count.
461
        if (resolved === match) return match;
462
        matched += 1;
463
        return resolved;
464
      }
390 465
      matched += 1;
391 466
      // Rebuild the replacement's capture references by hand: the replacement
392 467
      // string is data, and String.replace only expands `$1` for literal
packages/openagents-cli/test/identity-command.test.ts added +164

@@ -0,0 +1,164 @@

1
/**
2
 * What `openagents identity` shows, and what it must never show.
3
 *
4
 * The derivation itself is pinned in `seed-identity.test.ts`. This file covers
5
 * the surface a person actually touches: that `show` and `create` print the
6
 * public identity and not the phrase, that `backup` is the single command that
7
 * does print it and refuses `--json`, and that a destructive command asks first.
8
 */
9
10
import { mkdtempSync, statSync } from "node:fs";
11
import { tmpdir } from "node:os";
12
import { join } from "node:path";
13
14
import * as NodeServices from "@effect/platform-node/NodeServices";
15
import { Effect, Layer } from "effect";
16
import { afterEach, describe, expect, it } from "vitest";
17
18
import { runCliWith } from "../src/cli.js";
19
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
20
import { environmentLayerFromValues } from "../src/environment.js";
21
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
22
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
23
import { secretInputTestLayer } from "../src/secret-input.js";
24
import { seedPath } from "../src/seed-identity.js";
25
import { terminalSessionTestLayer } from "../src/terminal-session.js";
26
27
const TEST_PHRASE =
28
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
29
const TEST_NPUB = "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7";
30
const TEST_ADDRESS = "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA";
31
32
interface Written {
33
  readonly document: OutputDocument;
34
  readonly mode: OutputMode;
35
}
36
37
const harness = (stdin = TEST_PHRASE) => {
38
  process.env["OPENAGENTS_IDENTITY_DIR"] = mkdtempSync(join(tmpdir(), "identity-command-"));
39
  const written: Array<Written> = [];
40
  const layer = Layer.mergeAll(
41
    NodeServices.layer,
42
    environmentLayerFromValues({}),
43
    persistedConfigurationTestLayer({}),
44
    terminalSessionTestLayer(false),
45
    credentialStoreUnavailableLayer,
46
    secretInputTestLayer(stdin),
47
    outputTestLayer((document, mode) =>
48
      Effect.sync(() => {
49
        written.push({ document, mode });
50
      }),
51
    ),
52
  );
53
  const run = (argv: ReadonlyArray<string>) =>
54
    Effect.runPromise(
55
      runCliWith([...argv]).pipe(Effect.provide(layer)) as Effect.Effect<void, unknown>,
56
    );
57
  const fail = (argv: ReadonlyArray<string>) =>
58
    Effect.runPromise(
59
      runCliWith([...argv]).pipe(Effect.provide(layer), Effect.flip) as Effect.Effect<
60
        unknown,
61
        unknown
62
      >,
63
    );
64
  const last = () => written[written.length - 1];
65
  return { run, fail, written, last };
66
};
67
68
const messageOf = (error: unknown): string =>
69
  typeof error === "object" && error !== null && "message" in error
70
    ? String((error as { message: unknown }).message)
71
    : String(error);
72
73
afterEach(() => {
74
  delete process.env["OPENAGENTS_IDENTITY_DIR"];
75
});
76
77
describe("openagents identity", () => {
78
  it("says what to do when no seed is stored", async () => {
79
    const cli = harness();
80
    expect(messageOf(await cli.fail(["identity", "show"]))).toMatch(
81
      /No seed is stored.*identity create/s,
82
    );
83
  });
84
85
  it("imports a phrase from standard input and shows what it derives", async () => {
86
    const cli = harness();
87
    await cli.run(["identity", "import"]);
88
    await cli.run(["identity", "show", "--json"]);
89
    const document = cli.last()?.document;
90
    expect(document?.value).toMatchObject({
91
      npub: TEST_NPUB,
92
      wallet_address: TEST_ADDRESS,
93
      nostr_derivation_path: "m/44'/1237'/0'/0/0",
94
      wallet_derivation_path: "m/44'/0'/0'/0/0",
95
      spending_rail: null,
96
    });
97
    expect(statSync(seedPath()).mode & 0o777).toBe(0o600);
98
  });
99
100
  it("never echoes the phrase through import or show", async () => {
101
    const cli = harness();
102
    await cli.run(["identity", "import"]);
103
    await cli.run(["identity", "show"]);
104
    const printed = JSON.stringify(cli.written);
105
    expect(printed).toContain(TEST_NPUB);
106
    expect(printed).not.toContain("abandon");
107
  });
108
109
  it("rejects an invalid phrase without writing or quoting it", async () => {
110
    const cli = harness("clearly not a bip39 phrase");
111
    const error = await cli.fail(["identity", "import"]);
112
    expect(messageOf(error)).toMatch(/not a valid English BIP-39 seed phrase/);
113
    expect(messageOf(error)).not.toContain("clearly");
114
    expect(messageOf(await cli.fail(["identity", "show"]))).toMatch(/No seed is stored/);
115
  });
116
117
  it("creates a seed without printing it, and refuses to overwrite one", async () => {
118
    const cli = harness();
119
    await cli.run(["identity", "create"]);
120
    const created = cli.last();
121
    expect(created?.document.human.join("\n")).toMatch(/identity backup/);
122
    expect(JSON.stringify(created?.document)).not.toContain(TEST_PHRASE);
123
    expect(statSync(seedPath()).mode & 0o777).toBe(0o600);
124
125
    expect(messageOf(await cli.fail(["identity", "create"]))).toMatch(/already stored.*--force/s);
126
    expect(messageOf(await cli.fail(["identity", "import"]))).toMatch(/already stored.*--force/s);
127
  });
128
129
  it("rejects a word count that is not 12 or 24", async () => {
130
    const cli = harness();
131
    expect(messageOf(await cli.fail(["identity", "create", "--words", "13"]))).toMatch(
132
      /--words must be 12 or 24/,
133
    );
134
  });
135
136
  it("prints the phrase only through backup, and never as JSON", async () => {
137
    const cli = harness();
138
    await cli.run(["identity", "import"]);
139
    expect(messageOf(await cli.fail(["--json", "identity", "backup"]))).toMatch(
140
      /does not support --json/,
141
    );
142
    await cli.run(["identity", "backup"]);
143
    const backup = cli.last();
144
    expect(backup?.mode).toBe("human");
145
    expect(backup?.document.human).toContain(TEST_PHRASE);
146
    expect(JSON.stringify(backup?.document.value)).not.toContain("abandon");
147
  });
148
149
  it("forgets the seed only when forced", async () => {
150
    const cli = harness();
151
    await cli.run(["identity", "import"]);
152
    expect(messageOf(await cli.fail(["identity", "forget"]))).toMatch(/--force/);
153
    await cli.run(["identity", "forget", "--force"]);
154
    expect(cli.last()?.document.value).toMatchObject({ removed: true });
155
    expect(messageOf(await cli.fail(["identity", "show"]))).toMatch(/No seed is stored/);
156
  });
157
158
  it("names the rail decision rather than implying a spend path exists", async () => {
159
    const cli = harness();
160
    await cli.run(["identity", "import"]);
161
    await cli.run(["identity", "show"]);
162
    expect(cli.last()?.document.human.join("\n")).toMatch(/Spending rail: not selected/);
163
  });
164
});
packages/openagents-cli/test/seed-identity.test.ts added +155

@@ -0,0 +1,155 @@

1
/**
2
 * The vectors that make the CLI's identity permanent.
3
 *
4
 * A derivation bug is not a normal regression. Nobody sees a stack trace: the
5
 * CLI keeps working, derives a different `npub` and a different address from the
6
 * same phrase, and every identity, every signature anyone verified, and every
7
 * payment sent to the old address belongs to a stranger. The only defence is a
8
 * fixed phrase with fixed answers, asserted literally, so a refactor that
9
 * changes anyone's identity fails here instead of in the field.
10
 *
11
 * The literals below are not hand-typed. They match, field for field, the frozen
12
 * IDR-00 vectors in `packages/sovereign-identity/src/contract/vectors.ts`, and
13
 * the last test re-derives them through that package to prove the two surfaces
14
 * still agree — one seed, one identity, wherever it is derived.
15
 *
16
 * `walletAddress` has an independent witness: `1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA`
17
 * is the published first BIP-44 address of this mnemonic, quoted in BIP-39
18
 * tooling everywhere. If this file ever disagrees with it, this file is wrong.
19
 */
20
21
import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
23
import { join } from "node:path";
24
25
import {
26
  deriveSovereignIdentityPublic,
27
  PUBLIC_TEST_IDENTITY_EMPTY_PASSPHRASE,
28
} from "@openagentsinc/sovereign-identity/contract";
29
import { afterEach, describe, expect, it } from "vitest";
30
31
import {
32
  deriveSeedIdentity,
33
  forgetSeedPhrase,
34
  generateSeedPhrase,
35
  isValidSeedPhrase,
36
  readSeedPhrase,
37
  seedPath,
38
  seedPresent,
39
  writeSeedPhrase,
40
} from "../src/seed-identity.js";
41
42
/**
43
 * The canonical published BIP-39 test phrase. It is not a secret and never was;
44
 * it exists so a deterministic answer can be committed.
45
 */
46
const TEST_PHRASE =
47
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
48
49
/** What `TEST_PHRASE` must always derive. Changing any line reissues identities. */
50
const FROZEN = {
51
  profile: "openagents.legacy_unified_nostr_spark.v1",
52
  npub: "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7",
53
  nostrPublicKeyHex: "e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f",
54
  nostrDerivationPath: "m/44'/1237'/0'/0/0",
55
  walletPublicKeyHex: "03aaeb52dd7494c361049de67cc680e83ebcbbbdbeb13637d92cd845f70308af5e",
56
  walletFingerprintHex: "d986ed01",
57
  walletAddress: "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA",
58
  walletDerivationPath: "m/44'/0'/0'/0/0",
59
} as const;
60
61
const isolatedIdentityDirectory = () => {
62
  const directory = mkdtempSync(join(tmpdir(), "openagents-identity-"));
63
  process.env["OPENAGENTS_IDENTITY_DIR"] = directory;
64
  return directory;
65
};
66
67
afterEach(() => {
68
  delete process.env["OPENAGENTS_IDENTITY_DIR"];
69
});
70
71
describe("seed derivation", () => {
72
  it("derives the frozen identity and wallet from the published test phrase", () => {
73
    expect(deriveSeedIdentity(TEST_PHRASE)).toEqual(FROZEN);
74
  });
75
76
  it("is insensitive to surrounding whitespace but not to the words", () => {
77
    expect(deriveSeedIdentity(`  ${TEST_PHRASE.replace(/ /g, "  ")}\n`).npub).toBe(FROZEN.npub);
78
  });
79
80
  it("refuses a phrase whose checksum does not hold", () => {
81
    const wrongChecksum = TEST_PHRASE.replace(/about$/, "abandon");
82
    expect(isValidSeedPhrase(wrongChecksum)).toBe(false);
83
    expect(() => deriveSeedIdentity(wrongChecksum)).toThrow(/valid English BIP-39/);
84
  });
85
86
  it("gives every generated phrase its own identity and wallet", () => {
87
    const first = deriveSeedIdentity(generateSeedPhrase(12));
88
    const second = deriveSeedIdentity(generateSeedPhrase(24));
89
    expect(first.npub).not.toBe(second.npub);
90
    expect(first.walletAddress).not.toBe(second.walletAddress);
91
    expect(generateSeedPhrase(24).split(" ")).toHaveLength(24);
92
  });
93
94
  it("agrees with the frozen sovereign-identity contract, field for field", () => {
95
    const cli = deriveSeedIdentity(TEST_PHRASE);
96
    const frozen = deriveSovereignIdentityPublic(TEST_PHRASE);
97
    expect(cli.npub).toBe(frozen.npub);
98
    expect(cli.nostrPublicKeyHex).toBe(frozen.nostrPublicKeyHex);
99
    expect(cli.nostrDerivationPath).toBe(frozen.nostrDerivationPath);
100
    expect(cli.walletPublicKeyHex).toBe(frozen.sparkPublicKeyHex);
101
    expect(cli.walletFingerprintHex).toBe(frozen.sparkBip32FingerprintHex);
102
    expect(cli.walletDerivationPath).toBe(frozen.sparkDerivationPath);
103
    expect(cli.profile).toBe(frozen.derivationProfile);
104
    // And the package's own committed vector, so neither side can drift alone.
105
    expect(cli.npub).toBe(PUBLIC_TEST_IDENTITY_EMPTY_PASSPHRASE.npub);
106
  });
107
});
108
109
describe("seed storage", () => {
110
  it("writes the phrase 0600 and reads it back unchanged", () => {
111
    isolatedIdentityDirectory();
112
    expect(seedPresent()).toBe(false);
113
    const path = writeSeedPhrase(`  ${TEST_PHRASE}  `);
114
    expect(path).toBe(seedPath());
115
    expect(seedPresent()).toBe(true);
116
    expect(statSync(path).mode & 0o777).toBe(0o600);
117
    expect(readSeedPhrase()).toBe(TEST_PHRASE);
118
    expect(deriveSeedIdentity(readSeedPhrase() ?? "").npub).toBe(FROZEN.npub);
119
  });
120
121
  it("restores 0600 when the file on disk was left readable", () => {
122
    isolatedIdentityDirectory();
123
    const path = writeSeedPhrase(TEST_PHRASE);
124
    chmodSync(path, 0o644);
125
    writeSeedPhrase(TEST_PHRASE);
126
    expect(statSync(path).mode & 0o777).toBe(0o600);
127
  });
128
129
  it("writes nothing when the phrase is not a valid mnemonic", () => {
130
    isolatedIdentityDirectory();
131
    expect(() => writeSeedPhrase("not a seed phrase at all")).toThrow(/valid English BIP-39/);
132
    expect(seedPresent()).toBe(false);
133
  });
134
135
  it("reports no seed for an absent or empty file, and forgets idempotently", () => {
136
    const directory = isolatedIdentityDirectory();
137
    expect(readSeedPhrase()).toBeUndefined();
138
    expect(forgetSeedPhrase()).toBe(false);
139
    writeFileSync(join(directory, "seed"), "   \n", { mode: 0o600 });
140
    expect(readSeedPhrase()).toBeUndefined();
141
    writeSeedPhrase(TEST_PHRASE);
142
    expect(forgetSeedPhrase()).toBe(true);
143
    expect(seedPresent()).toBe(false);
144
    expect(forgetSeedPhrase()).toBe(false);
145
  });
146
147
  it("keeps the seed out of everything except the seed file", () => {
148
    const directory = isolatedIdentityDirectory();
149
    writeSeedPhrase(TEST_PHRASE);
150
    const identity = deriveSeedIdentity(TEST_PHRASE);
151
    expect(readFileSync(join(directory, "seed"), "utf8")).toContain(TEST_PHRASE);
152
    expect(JSON.stringify(identity)).not.toContain("abandon");
153
    expect(JSON.stringify(identity)).not.toContain("nsec");
154
  });
155
});
packages/openagents-cli/test/trace-store.test.ts modified +24

@@ -236,6 +236,19 @@ describe("trace redaction", () => {

236 236
      text: `exported OPENAI_API_KEY="quoted-secret-value"`,
237 237
      secret: "quoted-secret-value",
238 238
    },
239
    {
240
      // The published BIP-39 test phrase, not anyone's seed. `openagents
241
      // identity` gives every machine one of these to keep, so a phrase pasted
242
      // into a session is now a shape a redacted export has to remove.
243
      category: "seed_phrase",
244
      text: "my backup is abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ok",
245
      secret: "abandon abandon",
246
    },
247
    {
248
      category: "private_key",
249
      text: "signing with nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5 today",
250
      secret: "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
251
    },
239 252
  ];
240 253
241 254
  it.each(plantedSecrets)("removes a planted $category", ({ category, secret, text }) => {

@@ -267,6 +280,17 @@ describe("trace redaction", () => {

267 280
    expect(result.text).toBe("The agent listed files and wrote a summary.");
268 281
  });
269 282
283
  it("keeps a twelve-word run of ordinary words, and keeps the public npub", () => {
284
    const prose =
285
      "the coder read every file that the reviewer had marked before the second pass began";
286
    const npub = "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7";
287
    const result = redactText(`${prose} for ${npub}`, rules);
288
    expect(result.text).toContain(prose);
289
    expect(result.text).toContain(npub);
290
    expect(result.counts["seed_phrase"]).toBeUndefined();
291
    expect(result.counts["private_key"]).toBeUndefined();
292
  });
293
270 294
  it("shapes the sibling path for json, jsonl, and other names", () => {
271 295
    expect(redactedPathFor("/a/trace.json")).toBe("/a/trace.redacted.json");
272 296
    expect(redactedPathFor("/a/rollout.jsonl")).toBe("/a/rollout.redacted.jsonl");
pnpm-lock.yaml modified +12

@@ -2220,6 +2220,15 @@ importers:

2220 2220
      '@noble/hashes':
2221 2221
        specifier: 1.7.1
2222 2222
        version: 1.7.1
2223
      '@scure/base':
2224
        specifier: 1.2.4
2225
        version: 1.2.4
2226
      '@scure/bip32':
2227
        specifier: 1.6.2
2228
        version: 1.6.2
2229
      '@scure/bip39':
2230
        specifier: 1.5.4
2231
        version: 1.5.4
2223 2232
      effect:
2224 2233
        specifier: 4.0.0-beta.94
2225 2234
        version: 4.0.0-beta.94

@@ -2230,6 +2239,9 @@ importers:

2230 2239
        specifier: 8.21.1
2231 2240
        version: 8.21.1
2232 2241
    devDependencies:
2242
      '@openagentsinc/sovereign-identity':
2243
        specifier: workspace:*
2244
        version: link:../sovereign-identity
2233 2245
      '@types/node':
2234 2246
        specifier: 24.13.1
2235 2247
        version: 24.13.1

This page updates live while a promote is in flight · changelog