@openshain/core 0.2.0 → 0.4.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/NOTICE +4 -0
- package/dist/authority/policy.d.ts +131 -0
- package/dist/authority/policy.js +335 -0
- package/dist/config/load.js +4 -41
- package/dist/config/schema.d.ts +12 -9
- package/dist/config/schema.js +14 -10
- package/dist/config/yaml.d.ts +12 -0
- package/dist/config/yaml.js +49 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/runtime.d.ts +28 -3
- package/dist/runtime.js +148 -6
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -0
- package/dist/tool/ask-user.d.ts +5 -0
- package/dist/tool/ask-user.js +22 -0
- package/dist/tool/paths.d.ts +1 -1
- package/dist/tool/paths.js +1 -1
- package/dist/tool/types.js +6 -0
- package/dist/work/events.d.ts +150 -1
- package/dist/work/events.js +146 -0
- package/dist/work/history.d.ts +57 -0
- package/dist/work/history.js +81 -0
- package/dist/work/projection.js +21 -6
- package/package.json +3 -2
- package/src/authority/policy.ts +400 -0
- package/src/config/load.ts +4 -43
- package/src/config/schema.ts +41 -31
- package/src/config/yaml.ts +60 -0
- package/src/index.ts +43 -1
- package/src/runtime.ts +189 -10
- package/src/schemas.ts +17 -1
- package/src/tool/ask-user.ts +25 -0
- package/src/tool/paths.ts +1 -1
- package/src/tool/types.ts +6 -0
- package/src/work/events.ts +192 -0
- package/src/work/history.ts +121 -0
- package/src/work/projection.ts +21 -6
package/NOTICE
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Files under authority/ the runtime reads. */
|
|
3
|
+
export declare const AUTHORITY_DIR_NAME = "authority";
|
|
4
|
+
export declare const POLICY_FILE_NAME = "policy.yaml";
|
|
5
|
+
export declare const DELEGATIONS_FILE_NAME = "delegations.yaml";
|
|
6
|
+
export declare const DECISIONS_DIR_NAME = "decisions";
|
|
7
|
+
export declare const DECISION_KINDS: readonly ["allow", "approval_required", "review_required", "deny", "decision_backed"];
|
|
8
|
+
export type DecisionKind = (typeof DECISION_KINDS)[number];
|
|
9
|
+
export declare const PolicyFileSchema: z.ZodObject<{
|
|
10
|
+
version: z.ZodLiteral<1>;
|
|
11
|
+
default: z.ZodDefault<z.ZodEnum<{
|
|
12
|
+
allow: "allow";
|
|
13
|
+
approval_required: "approval_required";
|
|
14
|
+
decision_backed: "decision_backed";
|
|
15
|
+
deny: "deny";
|
|
16
|
+
review_required: "review_required";
|
|
17
|
+
}>>;
|
|
18
|
+
rules: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
19
|
+
id: z.ZodString;
|
|
20
|
+
match: z.ZodObject<{
|
|
21
|
+
tool: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
22
|
+
effect: z.ZodOptional<z.ZodEnum<{
|
|
23
|
+
mutate: "mutate";
|
|
24
|
+
observe: "observe";
|
|
25
|
+
}>>;
|
|
26
|
+
path: z.ZodOptional<z.ZodString>;
|
|
27
|
+
principal: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
28
|
+
work_type: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
29
|
+
action: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
30
|
+
}, z.core.$strict>;
|
|
31
|
+
decision: z.ZodEnum<{
|
|
32
|
+
allow: "allow";
|
|
33
|
+
approval_required: "approval_required";
|
|
34
|
+
decision_backed: "decision_backed";
|
|
35
|
+
deny: "deny";
|
|
36
|
+
review_required: "review_required";
|
|
37
|
+
}>;
|
|
38
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
39
|
+
approvers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
40
|
+
reviewer: z.ZodOptional<z.ZodObject<{
|
|
41
|
+
role: z.ZodString;
|
|
42
|
+
name: z.ZodOptional<z.ZodString>;
|
|
43
|
+
}, z.core.$strict>>;
|
|
44
|
+
decision_id: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strict>>>;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
export declare const DelegationsFileSchema: z.ZodObject<{
|
|
48
|
+
version: z.ZodLiteral<1>;
|
|
49
|
+
delegations: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
50
|
+
principal: z.ZodString;
|
|
51
|
+
profession: z.ZodString;
|
|
52
|
+
valid_from: z.ZodOptional<z.ZodString>;
|
|
53
|
+
valid_until: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
}, z.core.$strict>>>;
|
|
55
|
+
}, z.core.$strict>;
|
|
56
|
+
/** What a reviewer decided, written to authority/decisions/ and cited by a decision_backed rule. */
|
|
57
|
+
export declare const DecisionFileSchema: z.ZodObject<{
|
|
58
|
+
id: z.ZodString;
|
|
59
|
+
reviewer: z.ZodObject<{
|
|
60
|
+
name: z.ZodString;
|
|
61
|
+
role: z.ZodString;
|
|
62
|
+
qualification: z.ZodOptional<z.ZodString>;
|
|
63
|
+
}, z.core.$strict>;
|
|
64
|
+
approval_id: z.ZodString;
|
|
65
|
+
decided_at: z.ZodISODateTime;
|
|
66
|
+
effective_from: z.ZodString;
|
|
67
|
+
effective_until: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
68
|
+
interpretation: z.ZodString;
|
|
69
|
+
applies_to: z.ZodDefault<z.ZodObject<{
|
|
70
|
+
action: z.ZodOptional<z.ZodString>;
|
|
71
|
+
path: z.ZodOptional<z.ZodString>;
|
|
72
|
+
}, z.core.$strict>>;
|
|
73
|
+
}, z.core.$strict>;
|
|
74
|
+
export type DecisionRecord = z.output<typeof DecisionFileSchema>;
|
|
75
|
+
export type PolicyFile = z.output<typeof PolicyFileSchema>;
|
|
76
|
+
export type Rule = PolicyFile["rules"][number];
|
|
77
|
+
export type Delegation = z.output<typeof DelegationsFileSchema>["delegations"][number];
|
|
78
|
+
/** What the runtime knows about who may do what in a workspace. */
|
|
79
|
+
export interface Authority {
|
|
80
|
+
/** False when the workspace has no authority/ directory: everything is allowed, as before. */
|
|
81
|
+
present: boolean;
|
|
82
|
+
policy: PolicyFile;
|
|
83
|
+
delegations: Delegation[];
|
|
84
|
+
/** The reviewers' decisions, by id. A decision_backed rule cites one. */
|
|
85
|
+
decisions: Map<string, DecisionRecord>;
|
|
86
|
+
}
|
|
87
|
+
/** One tool call, as the policy sees it. */
|
|
88
|
+
export interface AuthorityRequest {
|
|
89
|
+
tool: string;
|
|
90
|
+
effect: "observe" | "mutate";
|
|
91
|
+
/** The path the call names, normalized and relative to the workspace, if it names one. */
|
|
92
|
+
path?: string;
|
|
93
|
+
principal: string;
|
|
94
|
+
profession: string;
|
|
95
|
+
workType: string;
|
|
96
|
+
/** A name a pack or the policy gives the action. This version uses the tool's name. */
|
|
97
|
+
action?: string;
|
|
98
|
+
/** Today's business date (YYYY-MM-DD), for the delegation's validity. */
|
|
99
|
+
businessDate: string;
|
|
100
|
+
}
|
|
101
|
+
export type Decision = {
|
|
102
|
+
kind: "allow";
|
|
103
|
+
rule?: Rule;
|
|
104
|
+
decision?: DecisionRecord;
|
|
105
|
+
} | {
|
|
106
|
+
kind: "deny";
|
|
107
|
+
rule?: Rule;
|
|
108
|
+
reason: string;
|
|
109
|
+
} | {
|
|
110
|
+
kind: "approval_required" | "review_required";
|
|
111
|
+
rule: Rule;
|
|
112
|
+
why?: string;
|
|
113
|
+
};
|
|
114
|
+
/** An authority that allows everything: what a workspace without authority/ gets. */
|
|
115
|
+
export declare const OPEN_AUTHORITY: Authority;
|
|
116
|
+
/** Reads authority/ of a workspace. A workspace without it is open, as every workspace was before. */
|
|
117
|
+
export declare function loadAuthority(workspaceRoot: string): Promise<Authority>;
|
|
118
|
+
/** Writes one decision under authority/decisions/. The runtime owns that directory. */
|
|
119
|
+
export declare function writeDecision(workspaceRoot: string, decision: DecisionRecord): Promise<string>;
|
|
120
|
+
/**
|
|
121
|
+
* Judges one call. Ordinary code: the first rule whose every condition holds decides, else the
|
|
122
|
+
* policy's default. Without a delegation for the principal and the profession, everything is
|
|
123
|
+
* denied. A workspace without authority/ allows everything and needs no delegation.
|
|
124
|
+
*/
|
|
125
|
+
export declare function evaluate(authority: Authority, request: AuthorityRequest): Decision;
|
|
126
|
+
/**
|
|
127
|
+
* Matches a workspace-relative path against a glob: `*` stands for part of one segment, `**`
|
|
128
|
+
* for any number of whole segments. No other syntax. `ledger/**` matches everything under
|
|
129
|
+
* ledger/, `*.csv` a CSV at the root, `**\/*.csv` a CSV anywhere.
|
|
130
|
+
*/
|
|
131
|
+
export declare function matchGlob(pattern: string, path: string): boolean;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { parseYamlFile } from "../config/yaml.js";
|
|
5
|
+
import { OpenshainError } from "../errors.js";
|
|
6
|
+
/** Files under authority/ the runtime reads. */
|
|
7
|
+
export const AUTHORITY_DIR_NAME = "authority";
|
|
8
|
+
export const POLICY_FILE_NAME = "policy.yaml";
|
|
9
|
+
export const DELEGATIONS_FILE_NAME = "delegations.yaml";
|
|
10
|
+
export const DECISIONS_DIR_NAME = "decisions";
|
|
11
|
+
export const DECISION_KINDS = [
|
|
12
|
+
"allow",
|
|
13
|
+
"approval_required",
|
|
14
|
+
"review_required",
|
|
15
|
+
"deny",
|
|
16
|
+
"decision_backed",
|
|
17
|
+
];
|
|
18
|
+
const identifier = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
19
|
+
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
20
|
+
const oneOrMany = z.union([z.string().min(1).max(200), z.array(z.string().min(1).max(200)).min(1)]);
|
|
21
|
+
const MatchSchema = z
|
|
22
|
+
.strictObject({
|
|
23
|
+
tool: oneOrMany.optional(),
|
|
24
|
+
effect: z.enum(["observe", "mutate"]).optional(),
|
|
25
|
+
path: z.string().min(1).max(1000).optional(),
|
|
26
|
+
principal: oneOrMany.optional(),
|
|
27
|
+
work_type: oneOrMany.optional(),
|
|
28
|
+
action: oneOrMany.optional(),
|
|
29
|
+
})
|
|
30
|
+
.refine((m) => Object.values(m).some((v) => v !== undefined), "a rule must match on something");
|
|
31
|
+
const RuleSchema = z
|
|
32
|
+
.strictObject({
|
|
33
|
+
id: identifier.max(100),
|
|
34
|
+
match: MatchSchema,
|
|
35
|
+
decision: z.enum(DECISION_KINDS),
|
|
36
|
+
reason: z.string().max(2000).optional(),
|
|
37
|
+
approvers: z.array(identifier).min(1).optional(),
|
|
38
|
+
reviewer: z.strictObject({ role: identifier, name: z.string().max(200).optional() }).optional(),
|
|
39
|
+
decision_id: z.string().min(1).max(200).optional(),
|
|
40
|
+
})
|
|
41
|
+
.refine((r) => r.decision !== "decision_backed" || r.decision_id !== undefined, "decision_backed needs decision_id");
|
|
42
|
+
export const PolicyFileSchema = z.strictObject({
|
|
43
|
+
version: z.literal(1),
|
|
44
|
+
default: z.enum(DECISION_KINDS).default("allow"),
|
|
45
|
+
rules: z.array(RuleSchema).default([]),
|
|
46
|
+
});
|
|
47
|
+
export const DelegationsFileSchema = z.strictObject({
|
|
48
|
+
version: z.literal(1),
|
|
49
|
+
delegations: z
|
|
50
|
+
.array(z.strictObject({
|
|
51
|
+
principal: identifier,
|
|
52
|
+
profession: identifier,
|
|
53
|
+
valid_from: isoDate.optional(),
|
|
54
|
+
valid_until: isoDate.nullable().optional(),
|
|
55
|
+
}))
|
|
56
|
+
.default([]),
|
|
57
|
+
});
|
|
58
|
+
/** What a reviewer decided, written to authority/decisions/ and cited by a decision_backed rule. */
|
|
59
|
+
export const DecisionFileSchema = z.strictObject({
|
|
60
|
+
// One path segment: the id becomes the file name under authority/decisions/.
|
|
61
|
+
id: z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(200)
|
|
65
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "a decision id is letters, digits, dot, dash and underscore"),
|
|
66
|
+
reviewer: z.strictObject({
|
|
67
|
+
name: z.string().min(1).max(200),
|
|
68
|
+
role: identifier,
|
|
69
|
+
/** As the company states it. openshain does not verify a qualification. */
|
|
70
|
+
qualification: z.string().max(500).optional(),
|
|
71
|
+
}),
|
|
72
|
+
approval_id: z.string().min(1).max(200),
|
|
73
|
+
decided_at: z.iso.datetime(),
|
|
74
|
+
effective_from: isoDate,
|
|
75
|
+
effective_until: isoDate.nullable().default(null),
|
|
76
|
+
interpretation: z.string().min(1).max(100_000),
|
|
77
|
+
applies_to: z
|
|
78
|
+
.strictObject({ action: z.string().max(200).optional(), path: z.string().max(1000).optional() })
|
|
79
|
+
.default({}),
|
|
80
|
+
});
|
|
81
|
+
/** An authority that allows everything: what a workspace without authority/ gets. */
|
|
82
|
+
export const OPEN_AUTHORITY = Object.freeze({
|
|
83
|
+
present: false,
|
|
84
|
+
policy: { version: 1, default: "allow", rules: [] },
|
|
85
|
+
delegations: [],
|
|
86
|
+
decisions: new Map(),
|
|
87
|
+
});
|
|
88
|
+
/** Reads authority/ of a workspace. A workspace without it is open, as every workspace was before. */
|
|
89
|
+
export async function loadAuthority(workspaceRoot) {
|
|
90
|
+
const dir = join(workspaceRoot, AUTHORITY_DIR_NAME);
|
|
91
|
+
try {
|
|
92
|
+
if (!(await stat(dir)).isDirectory())
|
|
93
|
+
return OPEN_AUTHORITY;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return OPEN_AUTHORITY;
|
|
97
|
+
}
|
|
98
|
+
const policy = await readOptional(join(dir, POLICY_FILE_NAME));
|
|
99
|
+
const delegations = await readOptional(join(dir, DELEGATIONS_FILE_NAME));
|
|
100
|
+
return {
|
|
101
|
+
present: true,
|
|
102
|
+
decisions: await readDecisions(join(dir, DECISIONS_DIR_NAME)),
|
|
103
|
+
policy: policy === undefined
|
|
104
|
+
? { version: 1, default: "allow", rules: [] }
|
|
105
|
+
: parseYamlFile(policy, PolicyFileSchema, `${AUTHORITY_DIR_NAME}/${POLICY_FILE_NAME}`).data,
|
|
106
|
+
delegations: delegations === undefined
|
|
107
|
+
? []
|
|
108
|
+
: parseYamlFile(delegations, DelegationsFileSchema, `${AUTHORITY_DIR_NAME}/${DELEGATIONS_FILE_NAME}`).data.delegations,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Every decision under authority/decisions/, by id. A file that cannot be read is a config error. */
|
|
112
|
+
async function readDecisions(dir) {
|
|
113
|
+
let names;
|
|
114
|
+
try {
|
|
115
|
+
names = (await readdir(dir)).filter((name) => name.endsWith(".yaml"));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return new Map();
|
|
119
|
+
}
|
|
120
|
+
const decisions = new Map();
|
|
121
|
+
for (const name of names.sort()) {
|
|
122
|
+
const text = await readFile(join(dir, name), "utf8");
|
|
123
|
+
const { data } = parseYamlFile(text, DecisionFileSchema, `${AUTHORITY_DIR_NAME}/${DECISIONS_DIR_NAME}/${name}`);
|
|
124
|
+
decisions.set(data.id, data);
|
|
125
|
+
}
|
|
126
|
+
return decisions;
|
|
127
|
+
}
|
|
128
|
+
/** Writes one decision under authority/decisions/. The runtime owns that directory. */
|
|
129
|
+
export async function writeDecision(workspaceRoot, decision) {
|
|
130
|
+
// The id is checked again here: this function is public, and the id names a file.
|
|
131
|
+
const checked = DecisionFileSchema.parse(decision);
|
|
132
|
+
try {
|
|
133
|
+
if (!(await stat(join(workspaceRoot, AUTHORITY_DIR_NAME))).isDirectory())
|
|
134
|
+
throw new Error();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new OpenshainError("config", `this workspace has no ${AUTHORITY_DIR_NAME}/, so it has no policy to decide under`);
|
|
138
|
+
}
|
|
139
|
+
const dir = join(workspaceRoot, AUTHORITY_DIR_NAME, DECISIONS_DIR_NAME);
|
|
140
|
+
await mkdir(dir, { recursive: true });
|
|
141
|
+
const file = join(dir, `${checked.id}.yaml`);
|
|
142
|
+
await writeFile(file, toYaml(checked), { flag: "wx" });
|
|
143
|
+
return file;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A decision as YAML. Written by hand so that core keeps one YAML dependency, for reading.
|
|
147
|
+
* The interpretation is a block scalar without trailing blank lines, so that what is read back
|
|
148
|
+
* equals what was written.
|
|
149
|
+
*/
|
|
150
|
+
function toYaml(decision) {
|
|
151
|
+
const quote = (text) => JSON.stringify(text);
|
|
152
|
+
return `${[
|
|
153
|
+
`id: ${quote(decision.id)}`,
|
|
154
|
+
"reviewer:",
|
|
155
|
+
` name: ${quote(decision.reviewer.name)}`,
|
|
156
|
+
` role: ${decision.reviewer.role}`,
|
|
157
|
+
...(decision.reviewer.qualification !== undefined
|
|
158
|
+
? [` qualification: ${quote(decision.reviewer.qualification)}`]
|
|
159
|
+
: []),
|
|
160
|
+
`approval_id: ${quote(decision.approval_id)}`,
|
|
161
|
+
`decided_at: ${quote(decision.decided_at)}`,
|
|
162
|
+
`effective_from: ${quote(decision.effective_from)}`,
|
|
163
|
+
`effective_until: ${decision.effective_until === null ? "null" : quote(decision.effective_until)}`,
|
|
164
|
+
"interpretation: |-",
|
|
165
|
+
...decision.interpretation
|
|
166
|
+
.replace(/\n+$/, "")
|
|
167
|
+
.split("\n")
|
|
168
|
+
.map((line) => ` ${line}`),
|
|
169
|
+
// An empty applies_to is written inline: a bare key would read back as null, not as an object.
|
|
170
|
+
...(decision.applies_to.action === undefined && decision.applies_to.path === undefined
|
|
171
|
+
? ["applies_to: {}"]
|
|
172
|
+
: [
|
|
173
|
+
"applies_to:",
|
|
174
|
+
...(decision.applies_to.action !== undefined
|
|
175
|
+
? [` action: ${quote(decision.applies_to.action)}`]
|
|
176
|
+
: []),
|
|
177
|
+
...(decision.applies_to.path !== undefined
|
|
178
|
+
? [` path: ${quote(decision.applies_to.path)}`]
|
|
179
|
+
: []),
|
|
180
|
+
]),
|
|
181
|
+
].join("\n")}\n`;
|
|
182
|
+
}
|
|
183
|
+
async function readOptional(file) {
|
|
184
|
+
try {
|
|
185
|
+
return await readFile(file, "utf8");
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (err.code === "ENOENT")
|
|
189
|
+
return undefined;
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Judges one call. Ordinary code: the first rule whose every condition holds decides, else the
|
|
195
|
+
* policy's default. Without a delegation for the principal and the profession, everything is
|
|
196
|
+
* denied. A workspace without authority/ allows everything and needs no delegation.
|
|
197
|
+
*/
|
|
198
|
+
export function evaluate(authority, request) {
|
|
199
|
+
if (!authority.present)
|
|
200
|
+
return { kind: "allow" };
|
|
201
|
+
if (!delegated(authority.delegations, request)) {
|
|
202
|
+
return {
|
|
203
|
+
kind: "deny",
|
|
204
|
+
reason: `no delegation lets a ${request.profession} act for ${request.principal} on ${request.businessDate}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const rule = authority.policy.rules.find((r) => matches(r, request));
|
|
208
|
+
const kind = rule?.decision ?? authority.policy.default;
|
|
209
|
+
switch (kind) {
|
|
210
|
+
case "allow":
|
|
211
|
+
return rule ? { kind, rule } : { kind };
|
|
212
|
+
case "deny":
|
|
213
|
+
return {
|
|
214
|
+
kind,
|
|
215
|
+
...(rule && { rule }),
|
|
216
|
+
reason: rule?.reason ?? (rule ? `denied by rule ${rule.id}` : "denied by the policy's default"),
|
|
217
|
+
};
|
|
218
|
+
case "decision_backed": {
|
|
219
|
+
// The rule cites a reviewer's decision. Without a valid one that covers this call, the
|
|
220
|
+
// reviewer has to look at it again: the rule falls back to a review.
|
|
221
|
+
const named = rule ?? { id: "default", match: {}, decision: kind };
|
|
222
|
+
const decision = named.decision_id ? authority.decisions.get(named.decision_id) : undefined;
|
|
223
|
+
const why = !decision
|
|
224
|
+
? `rule ${named.id} cites decision ${named.decision_id}, which this workspace does not have`
|
|
225
|
+
: !inEffect(decision, request.businessDate)
|
|
226
|
+
? `decision ${decision.id} is not in effect on ${request.businessDate}`
|
|
227
|
+
: !covers(decision, request)
|
|
228
|
+
? `decision ${decision.id} does not cover this call`
|
|
229
|
+
: undefined;
|
|
230
|
+
if (decision && why === undefined)
|
|
231
|
+
return { kind: "allow", rule: named, decision };
|
|
232
|
+
return { kind: "review_required", rule: named, ...(why !== undefined && { why }) };
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
// approval_required and review_required need a rule to name approvers or a reviewer;
|
|
236
|
+
// a default of that kind is treated as a rule-less request.
|
|
237
|
+
return { kind, rule: rule ?? { id: "default", match: {}, decision: kind } };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Whether the business date falls in the decision's window. */
|
|
241
|
+
function inEffect(decision, businessDate) {
|
|
242
|
+
return (decision.effective_from <= businessDate &&
|
|
243
|
+
(decision.effective_until === null || businessDate <= decision.effective_until));
|
|
244
|
+
}
|
|
245
|
+
/** Whether the decision was written for this kind of call. An empty applies_to covers the rule. */
|
|
246
|
+
function covers(decision, request) {
|
|
247
|
+
const { action, path } = decision.applies_to;
|
|
248
|
+
if (action !== undefined && action !== (request.action ?? request.tool))
|
|
249
|
+
return false;
|
|
250
|
+
if (path !== undefined && (request.path === undefined || !matchGlob(path, request.path))) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
function delegated(delegations, request) {
|
|
256
|
+
return delegations.some((d) => d.principal === request.principal &&
|
|
257
|
+
d.profession === request.profession &&
|
|
258
|
+
(d.valid_from === undefined || d.valid_from <= request.businessDate) &&
|
|
259
|
+
(d.valid_until === undefined ||
|
|
260
|
+
d.valid_until === null ||
|
|
261
|
+
request.businessDate <= d.valid_until));
|
|
262
|
+
}
|
|
263
|
+
function matches(rule, request) {
|
|
264
|
+
const m = rule.match;
|
|
265
|
+
if (m.tool !== undefined && !oneOf(m.tool, request.tool))
|
|
266
|
+
return false;
|
|
267
|
+
if (m.effect !== undefined && m.effect !== request.effect)
|
|
268
|
+
return false;
|
|
269
|
+
if (m.principal !== undefined && !oneOf(m.principal, request.principal))
|
|
270
|
+
return false;
|
|
271
|
+
if (m.work_type !== undefined && !oneOf(m.work_type, request.workType))
|
|
272
|
+
return false;
|
|
273
|
+
if (m.action !== undefined && !oneOf(m.action, request.action ?? request.tool))
|
|
274
|
+
return false;
|
|
275
|
+
if (m.path !== undefined) {
|
|
276
|
+
if (request.path === undefined)
|
|
277
|
+
return false;
|
|
278
|
+
if (!matchGlob(m.path, request.path))
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
function oneOf(expected, actual) {
|
|
284
|
+
return Array.isArray(expected) ? expected.includes(actual) : expected === actual;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Matches a workspace-relative path against a glob: `*` stands for part of one segment, `**`
|
|
288
|
+
* for any number of whole segments. No other syntax. `ledger/**` matches everything under
|
|
289
|
+
* ledger/, `*.csv` a CSV at the root, `**\/*.csv` a CSV anywhere.
|
|
290
|
+
*/
|
|
291
|
+
export function matchGlob(pattern, path) {
|
|
292
|
+
// Repeated `**` means the same as one, and collapsing them keeps the match linear.
|
|
293
|
+
const parts = pattern.split("/").filter((part, i, all) => part !== "**" || all[i - 1] !== "**");
|
|
294
|
+
return matchSegments(parts, path.split("/"));
|
|
295
|
+
}
|
|
296
|
+
function matchSegments(pattern, path) {
|
|
297
|
+
if (pattern.length === 0)
|
|
298
|
+
return path.length === 0;
|
|
299
|
+
const [head, ...rest] = pattern;
|
|
300
|
+
if (head === "**") {
|
|
301
|
+
for (let i = 0; i <= path.length; i++) {
|
|
302
|
+
if (matchSegments(rest, path.slice(i)))
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (path.length === 0)
|
|
308
|
+
return false;
|
|
309
|
+
const [segment, ...remaining] = path;
|
|
310
|
+
return matchSegment(head, segment) && matchSegments(rest, remaining);
|
|
311
|
+
}
|
|
312
|
+
function matchSegment(pattern, segment) {
|
|
313
|
+
const parts = pattern.split("*");
|
|
314
|
+
if (parts.length === 1)
|
|
315
|
+
return pattern === segment;
|
|
316
|
+
let position = 0;
|
|
317
|
+
for (let i = 0; i < parts.length; i++) {
|
|
318
|
+
const part = parts[i] ?? "";
|
|
319
|
+
if (i === 0) {
|
|
320
|
+
if (!segment.startsWith(part))
|
|
321
|
+
return false;
|
|
322
|
+
position = part.length;
|
|
323
|
+
}
|
|
324
|
+
else if (i === parts.length - 1) {
|
|
325
|
+
return segment.slice(position).endsWith(part);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const found = segment.indexOf(part, position);
|
|
329
|
+
if (found === -1)
|
|
330
|
+
return false;
|
|
331
|
+
position = found + part.length;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
package/dist/config/load.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { isNode, LineCounter, parseDocument } from "yaml";
|
|
4
3
|
import { OpenshainError } from "../errors.js";
|
|
5
4
|
import { ConfigFileSchema, toConfig } from "./schema.js";
|
|
5
|
+
import { parseYamlFile } from "./yaml.js";
|
|
6
6
|
export const CONFIG_FILE_NAME = "openshain.yaml";
|
|
7
7
|
export async function loadConfig(workspaceRoot, options = {}) {
|
|
8
8
|
const fileName = join(workspaceRoot, CONFIG_FILE_NAME);
|
|
@@ -19,48 +19,11 @@ export async function loadConfig(workspaceRoot, options = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
export function parseConfig(text, options = {}) {
|
|
21
21
|
const fileName = options.fileName ?? CONFIG_FILE_NAME;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
let data;
|
|
25
|
-
try {
|
|
26
|
-
doc = parseDocument(text, { lineCounter });
|
|
27
|
-
data = doc.errors.length > 0 ? undefined : doc.toJS();
|
|
28
|
-
}
|
|
29
|
-
catch (cause) {
|
|
30
|
-
// yaml refuses resource-exhaustion documents (alias bombs) with a plain error
|
|
31
|
-
throw new OpenshainError("config", `${fileName}: ${cause.message}`, { cause });
|
|
32
|
-
}
|
|
33
|
-
if (doc.errors.length > 0) {
|
|
34
|
-
const lines = doc.errors.map((error) => {
|
|
35
|
-
const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
|
|
36
|
-
return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
|
|
37
|
-
});
|
|
38
|
-
throw new OpenshainError("config", lines.join("\n"));
|
|
39
|
-
}
|
|
40
|
-
const locate = (path) => {
|
|
41
|
-
for (let i = path.length; i >= 0; i--) {
|
|
42
|
-
const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
|
|
43
|
-
if (isNode(node) && node.range)
|
|
44
|
-
return lineCounter.linePos(node.range[0]);
|
|
45
|
-
}
|
|
46
|
-
return { line: 1, col: 1 };
|
|
47
|
-
};
|
|
48
|
-
const problem = (path, message) => {
|
|
49
|
-
const { line, col } = locate(path);
|
|
50
|
-
const where = path.length === 0 ? "<root>" : path.map(String).join(".");
|
|
51
|
-
return `${fileName}:${line}:${col} ${where}: ${message}`;
|
|
52
|
-
};
|
|
53
|
-
const result = ConfigFileSchema.safeParse(data);
|
|
54
|
-
if (!result.success) {
|
|
55
|
-
const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
|
|
56
|
-
throw new OpenshainError("config", problems.join("\n"));
|
|
57
|
-
}
|
|
22
|
+
const { data, problem } = parseYamlFile(text, ConfigFileSchema, fileName);
|
|
23
|
+
const result = { data };
|
|
58
24
|
const known = options.modelProviders;
|
|
59
|
-
if (known && !known.includes(result.data.model.provider)) {
|
|
25
|
+
if (known && result.data.model && !known.includes(result.data.model.provider)) {
|
|
60
26
|
throw new OpenshainError("config", problem(["model", "provider"], `unknown provider "${result.data.model.provider}"; known providers: ${known.length > 0 ? known.join(", ") : "none"}`));
|
|
61
27
|
}
|
|
62
28
|
return toConfig(result.data);
|
|
63
29
|
}
|
|
64
|
-
function firstLine(message) {
|
|
65
|
-
return message.split("\n", 1)[0] ?? message;
|
|
66
|
-
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -20,13 +20,13 @@ export declare const ConfigFileSchema: z.ZodObject<{
|
|
|
20
20
|
id: z.ZodString;
|
|
21
21
|
instructions: z.ZodString;
|
|
22
22
|
}, z.core.$strict>;
|
|
23
|
-
model: z.ZodObject<{
|
|
23
|
+
model: z.ZodOptional<z.ZodObject<{
|
|
24
24
|
provider: z.ZodString;
|
|
25
25
|
model: z.ZodString;
|
|
26
26
|
api_key_env: z.ZodString;
|
|
27
27
|
base_url: z.ZodOptional<z.ZodURL>;
|
|
28
28
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
29
|
-
}, z.core.$strict
|
|
29
|
+
}, z.core.$strict>>;
|
|
30
30
|
tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
31
31
|
provider: z.ZodOptional<z.ZodString>;
|
|
32
32
|
module: z.ZodOptional<z.ZodString>;
|
|
@@ -50,6 +50,14 @@ export type ToolProviderRef = {
|
|
|
50
50
|
allow: readonly string[] | undefined;
|
|
51
51
|
};
|
|
52
52
|
/** Configuration as used in code (camelCase). */
|
|
53
|
+
/** The model section of openshain.yaml, as the model providers take it. */
|
|
54
|
+
export interface ModelConfig {
|
|
55
|
+
provider: string;
|
|
56
|
+
model: string;
|
|
57
|
+
apiKeyEnv: string;
|
|
58
|
+
baseUrl: string | undefined;
|
|
59
|
+
options: Record<string, unknown> | undefined;
|
|
60
|
+
}
|
|
53
61
|
export interface Config {
|
|
54
62
|
version: 1;
|
|
55
63
|
company: {
|
|
@@ -64,13 +72,8 @@ export interface Config {
|
|
|
64
72
|
id: string;
|
|
65
73
|
instructions: string;
|
|
66
74
|
};
|
|
67
|
-
model
|
|
68
|
-
|
|
69
|
-
model: string;
|
|
70
|
-
apiKeyEnv: string;
|
|
71
|
-
baseUrl: string | undefined;
|
|
72
|
-
options: Record<string, unknown> | undefined;
|
|
73
|
-
};
|
|
75
|
+
/** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
|
|
76
|
+
model?: ModelConfig;
|
|
74
77
|
tools: ToolProviderRef[];
|
|
75
78
|
limits: {
|
|
76
79
|
maxModelCalls: number;
|
package/dist/config/schema.js
CHANGED
|
@@ -38,7 +38,8 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
38
38
|
}),
|
|
39
39
|
principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
|
|
40
40
|
profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
|
|
41
|
-
model: z
|
|
41
|
+
model: z
|
|
42
|
+
.strictObject({
|
|
42
43
|
provider: identifier,
|
|
43
44
|
model: z.string().min(1).max(200),
|
|
44
45
|
api_key_env: envVarName,
|
|
@@ -50,11 +51,12 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
50
51
|
}, "base_url must not carry credentials; use api_key_env")
|
|
51
52
|
.refine((value) => {
|
|
52
53
|
const url = new URL(value);
|
|
53
|
-
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname));
|
|
54
|
+
return (url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)));
|
|
54
55
|
}, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
|
|
55
56
|
.optional(),
|
|
56
57
|
options: z.record(z.string(), z.unknown()).optional(),
|
|
57
|
-
})
|
|
58
|
+
})
|
|
59
|
+
.optional(),
|
|
58
60
|
tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
|
|
59
61
|
limits: z
|
|
60
62
|
.strictObject({
|
|
@@ -71,13 +73,15 @@ export function toConfig(file) {
|
|
|
71
73
|
company: { name: file.company.name, language: file.company.language },
|
|
72
74
|
principal: { id: file.principal.id, name: file.principal.name },
|
|
73
75
|
profession: { id: file.profession.id, instructions: file.profession.instructions },
|
|
74
|
-
model
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
...(file.model && {
|
|
77
|
+
model: {
|
|
78
|
+
provider: file.model.provider,
|
|
79
|
+
model: file.model.model,
|
|
80
|
+
apiKeyEnv: file.model.api_key_env,
|
|
81
|
+
baseUrl: file.model.base_url,
|
|
82
|
+
options: file.model.options,
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
81
85
|
tools: file.tools.map(toToolProviderRef),
|
|
82
86
|
limits: {
|
|
83
87
|
maxModelCalls: file.limits.max_model_calls,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
/** Where a problem in a YAML file is, as `file:line:col path: message`. */
|
|
3
|
+
export type Problem = (path: readonly PropertyKey[], message: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Parses a YAML file against a zod schema. Every problem is reported with its line and column
|
|
6
|
+
* and the path of the field, so that a person can fix the file. Returns the data together with
|
|
7
|
+
* `problem`, for checks the caller adds after parsing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseYamlFile<T extends z.ZodType>(text: string, schema: T, fileName: string): {
|
|
10
|
+
data: z.output<T>;
|
|
11
|
+
problem: Problem;
|
|
12
|
+
};
|