@gtrabanco/pi-agentic-workflow 0.11.1 → 0.12.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 +26 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +3 -1
- package/dist/config/merge.js +2 -0
- package/dist/config/path-policy.d.ts +97 -0
- package/dist/config/path-policy.js +301 -0
- package/dist/config/schema.js +65 -2
- package/dist/config/types.d.ts +5 -0
- package/dist/extension/index.js +147 -5
- package/package.json +1 -1
- package/skills/design-feature/SKILL.md +1 -1
- package/skills/design-feature/references/REPAIR.md +12 -10
- package/skills/execute-phase/SKILL.md +1 -1
- package/skills/execute-phase/references/PREFLIGHT.md +28 -0
- package/skills/fold-findings/SKILL.md +17 -7
- package/skills/fold-findings/references/FOLD_PROCESS.md +10 -4
- package/skills/init-workspace/SKILL.md +1 -1
- package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +7 -1
- package/skills/init-workspace/references/UPGRADE.md +7 -2
- package/skills/orchestration-envelope/SKILL.md +1 -1
- package/skills/orchestration-envelope/references/TURN_CONTRACT.md +52 -0
- package/skills/plan-feature-scaffold/SKILL.md +1 -1
- package/skills/plan-feature-scaffold/references/SCAFFOLD_PROCESS.md +8 -0
- package/skills/pre-execution-review/SKILL.md +1 -1
- package/skills/pre-execution-review/references/LEDGERS.md +8 -4
- package/skills/pre-execution-review/references/POLICY.md +40 -14
- package/skills/review-plan/SKILL.md +1 -1
- package/skills/review-plan/references/CHECKS.md +5 -2
- package/skills/review-plan/references/OUTPUT.md +6 -6
- package/skills/review-spec/SKILL.md +1 -1
- package/skills/review-spec/references/CHECKS.md +4 -1
- package/skills/review-spec/references/OUTPUT.md +9 -5
- package/skills/verification-contract/SKILL.md +7 -1
package/README.md
CHANGED
|
@@ -103,6 +103,32 @@ The first workflow command you run after install says once that routing is
|
|
|
103
103
|
configurable, then never again. That acknowledgement is stored in
|
|
104
104
|
`~/.pi/agent/pi-agentic-workflow-state.json`, not in your config.
|
|
105
105
|
|
|
106
|
+
## Path protection (optional)
|
|
107
|
+
|
|
108
|
+
The Tier 2 preventive guard blocks a `write` / `edit` tool call to an existing
|
|
109
|
+
protected path with no matching justification record. Its policy is the shipped
|
|
110
|
+
`path-protection-policy@1` default, tightened by an optional `pathProtection`
|
|
111
|
+
key in the same config files:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"pathProtection": {
|
|
116
|
+
"protectedGlobs": ["secrets/**"],
|
|
117
|
+
"requirements": { "post-freeze": { "modify": "approval" } }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The override is **tighten-only**: `protectedGlobs` are unioned with the shipped
|
|
123
|
+
globs, and each `requirements` entry takes the stricter of the two — so a removal
|
|
124
|
+
or a lowering is ignored (the shipped protection stays in force) and reported
|
|
125
|
+
once per session as a `pi-agentic-workflow: path protection — <code>: <detail>`
|
|
126
|
+
notification. `requirements` is keyed by phase state (`pre-freeze`, `post-freeze`,
|
|
127
|
+
`always`) and operation (`create`, `modify`, `delete`, `rename`), with values
|
|
128
|
+
`none` < `justification` < `approval`. To allow a protected edit, record the
|
|
129
|
+
matching `path-protection-records@1` `justification` row in the unit's
|
|
130
|
+
`decisions.md`.
|
|
131
|
+
|
|
106
132
|
## When a configured model is unavailable
|
|
107
133
|
|
|
108
134
|
Default: the command **refuses to start** and tells you why — the model is not in
|
package/dist/config/defaults.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SHIPPED_PATH_POLICY, intersectPathPolicy } from "./path-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* The in-package default (SPEC S6, D-P6): a fresh install with no config file
|
|
3
4
|
* anywhere routes every command on the session model, and the fail-closed
|
|
@@ -9,4 +10,5 @@ export const DEFAULT_CONFIG = {
|
|
|
9
10
|
commands: {},
|
|
10
11
|
onUnavailableRoute: "stop",
|
|
11
12
|
onSettle: "keep",
|
|
13
|
+
pathProtection: intersectPathPolicy(SHIPPED_PATH_POLICY, {}),
|
|
12
14
|
};
|
package/dist/config/load.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PathPolicyDegradation } from "./path-policy.js";
|
|
1
2
|
import type { ConfigPaths, ConfigProblem, EffectiveConfig } from "./types.js";
|
|
2
3
|
export declare function configFilePaths(agentDir: string, cwd: string): ConfigPaths;
|
|
3
4
|
export interface LoadConfigInput {
|
|
@@ -15,5 +16,7 @@ export interface LoadedConfig {
|
|
|
15
16
|
/** The merged configuration; the shipped default whenever `ok` is false. */
|
|
16
17
|
config: EffectiveConfig;
|
|
17
18
|
problems: ConfigProblem[];
|
|
19
|
+
/** The effective policy's tighten-only degradations (feature 60); empty when clean. */
|
|
20
|
+
pathProtectionDegradations: PathPolicyDegradation[];
|
|
18
21
|
}
|
|
19
22
|
export declare function loadConfig({ agentDir, cwd, projectTrusted, readFile }: LoadConfigInput): LoadedConfig;
|
package/dist/config/load.js
CHANGED
|
@@ -66,11 +66,13 @@ export function loadConfig({ agentDir, cwd, projectTrusted, readFile = readIfExi
|
|
|
66
66
|
const problems = [];
|
|
67
67
|
const globalFile = loadScope("global", paths.global, readFile, problems);
|
|
68
68
|
const projectFile = projectTrusted ? loadScope("project", paths.project, readFile, problems) : {};
|
|
69
|
+
const config = problems.length === 0 ? mergeConfigs(globalFile, projectFile) : DEFAULT_CONFIG;
|
|
69
70
|
return {
|
|
70
71
|
ok: problems.length === 0,
|
|
71
72
|
// Fail closed: a broken file hands the caller the unrouted default and the
|
|
72
73
|
// problems that explain why dispatch is refused.
|
|
73
|
-
config
|
|
74
|
+
config,
|
|
74
75
|
problems,
|
|
76
|
+
pathProtectionDegradations: config.pathProtection.degradations,
|
|
75
77
|
};
|
|
76
78
|
}
|
package/dist/config/merge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG, DEFAULT_ROUTE } from "./defaults.js";
|
|
2
|
+
import { SHIPPED_PATH_POLICY, intersectPathPolicy, mergePathProtectionOverrides } from "./path-policy.js";
|
|
2
3
|
/**
|
|
3
4
|
* Project-over-global merge (SPEC S5, S6).
|
|
4
5
|
*
|
|
@@ -36,6 +37,7 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
|
|
|
36
37
|
commands,
|
|
37
38
|
onUnavailableRoute: projectFile.onUnavailableRoute ?? globalFile.onUnavailableRoute ?? DEFAULT_CONFIG.onUnavailableRoute,
|
|
38
39
|
onSettle: projectFile.onSettle ?? globalFile.onSettle ?? DEFAULT_CONFIG.onSettle,
|
|
40
|
+
pathProtection: intersectPathPolicy(SHIPPED_PATH_POLICY, mergePathProtectionOverrides(globalFile.pathProtection, projectFile.pathProtection)),
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
/** The route a command runs under: its own resolved override, else the default route. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-side path-protection policy: the shipped-default mirror (E-60-1), the
|
|
3
|
+
* tighten-only resolver (E-60-7), the record reader, and the pure Tier 2 tool
|
|
4
|
+
* call decision (E-60-5).
|
|
5
|
+
*
|
|
6
|
+
* The canonical shipped defaults live in the producer crate
|
|
7
|
+
* (`packages/agentic-workflow/src/path-policy.mjs`); this file embeds a copy
|
|
8
|
+
* whose parity is pinned by `test/path-protection.test.mjs`. The guard never
|
|
9
|
+
* weakens the shipped floor: an override may only union globs and raise a
|
|
10
|
+
* requirement, and a removal or a lowering is ignored and reported.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PATH_POLICY_SCHEMA = "path-protection-policy@1";
|
|
13
|
+
export declare const RECORDS_SCHEMA = "path-protection-records@1";
|
|
14
|
+
/** Mirror of the crate's glob-length bound: a longer `protectedGlobs` entry is refused (F29). */
|
|
15
|
+
export declare const PATH_GLOB_MAX_LENGTH = 512;
|
|
16
|
+
export type PathRequirement = "none" | "justification" | "approval";
|
|
17
|
+
export type PathOperation = "create" | "modify" | "delete" | "rename";
|
|
18
|
+
export type PathPhaseState = "pre-freeze" | "post-freeze" | "always";
|
|
19
|
+
export declare const REQUIREMENTS: readonly PathRequirement[];
|
|
20
|
+
export declare const OPERATIONS: readonly PathOperation[];
|
|
21
|
+
export declare const PHASE_STATES: readonly PathPhaseState[];
|
|
22
|
+
export interface PathPolicyClass {
|
|
23
|
+
globs: string[];
|
|
24
|
+
freeze: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type PathPolicyMatrix = Record<PathPhaseState, Record<PathOperation, PathRequirement>>;
|
|
27
|
+
export interface PathPolicy {
|
|
28
|
+
schema: string;
|
|
29
|
+
classes: Record<string, PathPolicyClass>;
|
|
30
|
+
matrix: PathPolicyMatrix;
|
|
31
|
+
}
|
|
32
|
+
export interface PathProtectionOverride {
|
|
33
|
+
protectedGlobs?: string[];
|
|
34
|
+
requirements?: Partial<Record<PathPhaseState, Partial<Record<PathOperation, PathRequirement>>>>;
|
|
35
|
+
}
|
|
36
|
+
export type DegradationCode = "missing-config" | "malformed-config" | "ignored-removal" | "ignored-lowering";
|
|
37
|
+
export interface PathPolicyDegradation {
|
|
38
|
+
code: DegradationCode;
|
|
39
|
+
detail: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ResolvedPathPolicy extends PathPolicy {
|
|
42
|
+
degradations: PathPolicyDegradation[];
|
|
43
|
+
}
|
|
44
|
+
/** The shipped-default mirror, byte-for-byte the crate's projection. */
|
|
45
|
+
export declare const SHIPPED_PATH_POLICY: PathPolicy;
|
|
46
|
+
/** Convert a glob to an anchored regexp. `**` crosses `/`, `*`/`?` do not. */
|
|
47
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
48
|
+
/**
|
|
49
|
+
* Linear-time glob match with the same semantics as `globToRegExp` (`**` crosses
|
|
50
|
+
* `/`; `*` and `?` do not). Memoised so a star-chain glob cannot trigger the
|
|
51
|
+
* catastrophic backtracking the RegExp form suffers (F20).
|
|
52
|
+
*/
|
|
53
|
+
export declare function matchGlob(glob: string, target: string): boolean;
|
|
54
|
+
export declare function pathMatchesGlob(target: string, glob: string): boolean;
|
|
55
|
+
export declare function isProtectedPath(policy: PathPolicy, target: string): boolean;
|
|
56
|
+
/** Merge two owner overrides before intersecting: globs union, requirements max. */
|
|
57
|
+
export declare function mergePathProtectionOverrides(base: PathProtectionOverride | undefined, next: PathProtectionOverride | undefined): PathProtectionOverride;
|
|
58
|
+
/**
|
|
59
|
+
* Tighten-only resolution (E-60-7). The shipped floor is always kept: globs
|
|
60
|
+
* union, requirements take the maximum, and a removal or a lowering appends a
|
|
61
|
+
* degradation record. When the override declares `protectedGlobs`, any shipped
|
|
62
|
+
* glob it omits is a rejected removal and is reported too.
|
|
63
|
+
*/
|
|
64
|
+
export declare function intersectPathPolicy(shipped: PathPolicy, override: PathProtectionOverride | undefined): ResolvedPathPolicy;
|
|
65
|
+
/**
|
|
66
|
+
* Collapse `.`/`..` segments and normalise separators so equivalent spellings
|
|
67
|
+
* (`./tests/x`, `sub/../tests/x`) match the same glob. A leading `..` is kept:
|
|
68
|
+
* the caller refuses an out-of-root result before matching (E-60-5).
|
|
69
|
+
*/
|
|
70
|
+
export declare function normalizeTarget(target: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Read the `path-protection-records@1` fenced blocks and report whether a
|
|
73
|
+
* `justification` row (authority `execute-phase`) matches `target`. A missing
|
|
74
|
+
* marker permits nothing; a malformed row voids only its own block. Every
|
|
75
|
+
* block is consulted, because the guard reads the ledgers of every unit.
|
|
76
|
+
*/
|
|
77
|
+
export declare function matchingJustification(target: string, recordsText: string): boolean;
|
|
78
|
+
export type ToolCallGuardResult = {
|
|
79
|
+
block: false;
|
|
80
|
+
} | {
|
|
81
|
+
block: true;
|
|
82
|
+
reason: string;
|
|
83
|
+
};
|
|
84
|
+
export interface ToolCallGuardInput {
|
|
85
|
+
toolName: string;
|
|
86
|
+
targetPath: string;
|
|
87
|
+
/** A new-file create is authoring, not modification, so it passes (E-60-5). */
|
|
88
|
+
targetExists: boolean;
|
|
89
|
+
policy: PathPolicy;
|
|
90
|
+
recordsText: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The pure Tier 2 decision: block a `write` / `edit` call to an existing
|
|
94
|
+
* protected path with no matching justification record; pass reads and
|
|
95
|
+
* new-file creates. The reason names the escape path.
|
|
96
|
+
*/
|
|
97
|
+
export declare function evaluateToolCall(input: ToolCallGuardInput): ToolCallGuardResult;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-side path-protection policy: the shipped-default mirror (E-60-1), the
|
|
3
|
+
* tighten-only resolver (E-60-7), the record reader, and the pure Tier 2 tool
|
|
4
|
+
* call decision (E-60-5).
|
|
5
|
+
*
|
|
6
|
+
* The canonical shipped defaults live in the producer crate
|
|
7
|
+
* (`packages/agentic-workflow/src/path-policy.mjs`); this file embeds a copy
|
|
8
|
+
* whose parity is pinned by `test/path-protection.test.mjs`. The guard never
|
|
9
|
+
* weakens the shipped floor: an override may only union globs and raise a
|
|
10
|
+
* requirement, and a removal or a lowering is ignored and reported.
|
|
11
|
+
*/
|
|
12
|
+
export const PATH_POLICY_SCHEMA = "path-protection-policy@1";
|
|
13
|
+
export const RECORDS_SCHEMA = "path-protection-records@1";
|
|
14
|
+
/** Mirror of the crate's glob-length bound: a longer `protectedGlobs` entry is refused (F29). */
|
|
15
|
+
export const PATH_GLOB_MAX_LENGTH = 512;
|
|
16
|
+
export const REQUIREMENTS = ["none", "justification", "approval"];
|
|
17
|
+
export const OPERATIONS = ["create", "modify", "delete", "rename"];
|
|
18
|
+
export const PHASE_STATES = ["pre-freeze", "post-freeze", "always"];
|
|
19
|
+
/** The shipped-default mirror, byte-for-byte the crate's projection. */
|
|
20
|
+
export const SHIPPED_PATH_POLICY = {
|
|
21
|
+
schema: PATH_POLICY_SCHEMA,
|
|
22
|
+
classes: {
|
|
23
|
+
tests: { globs: ["tests/**"], freeze: true },
|
|
24
|
+
e2e: { globs: ["e2e/**"], freeze: true },
|
|
25
|
+
"test-file": { globs: ["**/*.test.*"], freeze: true },
|
|
26
|
+
fixtures: { globs: ["fixtures/**", "**/fixtures/**"], freeze: true },
|
|
27
|
+
"policy-config": { globs: [".agentic-workflow/path-policy.json"], freeze: false },
|
|
28
|
+
},
|
|
29
|
+
matrix: {
|
|
30
|
+
"pre-freeze": { create: "none", modify: "justification", delete: "justification", rename: "justification" },
|
|
31
|
+
"post-freeze": { create: "justification", modify: "approval", delete: "approval", rename: "approval" },
|
|
32
|
+
always: { create: "approval", modify: "approval", delete: "approval", rename: "approval" },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
function rank(requirement) {
|
|
36
|
+
return REQUIREMENTS.indexOf(requirement);
|
|
37
|
+
}
|
|
38
|
+
/** Convert a glob to an anchored regexp. `**` crosses `/`, `*`/`?` do not. */
|
|
39
|
+
export function globToRegExp(glob) {
|
|
40
|
+
let out = "";
|
|
41
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
42
|
+
const char = glob[index];
|
|
43
|
+
if (char === "*") {
|
|
44
|
+
if (glob[index + 1] === "*") {
|
|
45
|
+
index += 1;
|
|
46
|
+
if (glob[index + 1] === "/") {
|
|
47
|
+
index += 1;
|
|
48
|
+
out += "(?:.*/)?";
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
out += ".*";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
out += "[^/]*";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (char === "?") {
|
|
59
|
+
out += "[^/]";
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return new RegExp(`^${out}$`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Linear-time glob match with the same semantics as `globToRegExp` (`**` crosses
|
|
69
|
+
* `/`; `*` and `?` do not). Memoised so a star-chain glob cannot trigger the
|
|
70
|
+
* catastrophic backtracking the RegExp form suffers (F20).
|
|
71
|
+
*/
|
|
72
|
+
export function matchGlob(glob, target) {
|
|
73
|
+
const glen = glob.length;
|
|
74
|
+
const plen = target.length;
|
|
75
|
+
const memo = new Int8Array((glen + 1) * (plen + 1)).fill(-1);
|
|
76
|
+
const at = (gi, pi) => {
|
|
77
|
+
if (gi === glen)
|
|
78
|
+
return pi === plen;
|
|
79
|
+
const key = gi * (plen + 1) + pi;
|
|
80
|
+
const cached = memo[key];
|
|
81
|
+
if (cached !== -1)
|
|
82
|
+
return cached === 1;
|
|
83
|
+
let result = false;
|
|
84
|
+
const char = glob[gi];
|
|
85
|
+
if (char === "?") {
|
|
86
|
+
result = pi < plen && target[pi] !== "/" && at(gi + 1, pi + 1);
|
|
87
|
+
}
|
|
88
|
+
else if (char === "*") {
|
|
89
|
+
if (glob[gi + 1] === "*") {
|
|
90
|
+
const after = gi + 2;
|
|
91
|
+
if (glob[after] === "/") {
|
|
92
|
+
// `**/` matches zero or more characters ending in `/`.
|
|
93
|
+
result = at(after + 1, pi);
|
|
94
|
+
for (let index = pi; index < plen && !result; index += 1) {
|
|
95
|
+
if (target[index] === "/")
|
|
96
|
+
result = at(after + 1, index + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// `**` matches any sequence, crossing `/`.
|
|
101
|
+
result = at(after, pi);
|
|
102
|
+
for (let index = pi; index < plen && !result; index += 1) {
|
|
103
|
+
result = at(after, index + 1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// `*` matches any sequence within a path segment.
|
|
109
|
+
result = at(gi + 1, pi);
|
|
110
|
+
for (let index = pi; index < plen && !result && target[index] !== "/"; index += 1) {
|
|
111
|
+
result = at(gi + 1, index + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
result = pi < plen && target[pi] === char && at(gi + 1, pi + 1);
|
|
117
|
+
}
|
|
118
|
+
memo[key] = result ? 1 : 0;
|
|
119
|
+
return result;
|
|
120
|
+
};
|
|
121
|
+
return at(0, 0);
|
|
122
|
+
}
|
|
123
|
+
export function pathMatchesGlob(target, glob) {
|
|
124
|
+
return matchGlob(glob, target);
|
|
125
|
+
}
|
|
126
|
+
export function isProtectedPath(policy, target) {
|
|
127
|
+
return Object.values(policy.classes).some((cls) => cls.globs.some((glob) => pathMatchesGlob(target, glob)));
|
|
128
|
+
}
|
|
129
|
+
/** Merge two owner overrides before intersecting: globs union, requirements max. */
|
|
130
|
+
export function mergePathProtectionOverrides(base, next) {
|
|
131
|
+
const result = {};
|
|
132
|
+
if (base?.protectedGlobs !== undefined || next?.protectedGlobs !== undefined) {
|
|
133
|
+
result.protectedGlobs = [...new Set([...(base?.protectedGlobs ?? []), ...(next?.protectedGlobs ?? [])])];
|
|
134
|
+
}
|
|
135
|
+
const requirements = {};
|
|
136
|
+
for (const state of PHASE_STATES) {
|
|
137
|
+
const row = {};
|
|
138
|
+
for (const operation of OPERATIONS) {
|
|
139
|
+
const a = base?.requirements?.[state]?.[operation];
|
|
140
|
+
const b = next?.requirements?.[state]?.[operation];
|
|
141
|
+
const best = a === undefined ? b : b === undefined ? a : rank(a) >= rank(b) ? a : b;
|
|
142
|
+
if (best !== undefined)
|
|
143
|
+
row[operation] = best;
|
|
144
|
+
}
|
|
145
|
+
if (Object.keys(row).length > 0)
|
|
146
|
+
requirements[state] = row;
|
|
147
|
+
}
|
|
148
|
+
if (Object.keys(requirements).length > 0)
|
|
149
|
+
result.requirements = requirements;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Tighten-only resolution (E-60-7). The shipped floor is always kept: globs
|
|
154
|
+
* union, requirements take the maximum, and a removal or a lowering appends a
|
|
155
|
+
* degradation record. When the override declares `protectedGlobs`, any shipped
|
|
156
|
+
* glob it omits is a rejected removal and is reported too.
|
|
157
|
+
*/
|
|
158
|
+
export function intersectPathPolicy(shipped, override) {
|
|
159
|
+
const degradations = [];
|
|
160
|
+
const classes = {};
|
|
161
|
+
for (const [name, cls] of Object.entries(shipped.classes)) {
|
|
162
|
+
classes[name] = { globs: [...cls.globs], freeze: cls.freeze };
|
|
163
|
+
}
|
|
164
|
+
if (override?.protectedGlobs !== undefined) {
|
|
165
|
+
for (const glob of override.protectedGlobs) {
|
|
166
|
+
if (!isProtectedGlob(classes, glob)) {
|
|
167
|
+
classes[`override:${glob}`] = { globs: [glob], freeze: true };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const cls of Object.values(shipped.classes)) {
|
|
171
|
+
for (const glob of cls.globs) {
|
|
172
|
+
if (!override.protectedGlobs.includes(glob)) {
|
|
173
|
+
degradations.push({ code: "ignored-removal", detail: `the override omitted the shipped glob "${glob}"; kept` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const matrix = {};
|
|
179
|
+
for (const state of PHASE_STATES) {
|
|
180
|
+
const row = {};
|
|
181
|
+
for (const operation of OPERATIONS) {
|
|
182
|
+
const floor = shipped.matrix[state][operation];
|
|
183
|
+
const wanted = override?.requirements?.[state]?.[operation];
|
|
184
|
+
if (wanted === undefined || rank(wanted) >= rank(floor)) {
|
|
185
|
+
row[operation] = wanted ?? floor;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
degradations.push({
|
|
189
|
+
code: "ignored-lowering",
|
|
190
|
+
detail: `matrix "${state}.${operation}" lowered to "${wanted}"; kept "${floor}"`,
|
|
191
|
+
});
|
|
192
|
+
row[operation] = floor;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
matrix[state] = row;
|
|
196
|
+
}
|
|
197
|
+
return { schema: PATH_POLICY_SCHEMA, classes, matrix, degradations };
|
|
198
|
+
}
|
|
199
|
+
function isProtectedGlob(classes, glob) {
|
|
200
|
+
return Object.values(classes).some((cls) => cls.globs.includes(glob));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Collapse `.`/`..` segments and normalise separators so equivalent spellings
|
|
204
|
+
* (`./tests/x`, `sub/../tests/x`) match the same glob. A leading `..` is kept:
|
|
205
|
+
* the caller refuses an out-of-root result before matching (E-60-5).
|
|
206
|
+
*/
|
|
207
|
+
export function normalizeTarget(target) {
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const part of target.replace(/\\/g, "/").split("/")) {
|
|
210
|
+
if (part === "" || part === ".")
|
|
211
|
+
continue;
|
|
212
|
+
if (part === "..") {
|
|
213
|
+
if (out.length > 0 && out[out.length - 1] !== "..")
|
|
214
|
+
out.pop();
|
|
215
|
+
else
|
|
216
|
+
out.push("..");
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
out.push(part);
|
|
220
|
+
}
|
|
221
|
+
return out.join("/");
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Read the `path-protection-records@1` fenced blocks and report whether a
|
|
225
|
+
* `justification` row (authority `execute-phase`) matches `target`. A missing
|
|
226
|
+
* marker permits nothing; a malformed row voids only its own block. Every
|
|
227
|
+
* block is consulted, because the guard reads the ledgers of every unit.
|
|
228
|
+
*/
|
|
229
|
+
export function matchingJustification(target, recordsText) {
|
|
230
|
+
const normalized = normalizeTarget(target);
|
|
231
|
+
for (const block of recordsBlocks(recordsText)) {
|
|
232
|
+
let headerSeen = false;
|
|
233
|
+
for (const line of block) {
|
|
234
|
+
if (line === "kind | paths | phase | date | authority | justification") {
|
|
235
|
+
headerSeen = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!headerSeen)
|
|
239
|
+
continue;
|
|
240
|
+
const cells = line.split("|").map((cell) => cell.trim());
|
|
241
|
+
if (cells.length !== 6)
|
|
242
|
+
break;
|
|
243
|
+
const [kind, pathsCell, phase, date, authority, justification] = cells;
|
|
244
|
+
if (kind !== "justification" || authority !== "execute-phase")
|
|
245
|
+
continue;
|
|
246
|
+
if (!/^P\d+$/.test(phase) || !/^\d{4}-\d{2}-\d{2}$/.test(date) || justification === "")
|
|
247
|
+
continue;
|
|
248
|
+
const paths = pathsCell.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
249
|
+
if (paths.some((glob) => pathMatchesGlob(normalized, glob)))
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
/** Every marker-delimited block's non-empty lines, in file order. */
|
|
256
|
+
function recordsBlocks(text) {
|
|
257
|
+
const lines = text.split(/\r?\n/);
|
|
258
|
+
const blocks = [];
|
|
259
|
+
let index = 0;
|
|
260
|
+
while (index < lines.length) {
|
|
261
|
+
if (lines[index].trim() !== RECORDS_SCHEMA) {
|
|
262
|
+
index += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const body = [];
|
|
266
|
+
index += 1;
|
|
267
|
+
while (index < lines.length && !lines[index].trim().startsWith("```")) {
|
|
268
|
+
const trimmed = lines[index].trim();
|
|
269
|
+
if (trimmed !== "")
|
|
270
|
+
body.push(trimmed);
|
|
271
|
+
index += 1;
|
|
272
|
+
}
|
|
273
|
+
blocks.push(body);
|
|
274
|
+
index += 1;
|
|
275
|
+
}
|
|
276
|
+
return blocks;
|
|
277
|
+
}
|
|
278
|
+
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
279
|
+
/**
|
|
280
|
+
* The pure Tier 2 decision: block a `write` / `edit` call to an existing
|
|
281
|
+
* protected path with no matching justification record; pass reads and
|
|
282
|
+
* new-file creates. The reason names the escape path.
|
|
283
|
+
*/
|
|
284
|
+
export function evaluateToolCall(input) {
|
|
285
|
+
if (!WRITE_TOOLS.has(input.toolName))
|
|
286
|
+
return { block: false };
|
|
287
|
+
if (!input.targetExists)
|
|
288
|
+
return { block: false };
|
|
289
|
+
const target = normalizeTarget(input.targetPath);
|
|
290
|
+
if (!isProtectedPath(input.policy, target))
|
|
291
|
+
return { block: false };
|
|
292
|
+
if (matchingJustification(target, input.recordsText))
|
|
293
|
+
return { block: false };
|
|
294
|
+
return {
|
|
295
|
+
block: true,
|
|
296
|
+
reason: `"${target}" is a protected path (path-protection policy) and no matching ` +
|
|
297
|
+
`justification record exists. Record a "${RECORDS_SCHEMA}" row ` +
|
|
298
|
+
`\`justification | ${target} | <P<n>> | <YYYY-MM-DD> | execute-phase | <reason>\` in the unit's ` +
|
|
299
|
+
`decisions.md, and for a post-freeze change ask the owner for the recorded approval the checkpoint gate requires.`,
|
|
300
|
+
};
|
|
301
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
|
|
2
|
+
import { OPERATIONS, PATH_GLOB_MAX_LENGTH, PHASE_STATES, REQUIREMENTS } from "./path-policy.js";
|
|
2
3
|
/**
|
|
3
4
|
* Strict validator for one config file (SPEC S5-S8, D-E5).
|
|
4
5
|
*
|
|
@@ -8,7 +9,7 @@ import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_PO
|
|
|
8
9
|
* strong model ran when it did not. Only a *missing* file resolves to the
|
|
9
10
|
* default, and that decision belongs to the loader.
|
|
10
11
|
*/
|
|
11
|
-
const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute", "onSettle"]);
|
|
12
|
+
const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute", "onSettle", "pathProtection"]);
|
|
12
13
|
const ROUTE_KEYS = new Set(["model", "thinking"]);
|
|
13
14
|
const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
14
15
|
function isRecord(value) {
|
|
@@ -40,6 +41,63 @@ function isUnavailableRoutePolicy(value) {
|
|
|
40
41
|
function isSettlePolicy(value) {
|
|
41
42
|
return SETTLE_POLICIES.includes(value);
|
|
42
43
|
}
|
|
44
|
+
/** Strict validator for the optional `pathProtection` override (feature 60, AC10). */
|
|
45
|
+
function checkPathProtection(value, path, issues) {
|
|
46
|
+
if (!isRecord(value)) {
|
|
47
|
+
issues.push({ path, message: "must be an object with optional protectedGlobs/requirements keys" });
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const override = {};
|
|
51
|
+
for (const key of Object.keys(value)) {
|
|
52
|
+
if (key !== "protectedGlobs" && key !== "requirements") {
|
|
53
|
+
issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown pathProtection key "${key}" (allowed: protectedGlobs, requirements)` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (value.protectedGlobs !== undefined) {
|
|
57
|
+
if (!Array.isArray(value.protectedGlobs) || value.protectedGlobs.some((glob) => typeof glob !== "string" || glob.trim() === "")) {
|
|
58
|
+
issues.push({ path: `${path}.protectedGlobs`, message: "must be an array of non-empty glob strings" });
|
|
59
|
+
}
|
|
60
|
+
else if (value.protectedGlobs.some((glob) => glob.length > PATH_GLOB_MAX_LENGTH)) {
|
|
61
|
+
issues.push({ path: `${path}.protectedGlobs`, message: `a glob must be at most ${PATH_GLOB_MAX_LENGTH} characters` });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
override.protectedGlobs = value.protectedGlobs;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (value.requirements !== undefined) {
|
|
68
|
+
if (!isRecord(value.requirements)) {
|
|
69
|
+
issues.push({ path: `${path}.requirements`, message: "must be an object keyed by phase state" });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const requirements = {};
|
|
73
|
+
for (const [state, raw] of Object.entries(value.requirements)) {
|
|
74
|
+
if (!PHASE_STATES.includes(state)) {
|
|
75
|
+
issues.push({ path: `${path}.requirements.${displayKey(state)}`, message: `unknown phase state "${state}"` });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!isRecord(raw)) {
|
|
79
|
+
issues.push({ path: `${path}.requirements.${displayKey(state)}`, message: "must be an object keyed by operation" });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const row = {};
|
|
83
|
+
for (const [operation, requirement] of Object.entries(raw)) {
|
|
84
|
+
if (!OPERATIONS.includes(operation)) {
|
|
85
|
+
issues.push({ path: `${path}.requirements.${displayKey(state)}.${displayKey(operation)}`, message: `unknown operation "${operation}"` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!REQUIREMENTS.includes(requirement)) {
|
|
89
|
+
issues.push({ path: `${path}.requirements.${displayKey(state)}.${displayKey(operation)}`, message: `must be one of ${REQUIREMENTS.join(", ")}` });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
row[operation] = requirement;
|
|
93
|
+
}
|
|
94
|
+
requirements[state] = row;
|
|
95
|
+
}
|
|
96
|
+
override.requirements = requirements;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return override;
|
|
100
|
+
}
|
|
43
101
|
function describe(value) {
|
|
44
102
|
return typeof value === "string" ? `"${value}"` : JSON.stringify(value) ?? String(value);
|
|
45
103
|
}
|
|
@@ -118,7 +176,7 @@ function validateConfig(value) {
|
|
|
118
176
|
if (!ROOT_KEYS.has(key)) {
|
|
119
177
|
issues.push({
|
|
120
178
|
path: `$.${displayKey(key)}`,
|
|
121
|
-
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle)`,
|
|
179
|
+
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection)`,
|
|
122
180
|
});
|
|
123
181
|
}
|
|
124
182
|
}
|
|
@@ -167,6 +225,11 @@ function validateConfig(value) {
|
|
|
167
225
|
config.onSettle = value.onSettle;
|
|
168
226
|
}
|
|
169
227
|
}
|
|
228
|
+
if (value.pathProtection !== undefined) {
|
|
229
|
+
const override = checkPathProtection(value.pathProtection, "$.pathProtection", issues);
|
|
230
|
+
if (override)
|
|
231
|
+
config.pathProtection = override;
|
|
232
|
+
}
|
|
170
233
|
return issues.length > 0 ? { ok: false, issues } : { ok: true, config };
|
|
171
234
|
}
|
|
172
235
|
/**
|
package/dist/config/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - `EffectiveConfig` is what the extension READS after merge: every route
|
|
8
8
|
* fully resolved, so no downstream code has to reason about optionality.
|
|
9
9
|
*/
|
|
10
|
+
import type { PathProtectionOverride, ResolvedPathPolicy } from "./path-policy.js";
|
|
10
11
|
/** Pi's thinking levels, mirrored here so the domain layer stays Pi-free. */
|
|
11
12
|
export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
12
13
|
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
@@ -39,6 +40,8 @@ export interface ConfigFile {
|
|
|
39
40
|
onUnavailableRoute?: UnavailableRoutePolicy;
|
|
40
41
|
/** What happens to the routed model and thinking level after the command settles. */
|
|
41
42
|
onSettle?: SettlePolicy;
|
|
43
|
+
/** The tighten-only path-protection override (feature 60, AC10). */
|
|
44
|
+
pathProtection?: PathProtectionOverride;
|
|
42
45
|
}
|
|
43
46
|
export type UnavailableRoutePolicy = "stop" | "inherit";
|
|
44
47
|
/**
|
|
@@ -60,6 +63,8 @@ export interface EffectiveConfig {
|
|
|
60
63
|
commands: Record<string, Route>;
|
|
61
64
|
onUnavailableRoute: UnavailableRoutePolicy;
|
|
62
65
|
onSettle: SettlePolicy;
|
|
66
|
+
/** The effective path-protection policy: the shipped floor tightened by any override. */
|
|
67
|
+
pathProtection: ResolvedPathPolicy;
|
|
63
68
|
}
|
|
64
69
|
export interface ConfigProblem {
|
|
65
70
|
scope: "global" | "project";
|