@tpsdev-ai/flair 0.8.3 → 0.9.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/README.md +27 -0
- package/dist/cli.js +380 -5
- package/dist/rem/restore.js +205 -0
- package/dist/rem/runner.js +221 -0
- package/dist/rem/scheduler.js +240 -0
- package/dist/rem/snapshot.js +139 -0
- package/dist/resources/A2AAdapter.js +5 -0
- package/dist/resources/Admin.js +16 -0
- package/dist/resources/AdminInstance.js +50 -4
- package/dist/resources/AdminMemory.js +7 -1
- package/dist/resources/MemoryMaintenance.js +50 -22
- package/dist/resources/OAuth.js +22 -0
- package/dist/resources/ObservationCenter.js +4 -0
- package/dist/resources/SkillScan.js +32 -89
- package/dist/resources/admin-layout.js +25 -1
- package/dist/resources/auth-middleware.js +14 -0
- package/dist/resources/scan/skill-scanner.js +166 -0
- package/package.json +2 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +9 -0
- package/templates/launchd/dev.flair.rem.nightly.plist.tmpl +46 -0
- package/templates/systemd/flair-rem-nightly.service.tmpl +14 -0
- package/templates/systemd/flair-rem-nightly.timer.tmpl +10 -0
|
@@ -1,15 +1,61 @@
|
|
|
1
1
|
import { Resource } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse } from "./admin-layout.js";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
4
5
|
import { homedir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the public URL operators reach this Flair on.
|
|
9
|
+
*
|
|
10
|
+
* Production deployments (Fabric, VPS-hosted, behind any reverse proxy)
|
|
11
|
+
* should set `FLAIR_PUBLIC_URL` in their launchd / systemd unit. The
|
|
12
|
+
* admin pane then shows the URL operators actually type — not the
|
|
13
|
+
* 127.0.0.1 binding address Harper sees internally — so the Endpoints
|
|
14
|
+
* table is copy-pasteable.
|
|
15
|
+
*
|
|
16
|
+
* Fall back to `http://127.0.0.1:${HTTP_PORT}` for local-only installs.
|
|
17
|
+
*/
|
|
18
|
+
function resolvePublicUrl() {
|
|
19
|
+
// Explicit override wins. Production deployments (Fabric, VPS-hosted)
|
|
20
|
+
// should set FLAIR_PUBLIC_URL in their launchd / systemd unit so the
|
|
21
|
+
// admin pane shows the URL operators actually type, not the binding
|
|
22
|
+
// address Harper sees internally. Auto-detecting from request headers
|
|
23
|
+
// is brittle across reverse-proxy configurations.
|
|
24
|
+
if (process.env.FLAIR_PUBLIC_URL) {
|
|
25
|
+
return process.env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
|
|
26
|
+
}
|
|
27
|
+
return `http://127.0.0.1:${process.env.HTTP_PORT ?? "19926"}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read the runtime package version from the bundled package.json so the
|
|
31
|
+
* Instance pane shows the real version (e.g. 0.8.3) rather than "dev" —
|
|
32
|
+
* `process.env.npm_package_version` is only populated inside `npm run`.
|
|
33
|
+
*/
|
|
34
|
+
function resolveVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const candidates = [
|
|
38
|
+
join(here, "..", "..", "package.json"),
|
|
39
|
+
join(here, "..", "package.json"),
|
|
40
|
+
];
|
|
41
|
+
for (const p of candidates) {
|
|
42
|
+
if (existsSync(p)) {
|
|
43
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
44
|
+
if (pkg.version)
|
|
45
|
+
return pkg.version;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch { /* fall through */ }
|
|
50
|
+
return process.env.npm_package_version ?? "dev";
|
|
51
|
+
}
|
|
5
52
|
/**
|
|
6
53
|
* GET /AdminInstance — instance info, public key, version.
|
|
7
54
|
*/
|
|
8
55
|
export class AdminInstance extends Resource {
|
|
9
56
|
async get() {
|
|
10
|
-
const version =
|
|
11
|
-
const
|
|
12
|
-
const publicUrl = process.env.FLAIR_PUBLIC_URL ?? `http://127.0.0.1:${httpPort}`;
|
|
57
|
+
const version = resolveVersion();
|
|
58
|
+
const publicUrl = resolvePublicUrl();
|
|
13
59
|
// Try to read instance public key
|
|
14
60
|
let publicKey = "—";
|
|
15
61
|
const keyDir = join(homedir(), ".flair", "keys");
|
|
@@ -41,10 +41,16 @@ export class AdminMemory extends Resource {
|
|
|
41
41
|
if (subject) {
|
|
42
42
|
conditions.push({ attribute: "subject", comparator: "equals", value: subject.toLowerCase() });
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
// Note: Harper's `not_equal true` predicate doesn't match rows where
|
|
45
|
+
// `archived` is `false` *or* unset — boolean comparators behave
|
|
46
|
+
// unevenly across boolean / undefined / null storage states. We skip
|
|
47
|
+
// archived rows in the JS-side filter loop below instead, so the list
|
|
48
|
+
// view actually returns non-archived memories rather than zero.
|
|
45
49
|
const searchQuery = conditions.length > 0 ? { conditions } : {};
|
|
46
50
|
let count = 0;
|
|
47
51
|
for await (const m of databases.flair.Memory.search(searchQuery)) {
|
|
52
|
+
if (m.archived === true)
|
|
53
|
+
continue;
|
|
48
54
|
if (m.expiresAt && Date.parse(m.expiresAt) < Date.now())
|
|
49
55
|
continue;
|
|
50
56
|
if (query && !String(m.content || "").toLowerCase().includes(query.toLowerCase()))
|
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MemoryMaintenance.ts — Maintenance worker for memory hygiene.
|
|
3
3
|
*
|
|
4
|
-
* POST /MemoryMaintenance
|
|
4
|
+
* POST /MemoryMaintenance — runs cleanup tasks:
|
|
5
5
|
* 1. Delete expired ephemeral memories (expiresAt < now)
|
|
6
6
|
* 2. Archive old session memories (> 30 days, standard durability)
|
|
7
7
|
* 3. Report stats
|
|
8
8
|
*
|
|
9
|
-
* Designed to run periodically (daily cron or
|
|
10
|
-
*
|
|
9
|
+
* Designed to run periodically (daily cron, scheduler, or REM nightly cycle).
|
|
10
|
+
* Authenticated via Ed25519 (agent acts on own memories) or admin (system-wide).
|
|
11
|
+
*
|
|
12
|
+
* History: prior to slice-2 PR-3, this class used a static-ROUTE pattern
|
|
13
|
+
* (`export default class MemoryMaintenance` with `static ROUTE`/`METHOD`)
|
|
14
|
+
* that Harper 5.x does not auto-register. Both `flair rem light` and the
|
|
15
|
+
* REM nightly runner were returning "Not found" against the endpoint.
|
|
16
|
+
* Migrated to the standard `extends Resource` shape with `allowCreate()`
|
|
17
|
+
* to gate auth correctly.
|
|
11
18
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
20
|
+
import { isAdmin } from "./auth-middleware.js";
|
|
21
|
+
export class MemoryMaintenance extends Resource {
|
|
22
|
+
/** POST requires auth — either an agent acting on its own memories, or admin. */
|
|
23
|
+
allowCreate() {
|
|
24
|
+
const ctx = this.getContext?.();
|
|
25
|
+
const request = ctx?.request ?? ctx;
|
|
26
|
+
return !!(request?.tpsAgent || request?.tpsAgentIsAdmin);
|
|
27
|
+
}
|
|
15
28
|
async post(data) {
|
|
16
|
-
const {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
const { dryRun = false, agentId: bodyAgentId } = data || {};
|
|
30
|
+
const ctx = this.getContext?.();
|
|
31
|
+
const request = ctx?.request ?? ctx;
|
|
32
|
+
const actorId = request?.tpsAgent;
|
|
33
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true
|
|
34
|
+
|| (actorId ? await isAdmin(actorId) : false);
|
|
35
|
+
// Scope rules:
|
|
36
|
+
// - Admin can pass agentId to maintain a specific agent (or omit it
|
|
37
|
+
// for fleet-wide maintenance).
|
|
38
|
+
// - Non-admin agents are scoped to their own memories — bodyAgentId
|
|
39
|
+
// either matches the authenticated agent or is ignored.
|
|
40
|
+
const targetAgent = callerIsAdmin
|
|
41
|
+
? bodyAgentId
|
|
42
|
+
: actorId;
|
|
43
|
+
if (!targetAgent && !callerIsAdmin) {
|
|
44
|
+
return new Response(JSON.stringify({ error: "agentId required" }), {
|
|
45
|
+
status: 400,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
26
48
|
}
|
|
27
49
|
const now = new Date();
|
|
28
50
|
const stats = { expired: 0, archived: 0, total: 0, errors: 0, agent: targetAgent || "all" };
|
|
29
51
|
try {
|
|
30
52
|
for await (const record of databases.flair.Memory.search()) {
|
|
31
|
-
// Skip records not belonging to target agent (unless admin running
|
|
53
|
+
// Skip records not belonging to target agent (unless admin running fleet-wide).
|
|
32
54
|
if (targetAgent && record.agentId !== targetAgent)
|
|
33
55
|
continue;
|
|
34
56
|
stats.total++;
|
|
@@ -48,9 +70,9 @@ export default class MemoryMaintenance {
|
|
|
48
70
|
}
|
|
49
71
|
continue;
|
|
50
72
|
}
|
|
51
|
-
// 2. Archive old standard session memories (> 30 days)
|
|
52
|
-
//
|
|
53
|
-
//
|
|
73
|
+
// 2. Archive old standard session memories (> 30 days). These are
|
|
74
|
+
// low-value session notes that weren't promoted to persistent.
|
|
75
|
+
// Soft-archive removes them from search results but keeps the data.
|
|
54
76
|
if (record.durability === "standard" &&
|
|
55
77
|
record.type === "session" &&
|
|
56
78
|
!record.archived &&
|
|
@@ -60,7 +82,6 @@ export default class MemoryMaintenance {
|
|
|
60
82
|
if (ageDays > 30) {
|
|
61
83
|
if (!dryRun) {
|
|
62
84
|
try {
|
|
63
|
-
// Soft archive — set archived flag, keep data
|
|
64
85
|
await databases.flair.Memory.update(record.id, {
|
|
65
86
|
...record,
|
|
66
87
|
archived: true,
|
|
@@ -80,11 +101,18 @@ export default class MemoryMaintenance {
|
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
catch (err) {
|
|
83
|
-
return { error: err.message, stats };
|
|
104
|
+
return new Response(JSON.stringify({ error: err.message, stats }), { status: 500, headers: { "content-type": "application/json" } });
|
|
84
105
|
}
|
|
106
|
+
// Flatten the historical { stats } wrapper into the top level so callers
|
|
107
|
+
// can read `.expired` / `.archived` directly. The wrapper shape is kept
|
|
108
|
+
// for backward compatibility with `flair rem light`.
|
|
85
109
|
return {
|
|
86
110
|
message: dryRun ? "Dry run complete" : "Maintenance complete",
|
|
87
111
|
stats,
|
|
112
|
+
expired: stats.expired,
|
|
113
|
+
archived: stats.archived,
|
|
114
|
+
total: stats.total,
|
|
115
|
+
errors: stats.errors,
|
|
88
116
|
};
|
|
89
117
|
}
|
|
90
118
|
}
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -36,6 +36,12 @@ function futureISO(ms) {
|
|
|
36
36
|
}
|
|
37
37
|
// ─── Discovery metadata ──────────────────────────────────────────────────────
|
|
38
38
|
export class OAuthMetadata extends Resource {
|
|
39
|
+
// OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
|
|
40
|
+
// it be accessible without authentication so clients can bootstrap their
|
|
41
|
+
// flow. `allowRead() { return true }` opens Harper's role gate so remote
|
|
42
|
+
// operators (e.g. Fabric-hosted Flair) get the metadata document instead
|
|
43
|
+
// of a 401 from Harper's intrinsic auth layer. Same pattern as Health (#386).
|
|
44
|
+
allowRead() { return true; }
|
|
39
45
|
async get() {
|
|
40
46
|
const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
|
|
41
47
|
return {
|
|
@@ -65,6 +71,10 @@ export class OAuthMetadata extends Resource {
|
|
|
65
71
|
}
|
|
66
72
|
// ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
|
|
67
73
|
export class OAuthRegister extends Resource {
|
|
74
|
+
// Dynamic Client Registration (RFC 7591) — clients must be able to register
|
|
75
|
+
// anonymously to bootstrap. Validation (redirect_uri allow-list, etc.)
|
|
76
|
+
// happens in post(). Pattern matches FederationPair.allowCreate (#299).
|
|
77
|
+
allowCreate() { return true; }
|
|
68
78
|
async post(data) {
|
|
69
79
|
const redirectUris = data?.redirect_uris ?? [];
|
|
70
80
|
const clientName = data?.client_name ?? "Unknown Client";
|
|
@@ -103,6 +113,11 @@ export class OAuthRegister extends Resource {
|
|
|
103
113
|
}
|
|
104
114
|
// ─── Authorization endpoint ──────────────────────────────────────────────────
|
|
105
115
|
export class OAuthAuthorize extends Resource {
|
|
116
|
+
// OAuth authorize endpoint must accept anonymous GET/POST — the whole
|
|
117
|
+
// point of authorize is to start an unauthenticated user's flow. PKCE
|
|
118
|
+
// and state-parameter validation in the handler enforce real auth.
|
|
119
|
+
allowRead() { return true; }
|
|
120
|
+
allowCreate() { return true; }
|
|
106
121
|
async get() {
|
|
107
122
|
// In 1.0, this returns a simple HTML consent page.
|
|
108
123
|
// The user (Nathan) approves or denies, which POSTs back.
|
|
@@ -202,6 +217,10 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
202
217
|
}
|
|
203
218
|
// ─── Token endpoint ──────────────────────────────────────────────────────────
|
|
204
219
|
export class OAuthToken extends Resource {
|
|
220
|
+
// OAuth token exchange — client must hit this without prior Harper auth
|
|
221
|
+
// since they're trading code/refresh for a token. Authentication is in
|
|
222
|
+
// the post() handler via PKCE verifier + client_secret_basic.
|
|
223
|
+
allowCreate() { return true; }
|
|
205
224
|
async post(data) {
|
|
206
225
|
const grantType = data?.grant_type;
|
|
207
226
|
if (grantType === "authorization_code") {
|
|
@@ -359,6 +378,9 @@ export class OAuthToken extends Resource {
|
|
|
359
378
|
}
|
|
360
379
|
// ─── Revocation endpoint ─────────────────────────────────────────────────────
|
|
361
380
|
export class OAuthRevoke extends Resource {
|
|
381
|
+
// RFC 7009 — token revocation accepts the token itself as proof; clients
|
|
382
|
+
// may not have a separate auth credential. Handler validates the token.
|
|
383
|
+
allowCreate() { return true; }
|
|
362
384
|
async post(data) {
|
|
363
385
|
const token = data?.token;
|
|
364
386
|
if (!token) {
|
|
@@ -10,6 +10,10 @@ const HTML = readFileSync(HTML_PATH, "utf8");
|
|
|
10
10
|
* The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
|
|
11
11
|
*/
|
|
12
12
|
export class ObservationCenter extends Resource {
|
|
13
|
+
// The dashboard shell is just HTML + inline JS; the JS prompts the user
|
|
14
|
+
// for admin-pass and authenticates each API call. The shell itself is
|
|
15
|
+
// intentionally public so it can render before auth happens.
|
|
16
|
+
allowRead() { return true; }
|
|
13
17
|
async get() {
|
|
14
18
|
return new Response(HTML, {
|
|
15
19
|
status: 200,
|
|
@@ -1,101 +1,44 @@
|
|
|
1
1
|
import { Resource } from "@harperfast/harper";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{ regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
|
|
33
|
-
];
|
|
34
|
-
// Unicode zero-width and homoglyph detection (raw chars)
|
|
35
|
-
const UNICODE_PATTERNS = [
|
|
36
|
-
{ regex: /[\u200B-\u200F\u2060\uFEFF]/, type: "zero_width_char" },
|
|
37
|
-
{ regex: /[\u0410-\u044F]/, type: "cyrillic_homoglyph" }, // Cyrillic chars that look like Latin
|
|
38
|
-
];
|
|
39
|
-
const ALL_PATTERNS = [
|
|
40
|
-
...SHELL_PATTERNS,
|
|
41
|
-
...NETWORK_PATTERNS,
|
|
42
|
-
...FS_PATTERNS,
|
|
43
|
-
...ENV_PATTERNS,
|
|
44
|
-
...ENCODING_PATTERNS,
|
|
45
|
-
...UNICODE_PATTERNS,
|
|
46
|
-
];
|
|
47
|
-
function assessRisk(violations) {
|
|
48
|
-
if (violations.length === 0)
|
|
49
|
-
return "low";
|
|
50
|
-
const types = new Set(violations.map((v) => v.type));
|
|
51
|
-
const hasShell = types.has("shell_command") || types.has("shell_backtick");
|
|
52
|
-
const hasNetwork = types.has("network_call");
|
|
53
|
-
const hasFs = types.has("fs_write") || types.has("fs_redirect");
|
|
54
|
-
const hasZeroWidth = types.has("zero_width_char");
|
|
55
|
-
const hasHomoglyph = types.has("cyrillic_homoglyph");
|
|
56
|
-
// Critical: shell + encoding, or zero-width/homoglyph obfuscation
|
|
57
|
-
if ((hasShell && (types.has("base64_decode") || types.has("hex_decode"))) ||
|
|
58
|
-
hasZeroWidth || hasHomoglyph) {
|
|
59
|
-
return "critical";
|
|
60
|
-
}
|
|
61
|
-
// High: direct shell commands or fs writes
|
|
62
|
-
if (hasShell || hasFs)
|
|
63
|
-
return "high";
|
|
64
|
-
// Medium: network calls or encoding without shell
|
|
65
|
-
if (hasNetwork || types.has("base64_decode") || types.has("hex_decode"))
|
|
66
|
-
return "medium";
|
|
67
|
-
return "low";
|
|
68
|
-
}
|
|
2
|
+
import { scanSkillContent } from "./scan/skill-scanner.js";
|
|
3
|
+
/**
|
|
4
|
+
* POST /SkillScan/
|
|
5
|
+
*
|
|
6
|
+
* Static analysis of skill content for security violations.
|
|
7
|
+
* Scans for shell commands, network calls, fs writes, env access,
|
|
8
|
+
* encoded payloads, zero-width chars, and homoglyphs.
|
|
9
|
+
*
|
|
10
|
+
* Request: { content: string }
|
|
11
|
+
* Response: { safe, violations, riskLevel }
|
|
12
|
+
*
|
|
13
|
+
* Auth: any authenticated agent (read-only analysis).
|
|
14
|
+
* Size limit: 8KB (8192 bytes).
|
|
15
|
+
*
|
|
16
|
+
* Scanner logic lives in `./scan/skill-scanner.ts` (pure module, no Harper
|
|
17
|
+
* runtime deps) so unit tests can exercise it without instantiating the
|
|
18
|
+
* Harper database.
|
|
19
|
+
*
|
|
20
|
+
* Markdown awareness:
|
|
21
|
+
* - Inline `single-token` backticks (markdown identifier convention) are NOT
|
|
22
|
+
* flagged. Only inline backticks whose content actually looks shell-ish
|
|
23
|
+
* (whitespace, pipe, semicolon, env-var, $(), &&, ||) fire shell_backtick.
|
|
24
|
+
* - Fenced ```code blocks``` with no language hint or a shell-family
|
|
25
|
+
* language (sh|bash|shell|zsh) are scanned for shell patterns. Non-shell
|
|
26
|
+
* language hints (json, yaml, ts, py, graphql, etc.) skip the shell
|
|
27
|
+
* patterns but keep network/fs/encoding/unicode detection active — those
|
|
28
|
+
* are language-agnostic attack surfaces.
|
|
29
|
+
* - This prevents the documentation-as-skill false-positive class without
|
|
30
|
+
* weakening detection on actual shell content.
|
|
31
|
+
*/
|
|
69
32
|
export class SkillScan extends Resource {
|
|
70
|
-
async post(data,
|
|
33
|
+
async post(data, _context) {
|
|
71
34
|
const { content } = data || {};
|
|
72
35
|
if (!content || typeof content !== "string") {
|
|
73
36
|
return new Response(JSON.stringify({ error: "content (string) required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
74
37
|
}
|
|
75
|
-
// 8KB size limit
|
|
76
38
|
const byteLength = new TextEncoder().encode(content).length;
|
|
77
39
|
if (byteLength > 8192) {
|
|
78
40
|
return new Response(JSON.stringify({ error: `Content exceeds 8KB limit (${byteLength} bytes)` }), { status: 413, headers: { "Content-Type": "application/json" } });
|
|
79
41
|
}
|
|
80
|
-
|
|
81
|
-
const violations = [];
|
|
82
|
-
for (let i = 0; i < lines.length; i++) {
|
|
83
|
-
const line = lines[i];
|
|
84
|
-
for (const pattern of ALL_PATTERNS) {
|
|
85
|
-
if (pattern.regex.test(line)) {
|
|
86
|
-
violations.push({
|
|
87
|
-
type: pattern.type,
|
|
88
|
-
line: i + 1,
|
|
89
|
-
content: line.trim().slice(0, 200),
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
const riskLevel = assessRisk(violations);
|
|
95
|
-
return {
|
|
96
|
-
safe: violations.length === 0,
|
|
97
|
-
violations,
|
|
98
|
-
riskLevel,
|
|
99
|
-
};
|
|
42
|
+
return scanSkillContent(content);
|
|
100
43
|
}
|
|
101
44
|
}
|
|
@@ -2,7 +2,31 @@
|
|
|
2
2
|
* Shared HTML layout for the Flair web admin.
|
|
3
3
|
* Server-rendered, no JS framework. Minimal CSS for Nathan-grade UX.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
// Resolve the runtime package version from package.json — process.env.npm_package_version
|
|
9
|
+
// is only populated inside `npm run`, so it returned "dev" in production and rendered
|
|
10
|
+
// "vdev" in the admin sidebar footer for anyone running the published binary.
|
|
11
|
+
function resolveVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const candidates = [
|
|
15
|
+
join(here, "..", "..", "package.json"),
|
|
16
|
+
join(here, "..", "package.json"),
|
|
17
|
+
];
|
|
18
|
+
for (const p of candidates) {
|
|
19
|
+
if (existsSync(p)) {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
21
|
+
if (pkg.version)
|
|
22
|
+
return pkg.version;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch { /* fall through */ }
|
|
27
|
+
return process.env.npm_package_version ?? "dev";
|
|
28
|
+
}
|
|
29
|
+
const VERSION = resolveVersion();
|
|
6
30
|
/** Escape HTML special characters to prevent stored XSS. */
|
|
7
31
|
export function esc(str) {
|
|
8
32
|
if (!str)
|
|
@@ -220,6 +220,20 @@ server.http(async (request, nextLayer) => {
|
|
|
220
220
|
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
221
221
|
const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
222
222
|
if (!m) {
|
|
223
|
+
// For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
|
|
224
|
+
// the browser shows a native auth dialog instead of a bare 401 page.
|
|
225
|
+
// JSON API endpoints don't get this — they should keep the structured
|
|
226
|
+
// 401 body so the client can parse the error.
|
|
227
|
+
const isAdminPage = url.pathname === "/Admin" || url.pathname.startsWith("/Admin");
|
|
228
|
+
if (isAdminPage) {
|
|
229
|
+
return new Response("Authentication required.", {
|
|
230
|
+
status: 401,
|
|
231
|
+
headers: {
|
|
232
|
+
"WWW-Authenticate": 'Basic realm="Flair Admin"',
|
|
233
|
+
"content-type": "text/plain; charset=utf-8",
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
223
237
|
return new Response(JSON.stringify({ error: "missing_or_invalid_authorization" }), { status: 401 });
|
|
224
238
|
}
|
|
225
239
|
const [, agentId, tsRaw, nonce, signatureB64] = m;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill content static analyzer — pure, no Harper runtime deps.
|
|
3
|
+
*
|
|
4
|
+
* Imported by `resources/SkillScan.ts` (the HTTP Resource) and by unit
|
|
5
|
+
* tests in `test/unit/SkillScan.test.ts`. Keeping the scanner logic in a
|
|
6
|
+
* separate module lets the tests run without instantiating the Harper
|
|
7
|
+
* runtime.
|
|
8
|
+
*
|
|
9
|
+
* See SkillScan.ts for the design rationale on markdown awareness and the
|
|
10
|
+
* language-agnostic vs shell-only pattern split.
|
|
11
|
+
*/
|
|
12
|
+
const SHELL_PATTERNS = [
|
|
13
|
+
{ regex: /\bexec\s*\(/, type: "shell_command" },
|
|
14
|
+
{ regex: /\bspawn\s*\(/, type: "shell_command" },
|
|
15
|
+
{ regex: /\bsystem\s*\(/, type: "shell_command" },
|
|
16
|
+
{ regex: /\bchild_process\b/, type: "shell_command" },
|
|
17
|
+
];
|
|
18
|
+
const NETWORK_PATTERNS = [
|
|
19
|
+
{ regex: /\bfetch\s*\(/, type: "network_call" },
|
|
20
|
+
{ regex: /\bcurl\b/, type: "network_call" },
|
|
21
|
+
{ regex: /https?:\/\//, type: "url_reference" },
|
|
22
|
+
{ regex: /\bXMLHttpRequest\b/, type: "network_call" },
|
|
23
|
+
{ regex: /\baxios\b/, type: "network_call" },
|
|
24
|
+
];
|
|
25
|
+
const FS_PATTERNS = [
|
|
26
|
+
{ regex: /\bfs\.write/, type: "fs_write" },
|
|
27
|
+
{ regex: /\bwriteFile/, type: "fs_write" },
|
|
28
|
+
{ regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
|
|
29
|
+
];
|
|
30
|
+
const ENV_PATTERNS = [
|
|
31
|
+
{ regex: /\bprocess\.env\b/, type: "env_access" },
|
|
32
|
+
{ regex: /\$ENV\b/, type: "env_access" },
|
|
33
|
+
{ regex: /\$\{?\w+\}?/, type: "env_variable" },
|
|
34
|
+
];
|
|
35
|
+
const ENCODING_PATTERNS = [
|
|
36
|
+
{ regex: /\batob\s*\(/, type: "base64_decode" },
|
|
37
|
+
{ regex: /\bbtoa\s*\(/, type: "base64_encode" },
|
|
38
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
|
|
39
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
|
|
40
|
+
{ regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
|
|
41
|
+
{ regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
|
|
42
|
+
];
|
|
43
|
+
const UNICODE_PATTERNS = [
|
|
44
|
+
{ regex: /[-]/, type: "zero_width_char" },
|
|
45
|
+
{ regex: /[А-я]/, type: "cyrillic_homoglyph" },
|
|
46
|
+
];
|
|
47
|
+
const LANG_AGNOSTIC_PATTERNS = [
|
|
48
|
+
...NETWORK_PATTERNS,
|
|
49
|
+
...FS_PATTERNS,
|
|
50
|
+
...ENV_PATTERNS,
|
|
51
|
+
...ENCODING_PATTERNS,
|
|
52
|
+
...UNICODE_PATTERNS,
|
|
53
|
+
];
|
|
54
|
+
const SHELL_FENCE_LANGS = new Set(["", "sh", "bash", "shell", "zsh"]);
|
|
55
|
+
const MARKDOWN_IDENTIFIER_RE = /^[\w@./-]+$/;
|
|
56
|
+
const SHELL_BACKTICK_INDICATORS = [
|
|
57
|
+
/\s/,
|
|
58
|
+
/[|;&]/,
|
|
59
|
+
/\$\(/,
|
|
60
|
+
/\$\{/,
|
|
61
|
+
/^\$\w/,
|
|
62
|
+
/>/,
|
|
63
|
+
/<\(/,
|
|
64
|
+
];
|
|
65
|
+
function lineHasShellishBacktick(line) {
|
|
66
|
+
const matches = line.match(/`([^`\n]+)`/g);
|
|
67
|
+
if (!matches)
|
|
68
|
+
return false;
|
|
69
|
+
for (const raw of matches) {
|
|
70
|
+
const inner = raw.slice(1, -1);
|
|
71
|
+
if (MARKDOWN_IDENTIFIER_RE.test(inner))
|
|
72
|
+
continue;
|
|
73
|
+
for (const indicator of SHELL_BACKTICK_INDICATORS) {
|
|
74
|
+
if (indicator.test(inner))
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function assessRisk(violations) {
|
|
81
|
+
if (violations.length === 0)
|
|
82
|
+
return "low";
|
|
83
|
+
const types = new Set(violations.map((v) => v.type));
|
|
84
|
+
// Programmatic shell call patterns: exec/spawn/system/child_process. These
|
|
85
|
+
// are definite payloads when they appear in a skill.
|
|
86
|
+
const hasShellCommand = types.has("shell_command");
|
|
87
|
+
// Backticked shell-ish content in markdown prose. Could be `npm run deploy`
|
|
88
|
+
// in documentation (medium), or could combine with other smells (high).
|
|
89
|
+
const hasShellBacktick = types.has("shell_backtick");
|
|
90
|
+
const hasFs = types.has("fs_write") || types.has("fs_redirect");
|
|
91
|
+
const hasEncoding = types.has("base64_decode") || types.has("hex_decode");
|
|
92
|
+
const hasZeroWidth = types.has("zero_width_char");
|
|
93
|
+
const hasHomoglyph = types.has("cyrillic_homoglyph");
|
|
94
|
+
// Critical: any shell combined with encoded payloads, OR obfuscation chars.
|
|
95
|
+
if (((hasShellCommand || hasShellBacktick) && hasEncoding) ||
|
|
96
|
+
hasZeroWidth ||
|
|
97
|
+
hasHomoglyph) {
|
|
98
|
+
return "critical";
|
|
99
|
+
}
|
|
100
|
+
// High: programmatic shell, fs writes, or shell-backtick + other smells
|
|
101
|
+
// (env access, network call). A skill that quotes a command AND reads env
|
|
102
|
+
// AND fetches a URL is doing something, not just documenting.
|
|
103
|
+
const hasOtherSmells = types.has("env_access") ||
|
|
104
|
+
types.has("env_variable") ||
|
|
105
|
+
types.has("network_call") ||
|
|
106
|
+
types.has("url_reference");
|
|
107
|
+
if (hasShellCommand || hasFs || (hasShellBacktick && hasOtherSmells)) {
|
|
108
|
+
return "high";
|
|
109
|
+
}
|
|
110
|
+
// Medium: shell_backtick alone (doc reference), or network/encoding
|
|
111
|
+
// without shell. Reviewable but not blocked.
|
|
112
|
+
if (hasShellBacktick || hasOtherSmells || hasEncoding) {
|
|
113
|
+
return "medium";
|
|
114
|
+
}
|
|
115
|
+
return "low";
|
|
116
|
+
}
|
|
117
|
+
function detectFenceTransition(line, state) {
|
|
118
|
+
const m = line.match(/^[ \t]*```([\w.-]*)\s*$/);
|
|
119
|
+
if (!m)
|
|
120
|
+
return false;
|
|
121
|
+
if (state.inFence) {
|
|
122
|
+
state.inFence = false;
|
|
123
|
+
state.lang = "";
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
state.inFence = true;
|
|
127
|
+
state.lang = (m[1] || "").toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
export function scanSkillContent(content) {
|
|
132
|
+
const lines = content.split("\n");
|
|
133
|
+
const violations = [];
|
|
134
|
+
const fence = { inFence: false, lang: "" };
|
|
135
|
+
const recordIfMatch = (lineIndex, line, patterns) => {
|
|
136
|
+
for (const pattern of patterns) {
|
|
137
|
+
if (pattern.regex.test(line)) {
|
|
138
|
+
violations.push({
|
|
139
|
+
type: pattern.type,
|
|
140
|
+
line: lineIndex + 1,
|
|
141
|
+
content: line.trim().slice(0, 200),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const line = lines[i] ?? "";
|
|
148
|
+
if (detectFenceTransition(line, fence))
|
|
149
|
+
continue;
|
|
150
|
+
const insideShellFence = fence.inFence && SHELL_FENCE_LANGS.has(fence.lang);
|
|
151
|
+
const insideNonShellFence = fence.inFence && !insideShellFence;
|
|
152
|
+
if (!fence.inFence && lineHasShellishBacktick(line)) {
|
|
153
|
+
violations.push({
|
|
154
|
+
type: "shell_backtick",
|
|
155
|
+
line: i + 1,
|
|
156
|
+
content: line.trim().slice(0, 200),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (!insideNonShellFence) {
|
|
160
|
+
recordIfMatch(i, line, SHELL_PATTERNS);
|
|
161
|
+
}
|
|
162
|
+
recordIfMatch(i, line, LANG_AGNOSTIC_PATTERNS);
|
|
163
|
+
}
|
|
164
|
+
const riskLevel = assessRisk(violations);
|
|
165
|
+
return { safe: violations.length === 0, violations, riskLevel };
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist/",
|
|
32
32
|
"schemas/",
|
|
33
|
+
"templates/",
|
|
33
34
|
"ui/",
|
|
34
35
|
"config.yaml",
|
|
35
36
|
"LICENSE",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Flair REM nightly runner shim.
|
|
3
|
+
# Deployed by `flair rem nightly enable` to {{SHIM_PATH}}.
|
|
4
|
+
# Invoked by launchd (macOS) or systemd (Linux) on the configured schedule.
|
|
5
|
+
#
|
|
6
|
+
# Single line that invokes the CLI's run-once subcommand — the runner module
|
|
7
|
+
# does all the work. Logs land in {{HOME}}/.flair/logs/.
|
|
8
|
+
set -e
|
|
9
|
+
exec {{FLAIR_BIN}} rem nightly run-once
|