@needmoretruth/nmts-cli 0.17.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.
Files changed (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,122 @@
1
+ // `nmts wallet` — which wallet this account code opens, and what the chain says is in it.
2
+ //
3
+ // ⛔ IT READS. Nothing in this command signs, sends, swaps or spends, and there is no option that
4
+ // makes it. That is why it asks for no agreement: the agreement ladder stops a program from
5
+ // signing with a wallet on somebody's behalf, and asking for it here — where nothing can move —
6
+ // would teach a person to grant the one key that matters in order to look at a number.
7
+ //
8
+ // ⛔ THE ADDRESS AND THE BALANCES ARE DIFFERENT KINDS OF FACT, so they fail differently. The
9
+ // address is computed on this machine from the account code and cannot fail for any reason
10
+ // outside it; the balances come from a public node that can be slow, wrong or down. `nmts
11
+ // wallet address` is the half that never needs a network, and it exists because "what is my
12
+ // address" is the question somebody asks when the network is the thing that is broken.
13
+ //
14
+ // ⛔ AND A BALANCE THAT COULD NOT BE READ IS PRINTED AS THAT, NEVER AS ZERO — in words for a
15
+ // person, as `"read": false` for a program, and as a non-zero exit code for whatever is
16
+ // checking only that. An empty wallet and an unanswered question look identical on a screen,
17
+ // and one of them is a reason to stop.
18
+ import { requireAccountCode } from "../code-access.js";
19
+ import { readCredentialsFile } from "../credentials.js";
20
+ import { NmtsError } from "../errors.js";
21
+ import { resolveNetwork } from "../network.js";
22
+ import { BINARY_NAME } from "../product.js";
23
+ import { resolveServer } from "../server.js";
24
+ import { coinAmount, readBalances, walCoinType, walletAddress, SUI_COIN_TYPE, } from "../wallet.js";
25
+ /** What the operand may say. Anything else is a command line to correct, not a guess to act on. */
26
+ const MODES = ["address", "balance"];
27
+ function modeOf(what) {
28
+ if (what === undefined || what === "balance")
29
+ return "balance";
30
+ if (what === "address")
31
+ return "address";
32
+ throw new NmtsError(`\`${BINARY_NAME} wallet ${what}\` is not something this command does.`, {
33
+ exitCode: 2,
34
+ nextStep: `\`${BINARY_NAME} wallet\` shows the address and the balances, and ` +
35
+ `\`${BINARY_NAME} wallet address\` shows the address alone, without touching a network.`,
36
+ });
37
+ }
38
+ export async function wallet(what, options = {}) {
39
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
40
+ const mode = modeOf(what);
41
+ const resolved = await requireAccountCode();
42
+ const address = await walletAddress(resolved.code);
43
+ if (mode === "address") {
44
+ if (options.json) {
45
+ // ⛔ NO `network` FIELD. There is no network in this answer — an account has one wallet and
46
+ // it is called the same thing on every chain — and a field naming one would invite a
47
+ // reader to believe this address was looked up somewhere.
48
+ say(JSON.stringify({ address }));
49
+ return 0;
50
+ }
51
+ say(`Address ${address}`);
52
+ say(``);
53
+ say(` Derived on this machine from the account code. Nothing was asked of the NMTS server or`);
54
+ say(` of any chain, so this says what the wallet is called and nothing about what is in it.`);
55
+ say(` The browser app derives the same address from the same code.`);
56
+ return 0;
57
+ }
58
+ // ⛔ THE SERVER IS RESOLVED BUT NEVER CALLED. It is here for one thing only: the live server
59
+ // implies mainnet, and which chain to ask is a question this tool refuses to guess at
60
+ // (`network.ts`). Reading the stored file matches `whoami` — somebody who signed in against
61
+ // a development stack on testnet must not be shown a mainnet answer.
62
+ const stored = resolved.source === "file" || resolved.source === "file-locked" ? readCredentialsFile() : null;
63
+ const server = resolveServer(options.server ?? stored?.server);
64
+ const network = resolveNetwork(server, options.network ?? stored?.network);
65
+ const open = options.openChain ??
66
+ (async (net, addr) => (await import("../wallet-chain.js")).chainReader(net, addr));
67
+ const walType = walCoinType(network);
68
+ const balances = await readBalances(await open(network, address), walType);
69
+ if (options.json) {
70
+ say(JSON.stringify({
71
+ address,
72
+ network,
73
+ sui: asJson(balances.sui, SUI_COIN_TYPE),
74
+ wal: asJson(balances.wal, walType),
75
+ }));
76
+ return exitCodeFor(balances);
77
+ }
78
+ say(`Address ${address}`);
79
+ say(`Network ${network}`);
80
+ say(`SUI ${inWords(balances.sui)}`);
81
+ say(`WAL ${inWords(balances.wal)}`);
82
+ say(``);
83
+ say(` The address is derived on this machine from the account code; the balances were read`);
84
+ say(` from the ${network} chain just now, and they move without this tool.`);
85
+ if (!balances.sui.read || !balances.wal.read) {
86
+ say(` A balance that could not be read is printed as that and never as zero, and this command`);
87
+ say(` exits non-zero when it happens — so nothing driving it reads a missing number as an`);
88
+ say(` empty wallet.`);
89
+ }
90
+ say(` Nothing here signs or spends. \`${BINARY_NAME} extend\` is the one command that signs, and it`);
91
+ say(` asks for a separate agreement before it does.`);
92
+ return exitCodeFor(balances);
93
+ }
94
+ /**
95
+ * ⛔ AN UNREAD BALANCE IS A FAILURE, even beside one that answered. A program that checks the exit
96
+ * code and nothing else is the ordinary case, and telling it "fine" while half the answer is
97
+ * missing is the same mistake as printing the missing half as zero.
98
+ */
99
+ function exitCodeFor(balances) {
100
+ return balances.sui.read && balances.wal.read ? 0 : 1;
101
+ }
102
+ /** One balance, for a person. */
103
+ function inWords(balance) {
104
+ return balance.read ? coinAmount(balance.baseUnits) : `⛔ could not be read — ${balance.why}`;
105
+ }
106
+ /**
107
+ * One balance, for a program.
108
+ *
109
+ * ⚠ `baseUnits` IS A STRING. A balance can hold more digits than a JSON number keeps, and a
110
+ * silently rounded total is worse than no total. `amount` is the same value written as a person
111
+ * reads it, exactly, so a caller never has to divide.
112
+ */
113
+ function asJson(balance, coinType) {
114
+ if (!balance.read)
115
+ return { coinType, read: false, error: balance.why };
116
+ return {
117
+ coinType,
118
+ read: true,
119
+ baseUnits: balance.baseUnits.toString(),
120
+ amount: coinAmount(balance.baseUnits),
121
+ };
122
+ }
@@ -0,0 +1,33 @@
1
+ // `nmts whoami` — which account the code on this machine belongs to.
2
+ //
3
+ // ⚠ IT ANSWERS OFFLINE, AND IT SAYS WHAT IT DID NOT DO. Everything printed is derived from the
4
+ // code on this machine; nothing is asked of a server. So it proves the code is well-formed and
5
+ // which account it is, and it proves nothing about whether that account exists, has credits, or
6
+ // is signed in. Printing an account id without saying that would read as "connected".
7
+ import { identityOf } from "../account.js";
8
+ import { requireAccountCode } from "../code-access.js";
9
+ import { CODE_ENV_VAR, readCredentialsFile } from "../credentials.js";
10
+ import { BINARY_NAME } from "../product.js";
11
+ import { resolveNetwork } from "../network.js";
12
+ import { resolveServer } from "../server.js";
13
+ export async function whoami(options = {}) {
14
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
15
+ const resolved = await requireAccountCode();
16
+ const identity = await identityOf(resolved.code);
17
+ // ⛔ BOTH STORED SHAPES, and an adversarial review is why. This read `source === "file"` only,
18
+ // which was right while the file always held the code in the clear — and became wrong the
19
+ // day `login` started sealing by default. Somebody who ran `login --server … --network
20
+ // testnet` was then told, silently, that their account was on the live server and mainnet.
21
+ const stored = resolved.source === "file" || resolved.source === "file-locked" ? readCredentialsFile() : null;
22
+ const server = resolveServer(options.server ?? stored?.server);
23
+ const network = resolveNetwork(server, options.network ?? stored?.network);
24
+ say(`Account id ${identity.accountId}`);
25
+ say(`Public code ${identity.publicCode}`);
26
+ say(`Code from ${resolved.source === "env" ? `${CODE_ENV_VAR} (not stored)` : "this machine"}`);
27
+ say(`Server ${server}`);
28
+ say(`Network ${network}`);
29
+ say(``);
30
+ say(` Derived on this machine. Nothing was asked of the server, so this does not say whether`);
31
+ say(` the account exists there, has credits, or is signed in.`);
32
+ return 0;
33
+ }
@@ -0,0 +1,200 @@
1
+ // What this machine's owner has agreed to, and the few things worth asking about.
2
+ //
3
+ // ⛔ THE DEFAULT IS SAFE AND NOTHING IS UNREACHABLE. A tool that simply refuses to do a risky
4
+ // thing does not prevent the risky thing — it gets forked, or worked around with a shell
5
+ // script, and then it happens with no warning at all and no record that anybody chose it. So
6
+ // every capability is here; what changes is whether it happens by accident.
7
+ //
8
+ // ⛔ FIVE KEYS, NOT TWENTY. A tool that asks about everything trains the person to say yes
9
+ // without reading, and then the one question that mattered is the one they clicked through.
10
+ // The bar for a key existing is one of: it cannot be undone · it costs money · it puts the
11
+ // account code somewhere that is not this tool's sealed file. Anything else happens without
12
+ // asking, and the count is written here so that adding a sixth has to be a decision.
13
+ // ⚠ The fifth was added for sharing, which is the first thing this tool can do that hands
14
+ // something to a person who is not the account holder — and the only one whose undo does not
15
+ // undo what already happened.
16
+ //
17
+ // ⛔ ONCE PER MACHINE, NOT ONCE PER RUN. The record is on disk, so an agent working through fifty
18
+ // files is asked nothing after the first time — which is the whole point of writing it down.
19
+ //
20
+ // ⚠ WHAT THIS CANNOT DO. Nothing here can tell whether a person or a program typed the grant
21
+ // command; no command-line tool can. What it CAN do is make the risk impossible to miss, put
22
+ // the decision in one obvious place, and keep a dated record of it. The instruction that an
23
+ // agent must not grant these on somebody's behalf is in AGENTS.md, and it is a rule rather
24
+ // than a mechanism. Saying otherwise would be claiming a protection that is not there.
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { configDir, modesAreEnforced } from "./credentials.js";
28
+ import { NmtsError } from "./errors.js";
29
+ import { BINARY_NAME, SUPPORT_EMAIL } from "./product.js";
30
+ export const CONSENTS = {
31
+ /**
32
+ * Spending the account's credits.
33
+ *
34
+ * Asked once because the price of every single upload is printed anyway — what this covers is
35
+ * the person understanding that this tool can spend at all, which is not obvious from a command
36
+ * called `put`.
37
+ */
38
+ spend: {
39
+ what: "Spend this account's credits on storage.",
40
+ risk: "Credits are consumed and are not refundable. Storage is bought on a public network and " +
41
+ "cannot be un-bought. The price of each upload is printed before it happens, and daily " +
42
+ "ceilings apply on the server whatever this machine asks for.",
43
+ limit: "This does not cover anything spent from a wallet. That is a separate agreement, and this " +
44
+ "one does not grant it.",
45
+ },
46
+ /**
47
+ * Writing the account code down UNSEALED — in the clear, in this tool's own file.
48
+ *
49
+ * ⛔ THE DEFAULT IS THE SEALED FORM, and this key is what unlocks the other one (owner,
50
+ * 2026-08-23: support storing it, but only behind encryption unless somebody agrees to a
51
+ * disclaimer). It covers both the ordinary case, where mode 600 is the only protection and
52
+ * every program running as you can read it, and the worse one, where the filesystem cannot
53
+ * keep the mode either — a Windows share, some container mounts, a network drive. `login`
54
+ * prints which of the two this machine is before it asks.
55
+ *
56
+ * ⚠ IT WAS NARROWER UNTIL 2026-08-23, covering only the filesystem case. Nothing had shipped
57
+ * under the old meaning — the tool is not published — so no grant anywhere means less than
58
+ * the words above. If it ever does, this needs a new key rather than a wider one: a grant
59
+ * means what it said on the day it was given.
60
+ */
61
+ "unsafe-code-storage": {
62
+ what: "Store the account code in the clear, unsealed, in this tool's own file.",
63
+ risk: "The account code opens every file in this account and derives its wallet. Written in the " +
64
+ "clear it is readable by anything running as you — every agent, every script, every " +
65
+ "backup that copies your home directory, every image layer built from it. Where the " +
66
+ "filesystem cannot keep a file private, it is readable by others as well.",
67
+ limit: "There is no recovery from a leaked code: it cannot be changed while it still opens the " +
68
+ "files it opened. The alternatives are the sealed form, which is what `login` does by " +
69
+ "default, and a secret file the code is read from and never copied into.",
70
+ },
71
+ /**
72
+ * Reading the code out of a plain environment variable, or printing one to be set.
73
+ *
74
+ * ⛔ IT IS A DIFFERENT PLACE, NOT A SMALLER VERSION OF THE FILE (owner, 2026-08-23: an agent
75
+ * must be able to choose this, with the person's agreement). An environment variable leaks
76
+ * through channels a file does not: `docker inspect` prints the whole environment of a
77
+ * container, `/proc/<pid>/environ` is readable by the same user for as long as the process
78
+ * lives, every child process inherits it, and CI systems echo it into logs. A variable that
79
+ * names a FILE — `NMTS_ACCOUNT_CODE_FILE` — has none of those, which is why it needs no
80
+ * agreement.
81
+ */
82
+ "plain-env": {
83
+ what: "Use the account code from a plain environment variable, or print one to be set.",
84
+ risk: "An environment variable is not private to the program that reads it. `docker inspect` " +
85
+ "prints the entire environment of a container, anything running as you can read " +
86
+ "/proc/<pid>/environ while the process lives, every child process inherits it, and " +
87
+ "continuous-integration systems commonly write it into logs.",
88
+ limit: "This covers this tool reading it and printing it. It cannot cover where you put it " +
89
+ "afterwards, and it does not make the variable private. Naming a file instead — " +
90
+ "NMTS_ACCOUNT_CODE_FILE — avoids all of the above and asks for nothing.",
91
+ },
92
+ /**
93
+ * Signing a chain transaction with the wallet the account code derives.
94
+ *
95
+ * Separate from `spend` because it is a different pot of money: credits are a promise this
96
+ * service made, and a wallet holds assets nobody can restore.
97
+ */
98
+ /**
99
+ * Handing a file to another account.
100
+ *
101
+ * ⛔ IT IS HERE BECAUSE THE UNDO DOES NOT UNDO IT. Every other irreversible thing in this tool
102
+ * costs money or moves the account code; this one gives somebody else a copy of a file, and
103
+ * taking the share back afterwards stops future downloads and reaches nothing already
104
+ * fetched. That gap is not a flaw to be fixed later — it is what handing somebody a file
105
+ * means — so it is said before the first share rather than after it.
106
+ */
107
+ share: {
108
+ what: "Give another account the key to one of this account's files.",
109
+ risk: "Whoever holds that address can then download the file. Withdrawing the share stops further " +
110
+ "downloads and cannot reach a copy they already have. The address is typed by you and is " +
111
+ "not checked against a person — a share sent to the wrong address is sent.",
112
+ limit: "This does not cover uploading, spending, or anything to do with a wallet. It covers giving " +
113
+ "away files this account already holds.",
114
+ },
115
+ wallet: {
116
+ what: "Use the wallet this account code derives, and sign transactions with it.",
117
+ risk: "A signed transaction moves real assets and cannot be reversed by anybody, including NMTS. " +
118
+ "A mistake here is permanent.",
119
+ limit: "Only what this tool signs. Handing the account code to another program gives that program " +
120
+ "the same wallet, and nothing here can see that happen.",
121
+ },
122
+ };
123
+ /**
124
+ * ⛔ DERIVED FROM THE TABLE, so a key added above cannot be missing from the checks below.
125
+ * Written as `Object.keys` of the table rather than a hand-kept array: a hand-kept one goes
126
+ * stale silently, and the failure is a capability nobody is ever asked about.
127
+ */
128
+ export const CONSENT_KEYS = Object.keys(CONSENTS);
129
+ function path() {
130
+ return join(configDir(), "consent.json");
131
+ }
132
+ function read() {
133
+ try {
134
+ const parsed = JSON.parse(readFileSync(path(), "utf8"));
135
+ if (typeof parsed !== "object" || parsed === null)
136
+ return {};
137
+ return parsed;
138
+ }
139
+ catch {
140
+ // ⛔ Unreadable counts as NOT granted. The fail-safe direction for "I do not know" is to ask
141
+ // again — a consent record that switches itself on when it cannot be read is not a record.
142
+ return {};
143
+ }
144
+ }
145
+ /** When this key was agreed to on this machine, or null. */
146
+ export function grantedAt(key) {
147
+ return read()[key]?.grantedAt ?? null;
148
+ }
149
+ export function isGranted(key) {
150
+ return grantedAt(key) !== null;
151
+ }
152
+ /** Write the grant down, with the date and the version that asked. */
153
+ export function grant(key, version, now) {
154
+ const all = read();
155
+ all[key] = { grantedAt: now.toISOString(), byVersion: version };
156
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
157
+ writeFileSync(path(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
158
+ if (modesAreEnforced())
159
+ chmodSync(path(), 0o600);
160
+ }
161
+ export function revoke(key) {
162
+ const all = read();
163
+ delete all[key];
164
+ if (!existsSync(path()) && Object.keys(all).length === 0)
165
+ return;
166
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
167
+ writeFileSync(path(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
168
+ }
169
+ /**
170
+ * Stop unless this has been agreed to, and say exactly what agreeing would mean.
171
+ *
172
+ * ⛔ THE MESSAGE IS THE PRODUCT HERE. It is the only thing standing between somebody and a
173
+ * decision they cannot take back, so it says what happens, what can go wrong, what is not
174
+ * covered, and the one command that agrees — in that order, every time.
175
+ */
176
+ export function requireConsent(key) {
177
+ if (isGranted(key))
178
+ return;
179
+ const consent = CONSENTS[key];
180
+ throw new NmtsError(consent.what, {
181
+ exitCode: 5,
182
+ nextStep: [
183
+ consent.risk,
184
+ "",
185
+ consent.limit,
186
+ "",
187
+ `NMTS is not responsible for what is done with this account by any program running on this`,
188
+ `machine, including an AI agent. The published Terms are what govern the service; this is a`,
189
+ `warning, not a substitute for them.`,
190
+ "",
191
+ `To agree, on this machine, once: ${BINARY_NAME} consent grant ${key}`,
192
+ `To see what has been agreed to: ${BINARY_NAME} consent`,
193
+ "",
194
+ `⛔ If a program is reading this on somebody's behalf: show it to them and let them decide.`,
195
+ ` Do not run the grant command yourself.`,
196
+ "",
197
+ `Something wrong or confusing here? ${SUPPORT_EMAIL}`,
198
+ ].join("\n"),
199
+ });
200
+ }
@@ -0,0 +1,311 @@
1
+ // Where an account code lives on this machine, and every rule about how it gets there.
2
+ //
3
+ // ⛔ NEVER FROM THE COMMAND LINE. On Linux any process can read /proc/<pid>/cmdline, so a secret
4
+ // passed as an argument is readable by anything running as the same user for as long as the
5
+ // process lives — and it lands in the shell history besides. NMTS's standalone recovery tool
6
+ // learned this the same way, in an adversarial review, and stopped passing its own token as an
7
+ // argument. So there is no --code flag here, and there never should be: the ways in are an
8
+ // environment variable and the terminal.
9
+ //
10
+ // ⛔ NEVER IN AN ERROR MESSAGE. Every failure below names the FILE, never the value. An error
11
+ // string is the one place a secret escapes without anybody choosing to print it.
12
+ //
13
+ // ⚠ WHAT THE FILE MODE DOES AND DOES NOT DO. On Linux and macOS the file is created 0600, so
14
+ // other users on the machine cannot read it. That is the whole of the protection: it is not
15
+ // encrypted, and it does not stop anything running as YOU — which includes every agent you run.
16
+ // On Windows Node ignores the mode argument entirely and the file inherits directory
17
+ // permissions; saying otherwise would be claiming a guarantee the platform does not give.
18
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
19
+ import { homedir, tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { isLockedCode } from "./code-vault.js";
22
+ import { NmtsError } from "./errors.js";
23
+ /** The environment variable an agent sets instead of running `login`. */
24
+ export const CODE_ENV_VAR = "NMTS_ACCOUNT_CODE";
25
+ /** The API key, same rules: read fresh, never an argument, never printed. */
26
+ export const API_KEY_ENV_VAR = "NMTS_API_KEY";
27
+ /**
28
+ * Opens a passphrase-locked stored code without a terminal.
29
+ *
30
+ * ⚠ A passphrase in an environment variable protects the FILE, not this run: anything that can
31
+ * read this variable can read the code the moment the tool decodes it. What it still buys is
32
+ * real — a copied home directory, a backup, an image layer and a stolen disk all yield nothing
33
+ * — and it is the only shape that works where no person is present to type.
34
+ */
35
+ export const PASSPHRASE_ENV_VAR = "NMTS_PASSPHRASE";
36
+ /**
37
+ * Names a FILE holding the account code, rather than holding it directly.
38
+ *
39
+ * ⛔ THE ONLY SAFE WAY TO GIVE A CONTAINER A SECRET. See `readSecretFile`.
40
+ */
41
+ export const CODE_FILE_ENV_VAR = "NMTS_ACCOUNT_CODE_FILE";
42
+ /** Names a file holding the API key. Same reason. */
43
+ export const API_KEY_FILE_ENV_VAR = "NMTS_API_KEY_FILE";
44
+ /** Directory holding everything this tool keeps. 0700 where the platform honours it. */
45
+ export function configDir() {
46
+ const override = process.env["NMTS_CONFIG_DIR"];
47
+ return override && override.length > 0 ? override : join(homedir(), ".nmts");
48
+ }
49
+ export function credentialsPath() {
50
+ return join(configDir(), "credentials.json");
51
+ }
52
+ /** True on platforms where Node applies a POSIX file mode. */
53
+ export function modesAreEnforced() {
54
+ return process.platform !== "win32";
55
+ }
56
+ /**
57
+ * Can this machine actually keep a file private, where the account code would go?
58
+ *
59
+ * ⛔ IT MEASURES RATHER THAN ASSUMES. "Not Windows" is not the same question: a container with a
60
+ * bind mount from a Windows host, a network drive, an exFAT stick and several FUSE filesystems
61
+ * all accept `chmod` and then ignore it. The mode comes back as whatever the filesystem felt
62
+ * like, and the tool would have written the account code into a file anybody can read while
63
+ * believing it had locked it.
64
+ *
65
+ * So: write a file, ask for 0600, read the mode back, and delete it. The probe is empty, its name
66
+ * is not the credentials name, and it is removed whatever happens.
67
+ *
68
+ * Returns false on Windows without probing — the platform has no POSIX mode to check, and
69
+ * answering "yes" from a successful no-op would be the worst of the three possible answers.
70
+ */
71
+ export function codeStorageIsPrivate() {
72
+ if (!modesAreEnforced())
73
+ return false;
74
+ const dir = configDir();
75
+ const probe = join(dir, `.mode-probe.${process.pid}.tmp`);
76
+ try {
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ writeFileSync(probe, "", { mode: 0o600, flag: "wx" });
79
+ chmodSync(probe, 0o600);
80
+ return (statSync(probe).mode & 0o077) === 0;
81
+ }
82
+ catch {
83
+ // ⛔ Could not tell counts as NOT private. The fail-safe direction for "I do not know" is to
84
+ // warn: a wrong warning costs one command, a wrong silence costs the account.
85
+ return false;
86
+ }
87
+ finally {
88
+ try {
89
+ rmSync(probe, { force: true });
90
+ }
91
+ catch {
92
+ // An undeleted empty probe file is harmless; failing the login over it would not be.
93
+ }
94
+ }
95
+ }
96
+ /**
97
+ * Write credentials, replacing any existing file, without ever leaving a readable window.
98
+ *
99
+ * The write goes to a fresh file created with `wx` (fails if the name exists, so nothing already
100
+ * on disk is opened) and is then renamed over the target. A rename within the same directory is
101
+ * atomic, so a reader either sees the old file or the new one and never a half-written one.
102
+ */
103
+ export function writeCredentials(creds) {
104
+ const dir = configDir();
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ if (modesAreEnforced())
107
+ chmodSync(dir, 0o700);
108
+ const target = credentialsPath();
109
+ const scratch = join(dir, `.credentials.${process.pid}.${Date.now()}.tmp`);
110
+ // ⛔ THE SCRATCH FILE NEVER OUTLIVES A FAILURE. It holds exactly what the target would — the
111
+ // account code, in the clear when that is the shape being written — and `logout` removes
112
+ // only `credentials.json`, so one left behind by a full disk or a failed rename would sit
113
+ // there unreferenced and unnoticed. The rename is the last thing that happens; anything that
114
+ // stops before it leaves nothing.
115
+ let renamed = false;
116
+ try {
117
+ writeFileSync(scratch, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600, flag: "wx" });
118
+ if (modesAreEnforced())
119
+ chmodSync(scratch, 0o600);
120
+ renameSync(scratch, target);
121
+ renamed = true;
122
+ }
123
+ finally {
124
+ if (!renamed) {
125
+ try {
126
+ rmSync(scratch, { force: true });
127
+ }
128
+ catch {
129
+ // Nothing more can be done about it here, and throwing from a `finally` would replace the
130
+ // real failure with this one.
131
+ }
132
+ }
133
+ }
134
+ }
135
+ /** Raised when the file exists but this machine is not keeping it private. */
136
+ export class CredentialsTooOpenError extends Error {
137
+ // ⛔ Explicit fields, not constructor parameter properties: Node runs these files by ERASING the
138
+ // types, and a parameter property is not erasable — it has to emit an assignment. `tsc`
139
+ // refuses it here too (`erasableSyntaxOnly`), so the rule is machine-held, not remembered.
140
+ path;
141
+ mode;
142
+ constructor(path, mode) {
143
+ super(`${path} can be read by other users on this machine (mode ${mode.toString(8)}). ` +
144
+ `Run: chmod 600 ${path}`);
145
+ this.name = "CredentialsTooOpenError";
146
+ this.path = path;
147
+ this.mode = mode;
148
+ }
149
+ }
150
+ /**
151
+ * Read credentials from disk, refusing a file other users can read.
152
+ *
153
+ * ⛔ The refusal is the point. A credentials file that went world-readable — copied with `cp -r`,
154
+ * restored from an archive, written by an older version — is a leak that nothing else in the
155
+ * system would ever mention. Reading it anyway and carrying on is how that stays quiet.
156
+ */
157
+ export function readCredentialsFile() {
158
+ const path = credentialsPath();
159
+ let raw;
160
+ try {
161
+ if (modesAreEnforced()) {
162
+ const mode = statSync(path).mode & 0o777;
163
+ if ((mode & 0o077) !== 0)
164
+ throw new CredentialsTooOpenError(path, mode);
165
+ }
166
+ raw = readFileSync(path, "utf8");
167
+ }
168
+ catch (error) {
169
+ if (error instanceof CredentialsTooOpenError)
170
+ throw error;
171
+ if (isNotFound(error))
172
+ return null;
173
+ throw error;
174
+ }
175
+ // ⛔ THE PARSER'S OWN MESSAGE NEVER ESCAPES. V8 quotes about thirty characters of the input in
176
+ // `Unexpected token …`, and the input here is the file the account code is in — an
177
+ // adversarial review printed nine symbols of a real code to stderr from a single lost quote.
178
+ // `errors.ts` prints an unknown error's message verbatim, so the only place to stop it is
179
+ // here, before it becomes an error at all.
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(raw);
183
+ }
184
+ catch {
185
+ throw unusable(path, "is not readable as JSON");
186
+ }
187
+ if (!isCredentials(parsed))
188
+ throw unusable(path, "is not a credentials file this version understands");
189
+ return parsed;
190
+ }
191
+ /**
192
+ * The one refusal for a file this version cannot use, in the one wording.
193
+ *
194
+ * ⛔ IT NAMES THE PATH AND NOTHING ELSE. Not the contents, not the parser's complaint, not which
195
+ * field was wrong — the file is the account code's file, and every one of those quotes it.
196
+ *
197
+ * ⚠ AND IT IS AN `NmtsError` WITH A WAY OUT. It used to be a bare `Error`, which meant the
198
+ * generic exit code and no next step: `whoami`, `ls`, `get` and `put` all died at 1 saying
199
+ * only that the file was not understood, and the only command that could fix it — `logout` —
200
+ * was the one nobody was told to run.
201
+ */
202
+ function unusable(path, what) {
203
+ return new NmtsError(`${path} ${what}.`, {
204
+ exitCode: 3,
205
+ nextStep: `Nothing was read from it. \`nmts logout\` removes it, and \`nmts login\` writes a fresh ` +
206
+ `one. If that file is the only copy of the account code, take the code out of it by hand ` +
207
+ `first — this tool will not open it.`,
208
+ });
209
+ }
210
+ function isNotFound(error) {
211
+ return (typeof error === "object" &&
212
+ error !== null &&
213
+ "code" in error &&
214
+ error.code === "ENOENT");
215
+ }
216
+ function isCredentials(value) {
217
+ if (typeof value !== "object" || value === null)
218
+ return false;
219
+ const v = value;
220
+ const plain = typeof v["accountCode"] === "string" && v["accountCode"].length > 0;
221
+ const locked = isLockedCode(v["lockedCode"]);
222
+ // ⛔ Exactly one. Neither means there is no code to use; both means somebody merged two files.
223
+ if (plain === locked)
224
+ return false;
225
+ if (typeof v["server"] !== "string" || v["server"].length === 0)
226
+ return false;
227
+ if ("apiKey" in v && typeof v["apiKey"] !== "string")
228
+ return false;
229
+ if ("network" in v && typeof v["network"] !== "string")
230
+ return false;
231
+ return true;
232
+ }
233
+ /**
234
+ * The account code this run should use, and where it came from.
235
+ *
236
+ * The environment variable wins over the file so an agent can be handed a code for one run without
237
+ * writing anything to disk — which is the safer shape when the machine is shared or ephemeral.
238
+ */
239
+ export function resolveAccountCode() {
240
+ const fromEnv = process.env[CODE_ENV_VAR];
241
+ if (fromEnv !== undefined && fromEnv.length > 0)
242
+ return { code: fromEnv, source: "env" };
243
+ const fromSecret = readSecretFile(CODE_FILE_ENV_VAR);
244
+ if (fromSecret !== null)
245
+ return { code: fromSecret, source: "secret-file" };
246
+ const file = readCredentialsFile();
247
+ if (file?.accountCode !== undefined)
248
+ return { code: file.accountCode, source: "file" };
249
+ if (file?.lockedCode !== undefined)
250
+ return { locked: file.lockedCode, source: "file-locked" };
251
+ return null;
252
+ }
253
+ /**
254
+ * Read a secret out of the file an environment variable NAMES.
255
+ *
256
+ * ⛔ THIS IS HOW A SECRET GETS INTO A CONTAINER. An environment variable holding the value itself
257
+ * is readable by anybody who can inspect the container — `docker inspect` prints the whole
258
+ * environment, and so does the API behind it. A variable holding a PATH gives that reader a
259
+ * filename and nothing else, while the value rides in on a `--secret` mount, a tmpfs, or a
260
+ * bind-mounted file whose permissions the host controls. It is the convention the official
261
+ * database images use, for the same reason.
262
+ *
263
+ * Trailing whitespace and a trailing newline are removed: writing a secret to a file with `echo`
264
+ * appends one, and refusing a code because of it would be a puzzle with no clue.
265
+ */
266
+ export function readSecretFile(variable) {
267
+ const path = process.env[variable];
268
+ if (path === undefined || path.length === 0)
269
+ return null;
270
+ let raw;
271
+ try {
272
+ raw = readFileSync(path, "utf8");
273
+ }
274
+ catch (error) {
275
+ // ⛔ Named, not swallowed. A variable that points at nothing is a mistake somebody made on
276
+ // purpose — falling through to "not signed in" would send them looking at the wrong thing.
277
+ throw new NmtsError(`${variable} names ${path}, which could not be read.`, {
278
+ exitCode: 3,
279
+ nextStep: `Check the path and its permissions. Cause: ${error instanceof Error ? error.message : String(error)}`,
280
+ });
281
+ }
282
+ const value = raw.trim();
283
+ if (value.length === 0) {
284
+ throw new NmtsError(`${variable} names ${path}, which is empty.`, { exitCode: 3 });
285
+ }
286
+ return value;
287
+ }
288
+ /**
289
+ * The API key this run should use, and where it came from.
290
+ *
291
+ * ⚠ SEPARATE FROM THE ACCOUNT CODE ON PURPOSE, and the two can come from different places. The
292
+ * code is what opens the files; the key is only what makes the server answer without a human
293
+ * check. Somebody may keep the code in the environment for one run while the key stays on the
294
+ * machine, or the other way round, and neither combination is unusual enough to refuse.
295
+ */
296
+ export function resolveApiKey() {
297
+ const fromEnv = process.env[API_KEY_ENV_VAR];
298
+ if (fromEnv !== undefined && fromEnv.length > 0)
299
+ return { key: fromEnv, source: "env" };
300
+ const fromSecret = readSecretFile(API_KEY_FILE_ENV_VAR);
301
+ if (fromSecret !== null)
302
+ return { key: fromSecret, source: "secret-file" };
303
+ const file = readCredentialsFile();
304
+ if (file?.apiKey !== undefined && file.apiKey.length > 0)
305
+ return { key: file.apiKey, source: "file" };
306
+ return null;
307
+ }
308
+ /** Scratch location used only by tests that need a directory outside the real home. */
309
+ export function testConfigDir(name) {
310
+ return join(tmpdir(), `nmts-agents-test-${name}-${process.pid}`);
311
+ }