@grwnd/pi-governance 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/extensions/index.cjs +1169 -0
- package/dist/extensions/index.cjs.map +1 -0
- package/dist/extensions/index.d.cts +42 -0
- package/dist/extensions/index.d.ts +42 -0
- package/dist/extensions/index.js +1146 -0
- package/dist/extensions/index.js.map +1 -0
- package/dist/index.cjs +4545 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +282 -1
- package/dist/index.d.ts +282 -1
- package/dist/index.js +4542 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/policies/base.polar +83 -0
- package/policies/tools.polar +16 -0
- package/prompts/admin.md +28 -0
- package/prompts/analyst.md +36 -0
- package/prompts/dry-run.md +36 -0
- package/prompts/project-lead.md +36 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
// src/lib/config/loader.ts
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { Value } from "@sinclair/typebox/value";
|
|
5
|
+
|
|
6
|
+
// src/lib/config/schema.ts
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
var AuthEnvConfig = Type.Object({
|
|
9
|
+
user_var: Type.String({ default: "GRWND_USER" }),
|
|
10
|
+
role_var: Type.String({ default: "GRWND_ROLE" }),
|
|
11
|
+
org_unit_var: Type.String({ default: "GRWND_ORG_UNIT" })
|
|
12
|
+
});
|
|
13
|
+
var AuthLocalConfig = Type.Object({
|
|
14
|
+
users_file: Type.String({ default: "./users.yaml" })
|
|
15
|
+
});
|
|
16
|
+
var AuthConfig = Type.Object({
|
|
17
|
+
provider: Type.Union([Type.Literal("env"), Type.Literal("local"), Type.Literal("oidc")], {
|
|
18
|
+
default: "env"
|
|
19
|
+
}),
|
|
20
|
+
env: Type.Optional(AuthEnvConfig),
|
|
21
|
+
local: Type.Optional(AuthLocalConfig)
|
|
22
|
+
});
|
|
23
|
+
var YamlPolicyConfig = Type.Object({
|
|
24
|
+
rules_file: Type.String({ default: "./governance-rules.yaml" })
|
|
25
|
+
});
|
|
26
|
+
var OsoPolicyConfig = Type.Object({
|
|
27
|
+
polar_files: Type.Array(Type.String(), {
|
|
28
|
+
default: ["./policies/base.polar", "./policies/tools.polar"]
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
var PolicyConfig = Type.Object({
|
|
32
|
+
engine: Type.Union([Type.Literal("yaml"), Type.Literal("oso")], { default: "yaml" }),
|
|
33
|
+
yaml: Type.Optional(YamlPolicyConfig),
|
|
34
|
+
oso: Type.Optional(OsoPolicyConfig)
|
|
35
|
+
});
|
|
36
|
+
var TemplatesConfig = Type.Object({
|
|
37
|
+
directory: Type.String({ default: "./templates/" }),
|
|
38
|
+
default: Type.String({ default: "project-lead" })
|
|
39
|
+
});
|
|
40
|
+
var HitlWebhookConfig = Type.Object({
|
|
41
|
+
url: Type.String()
|
|
42
|
+
});
|
|
43
|
+
var HitlConfig = Type.Object({
|
|
44
|
+
default_mode: Type.Union(
|
|
45
|
+
[Type.Literal("autonomous"), Type.Literal("supervised"), Type.Literal("dry_run")],
|
|
46
|
+
{ default: "supervised" }
|
|
47
|
+
),
|
|
48
|
+
approval_channel: Type.Union([Type.Literal("cli"), Type.Literal("webhook")], { default: "cli" }),
|
|
49
|
+
timeout_seconds: Type.Number({ default: 300, minimum: 10, maximum: 3600 }),
|
|
50
|
+
webhook: Type.Optional(HitlWebhookConfig)
|
|
51
|
+
});
|
|
52
|
+
var JsonlSinkConfig = Type.Object({
|
|
53
|
+
type: Type.Literal("jsonl"),
|
|
54
|
+
path: Type.String({ default: "~/.pi/agent/audit.jsonl" })
|
|
55
|
+
});
|
|
56
|
+
var WebhookSinkConfig = Type.Object({
|
|
57
|
+
type: Type.Literal("webhook"),
|
|
58
|
+
url: Type.String()
|
|
59
|
+
});
|
|
60
|
+
var PostgresSinkConfig = Type.Object({
|
|
61
|
+
type: Type.Literal("postgres"),
|
|
62
|
+
connection: Type.String()
|
|
63
|
+
});
|
|
64
|
+
var AuditSinkConfig = Type.Union([JsonlSinkConfig, WebhookSinkConfig, PostgresSinkConfig]);
|
|
65
|
+
var AuditConfig = Type.Object({
|
|
66
|
+
sinks: Type.Array(AuditSinkConfig, {
|
|
67
|
+
default: [{ type: "jsonl", path: "~/.pi/agent/audit.jsonl" }]
|
|
68
|
+
})
|
|
69
|
+
});
|
|
70
|
+
var OrgUnitOverride = Type.Object({
|
|
71
|
+
hitl: Type.Optional(Type.Partial(HitlConfig)),
|
|
72
|
+
policy: Type.Optional(
|
|
73
|
+
Type.Object({
|
|
74
|
+
extra_polar: Type.Optional(Type.String()),
|
|
75
|
+
extra_rules: Type.Optional(Type.String())
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
});
|
|
79
|
+
var GovernanceConfigSchema = Type.Object({
|
|
80
|
+
auth: Type.Optional(AuthConfig),
|
|
81
|
+
policy: Type.Optional(PolicyConfig),
|
|
82
|
+
templates: Type.Optional(TemplatesConfig),
|
|
83
|
+
hitl: Type.Optional(HitlConfig),
|
|
84
|
+
audit: Type.Optional(AuditConfig),
|
|
85
|
+
org_units: Type.Optional(Type.Record(Type.String(), OrgUnitOverride))
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// src/lib/config/defaults.ts
|
|
89
|
+
var DEFAULTS = {
|
|
90
|
+
auth: {
|
|
91
|
+
provider: "env",
|
|
92
|
+
env: {
|
|
93
|
+
user_var: "GRWND_USER",
|
|
94
|
+
role_var: "GRWND_ROLE",
|
|
95
|
+
org_unit_var: "GRWND_ORG_UNIT"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
policy: {
|
|
99
|
+
engine: "yaml",
|
|
100
|
+
yaml: {
|
|
101
|
+
rules_file: "./governance-rules.yaml"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
templates: {
|
|
105
|
+
directory: "./templates/",
|
|
106
|
+
default: "project-lead"
|
|
107
|
+
},
|
|
108
|
+
hitl: {
|
|
109
|
+
default_mode: "supervised",
|
|
110
|
+
approval_channel: "cli",
|
|
111
|
+
timeout_seconds: 300
|
|
112
|
+
},
|
|
113
|
+
audit: {
|
|
114
|
+
sinks: [{ type: "jsonl", path: "~/.pi/agent/audit.jsonl" }]
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/lib/config/loader.ts
|
|
119
|
+
function getConfigPaths() {
|
|
120
|
+
return [
|
|
121
|
+
process.env["GRWND_GOVERNANCE_CONFIG"],
|
|
122
|
+
".pi/governance.yaml",
|
|
123
|
+
`${process.env["HOME"]}/.pi/agent/governance.yaml`
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
function loadConfig() {
|
|
127
|
+
for (const path of getConfigPaths()) {
|
|
128
|
+
if (path && existsSync(path)) {
|
|
129
|
+
const raw = readFileSync(path, "utf-8");
|
|
130
|
+
const parsed = parseYaml(raw);
|
|
131
|
+
const resolved = resolveEnvVars(parsed);
|
|
132
|
+
const errors = [...Value.Errors(GovernanceConfigSchema, resolved)];
|
|
133
|
+
if (errors.length > 0) {
|
|
134
|
+
throw new ConfigValidationError(
|
|
135
|
+
path,
|
|
136
|
+
errors.map((e) => ({ path: e.path, message: e.message }))
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const config = Value.Default(GovernanceConfigSchema, resolved);
|
|
140
|
+
return { config, source: path };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { config: DEFAULTS, source: "built-in" };
|
|
144
|
+
}
|
|
145
|
+
function resolveEnvVars(obj) {
|
|
146
|
+
if (typeof obj === "string") {
|
|
147
|
+
return obj.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(obj)) return obj.map(resolveEnvVars);
|
|
150
|
+
if (obj && typeof obj === "object") {
|
|
151
|
+
return Object.fromEntries(
|
|
152
|
+
Object.entries(obj).map(([k, v]) => [k, resolveEnvVars(v)])
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return obj;
|
|
156
|
+
}
|
|
157
|
+
var ConfigValidationError = class extends Error {
|
|
158
|
+
constructor(path, errors) {
|
|
159
|
+
const details = errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
|
|
160
|
+
super(`Invalid governance config at ${path}:
|
|
161
|
+
${details}`);
|
|
162
|
+
this.name = "ConfigValidationError";
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// src/lib/identity/env-provider.ts
|
|
167
|
+
var EnvIdentityProvider = class {
|
|
168
|
+
constructor(userVar = "GRWND_USER", roleVar = "GRWND_ROLE", orgUnitVar = "GRWND_ORG_UNIT") {
|
|
169
|
+
this.userVar = userVar;
|
|
170
|
+
this.roleVar = roleVar;
|
|
171
|
+
this.orgUnitVar = orgUnitVar;
|
|
172
|
+
}
|
|
173
|
+
name = "env";
|
|
174
|
+
async resolve() {
|
|
175
|
+
const userId = process.env[this.userVar];
|
|
176
|
+
const role = process.env[this.roleVar];
|
|
177
|
+
const orgUnit = process.env[this.orgUnitVar];
|
|
178
|
+
if (!userId || !role) return null;
|
|
179
|
+
return {
|
|
180
|
+
userId,
|
|
181
|
+
role,
|
|
182
|
+
orgUnit: orgUnit ?? "default",
|
|
183
|
+
source: "env"
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/lib/identity/local-provider.ts
|
|
189
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
190
|
+
import { parse as parseYaml2 } from "yaml";
|
|
191
|
+
var LocalIdentityProvider = class {
|
|
192
|
+
name = "local";
|
|
193
|
+
users;
|
|
194
|
+
constructor(usersFilePath) {
|
|
195
|
+
const raw = readFileSync2(usersFilePath, "utf-8");
|
|
196
|
+
this.users = parseYaml2(raw);
|
|
197
|
+
}
|
|
198
|
+
async resolve() {
|
|
199
|
+
const username = process.env.USER || process.env.USERNAME;
|
|
200
|
+
if (!username) return null;
|
|
201
|
+
const entry = this.users[username];
|
|
202
|
+
if (!entry) return null;
|
|
203
|
+
return {
|
|
204
|
+
userId: username,
|
|
205
|
+
role: entry.role,
|
|
206
|
+
orgUnit: entry.org_unit ?? "default",
|
|
207
|
+
source: "local"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/lib/identity/chain.ts
|
|
213
|
+
var IdentityChain = class {
|
|
214
|
+
providers;
|
|
215
|
+
constructor(providers) {
|
|
216
|
+
this.providers = providers;
|
|
217
|
+
}
|
|
218
|
+
async resolve() {
|
|
219
|
+
for (const provider of this.providers) {
|
|
220
|
+
const identity = await provider.resolve();
|
|
221
|
+
if (identity) return identity;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
userId: "unknown",
|
|
225
|
+
role: "analyst",
|
|
226
|
+
// most restrictive role by default
|
|
227
|
+
orgUnit: "default",
|
|
228
|
+
source: "fallback"
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
function createIdentityChain(config) {
|
|
233
|
+
const providers = [];
|
|
234
|
+
providers.push(
|
|
235
|
+
new EnvIdentityProvider(
|
|
236
|
+
config?.env?.user_var,
|
|
237
|
+
config?.env?.role_var,
|
|
238
|
+
config?.env?.org_unit_var
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
if (config?.provider === "local" && config.local?.users_file) {
|
|
242
|
+
try {
|
|
243
|
+
providers.push(new LocalIdentityProvider(config.local.users_file));
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return new IdentityChain(providers);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/lib/policy/yaml-engine.ts
|
|
251
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
252
|
+
import { parse as parseYaml3 } from "yaml";
|
|
253
|
+
import { minimatch } from "minimatch";
|
|
254
|
+
var YamlPolicyEngine = class {
|
|
255
|
+
rules;
|
|
256
|
+
constructor(rulesFilePathOrRules) {
|
|
257
|
+
if (typeof rulesFilePathOrRules === "string") {
|
|
258
|
+
const raw = readFileSync3(rulesFilePathOrRules, "utf-8");
|
|
259
|
+
this.rules = parseYaml3(raw);
|
|
260
|
+
} else {
|
|
261
|
+
this.rules = rulesFilePathOrRules;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
getRole(role) {
|
|
265
|
+
const r = this.rules.roles[role];
|
|
266
|
+
if (!r) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Unknown role: ${role}. Available roles: ${Object.keys(this.rules.roles).join(", ")}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return r;
|
|
272
|
+
}
|
|
273
|
+
evaluateTool(role, tool) {
|
|
274
|
+
const r = this.getRole(role);
|
|
275
|
+
if (r.blocked_tools.includes(tool)) return "deny";
|
|
276
|
+
if (r.allowed_tools.includes("all") || r.allowed_tools.includes(tool)) {
|
|
277
|
+
return "allow";
|
|
278
|
+
}
|
|
279
|
+
return "deny";
|
|
280
|
+
}
|
|
281
|
+
evaluatePath(role, _orgUnit, _operation, path) {
|
|
282
|
+
const r = this.getRole(role);
|
|
283
|
+
for (const pattern of r.blocked_paths) {
|
|
284
|
+
if (minimatch(path, pattern, { dot: true })) {
|
|
285
|
+
return "deny";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const pattern of r.allowed_paths) {
|
|
289
|
+
const resolved = pattern.replace("{{project_path}}", process.cwd());
|
|
290
|
+
if (minimatch(path, resolved, { dot: true })) {
|
|
291
|
+
return "allow";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return "deny";
|
|
295
|
+
}
|
|
296
|
+
requiresApproval(role, tool) {
|
|
297
|
+
const r = this.getRole(role);
|
|
298
|
+
if (r.human_approval.auto_approve?.includes(tool)) return false;
|
|
299
|
+
if (r.human_approval.required_for.includes("all")) return true;
|
|
300
|
+
return r.human_approval.required_for.includes(tool);
|
|
301
|
+
}
|
|
302
|
+
getExecutionMode(role) {
|
|
303
|
+
return this.getRole(role).execution_mode;
|
|
304
|
+
}
|
|
305
|
+
getTemplateName(role) {
|
|
306
|
+
return this.getRole(role).prompt_template;
|
|
307
|
+
}
|
|
308
|
+
getBashOverrides(role) {
|
|
309
|
+
const r = this.getRole(role);
|
|
310
|
+
const overrides = r.bash_overrides;
|
|
311
|
+
if (!overrides) return {};
|
|
312
|
+
return {
|
|
313
|
+
additionalBlocked: overrides.additional_blocked?.map((p) => new RegExp(p)),
|
|
314
|
+
additionalAllowed: overrides.additional_allowed?.map((p) => new RegExp(p))
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
getTokenBudget(role) {
|
|
318
|
+
return this.getRole(role).token_budget_daily;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// src/lib/bash/patterns.ts
|
|
323
|
+
var SAFE_PATTERNS = [
|
|
324
|
+
// File viewing
|
|
325
|
+
/^(cat|head|tail|less|more)\s/,
|
|
326
|
+
/^(file|stat|wc|md5sum|sha256sum)\s/,
|
|
327
|
+
// Directory listing
|
|
328
|
+
/^(ls|ll|la|tree|du|df)\b/,
|
|
329
|
+
/^(pwd|cd)\b/,
|
|
330
|
+
// Searching
|
|
331
|
+
/^(grep|rg|ag|ack|find|fd|locate)\s/,
|
|
332
|
+
/^(which|whereis|type|command)\s/,
|
|
333
|
+
// Text processing (read-only)
|
|
334
|
+
/^(sort|uniq|cut|awk|sed)\s.*(?!-i)/,
|
|
335
|
+
// sed without -i (in-place)
|
|
336
|
+
/^(tr|diff|comm|join|paste)\s/,
|
|
337
|
+
/^(jq|yq|xmlstarlet)\s/,
|
|
338
|
+
// Git (read-only operations)
|
|
339
|
+
/^git\s+(log|status|diff|show|blame|branch|tag|remote|stash list)\b/,
|
|
340
|
+
/^git\s+(ls-files|ls-tree|rev-parse|describe)\b/,
|
|
341
|
+
// System info
|
|
342
|
+
/^(whoami|id|groups|uname|hostname|date|uptime|env|printenv)\b/,
|
|
343
|
+
/^(echo|printf)\s/,
|
|
344
|
+
// Package info (not install)
|
|
345
|
+
/^(npm|yarn|pnpm)\s+(list|ls|info|show|view|outdated|audit)\b/,
|
|
346
|
+
/^pip\s+(list|show|freeze)\b/,
|
|
347
|
+
/^(node|python|ruby|go)\s+--version\b/,
|
|
348
|
+
/^(node|python|ruby)\s+-e\s/,
|
|
349
|
+
// Networking (read-only)
|
|
350
|
+
/^(ping|dig|nslookup|host|traceroute|tracepath)\s/,
|
|
351
|
+
/^curl\s.*--head\b/,
|
|
352
|
+
/^curl\s.*-I\b/,
|
|
353
|
+
// Additional file viewing / inspection
|
|
354
|
+
/^(basename|dirname|realpath|readlink)\s/,
|
|
355
|
+
/^(xxd|od|hexdump)\s/,
|
|
356
|
+
/^(strings|nm|objdump)\s/,
|
|
357
|
+
// Additional search / navigation
|
|
358
|
+
/^(xargs)\s/,
|
|
359
|
+
/^(tee)\s/,
|
|
360
|
+
// Additional text processing (read-only)
|
|
361
|
+
/^(fmt|fold|column|expand|unexpand)\s/,
|
|
362
|
+
/^(tac|rev|nl)\s/,
|
|
363
|
+
/^(yes|seq|shuf)\s/,
|
|
364
|
+
// Additional system info
|
|
365
|
+
/^(lsof|ps|top|htop|vmstat|iostat|free|df)\b/,
|
|
366
|
+
/^(lscpu|lsblk|lsusb|lspci)\b/,
|
|
367
|
+
/^(nproc|getconf)\b/,
|
|
368
|
+
// Additional git read-only
|
|
369
|
+
/^git\s+(config\s+--get|config\s+-l|shortlog|reflog|cherry)\b/,
|
|
370
|
+
/^git\s+(cat-file|count-objects|fsck|verify-pack)\b/
|
|
371
|
+
];
|
|
372
|
+
var DANGEROUS_PATTERNS = [
|
|
373
|
+
// Destructive file operations
|
|
374
|
+
/\brm\s+(-[a-zA-Z]*r|-[a-zA-Z]*f|--recursive|--force)\b/,
|
|
375
|
+
/\brm\s+-[a-zA-Z]*rf\b/,
|
|
376
|
+
/\bshred\b/,
|
|
377
|
+
// Privilege escalation
|
|
378
|
+
/\bsudo\b/,
|
|
379
|
+
/\bsu\s+-?\s*\w/,
|
|
380
|
+
/\bdoas\b/,
|
|
381
|
+
// Permission/ownership changes
|
|
382
|
+
/\bchmod\b/,
|
|
383
|
+
/\bchown\b/,
|
|
384
|
+
/\bchgrp\b/,
|
|
385
|
+
// Disk/partition operations
|
|
386
|
+
/\bdd\b.*\bof=/,
|
|
387
|
+
/\bmkfs\b/,
|
|
388
|
+
/\bfdisk\b/,
|
|
389
|
+
/\bparted\b/,
|
|
390
|
+
/\bmount\b/,
|
|
391
|
+
/\bumount\b/,
|
|
392
|
+
// Remote code execution
|
|
393
|
+
/\bcurl\b.*\|\s*(bash|sh|zsh|python|perl|ruby)\b/,
|
|
394
|
+
/\bwget\b.*\|\s*(bash|sh|zsh|python|perl|ruby)\b/,
|
|
395
|
+
/\bcurl\b.*>\s*.*\.sh\s*&&/,
|
|
396
|
+
// Remote access
|
|
397
|
+
/\bssh\b/,
|
|
398
|
+
/\bscp\b/,
|
|
399
|
+
/\brsync\b.*:\//,
|
|
400
|
+
/\bnc\s+(-[a-zA-Z]*l|-[a-zA-Z]*p|--listen)\b/,
|
|
401
|
+
/\bncat\b/,
|
|
402
|
+
/\bsocat\b/,
|
|
403
|
+
/\btelnet\b/,
|
|
404
|
+
// System modification
|
|
405
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)\b/,
|
|
406
|
+
/\bservice\s+\w+\s+(start|stop|restart)\b/,
|
|
407
|
+
/\biptables\b/,
|
|
408
|
+
/\bufw\b/,
|
|
409
|
+
/\bfirewall-cmd\b/,
|
|
410
|
+
// Package installation (can run arbitrary post-install scripts)
|
|
411
|
+
/\bnpm\s+(install|i|add|ci)\b/,
|
|
412
|
+
/\byarn\s+(add|install)\b/,
|
|
413
|
+
/\bpnpm\s+(add|install|i)\b/,
|
|
414
|
+
/\bpip\s+install\b/,
|
|
415
|
+
/\bapt(-get)?\s+install\b/,
|
|
416
|
+
/\bbrew\s+install\b/,
|
|
417
|
+
/\bcargo\s+install\b/,
|
|
418
|
+
// Environment variable manipulation (can leak secrets)
|
|
419
|
+
/\bexport\b.*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i,
|
|
420
|
+
// Cron / scheduled tasks
|
|
421
|
+
/\bcrontab\b/,
|
|
422
|
+
/\bat\s+/,
|
|
423
|
+
// Container escape vectors
|
|
424
|
+
/\bdocker\s+(run|exec|build|push|pull)\b/,
|
|
425
|
+
/\bkubectl\s+(exec|run|apply|delete)\b/,
|
|
426
|
+
// Process manipulation
|
|
427
|
+
/\bkill\b/,
|
|
428
|
+
/\bkillall\b/,
|
|
429
|
+
/\bpkill\b/,
|
|
430
|
+
// History manipulation
|
|
431
|
+
/\bhistory\s+-c\b/,
|
|
432
|
+
/\bunset\s+HISTFILE\b/,
|
|
433
|
+
// Compiler/build (can execute arbitrary code)
|
|
434
|
+
/\bmake\s/,
|
|
435
|
+
/\bgcc\b/,
|
|
436
|
+
/\bg\+\+/
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
// src/lib/bash/classifier.ts
|
|
440
|
+
var BashClassifier = class {
|
|
441
|
+
safePatterns;
|
|
442
|
+
dangerousPatterns;
|
|
443
|
+
constructor(overrides) {
|
|
444
|
+
this.safePatterns = [...SAFE_PATTERNS, ...overrides?.additionalAllowed ?? []];
|
|
445
|
+
this.dangerousPatterns = [...DANGEROUS_PATTERNS, ...overrides?.additionalBlocked ?? []];
|
|
446
|
+
}
|
|
447
|
+
classify(command) {
|
|
448
|
+
const trimmed = command.trim();
|
|
449
|
+
for (const pattern of this.dangerousPatterns) {
|
|
450
|
+
if (pattern.test(trimmed)) return "dangerous";
|
|
451
|
+
}
|
|
452
|
+
const segments = this.splitCommand(trimmed);
|
|
453
|
+
if (segments.length > 1) {
|
|
454
|
+
const classifications = segments.map((s) => this.classifySingle(s));
|
|
455
|
+
if (classifications.includes("dangerous")) return "dangerous";
|
|
456
|
+
if (classifications.includes("needs_review")) return "needs_review";
|
|
457
|
+
return "safe";
|
|
458
|
+
}
|
|
459
|
+
return this.classifySingle(trimmed);
|
|
460
|
+
}
|
|
461
|
+
classifySingle(command) {
|
|
462
|
+
const trimmed = command.trim();
|
|
463
|
+
for (const pattern of this.dangerousPatterns) {
|
|
464
|
+
if (pattern.test(trimmed)) return "dangerous";
|
|
465
|
+
}
|
|
466
|
+
for (const pattern of this.safePatterns) {
|
|
467
|
+
if (pattern.test(trimmed)) return "safe";
|
|
468
|
+
}
|
|
469
|
+
return "needs_review";
|
|
470
|
+
}
|
|
471
|
+
splitCommand(command) {
|
|
472
|
+
const segments = [];
|
|
473
|
+
let current = "";
|
|
474
|
+
let inSingleQuote = false;
|
|
475
|
+
let inDoubleQuote = false;
|
|
476
|
+
let escaped = false;
|
|
477
|
+
for (let i = 0; i < command.length; i++) {
|
|
478
|
+
const char = command[i];
|
|
479
|
+
if (escaped) {
|
|
480
|
+
current += char;
|
|
481
|
+
escaped = false;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (char === "\\") {
|
|
485
|
+
escaped = true;
|
|
486
|
+
current += char;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (char === "'" && !inDoubleQuote) {
|
|
490
|
+
inSingleQuote = !inSingleQuote;
|
|
491
|
+
current += char;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (char === '"' && !inSingleQuote) {
|
|
495
|
+
inDoubleQuote = !inDoubleQuote;
|
|
496
|
+
current += char;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
500
|
+
if (char === "|" && command[i + 1] !== "|") {
|
|
501
|
+
segments.push(current.trim());
|
|
502
|
+
current = "";
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (char === ";") {
|
|
506
|
+
segments.push(current.trim());
|
|
507
|
+
current = "";
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (char === "&" && command[i + 1] === "&") {
|
|
511
|
+
segments.push(current.trim());
|
|
512
|
+
current = "";
|
|
513
|
+
i++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (char === "|" && command[i + 1] === "|") {
|
|
517
|
+
segments.push(current.trim());
|
|
518
|
+
current = "";
|
|
519
|
+
i++;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
current += char;
|
|
524
|
+
}
|
|
525
|
+
if (current.trim()) segments.push(current.trim());
|
|
526
|
+
return segments.filter((s) => s.length > 0);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// src/lib/audit/logger.ts
|
|
531
|
+
import { randomUUID } from "crypto";
|
|
532
|
+
|
|
533
|
+
// src/lib/audit/sinks/jsonl.ts
|
|
534
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
535
|
+
import { dirname } from "path";
|
|
536
|
+
import { homedir } from "os";
|
|
537
|
+
var JsonlAuditSink = class {
|
|
538
|
+
path;
|
|
539
|
+
buffer = [];
|
|
540
|
+
flushThreshold = 10;
|
|
541
|
+
constructor(path) {
|
|
542
|
+
this.path = path.replace(/^~/, homedir());
|
|
543
|
+
}
|
|
544
|
+
async write(record) {
|
|
545
|
+
this.buffer.push(record);
|
|
546
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
547
|
+
await this.flush();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async flush() {
|
|
551
|
+
if (this.buffer.length === 0) return;
|
|
552
|
+
const lines = this.buffer.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
553
|
+
this.buffer = [];
|
|
554
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
555
|
+
await appendFile(this.path, lines, "utf-8");
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// src/lib/audit/sinks/webhook.ts
|
|
560
|
+
var WebhookAuditSink = class {
|
|
561
|
+
url;
|
|
562
|
+
buffer = [];
|
|
563
|
+
flushThreshold = 10;
|
|
564
|
+
constructor(url) {
|
|
565
|
+
this.url = url;
|
|
566
|
+
}
|
|
567
|
+
async write(record) {
|
|
568
|
+
this.buffer.push(record);
|
|
569
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
570
|
+
await this.flush();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async flush() {
|
|
574
|
+
if (this.buffer.length === 0) return;
|
|
575
|
+
const records = [...this.buffer];
|
|
576
|
+
this.buffer = [];
|
|
577
|
+
try {
|
|
578
|
+
await this.send(records);
|
|
579
|
+
} catch {
|
|
580
|
+
try {
|
|
581
|
+
await this.send(records);
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async send(records) {
|
|
587
|
+
const controller = new AbortController();
|
|
588
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
589
|
+
try {
|
|
590
|
+
const response = await fetch(this.url, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { "Content-Type": "application/json" },
|
|
593
|
+
body: JSON.stringify(records),
|
|
594
|
+
signal: controller.signal
|
|
595
|
+
});
|
|
596
|
+
if (!response.ok) {
|
|
597
|
+
throw new Error(`Webhook returned ${response.status}`);
|
|
598
|
+
}
|
|
599
|
+
} finally {
|
|
600
|
+
clearTimeout(timeout);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/lib/audit/logger.ts
|
|
606
|
+
var AuditLogger = class {
|
|
607
|
+
sinks;
|
|
608
|
+
counts = /* @__PURE__ */ new Map();
|
|
609
|
+
constructor(config) {
|
|
610
|
+
const sinkConfigs = config?.sinks ?? [
|
|
611
|
+
{ type: "jsonl", path: "~/.pi/agent/audit.jsonl" }
|
|
612
|
+
];
|
|
613
|
+
this.sinks = [];
|
|
614
|
+
for (const sc of sinkConfigs) {
|
|
615
|
+
if (sc.type === "jsonl") {
|
|
616
|
+
this.sinks.push(new JsonlAuditSink(sc.path ?? "~/.pi/agent/audit.jsonl"));
|
|
617
|
+
} else if (sc.type === "webhook" && sc.url) {
|
|
618
|
+
this.sinks.push(new WebhookAuditSink(sc.url));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (this.sinks.length === 0) {
|
|
622
|
+
this.sinks.push(new JsonlAuditSink("~/.pi/agent/audit.jsonl"));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async log(record) {
|
|
626
|
+
const full = {
|
|
627
|
+
...record,
|
|
628
|
+
id: randomUUID(),
|
|
629
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
630
|
+
};
|
|
631
|
+
this.counts.set(full.event, (this.counts.get(full.event) ?? 0) + 1);
|
|
632
|
+
await Promise.all(this.sinks.map((s) => s.write(full)));
|
|
633
|
+
}
|
|
634
|
+
async flush() {
|
|
635
|
+
await Promise.all(this.sinks.map((s) => s.flush()));
|
|
636
|
+
}
|
|
637
|
+
getSummary() {
|
|
638
|
+
return new Map(this.counts);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// src/lib/hitl/cli-approver.ts
|
|
643
|
+
var CliApprover = class {
|
|
644
|
+
ui;
|
|
645
|
+
timeoutSeconds;
|
|
646
|
+
constructor(ui, timeoutSeconds = 300) {
|
|
647
|
+
this.ui = ui;
|
|
648
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
649
|
+
}
|
|
650
|
+
async requestApproval(toolCall, context) {
|
|
651
|
+
const title = `Approval Required: ${toolCall.toolName}`;
|
|
652
|
+
const inputSummary = Object.entries(toolCall.input).map(
|
|
653
|
+
([k, v]) => ` ${k}: ${typeof v === "string" ? v.slice(0, 200) : JSON.stringify(v).slice(0, 200)}`
|
|
654
|
+
).join("\n");
|
|
655
|
+
const message = `User: ${context.userId} (${context.role})
|
|
656
|
+
Org: ${context.orgUnit}
|
|
657
|
+
|
|
658
|
+
Tool: ${toolCall.toolName}
|
|
659
|
+
Input:
|
|
660
|
+
${inputSummary}`;
|
|
661
|
+
const start = Date.now();
|
|
662
|
+
try {
|
|
663
|
+
const approved = await this.ui.confirm(title, message, {
|
|
664
|
+
timeout: this.timeoutSeconds * 1e3
|
|
665
|
+
});
|
|
666
|
+
return {
|
|
667
|
+
approved,
|
|
668
|
+
approver: "cli",
|
|
669
|
+
duration: Date.now() - start,
|
|
670
|
+
reason: approved ? void 0 : "Denied by user"
|
|
671
|
+
};
|
|
672
|
+
} catch {
|
|
673
|
+
return {
|
|
674
|
+
approved: false,
|
|
675
|
+
approver: "cli",
|
|
676
|
+
duration: Date.now() - start,
|
|
677
|
+
reason: "Approval timed out"
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// src/lib/hitl/webhook-approver.ts
|
|
684
|
+
var WebhookApprover = class {
|
|
685
|
+
url;
|
|
686
|
+
timeoutMs;
|
|
687
|
+
constructor(url, timeoutSeconds = 300) {
|
|
688
|
+
this.url = url;
|
|
689
|
+
this.timeoutMs = timeoutSeconds * 1e3;
|
|
690
|
+
}
|
|
691
|
+
async requestApproval(toolCall, context) {
|
|
692
|
+
const start = Date.now();
|
|
693
|
+
const controller = new AbortController();
|
|
694
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
695
|
+
try {
|
|
696
|
+
const response = await fetch(this.url, {
|
|
697
|
+
method: "POST",
|
|
698
|
+
headers: { "Content-Type": "application/json" },
|
|
699
|
+
body: JSON.stringify({
|
|
700
|
+
toolCall: { toolName: toolCall.toolName, input: toolCall.input },
|
|
701
|
+
context
|
|
702
|
+
}),
|
|
703
|
+
signal: controller.signal
|
|
704
|
+
});
|
|
705
|
+
if (!response.ok) {
|
|
706
|
+
return {
|
|
707
|
+
approved: false,
|
|
708
|
+
approver: "webhook",
|
|
709
|
+
duration: Date.now() - start,
|
|
710
|
+
reason: `Webhook returned ${response.status}`
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const body = await response.json();
|
|
714
|
+
return {
|
|
715
|
+
approved: body.approved,
|
|
716
|
+
approver: "webhook",
|
|
717
|
+
duration: Date.now() - start,
|
|
718
|
+
reason: body.reason
|
|
719
|
+
};
|
|
720
|
+
} catch (err) {
|
|
721
|
+
return {
|
|
722
|
+
approved: false,
|
|
723
|
+
approver: "webhook",
|
|
724
|
+
duration: Date.now() - start,
|
|
725
|
+
reason: err instanceof Error && err.name === "AbortError" ? "Webhook timed out" : "Webhook request failed"
|
|
726
|
+
};
|
|
727
|
+
} finally {
|
|
728
|
+
clearTimeout(timeout);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// src/lib/hitl/approval.ts
|
|
734
|
+
function createApprovalFlow(config, ui) {
|
|
735
|
+
if (config.approval_channel === "webhook" && config.webhook?.url) {
|
|
736
|
+
return new WebhookApprover(config.webhook.url, config.timeout_seconds);
|
|
737
|
+
}
|
|
738
|
+
if (!ui) {
|
|
739
|
+
throw new Error("CLI approval channel requires a ConfirmUI (ExtensionContext.ui)");
|
|
740
|
+
}
|
|
741
|
+
return new CliApprover(ui, config.timeout_seconds);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/lib/budget/tracker.ts
|
|
745
|
+
var BudgetTracker = class {
|
|
746
|
+
_used = 0;
|
|
747
|
+
_budget;
|
|
748
|
+
constructor(budget) {
|
|
749
|
+
this._budget = budget;
|
|
750
|
+
}
|
|
751
|
+
/** Returns false if consuming would exceed the budget. On success, increments the counter. */
|
|
752
|
+
consume(amount = 1) {
|
|
753
|
+
if (this._budget === -1) {
|
|
754
|
+
this._used += amount;
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
if (this._used + amount > this._budget) return false;
|
|
758
|
+
this._used += amount;
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
remaining() {
|
|
762
|
+
if (this._budget === -1) return Infinity;
|
|
763
|
+
return Math.max(0, this._budget - this._used);
|
|
764
|
+
}
|
|
765
|
+
used() {
|
|
766
|
+
return this._used;
|
|
767
|
+
}
|
|
768
|
+
isUnlimited() {
|
|
769
|
+
return this._budget === -1;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// src/lib/config/watcher.ts
|
|
774
|
+
import { watch, readFileSync as readFileSync4 } from "fs";
|
|
775
|
+
import { parse as parseYaml4 } from "yaml";
|
|
776
|
+
import { Value as Value2 } from "@sinclair/typebox/value";
|
|
777
|
+
var ConfigWatcher = class {
|
|
778
|
+
watcher;
|
|
779
|
+
debounceTimer;
|
|
780
|
+
configPath;
|
|
781
|
+
onChange;
|
|
782
|
+
onError;
|
|
783
|
+
constructor(configPath, onChange, onError) {
|
|
784
|
+
this.configPath = configPath;
|
|
785
|
+
this.onChange = onChange;
|
|
786
|
+
this.onError = onError;
|
|
787
|
+
}
|
|
788
|
+
start() {
|
|
789
|
+
if (this.watcher) return;
|
|
790
|
+
this.watcher = watch(this.configPath, () => this.handleChange());
|
|
791
|
+
}
|
|
792
|
+
stop() {
|
|
793
|
+
if (this.debounceTimer) {
|
|
794
|
+
clearTimeout(this.debounceTimer);
|
|
795
|
+
this.debounceTimer = void 0;
|
|
796
|
+
}
|
|
797
|
+
if (this.watcher) {
|
|
798
|
+
this.watcher.close();
|
|
799
|
+
this.watcher = void 0;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
handleChange() {
|
|
803
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
804
|
+
this.debounceTimer = setTimeout(() => this.reload(), 500);
|
|
805
|
+
}
|
|
806
|
+
reload() {
|
|
807
|
+
try {
|
|
808
|
+
const raw = readFileSync4(this.configPath, "utf-8");
|
|
809
|
+
const parsed = parseYaml4(raw);
|
|
810
|
+
const errors = [...Value2.Errors(GovernanceConfigSchema, parsed)];
|
|
811
|
+
if (errors.length > 0) {
|
|
812
|
+
const msg = errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
813
|
+
this.onError?.(new Error(`Config validation failed: ${msg}`));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const config = Value2.Default(GovernanceConfigSchema, parsed);
|
|
817
|
+
this.onChange(config);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
this.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// src/extensions/index.ts
|
|
825
|
+
var PATH_TOOLS = {
|
|
826
|
+
read: "path",
|
|
827
|
+
write: "path",
|
|
828
|
+
edit: "file_path",
|
|
829
|
+
grep: "path",
|
|
830
|
+
find: "path",
|
|
831
|
+
ls: "path"
|
|
832
|
+
};
|
|
833
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit"]);
|
|
834
|
+
function summarizeParams(toolName, input) {
|
|
835
|
+
switch (toolName) {
|
|
836
|
+
case "bash": {
|
|
837
|
+
const cmd = typeof input["command"] === "string" ? input["command"] : "";
|
|
838
|
+
return { command: cmd.slice(0, 100) + (cmd.length > 100 ? "..." : "") };
|
|
839
|
+
}
|
|
840
|
+
case "read":
|
|
841
|
+
return { path: input["path"] };
|
|
842
|
+
case "write":
|
|
843
|
+
return { path: input["path"] };
|
|
844
|
+
case "edit":
|
|
845
|
+
return { file_path: input["file_path"] };
|
|
846
|
+
case "grep":
|
|
847
|
+
return { pattern: input["pattern"], path: input["path"] };
|
|
848
|
+
case "find":
|
|
849
|
+
case "ls":
|
|
850
|
+
return { path: input["path"] };
|
|
851
|
+
default:
|
|
852
|
+
return {};
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function extractPath(toolName, input) {
|
|
856
|
+
const key = PATH_TOOLS[toolName];
|
|
857
|
+
if (!key) return void 0;
|
|
858
|
+
const val = input[key];
|
|
859
|
+
return typeof val === "string" ? val : void 0;
|
|
860
|
+
}
|
|
861
|
+
var piGovernance = (pi) => {
|
|
862
|
+
let config;
|
|
863
|
+
let policyEngine;
|
|
864
|
+
let audit;
|
|
865
|
+
let approvalFlow;
|
|
866
|
+
let bashClassifier;
|
|
867
|
+
let identity;
|
|
868
|
+
let executionMode;
|
|
869
|
+
let sessionId;
|
|
870
|
+
let budgetTracker;
|
|
871
|
+
let configWatcher;
|
|
872
|
+
const stats = { allowed: 0, denied: 0, approvals: 0, dryRun: 0, budgetExceeded: 0 };
|
|
873
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
874
|
+
sessionId = ctx.sessionId;
|
|
875
|
+
const loaded = loadConfig();
|
|
876
|
+
config = loaded.config;
|
|
877
|
+
const chain = createIdentityChain(config.auth);
|
|
878
|
+
identity = await chain.resolve();
|
|
879
|
+
const rulesFile = config.policy?.yaml?.rules_file ?? "./governance-rules.yaml";
|
|
880
|
+
policyEngine = new YamlPolicyEngine(rulesFile);
|
|
881
|
+
executionMode = policyEngine.getExecutionMode(identity.role);
|
|
882
|
+
const bashOverrides = policyEngine.getBashOverrides(identity.role);
|
|
883
|
+
bashClassifier = new BashClassifier(bashOverrides);
|
|
884
|
+
audit = new AuditLogger(config.audit);
|
|
885
|
+
if (executionMode === "supervised") {
|
|
886
|
+
try {
|
|
887
|
+
approvalFlow = createApprovalFlow(
|
|
888
|
+
{
|
|
889
|
+
default_mode: config.hitl?.default_mode ?? "supervised",
|
|
890
|
+
approval_channel: config.hitl?.approval_channel ?? "cli",
|
|
891
|
+
timeout_seconds: config.hitl?.timeout_seconds ?? 300,
|
|
892
|
+
webhook: config.hitl?.webhook
|
|
893
|
+
},
|
|
894
|
+
ctx.ui
|
|
895
|
+
);
|
|
896
|
+
} catch {
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const budget = policyEngine.getTokenBudget(identity.role);
|
|
900
|
+
budgetTracker = new BudgetTracker(budget);
|
|
901
|
+
if (loaded.source !== "built-in") {
|
|
902
|
+
configWatcher = new ConfigWatcher(
|
|
903
|
+
loaded.source,
|
|
904
|
+
(newConfig) => {
|
|
905
|
+
config = newConfig;
|
|
906
|
+
const newRulesFile = newConfig.policy?.yaml?.rules_file ?? "./governance-rules.yaml";
|
|
907
|
+
policyEngine = new YamlPolicyEngine(newRulesFile);
|
|
908
|
+
const newOverrides = policyEngine.getBashOverrides(identity.role);
|
|
909
|
+
bashClassifier = new BashClassifier(newOverrides);
|
|
910
|
+
audit.log({
|
|
911
|
+
sessionId,
|
|
912
|
+
event: "config_reloaded",
|
|
913
|
+
userId: identity.userId,
|
|
914
|
+
role: identity.role,
|
|
915
|
+
orgUnit: identity.orgUnit,
|
|
916
|
+
metadata: { source: loaded.source }
|
|
917
|
+
});
|
|
918
|
+
ctx.ui.notify("Governance config reloaded", "info");
|
|
919
|
+
},
|
|
920
|
+
(error) => {
|
|
921
|
+
ctx.ui.notify(`Config reload failed: ${error.message}`, "warning");
|
|
922
|
+
}
|
|
923
|
+
);
|
|
924
|
+
configWatcher.start();
|
|
925
|
+
}
|
|
926
|
+
await audit.log({
|
|
927
|
+
sessionId,
|
|
928
|
+
event: "session_start",
|
|
929
|
+
userId: identity.userId,
|
|
930
|
+
role: identity.role,
|
|
931
|
+
orgUnit: identity.orgUnit,
|
|
932
|
+
metadata: { source: loaded.source, executionMode }
|
|
933
|
+
});
|
|
934
|
+
ctx.ui.setStatus("governance", `Governance: ${identity.role} (${executionMode})`);
|
|
935
|
+
ctx.ui.notify(
|
|
936
|
+
`Governance active \u2014 Role: ${identity.role} | Mode: ${executionMode} | Org: ${identity.orgUnit}`,
|
|
937
|
+
"info"
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
pi.on("tool_call", async (event, _ctx) => {
|
|
941
|
+
const { toolName, input } = event;
|
|
942
|
+
const params = summarizeParams(toolName, input);
|
|
943
|
+
const baseRecord = {
|
|
944
|
+
sessionId,
|
|
945
|
+
userId: identity.userId,
|
|
946
|
+
role: identity.role,
|
|
947
|
+
orgUnit: identity.orgUnit,
|
|
948
|
+
tool: toolName,
|
|
949
|
+
input: params
|
|
950
|
+
};
|
|
951
|
+
if (executionMode === "dry_run") {
|
|
952
|
+
stats.dryRun++;
|
|
953
|
+
await audit.log({
|
|
954
|
+
...baseRecord,
|
|
955
|
+
event: "tool_dry_run",
|
|
956
|
+
decision: "blocked",
|
|
957
|
+
reason: "Dry-run mode"
|
|
958
|
+
});
|
|
959
|
+
return { block: true, reason: "Dry-run mode: tool execution blocked for observation" };
|
|
960
|
+
}
|
|
961
|
+
if (!budgetTracker.consume()) {
|
|
962
|
+
stats.budgetExceeded++;
|
|
963
|
+
await audit.log({
|
|
964
|
+
...baseRecord,
|
|
965
|
+
event: "budget_exceeded",
|
|
966
|
+
decision: "denied",
|
|
967
|
+
reason: `Budget exhausted (${budgetTracker.used()} invocations used)`
|
|
968
|
+
});
|
|
969
|
+
return {
|
|
970
|
+
block: true,
|
|
971
|
+
reason: `Tool invocation budget exhausted (${budgetTracker.used()} used). Session limit reached.`
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
const toolDecision = policyEngine.evaluateTool(identity.role, toolName);
|
|
975
|
+
if (toolDecision === "deny") {
|
|
976
|
+
stats.denied++;
|
|
977
|
+
await audit.log({
|
|
978
|
+
...baseRecord,
|
|
979
|
+
event: "tool_denied",
|
|
980
|
+
decision: "denied",
|
|
981
|
+
reason: "Policy denied tool"
|
|
982
|
+
});
|
|
983
|
+
return { block: true, reason: `Policy denies ${identity.role} from using ${toolName}` };
|
|
984
|
+
}
|
|
985
|
+
if (toolName === "bash") {
|
|
986
|
+
const command = typeof input["command"] === "string" ? input["command"] : "";
|
|
987
|
+
const classification = bashClassifier.classify(command);
|
|
988
|
+
if (classification === "dangerous") {
|
|
989
|
+
stats.denied++;
|
|
990
|
+
await audit.log({
|
|
991
|
+
...baseRecord,
|
|
992
|
+
event: "bash_denied",
|
|
993
|
+
decision: "denied",
|
|
994
|
+
reason: "Dangerous command"
|
|
995
|
+
});
|
|
996
|
+
return { block: true, reason: `Dangerous bash command blocked: ${command.slice(0, 80)}` };
|
|
997
|
+
}
|
|
998
|
+
if (classification === "needs_review" && policyEngine.requiresApproval(identity.role, "bash")) {
|
|
999
|
+
if (approvalFlow) {
|
|
1000
|
+
stats.approvals++;
|
|
1001
|
+
await audit.log({ ...baseRecord, event: "approval_requested" });
|
|
1002
|
+
const result = await approvalFlow.requestApproval(
|
|
1003
|
+
{ toolName, input },
|
|
1004
|
+
{ userId: identity.userId, role: identity.role, orgUnit: identity.orgUnit }
|
|
1005
|
+
);
|
|
1006
|
+
if (result.approved) {
|
|
1007
|
+
await audit.log({
|
|
1008
|
+
...baseRecord,
|
|
1009
|
+
event: "approval_granted",
|
|
1010
|
+
duration: result.duration
|
|
1011
|
+
});
|
|
1012
|
+
} else {
|
|
1013
|
+
stats.denied++;
|
|
1014
|
+
await audit.log({
|
|
1015
|
+
...baseRecord,
|
|
1016
|
+
event: "approval_denied",
|
|
1017
|
+
reason: result.reason,
|
|
1018
|
+
duration: result.duration
|
|
1019
|
+
});
|
|
1020
|
+
return { block: true, reason: result.reason ?? "Approval denied" };
|
|
1021
|
+
}
|
|
1022
|
+
} else {
|
|
1023
|
+
stats.denied++;
|
|
1024
|
+
await audit.log({
|
|
1025
|
+
...baseRecord,
|
|
1026
|
+
event: "tool_denied",
|
|
1027
|
+
decision: "denied",
|
|
1028
|
+
reason: "Requires approval but no approval channel"
|
|
1029
|
+
});
|
|
1030
|
+
return {
|
|
1031
|
+
block: true,
|
|
1032
|
+
reason: "Bash command requires approval but no approval channel is configured"
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
const path = extractPath(toolName, input);
|
|
1038
|
+
if (path) {
|
|
1039
|
+
const operation = WRITE_TOOLS.has(toolName) ? "write" : "read";
|
|
1040
|
+
const pathDecision = policyEngine.evaluatePath(
|
|
1041
|
+
identity.role,
|
|
1042
|
+
identity.orgUnit,
|
|
1043
|
+
operation,
|
|
1044
|
+
path
|
|
1045
|
+
);
|
|
1046
|
+
if (pathDecision === "deny") {
|
|
1047
|
+
stats.denied++;
|
|
1048
|
+
await audit.log({
|
|
1049
|
+
...baseRecord,
|
|
1050
|
+
event: "path_denied",
|
|
1051
|
+
decision: "denied",
|
|
1052
|
+
reason: `Path denied: ${path}`
|
|
1053
|
+
});
|
|
1054
|
+
return { block: true, reason: `Access denied to path: ${path}` };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (toolName !== "bash" && policyEngine.requiresApproval(identity.role, toolName)) {
|
|
1058
|
+
if (approvalFlow) {
|
|
1059
|
+
stats.approvals++;
|
|
1060
|
+
await audit.log({ ...baseRecord, event: "approval_requested" });
|
|
1061
|
+
const result = await approvalFlow.requestApproval(
|
|
1062
|
+
{ toolName, input },
|
|
1063
|
+
{ userId: identity.userId, role: identity.role, orgUnit: identity.orgUnit }
|
|
1064
|
+
);
|
|
1065
|
+
if (result.approved) {
|
|
1066
|
+
await audit.log({ ...baseRecord, event: "approval_granted", duration: result.duration });
|
|
1067
|
+
} else {
|
|
1068
|
+
stats.denied++;
|
|
1069
|
+
await audit.log({
|
|
1070
|
+
...baseRecord,
|
|
1071
|
+
event: "approval_denied",
|
|
1072
|
+
reason: result.reason,
|
|
1073
|
+
duration: result.duration
|
|
1074
|
+
});
|
|
1075
|
+
return { block: true, reason: result.reason ?? "Approval denied" };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
stats.allowed++;
|
|
1080
|
+
await audit.log({ ...baseRecord, event: "tool_allowed", decision: "allowed" });
|
|
1081
|
+
return void 0;
|
|
1082
|
+
});
|
|
1083
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
1084
|
+
await audit.log({
|
|
1085
|
+
sessionId,
|
|
1086
|
+
event: "tool_result",
|
|
1087
|
+
userId: identity.userId,
|
|
1088
|
+
role: identity.role,
|
|
1089
|
+
orgUnit: identity.orgUnit,
|
|
1090
|
+
tool: event.toolName,
|
|
1091
|
+
input: summarizeParams(event.toolName, event.input),
|
|
1092
|
+
metadata: { isError: event.isError }
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
1096
|
+
configWatcher?.stop();
|
|
1097
|
+
await audit.log({
|
|
1098
|
+
sessionId,
|
|
1099
|
+
event: "session_end",
|
|
1100
|
+
userId: identity.userId,
|
|
1101
|
+
role: identity.role,
|
|
1102
|
+
orgUnit: identity.orgUnit,
|
|
1103
|
+
metadata: {
|
|
1104
|
+
stats: { ...stats },
|
|
1105
|
+
budget: { used: budgetTracker.used(), remaining: budgetTracker.remaining() },
|
|
1106
|
+
summary: Object.fromEntries(audit.getSummary())
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
await audit.flush();
|
|
1110
|
+
});
|
|
1111
|
+
pi.registerCommand("governance", {
|
|
1112
|
+
description: "Governance status and controls",
|
|
1113
|
+
handler: async (args, ctx) => {
|
|
1114
|
+
const subcommand = args.trim().split(/\s+/)[0] ?? "";
|
|
1115
|
+
if (subcommand === "status") {
|
|
1116
|
+
const summary = audit.getSummary();
|
|
1117
|
+
const budgetInfo = budgetTracker.isUnlimited() ? "unlimited" : `${budgetTracker.used()} / ${budgetTracker.used() + budgetTracker.remaining()} (${budgetTracker.remaining()} remaining)`;
|
|
1118
|
+
const lines = [
|
|
1119
|
+
`Role: ${identity.role}`,
|
|
1120
|
+
`Org Unit: ${identity.orgUnit}`,
|
|
1121
|
+
`Mode: ${executionMode}`,
|
|
1122
|
+
`Session: ${sessionId}`,
|
|
1123
|
+
`Budget: ${budgetInfo}`,
|
|
1124
|
+
"",
|
|
1125
|
+
"Session Stats:",
|
|
1126
|
+
` Allowed: ${stats.allowed}`,
|
|
1127
|
+
` Denied: ${stats.denied}`,
|
|
1128
|
+
` Approvals: ${stats.approvals}`,
|
|
1129
|
+
` Dry-run blocks: ${stats.dryRun}`,
|
|
1130
|
+
` Budget exceeded: ${stats.budgetExceeded}`,
|
|
1131
|
+
"",
|
|
1132
|
+
"Audit Events:",
|
|
1133
|
+
...[...summary.entries()].map(([k, v]) => ` ${k}: ${v}`)
|
|
1134
|
+
];
|
|
1135
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1136
|
+
} else {
|
|
1137
|
+
ctx.ui.notify("Usage: /governance status", "info");
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
var extensions_default = piGovernance;
|
|
1143
|
+
export {
|
|
1144
|
+
extensions_default as default
|
|
1145
|
+
};
|
|
1146
|
+
//# sourceMappingURL=index.js.map
|