@gtrabanco/pi-agentic-workflow 0.37.0 → 4.10.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/dist/config/defaults.js +8 -0
- package/dist/config/merge.js +20 -0
- package/dist/config/schema.js +103 -1
- package/dist/config/types.d.ts +60 -0
- package/dist/extension/ci-guard.d.ts +29 -0
- package/dist/extension/ci-guard.js +39 -0
- package/dist/extension/cost-guard.d.ts +30 -0
- package/dist/extension/cost-guard.js +50 -0
- package/dist/extension/factory.d.ts +11 -4
- package/dist/extension/factory.js +4 -1
- package/dist/extension/guard-util.d.ts +26 -0
- package/dist/extension/guard-util.js +35 -0
- package/dist/extension/history-guard.d.ts +59 -0
- package/dist/extension/history-guard.js +132 -0
- package/dist/extension/index.js +21 -5
- package/dist/extension/merge-guard.d.ts +26 -28
- package/dist/extension/merge-guard.js +64 -38
- package/package.json +7 -6
- package/skills/audit-docs/SKILL.md +1 -1
- package/skills/audit-pr/SKILL.md +12 -1
- package/skills/discover-repository-state/SKILL.md +1 -1
- package/skills/execute-phase/SKILL.md +61 -6
- package/skills/fold-findings/SKILL.md +126 -8
- package/skills/fold-findings/references/FOLD_PROCESS.md +7 -1
- package/skills/init-workspace/SKILL.md +1 -1
- package/skills/log-session/SKILL.md +1 -1
- package/skills/orchestration-envelope/SKILL.md +1 -1
- package/skills/phase-contract/SKILL.md +1 -1
- package/skills/pre-execution-review/SKILL.md +1 -1
- package/skills/product-audit/SKILL.md +4 -1
- package/skills/product-audit/references/AUDIT_DIMENSIONS.md +34 -0
- package/skills/product-audit/references/AUDIT_PROCESS.md +5 -1
- package/skills/resolve-repository-state/SKILL.md +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-brand/SKILL.md +1 -1
- package/skills/review-change/SKILL.md +18 -3
- package/skills/review-code/SKILL.md +28 -2
- package/skills/review-debt/SKILL.md +8 -2
- package/skills/review-design/SKILL.md +1 -1
- package/skills/review-implementation/SKILL.md +1 -1
- package/skills/review-implementation/references/CLASSIFY.md +25 -0
- package/skills/review-perf/SKILL.md +1 -1
- package/skills/review-security/SKILL.md +1 -1
- package/skills/review-seo/SKILL.md +1 -1
- package/skills/review-verify/SKILL.md +19 -2
- package/skills/triage-issue/SKILL.md +9 -1
- package/skills/triage-issue/references/LABELS.md +22 -0
- package/skills/unit-lane/SKILL.md +26 -7
- package/skills/unit-lane/references/DESIGN.md +55 -1
- package/skills/unit-lane/references/PLAN.md +29 -0
- package/skills/verification-contract/SKILL.md +9 -1
- package/skills/workflow-status/SKILL.md +1 -1
package/dist/config/defaults.js
CHANGED
|
@@ -20,6 +20,14 @@ export const DEFAULT_CONFIG = {
|
|
|
20
20
|
// Feature 66 — the memory substrate is ask-first: nothing is provisioned
|
|
21
21
|
// until the operator opts in.
|
|
22
22
|
memory: { engram: false },
|
|
23
|
+
// Tool-call guards ship all-off: they are this repository's working rules,
|
|
24
|
+
// not a package opinion — a project opts in per guard in its config file.
|
|
25
|
+
guards: {
|
|
26
|
+
merge: false,
|
|
27
|
+
history: false,
|
|
28
|
+
ci: { create: false, run: false },
|
|
29
|
+
expensiveTests: { maxTokens: 0, patterns: [] },
|
|
30
|
+
},
|
|
23
31
|
profiles: {},
|
|
24
32
|
profileOrder: [],
|
|
25
33
|
profileFallback: { ...DEFAULT_PROFILE_FALLBACK },
|
package/dist/config/merge.js
CHANGED
|
@@ -132,6 +132,9 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
|
|
|
132
132
|
memory: {
|
|
133
133
|
engram: pick(false, projectFile.memory?.engram, globalFile.memory?.engram) === true,
|
|
134
134
|
},
|
|
135
|
+
// Tool-call guards resolve like every other knob: project wins, then
|
|
136
|
+
// global, then the shipped OFF. A guard no scope mentions stays disabled.
|
|
137
|
+
guards: mergeGuards(globalFile.guards, projectFile.guards),
|
|
135
138
|
profiles: mergeProfiles(globalFile.profiles, projectFile.profiles),
|
|
136
139
|
profileOrder: pick([], projectFile.profileOrder, globalFile.profileOrder),
|
|
137
140
|
profileFallback: mergeProfileFallback(globalFile.profileFallback, projectFile.profileFallback),
|
|
@@ -144,6 +147,23 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
|
|
|
144
147
|
merged.advance = advance;
|
|
145
148
|
return merged;
|
|
146
149
|
}
|
|
150
|
+
/** Guards merge whole-knob: a boolean takes project-then-global, and the
|
|
151
|
+
* expensive-tests block resolves field-by-field (patterns are replaced, not
|
|
152
|
+
* concatenated, so a project override is predictable — no hidden union). */
|
|
153
|
+
function mergeGuards(globalGuards, projectGuards) {
|
|
154
|
+
return {
|
|
155
|
+
merge: pick(DEFAULT_CONFIG.guards.merge, projectGuards?.merge, globalGuards?.merge),
|
|
156
|
+
history: pick(DEFAULT_CONFIG.guards.history, projectGuards?.history, globalGuards?.history),
|
|
157
|
+
ci: {
|
|
158
|
+
create: pick(DEFAULT_CONFIG.guards.ci.create, projectGuards?.ci?.create, globalGuards?.ci?.create),
|
|
159
|
+
run: pick(DEFAULT_CONFIG.guards.ci.run, projectGuards?.ci?.run, globalGuards?.ci?.run),
|
|
160
|
+
},
|
|
161
|
+
expensiveTests: {
|
|
162
|
+
maxTokens: pick(DEFAULT_CONFIG.guards.expensiveTests.maxTokens, projectGuards?.expensiveTests?.maxTokens, globalGuards?.expensiveTests?.maxTokens),
|
|
163
|
+
patterns: pick(DEFAULT_CONFIG.guards.expensiveTests.patterns, projectGuards?.expensiveTests?.patterns, globalGuards?.expensiveTests?.patterns),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
147
167
|
/** Project-over-global merge of the advance (conductor) knobs (feature 62).
|
|
148
168
|
* Absent from the result when neither scope declares any — the resolved
|
|
149
169
|
* shape of pre-62 configs is byte-stable. */
|
package/dist/config/schema.js
CHANGED
|
@@ -21,6 +21,7 @@ const ROOT_KEYS = new Set([
|
|
|
21
21
|
"profileOrder",
|
|
22
22
|
"profileFallback",
|
|
23
23
|
"memory",
|
|
24
|
+
"guards",
|
|
24
25
|
]);
|
|
25
26
|
const ROUTE_KEYS = new Set(["model", "thinking"]);
|
|
26
27
|
const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
@@ -332,7 +333,7 @@ function validateConfig(value) {
|
|
|
332
333
|
if (!ROOT_KEYS.has(key)) {
|
|
333
334
|
issues.push({
|
|
334
335
|
path: `$.${displayKey(key)}`,
|
|
335
|
-
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory)`,
|
|
336
|
+
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory, guards)`,
|
|
336
337
|
});
|
|
337
338
|
}
|
|
338
339
|
}
|
|
@@ -407,6 +408,11 @@ function validateConfig(value) {
|
|
|
407
408
|
if (memory)
|
|
408
409
|
config.memory = memory;
|
|
409
410
|
}
|
|
411
|
+
if (value.guards !== undefined) {
|
|
412
|
+
const guards = checkGuards(value.guards, "$.guards", issues);
|
|
413
|
+
if (guards)
|
|
414
|
+
config.guards = guards;
|
|
415
|
+
}
|
|
410
416
|
if (value.profiles !== undefined) {
|
|
411
417
|
if (!isRecord(value.profiles)) {
|
|
412
418
|
issues.push({ path: "$.profiles", message: "must be an object mapping profile names to profile definitions" });
|
|
@@ -466,6 +472,102 @@ function checkMemory(value, path, issues) {
|
|
|
466
472
|
}
|
|
467
473
|
return unknown ? undefined : out;
|
|
468
474
|
}
|
|
475
|
+
/** Strict validator for the `guards` block. Everything absent stays off. */
|
|
476
|
+
function checkGuards(value, path, issues) {
|
|
477
|
+
if (!isRecord(value)) {
|
|
478
|
+
issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const key of ["merge", "history"]) {
|
|
483
|
+
if (value[key] !== undefined) {
|
|
484
|
+
if (typeof value[key] !== "boolean") {
|
|
485
|
+
issues.push({ path: `${path}.${key}`, message: `must be a boolean, got ${describe(value[key])}` });
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
out[key] = value[key];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (value.ci !== undefined) {
|
|
493
|
+
const ciPath = `${path}.ci`;
|
|
494
|
+
if (!isRecord(value.ci)) {
|
|
495
|
+
issues.push({ path: ciPath, message: `must be a JSON object, got ${describe(value.ci)}` });
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
const ci = {};
|
|
499
|
+
for (const key of ["create", "run"]) {
|
|
500
|
+
if (value.ci[key] !== undefined) {
|
|
501
|
+
if (typeof value.ci[key] !== "boolean") {
|
|
502
|
+
issues.push({ path: `${ciPath}.${key}`, message: `must be a boolean, got ${describe(value.ci[key])}` });
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
ci[key] = value.ci[key];
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const key of Object.keys(value.ci)) {
|
|
510
|
+
if (key !== "create" && key !== "run") {
|
|
511
|
+
issues.push({ path: `${ciPath}.${displayKey(key)}`, message: `unknown ci key "${key}" (allowed: create, run)` });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
out.ci = ci;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (value.expensiveTests !== undefined) {
|
|
518
|
+
out.expensiveTests = checkExpensiveTests(value.expensiveTests, `${path}.expensiveTests`, issues);
|
|
519
|
+
}
|
|
520
|
+
for (const key of Object.keys(value)) {
|
|
521
|
+
if (key !== "merge" && key !== "history" && key !== "ci" && key !== "expensiveTests") {
|
|
522
|
+
issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown guards key "${key}" (allowed: merge, history, ci, expensiveTests)` });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
function checkExpensiveTests(value, path, issues) {
|
|
528
|
+
if (!isRecord(value)) {
|
|
529
|
+
issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
const out = {};
|
|
533
|
+
if (value.maxTokens !== undefined) {
|
|
534
|
+
if (typeof value.maxTokens !== "number" || !Number.isInteger(value.maxTokens) || value.maxTokens < 0) {
|
|
535
|
+
issues.push({ path: `${path}.maxTokens`, message: `must be a non-negative integer, got ${describe(value.maxTokens)}` });
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
out.maxTokens = value.maxTokens;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (value.patterns !== undefined) {
|
|
542
|
+
if (!Array.isArray(value.patterns)) {
|
|
543
|
+
issues.push({ path: `${path}.patterns`, message: `must be an array, got ${describe(value.patterns)}` });
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
const patterns = [];
|
|
547
|
+
value.patterns.forEach((entry, index) => {
|
|
548
|
+
const entryPath = `${path}.patterns[${index}]`;
|
|
549
|
+
if (!isRecord(entry) || typeof entry.match !== "string" || entry.match === "") {
|
|
550
|
+
issues.push({ path: entryPath, message: `must be an object with a non-empty string "match", got ${describe(entry)}` });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (typeof entry.tokens !== "number" || !Number.isInteger(entry.tokens) || entry.tokens < 0) {
|
|
554
|
+
issues.push({ path: `${entryPath}.tokens`, message: `must be a non-negative integer, got ${describe(entry.tokens)}` });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
new RegExp(entry.match, "u");
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
issues.push({ path: `${entryPath}.match`, message: `not a valid regular expression: ${entry.match}` });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
patterns.push({ match: entry.match, tokens: entry.tokens });
|
|
565
|
+
});
|
|
566
|
+
out.patterns = patterns;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
469
571
|
export function parseConfigFile(text) {
|
|
470
572
|
if (text.trim() === "")
|
|
471
573
|
return { ok: true, config: {} };
|
package/dist/config/types.d.ts
CHANGED
|
@@ -78,6 +78,9 @@ export interface ConfigFile {
|
|
|
78
78
|
/** Feature 66 (AC3/D3): the memory-substrate consent surface — Engram's MCP
|
|
79
79
|
* registration is ask-first, so nothing is provisioned unless this says so. */
|
|
80
80
|
memory?: MemoryConfigFile;
|
|
81
|
+
/** Tool-call guards (owner-gated merges, history, CI, expensive tests).
|
|
82
|
+
* Everything absent stays off. */
|
|
83
|
+
guards?: GuardsConfigFile;
|
|
81
84
|
/** Named user profiles keyed by profile name (feature 63, AC5). */
|
|
82
85
|
profiles?: Record<string, ProfileFile>;
|
|
83
86
|
/** Ordered list of profile names to probe; empty means ["default"] only
|
|
@@ -105,6 +108,61 @@ export interface MemoryConfigFile {
|
|
|
105
108
|
export interface MemoryConfig {
|
|
106
109
|
engram: boolean;
|
|
107
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* One tool-call guard, as an operator WRITES it (this repository's working
|
|
113
|
+
* rules, made mechanical; every other project gets none of it unless it
|
|
114
|
+
* configures it). Each guard ships disabled — an absent key is OFF.
|
|
115
|
+
*
|
|
116
|
+
* merge every direct merge route is owner-only: `git merge`,
|
|
117
|
+
* `gh pr merge`, and `git pull`'s merge form.
|
|
118
|
+
* history pushes are decided by asking git (`git push --dry-run`):
|
|
119
|
+
* a forced update, a ref delete or a push to `main` is
|
|
120
|
+
* declined — one mechanism covering every flag spelling and
|
|
121
|
+
* git alias.
|
|
122
|
+
* ci CI is owner-gated: creating workflow files and triggering
|
|
123
|
+
* workflow runs are blocked.
|
|
124
|
+
* expensiveTests commands matching a configured pattern are estimated in
|
|
125
|
+
* tokens; above `maxTokens` they are blocked. `tokens: 0`
|
|
126
|
+
* blocks every match regardless of estimate.
|
|
127
|
+
*
|
|
128
|
+
* The only way through a guard is the explicit per-command token — the
|
|
129
|
+
* command's first word — after an owner authorization in the same turn:
|
|
130
|
+
* `AW_ALLOW=merge`, `AW_ALLOW=force`, `AW_ALLOW=ci`, `AW_ALLOW=cost`.
|
|
131
|
+
*/
|
|
132
|
+
export interface GuardsConfigFile {
|
|
133
|
+
merge?: boolean;
|
|
134
|
+
history?: boolean;
|
|
135
|
+
ci?: GuardsCiFile;
|
|
136
|
+
expensiveTests?: GuardsExpensiveTestsFile;
|
|
137
|
+
}
|
|
138
|
+
export interface GuardsCiFile {
|
|
139
|
+
create?: boolean;
|
|
140
|
+
run?: boolean;
|
|
141
|
+
}
|
|
142
|
+
export interface GuardsExpensiveTestsFile {
|
|
143
|
+
/** Estimated-token ceiling; a match above it is blocked. `0` (default)
|
|
144
|
+
* blocks every match regardless of estimate. */
|
|
145
|
+
maxTokens?: number;
|
|
146
|
+
/** Command patterns (regex source, `u` flag) and their estimated cost. */
|
|
147
|
+
patterns?: GuardsCostPattern[];
|
|
148
|
+
}
|
|
149
|
+
export interface GuardsCostPattern {
|
|
150
|
+
match: string;
|
|
151
|
+
tokens: number;
|
|
152
|
+
}
|
|
153
|
+
/** What the extension READS: every guard knob resolved. */
|
|
154
|
+
export interface GuardsConfig {
|
|
155
|
+
merge: boolean;
|
|
156
|
+
history: boolean;
|
|
157
|
+
ci: {
|
|
158
|
+
create: boolean;
|
|
159
|
+
run: boolean;
|
|
160
|
+
};
|
|
161
|
+
expensiveTests: {
|
|
162
|
+
maxTokens: number;
|
|
163
|
+
patterns: GuardsCostPattern[];
|
|
164
|
+
};
|
|
165
|
+
}
|
|
108
166
|
export interface ConfigIssue {
|
|
109
167
|
path: string;
|
|
110
168
|
message: string;
|
|
@@ -133,6 +191,8 @@ export interface EffectiveConfig {
|
|
|
133
191
|
profileFallback: ProfileFallbackConfig;
|
|
134
192
|
/** Feature 66: the memory-substrate consent surface. */
|
|
135
193
|
memory: MemoryConfig;
|
|
194
|
+
/** The effective tool-call guards (all off unless a config file enables them). */
|
|
195
|
+
guards: GuardsConfig;
|
|
136
196
|
/** Feature 63: declaredness of the implicit `default` profile. */
|
|
137
197
|
declared: {
|
|
138
198
|
default: RouteDeclaration;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CI guard — creating and triggering CI is owner-gated.
|
|
3
|
+
*
|
|
4
|
+
* Two routes, both config-gated (`guards.ci.create` / `guards.ci.run`; off by
|
|
5
|
+
* default for every project):
|
|
6
|
+
*
|
|
7
|
+
* create a write/edit that adds or changes a workflow definition — under
|
|
8
|
+
* `.github/workflows/`, `.gitea/workflows/`, `.forgejo/workflows/`,
|
|
9
|
+
* or a `.gitlab-ci.yml` — is blocked. A workflow file is code the
|
|
10
|
+
* forge runs on the next push; writing one is arming it.
|
|
11
|
+
* run `gh workflow run`, `gh run rerun`, and the `dispatches` REST
|
|
12
|
+
* endpoint are blocked: triggering CI consumes the repo's minutes
|
|
13
|
+
* and runs its automation without the owner watching.
|
|
14
|
+
*
|
|
15
|
+
* The only way through is an explicit owner instruction in the same turn,
|
|
16
|
+
* carried as the command's first word: `AW_ALLOW=ci`. Reads (`gh workflow
|
|
17
|
+
* list`, `gh run list`, `gh run view`) are not gated.
|
|
18
|
+
*/
|
|
19
|
+
import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
|
|
20
|
+
export declare const CI_CREATE_REASON: string;
|
|
21
|
+
export declare const CI_RUN_REASON: string;
|
|
22
|
+
/**
|
|
23
|
+
* The `tool_call` gate. `create` judges write/edit targets, `run` judges bash
|
|
24
|
+
* commands; both are no-ops while disabled. Reads pass.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ciGuard({ toolName, command, path }: ToolCallLike, policy?: {
|
|
27
|
+
create: boolean;
|
|
28
|
+
run: boolean;
|
|
29
|
+
}): GuardDecision;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CI guard — creating and triggering CI is owner-gated.
|
|
3
|
+
*
|
|
4
|
+
* Two routes, both config-gated (`guards.ci.create` / `guards.ci.run`; off by
|
|
5
|
+
* default for every project):
|
|
6
|
+
*
|
|
7
|
+
* create a write/edit that adds or changes a workflow definition — under
|
|
8
|
+
* `.github/workflows/`, `.gitea/workflows/`, `.forgejo/workflows/`,
|
|
9
|
+
* or a `.gitlab-ci.yml` — is blocked. A workflow file is code the
|
|
10
|
+
* forge runs on the next push; writing one is arming it.
|
|
11
|
+
* run `gh workflow run`, `gh run rerun`, and the `dispatches` REST
|
|
12
|
+
* endpoint are blocked: triggering CI consumes the repo's minutes
|
|
13
|
+
* and runs its automation without the owner watching.
|
|
14
|
+
*
|
|
15
|
+
* The only way through is an explicit owner instruction in the same turn,
|
|
16
|
+
* carried as the command's first word: `AW_ALLOW=ci`. Reads (`gh workflow
|
|
17
|
+
* list`, `gh run list`, `gh run view`) are not gated.
|
|
18
|
+
*/
|
|
19
|
+
import { explicitAllow } from "./guard-util.js";
|
|
20
|
+
const WORKFLOW_PATH_RE = /(?:^|\/)\.(?:github|gitea|forgejo)\/workflows\/|(?:^|\/)\.gitlab-ci\.yml$/u;
|
|
21
|
+
const CI_RUN_RE = /\bgh\s+(?:workflow\s+run|run\s+rerun)\b|actions\/workflows\/[^/\s"']+\/dispatches\b/u;
|
|
22
|
+
export const CI_CREATE_REASON = "Creating CI is owner-gated (ci guard): a workflow definition is code the forge runs on the next push, so an agent session does not write one unannounced. " +
|
|
23
|
+
"Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=ci`.";
|
|
24
|
+
export const CI_RUN_REASON = "Running CI is owner-gated (ci guard): a workflow run consumes the repository's minutes and fires its automation, so an agent session does not trigger one unannounced. " +
|
|
25
|
+
"Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=ci`.";
|
|
26
|
+
/**
|
|
27
|
+
* The `tool_call` gate. `create` judges write/edit targets, `run` judges bash
|
|
28
|
+
* commands; both are no-ops while disabled. Reads pass.
|
|
29
|
+
*/
|
|
30
|
+
export function ciGuard({ toolName, command, path }, policy = { create: false, run: false }) {
|
|
31
|
+
if (policy.create && (toolName === "write" || toolName === "edit") && path !== undefined && WORKFLOW_PATH_RE.test(path)) {
|
|
32
|
+
if (!explicitAllow(command ?? "").has("ci"))
|
|
33
|
+
return { block: true, reason: CI_CREATE_REASON };
|
|
34
|
+
}
|
|
35
|
+
if (policy.run && toolName === "bash" && command !== undefined && !explicitAllow(command).has("ci") && CI_RUN_RE.test(command)) {
|
|
36
|
+
return { block: true, reason: CI_RUN_REASON };
|
|
37
|
+
}
|
|
38
|
+
return { block: false };
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost guard — tests estimated above the token budget are owner-gated.
|
|
3
|
+
*
|
|
4
|
+
* Operators declare command patterns and their estimated cost in tokens
|
|
5
|
+
* (`guards.expensiveTests.patterns`), plus a ceiling (`maxTokens`, `0` = every
|
|
6
|
+
* match is gated). A bash command matching a pattern whose estimate exceeds the
|
|
7
|
+
* ceiling is blocked — an LLM-driven suite or a mutation run can burn a budget
|
|
8
|
+
* silently, and "it was just a test" is not authorization to spend it.
|
|
9
|
+
*
|
|
10
|
+
* The estimate is exactly what the word says: a number the operator wrote next
|
|
11
|
+
* to the pattern, not a measurement. It answers "may this run unattended?",
|
|
12
|
+
* never "how much did it cost?".
|
|
13
|
+
*
|
|
14
|
+
* The only way through is an explicit owner instruction in the same turn,
|
|
15
|
+
* carried as the command's first word: `AW_ALLOW=cost`. Listing or reading
|
|
16
|
+
* tests is never gated.
|
|
17
|
+
*/
|
|
18
|
+
import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
|
|
19
|
+
import type { GuardsCostPattern } from "../config/types.js";
|
|
20
|
+
export declare const COST_REASON: string;
|
|
21
|
+
/** The estimated cost of one command, or null when it matches nothing. */
|
|
22
|
+
export declare function estimatedCost(command: string, patterns: GuardsCostPattern[]): number | null;
|
|
23
|
+
/**
|
|
24
|
+
* The `tool_call` gate, bash only. `policy.maxTokens` is the ceiling; `0`
|
|
25
|
+
* gates every match. The guard is a no-op while no patterns are configured.
|
|
26
|
+
*/
|
|
27
|
+
export declare function costGuard({ toolName, command }: ToolCallLike, policy?: {
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
patterns: GuardsCostPattern[];
|
|
30
|
+
}): GuardDecision;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost guard — tests estimated above the token budget are owner-gated.
|
|
3
|
+
*
|
|
4
|
+
* Operators declare command patterns and their estimated cost in tokens
|
|
5
|
+
* (`guards.expensiveTests.patterns`), plus a ceiling (`maxTokens`, `0` = every
|
|
6
|
+
* match is gated). A bash command matching a pattern whose estimate exceeds the
|
|
7
|
+
* ceiling is blocked — an LLM-driven suite or a mutation run can burn a budget
|
|
8
|
+
* silently, and "it was just a test" is not authorization to spend it.
|
|
9
|
+
*
|
|
10
|
+
* The estimate is exactly what the word says: a number the operator wrote next
|
|
11
|
+
* to the pattern, not a measurement. It answers "may this run unattended?",
|
|
12
|
+
* never "how much did it cost?".
|
|
13
|
+
*
|
|
14
|
+
* The only way through is an explicit owner instruction in the same turn,
|
|
15
|
+
* carried as the command's first word: `AW_ALLOW=cost`. Listing or reading
|
|
16
|
+
* tests is never gated.
|
|
17
|
+
*/
|
|
18
|
+
import { explicitAllow } from "./guard-util.js";
|
|
19
|
+
export const COST_REASON = "Running this is owner-gated (cost guard): its estimated token cost exceeds the configured budget, and a test is not authorization to spend it. " +
|
|
20
|
+
"Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=cost`.";
|
|
21
|
+
/** The estimated cost of one command, or null when it matches nothing. */
|
|
22
|
+
export function estimatedCost(command, patterns) {
|
|
23
|
+
let cost = null;
|
|
24
|
+
for (const pattern of patterns) {
|
|
25
|
+
let re;
|
|
26
|
+
try {
|
|
27
|
+
re = new RegExp(pattern.match, "u");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue; // Invalid patterns are rejected at config load; never trust twice.
|
|
31
|
+
}
|
|
32
|
+
if (re.test(command) && (cost === null || pattern.tokens > cost))
|
|
33
|
+
cost = pattern.tokens;
|
|
34
|
+
}
|
|
35
|
+
return cost;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The `tool_call` gate, bash only. `policy.maxTokens` is the ceiling; `0`
|
|
39
|
+
* gates every match. The guard is a no-op while no patterns are configured.
|
|
40
|
+
*/
|
|
41
|
+
export function costGuard({ toolName, command }, policy = { maxTokens: 0, patterns: [] }) {
|
|
42
|
+
if (toolName !== "bash" || command === undefined || policy.patterns.length === 0)
|
|
43
|
+
return { block: false };
|
|
44
|
+
if (explicitAllow(command).has("cost"))
|
|
45
|
+
return { block: false };
|
|
46
|
+
const cost = estimatedCost(command, policy.patterns);
|
|
47
|
+
if (cost === null || cost <= policy.maxTokens)
|
|
48
|
+
return { block: false };
|
|
49
|
+
return { block: true, reason: `${COST_REASON} (estimated: ${cost} tokens, budget: ${policy.maxTokens})` };
|
|
50
|
+
}
|
|
@@ -3,6 +3,9 @@ import type { Catalogue } from "../routing/catalogue.js";
|
|
|
3
3
|
import type { Router } from "../routing/dispatch.js";
|
|
4
4
|
import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
|
|
5
5
|
import { mergeGuard } from "./merge-guard.js";
|
|
6
|
+
import { historyGuard } from "./history-guard.js";
|
|
7
|
+
import { ciGuard } from "./ci-guard.js";
|
|
8
|
+
import { costGuard } from "./cost-guard.js";
|
|
6
9
|
import type { ExtensionSurface, InvocationContext, ModelRef, RoutingControls } from "../routing/types.js";
|
|
7
10
|
import type { HintStore } from "../routing/state.js";
|
|
8
11
|
/**
|
|
@@ -55,14 +58,18 @@ export interface ExtensionHandle<M extends ModelRef = ModelRef> {
|
|
|
55
58
|
catalogue: Catalogue;
|
|
56
59
|
/**
|
|
57
60
|
* The Pi-free predicates the adapter (`index.ts`) binds to Pi's lifecycle
|
|
58
|
-
* events: the inline-receipt block guard, the
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* events: the inline-receipt block guard, the owner-gated tool guards (merge,
|
|
62
|
+
* history, CI, cost — all config-gated, all off unless a project enables
|
|
63
|
+
* them), and the dirty-worktree notice. They travel with the handle so the
|
|
64
|
+
* entry stays a thin translation layer and the decisions are unit-tested with
|
|
65
|
+
* no session (issues #182, #278).
|
|
62
66
|
*/
|
|
63
67
|
guards: {
|
|
64
68
|
receiptGuard: typeof receiptGuard;
|
|
65
69
|
mergeGuard: typeof mergeGuard;
|
|
70
|
+
historyGuard: typeof historyGuard;
|
|
71
|
+
ciGuard: typeof ciGuard;
|
|
72
|
+
costGuard: typeof costGuard;
|
|
66
73
|
dirtyWorktreeWarning: typeof dirtyWorktreeWarning;
|
|
67
74
|
};
|
|
68
75
|
}
|
|
@@ -3,6 +3,9 @@ import { readCatalogue } from "../routing/catalogue.js";
|
|
|
3
3
|
import { createRouter } from "../routing/dispatch.js";
|
|
4
4
|
import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
|
|
5
5
|
import { mergeGuard } from "./merge-guard.js";
|
|
6
|
+
import { historyGuard } from "./history-guard.js";
|
|
7
|
+
import { ciGuard } from "./ci-guard.js";
|
|
8
|
+
import { costGuard } from "./cost-guard.js";
|
|
6
9
|
import { ADVANCE_COMMAND, SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
|
|
7
10
|
import { registerAdvanceCommand } from "./conductor-command.js";
|
|
8
11
|
import { createProfileStateStore, stateFilePath } from "../routing/state.js";
|
|
@@ -79,5 +82,5 @@ export function createExtension(deps) {
|
|
|
79
82
|
description: "Show and configure per-command model routing",
|
|
80
83
|
handler: settingsHandler,
|
|
81
84
|
});
|
|
82
|
-
return { router, catalogue, guards: { receiptGuard, mergeGuard, dirtyWorktreeWarning } };
|
|
85
|
+
return { router, catalogue, guards: { receiptGuard, mergeGuard, historyGuard, ciGuard, costGuard, dirtyWorktreeWarning } };
|
|
83
86
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the pure `tool_call` guards (`merge-guard`,
|
|
3
|
+
* `history-guard`, `ci-guard`, `cost-guard`): the input shape, the
|
|
4
|
+
* explicit-authorization token, and command segmentation.
|
|
5
|
+
*
|
|
6
|
+
* Pure on purpose: no session, no fs, no Pi — unit-tested with no session,
|
|
7
|
+
* exactly like the guards themselves.
|
|
8
|
+
*/
|
|
9
|
+
/** The narrow slice of a Pi `tool_call` the guards read. */
|
|
10
|
+
export interface ToolCallLike {
|
|
11
|
+
toolName: string;
|
|
12
|
+
command?: string | undefined;
|
|
13
|
+
path?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
/** The Pi `tool_call` result shape: `block` plus an actionable `reason`. */
|
|
16
|
+
export interface GuardDecision {
|
|
17
|
+
block: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function explicitAllow(text: string): Set<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Split a command into shell segments so a flag is never attributed across
|
|
23
|
+
* commands: `docker build -f Dockerfile . && git push origin feat/x` must read
|
|
24
|
+
* as a docker flag plus a clean push, not as a force push.
|
|
25
|
+
*/
|
|
26
|
+
export declare function splitSegments(text: string): string[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the pure `tool_call` guards (`merge-guard`,
|
|
3
|
+
* `history-guard`, `ci-guard`, `cost-guard`): the input shape, the
|
|
4
|
+
* explicit-authorization token, and command segmentation.
|
|
5
|
+
*
|
|
6
|
+
* Pure on purpose: no session, no fs, no Pi — unit-tested with no session,
|
|
7
|
+
* exactly like the guards themselves.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The explicit-authorization tokens, one per guard capability:
|
|
11
|
+
*
|
|
12
|
+
* AW_ALLOW=merge owner-only merges ("Sí, mergea la PR", "Mergea rama B en rama A")
|
|
13
|
+
* AW_ALLOW=force a forced/deleting push ("Sí, usa force") — never a push to `main`
|
|
14
|
+
* AW_ALLOW=ci creating or triggering CI workflows
|
|
15
|
+
* AW_ALLOW=cost running a test estimated above the token budget
|
|
16
|
+
*
|
|
17
|
+
* Only an owner instruction in the same turn lets a guard through, and the
|
|
18
|
+
* authorization must travel with the command it enables: the token is the
|
|
19
|
+
* command's first word (`AW_ALLOW=merge git merge ...`) or follows `env `
|
|
20
|
+
* (`env AW_ALLOW=ci gh workflow run x`). Anywhere else — mid-command, in a
|
|
21
|
+
* search string, in a chained command, in an export — it authorizes nothing,
|
|
22
|
+
* so a command cannot smuggle the token for a later or different action.
|
|
23
|
+
*/
|
|
24
|
+
const AW_ALLOW_RE = /(?:^|env\s+)AW_ALLOW=([a-z]+(?:,[a-z]+)*)/u;
|
|
25
|
+
export function explicitAllow(text) {
|
|
26
|
+
return new Set(AW_ALLOW_RE.exec(text ?? "")?.[1]?.split(",") ?? []);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Split a command into shell segments so a flag is never attributed across
|
|
30
|
+
* commands: `docker build -f Dockerfile . && git push origin feat/x` must read
|
|
31
|
+
* as a docker flag plus a clean push, not as a force push.
|
|
32
|
+
*/
|
|
33
|
+
export function splitSegments(text) {
|
|
34
|
+
return String(text ?? "").split(/[;|&\n]/u);
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The history guard — published history is owner-written, decided by git
|
|
3
|
+
* itself (`git push --dry-run`), not by enumerating spellings.
|
|
4
|
+
*
|
|
5
|
+
* AGENTS.md → Working rules states the way this repository integrates work:
|
|
6
|
+
* short branches off `main`, `main` only advances through a PR, and when `main`
|
|
7
|
+
* moves the conflicted branch is handed back for the owner to integrate. A
|
|
8
|
+
* local `git rebase` is a private rewrite; the moment it is pushed it rewrites
|
|
9
|
+
* commits a reviewer, another session or a CI artifact may already be building
|
|
10
|
+
* on, so the block sits where the rewrite becomes public — on `git push`.
|
|
11
|
+
*
|
|
12
|
+
* The outcome, not the spelling, is what is judged. Every direct `git push`
|
|
13
|
+
* runs first as `git push --dry-run` (the same arguments, plus `--dry-run` —
|
|
14
|
+
* git resolves flags and refspecs itself), and the printed outcome decides: a
|
|
15
|
+
* `(forced update)`, a `[deleted]` ref, or a destination of `main` declines
|
|
16
|
+
* the push. One mechanism covers every force flag spelling and `+`/`:ref`
|
|
17
|
+
* refspec, because the answer comes from git, not from this file. When the
|
|
18
|
+
* dry-run cannot run (offline — where a real push would fail anyway), a simple
|
|
19
|
+
* text check on the push's own segment is the fallback.
|
|
20
|
+
*
|
|
21
|
+
* The guard is config-gated (`guards.history`): off by default for every
|
|
22
|
+
* project, enabled where an operator asks for it. The only way through is an
|
|
23
|
+
* explicit owner instruction in the same turn, carried as the command's first
|
|
24
|
+
* word: `AW_ALLOW=force` — which never enables a push to `main`, because main
|
|
25
|
+
* advances only through a PR.
|
|
26
|
+
*
|
|
27
|
+
* What is deliberately out of scope: payloads hidden inside scripts or
|
|
28
|
+
* interpreter one-liners that never spell `git push` in the command text, and
|
|
29
|
+
* a git alias invoked as `git fp` — the dry-run sees what git is told to push,
|
|
30
|
+
* not what an alias named `fp` expands to. The dry-run sees what git sees; it
|
|
31
|
+
* cannot see what a script builds at runtime, and no text predicate honestly
|
|
32
|
+
* can. The residual risk is documented, not chased with a regex zoo.
|
|
33
|
+
*/
|
|
34
|
+
import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
|
|
35
|
+
/** The outcome a dry-run can report for one push. */
|
|
36
|
+
export type PushOutcome = "clean" | "forced" | "deleted" | "main" | "unknown";
|
|
37
|
+
export interface PushRunnerResult {
|
|
38
|
+
status: number;
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
}
|
|
42
|
+
export type PushRunner = (args: string[]) => PushRunnerResult;
|
|
43
|
+
/** The default runner: git in the session's working directory. */
|
|
44
|
+
export declare function spawnPushRunner(cwd: string): PushRunner;
|
|
45
|
+
/** Shell-tokenize the arguments of one push segment (quotes stripped). */
|
|
46
|
+
export declare function pushArgs(segment: string): string[];
|
|
47
|
+
/** Read the outcome git printed for one dry-run. */
|
|
48
|
+
export declare function readPushOutcome(result: PushRunnerResult): PushOutcome;
|
|
49
|
+
/**
|
|
50
|
+
* The `tool_call` gate. `enabled` comes from the config (`guards.history`);
|
|
51
|
+
* the guard is a no-op while disabled. `runner`/`cwd` are injectable so the
|
|
52
|
+
* suite never touches the network.
|
|
53
|
+
*/
|
|
54
|
+
export declare function historyGuard({ toolName, command }: ToolCallLike, enabled?: boolean, { runner, cwd, offlineFallback }?: {
|
|
55
|
+
runner?: PushRunner;
|
|
56
|
+
cwd?: string;
|
|
57
|
+
offlineFallback?: (segment: string) => PushOutcome;
|
|
58
|
+
}): GuardDecision;
|
|
59
|
+
export declare const HISTORY_REASON: string;
|