@atrib/emit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner. For the purposes
50
+ of this definition, "submitted" means any form of electronic, verbal,
51
+ or written communication sent to the Licensor or its representatives,
52
+ including but not limited to communication on electronic mailing lists,
53
+ source code control systems, and issue tracking systems that are managed
54
+ by, or on behalf of, the Licensor for the purpose of discussing and
55
+ improving the Work, but excluding communication that is conspicuously
56
+ marked or otherwise designated in writing by the copyright owner as
57
+ "Not a Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
+ whom a Contribution has been received by the Licensor and subsequently
61
+ incorporated within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ copyright license to reproduce, prepare Derivative Works of,
67
+ publicly display, publicly perform, sublicense, and distribute the
68
+ Work and such Derivative Works in Source or Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of
71
+ this License, each Contributor hereby grants to You a perpetual,
72
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
73
+ (except as stated in this section) patent license to make, have made,
74
+ use, offer to sell, sell, import, and otherwise transfer the Work,
75
+ where such license applies only to those patent claims licensable
76
+ by such Contributor that are necessarily infringed by their
77
+ Contribution(s) alone or by combination of their Contribution(s)
78
+ with the Work to which such Contribution(s) was submitted. If You
79
+ institute patent litigation against any entity (including a cross-claim
80
+ or counterclaim in a lawsuit) alleging that the Work or a Contribution
81
+ incorporated within the Work constitutes direct or contributory patent
82
+ infringement, then any patent licenses granted to You under this License
83
+ for that Work shall terminate as of the date such litigation is filed.
84
+
85
+ 4. Redistribution. You may reproduce and distribute copies of the
86
+ Work or Derivative Works thereof in any medium, with or without
87
+ modifications, and in Source or Object form, provided that You
88
+ meet the following conditions:
89
+
90
+ (a) You must give any other recipients of the Work or Derivative Works
91
+ a copy of this License; and
92
+
93
+ (b) You must cause any modified files to carry prominent notices
94
+ stating that You changed the files; and
95
+
96
+ (c) You must retain, in the Source form of any Derivative Works
97
+ that You distribute, all copyright, patent, trademark, and
98
+ attribution notices from the Source form of the Work,
99
+ excluding those notices that do not pertain to any part of
100
+ the Derivative Works; and
101
+
102
+ (d) If the Work includes a "NOTICE" text file as part of its
103
+ distribution, You must include a readable copy of the attribution
104
+ notices contained within such NOTICE file, in at least one
105
+ of the following places: within a NOTICE text file distributed
106
+ as part of the Derivative Works; within the Source form or
107
+ documentation, if provided along with the Derivative Works; or,
108
+ within a display generated by the Derivative Works, if and
109
+ wherever such third-party notices normally appear. The contents
110
+ of the NOTICE file are for informational purposes only and
111
+ do not modify the License. You may add Your own attribution
112
+ notices within Derivative Works that You distribute, alongside
113
+ or as an addendum to the NOTICE text from the Work, provided
114
+ that such additional attribution notices cannot be construed
115
+ as modifying the License.
116
+
117
+ You may add Your own license statement for Your modifications and
118
+ may provide additional grant of rights to use, modify, or distribute
119
+ those modifications in accordance with this License.
120
+
121
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
122
+ any Contribution intentionally submitted for inclusion in the Work
123
+ by You to the Licensor shall be under the terms and conditions of
124
+ this License, without any additional terms or conditions.
125
+ Notwithstanding the above, nothing herein shall supersede or modify
126
+ the terms of any separate license agreement you may have executed
127
+ with Licensor regarding such Contributions.
128
+
129
+ 6. Trademarks. This License does not grant permission to use the trade
130
+ names, trademarks, service marks, or product names of the Licensor,
131
+ except as required for reasonable and customary use in describing the
132
+ origin of the Work and reproducing the content of the NOTICE file.
133
+
134
+ 7. Disclaimer of Warranty. Unless required by applicable law or
135
+ agreed to in writing, Licensor provides the Work (and each
136
+ Contributor provides its Contributions) on an "AS IS" BASIS,
137
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
138
+ implied, including, without limitation, any warranties or conditions
139
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
140
+ PARTICULAR PURPOSE. You are solely responsible for determining the
141
+ appropriateness of using or reproducing the Work and assume any
142
+ risks associated with Your exercise of permissions under this License.
143
+
144
+ 8. Limitation of Liability. In no event and under no legal theory,
145
+ whether in tort (including negligence), contract, or otherwise,
146
+ unless required by applicable law (such as deliberate and grossly
147
+ negligent acts) or agreed to in writing, shall any Contributor be
148
+ liable to You for damages, including any direct, indirect, special,
149
+ incidental, or exemplary damages of any character arising as a
150
+ result of this License or out of the use or inability to use the
151
+ Work (including but not limited to damages for loss of goodwill,
152
+ work stoppage, computer failure or malfunction, or all other
153
+ commercial damages or losses), even if such Contributor has been
154
+ advised of the possibility of such damages.
155
+
156
+ 9. Accepting Warranty or Additional Liability. While redistributing
157
+ the Work or Derivative Works thereof, You may choose to offer,
158
+ and charge a fee for, acceptance of support, warranty, indemnity,
159
+ or other liability obligations and/or rights consistent with this
160
+ License. However, in accepting such obligations, You may act only
161
+ on Your own behalf and on Your sole responsibility, not on behalf
162
+ of any other Contributor, and only if You agree to indemnify,
163
+ defend, and hold each Contributor harmless for any liability
164
+ incurred by, or claims asserted against, such Contributor by reason
165
+ of your accepting any such warranty or additional liability.
166
+
167
+ END OF TERMS AND CONDITIONS
168
+
169
+ APPENDIX: How to apply the Apache License to your work.
170
+
171
+ To apply the Apache License to your work, attach the following
172
+ boilerplate notice, with the fields enclosed by brackets "[]"
173
+ replaced with your own identifying information. (Don't include
174
+ the brackets!) The text should be enclosed in the appropriate
175
+ comment syntax for the file format in question. Also, an optional
176
+ "Statement of Purpose" appears after the copyright notice.
177
+
178
+ Copyright 2025-2026 Atrib contributors
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
189
+ implied. See the License for the specific language governing
190
+ permissions and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # `@atrib/atrib-emit`
2
+
3
+ MCP server exposing the explicit `emit` tool — the producer-side cognitive primitive that lets an agent sign observations, annotations, and revisions under its own atrib identity, beyond what `@atrib/mcp` auto-signs.
4
+
5
+ ## Why this exists
6
+
7
+ `@atrib/mcp` middleware auto-signs every MCP tool call as it passes through. That captures the *mechanical* record-of-tool-call. But agents do plenty of cognitive work that doesn't go through MCP:
8
+ - Built-in tool calls (Read, Edit, Bash) don't pass through any MCP server.
9
+ - Reasoning steps, decisions, and revisions live in the agent's prose, not in a tool invocation.
10
+ - Annotations against prior records ("this one mattered, future-self should weight it heavy") have no auto-emit hook.
11
+
12
+ `atrib-emit` is the explicit signing tool the agent calls when it wants on-chain provenance for one of these. It complements `@atrib/mcp` rather than replacing it: the wrapper handles the mechanical capture; `atrib-emit` handles the explicit cognitive emit.
13
+
14
+ ## Tool surface
15
+
16
+ ```typescript
17
+ mcp__atrib-emit__emit({
18
+ // Required
19
+ event_type: string, // URI per spec §1.2.4. Common normative values:
20
+ // 'https://atrib.dev/v1/types/observation', '...annotation', '...revision'.
21
+ // Extension URIs in any namespace are also valid.
22
+ content: Record<string, unknown>, // Semantic content of the event. Stored in the local mirror,
23
+ // committed on-chain via content_id derived from event_type leaf.
24
+
25
+ // Optional
26
+ context_id?: string, // 32-hex. If omitted, atrib-emit auto-chains: it reads the
27
+ // wrapper's local mirror and inherits the most-recent record's
28
+ // context_id (or generates a fresh genesis if no mirror).
29
+ informed_by?: string[], // sha256:<64-hex> record_hashes that informed this event.
30
+ // Sorted lexicographically before signing per §1.2.5.
31
+ chain_root?: string, // sha256:<64-hex>. Caller-managed chain_root, the hash of the
32
+ // immediately preceding record under this context_id. Required
33
+ // when caller threads chain state across emits (e.g. multi-record
34
+ // watcher pipelines that emit a sequence under one context_id).
35
+ // When omitted with context_id present, atrib-emit synthesizes
36
+ // the genesis chain_root per spec §1.2.3. Without context_id,
37
+ // chain_root is meaningless and returns warnings.
38
+ provenance_token?: string, // 22-char base64url cross-session causal anchor per spec §1.2.6
39
+ // / D044. Genesis-record-only: atrib-emit refuses to sign when
40
+ // chain_root is non-genesis, returning warnings rather than
41
+ // emitting a malformed record (§5.8 graceful-degradation).
42
+ })
43
+ ```
44
+
45
+ Returns:
46
+
47
+ ```typescript
48
+ {
49
+ record_hash: string, // sha256:<64-hex> of the signed canonical form
50
+ log_index: number | null, // Position in the log if submission succeeded synchronously
51
+ inclusion_proof: object | null, // Proof bundle if available; null if queued
52
+ context_id: string, // The context_id the record was signed under
53
+ warnings: string[], // E.g., 'submission queued, log unreachable'
54
+ }
55
+ ```
56
+
57
+ ## Key resolution
58
+
59
+ Same chain as the wrapper for the first three sources, plus a 1Password fallback for recovery:
60
+
61
+ 1. `ATRIB_PRIVATE_KEY` env var (legacy / dev path)
62
+ 2. `ATRIB_KEY_FILE` env var → file path containing the base64url-encoded 32-byte seed
63
+ 3. macOS Keychain — services tried in order:
64
+ - `atrib-creator-<ATRIB_AGENT>` (agent-scoped; matches wrapper, defaults `ATRIB_AGENT=claude-code`)
65
+ - `atrib-creator` (generic fallback)
66
+ 4. 1Password CLI recovery (off by default) — set `ATRIB_OP_REFERENCE=op://<vault>/<item>/<field>` to enable. Optional `ATRIB_OP_ACCOUNT=<email-or-uuid>` pins a specific account for multi-account operators. Activated only when Keychain has nothing; designed to recover from a wiped Keychain. The operator must be signed in (`op signin`) and the read may prompt for biometric/master-password approval.
67
+
68
+ `atrib-emit` signs records under the **agent's** identity — the same key as the wrapper. There's no separate "emit identity"; skills don't have identities, the agent always signs as itself.
69
+
70
+ If a 1Password item stores the seed with a `ATRIB_PRIVATE_KEY=<value>` label prefix (the convention used by the existing `Atrib key (current — haoZK4D1AXmy)` item), the prefix is stripped before decoding so both shapes work.
71
+
72
+ ## Configuration
73
+
74
+ | Env var | Required | Purpose |
75
+ |---|---|---|
76
+ | `ATRIB_PRIVATE_KEY` | one of these | base64url Ed25519 seed (32 bytes) |
77
+ | `ATRIB_KEY_FILE` | three | path to a 0600 file containing the seed |
78
+ | (Keychain) | | macOS only; falls back here last |
79
+ | `ATRIB_LOG_ENDPOINT` | optional | log submission endpoint; defaults to `https://log.atrib.dev/v1/entries` |
80
+ | `ATRIB_MIRROR_FILE` | optional | JSONL path emit WRITES its own envelope mirror to; if unset, mirroring is skipped |
81
+ | `ATRIB_AUTOCHAIN_SOURCE` | optional | JSONL path emit READS to inherit the wrapper's session context_id; defaults to the wrapper's local mirror under `~/.atrib/records/`. Splitting read/write paths lets emit write its own envelope mirror while still inheriting the wrapper's chain |
82
+ | `ATRIB_AGENT` | optional | agent name for the agent-scoped Keychain service `atrib-creator-<agent>`; defaults to `claude-code` |
83
+ | `ATRIB_KEYCHAIN_ACCOUNT` | optional | Keychain account; defaults to `userInfo().username` |
84
+
85
+ ## Installation in an MCP host
86
+
87
+ For Claude Code or Claude Desktop, add to the MCP config:
88
+
89
+ ```jsonc
90
+ {
91
+ "mcpServers": {
92
+ "atrib-emit": {
93
+ "command": "node",
94
+ "args": ["/abs/path/to/atrib/services/atrib-emit/dist/main.js"],
95
+ "env": {
96
+ "ATRIB_LOG_ENDPOINT": "https://log.atrib.dev/v1/entries",
97
+ "ATRIB_MIRROR_FILE": "/abs/path/to/.atrib/mirror.jsonl"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Key resolution falls through to Keychain on macOS, so `ATRIB_PRIVATE_KEY` doesn't need to be in the env block (and shouldn't be, in production).
105
+
106
+ ## Architecture
107
+
108
+ Three files do the work:
109
+
110
+ - `src/index.ts` — McpServer registration; the `emit` tool calls `handleEmit` which orchestrates sign + submit + mirror.
111
+ - `src/sign.ts` — Builds and signs the AtribRecord. Pure aside from the signing primitive itself; reuses `@atrib/mcp`'s `signRecord`, `computeContentId`, `getPublicKey`. Records produced by emit are byte-identical in canonical form to wrapper-signed records (verifier MUST NOT distinguish them).
112
+ - `src/submit.ts` — wraps `@atrib/mcp`'s `createSubmissionQueue`. Same priority semantics as the wrapper (cognitive events use 'normal' priority).
113
+ - `src/storage.ts` — Best-effort JSONL mirror of full record + proof, for local recall.
114
+
115
+ Per §5.8 degradation contract: nothing in `atrib-emit` throws to the agent. Missing key → warning in the response. Sign failure → warning. Network failure → submission queued for retry.
116
+
117
+ ## autoChain inheritance from the wrapper
118
+
119
+ When `context_id` is omitted, atrib-emit reads the wrapper's local mirror under `~/.atrib/records/` (override with `ATRIB_AUTOCHAIN_SOURCE`) and inherits the most-recent record's `context_id`, chaining its own emit on top of that record's hash. This is the cognitive-feedback-loop convention: explicit observations, annotations, and revisions chain seamlessly with the agent's mechanical tool calls in the same session, and a verifier sees one coherent chain per `context_id`.
120
+
121
+ Resolution order:
122
+ When the caller supplies both `context_id` and `chain_root`, atrib-emit uses both verbatim. This path is used by consumers that manage chain state themselves, such as nightly observation pipelines emitting a sequence of records under one context_id with explicit chain_root threading.
123
+
124
+ When the caller supplies only `context_id`, atrib-emit synthesizes a genesis `chain_root` for that context_id, initiating a fresh chain.
125
+
126
+ When no `context_id` is supplied but a wrapper mirror is present, atrib-emit inherits the most recent record's `context_id` and chains on top.
127
+
128
+ When neither `context_id` nor a wrapper mirror is present, atrib-emit generates a fresh genesis with a random 16-byte context_id.
129
+
130
+ Both line shapes are accepted at read time: bare `AtribRecord` (the wrapper's convention) and `{record, proof, written_at}` envelope (atrib-emit's storage convention). When inheritance fires, the response's `warnings` array carries `inherited context_id from wrapper mirror: <id>` so the agent can confirm the session it landed in.
131
+
132
+ ## What v1 does NOT do
133
+
134
+ - **No semantic validation of `content`.** Caller passes any shape; the verifier eventually derives edges based on the spec for normative event types. v2 could add per-event-type schemas.
135
+ - **No annotation-specific tool.** v1 has one `emit` tool that handles all event types. v2 will add `atrib-annotate` with annotation-specific affordances (importance picker, automatic `annotates` linkage to most recent action).
136
+ - **No batch mode.** One emit per call. v2 if a high-volume producer needs it.
137
+
138
+ ## Test strategy
139
+
140
+ `test/setup.ts` installs a fetch guard that refuses any submission to a production atrib endpoint (log/graph/directory/explore.atrib.dev). Same pattern as `@atrib/mcp` and `@atrib/agent`.
@@ -0,0 +1,61 @@
1
+ import { type AtribRecord } from '@atrib/mcp';
2
+ export interface ChainContext {
3
+ contextId: string;
4
+ chainRoot: string;
5
+ inheritedFrom: 'caller-supplied' | 'wrapper-mirror' | 'fresh';
6
+ }
7
+ /**
8
+ * Decide what context_id + chain_root the next emit record should use.
9
+ *
10
+ * When the caller supplies both context_id and chain_root, atrib-emit
11
+ * uses them verbatim (inheritedFrom: 'caller-supplied'). This path is
12
+ * used by consumers that manage chain state themselves, such as nightly
13
+ * observation pipelines emitting a sequence of records under one
14
+ * context_id with explicit chain_root threading.
15
+ *
16
+ * When the caller supplies only context_id, atrib-emit generates a
17
+ * genesis record for that context_id (chain_root = genesisChainRoot(
18
+ * context_id), inheritedFrom: 'fresh').
19
+ *
20
+ * When the caller supplies neither, atrib-emit inherits both fields
21
+ * from the most recent record in the wrapper's mirror file. If no
22
+ * mirror is available, it falls back to a fresh genesis context_id.
23
+ *
24
+ * Caller passes a chainRootForCallerContext callback that knows how to
25
+ * compute the genesis chain_root for a given context_id (we accept it as
26
+ * a parameter rather than depending on @atrib/mcp's genesisChainRoot
27
+ * directly, so this module stays trivially testable without pulling in
28
+ * the rest of the signing surface).
29
+ */
30
+ export declare function resolveChainContext(opts: {
31
+ callerContextId?: string | undefined;
32
+ /**
33
+ * Caller-managed chain_root. Only honored when callerContextId is also
34
+ * supplied; chain_root without a context_id is meaningless and is treated
35
+ * as undefined here (the index.ts handler validates this case earlier and
36
+ * returns a warnings-only response).
37
+ */
38
+ callerChainRoot?: string | undefined;
39
+ /** Path override. Defaults to ATRIB_MIRROR_FILE env, then the wrapper's default. */
40
+ mirrorPath?: string | undefined;
41
+ /** Function returning genesis chain_root for a given context_id (spec §1.2.3). */
42
+ genesisChainRoot: (contextId: string) => string;
43
+ /** Random context_id generator (16 bytes hex). Injected for determinism in tests. */
44
+ randomContextId: () => string;
45
+ }): Promise<ChainContext>;
46
+ /**
47
+ * Read the JSONL mirror's last line and parse it as an AtribRecord.
48
+ * Returns null on any failure (missing file, empty file, malformed JSON,
49
+ * line missing required fields). Per §5.8 degradation: never throws.
50
+ *
51
+ * Implementation note: we read the whole file rather than seeking to the
52
+ * end. Mirror files are bounded (one entry per tool call within a session
53
+ * lifetime, single-digit MB at worst). If volume grows enough that this
54
+ * matters, switch to a tail read. Until then, simplicity wins.
55
+ */
56
+ declare function readMostRecentRecord(path: string): Promise<AtribRecord | null>;
57
+ export declare const __test_only__: {
58
+ readMostRecentRecord: typeof readMostRecentRecord;
59
+ DEFAULT_MIRROR: string;
60
+ };
61
+ export {};
@@ -0,0 +1,135 @@
1
+ // autoChain inheritance from the wrapper's local JSONL mirror.
2
+ //
3
+ // The wrapper service persists every signed record to a JSONL file under
4
+ // ~/.atrib/records/. Each line is a bare AtribRecord, newest at EOF. When
5
+ // atrib-emit runs in the same agent process, it can inherit the wrapper's
6
+ // active context_id by reading the most-recent line and chain its emit on
7
+ // top of that record (chain_root = sha256:<that record's hash>).
8
+ //
9
+ // This is the cognitive-feedback-loop convention: explicit observations
10
+ // chain seamlessly with the agent's mechanical tool calls in the same
11
+ // session, so the verifier sees one coherent chain per context_id.
12
+ //
13
+ // Per the scope doc design-question #2: same file as wrapper. Default path
14
+ // is the wrapper's default; override with ATRIB_MIRROR_FILE.
15
+ //
16
+ // Failure mode: never throws. Missing file → no inheritance → genesis
17
+ // record. Malformed last line → no inheritance → genesis record. The
18
+ // wrapper's autoChain across restarts uses the same file with the same
19
+ // silent-degradation contract.
20
+ import { readFile, stat } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { canonicalRecord, hexEncode, sha256 } from '@atrib/mcp';
24
+ // Default path is parameterized by ATRIB_AGENT so each agent gets its own
25
+ // mirror file under ~/.atrib/records/. Wrappers that follow the same
26
+ // convention will write to the same file and atrib-emit's autoChain picks
27
+ // up inheritance for free. Wrappers that use a different filename should
28
+ // have the operator set ATRIB_AUTOCHAIN_SOURCE explicitly.
29
+ const DEFAULT_MIRROR = join(homedir(), '.atrib', 'records', `${process.env.ATRIB_AGENT ?? 'claude-code'}.jsonl`);
30
+ /**
31
+ * Decide what context_id + chain_root the next emit record should use.
32
+ *
33
+ * When the caller supplies both context_id and chain_root, atrib-emit
34
+ * uses them verbatim (inheritedFrom: 'caller-supplied'). This path is
35
+ * used by consumers that manage chain state themselves, such as nightly
36
+ * observation pipelines emitting a sequence of records under one
37
+ * context_id with explicit chain_root threading.
38
+ *
39
+ * When the caller supplies only context_id, atrib-emit generates a
40
+ * genesis record for that context_id (chain_root = genesisChainRoot(
41
+ * context_id), inheritedFrom: 'fresh').
42
+ *
43
+ * When the caller supplies neither, atrib-emit inherits both fields
44
+ * from the most recent record in the wrapper's mirror file. If no
45
+ * mirror is available, it falls back to a fresh genesis context_id.
46
+ *
47
+ * Caller passes a chainRootForCallerContext callback that knows how to
48
+ * compute the genesis chain_root for a given context_id (we accept it as
49
+ * a parameter rather than depending on @atrib/mcp's genesisChainRoot
50
+ * directly, so this module stays trivially testable without pulling in
51
+ * the rest of the signing surface).
52
+ */
53
+ export async function resolveChainContext(opts) {
54
+ if (opts.callerContextId) {
55
+ if (opts.callerChainRoot) {
56
+ return {
57
+ contextId: opts.callerContextId,
58
+ chainRoot: opts.callerChainRoot,
59
+ inheritedFrom: 'caller-supplied',
60
+ };
61
+ }
62
+ return {
63
+ contextId: opts.callerContextId,
64
+ chainRoot: opts.genesisChainRoot(opts.callerContextId),
65
+ inheritedFrom: 'fresh',
66
+ };
67
+ }
68
+ // Reads from ATRIB_AUTOCHAIN_SOURCE first, falling back to the wrapper's
69
+ // mirror path (NOT emit's own mirror — they serve different concerns).
70
+ // ATRIB_MIRROR_FILE controls where emit writes; ATRIB_AUTOCHAIN_SOURCE
71
+ // controls what emit reads to inherit context. In a typical setup they
72
+ // point at different files: emit writes its own mirror, but inherits the
73
+ // wrapper's session context.
74
+ const path = opts.mirrorPath ??
75
+ process.env['ATRIB_AUTOCHAIN_SOURCE'] ??
76
+ process.env['ATRIB_MIRROR_FILE'] ??
77
+ DEFAULT_MIRROR;
78
+ const inherited = await readMostRecentRecord(path);
79
+ if (inherited) {
80
+ const recordHashHex = hexEncode(sha256(canonicalRecord(inherited)));
81
+ return {
82
+ contextId: inherited.context_id,
83
+ chainRoot: `sha256:${recordHashHex}`,
84
+ inheritedFrom: 'wrapper-mirror',
85
+ };
86
+ }
87
+ const fresh = opts.randomContextId();
88
+ return {
89
+ contextId: fresh,
90
+ chainRoot: opts.genesisChainRoot(fresh),
91
+ inheritedFrom: 'fresh',
92
+ };
93
+ }
94
+ /**
95
+ * Read the JSONL mirror's last line and parse it as an AtribRecord.
96
+ * Returns null on any failure (missing file, empty file, malformed JSON,
97
+ * line missing required fields). Per §5.8 degradation: never throws.
98
+ *
99
+ * Implementation note: we read the whole file rather than seeking to the
100
+ * end. Mirror files are bounded (one entry per tool call within a session
101
+ * lifetime, single-digit MB at worst). If volume grows enough that this
102
+ * matters, switch to a tail read. Until then, simplicity wins.
103
+ */
104
+ async function readMostRecentRecord(path) {
105
+ try {
106
+ const stats = await stat(path).catch(() => null);
107
+ if (!stats || stats.size === 0)
108
+ return null;
109
+ const contents = await readFile(path, 'utf-8');
110
+ const lines = contents.split('\n').filter((l) => l.trim().length > 0);
111
+ if (lines.length === 0)
112
+ return null;
113
+ const last = lines[lines.length - 1];
114
+ // Accept BOTH conventions:
115
+ // (a) bare AtribRecord — the wrapper service's mirror writes one
116
+ // record per line.
117
+ // (b) envelope { record, proof?, written_at? } — atrib-emit's own
118
+ // mirror writes this shape so it can preserve proof + timestamp
119
+ // metadata for local recall. autoChain inheritance only needs the
120
+ // record itself.
121
+ // Each line could come from either producer in a session that uses both.
122
+ const parsed = JSON.parse(last);
123
+ const candidate = 'record' in parsed && parsed.record ? parsed.record : parsed;
124
+ if (typeof candidate.context_id !== 'string' ||
125
+ typeof candidate.creator_key !== 'string' ||
126
+ typeof candidate.signature !== 'string') {
127
+ return null;
128
+ }
129
+ return candidate;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ export const __test_only__ = { readMostRecentRecord, DEFAULT_MIRROR };
@@ -0,0 +1,73 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { type ProofBundle, type SubmissionQueue } from '@atrib/mcp';
4
+ import { type ResolvedKey } from './keys.js';
5
+ declare const EmitInput: z.ZodObject<{
6
+ event_type: z.ZodString;
7
+ content: z.ZodRecord<z.ZodString, z.ZodUnknown>;
8
+ context_id: z.ZodOptional<z.ZodString>;
9
+ informed_by: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
10
+ chain_root: z.ZodOptional<z.ZodString>;
11
+ provenance_token: z.ZodOptional<z.ZodString>;
12
+ annotates: z.ZodOptional<z.ZodString>;
13
+ revises: z.ZodOptional<z.ZodString>;
14
+ }, "strip", z.ZodTypeAny, {
15
+ event_type: string;
16
+ content: Record<string, unknown>;
17
+ chain_root?: string | undefined;
18
+ context_id?: string | undefined;
19
+ annotates?: string | undefined;
20
+ revises?: string | undefined;
21
+ informed_by?: string[] | undefined;
22
+ provenance_token?: string | undefined;
23
+ }, {
24
+ event_type: string;
25
+ content: Record<string, unknown>;
26
+ chain_root?: string | undefined;
27
+ context_id?: string | undefined;
28
+ annotates?: string | undefined;
29
+ revises?: string | undefined;
30
+ informed_by?: string[] | undefined;
31
+ provenance_token?: string | undefined;
32
+ }>;
33
+ type EmitOutput = {
34
+ record_hash: string;
35
+ log_index: number | null;
36
+ inclusion_proof: ProofBundle['inclusion_proof'] | null;
37
+ context_id: string;
38
+ warnings: string[];
39
+ };
40
+ export interface AtribEmitServer {
41
+ /** Underlying McpServer; expose for testing or composition. */
42
+ mcp: McpServer;
43
+ /** Drain pending submissions (for tests/shutdown). */
44
+ flush(): Promise<void>;
45
+ }
46
+ export interface CreateAtribEmitServerOptions {
47
+ /** Override the resolved key (primarily for testing). */
48
+ key?: ResolvedKey;
49
+ /** Override the log endpoint (defaults to env or @atrib/mcp default). */
50
+ logEndpoint?: string | undefined;
51
+ }
52
+ /**
53
+ * Wire up the atrib-emit MCP server with one `emit` tool.
54
+ * Returns an AtribEmitServer handle whose `.mcp` is ready to attach to a
55
+ * transport (StdioServerTransport for the standalone binary; in-process
56
+ * transport for tests).
57
+ */
58
+ export declare function createAtribEmitServer(options?: CreateAtribEmitServerOptions): Promise<AtribEmitServer>;
59
+ interface HandleEmitInput {
60
+ input: z.infer<typeof EmitInput>;
61
+ key: ResolvedKey | null;
62
+ queue: SubmissionQueue;
63
+ }
64
+ /**
65
+ * Build, sign, submit, mirror. Returns the EmitOutput shape promised in the
66
+ * scope doc. Per §5.8 degradation: never throws to the agent; surfaces all
67
+ * partial-failure conditions in `warnings`.
68
+ */
69
+ declare function handleEmit({ input, key, queue }: HandleEmitInput): Promise<EmitOutput>;
70
+ export declare const __test_only__: {
71
+ handleEmit: typeof handleEmit;
72
+ };
73
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,240 @@
1
+ // atrib-emit MCP server: registers the explicit `emit` tool that lets an
2
+ // agent sign arbitrary cognitive events (observations, annotations,
3
+ // revisions) under its own identity. Reuses @atrib/mcp's signing and
4
+ // submission primitives so emit-signed records are byte-identical to
5
+ // wrapper-signed ones.
6
+ //
7
+ // Scope:
8
+ // - One tool: emit
9
+ // - One key per process (the agent's wrapper key)
10
+ // - Reuses @atrib/mcp signing + submission queue
11
+ // - Persists to the same JSONL convention as the wrapper
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { z } from 'zod';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { EVENT_TYPE_ANNOTATION_URI, EVENT_TYPE_REVISION_URI, canonicalRecord, createSubmissionQueue, genesisChainRoot, hexEncode, isValidEventTypeUri, sha256, } from '@atrib/mcp';
16
+ import { resolveChainContext } from './auto-chain.js';
17
+ import { resolveKey } from './keys.js';
18
+ import { buildAndSignEmitRecord } from './sign.js';
19
+ import { mirrorRecord } from './storage.js';
20
+ const SHA256_REF_PATTERN = /^sha256:[0-9a-f]{64}$/;
21
+ const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
22
+ // 16 bytes encoded as base64url with no padding = 22 chars per spec §1.2.6.
23
+ const PROVENANCE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/;
24
+ const EmitInput = z.object({
25
+ event_type: z.string().min(1).max(256).describe("Event type URI per spec §1.2.4. Common normative values: " +
26
+ "'https://atrib.dev/v1/types/observation', '...annotation', '...revision'. " +
27
+ 'Extension URIs in any namespace OK.'),
28
+ content: z.record(z.unknown()).describe('Semantic content of the cognitive event. Shape varies per event_type. ' +
29
+ "For observation: { what: string, why_noted?: string, topics?: string[] }. " +
30
+ "For annotation: { annotates: 'sha256:...', importance: 'critical'|'high'|'medium'|'low'|'noise', summary: string, topics?: string[] }. " +
31
+ "For revision: { revises: 'sha256:...', prior_position: string, new_position: string, reason: string }."),
32
+ context_id: z.string().regex(HEX_32_PATTERN).optional().describe('32-hex context_id. If omitted, a fresh genesis context_id is generated and the record is treated as a new chain.'),
33
+ informed_by: z.array(z.string().regex(SHA256_REF_PATTERN)).optional().describe("Array of 'sha256:<64-hex>' record_hashes that informed this event. " +
34
+ 'Sorted lexicographically before signing per §1.2.5.'),
35
+ chain_root: z.string().regex(SHA256_REF_PATTERN).optional().describe("Caller-managed chain_root, the 'sha256:<64-hex>' hash of the immediately " +
36
+ 'preceding record in this context_id. When supplied alongside context_id, ' +
37
+ 'atrib-emit uses both verbatim instead of treating context_id as a fresh ' +
38
+ 'genesis. Required when caller manages chain state across multiple emits ' +
39
+ "under one context_id (e.g. multi-record watcher pipelines). When omitted " +
40
+ 'with context_id present, atrib-emit synthesizes the genesis chain_root ' +
41
+ 'per spec §1.2.3. Without context_id, this field is meaningless and ' +
42
+ 'returns a warnings-only response.'),
43
+ provenance_token: z.string().regex(PROVENANCE_TOKEN_PATTERN).optional().describe('22-char base64url cross-session causal anchor per spec §1.2.6 / D044. ' +
44
+ 'Genesis-record-only: atrib-emit refuses to sign a record that carries ' +
45
+ 'this field if its chain_root is not the genesis chain_root for the ' +
46
+ 'context_id (per §5.8 graceful-degradation, this returns a warnings-only ' +
47
+ 'response rather than a malformed record).'),
48
+ annotates: z.string().regex(SHA256_REF_PATTERN).optional().describe("'sha256:<64-hex>' record_hash this annotation describes per spec §1.2.7 / D058. " +
49
+ 'REQUIRED when event_type is the annotation URI; FORBIDDEN on any other event_type. ' +
50
+ 'atrib-emit enforces the require/forbid invariant per §1.2.7 (validators MUST reject ' +
51
+ 'violations) and returns a warnings-only response rather than signing a malformed record.'),
52
+ revises: z.string().regex(SHA256_REF_PATTERN).optional().describe("'sha256:<64-hex>' record_hash this revision supersedes per spec §1.2.9 / D059. " +
53
+ 'REQUIRED when event_type is the revision URI; FORBIDDEN on any other event_type. ' +
54
+ 'atrib-emit enforces the require/forbid invariant per §1.2.9 (validators MUST reject ' +
55
+ 'violations) and returns a warnings-only response rather than signing a malformed record.'),
56
+ });
57
+ /**
58
+ * Wire up the atrib-emit MCP server with one `emit` tool.
59
+ * Returns an AtribEmitServer handle whose `.mcp` is ready to attach to a
60
+ * transport (StdioServerTransport for the standalone binary; in-process
61
+ * transport for tests).
62
+ */
63
+ export async function createAtribEmitServer(options = {}) {
64
+ const key = options.key ?? (await resolveKey());
65
+ const logEndpoint = options.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'];
66
+ const queue = createSubmissionQueue(logEndpoint);
67
+ const mcp = new McpServer({ name: 'atrib-emit', version: '0.1.0' });
68
+ mcp.registerTool('emit', {
69
+ description: 'Sign and submit an explicit cognitive event (observation, annotation, revision, etc.) under your atrib identity. Emits a record that chains with your wrapper-signed tool calls when context_id is shared.',
70
+ inputSchema: EmitInput.shape,
71
+ }, async (rawInput) => {
72
+ const input = EmitInput.parse(rawInput);
73
+ const result = await handleEmit({ input, key, queue });
74
+ return {
75
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
76
+ };
77
+ });
78
+ return {
79
+ mcp,
80
+ flush: () => queue.flush(),
81
+ };
82
+ }
83
+ /**
84
+ * Build, sign, submit, mirror. Returns the EmitOutput shape promised in the
85
+ * scope doc. Per §5.8 degradation: never throws to the agent; surfaces all
86
+ * partial-failure conditions in `warnings`.
87
+ */
88
+ async function handleEmit({ input, key, queue }) {
89
+ const warnings = [];
90
+ if (!isValidEventTypeUri(input.event_type)) {
91
+ return emptyOutput(input.context_id ?? randomContextId(), [
92
+ `event_type is not a valid absolute URI per §1.4.5: ${input.event_type}`,
93
+ ]);
94
+ }
95
+ if (!key) {
96
+ return emptyOutput(input.context_id ?? randomContextId(), [
97
+ 'no signing key resolved (set ATRIB_PRIVATE_KEY, ATRIB_KEY_FILE, or store seed in macOS Keychain as service "atrib-creator")',
98
+ ]);
99
+ }
100
+ // chain_root without context_id is malformed: chain_root is meaningless
101
+ // outside the context it chains within. Surface a warning instead of
102
+ // synthesizing one of the two halves.
103
+ if (input.chain_root && !input.context_id) {
104
+ return emptyOutput(input.context_id ?? randomContextId(), [
105
+ 'chain_root requires context_id (chain_root has no meaning without a context to chain within)',
106
+ ]);
107
+ }
108
+ // provenance_token is genesis-record-only per spec §1.2.6. If the caller
109
+ // also supplied chain_root, that chain_root must equal the genesis
110
+ // chain_root for the context_id. Middleware refuses to sign malformed
111
+ // records per §5.8 rather than emit something the validator + verifier
112
+ // would reject.
113
+ if (input.provenance_token && input.chain_root && input.context_id) {
114
+ const genesisRoot = genesisChainRoot(input.context_id);
115
+ if (input.chain_root !== genesisRoot) {
116
+ return emptyOutput(input.context_id, [
117
+ 'provenance_token is genesis-record-only per §1.2.6; ' +
118
+ 'chain_root must equal genesisChainRoot(context_id) when provenance_token is supplied',
119
+ ]);
120
+ }
121
+ }
122
+ // annotates require/forbid invariant per spec §1.2.7 / D058. Validators MUST
123
+ // reject violations; we surface as warnings-only per §5.8 so callers see why
124
+ // we refused to sign rather than getting back a malformed record. Use the
125
+ // @atrib/mcp normative constant so the URI string lives in one place.
126
+ if (input.event_type === EVENT_TYPE_ANNOTATION_URI && !input.annotates) {
127
+ return emptyOutput(input.context_id ?? randomContextId(), [
128
+ 'annotation event_type requires annotates per §1.2.7 (D058); ' +
129
+ 'omitted records would fail validator admission',
130
+ ]);
131
+ }
132
+ if (input.annotates && input.event_type !== EVENT_TYPE_ANNOTATION_URI) {
133
+ return emptyOutput(input.context_id ?? randomContextId(), [
134
+ 'annotates is FORBIDDEN on non-annotation event_types per §1.2.7 (D058); ' +
135
+ `received event_type=${input.event_type}`,
136
+ ]);
137
+ }
138
+ // revises require/forbid invariant per spec §1.2.9 / D059. Same shape as
139
+ // the annotates invariant above. Validators MUST reject violations; we
140
+ // surface as warnings-only per §5.8 so callers see why we refused to sign
141
+ // rather than getting back a malformed record.
142
+ if (input.event_type === EVENT_TYPE_REVISION_URI && !input.revises) {
143
+ return emptyOutput(input.context_id ?? randomContextId(), [
144
+ 'revision event_type requires revises per §1.2.9 (D059); ' +
145
+ 'omitted records would fail validator admission',
146
+ ]);
147
+ }
148
+ if (input.revises && input.event_type !== EVENT_TYPE_REVISION_URI) {
149
+ return emptyOutput(input.context_id ?? randomContextId(), [
150
+ 'revises is FORBIDDEN on non-revision event_types per §1.2.9 (D059); ' +
151
+ `received event_type=${input.event_type}`,
152
+ ]);
153
+ }
154
+ // autoChain inheritance: when the caller omits context_id, read the
155
+ // wrapper's local mirror and inherit its most-recent record's context_id
156
+ // (chaining on top of that record's hash). Falls back to a fresh genesis
157
+ // when no mirror is present. The inheritance source is surfaced to the
158
+ // caller in the warnings array so the agent knows which session this
159
+ // emit landed in. When the caller supplies BOTH context_id and chain_root,
160
+ // resolveChainContext uses them verbatim — the path needed by consumers
161
+ // that thread chain state themselves.
162
+ const chain = await resolveChainContext({
163
+ callerContextId: input.context_id,
164
+ callerChainRoot: input.chain_root,
165
+ genesisChainRoot,
166
+ randomContextId,
167
+ });
168
+ const contextId = chain.contextId;
169
+ const chainRoot = chain.chainRoot;
170
+ if (chain.inheritedFrom === 'wrapper-mirror') {
171
+ warnings.push(`inherited context_id from wrapper mirror: ${contextId}`);
172
+ }
173
+ let record;
174
+ try {
175
+ record = await buildAndSignEmitRecord({
176
+ privateKey: key.privateKey,
177
+ eventType: input.event_type,
178
+ contextId,
179
+ chainRoot,
180
+ content: input.content,
181
+ informedBy: input.informed_by,
182
+ provenanceToken: input.provenance_token,
183
+ annotates: input.annotates,
184
+ revises: input.revises,
185
+ });
186
+ }
187
+ catch (e) {
188
+ return emptyOutput(contextId, [
189
+ `signing failed: ${e instanceof Error ? e.message : String(e)}`,
190
+ ]);
191
+ }
192
+ const recordHash = record.signature ? hashRecord(record) : null;
193
+ // Submit asynchronously; the queue handles retry + degradation per §5.8.
194
+ // Cognitive events default to normal priority — annotations/observations
195
+ // never need to block the agent.
196
+ queue.submit(record, 'normal');
197
+ // Best-effort mirror; mirrorRecord internally swallows errors per §5.8.
198
+ // Persist the pre-sign `content` payload as a `_local` sidecar so
199
+ // consumers (recall, trace, summarize) can surface semantic context
200
+ // alongside the cryptographic evidence. The sidecar lives at the
201
+ // envelope level — the signed record bytes are unchanged.
202
+ await mirrorRecord(record, queue.getProof(recordHash ?? '') ?? null, {
203
+ content: input.content,
204
+ producer: 'atrib-emit',
205
+ });
206
+ // Try to read a proof if the queue submitted synchronously and the log
207
+ // returned one within the same tick. Most submissions return null here
208
+ // and the proof shows up on a later poll via getProof.
209
+ const proof = recordHash ? queue.getProof(recordHash) ?? null : null;
210
+ if (!proof) {
211
+ warnings.push('submission queued; proof not yet available (poll the log later if needed)');
212
+ }
213
+ return {
214
+ record_hash: recordHash ?? 'sha256:unknown',
215
+ log_index: proof?.log_index ?? null,
216
+ inclusion_proof: proof?.inclusion_proof ?? null,
217
+ context_id: contextId,
218
+ warnings,
219
+ };
220
+ }
221
+ function emptyOutput(contextId, warnings) {
222
+ return {
223
+ record_hash: 'sha256:unknown',
224
+ log_index: null,
225
+ inclusion_proof: null,
226
+ context_id: contextId,
227
+ warnings,
228
+ };
229
+ }
230
+ function randomContextId() {
231
+ // 16 random bytes → 32 hex chars; matches the spec's context_id format.
232
+ return randomBytes(16).toString('hex');
233
+ }
234
+ function hashRecord(record) {
235
+ return `sha256:${hexEncode(sha256(canonicalRecord(record)))}`;
236
+ }
237
+ // Test-only export of handleEmit. Mirrors the `__test_only__` pattern
238
+ // used in sign.ts; lets unit tests assert on the validation paths
239
+ // without going through the McpServer transport surface.
240
+ export const __test_only__ = { handleEmit };
package/dist/keys.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export interface ResolvedKey {
2
+ privateKey: Uint8Array;
3
+ /** Source the key came from. Surfaced in startup logs so operators can confirm key provenance. */
4
+ source: 'env' | 'file' | 'keychain' | 'op';
5
+ /** Which Keychain service yielded the key, when source === 'keychain'. */
6
+ keychainService?: string;
7
+ /** The `op://` reference that yielded the key, when source === 'op'. */
8
+ opReference?: string;
9
+ }
10
+ /**
11
+ * Resolve the agent's signing key. Returns null when no key is available;
12
+ * callers run in pass-through mode (per §5.8 degradation) and the emit
13
+ * tool returns a warning rather than crashing.
14
+ *
15
+ * The agent name (defaults to 'claude-code', override with ATRIB_AGENT)
16
+ * picks the agent-scoped Keychain service first, falling back to the
17
+ * generic 'atrib-creator' service. This matches the wrapper exactly: a
18
+ * Keychain entry created via `atrib keygen --keychain --service
19
+ * atrib-creator-claude-code` resolves identically here and in the wrapper.
20
+ */
21
+ export declare function resolveKey(): Promise<ResolvedKey | null>;
package/dist/keys.js ADDED
@@ -0,0 +1,104 @@
1
+ // Key resolution chain for atrib-emit. MUST mirror the agent's wrapper
2
+ // service resolution order exactly for the first three sources — emit
3
+ // signs records under the same identity as the wrapper, so a divergence
4
+ // here means the two producers would sign as different identities in the
5
+ // same session, breaking the chain assumption and creating mystery keys
6
+ // in the log.
7
+ //
8
+ // Resolution order (first hit wins):
9
+ // 1. ATRIB_PRIVATE_KEY env var (legacy / dev path)
10
+ // 2. ATRIB_KEY_FILE env var → 0600 file
11
+ // 3. macOS Keychain, account = current user, services tried in order:
12
+ // - atrib-creator-<ATRIB_AGENT> (agent-scoped; matches wrapper)
13
+ // - atrib-creator (generic fallback)
14
+ // 4. 1Password CLI (`op read`) — recovery path when Keychain is wiped.
15
+ // Off by default; enable by setting ATRIB_OP_REFERENCE to a valid
16
+ // `op://<vault>/<item>/<field>` reference. Requires the operator
17
+ // to be signed in (`op signin`) and willing to approve the read.
18
+ //
19
+ // The `op` fallback is deliberately last so that, in a healthy machine,
20
+ // the seed never leaves Keychain. It only fires when Keychain is empty —
21
+ // e.g., after a Keychain reset, fresh machine, or a corruption event.
22
+ // In that case the seed flows through `op read` stdout into our process
23
+ // memory; never touches argv (the reference contains no secret).
24
+ //
25
+ // Wrapper source of truth lives in the operator's internal repo; this
26
+ // resolution chain must be kept in lockstep with that wrapper.
27
+ import { readFile } from 'node:fs/promises';
28
+ import { spawnSync } from 'node:child_process';
29
+ import { userInfo } from 'node:os';
30
+ import { base64urlDecode } from '@atrib/mcp';
31
+ /**
32
+ * Resolve the agent's signing key. Returns null when no key is available;
33
+ * callers run in pass-through mode (per §5.8 degradation) and the emit
34
+ * tool returns a warning rather than crashing.
35
+ *
36
+ * The agent name (defaults to 'claude-code', override with ATRIB_AGENT)
37
+ * picks the agent-scoped Keychain service first, falling back to the
38
+ * generic 'atrib-creator' service. This matches the wrapper exactly: a
39
+ * Keychain entry created via `atrib keygen --keychain --service
40
+ * atrib-creator-claude-code` resolves identically here and in the wrapper.
41
+ */
42
+ export async function resolveKey() {
43
+ const envSeed = process.env['ATRIB_PRIVATE_KEY'];
44
+ if (envSeed) {
45
+ return { privateKey: decodeSeed(envSeed), source: 'env' };
46
+ }
47
+ const filePath = process.env['ATRIB_KEY_FILE'];
48
+ if (filePath) {
49
+ const contents = (await readFile(filePath, 'utf-8')).trim();
50
+ return { privateKey: decodeSeed(contents), source: 'file' };
51
+ }
52
+ if (process.platform === 'darwin') {
53
+ const account = process.env['ATRIB_KEYCHAIN_ACCOUNT'] ?? userInfo().username;
54
+ const agent = process.env['ATRIB_AGENT'] ?? 'claude-code';
55
+ const services = [`atrib-creator-${agent}`, 'atrib-creator'];
56
+ for (const service of services) {
57
+ const result = spawnSync('security', ['find-generic-password', '-a', account, '-s', service, '-w'], { encoding: 'utf-8' });
58
+ if (result.status === 0) {
59
+ const seed = result.stdout.trim();
60
+ if (seed.length > 0) {
61
+ return { privateKey: decodeSeed(seed), source: 'keychain', keychainService: service };
62
+ }
63
+ }
64
+ }
65
+ }
66
+ // 1Password recovery path (last resort). Activated only when
67
+ // ATRIB_OP_REFERENCE is set; the reference itself is non-secret.
68
+ // ATRIB_OP_ACCOUNT optionally pins which 1Password account, useful for
69
+ // operators with multiple accounts (e.g. personal + work).
70
+ const opReference = process.env['ATRIB_OP_REFERENCE'];
71
+ if (opReference) {
72
+ const args = ['read'];
73
+ const opAccount = process.env['ATRIB_OP_ACCOUNT'];
74
+ if (opAccount)
75
+ args.push('--account', opAccount);
76
+ args.push(opReference);
77
+ const result = spawnSync('op', args, { encoding: 'utf-8' });
78
+ if (result.status === 0) {
79
+ // 1Password items often store seeds with a label prefix like
80
+ // "ATRIB_PRIVATE_KEY=<seed>" so the operator can tell which field
81
+ // is which in the UI. Strip an optional `ATRIB_PRIVATE_KEY=` prefix
82
+ // before decoding so both shapes work.
83
+ const raw = result.stdout.trim();
84
+ const seed = raw.startsWith('ATRIB_PRIVATE_KEY=')
85
+ ? raw.slice('ATRIB_PRIVATE_KEY='.length).trim()
86
+ : raw;
87
+ if (seed.length > 0) {
88
+ return { privateKey: decodeSeed(seed), source: 'op', opReference };
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Decode a base64url-encoded 32-byte Ed25519 seed. Throws if length wrong.
96
+ * Per spec §1.4.1: atrib uses 32-byte seeds, not the 64-byte NaCl format.
97
+ */
98
+ function decodeSeed(b64url) {
99
+ const bytes = base64urlDecode(b64url.trim());
100
+ if (bytes.length !== 32) {
101
+ throw new Error(`atrib-emit: expected 32-byte Ed25519 seed, got ${bytes.length} bytes from key source`);
102
+ }
103
+ return bytes;
104
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // atrib-emit standalone binary. Wires the McpServer to a stdio transport
3
+ // so it can be launched as a subprocess by an MCP host (Claude Code,
4
+ // Claude Desktop, etc.).
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { createAtribEmitServer } from './index.js';
7
+ async function main() {
8
+ const { mcp } = await createAtribEmitServer();
9
+ const transport = new StdioServerTransport();
10
+ await mcp.connect(transport);
11
+ // Stays alive on the stdio transport until the host closes it.
12
+ }
13
+ main().catch((e) => {
14
+ console.error('atrib-emit: fatal', e instanceof Error ? e.stack ?? e.message : String(e));
15
+ process.exit(1);
16
+ });
package/dist/sign.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { type AtribRecord } from '@atrib/mcp';
2
+ export interface BuildEmitRecordInput {
3
+ privateKey: Uint8Array;
4
+ eventType: string;
5
+ contextId: string;
6
+ chainRoot: string;
7
+ /** Cognitive content. Used only for content_id derivation context; full content lives in the mirror. */
8
+ content: Record<string, unknown>;
9
+ informedBy?: string[] | undefined;
10
+ /**
11
+ * Optional cross-session causal anchor (spec §1.2.6 / D044). Caller is
12
+ * responsible for ensuring the genesis-record-only invariant holds; the
13
+ * index.ts handler validates this before reaching here.
14
+ */
15
+ provenanceToken?: string | undefined;
16
+ /**
17
+ * Optional annotates target per spec §1.2.7 / D058. Required when
18
+ * eventType is the annotation URI; FORBIDDEN on any other event_type.
19
+ * The index.ts handler enforces the require/forbid invariant before
20
+ * reaching here. Format: "sha256:" + 64 lowercase hex.
21
+ */
22
+ annotates?: string | undefined;
23
+ /**
24
+ * Optional revises target per spec §1.2.9 / D059. Required when
25
+ * eventType is the revision URI; FORBIDDEN on any other event_type.
26
+ * The index.ts handler enforces the require/forbid invariant before
27
+ * reaching here. Format: "sha256:" + 64 lowercase hex.
28
+ */
29
+ revises?: string | undefined;
30
+ }
31
+ /**
32
+ * Build, sign, and return a complete AtribRecord ready for submission.
33
+ * Pure aside from the signing primitive itself; no network I/O here.
34
+ */
35
+ export declare function buildAndSignEmitRecord(input: BuildEmitRecordInput): Promise<AtribRecord>;
36
+ /**
37
+ * Best-effort URI leaf extraction. For atrib-namespace URIs returns the
38
+ * trailing path segment (e.g. 'observation', 'annotation'); for extension
39
+ * URIs returns the URI itself.
40
+ */
41
+ declare function leafOfEventTypeUri(uri: string): string;
42
+ export declare const __test_only__: {
43
+ leafOfEventTypeUri: typeof leafOfEventTypeUri;
44
+ };
45
+ export {};
package/dist/sign.js ADDED
@@ -0,0 +1,60 @@
1
+ // Build + sign an attribution record for the agent's emit call. Reuses
2
+ // @atrib/mcp's signing primitives so emit-signed records are byte-identical
3
+ // in canonical form to wrapper-signed records — a verifier MUST NOT be
4
+ // able to distinguish "wrapper signed this" from "emit signed this."
5
+ //
6
+ // Per spec §1.2.2: content_id is a stable identifier for the *kind* of
7
+ // action, not the specific invocation. The wrapper derives it from
8
+ // server_url + tool_name; we use the synthetic pair (`mcp://atrib-emit` +
9
+ // leaf of event_type URI), so all observations share content_id, all
10
+ // annotations share content_id, etc. Per-emit distinctness comes from
11
+ // (creator_key, context_id, timestamp) — same as the wrapper.
12
+ //
13
+ // The `content` argument never lands on-chain; the log stores commitments
14
+ // only per spec §2.10. Full content lives in the local JSONL mirror for
15
+ // the agent's own recall.
16
+ import { computeContentId, getPublicKey, signRecord, base64urlEncode, } from '@atrib/mcp';
17
+ const SYNTHETIC_SERVER_URL = 'mcp://atrib-emit';
18
+ /**
19
+ * Build, sign, and return a complete AtribRecord ready for submission.
20
+ * Pure aside from the signing primitive itself; no network I/O here.
21
+ */
22
+ export async function buildAndSignEmitRecord(input) {
23
+ const publicKey = base64urlEncode(await getPublicKey(input.privateKey));
24
+ const toolName = leafOfEventTypeUri(input.eventType);
25
+ const contentId = computeContentId(SYNTHETIC_SERVER_URL, toolName);
26
+ // informed_by must be sorted lexicographically per §1.2.5 to keep the
27
+ // canonical form stable across emitters. Omitted entirely (not null,
28
+ // not empty) when no references are given — presence affects JCS.
29
+ const informedBySorted = input.informedBy && input.informedBy.length > 0
30
+ ? [...input.informedBy].sort()
31
+ : undefined;
32
+ const record = {
33
+ spec_version: 'atrib/1.0',
34
+ content_id: contentId,
35
+ creator_key: publicKey,
36
+ chain_root: input.chainRoot,
37
+ event_type: input.eventType,
38
+ context_id: input.contextId,
39
+ timestamp: Date.now(),
40
+ signature: '',
41
+ ...(informedBySorted ? { informed_by: informedBySorted } : {}),
42
+ ...(input.annotates ? { annotates: input.annotates } : {}),
43
+ ...(input.provenanceToken ? { provenance_token: input.provenanceToken } : {}),
44
+ ...(input.revises ? { revises: input.revises } : {}),
45
+ };
46
+ return signRecord(record, input.privateKey);
47
+ }
48
+ /**
49
+ * Best-effort URI leaf extraction. For atrib-namespace URIs returns the
50
+ * trailing path segment (e.g. 'observation', 'annotation'); for extension
51
+ * URIs returns the URI itself.
52
+ */
53
+ function leafOfEventTypeUri(uri) {
54
+ const slashIdx = uri.lastIndexOf('/');
55
+ if (slashIdx === -1)
56
+ return uri;
57
+ const leaf = uri.slice(slashIdx + 1);
58
+ return leaf.length > 0 ? leaf : uri;
59
+ }
60
+ export const __test_only__ = { leafOfEventTypeUri };
@@ -0,0 +1,27 @@
1
+ import type { AtribRecord } from '@atrib/mcp';
2
+ import type { ProofBundle } from '@atrib/mcp';
3
+ /**
4
+ * Pre-sign payload preserved locally. For atrib-emit, this is the original
5
+ * `content: { what, why_noted, topics, ... }` object the caller passed.
6
+ * Free-form per event_type (the same way `content` is on the input schema).
7
+ */
8
+ export type LocalSidecar = {
9
+ /** Original pre-sign content payload as supplied by the caller. */
10
+ content?: Record<string, unknown>;
11
+ /** Producer that emitted this record, for cross-source disambiguation. */
12
+ producer?: string;
13
+ };
14
+ export interface MirrorLine {
15
+ record: AtribRecord;
16
+ proof: ProofBundle | null;
17
+ written_at: number;
18
+ /** Optional local-only sidecar; absent on legacy entries. */
19
+ _local?: LocalSidecar;
20
+ }
21
+ /**
22
+ * Append one record + optional proof + optional local sidecar to the
23
+ * mirror file. Failures log with the atrib-emit prefix and otherwise
24
+ * no-op — per §5.8 the mirror is best-effort and never blocks the
25
+ * agent.
26
+ */
27
+ export declare function mirrorRecord(record: AtribRecord, proof: ProofBundle | null, localSidecar?: LocalSidecar): Promise<void>;
@@ -0,0 +1,56 @@
1
+ // Local JSONL mirror — same convention as the wrapper, so atrib-recall
2
+ // surfaces emit-signed records identically to wrapper-signed ones. Each
3
+ // line is one envelope around a signed AtribRecord plus optional proof
4
+ // and an OPTIONAL `_local` sidecar carrying pre-sign payload content.
5
+ //
6
+ // The `_local` sidecar is the local-only complement to the public log.
7
+ // Public log gets only the signed AtribRecord (with content_id as the
8
+ // commitment to the original content). Local mirror additionally keeps
9
+ // the pre-sign content so consumers (recall, trace, summarize) can
10
+ // surface semantic context — `topics`, `what`, `why_noted` — alongside
11
+ // the cryptographic evidence. Without the sidecar, mirror readers see
12
+ // only event_type + hashes and must guess at semantics.
13
+ //
14
+ // Sidecar shape rules:
15
+ // - Lives at the ENVELOPE level (not inside `record`). Never affects
16
+ // the signature — the signed bytes only ever contain the canonical
17
+ // AtribRecord fields.
18
+ // - Marked with underscore prefix (`_local`) per Python/etc. convention
19
+ // for "private to this layer".
20
+ // - Stripped at submission time by construction: the submission queue
21
+ // only ever sees the bare AtribRecord, so the sidecar can never
22
+ // leak to the public log.
23
+ // - Backward-compatible: existing mirror entries with no `_local`
24
+ // parse identically; readers must tolerate its absence.
25
+ //
26
+ // v1 keeps this minimal: append-only, no rotation, no compression. Path
27
+ // defaults to ATRIB_MIRROR_FILE; if unset, mirroring is skipped (the
28
+ // in-log record is still authoritative).
29
+ import { appendFile, mkdir } from 'node:fs/promises';
30
+ import { dirname } from 'node:path';
31
+ let ensuredDirs = new Set();
32
+ /**
33
+ * Append one record + optional proof + optional local sidecar to the
34
+ * mirror file. Failures log with the atrib-emit prefix and otherwise
35
+ * no-op — per §5.8 the mirror is best-effort and never blocks the
36
+ * agent.
37
+ */
38
+ export async function mirrorRecord(record, proof, localSidecar) {
39
+ const path = process.env['ATRIB_MIRROR_FILE'];
40
+ if (!path)
41
+ return;
42
+ const line = { record, proof, written_at: Date.now() };
43
+ if (localSidecar) {
44
+ line._local = localSidecar;
45
+ }
46
+ try {
47
+ if (!ensuredDirs.has(path)) {
48
+ await mkdir(dirname(path), { recursive: true });
49
+ ensuredDirs.add(path);
50
+ }
51
+ await appendFile(path, JSON.stringify(line) + '\n', 'utf-8');
52
+ }
53
+ catch (e) {
54
+ console.warn('atrib-emit: mirror append failed', e instanceof Error ? e.message : String(e));
55
+ }
56
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@atrib/emit",
3
+ "version": "0.4.0",
4
+ "description": "MCP server for atrib. The producer-side cognitive primitive: lets agents sign explicit observations, annotations, and revisions beyond what middleware auto-signs.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "bin": {
9
+ "atrib-emit": "./dist/main.js"
10
+ },
11
+ "dependencies": {
12
+ "@modelcontextprotocol/sdk": "^1.29.0",
13
+ "@noble/ed25519": "^2.3.0",
14
+ "@noble/hashes": "^1.8.0",
15
+ "zod": "^3.25.76",
16
+ "@atrib/mcp": "0.4.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.19.17",
20
+ "tsx": "^4.21.0",
21
+ "typescript": "^5.9.3",
22
+ "vitest": "^3.2.4"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "rm -rf dist && tsc",
32
+ "start": "node dist/main.js",
33
+ "dev": "tsx src/main.ts",
34
+ "test": "vitest run"
35
+ }
36
+ }