sortie-dogs 0.1.1
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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +317 -0
- package/dist/core/diagnostics.d.ts +2 -0
- package/dist/core/diagnostics.js +65 -0
- package/dist/core/initialize.d.ts +14 -0
- package/dist/core/initialize.js +371 -0
- package/dist/core/path.d.ts +10 -0
- package/dist/core/path.js +27 -0
- package/dist/core/types.d.ts +114 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate-manifest.d.ts +3 -0
- package/dist/core/validate-manifest.js +139 -0
- package/dist/core/validate-schema.d.ts +5 -0
- package/dist/core/validate-schema.js +215 -0
- package/dist/core/validate-semantics.d.ts +15 -0
- package/dist/core/validate-semantics.js +295 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/plugin/config.d.ts +29 -0
- package/dist/plugin/config.js +173 -0
- package/dist/plugin/gate.d.ts +38 -0
- package/dist/plugin/gate.js +320 -0
- package/dist/plugin/index.d.ts +36 -0
- package/dist/plugin/index.js +271 -0
- package/dist/plugin/model-routing-hook.d.ts +40 -0
- package/dist/plugin/model-routing-hook.js +55 -0
- package/dist/plugin/model-routing.d.ts +62 -0
- package/dist/plugin/model-routing.js +128 -0
- package/dist/runtime-assets.d.ts +37 -0
- package/dist/runtime-assets.js +333 -0
- package/package.json +56 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import Ajv2020Module from "ajv/dist/2020.js";
|
|
2
|
+
import addFormatsModule from "ajv-formats";
|
|
3
|
+
const Ajv2020 = Ajv2020Module;
|
|
4
|
+
const addFormats = addFormatsModule;
|
|
5
|
+
// Kept in the runtime module because the package publishes only dist/.
|
|
6
|
+
const HANDOFF_SCHEMA = {
|
|
7
|
+
type: "object",
|
|
8
|
+
additionalProperties: false,
|
|
9
|
+
required: ["version", "profile", "id", "created_at", "task", "state", "risks", "verification"],
|
|
10
|
+
properties: {
|
|
11
|
+
version: { const: "0.1.0" },
|
|
12
|
+
profile: { enum: ["minimal", "full"] },
|
|
13
|
+
ext: { type: "object" },
|
|
14
|
+
id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
|
15
|
+
created_at: { type: "string", format: "date-time" },
|
|
16
|
+
task: {
|
|
17
|
+
type: "object",
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
required: ["title", "objective"],
|
|
20
|
+
properties: {
|
|
21
|
+
title: { type: "string", minLength: 1, maxLength: 160 },
|
|
22
|
+
objective: { type: "string", minLength: 1, maxLength: 2000 },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
scope: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
required: ["paths"],
|
|
29
|
+
properties: {
|
|
30
|
+
paths: {
|
|
31
|
+
type: "array",
|
|
32
|
+
minItems: 1,
|
|
33
|
+
uniqueItems: true,
|
|
34
|
+
items: { type: "string", minLength: 1, maxLength: 512 },
|
|
35
|
+
},
|
|
36
|
+
excludes: {
|
|
37
|
+
type: "array",
|
|
38
|
+
uniqueItems: true,
|
|
39
|
+
items: { type: "string", minLength: 1, maxLength: 512 },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
state: {
|
|
44
|
+
type: "object",
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
required: ["done", "next", "blocked"],
|
|
47
|
+
properties: {
|
|
48
|
+
done: { $ref: "#/$defs/statements" },
|
|
49
|
+
next: { $ref: "#/$defs/statements" },
|
|
50
|
+
blocked: {
|
|
51
|
+
type: "array",
|
|
52
|
+
items: {
|
|
53
|
+
type: "object",
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
required: ["reason", "needed"],
|
|
56
|
+
properties: {
|
|
57
|
+
reason: { type: "string", minLength: 1, maxLength: 1000 },
|
|
58
|
+
needed: { type: "string", minLength: 1, maxLength: 1000 },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
sources: {
|
|
65
|
+
type: "array",
|
|
66
|
+
minItems: 1,
|
|
67
|
+
items: {
|
|
68
|
+
type: "object",
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
required: ["path", "rev"],
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: "string", minLength: 1, maxLength: 512 },
|
|
73
|
+
rev: { type: "string", minLength: 1, maxLength: 256 },
|
|
74
|
+
hash: { type: "string", pattern: "^(sha256|sha512):[A-Fa-f0-9]+$" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
risks: {
|
|
79
|
+
type: "array",
|
|
80
|
+
items: {
|
|
81
|
+
type: "object",
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
required: ["severity", "description"],
|
|
84
|
+
properties: {
|
|
85
|
+
severity: { enum: ["low", "medium", "high"] },
|
|
86
|
+
description: { type: "string", minLength: 1, maxLength: 1000 },
|
|
87
|
+
mitigation: { type: "string", minLength: 1, maxLength: 1000 },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
verification: {
|
|
92
|
+
type: "array",
|
|
93
|
+
items: {
|
|
94
|
+
type: "object",
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
required: ["check", "status", "summary"],
|
|
97
|
+
properties: {
|
|
98
|
+
check: { type: "string", minLength: 1, maxLength: 256 },
|
|
99
|
+
status: { enum: ["pass", "fail", "not_run"] },
|
|
100
|
+
exit_code: { type: ["integer", "null"] },
|
|
101
|
+
summary: { type: "string", minLength: 1, maxLength: 1000 },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
allOf: [{
|
|
107
|
+
if: { properties: { profile: { const: "full" } }, required: ["profile"] },
|
|
108
|
+
then: { required: ["scope", "sources"] },
|
|
109
|
+
}],
|
|
110
|
+
$defs: {
|
|
111
|
+
statements: {
|
|
112
|
+
type: "array",
|
|
113
|
+
items: { type: "string", minLength: 1, maxLength: 1000 },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const OPERATION_MANIFEST_SCHEMA = {
|
|
118
|
+
type: "object",
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
required: ["version", "task_id", "read", "write", "validation"],
|
|
121
|
+
properties: {
|
|
122
|
+
version: { const: "0.1.0" },
|
|
123
|
+
task_id: { type: "string", minLength: 1, maxLength: 128 },
|
|
124
|
+
read: {
|
|
125
|
+
type: "array",
|
|
126
|
+
uniqueItems: true,
|
|
127
|
+
items: { $ref: "#/$defs/path" },
|
|
128
|
+
},
|
|
129
|
+
write: {
|
|
130
|
+
type: "array",
|
|
131
|
+
uniqueItems: true,
|
|
132
|
+
items: { $ref: "#/$defs/path" },
|
|
133
|
+
},
|
|
134
|
+
validation: {
|
|
135
|
+
type: "array",
|
|
136
|
+
uniqueItems: true,
|
|
137
|
+
items: { $ref: "#/$defs/validationCommand" },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
$defs: {
|
|
141
|
+
path: { type: "string", minLength: 1, maxLength: 512 },
|
|
142
|
+
validationCommand: { type: "string", minLength: 1, maxLength: 1000 },
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const MESSAGES = {
|
|
146
|
+
additionalProperties: "Unknown property is not allowed.",
|
|
147
|
+
const: "Value is not allowed.",
|
|
148
|
+
enum: "Value is not allowed.",
|
|
149
|
+
format: "Value has an invalid format.",
|
|
150
|
+
if: "Value does not satisfy a conditional schema rule.",
|
|
151
|
+
maxItems: "Array length is outside the allowed range.",
|
|
152
|
+
maxLength: "String length is outside the allowed range.",
|
|
153
|
+
minItems: "Array length is outside the allowed range.",
|
|
154
|
+
minLength: "String length is outside the allowed range.",
|
|
155
|
+
pattern: "Value has an invalid format.",
|
|
156
|
+
required: "Required property is missing.",
|
|
157
|
+
type: "Value has an invalid type.",
|
|
158
|
+
uniqueItems: "Array items must be unique.",
|
|
159
|
+
};
|
|
160
|
+
const ajv = new Ajv2020({
|
|
161
|
+
allErrors: true,
|
|
162
|
+
strict: true,
|
|
163
|
+
strictRequired: false,
|
|
164
|
+
});
|
|
165
|
+
addFormats(ajv);
|
|
166
|
+
const validators = {
|
|
167
|
+
handoff: ajv.compile(HANDOFF_SCHEMA),
|
|
168
|
+
"operation-manifest": ajv.compile(OPERATION_MANIFEST_SCHEMA),
|
|
169
|
+
};
|
|
170
|
+
function escapePointerSegment(segment) {
|
|
171
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
172
|
+
}
|
|
173
|
+
function errorPointer(error) {
|
|
174
|
+
if (error.keyword === "required") {
|
|
175
|
+
return `${error.instancePath}/${escapePointerSegment(String(error.params.missingProperty))}`;
|
|
176
|
+
}
|
|
177
|
+
if (error.keyword === "additionalProperties") {
|
|
178
|
+
return `${error.instancePath}/${escapePointerSegment(String(error.params.additionalProperty))}`;
|
|
179
|
+
}
|
|
180
|
+
return error.instancePath;
|
|
181
|
+
}
|
|
182
|
+
function compareDiagnostics(left, right) {
|
|
183
|
+
const pointerOrder = left.pointer < right.pointer ? -1 : left.pointer > right.pointer ? 1 : 0;
|
|
184
|
+
if (pointerOrder !== 0)
|
|
185
|
+
return pointerOrder;
|
|
186
|
+
return left.code < right.code ? -1 : left.code > right.code ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
function diagnosticsFor(errors) {
|
|
189
|
+
return errors
|
|
190
|
+
.map((error) => ({
|
|
191
|
+
code: `schema_${error.keyword}`,
|
|
192
|
+
severity: "error",
|
|
193
|
+
pointer: errorPointer(error),
|
|
194
|
+
message: MESSAGES[error.keyword] ?? "Value does not satisfy the schema.",
|
|
195
|
+
}))
|
|
196
|
+
.sort(compareDiagnostics);
|
|
197
|
+
}
|
|
198
|
+
/** Validate structure only. The input object is returned unchanged and is never mutated. */
|
|
199
|
+
export function validateSchema(kind, value) {
|
|
200
|
+
const validate = validators[kind];
|
|
201
|
+
if (validate(value)) {
|
|
202
|
+
return { ok: true, value: value, diagnostics: [] };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
value,
|
|
207
|
+
diagnostics: diagnosticsFor(validate.errors ?? []),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function validateHandoffSchema(value) {
|
|
211
|
+
return validateSchema("handoff", value);
|
|
212
|
+
}
|
|
213
|
+
export function validateOperationManifestSchema(value) {
|
|
214
|
+
return validateSchema("operation-manifest", value);
|
|
215
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Handoff, SemanticIssue } from "./types.js";
|
|
2
|
+
export interface H001Issue {
|
|
3
|
+
code: "H001";
|
|
4
|
+
path: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export type HandoffRuleCode = "H002" | "H003" | "H004" | "H005" | "H008" | "H009" | "H010";
|
|
8
|
+
export interface HandoffRuleIssue {
|
|
9
|
+
code: HandoffRuleCode;
|
|
10
|
+
path: string;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
/** H001 repository-relative path and normalized-duplicate checks. */
|
|
14
|
+
export declare function lintHandoffPaths(handoff: Handoff): H001Issue[];
|
|
15
|
+
export declare function lintHandoff(handoff: Handoff): Array<H001Issue | HandoffRuleIssue | SemanticIssue>;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
const pathUtils = await import(`./path.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
2
|
+
const HASH_LENGTHS = {
|
|
3
|
+
sha256: 64,
|
|
4
|
+
sha512: 128,
|
|
5
|
+
};
|
|
6
|
+
const HEX_DIGEST = /^[0-9a-f]+$/i;
|
|
7
|
+
const INVALID_HASH_MESSAGE = "Source hash must use a supported algorithm and hexadecimal digest.";
|
|
8
|
+
const INVALID_PATH_MESSAGE = "Path must be a valid repository-relative path.";
|
|
9
|
+
const DUPLICATE_PATH_MESSAGE = "Path duplicates another entry after normalization.";
|
|
10
|
+
const RULE_MESSAGES = {
|
|
11
|
+
H002: "Scope path is excluded by the same scope.",
|
|
12
|
+
H003: "Source path is outside the effective scope.",
|
|
13
|
+
H004: "State has neither a next action nor completion evidence.",
|
|
14
|
+
H005: "Blocker needed action is a placeholder.",
|
|
15
|
+
H008: "Creation timestamp is not a real RFC 3339 date-time with an offset.",
|
|
16
|
+
H009: "Value resembles a credential or high-entropy token.",
|
|
17
|
+
H010: "Claim must contain a non-whitespace character.",
|
|
18
|
+
};
|
|
19
|
+
const CREDENTIAL_PATTERNS = [
|
|
20
|
+
/(?:^|[^A-Z0-9])AKIA[0-9A-Z]{16}(?=$|[^A-Z0-9])/,
|
|
21
|
+
/(?:^|[^A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})(?=$|[^A-Za-z0-9_])/,
|
|
22
|
+
/(?:^|[^A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/,
|
|
23
|
+
/(?:^|[^A-Za-z0-9_-])AIza[A-Za-z0-9_-]{35}/,
|
|
24
|
+
/(?:^|[^A-Za-z0-9_-])sk-(?:proj-)?[A-Za-z0-9_-]{20,}/,
|
|
25
|
+
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/,
|
|
26
|
+
/(?:^|\s)eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}(?=$|\s)/,
|
|
27
|
+
];
|
|
28
|
+
const TOKEN_CANDIDATES = /[A-Za-z0-9_+/=-]{16,256}/g;
|
|
29
|
+
const COMMON_DIGEST = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64}|[0-9a-f]{96}|[0-9a-f]{128})$/i;
|
|
30
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
31
|
+
const SCAN_WINDOW_LENGTH = 4096;
|
|
32
|
+
const SCAN_WINDOW_OVERLAP = 256;
|
|
33
|
+
function shannonEntropy(value) {
|
|
34
|
+
const frequencies = new Map();
|
|
35
|
+
for (const character of value)
|
|
36
|
+
frequencies.set(character, (frequencies.get(character) ?? 0) + 1);
|
|
37
|
+
let entropy = 0;
|
|
38
|
+
for (const count of frequencies.values()) {
|
|
39
|
+
const probability = count / value.length;
|
|
40
|
+
entropy -= probability * Math.log2(probability);
|
|
41
|
+
}
|
|
42
|
+
return entropy;
|
|
43
|
+
}
|
|
44
|
+
function isHighEntropyToken(candidate) {
|
|
45
|
+
if (COMMON_DIGEST.test(candidate) || UUID.test(candidate))
|
|
46
|
+
return false;
|
|
47
|
+
const hasLower = /[a-z]/.test(candidate);
|
|
48
|
+
const hasUpper = /[A-Z]/.test(candidate);
|
|
49
|
+
const hasDigit = /\d/.test(candidate);
|
|
50
|
+
const hasEncodingSymbol = /[_+/=]/.test(candidate);
|
|
51
|
+
const tokenShape = (hasLower && hasUpper && (hasDigit || hasEncodingSymbol)) ||
|
|
52
|
+
(hasLower && hasDigit && hasEncodingSymbol);
|
|
53
|
+
return tokenShape && shannonEntropy(candidate) >= 3.5;
|
|
54
|
+
}
|
|
55
|
+
function isSecretLike(value) {
|
|
56
|
+
const step = SCAN_WINDOW_LENGTH - SCAN_WINDOW_OVERLAP;
|
|
57
|
+
for (let offset = 0; offset < value.length; offset += step) {
|
|
58
|
+
const window = value.slice(offset, offset + SCAN_WINDOW_LENGTH);
|
|
59
|
+
if (CREDENTIAL_PATTERNS.some((pattern) => pattern.test(window)))
|
|
60
|
+
return true;
|
|
61
|
+
if ([...window.matchAll(TOKEN_CANDIDATES)].some(([candidate]) => isHighEntropyToken(candidate)))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function escapePointerSegment(segment) {
|
|
67
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
68
|
+
}
|
|
69
|
+
function lintSecretLikeValues(handoff) {
|
|
70
|
+
const issues = [];
|
|
71
|
+
const ancestors = new WeakSet();
|
|
72
|
+
function visit(value, path) {
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
if (isSecretLike(value))
|
|
75
|
+
issues.push({ code: "H009", path, message: RULE_MESSAGES.H009 });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (value === null || typeof value !== "object" || ancestors.has(value))
|
|
79
|
+
return;
|
|
80
|
+
ancestors.add(value);
|
|
81
|
+
try {
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
value.forEach((entry, index) => visit(entry, `${path}/${index}`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
Object.entries(value).forEach(([key, entry], index) => {
|
|
87
|
+
if (isSecretLike(key)) {
|
|
88
|
+
const position = `${path}/@${index}`;
|
|
89
|
+
issues.push({ code: "H009", path: `${position}/key`, message: RULE_MESSAGES.H009 });
|
|
90
|
+
visit(entry, `${position}/value`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
visit(entry, `${path}/${escapePointerSegment(key)}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
ancestors.delete(value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
visit(handoff, "");
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
104
|
+
// H005 placeholder vocabulary is intentionally centralized here.
|
|
105
|
+
const BLOCKER_ACTION_PLACEHOLDERS = new Set([
|
|
106
|
+
"?",
|
|
107
|
+
"n/a",
|
|
108
|
+
"none",
|
|
109
|
+
"tbd",
|
|
110
|
+
"todo",
|
|
111
|
+
"unknown",
|
|
112
|
+
"unspecified",
|
|
113
|
+
]);
|
|
114
|
+
function lintPathList(paths, pointer) {
|
|
115
|
+
const issues = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
paths.forEach((path, index) => {
|
|
118
|
+
try {
|
|
119
|
+
const normalized = pathUtils.normalizeRelativePath(path);
|
|
120
|
+
if (seen.has(normalized)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
code: "H001",
|
|
123
|
+
path: `${pointer}/${index}`,
|
|
124
|
+
message: DUPLICATE_PATH_MESSAGE,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
seen.add(normalized);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!(error instanceof pathUtils.RelativePathError))
|
|
133
|
+
throw error;
|
|
134
|
+
issues.push({
|
|
135
|
+
code: "H001",
|
|
136
|
+
path: `${pointer}/${index}`,
|
|
137
|
+
message: INVALID_PATH_MESSAGE,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return issues;
|
|
142
|
+
}
|
|
143
|
+
/** H001 repository-relative path and normalized-duplicate checks. */
|
|
144
|
+
export function lintHandoffPaths(handoff) {
|
|
145
|
+
const issues = [];
|
|
146
|
+
if (handoff.scope !== undefined) {
|
|
147
|
+
issues.push(...lintPathList(handoff.scope.paths, "/scope/paths"));
|
|
148
|
+
if (handoff.scope.excludes !== undefined) {
|
|
149
|
+
issues.push(...lintPathList(handoff.scope.excludes, "/scope/excludes"));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (handoff.sources !== undefined) {
|
|
153
|
+
issues.push(...lintPathList(handoff.sources.map(({ path }) => path), "/sources"));
|
|
154
|
+
for (const issue of issues) {
|
|
155
|
+
if (issue.path.startsWith("/sources/"))
|
|
156
|
+
issue.path += "/path";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return issues;
|
|
160
|
+
}
|
|
161
|
+
function normalizePath(path) {
|
|
162
|
+
try {
|
|
163
|
+
return pathUtils.normalizeRelativePath(path);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error instanceof pathUtils.RelativePathError)
|
|
167
|
+
return undefined;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function isWithin(path, parent) {
|
|
172
|
+
return path === parent || path.startsWith(`${parent}/`);
|
|
173
|
+
}
|
|
174
|
+
function isValidTimestamp(value) {
|
|
175
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(value);
|
|
176
|
+
if (match === null)
|
|
177
|
+
return false;
|
|
178
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match;
|
|
179
|
+
const year = Number(yearText);
|
|
180
|
+
const month = Number(monthText);
|
|
181
|
+
const day = Number(dayText);
|
|
182
|
+
const hour = Number(hourText);
|
|
183
|
+
const minute = Number(minuteText);
|
|
184
|
+
const second = Number(secondText);
|
|
185
|
+
const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText);
|
|
186
|
+
const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText);
|
|
187
|
+
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 60 || offsetHour > 23 || offsetMinute > 59) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
191
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
192
|
+
return day >= 1 && day <= daysInMonth[month - 1];
|
|
193
|
+
}
|
|
194
|
+
function addBlankClaimIssue(issues, value, path) {
|
|
195
|
+
if (value.trim().length === 0) {
|
|
196
|
+
issues.push({ code: "H010", path, message: RULE_MESSAGES.H010 });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function lintHandoffRules(handoff) {
|
|
200
|
+
const issues = [...lintSecretLikeValues(handoff)];
|
|
201
|
+
const scopePaths = handoff.scope?.paths.map(normalizePath).filter((path) => path !== undefined) ?? [];
|
|
202
|
+
const excludes = handoff.scope?.excludes?.map(normalizePath).filter((path) => path !== undefined) ?? [];
|
|
203
|
+
handoff.scope?.paths.forEach((path, index) => {
|
|
204
|
+
const normalized = normalizePath(path);
|
|
205
|
+
if (normalized !== undefined && excludes.some((exclude) => isWithin(normalized, exclude))) {
|
|
206
|
+
issues.push({ code: "H002", path: `/scope/paths/${index}`, message: RULE_MESSAGES.H002 });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
handoff.sources?.forEach((source, index) => {
|
|
210
|
+
const normalized = normalizePath(source.path);
|
|
211
|
+
if (normalized === undefined || handoff.scope === undefined)
|
|
212
|
+
return;
|
|
213
|
+
const included = scopePaths.some((scopePath) => isWithin(normalized, scopePath));
|
|
214
|
+
const excluded = excludes.some((exclude) => isWithin(normalized, exclude));
|
|
215
|
+
if (!included || excluded) {
|
|
216
|
+
issues.push({ code: "H003", path: `/sources/${index}/path`, message: RULE_MESSAGES.H003 });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
const completed = handoff.state.done.length > 0 &&
|
|
220
|
+
handoff.state.blocked.length === 0 &&
|
|
221
|
+
handoff.verification.length > 0 &&
|
|
222
|
+
handoff.verification.every(({ status }) => status === "pass");
|
|
223
|
+
if (handoff.state.next.length === 0 && !completed) {
|
|
224
|
+
issues.push({ code: "H004", path: "/state/next", message: RULE_MESSAGES.H004 });
|
|
225
|
+
}
|
|
226
|
+
handoff.state.blocked.forEach((blocker, index) => {
|
|
227
|
+
if (BLOCKER_ACTION_PLACEHOLDERS.has(blocker.needed.trim().toLowerCase())) {
|
|
228
|
+
issues.push({ code: "H005", path: `/state/blocked/${index}/needed`, message: RULE_MESSAGES.H005 });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
handoff.verification.forEach((verification, index) => {
|
|
232
|
+
const exitCode = verification.exit_code;
|
|
233
|
+
const mismatch = (verification.status === "pass" && exitCode !== 0) ||
|
|
234
|
+
(verification.status === "fail" && (typeof exitCode !== "number" || exitCode === 0)) ||
|
|
235
|
+
(verification.status === "not_run" && exitCode !== null);
|
|
236
|
+
if (mismatch) {
|
|
237
|
+
issues.push({
|
|
238
|
+
code: "H006",
|
|
239
|
+
path: `/verification/${index}/exit_code`,
|
|
240
|
+
message: `exit_code is inconsistent with verification status ${verification.status}`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
handoff.sources?.forEach((source, index) => {
|
|
245
|
+
if (source.hash === undefined)
|
|
246
|
+
return;
|
|
247
|
+
const separator = source.hash.indexOf(":");
|
|
248
|
+
if (separator < 1) {
|
|
249
|
+
issues.push({
|
|
250
|
+
code: "H007",
|
|
251
|
+
path: `/sources/${index}/hash`,
|
|
252
|
+
message: INVALID_HASH_MESSAGE,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const algorithm = source.hash.slice(0, separator);
|
|
257
|
+
const digest = source.hash.slice(separator + 1);
|
|
258
|
+
const expectedLength = Object.hasOwn(HASH_LENGTHS, algorithm)
|
|
259
|
+
? HASH_LENGTHS[algorithm]
|
|
260
|
+
: undefined;
|
|
261
|
+
if (expectedLength === undefined || digest.length !== expectedLength || !HEX_DIGEST.test(digest)) {
|
|
262
|
+
issues.push({
|
|
263
|
+
code: "H007",
|
|
264
|
+
path: `/sources/${index}/hash`,
|
|
265
|
+
message: expectedLength === undefined
|
|
266
|
+
? INVALID_HASH_MESSAGE
|
|
267
|
+
: `${algorithm} digest must contain ${expectedLength} hexadecimal characters.`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
if (!isValidTimestamp(handoff.created_at)) {
|
|
272
|
+
issues.push({ code: "H008", path: "/created_at", message: RULE_MESSAGES.H008 });
|
|
273
|
+
}
|
|
274
|
+
addBlankClaimIssue(issues, handoff.task.title, "/task/title");
|
|
275
|
+
addBlankClaimIssue(issues, handoff.task.objective, "/task/objective");
|
|
276
|
+
handoff.state.done.forEach((claim, index) => addBlankClaimIssue(issues, claim, `/state/done/${index}`));
|
|
277
|
+
handoff.state.next.forEach((claim, index) => addBlankClaimIssue(issues, claim, `/state/next/${index}`));
|
|
278
|
+
handoff.state.blocked.forEach((blocker, index) => {
|
|
279
|
+
addBlankClaimIssue(issues, blocker.reason, `/state/blocked/${index}/reason`);
|
|
280
|
+
addBlankClaimIssue(issues, blocker.needed, `/state/blocked/${index}/needed`);
|
|
281
|
+
});
|
|
282
|
+
handoff.risks.forEach((risk, index) => {
|
|
283
|
+
addBlankClaimIssue(issues, risk.description, `/risks/${index}/description`);
|
|
284
|
+
if (risk.mitigation !== undefined)
|
|
285
|
+
addBlankClaimIssue(issues, risk.mitigation, `/risks/${index}/mitigation`);
|
|
286
|
+
});
|
|
287
|
+
handoff.verification.forEach((verification, index) => {
|
|
288
|
+
addBlankClaimIssue(issues, verification.check, `/verification/${index}/check`);
|
|
289
|
+
addBlankClaimIssue(issues, verification.summary, `/verification/${index}/summary`);
|
|
290
|
+
});
|
|
291
|
+
return issues;
|
|
292
|
+
}
|
|
293
|
+
export function lintHandoff(handoff) {
|
|
294
|
+
return [...lintHandoffPaths(handoff), ...lintHandoffRules(handoff)];
|
|
295
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { lintHandoff } from "./core/validate-semantics.js";
|
|
2
|
+
export { initializeProject, ProjectInitializationError, } from "./core/initialize.js";
|
|
3
|
+
export type { InitializationStatus, InitializeProjectResult, ProjectInitializationErrorCode, } from "./core/initialize.js";
|
|
4
|
+
export { SortieDogsPlugin } from "./plugin/index.js";
|
|
5
|
+
export type * from "./core/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ModelCatalog, type ModelRoutingConfig } from "./model-routing.js";
|
|
2
|
+
export interface SortieDogsPluginOptions {
|
|
3
|
+
operationManifestPath?: string;
|
|
4
|
+
handoffPaths?: readonly string[];
|
|
5
|
+
modelRouting?: ModelRoutingConfig;
|
|
6
|
+
modelCatalog?: ModelCatalog;
|
|
7
|
+
}
|
|
8
|
+
export interface ConfiguredPlugin {
|
|
9
|
+
kind: "configured";
|
|
10
|
+
operationManifestPath: string;
|
|
11
|
+
handoffPaths: readonly string[];
|
|
12
|
+
modelRouting: ModelRoutingConfig;
|
|
13
|
+
modelCatalog: ModelCatalog;
|
|
14
|
+
}
|
|
15
|
+
export type PluginConfiguration = ConfiguredPlugin | {
|
|
16
|
+
kind: "invalid";
|
|
17
|
+
};
|
|
18
|
+
export interface ConfiguredPluginSources extends ConfiguredPlugin {
|
|
19
|
+
localModelRouting: ModelRoutingConfig;
|
|
20
|
+
globalModelRouting: ModelRoutingConfig;
|
|
21
|
+
}
|
|
22
|
+
export type PluginConfigurationSources = ConfiguredPluginSources | {
|
|
23
|
+
kind: "invalid";
|
|
24
|
+
};
|
|
25
|
+
export declare const DEFAULT_PLUGIN_OPTIONS: Readonly<Required<SortieDogsPluginOptions>>;
|
|
26
|
+
/** Merge defaults, optional project/env configuration, then the host override. */
|
|
27
|
+
export declare function resolvePluginConfiguration(...values: readonly unknown[]): PluginConfiguration;
|
|
28
|
+
/** Resolve the plugin's fixed source boundaries: project-local first, environment and host global. */
|
|
29
|
+
export declare function resolvePluginConfigurationSources(projectValue: unknown, environmentValue: unknown, hostValue: unknown): PluginConfigurationSources;
|