@tpsdev-ai/flair 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Trust allow-list for npm code-plugin bridges.
3
+ *
4
+ * A Shape B bridge is arbitrary JavaScript fetched from npm. The spec §7
5
+ * requires explicit operator opt-in on first use: `flair bridge allow <name>`
6
+ * records the approval; subsequent invocations see the bridge on the
7
+ * allow-list and skip the prompt. `flair bridge revoke <name>` removes it.
8
+ *
9
+ * Approvals are pinned to a concrete package location and its package.json
10
+ * content digest. A name-only approval is not enough: a malicious package
11
+ * squatting on an allowed short-name in a different `node_modules` tree
12
+ * (e.g. the user approved `mem0` in ProjectA, then cd'd into ProjectB which
13
+ * ships a planted `node_modules/flair-bridge-mem0`) would otherwise load
14
+ * under the existing trust record. Each entry therefore captures:
15
+ *
16
+ * - packageDir: canonical (realpath) directory where the approved
17
+ * package lives on disk at allow-time.
18
+ * - packageJsonSha256: hex sha256 of the package's package.json content.
19
+ * - version: the package's self-reported version (display only;
20
+ * the sha is what enforces identity).
21
+ *
22
+ * At load-time, `verifyAllow(discovered)` re-checks all three. A change in
23
+ * any triggers a BridgeRuntimeError pointing the operator back at
24
+ * `flair bridge allow` — either the move is intentional (re-approve) or
25
+ * it is a squat (refuse).
26
+ *
27
+ * Design notes:
28
+ * - YAML (Shape A) + built-in bridges skip this gate entirely. They don't
29
+ * execute arbitrary JS.
30
+ * - The store is a simple JSON file at ~/.flair/bridges-allowed.json.
31
+ * No separate DB, no Harper dependency — the trust decision should
32
+ * still work when Flair's own service is down.
33
+ * - Migration: older entries from 0.6.0/0.6.1 carry only {name, allowedAt}.
34
+ * `verifyAllow` treats those as invalid and forces re-approval on next
35
+ * load. Acceptable cost — no known external consumers pre-1.0.
36
+ */
37
+ import { promises as fsp } from "node:fs";
38
+ import { existsSync } from "node:fs";
39
+ import { join, dirname } from "node:path";
40
+ import { homedir } from "node:os";
41
+ import { createHash } from "node:crypto";
42
+ function resolvePath(opts) {
43
+ return opts?.path ?? join(homedir(), ".flair", "bridges-allowed.json");
44
+ }
45
+ function hasEntryFields(e) {
46
+ return (typeof e?.name === "string" &&
47
+ typeof e?.allowedAt === "string" &&
48
+ typeof e?.packageDir === "string" &&
49
+ typeof e?.packageJsonSha256 === "string");
50
+ }
51
+ async function read(path) {
52
+ if (!existsSync(path))
53
+ return { allowed: [] };
54
+ try {
55
+ const raw = await fsp.readFile(path, "utf-8");
56
+ const parsed = JSON.parse(raw);
57
+ if (!parsed || !Array.isArray(parsed.allowed))
58
+ return { allowed: [] };
59
+ // Keep structurally valid entries only. Legacy name-only rows are
60
+ // dropped here so `verifyAllow` reports not-allowed (forcing re-approve).
61
+ return {
62
+ allowed: parsed.allowed.filter(hasEntryFields).map((e) => ({
63
+ name: e.name,
64
+ allowedAt: e.allowedAt,
65
+ packageDir: e.packageDir,
66
+ packageJsonSha256: e.packageJsonSha256,
67
+ ...(typeof e.version === "string" ? { version: e.version } : {}),
68
+ })),
69
+ };
70
+ }
71
+ catch {
72
+ return { allowed: [] };
73
+ }
74
+ }
75
+ async function write(path, data) {
76
+ await fsp.mkdir(dirname(path), { recursive: true });
77
+ const tmp = `${path}.tmp`;
78
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
79
+ await fsp.rename(tmp, path);
80
+ }
81
+ /**
82
+ * Canonicalize a filesystem path. Resolves symlinks so node_modules hoisting
83
+ * and pnpm-style content-addressed stores still hash-match reliably.
84
+ */
85
+ async function canonical(path) {
86
+ try {
87
+ return await fsp.realpath(path);
88
+ }
89
+ catch {
90
+ return path;
91
+ }
92
+ }
93
+ /**
94
+ * Hash a package's package.json file. The sha changes whenever the package
95
+ * contents "change enough to matter" — version bumps, name changes, and
96
+ * realistically most substantive updates touch package.json. It is NOT a
97
+ * hash of every file in the package; that would be slower and more fragile
98
+ * without closing the meaningful attack paths (since a squatter needs the
99
+ * right package.json anyway to survive discovery).
100
+ */
101
+ export async function digestPackage(packageDir) {
102
+ const canonicalDir = await canonical(packageDir);
103
+ const pkgJsonPath = join(canonicalDir, "package.json");
104
+ const raw = await fsp.readFile(pkgJsonPath, "utf-8");
105
+ const sha = createHash("sha256").update(raw).digest("hex");
106
+ let version;
107
+ try {
108
+ const parsed = JSON.parse(raw);
109
+ if (typeof parsed?.version === "string")
110
+ version = parsed.version;
111
+ }
112
+ catch {
113
+ // A malformed package.json is itself a refusal signal — but leave that
114
+ // to the import path; here we just decline to record a version.
115
+ }
116
+ return { canonicalDir, sha256: sha, version };
117
+ }
118
+ /**
119
+ * Check if a name appears in the allow-list. Useful for UI listings; NOT a
120
+ * trust decision — use `verifyAllow` for load-time security checks.
121
+ */
122
+ export async function isAllowed(name, opts) {
123
+ const data = await read(resolvePath(opts));
124
+ return data.allowed.some((e) => e.name === name);
125
+ }
126
+ /**
127
+ * Load-time trust check. Verifies the discovered package still matches the
128
+ * approval record by canonical path AND package.json sha. Anything else is
129
+ * a refusal — the operator must re-run `flair bridge allow <name>`.
130
+ */
131
+ export async function verifyAllow(discovered, opts) {
132
+ const data = await read(resolvePath(opts));
133
+ const entry = data.allowed.find((e) => e.name === discovered.name);
134
+ if (!entry)
135
+ return { ok: false, reason: "not-allowed" };
136
+ // Guard: if we ever loaded a partial record, refuse. Belt-and-braces;
137
+ // read() already filters these, but if someone hand-edits the file we
138
+ // don't want to silently bypass the check.
139
+ if (!entry.packageDir || !entry.packageJsonSha256) {
140
+ return { ok: false, reason: "entry-incomplete", entry };
141
+ }
142
+ let observed;
143
+ try {
144
+ observed = await digestPackage(discovered.path);
145
+ }
146
+ catch {
147
+ return { ok: false, reason: "package-missing", entry };
148
+ }
149
+ if (observed.canonicalDir !== entry.packageDir) {
150
+ return { ok: false, reason: "path-mismatch", entry, observedPath: observed.canonicalDir };
151
+ }
152
+ if (observed.sha256 !== entry.packageJsonSha256) {
153
+ return { ok: false, reason: "digest-mismatch", entry, observedDigest: observed.sha256 };
154
+ }
155
+ return { ok: true, entry };
156
+ }
157
+ /**
158
+ * Approve a package for code-plugin execution. `packageDir` must be the
159
+ * directory holding the package.json — typically discovered via
160
+ * `flair bridge list` and passed through by the CLI layer.
161
+ */
162
+ export async function allow(name, packageDir, opts) {
163
+ const listPath = resolvePath(opts);
164
+ const data = await read(listPath);
165
+ const { canonicalDir, sha256, version } = await digestPackage(packageDir);
166
+ const now = new Date().toISOString();
167
+ const existing = data.allowed.find((e) => e.name === name);
168
+ if (existing && existing.packageDir === canonicalDir && existing.packageJsonSha256 === sha256) {
169
+ return { alreadyAllowed: true, updated: false, entry: existing };
170
+ }
171
+ const entry = {
172
+ name,
173
+ allowedAt: now,
174
+ packageDir: canonicalDir,
175
+ packageJsonSha256: sha256,
176
+ ...(version ? { version } : {}),
177
+ };
178
+ data.allowed = data.allowed.filter((e) => e.name !== name);
179
+ data.allowed.push(entry);
180
+ data.allowed.sort((a, b) => a.name.localeCompare(b.name));
181
+ await write(listPath, data);
182
+ return { alreadyAllowed: false, updated: !!existing, entry };
183
+ }
184
+ export async function revoke(name, opts) {
185
+ const path = resolvePath(opts);
186
+ const data = await read(path);
187
+ const before = data.allowed.length;
188
+ data.allowed = data.allowed.filter((e) => e.name !== name);
189
+ if (data.allowed.length === before) {
190
+ return { wasAllowed: false };
191
+ }
192
+ await write(path, data);
193
+ return { wasAllowed: true };
194
+ }
195
+ export async function list(opts) {
196
+ const data = await read(resolvePath(opts));
197
+ return data.allowed;
198
+ }
@@ -26,7 +26,11 @@ export async function runImport(opts) {
26
26
  let total = 0;
27
27
  let imported = 0;
28
28
  let skipped = 0;
29
- for await (const m of importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })) {
29
+ const iterable = opts.source
30
+ ?? (opts.descriptor
31
+ ? importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })
32
+ : (() => { throw new BridgeRuntimeError({ bridge: opts.bridgeName, op: "import", field: "(source)", expected: "descriptor or source", got: "neither", hint: "runImport requires exactly one of opts.descriptor (YAML) or opts.source (AsyncIterable)" }); })());
33
+ for await (const m of iterable) {
30
34
  total++;
31
35
  const resolvedAgent = m.agentId ?? opts.agentId;
32
36
  if (!resolvedAgent) {
@@ -35,7 +39,7 @@ export async function runImport(opts) {
35
39
  // the operator should know whether this is a descriptor bug or a
36
40
  // missing flag.
37
41
  throw new BridgeRuntimeError({
38
- bridge: opts.descriptor.name,
42
+ bridge: opts.bridgeName,
39
43
  op: "import",
40
44
  field: "agentId",
41
45
  expected: "set on record OR provided via --agent",
@@ -86,7 +90,7 @@ export async function runImport(opts) {
86
90
  catch (err) {
87
91
  // Wrap PUT errors so they read consistently with other BridgeRuntimeErrors.
88
92
  throw new BridgeRuntimeError({
89
- bridge: opts.descriptor.name,
93
+ bridge: opts.bridgeName,
90
94
  op: "import",
91
95
  record: total,
92
96
  field: "(write)",
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unified loader for all bridge sources (replaces load-descriptor.ts).
3
+ *
4
+ * Returns a `LoadedBridge` discriminated union. Runners dispatch on
5
+ * `.kind`:
6
+ * - "yaml": use the existing YAML runtime (applyMap, parseRecords, etc.)
7
+ * - "code": call the plugin's bridge.import() / bridge.export() directly
8
+ *
9
+ * For code-plugin sources (npm packages), the allow-list gate runs first:
10
+ * if not allowed, throw a BridgeRuntimeError pointing the operator at
11
+ * `flair bridge allow <name>`.
12
+ */
13
+ import { BridgeRuntimeError } from "../types.js";
14
+ import { loadYamlDescriptor } from "./yaml-loader.js";
15
+ import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
+ import { loadCodePlugin } from "./load-plugin.js";
17
+ import { verifyAllow } from "./allow-list.js";
18
+ export async function loadBridge(discovered, opts = {}) {
19
+ switch (discovered.source) {
20
+ case "builtin": {
21
+ const builtin = BUILTIN_BY_NAME.get(discovered.name);
22
+ if (!builtin) {
23
+ throw new BridgeRuntimeError({
24
+ bridge: discovered.name,
25
+ op: "import",
26
+ field: "(builtin)",
27
+ expected: "registered built-in name",
28
+ got: discovered.name,
29
+ hint: `built-in "${discovered.name}" not in the registry — likely code drift between discover.ts and builtins/index.ts`,
30
+ });
31
+ }
32
+ return { kind: "yaml", descriptor: builtin.descriptor, source: discovered };
33
+ }
34
+ case "project-yaml":
35
+ case "user-yaml": {
36
+ const descriptor = await loadYamlDescriptor(discovered.path);
37
+ return { kind: "yaml", descriptor, source: discovered };
38
+ }
39
+ case "npm-package": {
40
+ if (!opts.skipAllowCheck) {
41
+ const verdict = await verifyAllow(discovered, { path: opts.allowListPath });
42
+ if (!verdict.ok) {
43
+ throw new BridgeRuntimeError({
44
+ bridge: discovered.name,
45
+ op: "import",
46
+ path: discovered.path,
47
+ field: "(trust)",
48
+ expected: verdict.reason === "not-allowed" ? "allow-listed code plugin" : "approved package at recorded location/digest",
49
+ got: verdict.reason,
50
+ hint: trustHint(discovered.name, verdict),
51
+ context: trustContext(discovered, verdict),
52
+ });
53
+ }
54
+ }
55
+ const plugin = await loadCodePlugin(discovered, { importer: opts.importer });
56
+ return { kind: "code", plugin, source: discovered };
57
+ }
58
+ }
59
+ }
60
+ function trustContext(discovered, verdict) {
61
+ switch (verdict.reason) {
62
+ case "not-allowed":
63
+ return { name: discovered.name };
64
+ case "path-mismatch":
65
+ return {
66
+ name: discovered.name,
67
+ approvedPath: verdict.entry.packageDir,
68
+ observedPath: verdict.observedPath,
69
+ approvedVersion: verdict.entry.version ?? "(unknown)",
70
+ approvedAt: verdict.entry.allowedAt,
71
+ };
72
+ case "digest-mismatch":
73
+ return {
74
+ name: discovered.name,
75
+ packagePath: verdict.entry.packageDir,
76
+ approvedVersion: verdict.entry.version ?? "(unknown)",
77
+ approvedDigest: verdict.entry.packageJsonSha256,
78
+ observedDigest: verdict.observedDigest,
79
+ approvedAt: verdict.entry.allowedAt,
80
+ };
81
+ case "entry-incomplete":
82
+ return { name: discovered.name };
83
+ case "package-missing":
84
+ return {
85
+ name: discovered.name,
86
+ approvedPath: verdict.entry?.packageDir ?? "(unknown)",
87
+ discoveredPath: discovered.path,
88
+ };
89
+ }
90
+ }
91
+ function trustHint(name, verdict) {
92
+ const reapprove = `flair bridge allow ${name}`;
93
+ switch (verdict.reason) {
94
+ case "not-allowed":
95
+ return `npm code plugins run arbitrary JavaScript. First-use approval required. Run: ${reapprove}`;
96
+ case "path-mismatch":
97
+ return `approved package lives at ${verdict.entry.packageDir}, but a different package with the same name was discovered at ${verdict.observedPath}. This is how local squatting attacks present. If the new location is intentional, re-run: ${reapprove}`;
98
+ case "digest-mismatch":
99
+ return `package.json contents changed since approval (recorded sha ${verdict.entry.packageJsonSha256.slice(0, 12)}…, observed ${verdict.observedDigest.slice(0, 12)}…). Re-run if the update is intentional: ${reapprove}`;
100
+ case "entry-incomplete":
101
+ return `allow-list entry for "${name}" is missing a location/digest (likely from a pre-fix Flair version). Re-run: ${reapprove}`;
102
+ case "package-missing":
103
+ return `package at ${verdict.entry?.packageDir ?? "(unknown)"} could not be read; allow-list verification cannot proceed`;
104
+ }
105
+ }
106
+ /**
107
+ * Kept as a shim for code paths that still call the old name. Remove once
108
+ * all call sites move to loadBridge directly.
109
+ *
110
+ * Returns the YAML descriptor when the source produces one; throws for
111
+ * code plugins (they don't have a YamlBridgeDescriptor representation).
112
+ */
113
+ export async function loadDescriptor(discovered) {
114
+ const loaded = await loadBridge(discovered);
115
+ if (loaded.kind === "yaml")
116
+ return loaded.descriptor;
117
+ throw new BridgeRuntimeError({
118
+ bridge: discovered.name,
119
+ op: "import",
120
+ path: discovered.path,
121
+ field: "(kind)",
122
+ expected: "yaml descriptor",
123
+ got: "code plugin",
124
+ hint: "this code path expected a YAML descriptor; the bridge is a code plugin — caller should dispatch on loadBridge().kind instead",
125
+ });
126
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Dynamic loader for Shape B code-plugin bridges.
3
+ *
4
+ * Given a discovered `npm-package` source, dynamic-import the package,
5
+ * validate that it exports a `MemoryBridge`, and return the loaded module.
6
+ * The import is done via `createRequire` relative to the package root so
7
+ * scoped packages and deep nested deps resolve correctly.
8
+ *
9
+ * Validation is deliberately minimal: `name`, `version`, `kind`, and at
10
+ * least one of `import` / `export` must be present and look right. The
11
+ * spec §6 contract is "duck-type at runtime, strict error messages."
12
+ */
13
+ import { pathToFileURL } from "node:url";
14
+ import { BridgeRuntimeError } from "../types.js";
15
+ const DEFAULT_IMPORTER = (spec) => import(spec);
16
+ export async function loadCodePlugin(discovered, opts = {}) {
17
+ if (discovered.source !== "npm-package") {
18
+ throw new BridgeRuntimeError({
19
+ bridge: discovered.name,
20
+ op: "import",
21
+ field: "source",
22
+ expected: "npm-package",
23
+ got: discovered.source,
24
+ hint: "loadCodePlugin only handles Shape B (npm code plugin) bridges; YAML descriptors go through the YAML loader",
25
+ });
26
+ }
27
+ const importer = opts.importer ?? DEFAULT_IMPORTER;
28
+ // Package root path — the entry point is resolved by Node's package.json
29
+ // "main"/"exports" fields. Use a file:// URL so Node resolves it as a
30
+ // filesystem path rather than a bare specifier.
31
+ const spec = pathToFileURL(discovered.path + "/").href;
32
+ let mod;
33
+ try {
34
+ mod = await importer(spec);
35
+ }
36
+ catch (err) {
37
+ throw new BridgeRuntimeError({
38
+ bridge: discovered.name,
39
+ op: "import",
40
+ path: discovered.path,
41
+ field: "(import)",
42
+ expected: "importable npm package",
43
+ got: err?.code ?? "import error",
44
+ hint: `could not dynamic-import ${spec}: ${err?.message ?? err}`,
45
+ });
46
+ }
47
+ const candidate = pickBridgeExport(mod);
48
+ if (!candidate) {
49
+ throw new BridgeRuntimeError({
50
+ bridge: discovered.name,
51
+ op: "import",
52
+ path: discovered.path,
53
+ field: "exports",
54
+ expected: "named `bridge` export or default export implementing MemoryBridge",
55
+ got: typeof mod === "object" && mod !== null ? `exports=${Object.keys(mod).join(",") || "(empty)"}` : typeof mod,
56
+ hint: `flair-bridge-<name> packages must export \`bridge\` (or default-export) a MemoryBridge. See specs/FLAIR-BRIDGES.md §6`,
57
+ });
58
+ }
59
+ // Validate the shape. Keep this minimal — we trust the plugin author
60
+ // to build a working MemoryBridge; we just need enough to route calls.
61
+ validateBridge(discovered, candidate);
62
+ return candidate;
63
+ }
64
+ function pickBridgeExport(mod) {
65
+ if (!mod || typeof mod !== "object")
66
+ return null;
67
+ const m = mod;
68
+ if (isBridgeLike(m.bridge))
69
+ return m.bridge;
70
+ if (isBridgeLike(m.default))
71
+ return m.default;
72
+ if (isBridgeLike(m))
73
+ return m;
74
+ return null;
75
+ }
76
+ function isBridgeLike(x) {
77
+ if (!x || typeof x !== "object")
78
+ return false;
79
+ const o = x;
80
+ return typeof o.name === "string" && (typeof o.import === "function" || typeof o.export === "function");
81
+ }
82
+ function validateBridge(discovered, b) {
83
+ if (typeof b.name !== "string" || !b.name) {
84
+ throw new BridgeRuntimeError({
85
+ bridge: discovered.name,
86
+ op: "import",
87
+ path: discovered.path,
88
+ field: "name",
89
+ expected: "non-empty string",
90
+ got: JSON.stringify(b.name),
91
+ hint: "MemoryBridge.name must be a non-empty string matching the npm package's flair-bridge-<name>",
92
+ });
93
+ }
94
+ if (b.name !== discovered.name) {
95
+ throw new BridgeRuntimeError({
96
+ bridge: discovered.name,
97
+ op: "import",
98
+ path: discovered.path,
99
+ field: "name",
100
+ expected: `"${discovered.name}" (from package name flair-bridge-${discovered.name})`,
101
+ got: `"${b.name}"`,
102
+ hint: "MemoryBridge.name must match the npm package's public name suffix — mismatch would surprise discovery and allow-list lookups",
103
+ });
104
+ }
105
+ if (b.kind !== "file" && b.kind !== "api") {
106
+ throw new BridgeRuntimeError({
107
+ bridge: discovered.name,
108
+ op: "import",
109
+ path: discovered.path,
110
+ field: "kind",
111
+ expected: `"file" | "api"`,
112
+ got: JSON.stringify(b.kind),
113
+ hint: "MemoryBridge.kind must be 'file' or 'api'",
114
+ });
115
+ }
116
+ if (typeof b.import !== "function" && typeof b.export !== "function") {
117
+ throw new BridgeRuntimeError({
118
+ bridge: discovered.name,
119
+ op: "import",
120
+ path: discovered.path,
121
+ field: "(methods)",
122
+ expected: "at least one of `import` or `export`",
123
+ got: "neither",
124
+ hint: "MemoryBridge needs at least one of import/export implemented. See spec §6",
125
+ });
126
+ }
127
+ }
package/dist/cli.js CHANGED
@@ -3544,7 +3544,7 @@ bridge
3544
3544
  const cwd = opts.cwd ?? srcArg ?? process.cwd();
3545
3545
  const { discover } = await import("./bridges/discover.js");
3546
3546
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3547
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3547
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3548
3548
  const { runImport } = await import("./bridges/runtime/import-runner.js");
3549
3549
  const { makeContext } = await import("./bridges/runtime/context.js");
3550
3550
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3554,9 +3554,9 @@ bridge
3554
3554
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3555
3555
  process.exit(1);
3556
3556
  }
3557
- let descriptor;
3557
+ let loaded;
3558
3558
  try {
3559
- descriptor = await loadDescriptor(target);
3559
+ loaded = await loadBridge(target);
3560
3560
  }
3561
3561
  catch (err) {
3562
3562
  printBridgeError(err);
@@ -3591,10 +3591,10 @@ bridge
3591
3591
  if (ev.type === "done") {
3592
3592
  const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3593
3593
  if (opts.dryRun) {
3594
- console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3594
+ console.log(`\n${target.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3595
3595
  }
3596
3596
  else {
3597
- console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3597
+ console.log(`\n${target.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3598
3598
  }
3599
3599
  return;
3600
3600
  }
@@ -3611,15 +3611,40 @@ bridge
3611
3611
  }
3612
3612
  };
3613
3613
  try {
3614
- await runImport({
3615
- descriptor,
3616
- cwd,
3617
- agentId,
3618
- dryRun: !!opts.dryRun,
3619
- putMemory,
3620
- onProgress,
3621
- ctx,
3622
- });
3614
+ if (loaded.kind === "yaml") {
3615
+ await runImport({
3616
+ bridgeName: target.name,
3617
+ descriptor: loaded.descriptor,
3618
+ cwd,
3619
+ agentId,
3620
+ dryRun: !!opts.dryRun,
3621
+ putMemory,
3622
+ onProgress,
3623
+ ctx,
3624
+ });
3625
+ }
3626
+ else {
3627
+ // Code plugin: invoke bridge.import(opts, ctx) directly; the plugin
3628
+ // returns an AsyncIterable of BridgeMemory that runImport processes.
3629
+ if (!loaded.plugin.import) {
3630
+ console.error(`Bridge "${name}" is a code plugin without an import() function — can only export through it.`);
3631
+ process.exit(1);
3632
+ }
3633
+ // Code-plugin options: pass through all --X flags as a single object.
3634
+ // The plugin's declared `options` descriptor validates what it actually cares about.
3635
+ const pluginOpts = { ...opts };
3636
+ const source = loaded.plugin.import(pluginOpts, ctx);
3637
+ await runImport({
3638
+ bridgeName: target.name,
3639
+ source,
3640
+ cwd,
3641
+ agentId,
3642
+ dryRun: !!opts.dryRun,
3643
+ putMemory,
3644
+ onProgress,
3645
+ ctx,
3646
+ });
3647
+ }
3623
3648
  }
3624
3649
  catch (err) {
3625
3650
  if (err instanceof BridgeRuntimeError) {
@@ -3651,7 +3676,7 @@ bridge
3651
3676
  const cwd = opts.cwd ?? dst;
3652
3677
  const { discover } = await import("./bridges/discover.js");
3653
3678
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3654
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3679
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3655
3680
  const { runExport } = await import("./bridges/runtime/export-runner.js");
3656
3681
  const { makeContext } = await import("./bridges/runtime/context.js");
3657
3682
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3661,18 +3686,22 @@ bridge
3661
3686
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3662
3687
  process.exit(1);
3663
3688
  }
3664
- let descriptor;
3689
+ let loaded;
3665
3690
  try {
3666
- descriptor = await loadDescriptor(target);
3691
+ loaded = await loadBridge(target);
3667
3692
  }
3668
3693
  catch (err) {
3669
3694
  printBridgeError(err);
3670
3695
  process.exit(1);
3671
3696
  }
3672
- if (!descriptor.export) {
3697
+ if (loaded.kind === "yaml" && !loaded.descriptor.export) {
3673
3698
  console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3674
3699
  process.exit(1);
3675
3700
  }
3701
+ if (loaded.kind === "code" && !loaded.plugin.export) {
3702
+ console.error(`Bridge "${name}" is a code plugin without an export() function — can only import through it.`);
3703
+ process.exit(1);
3704
+ }
3676
3705
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3677
3706
  const ctx = makeContext({ bridge: name });
3678
3707
  // Memory fetcher — paginates GET /Memory?agentId=... applying any
@@ -3710,10 +3739,10 @@ bridge
3710
3739
  const onProgress = (ev) => {
3711
3740
  if (ev.type === "done") {
3712
3741
  if (opts.dryRun) {
3713
- console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3742
+ console.log(`\n${target.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3714
3743
  }
3715
3744
  else {
3716
- console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3745
+ console.log(`\n${target.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3717
3746
  }
3718
3747
  return;
3719
3748
  }
@@ -3733,15 +3762,28 @@ bridge
3733
3762
  process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3734
3763
  };
3735
3764
  try {
3736
- await runExport({
3737
- descriptor,
3738
- cwd,
3739
- fetchMemories,
3740
- filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3741
- dryRun: !!opts.dryRun,
3742
- ctx,
3743
- onProgress,
3744
- });
3765
+ if (loaded.kind === "yaml") {
3766
+ await runExport({
3767
+ descriptor: loaded.descriptor,
3768
+ cwd,
3769
+ fetchMemories,
3770
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3771
+ dryRun: !!opts.dryRun,
3772
+ ctx,
3773
+ onProgress,
3774
+ });
3775
+ }
3776
+ else {
3777
+ // Code plugin export: invoke plugin.export(memoryStream, opts, ctx) directly.
3778
+ // Plugin writes to its target however it likes (HTTP, file, etc.).
3779
+ if (opts.dryRun) {
3780
+ console.log(`${target.name}: dry-run not supported for code-plugin exports; aborting before invoking plugin.export().`);
3781
+ process.exit(2);
3782
+ }
3783
+ const pluginOpts = { ...opts };
3784
+ await loaded.plugin.export(fetchMemories({ agentId, subject: opts.subject, source: opts.source, since: opts.since }), pluginOpts, ctx);
3785
+ console.log(`${target.name}: code-plugin export completed. Record count not reported by the plugin.`);
3786
+ }
3745
3787
  }
3746
3788
  catch (err) {
3747
3789
  if (err instanceof BridgeRuntimeError) {
@@ -3762,7 +3804,7 @@ bridge
3762
3804
  const cwd = opts.cwd ?? process.cwd();
3763
3805
  const { discover } = await import("./bridges/discover.js");
3764
3806
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3765
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3807
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3766
3808
  const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
3767
3809
  const { BridgeRuntimeError } = await import("./bridges/types.js");
3768
3810
  const found = await discover({ builtins: builtinDiscoveryRecords() });
@@ -3771,25 +3813,30 @@ bridge
3771
3813
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3772
3814
  process.exit(1);
3773
3815
  }
3774
- let descriptor;
3816
+ let loaded;
3775
3817
  try {
3776
- descriptor = await loadDescriptor(target);
3818
+ loaded = await loadBridge(target);
3777
3819
  }
3778
3820
  catch (err) {
3779
3821
  printBridgeError(err);
3780
3822
  process.exit(1);
3781
3823
  }
3824
+ if (loaded.kind === "code") {
3825
+ console.error(`Bridge "${name}" is a code plugin — round-trip testing for code plugins lands in slice 3d (requires mocked fetch transport).`);
3826
+ console.error(`For now, code plugins should ship their own tests alongside the npm package.`);
3827
+ process.exit(2);
3828
+ }
3782
3829
  try {
3783
- const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
3830
+ const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
3784
3831
  if (opts.json) {
3785
3832
  console.log(JSON.stringify(result, null, 2));
3786
3833
  process.exit(result.passed ? 0 : 1);
3787
3834
  }
3788
3835
  if (result.passed) {
3789
- console.log(`✅ ${descriptor.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3836
+ console.log(`✅ ${target.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3790
3837
  process.exit(0);
3791
3838
  }
3792
- console.log(`❌ ${descriptor.name} round-trip failed.`);
3839
+ console.log(`❌ ${target.name} round-trip failed.`);
3793
3840
  console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
3794
3841
  if (result.missingInPass2.length > 0) {
3795
3842
  console.log(` missing from re-import (${result.missingInPass2.length}):`);
@@ -3825,11 +3872,92 @@ bridge
3825
3872
  process.exit(1);
3826
3873
  }
3827
3874
  });
3875
+ bridge
3876
+ .command("allow <name>")
3877
+ .description("Approve an npm code-plugin bridge for execution. Approval is pinned to the package's location and package.json contents — a malicious package squatting on the same name in a different node_modules tree will be refused at load-time.")
3878
+ .action(async (name) => {
3879
+ const { discover } = await import("./bridges/discover.js");
3880
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3881
+ const { allow } = await import("./bridges/runtime/allow-list.js");
3882
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3883
+ const target = found.find((b) => b.name === name);
3884
+ if (!target) {
3885
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3886
+ process.exit(1);
3887
+ }
3888
+ if (target.source !== "npm-package") {
3889
+ console.error(`"${name}" is a ${target.source} bridge; only npm code plugins require allow-list approval.`);
3890
+ console.error(`YAML and built-in bridges run via the descriptor runtime and don't execute arbitrary JS.`);
3891
+ process.exit(1);
3892
+ }
3893
+ try {
3894
+ const result = await allow(name, target.path);
3895
+ if (result.alreadyAllowed) {
3896
+ console.log(`${name} was already allowed at ${result.entry.packageDir} — no change.`);
3897
+ return;
3898
+ }
3899
+ const verb = result.updated ? "re-approved" : "allowed";
3900
+ console.log(`✓ ${name} ${verb}.`);
3901
+ console.log(` location: ${result.entry.packageDir}`);
3902
+ console.log(` version: ${result.entry.version ?? "(not declared)"}`);
3903
+ console.log(` digest: ${result.entry.packageJsonSha256.slice(0, 16)}…`);
3904
+ console.log(` If the package later moves or its package.json content changes, execution is refused until you re-run this command.`);
3905
+ console.log(` Revoke anytime with: flair bridge revoke ${name}`);
3906
+ }
3907
+ catch (err) {
3908
+ console.error(`Failed to approve "${name}": ${err?.message ?? err}`);
3909
+ process.exit(1);
3910
+ }
3911
+ });
3912
+ bridge
3913
+ .command("revoke <name>")
3914
+ .description("Revoke approval for an npm code-plugin bridge (future invocations will require `flair bridge allow <name>` again)")
3915
+ .action(async (name) => {
3916
+ const { revoke } = await import("./bridges/runtime/allow-list.js");
3917
+ const result = await revoke(name);
3918
+ if (!result.wasAllowed) {
3919
+ console.log(`${name} was not on the allow-list — no change.`);
3920
+ return;
3921
+ }
3922
+ console.log(`✓ ${name} revoked. Future invocations require \`flair bridge allow ${name}\` again.`);
3923
+ });
3924
+ bridge
3925
+ .command("allow-list")
3926
+ .description("Show the allow-listed code-plugin bridges")
3927
+ .option("--json", "Emit raw JSON")
3928
+ .action(async (opts) => {
3929
+ const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
3930
+ const entries = await listAllowed();
3931
+ if (opts.json) {
3932
+ console.log(JSON.stringify(entries, null, 2));
3933
+ return;
3934
+ }
3935
+ if (entries.length === 0) {
3936
+ console.log("No code-plugin bridges are allow-listed yet.");
3937
+ console.log("Allow one with: flair bridge allow <name>");
3938
+ return;
3939
+ }
3940
+ const nameW = Math.max(4, ...entries.map((e) => e.name.length));
3941
+ const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
3942
+ console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
3943
+ for (const e of entries) {
3944
+ console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
3945
+ console.log(` ${" ".repeat(nameW)} ${" ".repeat(verW)} ${" ".repeat(24)} sha256:${e.packageJsonSha256.slice(0, 16)}…`);
3946
+ }
3947
+ });
3828
3948
  function printBridgeError(err) {
3829
3949
  // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3830
3950
  // spec, plus a one-line human summary so the operator gets both.
3831
3951
  const detail = err?.detail;
3832
3952
  if (detail && typeof detail === "object") {
3953
+ // Trust-check failures get a dedicated, operator-facing rendering.
3954
+ // Dumping the full spec-§10 JSON is useful when an operator is
3955
+ // debugging a broken YAML descriptor; for trust errors it buries the
3956
+ // one thing that matters — the command to re-approve.
3957
+ if (detail.field === "(trust)") {
3958
+ printTrustError(detail);
3959
+ return;
3960
+ }
3833
3961
  console.error(`Bridge error: ${detail.hint ?? err.message}`);
3834
3962
  console.error(JSON.stringify(detail, null, 2));
3835
3963
  }
@@ -3837,6 +3965,73 @@ function printBridgeError(err) {
3837
3965
  console.error(`Bridge error: ${err.message ?? String(err)}`);
3838
3966
  }
3839
3967
  }
3968
+ function printTrustError(detail) {
3969
+ const name = detail.bridge ?? "(unknown)";
3970
+ const ctx = detail.context ?? {};
3971
+ const reapprove = ` flair bridge allow ${name}`;
3972
+ const bar = "─".repeat(60);
3973
+ const header = (title) => {
3974
+ console.error("");
3975
+ console.error(`⚠ ${title} — ${name}`);
3976
+ console.error(bar);
3977
+ };
3978
+ const footer = (label) => {
3979
+ console.error("");
3980
+ console.error(`${label}:`);
3981
+ console.error(reapprove);
3982
+ console.error("");
3983
+ };
3984
+ switch (detail.got) {
3985
+ case "not-allowed":
3986
+ header("Approval required");
3987
+ console.error("This bridge is an npm code plugin — it runs arbitrary JavaScript.");
3988
+ console.error("First-use approval is required before Flair will execute it.");
3989
+ footer("Approve it with");
3990
+ return;
3991
+ case "path-mismatch":
3992
+ header("Trust check failed: package location changed");
3993
+ console.error("A different package with the same name was discovered. This is how");
3994
+ console.error("local squatting attacks present — a planted `node_modules/flair-bridge-*`");
3995
+ console.error("in an unrelated project tree.");
3996
+ console.error("");
3997
+ console.error(` approved: ${ctx.approvedPath ?? "(unknown)"}`);
3998
+ console.error(` version ${ctx.approvedVersion ?? "?"} at ${ctx.approvedAt ?? "?"}`);
3999
+ console.error(` now: ${ctx.observedPath ?? "(unknown)"}`);
4000
+ footer("If the new location is intentional, re-approve");
4001
+ return;
4002
+ case "digest-mismatch":
4003
+ header("Trust check failed: package contents changed");
4004
+ console.error("The package.json at the approved location has changed since you");
4005
+ console.error("approved this bridge. This fires on every upgrade — it's a trust");
4006
+ console.error("event, not an error. If the update is intentional, re-approve.");
4007
+ console.error("");
4008
+ console.error(` location: ${ctx.packagePath ?? "(unknown)"}`);
4009
+ console.error(` approved version: ${ctx.approvedVersion ?? "?"} (at ${ctx.approvedAt ?? "?"})`);
4010
+ console.error(` approved digest: sha256:${(ctx.approvedDigest ?? "").slice(0, 16)}…`);
4011
+ console.error(` observed digest: sha256:${(ctx.observedDigest ?? "").slice(0, 16)}…`);
4012
+ footer("Re-approve");
4013
+ return;
4014
+ case "entry-incomplete":
4015
+ header("Trust check failed: approval record is incomplete");
4016
+ console.error("The allow-list entry for this bridge is missing a location or digest.");
4017
+ console.error("This usually means the record was created by a pre-fix Flair version");
4018
+ console.error("(0.6.0 / 0.6.1) that only stored the name. Re-approve to upgrade.");
4019
+ footer("Re-approve");
4020
+ return;
4021
+ case "package-missing":
4022
+ header("Trust check failed: approved package missing on disk");
4023
+ console.error("The package location recorded at allow-time is no longer readable.");
4024
+ console.error("");
4025
+ console.error(` approved at: ${ctx.approvedPath ?? "(unknown)"}`);
4026
+ console.error(` discovered: ${ctx.discoveredPath ?? "(unknown)"}`);
4027
+ footer("Reinstall the package, then re-approve");
4028
+ return;
4029
+ default:
4030
+ // Unknown trust sub-reason — fall back to the raw structured print.
4031
+ console.error(`Bridge error (trust): ${detail.hint ?? detail.got ?? "unknown"}`);
4032
+ console.error(JSON.stringify(detail, null, 2));
4033
+ }
4034
+ }
3840
4035
  // ─── flair backup ────────────────────────────────────────────────────────────
3841
4036
  program
3842
4037
  .command("backup")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,46 +0,0 @@
1
- /**
2
- * Unified descriptor loader.
3
- *
4
- * Given a discovered bridge, resolve it into a `YamlBridgeDescriptor`
5
- * that the runtime executor can drive — regardless of whether the
6
- * descriptor came from the in-tree built-in registry, a project-local
7
- * YAML, a user-scoped YAML, or an npm package.
8
- *
9
- * Code-plugin bridges (Shape B, npm packages) are not supported in
10
- * slice 2 — calling `loadDescriptor` on one returns a structured error
11
- * pointing at slice 3.
12
- */
13
- import { BridgeRuntimeError } from "../types.js";
14
- import { loadYamlDescriptor } from "./yaml-loader.js";
15
- import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
- export async function loadDescriptor(discovered) {
17
- switch (discovered.source) {
18
- case "builtin": {
19
- const builtin = BUILTIN_BY_NAME.get(discovered.name);
20
- if (!builtin) {
21
- throw new BridgeRuntimeError({
22
- bridge: discovered.name,
23
- op: "import",
24
- field: "(builtin)",
25
- expected: `registered built-in name`,
26
- got: discovered.name,
27
- hint: `discovery surfaced built-in "${discovered.name}" but the registry has no entry — likely a code drift between discover.ts and builtins/index.ts`,
28
- });
29
- }
30
- return builtin.descriptor;
31
- }
32
- case "project-yaml":
33
- case "user-yaml":
34
- return await loadYamlDescriptor(discovered.path);
35
- case "npm-package":
36
- throw new BridgeRuntimeError({
37
- bridge: discovered.name,
38
- op: "import",
39
- path: discovered.path,
40
- field: "(kind)",
41
- expected: "yaml file or built-in",
42
- got: "npm code plugin",
43
- hint: "code-plugin bridge runtime ships in slice 3 of FLAIR-BRIDGES (alongside the `flair bridge allow` trust prompt). Use a YAML descriptor in the meantime, or wait for slice 3.",
44
- });
45
- }
46
- }