@fiale-plus/pi-rogue-bundle 0.1.14 → 0.1.15
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/node_modules/@fiale-plus/pi-core/README.md +12 -0
- package/node_modules/@fiale-plus/pi-core/package.json +25 -0
- package/node_modules/@fiale-plus/pi-core/src/context-broker.test.ts +216 -0
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +308 -0
- package/node_modules/@fiale-plus/pi-core/src/index.ts +5 -0
- package/node_modules/@fiale-plus/pi-core/src/paths.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.test.ts +129 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.ts +97 -0
- package/node_modules/@fiale-plus/pi-core/src/storage.ts +39 -0
- package/node_modules/@fiale-plus/pi-core/src/text.test.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/text.ts +14 -0
- package/package.json +4 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Pi-Rogue Core
|
|
2
|
+
|
|
3
|
+
Shared helpers for the Pi-Rogue workspace.
|
|
4
|
+
|
|
5
|
+
Includes the first bounded context broker contract and in-memory implementation:
|
|
6
|
+
|
|
7
|
+
- `createInMemoryContextBroker()` stores artifacts behind stable `ctx://...` handles.
|
|
8
|
+
- Lookups support handle, session, kind, tag, path, command prefix, branch, and text filters.
|
|
9
|
+
- Omitted summaries become metadata-only placeholders, keeping raw payloads out of prompt briefs by default.
|
|
10
|
+
- Pruning enforces per-session record/byte caps, TTL expiry on reads, and pinned-artifact retention.
|
|
11
|
+
|
|
12
|
+
Install locally from this repo root: `npm install`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fiale-plus/pi-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared helpers for the Pi-Rogue workspace.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "cd ../.. && vitest run packages/core/src/*.test.ts"
|
|
12
|
+
},
|
|
13
|
+
"private": true,
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createInMemoryContextBroker } from "./context-broker.js";
|
|
4
|
+
|
|
5
|
+
describe("createInMemoryContextBroker", () => {
|
|
6
|
+
it("publishes stable, unique handles and looks up artifacts by handle", () => {
|
|
7
|
+
const broker = createInMemoryContextBroker();
|
|
8
|
+
const first = broker.publish({
|
|
9
|
+
sessionId: "session-a",
|
|
10
|
+
kind: "tool_output",
|
|
11
|
+
payload: "same payload",
|
|
12
|
+
summary: "tests passed",
|
|
13
|
+
tags: ["test"],
|
|
14
|
+
paths: ["packages/core"],
|
|
15
|
+
});
|
|
16
|
+
const second = broker.publish({
|
|
17
|
+
sessionId: "session-a",
|
|
18
|
+
kind: "tool_output",
|
|
19
|
+
payload: "same payload",
|
|
20
|
+
summary: "same payload repeat",
|
|
21
|
+
tags: ["test"],
|
|
22
|
+
paths: ["packages/core"],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(first.handle).not.toEqual(second.handle);
|
|
26
|
+
expect(first.handle).toMatch(/^ctx:\/\/session\/session-a\/tool_output\//);
|
|
27
|
+
expect(broker.lookup({ handle: first.handle })).toEqual([first]);
|
|
28
|
+
expect(broker.lookup({ handle: second.handle })).toEqual([second]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("filters by session, kind, tag, path, and text", () => {
|
|
32
|
+
const broker = createInMemoryContextBroker();
|
|
33
|
+
const core = broker.publish({
|
|
34
|
+
sessionId: "s1",
|
|
35
|
+
kind: "tool_output",
|
|
36
|
+
payload: "vitest packages/core passed",
|
|
37
|
+
tags: ["test", "core"],
|
|
38
|
+
paths: ["packages/core/src/context-broker.ts"],
|
|
39
|
+
});
|
|
40
|
+
broker.publish({
|
|
41
|
+
sessionId: "s2",
|
|
42
|
+
kind: "advisor_brief",
|
|
43
|
+
payload: "different payload",
|
|
44
|
+
tags: ["advisor"],
|
|
45
|
+
paths: ["packages/advisor/src/router.ts"],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(broker.lookup({ sessionId: "s1", kind: "tool_output", tag: "core" })).toEqual([core]);
|
|
49
|
+
expect(broker.lookup({ path: "packages/core" })).toEqual([core]);
|
|
50
|
+
expect(broker.lookup({ text: "vitest" })).toEqual([core]);
|
|
51
|
+
expect(broker.lookup({ sessionId: "s2", kind: "tool_output" })).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("uses a metadata-only summary when callers omit summaries", () => {
|
|
55
|
+
const broker = createInMemoryContextBroker({ briefBytes: 500 });
|
|
56
|
+
const artifact = broker.publish({
|
|
57
|
+
sessionId: "s",
|
|
58
|
+
kind: "tool_output",
|
|
59
|
+
payload: "SECRET_TOKEN=abc123\n".repeat(20),
|
|
60
|
+
paths: ["logs/secret-output.txt"],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const brief = broker.renderBrief({ sessionId: "s" });
|
|
64
|
+
|
|
65
|
+
expect(artifact.summary).toContain("payload stored externally");
|
|
66
|
+
expect(artifact.summary).not.toContain("SECRET_TOKEN");
|
|
67
|
+
expect(brief).not.toContain("SECRET_TOKEN");
|
|
68
|
+
expect(brief).toContain(artifact.handle);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("enforces record caps by pruning oldest unpinned artifacts", () => {
|
|
72
|
+
const broker = createInMemoryContextBroker({ maxRecords: 2, defaultTtlMs: 0 });
|
|
73
|
+
const first = broker.publish({ sessionId: "s", kind: "memory_note", payload: "one", createdAt: 1 });
|
|
74
|
+
const second = broker.publish({ sessionId: "s", kind: "memory_note", payload: "two", createdAt: 2 });
|
|
75
|
+
const third = broker.publish({ sessionId: "s", kind: "memory_note", payload: "three", createdAt: 3 });
|
|
76
|
+
|
|
77
|
+
expect(broker.lookup({ id: first.id })).toEqual([]);
|
|
78
|
+
expect(broker.lookup({ id: second.id })).toEqual([second]);
|
|
79
|
+
expect(broker.lookup({ id: third.id })).toEqual([third]);
|
|
80
|
+
expect(broker.status().records).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("applies caps independently per session", () => {
|
|
84
|
+
const broker = createInMemoryContextBroker({ maxRecords: 1, defaultTtlMs: 0 });
|
|
85
|
+
const sessionOne = broker.publish({ sessionId: "s1", kind: "tool_output", payload: "one", createdAt: 1 });
|
|
86
|
+
const sessionTwo = broker.publish({ sessionId: "s2", kind: "tool_output", payload: "two", createdAt: 2 });
|
|
87
|
+
|
|
88
|
+
expect(broker.lookup({ sessionId: "s1" })).toEqual([sessionOne]);
|
|
89
|
+
expect(broker.lookup({ sessionId: "s2" })).toEqual([sessionTwo]);
|
|
90
|
+
expect(broker.status().records).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses sequence tie-breakers when createdAt timestamps tie", () => {
|
|
94
|
+
const broker = createInMemoryContextBroker({ maxRecords: 2, defaultTtlMs: 0 });
|
|
95
|
+
const first = broker.publish({ sessionId: "s", kind: "tool_output", payload: "alpha", createdAt: 1000 });
|
|
96
|
+
const second = broker.publish({ sessionId: "s", kind: "tool_output", payload: "bravo", createdAt: 1000 });
|
|
97
|
+
const third = broker.publish({ sessionId: "s", kind: "tool_output", payload: "charlie", createdAt: 1000 });
|
|
98
|
+
|
|
99
|
+
expect(broker.lookup({ id: first.id })).toEqual([]);
|
|
100
|
+
expect(broker.lookup({ id: second.id })).toEqual([second]);
|
|
101
|
+
expect(broker.lookup({ id: third.id })).toEqual([third]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("enforces byte caps by pruning oldest unpinned artifacts", () => {
|
|
105
|
+
const broker = createInMemoryContextBroker({ maxBytes: 6, defaultTtlMs: 0 });
|
|
106
|
+
const first = broker.publish({ sessionId: "s", kind: "tool_output", payload: "12345", createdAt: 1 });
|
|
107
|
+
const second = broker.publish({ sessionId: "s", kind: "tool_output", payload: "abcde", createdAt: 2 });
|
|
108
|
+
|
|
109
|
+
expect(broker.lookup({ id: first.id })).toEqual([]);
|
|
110
|
+
expect(broker.lookup({ id: second.id })).toEqual([second]);
|
|
111
|
+
expect(broker.status().bytes).toBe(5);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("preserves the returned handle when a new artifact exceeds maxBytes", () => {
|
|
115
|
+
const broker = createInMemoryContextBroker({ maxBytes: 4, defaultTtlMs: 0 });
|
|
116
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload: "oversized", createdAt: 1 });
|
|
117
|
+
|
|
118
|
+
expect(broker.lookup({ id: artifact.id })).toEqual([artifact]);
|
|
119
|
+
expect(broker.lookup({ handle: artifact.handle })).toEqual([artifact]);
|
|
120
|
+
expect(broker.status().bytes).toBe(Buffer.byteLength("oversized", "utf8"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("computes bytes and SHA-256 on raw Buffer payload bytes", () => {
|
|
124
|
+
const broker = createInMemoryContextBroker({ maxBytes: 1024, defaultTtlMs: 0 });
|
|
125
|
+
const payload = Buffer.from([0x66, 0xff, 0x61, 0x62, 0x80, 0x00]);
|
|
126
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload, createdAt: 1 });
|
|
127
|
+
const expectedSha = createHash("sha256").update(payload).digest("hex");
|
|
128
|
+
|
|
129
|
+
expect(artifact.bytes).toBe(payload.length);
|
|
130
|
+
expect(artifact.sha256).toBe(expectedSha);
|
|
131
|
+
expect(Buffer.byteLength(artifact.payload, "utf8")).toBeGreaterThan(artifact.bytes);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps pinned artifacts visible while pruning unpinned records", () => {
|
|
135
|
+
const broker = createInMemoryContextBroker({ maxRecords: 2, defaultTtlMs: 0 });
|
|
136
|
+
const pinned = broker.publish({ sessionId: "s", kind: "diff", payload: "important", pinned: true, createdAt: 1 });
|
|
137
|
+
const older = broker.publish({ sessionId: "s", kind: "diff", payload: "temporary", createdAt: 2 });
|
|
138
|
+
const newer = broker.publish({ sessionId: "s", kind: "diff", payload: "latest", createdAt: 3 });
|
|
139
|
+
|
|
140
|
+
expect(broker.lookup({ id: pinned.id })).toEqual([pinned]);
|
|
141
|
+
expect(broker.lookup({ id: older.id })).toEqual([]);
|
|
142
|
+
expect(broker.lookup({ id: newer.id })).toEqual([newer]);
|
|
143
|
+
expect(broker.status().pinnedRecords).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("expires unpinned artifacts by ttl", () => {
|
|
147
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 10 });
|
|
148
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old", createdAt: 100 });
|
|
149
|
+
|
|
150
|
+
broker.prune(111);
|
|
151
|
+
|
|
152
|
+
expect(broker.lookup({ id: artifact.id })).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("prunes expired artifacts before lookup without an explicit prune call", () => {
|
|
156
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 1 });
|
|
157
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload: "expired", createdAt: 1 });
|
|
158
|
+
|
|
159
|
+
expect(broker.lookup({ id: artifact.id })).toEqual([]);
|
|
160
|
+
expect(broker.status().records).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("omits expired artifacts from rendered prompt briefs", () => {
|
|
164
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 1, briefBytes: 500 });
|
|
165
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload: "expired secret", summary: "expired summary", createdAt: 1 });
|
|
166
|
+
|
|
167
|
+
const brief = broker.renderBrief({ sessionId: "s" });
|
|
168
|
+
|
|
169
|
+
expect(brief).not.toContain(artifact.handle);
|
|
170
|
+
expect(brief).not.toContain("expired summary");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps pinned expired artifacts visible until unpinned", () => {
|
|
174
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 1 });
|
|
175
|
+
const artifact = broker.publish({ sessionId: "s", kind: "tool_output", payload: "pinned", pinned: true, createdAt: 1 });
|
|
176
|
+
|
|
177
|
+
expect(broker.lookup({ id: artifact.id })).toEqual([artifact]);
|
|
178
|
+
expect(broker.pin(artifact.id, false)).toBeNull();
|
|
179
|
+
expect(broker.lookup({ id: artifact.id })).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders a bounded prompt brief with lookup instructions", () => {
|
|
183
|
+
const broker = createInMemoryContextBroker({ briefBytes: 180 });
|
|
184
|
+
broker.publish({
|
|
185
|
+
sessionId: "s",
|
|
186
|
+
kind: "tool_output",
|
|
187
|
+
payload: "x".repeat(500),
|
|
188
|
+
summary: "large command output passed with no failures",
|
|
189
|
+
tags: ["test"],
|
|
190
|
+
paths: ["packages/core"],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const brief = broker.renderBrief();
|
|
194
|
+
|
|
195
|
+
expect(Buffer.byteLength(brief, "utf8")).toBeLessThanOrEqual(180);
|
|
196
|
+
expect(brief).toContain("Context Broker");
|
|
197
|
+
expect(brief).toContain("ctx://session/s/tool_output/");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("enforces prompt brief budgets by UTF-8 byte length", () => {
|
|
201
|
+
const broker = createInMemoryContextBroker({ briefBytes: 170 });
|
|
202
|
+
broker.publish({
|
|
203
|
+
sessionId: "emoji-session",
|
|
204
|
+
kind: "tool_output",
|
|
205
|
+
payload: "✅".repeat(200),
|
|
206
|
+
summary: "✅ 測試 passed ".repeat(20),
|
|
207
|
+
tags: ["測試", "✅"],
|
|
208
|
+
paths: ["packages/核心/✅.ts"],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const brief = broker.renderBrief({ budgetBytes: 170 });
|
|
212
|
+
|
|
213
|
+
expect(Buffer.byteLength(brief, "utf8")).toBeLessThanOrEqual(170);
|
|
214
|
+
expect(brief).toContain("Context Broker");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { safeName } from "./text.js";
|
|
3
|
+
|
|
4
|
+
export type ContextArtifactKind =
|
|
5
|
+
| "tool_output"
|
|
6
|
+
| "diff"
|
|
7
|
+
| "file_snapshot"
|
|
8
|
+
| "subagent_result"
|
|
9
|
+
| "advisor_brief"
|
|
10
|
+
| "memory_note";
|
|
11
|
+
|
|
12
|
+
export interface ContextArtifactInput {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
kind: ContextArtifactKind;
|
|
15
|
+
payload: string | Buffer;
|
|
16
|
+
summary?: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
paths?: string[];
|
|
19
|
+
command?: string;
|
|
20
|
+
branch?: string;
|
|
21
|
+
ttlMs?: number;
|
|
22
|
+
pinned?: boolean;
|
|
23
|
+
parentIds?: string[];
|
|
24
|
+
createdAt?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ContextArtifact {
|
|
28
|
+
id: string;
|
|
29
|
+
handle: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
kind: ContextArtifactKind;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
updatedAt: number;
|
|
34
|
+
bytes: number;
|
|
35
|
+
sha256: string;
|
|
36
|
+
payload: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
tags: string[];
|
|
39
|
+
paths: string[];
|
|
40
|
+
command?: string;
|
|
41
|
+
branch?: string;
|
|
42
|
+
expiresAt?: number;
|
|
43
|
+
pinned: boolean;
|
|
44
|
+
parentIds: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContextLookupQuery {
|
|
48
|
+
id?: string;
|
|
49
|
+
handle?: string;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
kind?: ContextArtifactKind;
|
|
52
|
+
tag?: string;
|
|
53
|
+
path?: string;
|
|
54
|
+
commandPrefix?: string;
|
|
55
|
+
branch?: string;
|
|
56
|
+
text?: string;
|
|
57
|
+
limit?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ContextBrokerStatus {
|
|
61
|
+
records: number;
|
|
62
|
+
bytes: number;
|
|
63
|
+
pinnedRecords: number;
|
|
64
|
+
pinnedBytes: number;
|
|
65
|
+
maxRecords: number;
|
|
66
|
+
maxBytes: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ContextBrokerOptions {
|
|
70
|
+
maxRecords?: number;
|
|
71
|
+
maxBytes?: number;
|
|
72
|
+
defaultTtlMs?: number;
|
|
73
|
+
summaryBytes?: number;
|
|
74
|
+
briefBytes?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BoundedContextBroker {
|
|
78
|
+
publish(input: ContextArtifactInput): ContextArtifact;
|
|
79
|
+
lookup(query?: ContextLookupQuery): ContextArtifact[];
|
|
80
|
+
pin(idOrHandle: string, pinned?: boolean): ContextArtifact | null;
|
|
81
|
+
prune(now?: number): ContextBrokerStatus;
|
|
82
|
+
status(): ContextBrokerStatus;
|
|
83
|
+
renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_MAX_RECORDS = 256;
|
|
87
|
+
const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
|
|
88
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
89
|
+
const DEFAULT_SUMMARY_BYTES = 320;
|
|
90
|
+
const DEFAULT_BRIEF_BYTES = 2_000;
|
|
91
|
+
|
|
92
|
+
function normalizeList(values: string[] | undefined): string[] {
|
|
93
|
+
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function payloadText(payload: string | Buffer): string {
|
|
97
|
+
return Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function payloadBytes(payload: string | Buffer): number {
|
|
101
|
+
return Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(String(payload ?? ""), "utf8");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hashPayload(payload: string | Buffer): string {
|
|
105
|
+
return createHash("sha256").update(Buffer.isBuffer(payload) ? payload : String(payload)).digest("hex");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeNeedle(value: string | undefined): string {
|
|
109
|
+
return String(value ?? "").trim().toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
113
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
114
|
+
if (Buffer.byteLength(text, "utf8") <= limit) return text;
|
|
115
|
+
if (limit === 0) return "";
|
|
116
|
+
|
|
117
|
+
const ellipsis = "…";
|
|
118
|
+
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8");
|
|
119
|
+
const contentLimit = Math.max(0, limit - ellipsisBytes);
|
|
120
|
+
let used = 0;
|
|
121
|
+
let result = "";
|
|
122
|
+
|
|
123
|
+
for (const char of text) {
|
|
124
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
125
|
+
if (used + bytes > contentLimit) break;
|
|
126
|
+
result += char;
|
|
127
|
+
used += bytes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Buffer.byteLength(result + ellipsis, "utf8") <= limit) return result + ellipsis;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKind, bytes: number, sha256: string, maxBytes: number): string {
|
|
135
|
+
const cleaned = String(summary ?? "").replace(/\s+/g, " ").trim();
|
|
136
|
+
if (cleaned) return truncateUtf8(cleaned, maxBytes);
|
|
137
|
+
return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
|
|
141
|
+
if (query.id && artifact.id !== query.id) return false;
|
|
142
|
+
if (query.handle && artifact.handle !== query.handle) return false;
|
|
143
|
+
if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
|
|
144
|
+
if (query.kind && artifact.kind !== query.kind) return false;
|
|
145
|
+
if (query.branch && artifact.branch !== query.branch) return false;
|
|
146
|
+
if (query.tag && !artifact.tags.includes(query.tag)) return false;
|
|
147
|
+
if (query.path) {
|
|
148
|
+
const queryPath = query.path.replace(/\/$/, "");
|
|
149
|
+
if (!artifact.paths.some((path) => path === query.path || path.startsWith(`${queryPath}/`))) return false;
|
|
150
|
+
}
|
|
151
|
+
if (query.commandPrefix && !artifact.command?.startsWith(query.commandPrefix)) return false;
|
|
152
|
+
|
|
153
|
+
const text = normalizeNeedle(query.text);
|
|
154
|
+
if (text) {
|
|
155
|
+
const haystack = [artifact.summary, artifact.payload, artifact.command, artifact.tags.join(" "), artifact.paths.join(" ")]
|
|
156
|
+
.join("\n")
|
|
157
|
+
.toLowerCase();
|
|
158
|
+
if (!haystack.includes(text)) return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
|
|
165
|
+
const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
|
|
166
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
167
|
+
const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
|
|
168
|
+
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
169
|
+
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
170
|
+
let artifacts: Array<ContextArtifact & { sequence: number }> = [];
|
|
171
|
+
let sequence = 0;
|
|
172
|
+
|
|
173
|
+
function currentStatus(): ContextBrokerStatus {
|
|
174
|
+
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
175
|
+
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
176
|
+
return {
|
|
177
|
+
records: artifacts.length,
|
|
178
|
+
bytes,
|
|
179
|
+
pinnedRecords: pinned.length,
|
|
180
|
+
pinnedBytes: pinned.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
181
|
+
maxRecords,
|
|
182
|
+
maxBytes,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function dropExpired(now = Date.now(), protectedIds = new Set<string>()): void {
|
|
187
|
+
artifacts = artifacts.filter(
|
|
188
|
+
(artifact) => artifact.pinned || protectedIds.has(artifact.id) || !artifact.expiresAt || artifact.expiresAt > now,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function oldestRemovable(sessionId: string, protectedIds: Set<string>): { artifact: ContextArtifact & { sequence: number }; index: number } | undefined {
|
|
193
|
+
return artifacts
|
|
194
|
+
.map((artifact, index) => ({ artifact, index }))
|
|
195
|
+
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id))
|
|
196
|
+
.sort((a, b) => {
|
|
197
|
+
if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
|
|
198
|
+
return a.artifact.sequence - b.artifact.sequence;
|
|
199
|
+
})[0];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sessionWithinCaps(sessionId: string): boolean {
|
|
203
|
+
const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId);
|
|
204
|
+
return sessionArtifacts.length <= maxRecords && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= maxBytes;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
208
|
+
dropExpired(now, protectedIds);
|
|
209
|
+
|
|
210
|
+
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
211
|
+
while (!sessionWithinCaps(sessionId)) {
|
|
212
|
+
const candidate = oldestRemovable(sessionId, protectedIds);
|
|
213
|
+
if (!candidate) break;
|
|
214
|
+
artifacts.splice(candidate.index, 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return currentStatus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function status(): ContextBrokerStatus {
|
|
222
|
+
dropExpired();
|
|
223
|
+
return currentStatus();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
227
|
+
const now = input.createdAt ?? Date.now();
|
|
228
|
+
const payload = payloadText(input.payload);
|
|
229
|
+
const sha256 = hashPayload(input.payload);
|
|
230
|
+
const bytes = payloadBytes(input.payload);
|
|
231
|
+
const artifactSequence = ++sequence;
|
|
232
|
+
const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
|
|
233
|
+
const session = safeName(input.sessionId || "session");
|
|
234
|
+
const kind = input.kind;
|
|
235
|
+
const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
|
|
236
|
+
const ttlMs = input.ttlMs ?? defaultTtlMs;
|
|
237
|
+
|
|
238
|
+
const artifact: ContextArtifact & { sequence: number } = {
|
|
239
|
+
id,
|
|
240
|
+
handle,
|
|
241
|
+
sessionId: input.sessionId,
|
|
242
|
+
kind,
|
|
243
|
+
createdAt: now,
|
|
244
|
+
updatedAt: now,
|
|
245
|
+
bytes,
|
|
246
|
+
sha256,
|
|
247
|
+
payload,
|
|
248
|
+
summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
|
|
249
|
+
tags: normalizeList(input.tags),
|
|
250
|
+
paths: normalizeList(input.paths),
|
|
251
|
+
command: input.command?.trim() || undefined,
|
|
252
|
+
branch: input.branch?.trim() || undefined,
|
|
253
|
+
expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
|
|
254
|
+
pinned: Boolean(input.pinned),
|
|
255
|
+
parentIds: normalizeList(input.parentIds),
|
|
256
|
+
sequence: artifactSequence,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
artifacts = [artifact, ...artifacts];
|
|
260
|
+
prune(now, new Set([artifact.id]));
|
|
261
|
+
return artifact;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
265
|
+
dropExpired();
|
|
266
|
+
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
267
|
+
return artifacts
|
|
268
|
+
.filter((artifact) => artifactMatches(artifact, query))
|
|
269
|
+
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.createdAt - a.createdAt || b.sequence - a.sequence)
|
|
270
|
+
.slice(0, limit);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pin(idOrHandle: string, pinned = true): ContextArtifact | null {
|
|
274
|
+
dropExpired();
|
|
275
|
+
const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
|
|
276
|
+
if (!artifact) return null;
|
|
277
|
+
artifact.pinned = pinned;
|
|
278
|
+
artifact.updatedAt = Date.now();
|
|
279
|
+
prune();
|
|
280
|
+
return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
|
|
284
|
+
const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
|
|
285
|
+
const lines = [
|
|
286
|
+
"## Context Broker",
|
|
287
|
+
`Budget: ${budget} bytes`,
|
|
288
|
+
...lookup({ ...query, limit: query.limit ?? 8 }).map((artifact) => {
|
|
289
|
+
const pin = artifact.pinned ? " pinned" : "";
|
|
290
|
+
const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
|
|
291
|
+
const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
|
|
292
|
+
return `- ${artifact.handle} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
|
|
293
|
+
}),
|
|
294
|
+
"Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
return truncateUtf8(lines.join("\n"), budget);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
publish,
|
|
302
|
+
lookup,
|
|
303
|
+
pin,
|
|
304
|
+
prune,
|
|
305
|
+
status,
|
|
306
|
+
renderBrief,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const ROOT_DIR = join(homedir(), ".pi", "agent", "fiale-plus");
|
|
6
|
+
|
|
7
|
+
export function appDir(): string {
|
|
8
|
+
mkdirSync(ROOT_DIR, { recursive: true });
|
|
9
|
+
return ROOT_DIR;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function featureDir(feature: string): string {
|
|
13
|
+
const dir = join(appDir(), feature);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sessionKey(ctx: any): string {
|
|
19
|
+
const sessionFile = ctx?.sessionManager?.getSessionFile?.();
|
|
20
|
+
if (!sessionFile) return "session";
|
|
21
|
+
return basename(String(sessionFile)).replace(/\.[^.]+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sessionDir(feature: string, ctx: any): string {
|
|
25
|
+
const dir = join(featureDir(feature), sessionKey(ctx));
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function featureFile(feature: string, filename: string): string {
|
|
31
|
+
return join(featureDir(feature), filename);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sessionFile(feature: string, ctx: any, filename: string): string {
|
|
35
|
+
return join(sessionDir(feature, ctx), filename);
|
|
36
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { scanShellCommand, type RiskScan } from "./risk.js";
|
|
3
|
+
|
|
4
|
+
describe("scanShellCommand", () => {
|
|
5
|
+
it("returns safe for empty command", () => {
|
|
6
|
+
const result = scanShellCommand("");
|
|
7
|
+
expect(result.safe).toBe(true);
|
|
8
|
+
expect(result.severity).toBe("safe");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns safe for a benign command", () => {
|
|
12
|
+
const result = scanShellCommand("echo hello world");
|
|
13
|
+
expect(result.safe).toBe(true);
|
|
14
|
+
expect(result.severity).toBe("safe");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("detects rm as dangerous", () => {
|
|
18
|
+
const result = scanShellCommand("rm -rf /tmp/foo");
|
|
19
|
+
expect(result.safe).toBe(false);
|
|
20
|
+
expect(result.severity).toBe("danger");
|
|
21
|
+
expect(result.findings.some((f) => f.id === "rm")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects rm only as a word boundary (not inside other words)", () => {
|
|
25
|
+
const result = scanShellCommand("program --remove-all");
|
|
26
|
+
expect(result.safe).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("detects sudo as warning", () => {
|
|
30
|
+
const result = scanShellCommand("sudo apt update");
|
|
31
|
+
expect(result.safe).toBe(false);
|
|
32
|
+
expect(result.severity).toBe("warn");
|
|
33
|
+
expect(result.findings.some((f) => f.id === "sudo")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("detects chmod -R as dangerous", () => {
|
|
37
|
+
const result = scanShellCommand("chmod -R 777 /etc");
|
|
38
|
+
expect(result.safe).toBe(false);
|
|
39
|
+
expect(result.severity).toBe("danger");
|
|
40
|
+
expect(result.findings.some((f) => f.id === "chmod-r")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("detects chown as dangerous", () => {
|
|
44
|
+
const result = scanShellCommand("chown root:root /var");
|
|
45
|
+
expect(result.safe).toBe(false);
|
|
46
|
+
expect(result.severity).toBe("danger");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("detects git push --force as dangerous", () => {
|
|
50
|
+
const result = scanShellCommand("git push --force origin main");
|
|
51
|
+
expect(result.safe).toBe(false);
|
|
52
|
+
expect(result.severity).toBe("danger");
|
|
53
|
+
expect(result.findings.some((f) => f.id === "force-push")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("detects git push --force-with-lease as dangerous", () => {
|
|
57
|
+
const result = scanShellCommand("git push --force-with-lease origin main");
|
|
58
|
+
expect(result.safe).toBe(false);
|
|
59
|
+
expect(result.severity).toBe("danger");
|
|
60
|
+
expect(result.findings.some((f) => f.id === "force-push")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("detects curl | sh as dangerous", () => {
|
|
64
|
+
const result = scanShellCommand("curl https://example.com/install.sh | sh");
|
|
65
|
+
expect(result.safe).toBe(false);
|
|
66
|
+
expect(result.severity).toBe("danger");
|
|
67
|
+
expect(result.findings.some((f) => f.id === "curl-shell")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("detects curl | bash as dangerous", () => {
|
|
71
|
+
const result = scanShellCommand("curl -fsSL https://example.com/install.sh | bash");
|
|
72
|
+
expect(result.safe).toBe(false);
|
|
73
|
+
expect(result.severity).toBe("danger");
|
|
74
|
+
expect(result.findings.some((f) => f.id === "curl-shell")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("detects mkfs as dangerous", () => {
|
|
78
|
+
const result = scanShellCommand("mkfs.ext4 /dev/sda1");
|
|
79
|
+
expect(result.safe).toBe(false);
|
|
80
|
+
expect(result.severity).toBe("danger");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("detects dd if= as dangerous", () => {
|
|
84
|
+
const result = scanShellCommand("dd if=/dev/zero of=/tmp/out bs=1M count=10");
|
|
85
|
+
expect(result.safe).toBe(false);
|
|
86
|
+
expect(result.severity).toBe("danger");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("detects shutdown as dangerous", () => {
|
|
90
|
+
const result = scanShellCommand("shutdown -h now");
|
|
91
|
+
expect(result.safe).toBe(false);
|
|
92
|
+
expect(result.severity).toBe("danger");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("detects reboot as dangerous", () => {
|
|
96
|
+
const result = scanShellCommand("reboot");
|
|
97
|
+
expect(result.safe).toBe(false);
|
|
98
|
+
expect(result.severity).toBe("danger");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("scans extra fragments", () => {
|
|
102
|
+
const result = scanShellCommand("docker exec -it container bash", ["docker exec"]);
|
|
103
|
+
expect(result.safe).toBe(false);
|
|
104
|
+
expect(result.findings.some((f) => f.id === "extra:docker exec")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns warn severity when only warnings are found", () => {
|
|
108
|
+
const result = scanShellCommand("sudo echo hello");
|
|
109
|
+
expect(result.safe).toBe(false);
|
|
110
|
+
expect(result.severity).toBe("warn");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns danger severity when danger items are found even with warnings", () => {
|
|
114
|
+
const result = scanShellCommand("sudo rm /tmp/foo");
|
|
115
|
+
expect(result.safe).toBe(false);
|
|
116
|
+
expect(result.severity).toBe("danger");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("handles case-insensitive matching", () => {
|
|
120
|
+
const result = scanShellCommand("RM -rf /");
|
|
121
|
+
expect(result.safe).toBe(false);
|
|
122
|
+
expect(result.findings.some((f) => f.id === "rm")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("ignores empty extra fragments", () => {
|
|
126
|
+
const result = scanShellCommand("echo foo", ["", " "]);
|
|
127
|
+
expect(result.safe).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export interface RiskFinding {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
severity: "warn" | "danger";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RiskScan {
|
|
8
|
+
safe: boolean;
|
|
9
|
+
severity: "safe" | "warn" | "danger";
|
|
10
|
+
findings: RiskFinding[];
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PATTERNS: RiskFinding[] = [
|
|
15
|
+
{ id: "rm", label: "rm", severity: "danger" },
|
|
16
|
+
{ id: "sudo", label: "sudo", severity: "warn" },
|
|
17
|
+
{ id: "chmod-r", label: "chmod -R", severity: "danger" },
|
|
18
|
+
{ id: "chown", label: "chown", severity: "danger" },
|
|
19
|
+
{ id: "mkfs", label: "mkfs", severity: "danger" },
|
|
20
|
+
{ id: "dd", label: "dd if=", severity: "danger" },
|
|
21
|
+
{ id: "shutdown", label: "shutdown", severity: "danger" },
|
|
22
|
+
{ id: "reboot", label: "reboot", severity: "danger" },
|
|
23
|
+
{ id: "force-push", label: "git push --force", severity: "danger" },
|
|
24
|
+
{ id: "curl-shell", label: "curl | sh", severity: "danger" },
|
|
25
|
+
{ id: "wget-shell", label: "wget | sh", severity: "danger" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function contains(command: string, fragment: string): boolean {
|
|
29
|
+
return command.toLowerCase().includes(fragment.toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scanShellCommand(command: string, extraFragments: string[] = []): RiskScan {
|
|
33
|
+
const findings: RiskFinding[] = [];
|
|
34
|
+
const text = command.trim();
|
|
35
|
+
|
|
36
|
+
if (!text) {
|
|
37
|
+
return { safe: true, severity: "safe", findings, reason: "Empty command." };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const pattern of DEFAULT_PATTERNS) {
|
|
41
|
+
switch (pattern.id) {
|
|
42
|
+
case "rm":
|
|
43
|
+
if (/\brm\b/i.test(text)) findings.push(pattern);
|
|
44
|
+
break;
|
|
45
|
+
case "sudo":
|
|
46
|
+
if (/\bsudo\b/i.test(text)) findings.push(pattern);
|
|
47
|
+
break;
|
|
48
|
+
case "chmod-r":
|
|
49
|
+
if (/\bchmod\s+-R\b/i.test(text)) findings.push(pattern);
|
|
50
|
+
break;
|
|
51
|
+
case "chown":
|
|
52
|
+
if (/\bchown\b/i.test(text)) findings.push(pattern);
|
|
53
|
+
break;
|
|
54
|
+
case "mkfs":
|
|
55
|
+
if (/\bmkfs(?:\.[\w-]+)?\b/i.test(text)) findings.push(pattern);
|
|
56
|
+
break;
|
|
57
|
+
case "dd":
|
|
58
|
+
if (/\bdd\s+if=/i.test(text)) findings.push(pattern);
|
|
59
|
+
break;
|
|
60
|
+
case "shutdown":
|
|
61
|
+
if (/\bshutdown\b/i.test(text)) findings.push(pattern);
|
|
62
|
+
break;
|
|
63
|
+
case "reboot":
|
|
64
|
+
if (/\breboot\b/i.test(text)) findings.push(pattern);
|
|
65
|
+
break;
|
|
66
|
+
case "force-push":
|
|
67
|
+
if (/\bgit\s+push\b[\s\S]*--force(?:-with-lease)?/i.test(text)) findings.push(pattern);
|
|
68
|
+
break;
|
|
69
|
+
case "curl-shell":
|
|
70
|
+
if (/\bcurl\b[\s\S]*\|\s*(sh|bash)\b/i.test(text)) findings.push(pattern);
|
|
71
|
+
break;
|
|
72
|
+
case "wget-shell":
|
|
73
|
+
if (/\bwget\b[\s\S]*\|\s*(sh|bash)\b/i.test(text)) findings.push(pattern);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const fragment of extraFragments) {
|
|
79
|
+
if (!fragment.trim()) continue;
|
|
80
|
+
if (contains(text, fragment)) {
|
|
81
|
+
findings.push({ id: `extra:${fragment}`, label: fragment, severity: "danger" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (findings.length === 0) {
|
|
86
|
+
return { safe: true, severity: "safe", findings, reason: "No risky shell fragments found." };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const severity = findings.some((finding) => finding.severity === "danger") ? "danger" : "warn";
|
|
90
|
+
const labels = findings.map((finding) => finding.label).join(", ");
|
|
91
|
+
return {
|
|
92
|
+
safe: false,
|
|
93
|
+
severity,
|
|
94
|
+
findings,
|
|
95
|
+
reason: `Detected risky shell fragment(s): ${labels}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureParent(filePath: string): string {
|
|
5
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
6
|
+
return filePath;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readText(filePath: string, fallback = ""): string {
|
|
10
|
+
try {
|
|
11
|
+
return readFileSync(filePath, "utf8");
|
|
12
|
+
} catch {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeText(filePath: string, text: string): void {
|
|
18
|
+
ensureParent(filePath);
|
|
19
|
+
writeFileSync(filePath, text, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function appendText(filePath: string, text: string): void {
|
|
23
|
+
ensureParent(filePath);
|
|
24
|
+
appendFileSync(filePath, text, "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readJson<T>(filePath: string, fallback: T): T {
|
|
28
|
+
try {
|
|
29
|
+
const raw = readText(filePath).trim();
|
|
30
|
+
if (!raw) return fallback;
|
|
31
|
+
return JSON.parse(raw) as T;
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeJson(filePath: string, value: unknown): void {
|
|
38
|
+
writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { truncate, safeName } from "./text.js";
|
|
3
|
+
|
|
4
|
+
describe("truncate", () => {
|
|
5
|
+
it("returns full text when within limit", () => {
|
|
6
|
+
expect(truncate("hello", 10)).toBe("hello");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("truncates and appends ellipsis when over limit", () => {
|
|
10
|
+
const result = truncate("hello world this is long", 12);
|
|
11
|
+
expect(result).toHaveLength(12);
|
|
12
|
+
expect(result.endsWith("…")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("handles empty string", () => {
|
|
16
|
+
expect(truncate("", 10)).toBe("");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("safeName", () => {
|
|
21
|
+
it("lowercases and replaces spaces with hyphens", () => {
|
|
22
|
+
expect(safeName("My Feature Branch")).toBe("my-feature-branch");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("replaces special characters with hyphens", () => {
|
|
26
|
+
expect(safeName("feature/auth!!@#")).toBe("feature-auth");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("strips leading/trailing hyphens", () => {
|
|
30
|
+
expect(safeName("--main--")).toBe("main");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to 'main' for empty input", () => {
|
|
34
|
+
expect(safeName("")).toBe("main");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function truncate(text: string, max: number): string {
|
|
2
|
+
if (text.length <= max) return text;
|
|
3
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function safeName(text: string): string {
|
|
7
|
+
return (
|
|
8
|
+
text
|
|
9
|
+
.trim()
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "") || "main"
|
|
13
|
+
);
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiale-plus/pi-rogue-bundle",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Public Pi-Rogue bundle for advisor and
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "Public Pi-Rogue bundle for advisor, orchestration, and shared context-broker core. Single consolidated artefact (leaf releases paused; private packages are bundled here).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -28,10 +28,12 @@
|
|
|
28
28
|
"@earendil-works/pi-coding-agent": "^0.74.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@fiale-plus/pi-core": "^0.1.0",
|
|
31
32
|
"@fiale-plus/pi-rogue-advisor": "^0.1.0",
|
|
32
33
|
"@fiale-plus/pi-rogue-orchestration": "^0.1.0"
|
|
33
34
|
},
|
|
34
35
|
"bundledDependencies": [
|
|
36
|
+
"@fiale-plus/pi-core",
|
|
35
37
|
"@fiale-plus/pi-rogue-advisor",
|
|
36
38
|
"@fiale-plus/pi-rogue-orchestration"
|
|
37
39
|
],
|