ethagent 4.1.1 → 4.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Portable Ethereum identity for your AI agent. Its soul, memory, and skills live onchain via ERC-8004 + IPFS and snap back into any session.",
5
5
  "author": { "name": "bairon.dev" },
6
6
  "homepage": "https://github.com/baairon/ethagent",
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "matcher": "Edit|Write|MultiEdit",
21
21
  "hooks": [
22
- { "type": "command", "command": "npx -y ethagent --memory-guard" }
22
+ { "type": "command", "command": "npx -y ethagent --pretool-guard" }
23
23
  ]
24
24
  }
25
25
  ],
package/README.md CHANGED
@@ -42,7 +42,7 @@ That's the whole setup. You'll only open `ethagent` again to hand-edit your agen
42
42
 
43
43
  - **Soul** (`SOUL.md`): who it is, your standards, your voice, the way you work.
44
44
  - **Memory** (`MEMORY.md`): what it has learned about you, your preferences, and your projects, so context survives the move to a new machine.
45
- - **Skills:** the commands, tools, and prompts you teach it. Public by default, so other agents can discover them; mark one private to keep it off your public Agent Card (the profile your token publishes).
45
+ - **Skills:** the commands, tools, and prompts you teach it. **Private by default** — yours alone, encrypted in your vault and mirrored into your harnesses so you can use them locally, but kept off your public Agent Card. Make one **public** when you want other agents to discover it; then only its name and description go on the card your token publishes.
46
46
 
47
47
  You grow these mostly by talking: with the plugin on, your agent updates its own soul and memory as you converse, and the changes sync automatically. To edit them by hand, open `ethagent`. To save your agent onchain so it can come back on any machine, choose **Save Snapshot** and sign.
48
48
 
@@ -63,7 +63,7 @@ Using another harness? You can still sync, but only Claude Code does it automati
63
63
  npx ethagent --sync
64
64
  ```
65
65
 
66
- It syncs files between `ethagent` and your harness on this machine, but only when you run it. To back it up so you can restore it anywhere, open `ethagent` and choose **Save Snapshot**.
66
+ One command syncs it into every harness on this machine — soul, memory, and skills (public and private) — but only when you run it. To back it up so you can restore it anywhere, open `ethagent` and choose **Save Snapshot**.
67
67
 
68
68
  ## 🔒 What stays private
69
69
 
@@ -95,6 +95,22 @@ Built on open standards, so your agent is never tied to one harness.
95
95
  | Naming | ENS | A human-readable name that resolves to your agent and restores it from the name alone. |
96
96
  | Backup | IPFS snapshot | The encrypted bundle of soul, memory, and skills, pinned offchain and unlocked only by your wallet. |
97
97
 
98
+ ## 🔄 Updating
99
+
100
+ ethagent ships as two pieces, and a full update can touch both:
101
+
102
+ - **The npm package** (the engine: sync, skills, the guards). The plugin's hooks call `npx -y ethagent`, which resolves the latest published release, so **publishing a new version is the update** — nothing for most people to run. If you installed it globally, or call a bare `ethagent` in a hook or your shell, refresh that copy:
103
+
104
+ ```bash
105
+ npm i -g ethagent@latest
106
+ ```
107
+
108
+ Confirm what you're running with `ethagent --version`.
109
+
110
+ - **The Claude Code plugin** (the hook wiring). New hooks ship in the plugin manifest, so to pick them up, update the plugin from the marketplace with `/plugin`.
111
+
112
+ Rule of thumb: new sync and skill behavior rides your existing hooks as soon as the package is published; a brand-new hook also needs a plugin update.
113
+
98
114
  ## ⌨️ Commands
99
115
 
100
116
  Run with `npx ethagent`:
@@ -102,7 +118,7 @@ Run with `npx ethagent`:
102
118
  | Command | What it does |
103
119
  | --- | --- |
104
120
  | `ethagent` | Open the interactive identity manager: create, ENS, custody, snapshots, transfer. |
105
- | `--sync` | Sync soul, memory, and public skills into every harness it detects. |
121
+ | `--sync` | Sync soul, memory, and skills (public and private) into every harness it detects. |
106
122
  | `--sync-list` | List sync adapters and which ones detect in the current environment. |
107
123
  | `--status` | Print a one-line identity summary. |
108
124
  | `--demo` | Walk the manager with synthetic data, no wallet needed. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Portable Ethereum identity for your AI agent. Its soul, memory, and skills live onchain via ERC-8004 + IPFS and snap back into any session.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/main.tsx CHANGED
@@ -13,6 +13,8 @@ import type { IdentityManagerResult } from '../identity/manager/IdentityManager.
13
13
  import { loadConfig, saveConfig, type EthagentConfig } from '../storage/config.js'
14
14
  import { runSync, runSyncList, runSyncOnEdit } from './sync.js'
15
15
  import { runMemoryGuard } from './memoryGuard.js'
16
+ import { runSkillGuard } from './skillGuard.js'
17
+ import { runPreToolGuard } from './pretoolGuard.js'
16
18
  import { runSessionStart } from './sessionStart.js'
17
19
  import { runStatus } from './status.js'
18
20
  import { runResetCommand } from './reset.js'
@@ -46,7 +48,9 @@ function printHelp(): void {
46
48
  '',
47
49
  'plugin hooks (invoked automatically, not meant to be run by hand):',
48
50
  ' ethagent --session-start sync, then tell the agent where to record memory',
51
+ ' ethagent --pretool-guard one PreToolUse guard combining --memory-guard + --skill-guard',
49
52
  ' ethagent --memory-guard keep agent memory in the portable markers, not local notes',
53
+ ' ethagent --skill-guard keep skills in the portable vault, not the harness mirror',
50
54
  ]
51
55
  for (const line of lines) process.stdout.write(line + '\n')
52
56
  }
@@ -149,7 +153,9 @@ async function main(): Promise<number> {
149
153
  }
150
154
  if (flags.has('--sync-on-edit')) return runSyncOnEdit()
151
155
  if (flags.has('--session-start')) return runSessionStart()
156
+ if (flags.has('--pretool-guard')) return runPreToolGuard()
152
157
  if (flags.has('--memory-guard')) return runMemoryGuard()
158
+ if (flags.has('--skill-guard')) return runSkillGuard()
153
159
  if (flags.has('--sync-list')) return runSyncList()
154
160
  if (flags.has('--sync')) return runSync()
155
161
  if (flags.has('--status')) return runStatus(version)
@@ -0,0 +1,32 @@
1
+ import { loadConfig } from '../storage/config.js'
2
+ import { hookFilePath, readHookPayload } from './hookIo.js'
3
+ import { decideMemoryGuard } from './memoryGuard.js'
4
+ import { decideSkillGuard } from './skillGuard.js'
5
+
6
+ /**
7
+ * Combined PreToolUse guard: runs the memory-dir guard and the skills-mirror
8
+ * guard from a single process, so each Edit/Write/MultiEdit spawns one `npx`
9
+ * (one config load, one stdin read) instead of two. The two guards check
10
+ * disjoint directories, so at most one denies.
11
+ */
12
+ export async function runPreToolGuard(): Promise<number> {
13
+ try {
14
+ const config = await loadConfig()
15
+ const filePath = hookFilePath(await readHookPayload())
16
+ const opts = { identityPresent: !!config?.identity }
17
+ const memory = decideMemoryGuard(filePath, opts)
18
+ const decision = memory.deny ? memory : decideSkillGuard(filePath, opts)
19
+ if (decision.deny) {
20
+ process.stdout.write(
21
+ JSON.stringify({
22
+ hookSpecificOutput: {
23
+ hookEventName: 'PreToolUse',
24
+ permissionDecision: 'deny',
25
+ permissionDecisionReason: decision.reason,
26
+ },
27
+ }) + '\n',
28
+ )
29
+ }
30
+ } catch {}
31
+ return 0
32
+ }
@@ -0,0 +1,47 @@
1
+ import { loadConfig } from '../storage/config.js'
2
+ import { hookFilePath, isWithinDir, readHookPayload } from './hookIo.js'
3
+ import { claudeSkillsDir } from './syncAdapters/claude-code.js'
4
+
5
+ export const SKILL_REDIRECT_REASON =
6
+ "ethagent keeps this agent's skills in its portable continuity vault and generates the ~/.claude/skills " +
7
+ 'mirror from it. Skills you create or edit directly here do not travel with the agent, and ethagent-managed ' +
8
+ 'mirror copies are regenerated from the vault on the next sync. Author or edit skills in the vault instead: ' +
9
+ 'run `ethagent` and open Skills (create, edit, set visibility) so they are versioned, encrypted, and synced ' +
10
+ 'into every harness automatically. New skills are private by default; switch one to public only when you want ' +
11
+ 'it listed on your Agent Card.'
12
+
13
+ export function decideSkillGuard(
14
+ filePath: string | null | undefined,
15
+ opts: { identityPresent: boolean },
16
+ ): { deny: boolean; reason?: string } {
17
+ if (!opts.identityPresent) return { deny: false }
18
+ if (!filePath) return { deny: false }
19
+ // Scope: this guards the Claude Code skills mirror only. The Codex
20
+ // ~/.codex/AGENTS.md skill mirror is intentionally left unguarded, matching
21
+ // the Claude-only --memory-guard — the PreToolUse hook is registered only in
22
+ // the Claude Code plugin manifest.
23
+ if (isWithinDir(claudeSkillsDir(), filePath)) {
24
+ return { deny: true, reason: SKILL_REDIRECT_REASON }
25
+ }
26
+ return { deny: false }
27
+ }
28
+
29
+ export async function runSkillGuard(): Promise<number> {
30
+ try {
31
+ const config = await loadConfig()
32
+ const filePath = hookFilePath(await readHookPayload())
33
+ const decision = decideSkillGuard(filePath, { identityPresent: !!config?.identity })
34
+ if (decision.deny) {
35
+ process.stdout.write(
36
+ JSON.stringify({
37
+ hookSpecificOutput: {
38
+ hookEventName: 'PreToolUse',
39
+ permissionDecision: 'deny',
40
+ permissionDecisionReason: decision.reason,
41
+ },
42
+ }) + '\n',
43
+ )
44
+ }
45
+ } catch {}
46
+ return 0
47
+ }
package/src/cli/sync.ts CHANGED
@@ -5,6 +5,7 @@ import { continuityWorkingTreeStatus } from '../identity/continuity/storage/stat
5
5
  import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js'
6
6
  import { changedContinuitySnapshotFiles } from '../identity/manager/continuity/state.js'
7
7
  import { listSkills } from '../identity/continuity/skills/loadSkills.js'
8
+ import { isDraftScaffold } from '../identity/continuity/skills/scaffold.js'
8
9
  import { hashManagedBody, normalizeBody, reconstructVaultFile, sectionKey } from './syncAdapters/managedBlock.js'
9
10
  import { hookFilePath, readHookPayload, samePath } from './hookIo.js'
10
11
  import {
@@ -16,6 +17,15 @@ import type { PublicSkill } from './syncAdapters/shared.js'
16
17
 
17
18
  export type SyncOptions = { quiet?: boolean }
18
19
 
20
+ /**
21
+ * Skills mirrored into local harnesses: every real skill (public AND private),
22
+ * so private skills are usable locally. The public Agent Card stays public-only
23
+ * (built separately via derivePublicSkillEntries). Drafts/scaffolds are skipped.
24
+ */
25
+ export function selectMirrorSkills(all: readonly PublicSkill[]): PublicSkill[] {
26
+ return all.filter(s => !isDraftScaffold(s))
27
+ }
28
+
19
29
  export async function runSync(opts: SyncOptions = {}): Promise<number> {
20
30
  const config = await loadConfig()
21
31
  if (!config?.identity) return 0
@@ -27,7 +37,7 @@ export async function runSync(opts: SyncOptions = {}): Promise<number> {
27
37
  process.stderr.write(`ethagent: could not load skills, skipping sync to avoid removing managed files (${(err as Error).message})\n`)
28
38
  return 1
29
39
  }
30
- const publicSkills: PublicSkill[] = all.filter(s => s.visibility === 'public')
40
+ const mirrorSkills: PublicSkill[] = selectMirrorSkills(all)
31
41
 
32
42
  const targets: SyncAdapter[] = []
33
43
  for (const adapter of BUILT_IN_ADAPTERS) {
@@ -52,7 +62,7 @@ export async function runSync(opts: SyncOptions = {}): Promise<number> {
52
62
  const summaries: string[] = []
53
63
  for (const adapter of targets) {
54
64
  try {
55
- const { count, skipped } = await adapter.mirror(publicSkills, context)
65
+ const { count, skipped } = await adapter.mirror(mirrorSkills, context)
56
66
  let summary = `${adapter.name}: ${count} skill${count === 1 ? '' : 's'}`
57
67
  if (skipped > 0) summary += `, skipped ${skipped} unmanaged`
58
68
  summaries.push(summary)
@@ -9,7 +9,7 @@ function claudeDir(): string {
9
9
  return path.join(os.homedir(), '.claude')
10
10
  }
11
11
 
12
- function claudeSkillsDir(): string {
12
+ export function claudeSkillsDir(): string {
13
13
  return path.join(claudeDir(), 'skills')
14
14
  }
15
15
 
@@ -44,7 +44,7 @@ export async function projectMemoryMirrorsUnder(claudeRoot: string): Promise<str
44
44
 
45
45
  export const claudeCodeAdapter = {
46
46
  name: 'claude-code' as const,
47
- description: 'Mirror public skills into ~/.claude/skills and inject soul/memory into ~/.claude/CLAUDE.md and the project MEMORY.md.',
47
+ description: 'Mirror skills (public and private) into ~/.claude/skills and inject soul/memory into ~/.claude/CLAUDE.md and the project MEMORY.md.',
48
48
  async detect(): Promise<boolean> {
49
49
  return pathExists(claudeDir())
50
50
  },
@@ -33,7 +33,7 @@ function neutralizeManagedMarkers(text: string): string {
33
33
  }
34
34
 
35
35
  function renderSkillsText(skills: EnrichedSkill[]): string {
36
- if (skills.length === 0) return '_no public skills published yet._'
36
+ if (skills.length === 0) return '_no skills synced yet._'
37
37
  const lines: string[] = []
38
38
  for (const skill of skills) {
39
39
  lines.push(`## ${neutralizeManagedMarkers(skill.displayName ?? skill.name)}`, '')
@@ -45,7 +45,7 @@ function renderSkillsText(skills: EnrichedSkill[]): string {
45
45
 
46
46
  export const codexAdapter = {
47
47
  name: 'codex' as const,
48
- description: 'Merge soul, memory, and public skill content into ~/.codex/AGENTS.md between ethagent markers.',
48
+ description: 'Merge soul, memory, and skill content (public and private) into ~/.codex/AGENTS.md between ethagent markers.',
49
49
  async detect(): Promise<boolean> {
50
50
  return pathExists(path.join(codexDir(), 'config.toml'))
51
51
  },
@@ -1,6 +1,15 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import type { SkillIndexEntry } from '../../identity/continuity/skills/types.js'
4
+ import {
5
+ isReservedWindowsSegment,
6
+ isValidFilenameSegment,
7
+ isValidSegment,
8
+ MAX_FOLDER_DEPTH,
9
+ } from '../../identity/continuity/skills/skillPaths.js'
10
+
11
+ // Cap copied files at the same size the vault loader enforces.
12
+ const MAX_MIRROR_FILE_BYTES = 256 * 1024
4
13
 
5
14
  export type PublicSkill = SkillIndexEntry
6
15
 
@@ -30,6 +39,39 @@ export async function pathExists(file: string): Promise<boolean> {
30
39
  try { await fs.access(file); return true } catch { return false }
31
40
  }
32
41
 
42
+ /**
43
+ * Copy a vault skill folder into the harness, applying the SAME vetting the
44
+ * vault loader uses (skip symlinks, dotfiles, reserved Windows names, invalid
45
+ * segments, and oversize files) so the mirror never copies a superset of — or a
46
+ * symlink escaping — the vault's recognized file set.
47
+ */
48
+ async function copyVettedSkillTree(srcDir: string, destDir: string, depth = 0): Promise<void> {
49
+ if (depth > MAX_FOLDER_DEPTH) return
50
+ await fs.mkdir(destDir, { recursive: true })
51
+ let entries: import('node:fs').Dirent[]
52
+ try {
53
+ entries = await fs.readdir(srcDir, { withFileTypes: true })
54
+ } catch {
55
+ return
56
+ }
57
+ for (const ent of entries) {
58
+ if (ent.isSymbolicLink()) continue
59
+ if (ent.name.startsWith('.')) continue
60
+ if (isReservedWindowsSegment(ent.name)) continue
61
+ const srcPath = path.join(srcDir, ent.name)
62
+ const destPath = path.join(destDir, ent.name)
63
+ if (ent.isDirectory()) {
64
+ if (!isValidSegment(ent.name)) continue
65
+ await copyVettedSkillTree(srcPath, destPath, depth + 1)
66
+ } else if (ent.isFile()) {
67
+ if (!isValidFilenameSegment(ent.name)) continue
68
+ const stat = await fs.stat(srcPath).catch(() => null)
69
+ if (!stat || stat.size > MAX_MIRROR_FILE_BYTES) continue
70
+ await fs.copyFile(srcPath, destPath)
71
+ }
72
+ }
73
+ }
74
+
33
75
  export async function mirrorAsSkillFolders(
34
76
  root: string,
35
77
  skills: PublicSkill[],
@@ -41,16 +83,25 @@ export async function mirrorAsSkillFolders(
41
83
  let skipped = 0
42
84
  for (const skill of skills) {
43
85
  const targetDir = path.join(root, skill.name)
44
- const targetFile = path.join(targetDir, 'SKILL.md')
45
86
  const exists = await pathExists(targetDir)
46
87
  const isOurs = manifest.skills.includes(skill.name)
47
88
  if (exists && !isOurs) { skipped++; continue }
89
+ const srcDir = path.dirname(skill.absolutePath)
90
+ const tmpDir = path.join(root, `.${skill.name}.ethagent-tmp`)
48
91
  try {
49
- const body = await fs.readFile(skill.absolutePath, 'utf8')
50
- await fs.mkdir(targetDir, { recursive: true })
51
- await fs.writeFile(targetFile, body, 'utf8')
92
+ // Stage the new copy in a temp sibling, then swap it in. This keeps the
93
+ // existing managed copy intact if the copy fails (no destructive
94
+ // rm-before-write window), and refreshes the whole folder (scripts/,
95
+ // assets/), dropping files removed upstream.
96
+ await fs.rm(tmpDir, { recursive: true, force: true })
97
+ await copyVettedSkillTree(srcDir, tmpDir)
98
+ await fs.rm(targetDir, { recursive: true, force: true })
99
+ await fs.rename(tmpDir, targetDir)
52
100
  owned.push(skill.name)
53
- } catch {}
101
+ } catch (err) {
102
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => null)
103
+ process.stderr.write(`ethagent: failed to mirror skill "${skill.name}": ${(err as Error).message}\n`)
104
+ }
54
105
  }
55
106
  const keep = new Set<string>(owned)
56
107
  for (const name of manifest.skills) if (incoming.has(name)) keep.add(name)
@@ -517,7 +517,7 @@ async function pathExists(file: string): Promise<boolean> {
517
517
  }
518
518
  }
519
519
 
520
- const DEFAULT_PASTED_VISIBILITY: SkillVisibility = 'public'
520
+ const DEFAULT_SKILL_VISIBILITY: SkillVisibility = 'private'
521
521
  const LEGACY_DISCOVERABLE_RE = /^\s*visibility\s*:\s*['"]?discoverable['"]?\s*$/im
522
522
 
523
523
  async function ensureSkillVisibilityWritten(skillFile: string, raw: string): Promise<string> {
@@ -531,7 +531,7 @@ async function ensureSkillVisibilityWritten(skillFile: string, raw: string): Pro
531
531
  if (LEGACY_DISCOVERABLE_RE.test(raw)) {
532
532
  target = 'private'
533
533
  } else if (parsed.frontmatter.visibility === undefined) {
534
- target = DEFAULT_PASTED_VISIBILITY
534
+ target = DEFAULT_SKILL_VISIBILITY
535
535
  }
536
536
  if (target === null) return raw
537
537
  const next = rewriteVisibility(raw, target)
@@ -587,7 +587,7 @@ function buildIndexEntry(args: {
587
587
  const derivedName = folder || segments.join('/')
588
588
  const fm = args.parsed.frontmatter
589
589
  const description = pickDescription(fm.description, args.parsed.body)
590
- const visibility: SkillVisibility = fm.visibility ?? DEFAULT_PASTED_VISIBILITY
590
+ const visibility: SkillVisibility = fm.visibility ?? DEFAULT_SKILL_VISIBILITY
591
591
  return {
592
592
  name: derivedName,
593
593
  ...(fm.name ? { displayName: fm.name } : {}),
@@ -5,7 +5,7 @@ export type SkillScaffoldArgs = {
5
5
  visibility?: SkillVisibility
6
6
  }
7
7
 
8
- export function defaultSkillScaffold({ name, visibility = 'public' }: SkillScaffoldArgs): string {
8
+ export function defaultSkillScaffold({ name, visibility = 'private' }: SkillScaffoldArgs): string {
9
9
  return [
10
10
  '---',
11
11
  `name: ${name}`,
@@ -22,7 +22,7 @@ export const NewSkillVisibilityScreen: React.FC<NewSkillVisibilityScreenProps> =
22
22
  }) => (
23
23
  <Surface
24
24
  title={`Visibility · ${name}`}
25
- subtitle="Public is the default."
25
+ subtitle="Private is the default."
26
26
  footer={footer}
27
27
  >
28
28
  {error && (
@@ -39,7 +39,7 @@ export const NewSkillVisibilityScreen: React.FC<NewSkillVisibilityScreenProps> =
39
39
  { value: 'back', label: 'Back', role: 'utility' },
40
40
  ]}
41
41
  hintLayout="inline"
42
- initialIndex={1}
42
+ initialIndex={0}
43
43
  onSubmit={choice => {
44
44
  if (choice === 'back') return onCancel()
45
45
  return onSelect(choice)