@tpsdev-ai/flair 0.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.
- package/LICENSE +19 -0
- package/README.md +246 -0
- package/SECURITY.md +116 -0
- package/config.yaml +16 -0
- package/dist/resources/A2AAdapter.js +474 -0
- package/dist/resources/Agent.js +9 -0
- package/dist/resources/AgentCard.js +45 -0
- package/dist/resources/AgentSeed.js +111 -0
- package/dist/resources/IngestEvents.js +149 -0
- package/dist/resources/Integration.js +13 -0
- package/dist/resources/IssueTokens.js +19 -0
- package/dist/resources/Memory.js +122 -0
- package/dist/resources/MemoryBootstrap.js +263 -0
- package/dist/resources/MemoryConsolidate.js +105 -0
- package/dist/resources/MemoryFeed.js +41 -0
- package/dist/resources/MemoryReflect.js +105 -0
- package/dist/resources/OrgEvent.js +43 -0
- package/dist/resources/OrgEventCatchup.js +65 -0
- package/dist/resources/OrgEventMaintenance.js +29 -0
- package/dist/resources/SemanticSearch.js +147 -0
- package/dist/resources/SkillScan.js +101 -0
- package/dist/resources/Soul.js +9 -0
- package/dist/resources/SoulFeed.js +12 -0
- package/dist/resources/WorkspaceLatest.js +45 -0
- package/dist/resources/WorkspaceState.js +76 -0
- package/dist/resources/auth-middleware.js +470 -0
- package/dist/resources/embeddings-provider.js +127 -0
- package/dist/resources/embeddings.js +42 -0
- package/dist/resources/health.js +6 -0
- package/dist/resources/memory-feed-lib.js +15 -0
- package/dist/resources/table-helpers.js +35 -0
- package/package.json +62 -0
- package/resources/A2AAdapter.ts +510 -0
- package/resources/Agent.ts +10 -0
- package/resources/AgentCard.ts +65 -0
- package/resources/AgentSeed.ts +119 -0
- package/resources/IngestEvents.ts +189 -0
- package/resources/Integration.ts +14 -0
- package/resources/IssueTokens.ts +29 -0
- package/resources/Memory.ts +138 -0
- package/resources/MemoryBootstrap.ts +283 -0
- package/resources/MemoryConsolidate.ts +121 -0
- package/resources/MemoryFeed.ts +48 -0
- package/resources/MemoryReflect.ts +122 -0
- package/resources/OrgEvent.ts +63 -0
- package/resources/OrgEventCatchup.ts +89 -0
- package/resources/OrgEventMaintenance.ts +37 -0
- package/resources/SemanticSearch.ts +157 -0
- package/resources/SkillScan.ts +146 -0
- package/resources/Soul.ts +10 -0
- package/resources/SoulFeed.ts +15 -0
- package/resources/WorkspaceLatest.ts +66 -0
- package/resources/WorkspaceState.ts +102 -0
- package/resources/auth-middleware.ts +502 -0
- package/resources/embeddings-provider.ts +144 -0
- package/resources/embeddings.ts +28 -0
- package/resources/health.ts +7 -0
- package/resources/memory-feed-lib.ts +22 -0
- package/resources/table-helpers.ts +46 -0
- package/schemas/agent.graphql +22 -0
- package/schemas/event.graphql +12 -0
- package/schemas/memory.graphql +50 -0
- package/schemas/schema.graphql +41 -0
- package/schemas/workspace.graphql +14 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrgEventCatchup.ts — Custom resource for filtered event retrieval.
|
|
3
|
+
*
|
|
4
|
+
* GET /OrgEventCatchup/{participantId}?since=<ISO timestamp>
|
|
5
|
+
*
|
|
6
|
+
* Returns events where:
|
|
7
|
+
* - targetIds includes participantId OR targetIds is empty/null
|
|
8
|
+
* - createdAt >= since
|
|
9
|
+
* Sorted by createdAt ascending (oldest first for in-order processing).
|
|
10
|
+
* Limit 50 events max.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
14
|
+
|
|
15
|
+
export class OrgEventCatchup extends Resource {
|
|
16
|
+
// HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
|
|
17
|
+
async get(pathInfo?: any) {
|
|
18
|
+
const request = (this as any).request;
|
|
19
|
+
const callerAgent = request?.tpsAgent;
|
|
20
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
21
|
+
|
|
22
|
+
// Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
|
|
23
|
+
const participantId: string | null =
|
|
24
|
+
(typeof pathInfo === "object" && pathInfo !== null ? (pathInfo as any).id : null) ??
|
|
25
|
+
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
26
|
+
(this as any).getId?.() ??
|
|
27
|
+
null;
|
|
28
|
+
|
|
29
|
+
if (!participantId) {
|
|
30
|
+
return new Response(
|
|
31
|
+
JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }),
|
|
32
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Auth: requesting agent must match participantId (or admin)
|
|
37
|
+
if (callerAgent && !callerIsAdmin && callerAgent !== participantId) {
|
|
38
|
+
return new Response(
|
|
39
|
+
JSON.stringify({ error: "forbidden: can only fetch events for yourself" }),
|
|
40
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Harper parses query params into pathInfo.conditions array:
|
|
45
|
+
// e.g. ?since=value → conditions: [{attribute:"since", value:"...", comparator:"equals"}]
|
|
46
|
+
// pathInfo.id is the URL path segment (participantId).
|
|
47
|
+
const since: string | null =
|
|
48
|
+
(typeof pathInfo === "object" && pathInfo !== null
|
|
49
|
+
? (pathInfo as any).conditions?.find((c: any) => c.attribute === "since")?.value ?? null
|
|
50
|
+
: null);
|
|
51
|
+
if (!since) {
|
|
52
|
+
return new Response(
|
|
53
|
+
JSON.stringify({ error: "since query parameter required (ISO timestamp)" }),
|
|
54
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sinceDate = new Date(since);
|
|
59
|
+
if (isNaN(sinceDate.getTime())) {
|
|
60
|
+
return new Response(
|
|
61
|
+
JSON.stringify({ error: "invalid since timestamp" }),
|
|
62
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Query all OrgEvents and filter in-memory
|
|
67
|
+
const results: any[] = [];
|
|
68
|
+
for await (const event of (databases as any).flair.OrgEvent.search()) {
|
|
69
|
+
// Filter by createdAt >= since
|
|
70
|
+
if (!event.createdAt || event.createdAt < since) continue;
|
|
71
|
+
|
|
72
|
+
// Filter by targetIds: includes participantId OR empty/null
|
|
73
|
+
const targets = event.targetIds;
|
|
74
|
+
const isTargeted = !targets || targets.length === 0 || targets.includes(participantId);
|
|
75
|
+
if (!isTargeted) continue;
|
|
76
|
+
|
|
77
|
+
// Skip expired events
|
|
78
|
+
if (event.expiresAt && new Date(event.expiresAt) < new Date()) continue;
|
|
79
|
+
|
|
80
|
+
results.push(event);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Sort ascending by createdAt (oldest first)
|
|
84
|
+
results.sort((a: any, b: any) => (a.createdAt || "").localeCompare(b.createdAt || ""));
|
|
85
|
+
|
|
86
|
+
// Limit to 50
|
|
87
|
+
return results.slice(0, 50);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrgEventMaintenance.ts — Maintenance worker for expired OrgEvents.
|
|
3
|
+
*
|
|
4
|
+
* POST /OrgEventMaintenance/ — deletes all records where expiresAt < now.
|
|
5
|
+
* Auth: admin only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
9
|
+
|
|
10
|
+
export class OrgEventMaintenance extends Resource {
|
|
11
|
+
async post(_data: any, context?: any) {
|
|
12
|
+
// Admin-only
|
|
13
|
+
if (!context?.request?.tpsAgentIsAdmin) {
|
|
14
|
+
return new Response(
|
|
15
|
+
JSON.stringify({ error: "forbidden: admin only" }),
|
|
16
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
const toDelete: string[] = [];
|
|
22
|
+
|
|
23
|
+
for await (const event of (databases as any).flair.OrgEvent.search()) {
|
|
24
|
+
if (event.expiresAt && event.expiresAt < now) {
|
|
25
|
+
toDelete.push(event.id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const id of toDelete) {
|
|
30
|
+
try {
|
|
31
|
+
await (databases as any).flair.OrgEvent.delete(id);
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { deleted: toDelete.length };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
3
|
+
import { patchRecord } from "./table-helpers.js";
|
|
4
|
+
|
|
5
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
6
|
+
let dot = 0;
|
|
7
|
+
const len = Math.min(a.length, b.length);
|
|
8
|
+
for (let i = 0; i < len; i++) dot += a[i] * b[i];
|
|
9
|
+
return dot;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const DURABILITY_WEIGHTS: Record<string, number> = {
|
|
15
|
+
permanent: 1.0,
|
|
16
|
+
persistent: 0.9,
|
|
17
|
+
standard: 0.7,
|
|
18
|
+
ephemeral: 0.4,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Half-life in days for exponential decay per durability level
|
|
22
|
+
const DECAY_HALF_LIFE_DAYS: Record<string, number> = {
|
|
23
|
+
permanent: Infinity, // never decays
|
|
24
|
+
persistent: 90,
|
|
25
|
+
standard: 30,
|
|
26
|
+
ephemeral: 7,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function recencyFactor(createdAt: string, durability: string): number {
|
|
30
|
+
const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
|
|
31
|
+
if (halfLife === Infinity) return 1.0;
|
|
32
|
+
const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
|
|
33
|
+
const lambda = Math.LN2 / halfLife;
|
|
34
|
+
return Math.exp(-lambda * ageDays);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function retrievalBoost(retrievalCount: number): number {
|
|
38
|
+
if (!retrievalCount || retrievalCount <= 0) return 1.0;
|
|
39
|
+
return 1.0 + 0.1 * Math.log2(retrievalCount); // gentle boost: 10 retrievals → ~1.33x
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compositeScore(
|
|
43
|
+
semanticScore: number,
|
|
44
|
+
record: { durability?: string; createdAt?: string; retrievalCount?: number; supersedes?: string },
|
|
45
|
+
): number {
|
|
46
|
+
const durability = record.durability ?? "standard";
|
|
47
|
+
const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
|
|
48
|
+
const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
|
|
49
|
+
const rBoost = retrievalBoost(record.retrievalCount ?? 0);
|
|
50
|
+
return semanticScore * dWeight * rFactor * rBoost;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class SemanticSearch extends Resource {
|
|
54
|
+
async post(data: any) {
|
|
55
|
+
const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite" } = data || {};
|
|
56
|
+
const subjectFilter = subjects
|
|
57
|
+
? new Set((subjects as string[]).map((s: string) => s.toLowerCase()))
|
|
58
|
+
: subject
|
|
59
|
+
? new Set([(subject as string).toLowerCase()])
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
// Defense-in-depth: verify agentId matches authenticated agent.
|
|
63
|
+
// The middleware already enforces this for non-admins, but double-check here
|
|
64
|
+
// so direct Harper API calls (e.g., admin scripts) are also scoped correctly.
|
|
65
|
+
const authenticatedAgent: string | undefined = (this as any).request?.headers?.get?.("x-tps-agent");
|
|
66
|
+
const callerIsAdmin: boolean = (this as any).request?.tpsAgentIsAdmin === true;
|
|
67
|
+
if (authenticatedAgent && !callerIsAdmin && agentId && agentId !== authenticatedAgent) {
|
|
68
|
+
return new Response(JSON.stringify({
|
|
69
|
+
error: "forbidden: agentId must match authenticated agent",
|
|
70
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Determine searchable agent IDs (own + granted)
|
|
74
|
+
const searchAgentIds = new Set<string>();
|
|
75
|
+
if (agentId) searchAgentIds.add(agentId);
|
|
76
|
+
|
|
77
|
+
if (agentId) {
|
|
78
|
+
try {
|
|
79
|
+
for await (const grant of (databases as any).flair.MemoryGrant.search({
|
|
80
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: agentId }],
|
|
81
|
+
})) {
|
|
82
|
+
if (grant.scope === "search" || grant.scope === "read") {
|
|
83
|
+
searchAgentIds.add(grant.ownerId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch { /* MemoryGrant may not exist */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Generate query embedding
|
|
90
|
+
let qEmb = queryEmbedding;
|
|
91
|
+
if (!qEmb && q) {
|
|
92
|
+
if (getMode() !== "none") {
|
|
93
|
+
try { qEmb = await getEmbedding(String(q).slice(0, 500)); } catch {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const results: any[] = [];
|
|
98
|
+
|
|
99
|
+
// Iterate ALL memories, filter by agent ID set
|
|
100
|
+
for await (const record of (databases as any).flair.Memory.search()) {
|
|
101
|
+
// Filter by agent
|
|
102
|
+
if (searchAgentIds.size > 0 && !searchAgentIds.has(record.agentId)) {
|
|
103
|
+
if (record.visibility !== "office") continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (record.archived === true) continue; // soft-deleted — excluded from search by default
|
|
107
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) continue;
|
|
108
|
+
if (tag && !(record.tags || []).includes(tag)) continue;
|
|
109
|
+
if (subjectFilter && record.subject && !subjectFilter.has(String(record.subject).toLowerCase())) continue;
|
|
110
|
+
|
|
111
|
+
let rawScore = 0;
|
|
112
|
+
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
113
|
+
rawScore += 0.5;
|
|
114
|
+
}
|
|
115
|
+
if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
|
|
116
|
+
rawScore += cosineSimilarity(qEmb, record.embedding);
|
|
117
|
+
}
|
|
118
|
+
if (q && rawScore === 0) continue;
|
|
119
|
+
|
|
120
|
+
// Apply composite scoring (temporal decay + durability + retrieval boost)
|
|
121
|
+
const finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
|
|
122
|
+
|
|
123
|
+
const { embedding, ...rest } = record;
|
|
124
|
+
results.push({
|
|
125
|
+
...rest,
|
|
126
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
127
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
128
|
+
_source: record.agentId !== agentId ? record.agentId : undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build superseded set and filter (unless caller opts in to see full history)
|
|
133
|
+
let filteredResults = results;
|
|
134
|
+
if (!includeSuperseded) {
|
|
135
|
+
const supersededIds = new Set<string>();
|
|
136
|
+
for (const r of results) {
|
|
137
|
+
if (r.supersedes) supersededIds.add(r.supersedes);
|
|
138
|
+
}
|
|
139
|
+
filteredResults = results.filter((r: any) => !supersededIds.has(r.id));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
filteredResults.sort((a: any, b: any) => b._score - a._score);
|
|
143
|
+
const topResults = filteredResults.slice(0, limit);
|
|
144
|
+
|
|
145
|
+
// Async hit tracking — don't block the response
|
|
146
|
+
// Use patchRecord to avoid wiping other fields (embedding, content, etc.)
|
|
147
|
+
const now = new Date().toISOString();
|
|
148
|
+
for (const r of topResults) {
|
|
149
|
+
patchRecord((databases as any).flair.Memory, r.id, {
|
|
150
|
+
retrievalCount: (r.retrievalCount || 0) + 1,
|
|
151
|
+
lastRetrieved: now,
|
|
152
|
+
}).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { results: topResults };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Resource } from "@harperfast/harper";
|
|
2
|
+
|
|
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
|
+
|
|
17
|
+
interface Violation {
|
|
18
|
+
type: string;
|
|
19
|
+
line: number;
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RiskLevel = "low" | "medium" | "high" | "critical";
|
|
24
|
+
|
|
25
|
+
const SHELL_PATTERNS = [
|
|
26
|
+
{ regex: /\bexec\s*\(/, type: "shell_command" },
|
|
27
|
+
{ regex: /\bspawn\s*\(/, type: "shell_command" },
|
|
28
|
+
{ regex: /\bsystem\s*\(/, type: "shell_command" },
|
|
29
|
+
{ regex: /`[^`]*`/, type: "shell_backtick" },
|
|
30
|
+
{ regex: /\bchild_process\b/, type: "shell_command" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const NETWORK_PATTERNS = [
|
|
34
|
+
{ regex: /\bfetch\s*\(/, type: "network_call" },
|
|
35
|
+
{ regex: /\bcurl\b/, type: "network_call" },
|
|
36
|
+
{ regex: /https?:\/\//, type: "url_reference" },
|
|
37
|
+
{ regex: /\bXMLHttpRequest\b/, type: "network_call" },
|
|
38
|
+
{ regex: /\baxios\b/, type: "network_call" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const FS_PATTERNS = [
|
|
42
|
+
{ regex: /\bfs\.write/, type: "fs_write" },
|
|
43
|
+
{ regex: /\bwriteFile/, type: "fs_write" },
|
|
44
|
+
{ regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const ENV_PATTERNS = [
|
|
48
|
+
{ regex: /\bprocess\.env\b/, type: "env_access" },
|
|
49
|
+
{ regex: /\$ENV\b/, type: "env_access" },
|
|
50
|
+
{ regex: /\$\{?\w+\}?/, type: "env_variable" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const ENCODING_PATTERNS = [
|
|
54
|
+
{ regex: /\batob\s*\(/, type: "base64_decode" },
|
|
55
|
+
{ regex: /\bbtoa\s*\(/, type: "base64_encode" },
|
|
56
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
|
|
57
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
|
|
58
|
+
{ regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
|
|
59
|
+
{ regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Unicode zero-width and homoglyph detection (raw chars)
|
|
63
|
+
const UNICODE_PATTERNS = [
|
|
64
|
+
{ regex: /[\u200B-\u200F\u2060\uFEFF]/, type: "zero_width_char" },
|
|
65
|
+
{ regex: /[\u0410-\u044F]/, type: "cyrillic_homoglyph" }, // Cyrillic chars that look like Latin
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const ALL_PATTERNS = [
|
|
69
|
+
...SHELL_PATTERNS,
|
|
70
|
+
...NETWORK_PATTERNS,
|
|
71
|
+
...FS_PATTERNS,
|
|
72
|
+
...ENV_PATTERNS,
|
|
73
|
+
...ENCODING_PATTERNS,
|
|
74
|
+
...UNICODE_PATTERNS,
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function assessRisk(violations: Violation[]): RiskLevel {
|
|
78
|
+
if (violations.length === 0) return "low";
|
|
79
|
+
|
|
80
|
+
const types = new Set(violations.map((v) => v.type));
|
|
81
|
+
const hasShell = types.has("shell_command") || types.has("shell_backtick");
|
|
82
|
+
const hasNetwork = types.has("network_call");
|
|
83
|
+
const hasFs = types.has("fs_write") || types.has("fs_redirect");
|
|
84
|
+
const hasZeroWidth = types.has("zero_width_char");
|
|
85
|
+
const hasHomoglyph = types.has("cyrillic_homoglyph");
|
|
86
|
+
|
|
87
|
+
// Critical: shell + encoding, or zero-width/homoglyph obfuscation
|
|
88
|
+
if ((hasShell && (types.has("base64_decode") || types.has("hex_decode"))) ||
|
|
89
|
+
hasZeroWidth || hasHomoglyph) {
|
|
90
|
+
return "critical";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// High: direct shell commands or fs writes
|
|
94
|
+
if (hasShell || hasFs) return "high";
|
|
95
|
+
|
|
96
|
+
// Medium: network calls or encoding without shell
|
|
97
|
+
if (hasNetwork || types.has("base64_decode") || types.has("hex_decode")) return "medium";
|
|
98
|
+
|
|
99
|
+
return "low";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class SkillScan extends Resource {
|
|
103
|
+
async post(data: any, context?: any) {
|
|
104
|
+
const { content } = data || {};
|
|
105
|
+
|
|
106
|
+
if (!content || typeof content !== "string") {
|
|
107
|
+
return new Response(
|
|
108
|
+
JSON.stringify({ error: "content (string) required" }),
|
|
109
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 8KB size limit
|
|
114
|
+
const byteLength = new TextEncoder().encode(content).length;
|
|
115
|
+
if (byteLength > 8192) {
|
|
116
|
+
return new Response(
|
|
117
|
+
JSON.stringify({ error: `Content exceeds 8KB limit (${byteLength} bytes)` }),
|
|
118
|
+
{ status: 413, headers: { "Content-Type": "application/json" } },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines = content.split("\n");
|
|
123
|
+
const violations: Violation[] = [];
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
for (const pattern of ALL_PATTERNS) {
|
|
128
|
+
if (pattern.regex.test(line)) {
|
|
129
|
+
violations.push({
|
|
130
|
+
type: pattern.type,
|
|
131
|
+
line: i + 1,
|
|
132
|
+
content: line.trim().slice(0, 200),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const riskLevel = assessRisk(violations);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
safe: violations.length === 0,
|
|
142
|
+
violations,
|
|
143
|
+
riskLevel,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
|
|
3
|
+
export class Soul extends (databases as any).flair.Soul {
|
|
4
|
+
async post(content: any, context?: any) {
|
|
5
|
+
content.durability ||= "permanent";
|
|
6
|
+
content.createdAt = new Date().toISOString();
|
|
7
|
+
content.updatedAt = content.createdAt;
|
|
8
|
+
return super.post(content, context);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Resource, databases } from '@harperfast/harper';
|
|
2
|
+
|
|
3
|
+
export class FeedSouls extends Resource {
|
|
4
|
+
async *connect(target: any, incomingMessages: any) {
|
|
5
|
+
const subscription = await (databases as any).flair.Soul.subscribe(target);
|
|
6
|
+
|
|
7
|
+
if (!incomingMessages) {
|
|
8
|
+
return subscription;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
for await (const event of subscription) {
|
|
12
|
+
yield event;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceLatest.ts — Custom resource returning the most recent WorkspaceState for an agent.
|
|
3
|
+
*
|
|
4
|
+
* GET /WorkspaceLatest/{agentId} — returns most recent WorkspaceState record.
|
|
5
|
+
* Auth: requesting agent must match agentId in path (or be admin).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
9
|
+
|
|
10
|
+
export class WorkspaceLatest extends Resource {
|
|
11
|
+
async get(pathInfo?: any) {
|
|
12
|
+
const request = (this as any).context?.request ?? (this as any).request;
|
|
13
|
+
const callerAgent = request?.tpsAgent;
|
|
14
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
15
|
+
|
|
16
|
+
// Extract agentId from path: /WorkspaceLatest/{agentId}
|
|
17
|
+
const agentId =
|
|
18
|
+
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
19
|
+
(this as any).getId?.() ??
|
|
20
|
+
null;
|
|
21
|
+
|
|
22
|
+
if (!agentId) {
|
|
23
|
+
return new Response(
|
|
24
|
+
JSON.stringify({ error: "agentId required in path: GET /WorkspaceLatest/{agentId}" }),
|
|
25
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Auth: requesting agent must match path agentId (or admin)
|
|
30
|
+
if (callerAgent && !callerIsAdmin && callerAgent !== agentId) {
|
|
31
|
+
return new Response(
|
|
32
|
+
JSON.stringify({ error: "forbidden: cannot read workspace state for another agent" }),
|
|
33
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Query WorkspaceState table for this agent, sorted by timestamp desc
|
|
38
|
+
let latest: any = null;
|
|
39
|
+
try {
|
|
40
|
+
const results = (databases as any).flair.WorkspaceState.search({
|
|
41
|
+
conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
|
|
42
|
+
sort: { attribute: "timestamp", descending: true },
|
|
43
|
+
limit: 1,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for await (const row of results) {
|
|
47
|
+
latest = row;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
return new Response(
|
|
52
|
+
JSON.stringify({ error: "workspace_state_query_failed", detail: err.message }),
|
|
53
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!latest) {
|
|
58
|
+
return new Response(
|
|
59
|
+
JSON.stringify({ error: "no_workspace_state_found", agentId }),
|
|
60
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return latest;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceState.ts — Harper table resource for workspace state records (OPS-47 Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* Auth: Ed25519 middleware sets request.tpsAgent. Agent can only read/write own records.
|
|
5
|
+
* Pattern follows Memory.ts — extends Harper auto-generated table class.
|
|
6
|
+
*
|
|
7
|
+
* Note: Harper's static methods call instance methods with positional args only.
|
|
8
|
+
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { databases } from "@harperfast/harper";
|
|
12
|
+
import { isAdmin } from "./auth-middleware.js";
|
|
13
|
+
|
|
14
|
+
export class WorkspaceState extends (databases as any).flair.WorkspaceState {
|
|
15
|
+
/**
|
|
16
|
+
* Helper to extract auth info from Harper's Resource instance context.
|
|
17
|
+
*/
|
|
18
|
+
private _authInfo() {
|
|
19
|
+
const ctx = (this as any).getContext?.();
|
|
20
|
+
const request = ctx?.request ?? ctx;
|
|
21
|
+
return {
|
|
22
|
+
agentId: request?.tpsAgent as string | undefined,
|
|
23
|
+
isAdmin: request?.tpsAgentIsAdmin as boolean ?? false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Override search() to scope collection GETs to the authenticated agent's
|
|
29
|
+
* own workspace state records. Admin agents see all records.
|
|
30
|
+
*/
|
|
31
|
+
async search(query?: any) {
|
|
32
|
+
const { agentId: authAgent, isAdmin: isAdminAgent } = this._authInfo();
|
|
33
|
+
|
|
34
|
+
if (!authAgent || isAdminAgent) {
|
|
35
|
+
return super.search(query);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: authAgent };
|
|
39
|
+
|
|
40
|
+
// Harper passes `query` as a request target object (with pathname, id, isCollection, etc.)
|
|
41
|
+
// Inject scope condition into its `.conditions` array so Table.search() processes it correctly.
|
|
42
|
+
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
43
|
+
const existing = query.conditions ?? [];
|
|
44
|
+
query.conditions = Array.isArray(existing)
|
|
45
|
+
? [agentIdCondition, ...existing]
|
|
46
|
+
: [agentIdCondition, existing];
|
|
47
|
+
return super.search(query);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const conditions = Array.isArray(query) && query.length > 0
|
|
51
|
+
? [agentIdCondition, ...query]
|
|
52
|
+
: [agentIdCondition];
|
|
53
|
+
return super.search(conditions);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async post(content: any) {
|
|
57
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
58
|
+
|
|
59
|
+
// Agent-scoped: agentId in body must match authenticated agent
|
|
60
|
+
if (agentId && !isAdminAgent && content.agentId !== agentId) {
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({ error: "forbidden: cannot write workspace state for another agent" }),
|
|
63
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
content.createdAt = new Date().toISOString();
|
|
68
|
+
content.timestamp ||= content.createdAt;
|
|
69
|
+
|
|
70
|
+
return super.post(content);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async put(content: any) {
|
|
74
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
75
|
+
|
|
76
|
+
if (agentId && !isAdminAgent && content.agentId !== agentId) {
|
|
77
|
+
return new Response(
|
|
78
|
+
JSON.stringify({ error: "forbidden: cannot write workspace state for another agent" }),
|
|
79
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return super.put(content);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async delete(id: any) {
|
|
87
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
88
|
+
if (!agentId) return super.delete(id);
|
|
89
|
+
|
|
90
|
+
const record = await this.get(id);
|
|
91
|
+
if (!record) return super.delete(id);
|
|
92
|
+
|
|
93
|
+
if (!isAdminAgent && record.agentId !== agentId) {
|
|
94
|
+
return new Response(
|
|
95
|
+
JSON.stringify({ error: "forbidden: cannot delete workspace state for another agent" }),
|
|
96
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return super.delete(id);
|
|
101
|
+
}
|
|
102
|
+
}
|