pi-sage 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/.pi/extensions/sage/index.ts +659 -0
- package/.pi/extensions/sage/policy.ts +114 -0
- package/.pi/extensions/sage/runner.ts +461 -0
- package/.pi/extensions/sage/settings.ts +202 -0
- package/.pi/extensions/sage/tool-policy.ts +195 -0
- package/.pi/extensions/sage/types.ts +108 -0
- package/AGENTS.md +87 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/docs/SAGE_SPEC.md +490 -0
- package/docs/coding-standards.md +116 -0
- package/docs/installation-requirements.md +70 -0
- package/docs/interactive-e2e-harness.md +46 -0
- package/docs/testing-standards.md +175 -0
- package/package.json +62 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ReasoningLevel, ToolProfile } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type SettingsScope = "project" | "global";
|
|
7
|
+
|
|
8
|
+
export interface ToolPolicySettings {
|
|
9
|
+
profile: ToolProfile;
|
|
10
|
+
customAllowedTools?: string[];
|
|
11
|
+
maxToolCalls?: number;
|
|
12
|
+
maxFilesRead?: number;
|
|
13
|
+
maxBytesPerFile?: number;
|
|
14
|
+
maxTotalBytesRead?: number;
|
|
15
|
+
sensitivePathDenylist?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SageSettings {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
autonomousEnabled: boolean;
|
|
21
|
+
explicitRequestAlwaysAllowed: boolean;
|
|
22
|
+
invocationScope: "interactive-primary-only";
|
|
23
|
+
model: string;
|
|
24
|
+
reasoningLevel: ReasoningLevel;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
maxCallsPerTurn: number;
|
|
27
|
+
maxCallsPerSession: number;
|
|
28
|
+
cooldownTurnsBetweenAutoCalls: number;
|
|
29
|
+
maxQuestionChars: number;
|
|
30
|
+
maxContextChars: number;
|
|
31
|
+
maxEstimatedCostPerSession?: number;
|
|
32
|
+
toolPolicy: ToolPolicySettings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LoadedSettings {
|
|
36
|
+
settings: SageSettings;
|
|
37
|
+
source: "project" | "global" | "default";
|
|
38
|
+
projectPath: string;
|
|
39
|
+
globalPath: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_DENYLIST = [".env", ".env.", ".pem", ".key", "id_rsa", "credentials", "secrets"];
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_SAGE_SETTINGS: SageSettings = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
autonomousEnabled: true,
|
|
47
|
+
explicitRequestAlwaysAllowed: true,
|
|
48
|
+
invocationScope: "interactive-primary-only",
|
|
49
|
+
model: "anthropic/claude-opus-4-6",
|
|
50
|
+
reasoningLevel: "high",
|
|
51
|
+
timeoutMs: 120000,
|
|
52
|
+
maxCallsPerTurn: 1,
|
|
53
|
+
maxCallsPerSession: 6,
|
|
54
|
+
cooldownTurnsBetweenAutoCalls: 1,
|
|
55
|
+
maxQuestionChars: 6000,
|
|
56
|
+
maxContextChars: 20000,
|
|
57
|
+
toolPolicy: {
|
|
58
|
+
profile: "read-only-lite",
|
|
59
|
+
maxToolCalls: 10,
|
|
60
|
+
maxFilesRead: 8,
|
|
61
|
+
maxBytesPerFile: 200 * 1024,
|
|
62
|
+
maxTotalBytesRead: 1024 * 1024,
|
|
63
|
+
sensitivePathDenylist: DEFAULT_DENYLIST
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function getProjectSettingsPath(cwd: string): string {
|
|
68
|
+
return join(cwd, ".pi", "sage-settings.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getGlobalSettingsPath(): string {
|
|
72
|
+
return join(homedir(), ".pi", "agent", "sage-settings.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeStringArray(value: unknown): string[] | undefined {
|
|
76
|
+
if (!Array.isArray(value)) return undefined;
|
|
77
|
+
return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeNumber(value: unknown): number | undefined {
|
|
81
|
+
if (typeof value !== "number" || Number.isFinite(value) === false) return undefined;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function mergeSettings(override: Partial<SageSettings> | undefined): SageSettings {
|
|
86
|
+
const merged: SageSettings = {
|
|
87
|
+
...DEFAULT_SAGE_SETTINGS,
|
|
88
|
+
...override,
|
|
89
|
+
toolPolicy: {
|
|
90
|
+
...DEFAULT_SAGE_SETTINGS.toolPolicy,
|
|
91
|
+
...(override?.toolPolicy ?? {})
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
merged.timeoutMs = Math.max(1000, Math.floor(merged.timeoutMs));
|
|
96
|
+
merged.maxCallsPerTurn = Math.max(1, Math.floor(merged.maxCallsPerTurn));
|
|
97
|
+
merged.maxCallsPerSession = Math.max(1, Math.floor(merged.maxCallsPerSession));
|
|
98
|
+
merged.cooldownTurnsBetweenAutoCalls = Math.max(0, Math.floor(merged.cooldownTurnsBetweenAutoCalls));
|
|
99
|
+
merged.maxQuestionChars = Math.max(256, Math.floor(merged.maxQuestionChars));
|
|
100
|
+
merged.maxContextChars = Math.max(512, Math.floor(merged.maxContextChars));
|
|
101
|
+
|
|
102
|
+
const policy = merged.toolPolicy;
|
|
103
|
+
policy.maxToolCalls = Math.max(1, Math.floor(policy.maxToolCalls ?? 10));
|
|
104
|
+
policy.maxFilesRead = Math.max(1, Math.floor(policy.maxFilesRead ?? 8));
|
|
105
|
+
policy.maxBytesPerFile = Math.max(1024, Math.floor(policy.maxBytesPerFile ?? 200 * 1024));
|
|
106
|
+
policy.maxTotalBytesRead = Math.max(policy.maxBytesPerFile, Math.floor(policy.maxTotalBytesRead ?? 1024 * 1024));
|
|
107
|
+
policy.sensitivePathDenylist = normalizeStringArray(policy.sensitivePathDenylist) ?? [...DEFAULT_DENYLIST];
|
|
108
|
+
|
|
109
|
+
if (merged.maxEstimatedCostPerSession !== undefined && merged.maxEstimatedCostPerSession < 0) {
|
|
110
|
+
delete merged.maxEstimatedCostPerSession;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return merged;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseSettingsRaw(content: string): Partial<SageSettings> | undefined {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
119
|
+
const toolPolicyRaw = (parsed.toolPolicy ?? {}) as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
const profile =
|
|
122
|
+
toolPolicyRaw.profile === "none" ||
|
|
123
|
+
toolPolicyRaw.profile === "read-only-lite" ||
|
|
124
|
+
toolPolicyRaw.profile === "custom-read-only" ||
|
|
125
|
+
toolPolicyRaw.profile === "git-review-readonly"
|
|
126
|
+
? toolPolicyRaw.profile
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
129
|
+
const toolPolicy: ToolPolicySettings | undefined = profile
|
|
130
|
+
? {
|
|
131
|
+
profile,
|
|
132
|
+
customAllowedTools: normalizeStringArray(toolPolicyRaw.customAllowedTools),
|
|
133
|
+
maxToolCalls: normalizeNumber(toolPolicyRaw.maxToolCalls),
|
|
134
|
+
maxFilesRead: normalizeNumber(toolPolicyRaw.maxFilesRead),
|
|
135
|
+
maxBytesPerFile: normalizeNumber(toolPolicyRaw.maxBytesPerFile),
|
|
136
|
+
maxTotalBytesRead: normalizeNumber(toolPolicyRaw.maxTotalBytesRead),
|
|
137
|
+
sensitivePathDenylist: normalizeStringArray(toolPolicyRaw.sensitivePathDenylist)
|
|
138
|
+
}
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
const raw: Partial<SageSettings> = {
|
|
142
|
+
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : undefined,
|
|
143
|
+
autonomousEnabled: typeof parsed.autonomousEnabled === "boolean" ? parsed.autonomousEnabled : undefined,
|
|
144
|
+
explicitRequestAlwaysAllowed:
|
|
145
|
+
typeof parsed.explicitRequestAlwaysAllowed === "boolean" ? parsed.explicitRequestAlwaysAllowed : undefined,
|
|
146
|
+
invocationScope:
|
|
147
|
+
parsed.invocationScope === "interactive-primary-only" ? "interactive-primary-only" : undefined,
|
|
148
|
+
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
|
149
|
+
reasoningLevel:
|
|
150
|
+
parsed.reasoningLevel === "minimal" ||
|
|
151
|
+
parsed.reasoningLevel === "low" ||
|
|
152
|
+
parsed.reasoningLevel === "medium" ||
|
|
153
|
+
parsed.reasoningLevel === "high" ||
|
|
154
|
+
parsed.reasoningLevel === "xhigh"
|
|
155
|
+
? parsed.reasoningLevel
|
|
156
|
+
: undefined,
|
|
157
|
+
timeoutMs: normalizeNumber(parsed.timeoutMs),
|
|
158
|
+
maxCallsPerTurn: normalizeNumber(parsed.maxCallsPerTurn),
|
|
159
|
+
maxCallsPerSession: normalizeNumber(parsed.maxCallsPerSession),
|
|
160
|
+
cooldownTurnsBetweenAutoCalls: normalizeNumber(parsed.cooldownTurnsBetweenAutoCalls),
|
|
161
|
+
maxQuestionChars: normalizeNumber(parsed.maxQuestionChars),
|
|
162
|
+
maxContextChars: normalizeNumber(parsed.maxContextChars),
|
|
163
|
+
maxEstimatedCostPerSession: normalizeNumber(parsed.maxEstimatedCostPerSession),
|
|
164
|
+
toolPolicy
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return raw;
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readSettingsFile(path: string): Partial<SageSettings> | undefined {
|
|
174
|
+
if (existsSync(path) === false) return undefined;
|
|
175
|
+
try {
|
|
176
|
+
return parseSettingsRaw(readFileSync(path, "utf8"));
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function loadSettings(cwd: string): LoadedSettings {
|
|
183
|
+
const projectPath = getProjectSettingsPath(cwd);
|
|
184
|
+
const globalPath = getGlobalSettingsPath();
|
|
185
|
+
|
|
186
|
+
const project = readSettingsFile(projectPath);
|
|
187
|
+
if (project) return { settings: mergeSettings(project), source: "project", projectPath, globalPath };
|
|
188
|
+
|
|
189
|
+
const global = readSettingsFile(globalPath);
|
|
190
|
+
if (global) return { settings: mergeSettings(global), source: "global", projectPath, globalPath };
|
|
191
|
+
|
|
192
|
+
return { settings: mergeSettings(undefined), source: "default", projectPath, globalPath };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function saveSettings(path: string, settings: SageSettings): void {
|
|
196
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
197
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function getSettingsPathForScope(cwd: string, scope: SettingsScope): string {
|
|
201
|
+
return scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath();
|
|
202
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { BlockCode, ToolUsage, ToolProfile } from "./types.js";
|
|
2
|
+
import type { ToolPolicySettings } from "./settings.js";
|
|
3
|
+
|
|
4
|
+
export const READ_ONLY_LITE_TOOLS = ["ls", "glob", "grep", "read"] as const;
|
|
5
|
+
export const GIT_REVIEW_READONLY_TOOLS = ["ls", "glob", "grep", "read", "bash"] as const;
|
|
6
|
+
|
|
7
|
+
const CLI_TOOL_NAME_MAP: Record<string, string> = {
|
|
8
|
+
glob: "find"
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const READ_ONLY_CUSTOM_ALLOWLIST = new Set(["ls", "glob", "find", "grep", "read"]);
|
|
12
|
+
const HARD_DISALLOWED_TOOLS = new Set(["edit", "write", "bash", "sage_consult"]);
|
|
13
|
+
|
|
14
|
+
const BASH_META_CHARS = /[;&|><`$\n\r]/;
|
|
15
|
+
const SAFE_TOKEN = /^[A-Za-z0-9_./:@+=,~%-]+$/;
|
|
16
|
+
|
|
17
|
+
const GIT_SUBCOMMAND_ALLOWLIST = new Set(["status", "diff", "show", "log", "blame", "rev-parse", "branch"]);
|
|
18
|
+
|
|
19
|
+
export interface ResolvedToolPolicy {
|
|
20
|
+
profile: ToolPolicySettings["profile"];
|
|
21
|
+
allowedTools: string[];
|
|
22
|
+
cliTools: string[];
|
|
23
|
+
maxToolCalls: number;
|
|
24
|
+
maxFilesRead: number;
|
|
25
|
+
maxBytesPerFile: number;
|
|
26
|
+
maxTotalBytesRead: number;
|
|
27
|
+
sensitivePathDenylist: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getDisallowedCustomTools(customAllowedTools: string[] | undefined): string[] {
|
|
31
|
+
if (!customAllowedTools) return [];
|
|
32
|
+
return customAllowedTools.filter((tool) => HARD_DISALLOWED_TOOLS.has(tool.trim()));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPolicy {
|
|
36
|
+
const requestedProfile = settings.profile;
|
|
37
|
+
|
|
38
|
+
if (requestedProfile === "none") {
|
|
39
|
+
return {
|
|
40
|
+
profile: "none",
|
|
41
|
+
allowedTools: [],
|
|
42
|
+
cliTools: [],
|
|
43
|
+
maxToolCalls: settings.maxToolCalls ?? 10,
|
|
44
|
+
maxFilesRead: settings.maxFilesRead ?? 8,
|
|
45
|
+
maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
|
|
46
|
+
maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
|
|
47
|
+
sensitivePathDenylist: settings.sensitivePathDenylist ?? []
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (requestedProfile === "read-only-lite") {
|
|
52
|
+
const allowedTools = [...READ_ONLY_LITE_TOOLS];
|
|
53
|
+
return {
|
|
54
|
+
profile: "read-only-lite",
|
|
55
|
+
allowedTools,
|
|
56
|
+
cliTools: toCliTools(allowedTools),
|
|
57
|
+
maxToolCalls: settings.maxToolCalls ?? 10,
|
|
58
|
+
maxFilesRead: settings.maxFilesRead ?? 8,
|
|
59
|
+
maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
|
|
60
|
+
maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
|
|
61
|
+
sensitivePathDenylist: settings.sensitivePathDenylist ?? []
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (requestedProfile === "git-review-readonly") {
|
|
66
|
+
const allowedTools = [...GIT_REVIEW_READONLY_TOOLS];
|
|
67
|
+
return {
|
|
68
|
+
profile: "git-review-readonly",
|
|
69
|
+
allowedTools,
|
|
70
|
+
cliTools: toCliTools(allowedTools),
|
|
71
|
+
maxToolCalls: settings.maxToolCalls ?? 20,
|
|
72
|
+
maxFilesRead: settings.maxFilesRead ?? 20,
|
|
73
|
+
maxBytesPerFile: settings.maxBytesPerFile ?? 300 * 1024,
|
|
74
|
+
maxTotalBytesRead: settings.maxTotalBytesRead ?? 2 * 1024 * 1024,
|
|
75
|
+
sensitivePathDenylist: settings.sensitivePathDenylist ?? []
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const requested = settings.customAllowedTools ?? [];
|
|
80
|
+
const filtered = requested
|
|
81
|
+
.map((tool) => tool.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.filter((tool) => HARD_DISALLOWED_TOOLS.has(tool) === false)
|
|
84
|
+
.filter((tool) => READ_ONLY_CUSTOM_ALLOWLIST.has(tool));
|
|
85
|
+
|
|
86
|
+
const deduped = [...new Set(filtered)];
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
profile: "custom-read-only",
|
|
90
|
+
allowedTools: deduped,
|
|
91
|
+
cliTools: toCliTools(deduped),
|
|
92
|
+
maxToolCalls: settings.maxToolCalls ?? 10,
|
|
93
|
+
maxFilesRead: settings.maxFilesRead ?? 8,
|
|
94
|
+
maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
|
|
95
|
+
maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
|
|
96
|
+
sensitivePathDenylist: settings.sensitivePathDenylist ?? []
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toCliTools(allowedTools: string[]): string[] {
|
|
101
|
+
const mapped = allowedTools.map((tool) => CLI_TOOL_NAME_MAP[tool] ?? tool);
|
|
102
|
+
return [...new Set(mapped)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function checkVolumeCaps(
|
|
106
|
+
usage: ToolUsage,
|
|
107
|
+
policy: Pick<ResolvedToolPolicy, "maxToolCalls" | "maxFilesRead" | "maxBytesPerFile" | "maxTotalBytesRead">
|
|
108
|
+
): { ok: boolean; blockCode?: BlockCode; reason: string } {
|
|
109
|
+
if (usage.callsUsed > policy.maxToolCalls) {
|
|
110
|
+
return { ok: false, blockCode: "volume-cap", reason: "Exceeded max tool calls" };
|
|
111
|
+
}
|
|
112
|
+
if (usage.filesRead > policy.maxFilesRead) {
|
|
113
|
+
return { ok: false, blockCode: "volume-cap", reason: "Exceeded max files read" };
|
|
114
|
+
}
|
|
115
|
+
if (usage.bytesRead > policy.maxTotalBytesRead) {
|
|
116
|
+
return { ok: false, blockCode: "volume-cap", reason: "Exceeded max total bytes read" };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, reason: "within caps" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isPathAllowed(
|
|
122
|
+
targetPath: string,
|
|
123
|
+
workspaceRoots: string[],
|
|
124
|
+
denylist: string[]
|
|
125
|
+
): { ok: boolean; blockCode?: BlockCode; reason: string } {
|
|
126
|
+
const normalizedPath = normalizePath(targetPath);
|
|
127
|
+
const normalizedRoots = workspaceRoots.map(normalizePath);
|
|
128
|
+
const isInWorkspace = normalizedRoots.some((root) => normalizedPath.startsWith(root));
|
|
129
|
+
|
|
130
|
+
if (isInWorkspace === false) {
|
|
131
|
+
return { ok: false, blockCode: "path-denied", reason: "Path is outside workspace roots" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const match = denylist.find((entry) => normalizedPath.includes(entry.toLowerCase()));
|
|
135
|
+
if (match) {
|
|
136
|
+
return { ok: false, blockCode: "path-denied", reason: `Path matches denylist entry: ${match}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { ok: true, reason: "allowed" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateBashCommandForProfile(
|
|
143
|
+
profile: ToolProfile,
|
|
144
|
+
command: string
|
|
145
|
+
): { ok: boolean; blockCode?: BlockCode; reason: string } {
|
|
146
|
+
if (profile !== "git-review-readonly") {
|
|
147
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "bash is only allowed in git-review-readonly profile" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const trimmed = command.trim();
|
|
151
|
+
if (!trimmed) {
|
|
152
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "Empty bash command" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (BASH_META_CHARS.test(trimmed)) {
|
|
156
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "Shell control characters are not allowed" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
160
|
+
if (tokens.length < 2 || tokens[0] !== "git") {
|
|
161
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "Only git read-only commands are allowed" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const subcommand = tokens[1] ?? "";
|
|
165
|
+
if (!GIT_SUBCOMMAND_ALLOWLIST.has(subcommand)) {
|
|
166
|
+
return { ok: false, blockCode: "tool-disallowed", reason: `Git subcommand not allowed: ${subcommand}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const token of tokens.slice(2)) {
|
|
170
|
+
if (!SAFE_TOKEN.test(token)) {
|
|
171
|
+
return { ok: false, blockCode: "tool-disallowed", reason: `Unsafe token in command: ${token}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (subcommand === "branch") {
|
|
176
|
+
const args = tokens.slice(2);
|
|
177
|
+
if (args.length !== 1 || args[0] !== "--show-current") {
|
|
178
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "Only 'git branch --show-current' is allowed" };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (subcommand === "rev-parse") {
|
|
183
|
+
const args = tokens.slice(2);
|
|
184
|
+
const allowedArgs = new Set(["--abbrev-ref", "HEAD"]);
|
|
185
|
+
if (args.some((arg) => !allowedArgs.has(arg))) {
|
|
186
|
+
return { ok: false, blockCode: "tool-disallowed", reason: "Unsupported git rev-parse arguments" };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: true, reason: "allowed" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizePath(pathValue: string): string {
|
|
194
|
+
return pathValue.replace(/\\/g, "/").toLowerCase();
|
|
195
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type SageMode = "autonomous" | "user-requested";
|
|
2
|
+
|
|
3
|
+
export type ReasoningLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
4
|
+
|
|
5
|
+
export type Objective = "debug" | "design" | "review" | "refactor" | "general";
|
|
6
|
+
|
|
7
|
+
export type Urgency = "low" | "medium" | "high";
|
|
8
|
+
|
|
9
|
+
export type BlockCode =
|
|
10
|
+
| "ineligible-caller"
|
|
11
|
+
| "non-interactive"
|
|
12
|
+
| "ci-mode"
|
|
13
|
+
| "rpc-role"
|
|
14
|
+
| "subagent"
|
|
15
|
+
| "unknown-context"
|
|
16
|
+
| "tool-disallowed"
|
|
17
|
+
| "path-denied"
|
|
18
|
+
| "volume-cap"
|
|
19
|
+
| "disabled"
|
|
20
|
+
| "model-unavailable"
|
|
21
|
+
| "timeout"
|
|
22
|
+
| "cost-cap"
|
|
23
|
+
| "soft-limit"
|
|
24
|
+
| "execution-error";
|
|
25
|
+
|
|
26
|
+
export interface CallerContext {
|
|
27
|
+
session: { interactive: boolean };
|
|
28
|
+
agent: {
|
|
29
|
+
role: string;
|
|
30
|
+
isSubagent: boolean;
|
|
31
|
+
isRpcOrchestrated: boolean;
|
|
32
|
+
};
|
|
33
|
+
runtime: { mode: "interactive" | "ci" | "non-interactive" | "rpc" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CallerDecision {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
reason: string;
|
|
39
|
+
blockCode?: BlockCode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ToolProfile = "none" | "read-only-lite" | "custom-read-only" | "git-review-readonly";
|
|
43
|
+
|
|
44
|
+
export interface ToolPolicyCaps {
|
|
45
|
+
maxToolCalls: number;
|
|
46
|
+
maxFilesRead: number;
|
|
47
|
+
maxBytesPerFile: number;
|
|
48
|
+
maxTotalBytesRead: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToolUsage {
|
|
52
|
+
profile: ToolProfile;
|
|
53
|
+
callsUsed: number;
|
|
54
|
+
filesRead: number;
|
|
55
|
+
bytesRead: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface UsageBreakdown {
|
|
59
|
+
input: number;
|
|
60
|
+
output: number;
|
|
61
|
+
cacheRead: number;
|
|
62
|
+
cacheWrite: number;
|
|
63
|
+
totalTokens: number;
|
|
64
|
+
costTotal?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SagePolicyMetadata {
|
|
68
|
+
mode: SageMode;
|
|
69
|
+
allowedByContext: boolean;
|
|
70
|
+
contextReason?: string;
|
|
71
|
+
blockCode?: BlockCode;
|
|
72
|
+
allowedByBudget: boolean;
|
|
73
|
+
budgetReason?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SageConsultDetails {
|
|
77
|
+
model: string;
|
|
78
|
+
reasoningLevel: ReasoningLevel;
|
|
79
|
+
latencyMs: number;
|
|
80
|
+
stopReason: string;
|
|
81
|
+
usage?: UsageBreakdown;
|
|
82
|
+
toolUsage?: ToolUsage;
|
|
83
|
+
policy: SagePolicyMetadata;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SageToolResult {
|
|
87
|
+
content: Array<{ type: "text"; text: string }>;
|
|
88
|
+
details: SageConsultDetails;
|
|
89
|
+
isError?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SageConsultInput {
|
|
93
|
+
question: string;
|
|
94
|
+
context?: string;
|
|
95
|
+
files?: string[];
|
|
96
|
+
evidence?: string[];
|
|
97
|
+
objective?: Objective;
|
|
98
|
+
urgency?: Urgency;
|
|
99
|
+
force?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SageBudgetState {
|
|
103
|
+
currentTurn: number;
|
|
104
|
+
callsThisTurn: number;
|
|
105
|
+
sessionCalls: number;
|
|
106
|
+
lastAutoTurn: number | undefined;
|
|
107
|
+
sessionCostTotal: number;
|
|
108
|
+
}
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Sage Project North Star (agents.md)
|
|
2
|
+
|
|
3
|
+
This file is the entry point for contributors and autonomous agents working in this repository.
|
|
4
|
+
|
|
5
|
+
## 1) Mission
|
|
6
|
+
|
|
7
|
+
Build Sage as a **safe, advisory, interactive-only reasoning assistant** for Pi.
|
|
8
|
+
|
|
9
|
+
Sage should improve decision quality for complex tasks while preserving strict safety and control boundaries.
|
|
10
|
+
|
|
11
|
+
## 2) Product Invariants (Do Not Violate)
|
|
12
|
+
|
|
13
|
+
1. Sage invocation is restricted to **interactive top-level primary** sessions.
|
|
14
|
+
2. RPC-orchestrated roles (`supervisor`, `worker`, `reviewer`, `merger`) cannot invoke Sage.
|
|
15
|
+
3. Sage is **single-shot** per call.
|
|
16
|
+
4. Sage cannot recursively invoke Sage.
|
|
17
|
+
5. Sage is **advisory-only** (analysis/recommendations, not implementation execution).
|
|
18
|
+
6. Unknown caller context is denied by default.
|
|
19
|
+
|
|
20
|
+
## 3) Canonical Project Documents
|
|
21
|
+
|
|
22
|
+
Read in this order:
|
|
23
|
+
|
|
24
|
+
1. `docs/SAGE_SPEC.md` — locked product/architecture contract (implementation baseline)
|
|
25
|
+
2. `docs/coding-standards.md` — implementation standards and review checklist
|
|
26
|
+
3. `docs/testing-standards.md` — required quality gates and test methodology
|
|
27
|
+
4. `docs/interactive-e2e-harness.md` — interactive-only E2E strategy (tmux-driven)
|
|
28
|
+
|
|
29
|
+
If docs conflict, resolve by updating the spec and standards together in the same change.
|
|
30
|
+
|
|
31
|
+
## 4) Engineering Priorities (in order)
|
|
32
|
+
|
|
33
|
+
1. **Safety & policy correctness**
|
|
34
|
+
2. **Deterministic behavior and clear blocking semantics**
|
|
35
|
+
3. **Observability and debuggability**
|
|
36
|
+
4. **Developer ergonomics**
|
|
37
|
+
5. **Performance/cost optimization**
|
|
38
|
+
|
|
39
|
+
## 5) Development Workflow Expectations
|
|
40
|
+
|
|
41
|
+
For every meaningful code change:
|
|
42
|
+
|
|
43
|
+
1. Update implementation
|
|
44
|
+
2. Run quality gates (including lint)
|
|
45
|
+
3. Run tests appropriate to change scope
|
|
46
|
+
4. Update docs when behavior/contract changes
|
|
47
|
+
|
|
48
|
+
Minimum continuous test gates:
|
|
49
|
+
- lint
|
|
50
|
+
- typecheck
|
|
51
|
+
- unit
|
|
52
|
+
- integration
|
|
53
|
+
- e2e (for behavior-impacting changes)
|
|
54
|
+
|
|
55
|
+
Testing runner policy:
|
|
56
|
+
- Use Node’s built-in test runner (`node:test` via `node --test`) for all test layers.
|
|
57
|
+
- Vitest is not permitted in this repository.
|
|
58
|
+
|
|
59
|
+
## 6) Definition of Ready for Implementation Tasks
|
|
60
|
+
|
|
61
|
+
Before coding, ensure:
|
|
62
|
+
- scope is clear and mapped to spec section(s)
|
|
63
|
+
- acceptance criteria are explicit
|
|
64
|
+
- safety/policy impact is identified
|
|
65
|
+
- required tests are listed
|
|
66
|
+
|
|
67
|
+
## 7) Definition of Done for Implementation Tasks
|
|
68
|
+
|
|
69
|
+
A task is complete when:
|
|
70
|
+
- behavior matches `docs/SAGE_SPEC.md`
|
|
71
|
+
- coding standards are satisfied
|
|
72
|
+
- testing standards pass (including lint)
|
|
73
|
+
- docs/spec are updated for contract changes
|
|
74
|
+
- no regression in caller gating, tool policy, or recursion protections
|
|
75
|
+
|
|
76
|
+
## 8) Change Management Guidance
|
|
77
|
+
|
|
78
|
+
For policy-sensitive changes (caller scope, tool access, recursion, safety limits), require:
|
|
79
|
+
- explicit rationale in PR notes
|
|
80
|
+
- test updates in the same PR
|
|
81
|
+
- acceptance criteria updates when applicable
|
|
82
|
+
|
|
83
|
+
## 9) Repo Intent
|
|
84
|
+
|
|
85
|
+
This repository is currently spec-and-foundation heavy. Prefer small, verifiable increments over broad changes.
|
|
86
|
+
|
|
87
|
+
When uncertain: choose stricter safety defaults and document the decision.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HenryLach
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|