@sabaiway/agent-workflow-kit 1.44.0 → 1.45.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/CHANGELOG.md +63 -0
- package/README.md +6 -3
- package/SKILL.md +9 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +7 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -3
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +4 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +93 -22
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
- package/bridges/codex-cli-bridge/capability.json +4 -3
- package/bridges/codex-cli-bridge/references/driving-codex.md +5 -4
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +5 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/bridge-settings.md +1 -1
- package/references/modes/grounding.md +8 -7
- package/references/modes/recommendations.md +14 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/sandbox-masks.md +15 -0
- package/references/modes/upgrade.md +10 -7
- package/references/modes/velocity.md +13 -2
- package/references/shared/composition-handoff.md +21 -16
- package/references/shared/report-footer.md +5 -1
- package/references/templates/AGENTS.md +2 -1
- package/references/templates/autonomy.json +3 -0
- package/tools/autonomy-config.mjs +13 -3
- package/tools/commands.mjs +15 -1
- package/tools/delegation.mjs +9 -5
- package/tools/detect-backends.mjs +2 -2
- package/tools/doc-parity.mjs +8 -0
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +21 -7
- package/tools/fold-completeness-run.mjs +5 -0
- package/tools/grounding.mjs +139 -22
- package/tools/inject-methodology.mjs +117 -28
- package/tools/manifest/schema.md +20 -0
- package/tools/manifest/validate.mjs +26 -0
- package/tools/procedures.mjs +61 -8
- package/tools/recipes.mjs +94 -15
- package/tools/recommendations.mjs +469 -0
- package/tools/review-ledger-write.mjs +14 -0
- package/tools/review-ledger.mjs +3 -2
- package/tools/review-state.mjs +101 -24
- package/tools/run-gates.mjs +3 -0
- package/tools/sandbox-masks.mjs +370 -0
- package/tools/set-recipe.mjs +13 -1
- package/tools/velocity-profile.mjs +228 -22
package/tools/grounding.mjs
CHANGED
|
@@ -19,14 +19,15 @@
|
|
|
19
19
|
// in-band marker + stderr report (never a silent cut).
|
|
20
20
|
//
|
|
21
21
|
// Catalog honesty: this is a WRITER — `--out <path>` writes a file. Invariant: `--out` accepts
|
|
22
|
-
// only gitignored
|
|
22
|
+
// only system-temp destinations or a FRESH gitignored in-repo path and REFUSES a tracked/in-repo
|
|
23
23
|
// not-ignored path (a new untracked file would itself move the review fingerprint the facts are
|
|
24
24
|
// about to ground). stdout is the default. It never commits and never runs a subscription CLI.
|
|
25
25
|
//
|
|
26
26
|
// Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
27
27
|
|
|
28
28
|
import { readFileSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
29
|
-
import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:path';
|
|
29
|
+
import { resolve, join, relative, isAbsolute, dirname, basename, sep } from 'node:path';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
30
31
|
import { pathToFileURL } from 'node:url';
|
|
31
32
|
import { spawnSync } from 'node:child_process';
|
|
32
33
|
import { fail } from './orchestration-config.mjs';
|
|
@@ -35,6 +36,9 @@ import { fail } from './orchestration-config.mjs';
|
|
|
35
36
|
// computeTelemetry give the counts; the segment filter gives the scope.
|
|
36
37
|
import { ORIGINS, computeTelemetry, filterSegmentRecords, readLedger, resolveLedgerPath, resolveBase } from './review-ledger.mjs';
|
|
37
38
|
import { plansInFlight } from './review-state.mjs';
|
|
39
|
+
// (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
|
|
40
|
+
// READ core only — never autonomy-write.mjs (the import-split invariant).
|
|
41
|
+
import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
|
|
38
42
|
|
|
39
43
|
const PLAN_EXECUTION = 'plan-execution';
|
|
40
44
|
|
|
@@ -82,11 +86,15 @@ export const sliceSection = (text, heading, { optional = false, label = 'documen
|
|
|
82
86
|
|
|
83
87
|
// ── assembly ───────────────────────────────────────────────────────────────────────
|
|
84
88
|
|
|
85
|
-
export const assembleGrounding = ({ constraintsText = null, planText = null, planLabel = 'plan' } = {}) => {
|
|
89
|
+
export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan' } = {}) => {
|
|
86
90
|
const parts = [];
|
|
87
91
|
if (constraintsText != null) {
|
|
88
92
|
parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
|
|
89
93
|
}
|
|
94
|
+
// The autonomy block is COMPUTED (resolveAutonomy over the policy file), not sliced — it rides
|
|
95
|
+
// between the constraints and the plan sections: the natural read order (what always binds →
|
|
96
|
+
// how autonomous this session is → what this plan decides).
|
|
97
|
+
if (autonomyText != null) parts.push(autonomyText);
|
|
90
98
|
if (planText != null) {
|
|
91
99
|
for (const { heading, optional } of PLAN_SECTIONS) {
|
|
92
100
|
const section = sliceSection(planText, heading, { optional, label: planLabel });
|
|
@@ -178,6 +186,38 @@ export const resolveLedgerSummary = ({ cwd, env }) => {
|
|
|
178
186
|
return renderLedgerSummary(records, { loop, base });
|
|
179
187
|
};
|
|
180
188
|
|
|
189
|
+
// ── (f) --autonomy: the effective autonomy policy as a computed facts block (AD-044 Plan 3) ─────
|
|
190
|
+
// The ONE shared resolver (resolveAutonomy) renders the FULL effective policy — every red-line and
|
|
191
|
+
// every activity resolved, no key undefined — with a stated source line. The policy file resolves
|
|
192
|
+
// from the GIT TOP of cwd (the --ledger-summary gitTop precedent): a cwd-relative read from a
|
|
193
|
+
// subdir would silently emit computed defaults for a policy-carrying repo, the exact silent failure
|
|
194
|
+
// grounding exists to kill. Malformed/unreadable policy fails CLOSED (loadAutonomy throws exit 1);
|
|
195
|
+
// an ABSENT file is NOT an error — the computed defaults ARE the effective policy (deliberately
|
|
196
|
+
// diverging from `velocity-profile --autonomy`'s refuse-on-absent: that surface WRITES settings and
|
|
197
|
+
// demands a declared policy; this one emits informational facts). Enforcement honesty: these facts
|
|
198
|
+
// INFORM a review; enforcement stays the OS sandbox + the orchestrator process (AD-044 D1).
|
|
199
|
+
|
|
200
|
+
export const renderAutonomyFacts = (config, source) => {
|
|
201
|
+
const resolved = resolveAutonomy(config);
|
|
202
|
+
// The STRUCTURAL seed (_README only) reads as computed defaults; an EXPLICIT declared-defaults
|
|
203
|
+
// policy reads as a declared policy (the shared predicate — same contract on every surface).
|
|
204
|
+
const defaultsEquivalent = source !== 'none' && isSparseSeedConfig(config);
|
|
205
|
+
const head =
|
|
206
|
+
source === 'none'
|
|
207
|
+
? `## Autonomy policy — ${AUTONOMY_REL} absent; the computed defaults ARE the effective policy`
|
|
208
|
+
: defaultsEquivalent
|
|
209
|
+
? `## Autonomy policy — ${AUTONOMY_REL} present but defaults-equivalent (the sparse seed); the computed defaults ARE the effective policy`
|
|
210
|
+
: `## Autonomy policy — ${AUTONOMY_REL}`;
|
|
211
|
+
const redlines = Object.entries(resolved.redlines).map(([k, v]) => `${k}:${v}`).join(' ');
|
|
212
|
+
const activities = Object.entries(resolved.activities).map(([a, v]) => `${a}:${v.autonomy}`).join(' ');
|
|
213
|
+
return `${head}\n\nred-lines — ${redlines}\nactivities — ${activities}\n`;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const resolveAutonomyFacts = ({ cwd }) => {
|
|
217
|
+
const { config, source } = loadAutonomy(gitTop(cwd));
|
|
218
|
+
return renderAutonomyFacts(config, source);
|
|
219
|
+
};
|
|
220
|
+
|
|
181
221
|
// ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
|
|
182
222
|
|
|
183
223
|
const gitLine = (args, cwd) => {
|
|
@@ -185,24 +225,35 @@ const gitLine = (args, cwd) => {
|
|
|
185
225
|
return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
|
|
186
226
|
};
|
|
187
227
|
|
|
188
|
-
// STOP unless `outPath` is a safe scratch destination
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
228
|
+
// STOP unless `outPath` is a safe scratch destination — the CLOSED surface (this writer is
|
|
229
|
+
// bridge-tier auto-allowable): (a) under a SYSTEM TEMP root outside the repo ($TMPDIR /
|
|
230
|
+
// os.tmpdir() / /tmp — rewritable), or (b) a FRESH gitignored in-repo path (create-only; an
|
|
231
|
+
// EXISTING in-repo file, even gitignored, is a project file — the .env clobber class). Refused:
|
|
232
|
+
// tracked paths, in-repo not-ignored paths (a new untracked file would move the fingerprint),
|
|
233
|
+
// every other outside-repo destination, symlink and existing non-regular leaves, and any
|
|
234
|
+
// unverifiable lstat. The check runs on the REAL destination, never the lexical path (codex R1):
|
|
235
|
+
// the parent directory is realpath-resolved first, so a symlink can never route the write onto a
|
|
236
|
+
// tracked/in-repo file. Returns { path, kind: 'temp' | 'repo-fresh' } — a repo-fresh caller MUST
|
|
237
|
+
// write with the exclusive flag ('wx'), sealing the guard→write race (codex R11).
|
|
195
238
|
export const assertScratchDestination = (outPath, cwd) => {
|
|
196
239
|
const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
|
|
197
240
|
let leaf = null;
|
|
198
241
|
try {
|
|
199
242
|
leaf = lstatSync(lexical);
|
|
200
|
-
} catch {
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// ONLY an absent leaf is a fresh file; any other lstat failure fails CLOSED (codex R9 — this
|
|
245
|
+
// writer is bridge-tier auto-allowable, so an unverifiable leaf must never be written).
|
|
246
|
+
if (err?.code !== 'ENOENT') {
|
|
247
|
+
throw fail(1, `--out cannot inspect the destination (${outPath}: ${err?.code ?? err?.message ?? err}) — refusing to write an unverifiable leaf`);
|
|
248
|
+
}
|
|
201
249
|
leaf = null; // absent → a fresh file; the parent still gets realpath-checked below
|
|
202
250
|
}
|
|
203
251
|
if (leaf?.isSymbolicLink()) {
|
|
204
252
|
throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
|
|
205
253
|
}
|
|
254
|
+
if (leaf != null && !leaf.isFile()) {
|
|
255
|
+
throw fail(1, `--out refuses an existing non-regular destination (${outPath}) — a FIFO/device/directory write would hang or land on a non-file (codex R9); name a fresh or regular scratch path`);
|
|
256
|
+
}
|
|
206
257
|
let realParent;
|
|
207
258
|
try {
|
|
208
259
|
realParent = realpathSync(dirname(lexical));
|
|
@@ -210,11 +261,31 @@ export const assertScratchDestination = (outPath, cwd) => {
|
|
|
210
261
|
throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
|
|
211
262
|
}
|
|
212
263
|
const full = join(realParent, basename(lexical));
|
|
264
|
+
// An out-of-repo (or no-repo) destination is scratch ONLY under a system temp root (codex R8
|
|
265
|
+
// blocker): the bridge tier auto-allows this tool with an args wildcard, so "anything outside
|
|
266
|
+
// the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
|
|
267
|
+
// $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
|
|
268
|
+
const assertTempScratch = () => {
|
|
269
|
+
const tempRoots = [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
|
|
270
|
+
try {
|
|
271
|
+
return realpathSync(p);
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}).filter(Boolean))];
|
|
276
|
+
if (!tempRoots.some((t) => full === t || full.startsWith(`${t}${sep}`))) {
|
|
277
|
+
throw fail(1, `--out refuses an outside-repo destination that is not under a system temp root (${full}) — grounding output is scratch: use $TMPDIR//tmp, or a fresh gitignored in-repo path (temp roots checked: ${tempRoots.join(', ')})`);
|
|
278
|
+
}
|
|
279
|
+
return { path: full, kind: 'temp' };
|
|
280
|
+
};
|
|
213
281
|
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
214
|
-
if (top == null || top.status !== 0) return
|
|
282
|
+
if (top == null || top.status !== 0) return { path: assertTempScratch().path, kind: 'temp' }; // no git tree → temp-only scratch
|
|
215
283
|
const root = top.stdout.replace(/\r?\n$/, '');
|
|
216
284
|
const rel = relative(root, full);
|
|
217
|
-
|
|
285
|
+
// Segment-safe outside test (the Issue-004 class, codex R5): an in-repo file literally named
|
|
286
|
+
// `..facts` has rel `..facts` — `startsWith('..')` would misread it as outside and BYPASS the
|
|
287
|
+
// tracked/ignored refusals below. Only `..` exactly or a `..${sep}`-prefixed rel is outside.
|
|
288
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { path: assertTempScratch().path, kind: 'temp' };
|
|
218
289
|
const tracked = gitLine(['ls-files', '--error-unmatch', '--', rel], root);
|
|
219
290
|
if (tracked != null && tracked.status === 0) {
|
|
220
291
|
throw fail(1, `--out refuses a TRACKED path (${rel}) — grounding output is scratch; write it to a gitignored path or outside the repo`);
|
|
@@ -223,7 +294,15 @@ export const assertScratchDestination = (outPath, cwd) => {
|
|
|
223
294
|
if (ignored == null || ignored.status !== 0) {
|
|
224
295
|
throw fail(1, `--out refuses an in-repo path that is not gitignored (${rel}) — a new untracked file would move the review fingerprint; use a gitignored path or a location outside the repo`);
|
|
225
296
|
}
|
|
226
|
-
|
|
297
|
+
// An in-repo destination must be a FRESH file (codex R10): even a gitignored existing file is a
|
|
298
|
+
// project file (.env is the canonical victim) — an auto-allowable writer must never clobber one.
|
|
299
|
+
// The rewritable scratch surface is the system temp; in-repo gitignored writes are create-only —
|
|
300
|
+
// the CALLER must open with the exclusive flag ('wx'): the kind seals the pre-check against the
|
|
301
|
+
// guard→write race (codex R11).
|
|
302
|
+
if (leaf != null) {
|
|
303
|
+
throw fail(1, `--out refuses to OVERWRITE an existing in-repo file (${rel}) — even gitignored, it is a project file (the .env class); write rewritable scratch under $TMPDIR//tmp, or remove the file first`);
|
|
304
|
+
}
|
|
305
|
+
return { path: full, kind: 'repo-fresh' };
|
|
227
306
|
};
|
|
228
307
|
|
|
229
308
|
// ── CLI ────────────────────────────────────────────────────────────────────────────
|
|
@@ -231,10 +310,15 @@ export const assertScratchDestination = (outPath, cwd) => {
|
|
|
231
310
|
const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
|
|
232
311
|
|
|
233
312
|
Usage:
|
|
234
|
-
node grounding.mjs [--constraints] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
|
|
313
|
+
node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
|
|
235
314
|
|
|
236
315
|
--constraints slice the root AGENTS.md "Hard Constraints" section verbatim
|
|
237
316
|
(exactly one matching heading, else a loud STOP)
|
|
317
|
+
--autonomy append the COMPUTED effective autonomy policy (resolveAutonomy over the
|
|
318
|
+
git-top docs/ai/autonomy.json): every red-line + per-activity level, with a
|
|
319
|
+
stated source line; absent file → the computed defaults ARE the policy
|
|
320
|
+
(exit 0); malformed/unreadable → fail-closed STOP (exit 1); informational —
|
|
321
|
+
enforcement stays the sandbox + the orchestrator
|
|
238
322
|
--plan <path> extract the plan's decision-bearing sections verbatim + whole:
|
|
239
323
|
"## Approach" + "## Verification" (REQUIRED — STOP if missing),
|
|
240
324
|
"## Decisions (locked)" when present; a duplicate heading is a STOP
|
|
@@ -244,7 +328,8 @@ Usage:
|
|
|
244
328
|
loud STOP unless exactly one plan is in flight
|
|
245
329
|
--reserve-bytes <n> the artifact share agy-review will add around these facts — the output
|
|
246
330
|
budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
|
|
247
|
-
--out <path> write instead of stdout —
|
|
331
|
+
--out <path> write instead of stdout — system-temp scratch (rewritable), or a FRESH
|
|
332
|
+
gitignored in-repo path (create-only); everything else refuses
|
|
248
333
|
(a tracked or in-repo not-ignored path is refused)
|
|
249
334
|
|
|
250
335
|
Feed the output to the review wrapper: agy-review code --facts @<out>. AGY_MAX_PROMPT_BYTES
|
|
@@ -256,6 +341,7 @@ Exit codes: 0 success; 2 usage; 1 STOP (missing/duplicate section, unreadable fi
|
|
|
256
341
|
|
|
257
342
|
const parseArgs = (argv) => {
|
|
258
343
|
let constraints = false;
|
|
344
|
+
let autonomy = false;
|
|
259
345
|
let plan = null;
|
|
260
346
|
let out = null;
|
|
261
347
|
let reserve = 0;
|
|
@@ -263,6 +349,7 @@ const parseArgs = (argv) => {
|
|
|
263
349
|
for (let i = 0; i < argv.length; i += 1) {
|
|
264
350
|
const a = argv[i];
|
|
265
351
|
if (a === '--constraints') constraints = true;
|
|
352
|
+
else if (a === '--autonomy') autonomy = true;
|
|
266
353
|
else if (a === '--ledger-summary') ledgerSummary = true;
|
|
267
354
|
else if (a === '--plan') {
|
|
268
355
|
plan = argv[i + 1];
|
|
@@ -279,8 +366,10 @@ const parseArgs = (argv) => {
|
|
|
279
366
|
i += 1;
|
|
280
367
|
} else throw fail(2, `unknown argument: ${a}`);
|
|
281
368
|
}
|
|
282
|
-
if (!constraints && plan == null && !ledgerSummary)
|
|
283
|
-
|
|
369
|
+
if (!constraints && !autonomy && plan == null && !ledgerSummary) {
|
|
370
|
+
throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, --plan <path>, and/or --ledger-summary');
|
|
371
|
+
}
|
|
372
|
+
return { constraints, autonomy, plan, out, reserve, ledgerSummary };
|
|
284
373
|
};
|
|
285
374
|
|
|
286
375
|
const resolveBudget = (env, reserve) => {
|
|
@@ -300,7 +389,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
300
389
|
const env = ctx.env ?? process.env;
|
|
301
390
|
try {
|
|
302
391
|
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
303
|
-
const { constraints, plan, out, reserve, ledgerSummary } = parseArgs(argv);
|
|
392
|
+
const { constraints, autonomy, plan, out, reserve, ledgerSummary } = parseArgs(argv);
|
|
304
393
|
const budget = resolveBudget(env, reserve);
|
|
305
394
|
|
|
306
395
|
const readOrStop = (path, label) => {
|
|
@@ -311,10 +400,29 @@ export const main = (argv, ctx = {}) => {
|
|
|
311
400
|
}
|
|
312
401
|
};
|
|
313
402
|
const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
|
|
403
|
+
const autonomyText = autonomy ? resolveAutonomyFacts({ cwd }) : null;
|
|
404
|
+
// --plan is CONFINED to the git work tree (codex R4): the bridge tier auto-allows this tool
|
|
405
|
+
// with an args wildcard, so an unattended invocation could otherwise point --plan at ANY
|
|
406
|
+
// readable file and ship it into the facts payload. Plans are in-repo by contract
|
|
407
|
+
// (docs/plans); an outside-tree path is a loud refusal, never a silent read.
|
|
408
|
+
if (plan != null) {
|
|
409
|
+
const top = gitTop(cwd);
|
|
410
|
+
const planReal = (() => {
|
|
411
|
+
try {
|
|
412
|
+
return realpathSync(resolve(cwd, plan));
|
|
413
|
+
} catch (err) {
|
|
414
|
+
throw fail(1, `plan file '${plan}' is unreadable (${(err && err.code) || err}) — STOP`);
|
|
415
|
+
}
|
|
416
|
+
})();
|
|
417
|
+
const topReal = realpathSync(top);
|
|
418
|
+
if (planReal !== topReal && !planReal.startsWith(`${topReal}${sep}`)) {
|
|
419
|
+
throw fail(1, `--plan '${plan}' resolves outside the git work tree (${planReal}) — plans live in-repo (docs/plans); refusing to read outside-tree content into the facts payload`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
314
422
|
const planText = plan != null ? readOrStop(plan, 'plan file') : null;
|
|
315
423
|
|
|
316
424
|
const parts = [];
|
|
317
|
-
const assembled = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
|
|
425
|
+
const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan' });
|
|
318
426
|
if (assembled) parts.push(assembled);
|
|
319
427
|
if (ledgerSummary) {
|
|
320
428
|
const summary = resolveLedgerSummary({ cwd, env });
|
|
@@ -327,8 +435,17 @@ export const main = (argv, ctx = {}) => {
|
|
|
327
435
|
: '';
|
|
328
436
|
|
|
329
437
|
if (out != null) {
|
|
330
|
-
const
|
|
331
|
-
|
|
438
|
+
const dest = assertScratchDestination(out, cwd);
|
|
439
|
+
// repo-fresh writes are EXCLUSIVE ('wx'): the create-only pre-check would otherwise race a
|
|
440
|
+
// file created between the guard and this write (codex R11); temp scratch stays rewritable.
|
|
441
|
+
// ctx.writeFile is a TEST seam (the EEXIST race arm is not constructible without it).
|
|
442
|
+
const writeFile = ctx.writeFile ?? writeFileSync;
|
|
443
|
+
try {
|
|
444
|
+
writeFile(dest.path, text, dest.kind === 'repo-fresh' ? { flag: 'wx' } : {});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (err?.code === 'EEXIST') throw fail(1, `--out lost the create-only race: ${out} appeared between the guard and the write — an in-repo destination is never overwritten; re-run with a fresh path or temp scratch`);
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
332
449
|
return { code: 0, stdout: `[grounding] wrote ${Buffer.byteLength(text, 'utf8')} bytes to ${out} — pass it as: agy-review code --facts @${out}`, stderr };
|
|
333
450
|
}
|
|
334
451
|
return { code: 0, stdout: text, stderr };
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Marker-slot injection + reconciliation — the composition root's only mutation of a deployed
|
|
3
|
-
// AGENTS.md. A deployed entry point carries
|
|
3
|
+
// AGENTS.md. A deployed entry point carries THREE reconciled marker slots, each filled LIVE from the
|
|
4
4
|
// installed agent-workflow-engine (the family's one source of truth, no bundled mirror):
|
|
5
5
|
// 1. workflow:methodology — the plan→execute→review pointer (references/methodology-slot.md).
|
|
6
6
|
// 2. workflow:orchestration — the recipe-vocabulary pointer (references/orchestration-slot.md).
|
|
7
|
+
// 3. workflow:autonomy — the autonomy-policy read contract (references/autonomy-slot.md).
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
+
// All share ONE generic marker engine (a slot DESCRIPTOR parameterizes the markers / anchor /
|
|
9
10
|
// empty-slot / leading-blank). The methodology exports (findSlot / injectMethodology / ensureSlot /
|
|
10
11
|
// reconcileSlot / slotNeedsFill / extractSlot) delegate to it byte-for-byte, so the methodology
|
|
11
|
-
// contract is unchanged; the
|
|
12
|
+
// contract is unchanged; the later slots are the SAME engine with different descriptors.
|
|
12
13
|
//
|
|
13
14
|
// Contract per slot, strictly enforced:
|
|
14
15
|
// exactly one ordered start→end pair → replace only the bytes between them;
|
|
@@ -31,6 +32,8 @@ export const START_MARKER = '<!-- workflow:methodology:start -->';
|
|
|
31
32
|
export const END_MARKER = '<!-- workflow:methodology:end -->';
|
|
32
33
|
export const ORCH_START_MARKER = '<!-- workflow:orchestration:start -->';
|
|
33
34
|
export const ORCH_END_MARKER = '<!-- workflow:orchestration:end -->';
|
|
35
|
+
export const AUTONOMY_START_MARKER = '<!-- workflow:autonomy:start -->';
|
|
36
|
+
export const AUTONOMY_END_MARKER = '<!-- workflow:autonomy:end -->';
|
|
34
37
|
export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
|
|
35
38
|
|
|
36
39
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -59,11 +62,16 @@ export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
|
|
|
59
62
|
// The orchestration pair lands right BELOW the methodology end marker — so its anchor is that line.
|
|
60
63
|
// A well-formed entry point carries exactly one methodology end marker → exactly one anchor.
|
|
61
64
|
export const ORCH_ANCHOR = new RegExp(`^.*${escapeRegExp(END_MARKER)}.*$`, 'm');
|
|
65
|
+
// The autonomy pair chains right below the orchestration pair — its anchor is the orchestration end
|
|
66
|
+
// marker line. The anchor can legitimately be ABSENT (a legacy file whose orchestration pair was
|
|
67
|
+
// itself skipped) — the chained CLI classifies that as a soft skip, never a hard STOP (D4).
|
|
68
|
+
export const AUTONOMY_ANCHOR = new RegExp(`^.*${escapeRegExp(ORCH_END_MARKER)}.*$`, 'm');
|
|
62
69
|
|
|
63
70
|
// The canonical empty slots (an ordered start→end pair, nothing between) — what a fresh template
|
|
64
71
|
// ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
|
|
65
72
|
export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
|
|
66
73
|
export const ORCH_EMPTY_SLOT = `${ORCH_START_MARKER}\n${ORCH_END_MARKER}`;
|
|
74
|
+
export const AUTONOMY_EMPTY_SLOT = `${AUTONOMY_START_MARKER}\n${AUTONOMY_END_MARKER}`;
|
|
67
75
|
|
|
68
76
|
// ── known-prior canonical fragments (drift-guarded, APPEND-ONLY per slot) ───────
|
|
69
77
|
// The EXACT content a PREVIOUS release's engine fragment shipped (what a filled slot would carry today).
|
|
@@ -113,6 +121,21 @@ export const ORCHESTRATION_DESCRIPTOR = {
|
|
|
113
121
|
upgradeAdvice:
|
|
114
122
|
'the orchestration-recipes pointer predates the read-at-start clause — add "read `docs/ai/orchestration.json` at session start; set it with /agent-workflow-kit set-recipe", or set your preference now with /agent-workflow-kit set-recipe.',
|
|
115
123
|
};
|
|
124
|
+
// The autonomy-policy slot (AD-044 Plan 3) — first canon, so no known priors yet: any future
|
|
125
|
+
// fragment change must append the outgoing content to a KNOWN_PRIOR_AUTONOMY_SLOT store first.
|
|
126
|
+
export const AUTONOMY_DESCRIPTOR = {
|
|
127
|
+
startMarker: AUTONOMY_START_MARKER,
|
|
128
|
+
endMarker: AUTONOMY_END_MARKER,
|
|
129
|
+
anchor: AUTONOMY_ANCHOR,
|
|
130
|
+
emptySlot: AUTONOMY_EMPTY_SLOT,
|
|
131
|
+
leadingBlank: false,
|
|
132
|
+
markerName: 'autonomy',
|
|
133
|
+
anchorLabel: 'autonomy anchor (the orchestration end-marker line)',
|
|
134
|
+
knownPriorCanonicals: [],
|
|
135
|
+
upgradeSignature: 'docs/ai/autonomy.json',
|
|
136
|
+
upgradeAdvice:
|
|
137
|
+
'the autonomy pointer does not name the per-project policy file — add "read `docs/ai/autonomy.json` at session start; set it with /agent-workflow-kit set-autonomy", or refresh the slot to the current canon.',
|
|
138
|
+
};
|
|
116
139
|
|
|
117
140
|
// ── generic marker-slot engine (descriptor-parameterized) ──────────────────────
|
|
118
141
|
|
|
@@ -283,15 +306,22 @@ export const markerSlotUpgradeHint = (text, descriptor) => {
|
|
|
283
306
|
// stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
|
|
284
307
|
// orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
|
|
285
308
|
const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && errorMessage.includes('(cap ');
|
|
309
|
+
// The autonomy lane distinguishes the TWO cap messages "(cap " conflates (D4 cap-lane honesty):
|
|
310
|
+
// only a FILL-overflow from an in-cap input (injectIntoSlot's "injection would push …") is the soft
|
|
311
|
+
// skip — the pointer was genuinely withheld. An ALREADY-over-cap custom file (reconcileMarkerSlot's
|
|
312
|
+
// "AGENTS.md is N lines … trim the file") keeps its distinct over-cap report as a hard refusal,
|
|
313
|
+
// never a mislabeled "skipped". Exported so the split is pinned against the real messages.
|
|
314
|
+
export const isFillCapRefusal = (errorMessage) =>
|
|
315
|
+
isCapRefusal(errorMessage) && errorMessage.includes('injection would push');
|
|
286
316
|
|
|
287
317
|
const main = async (argv) => {
|
|
288
318
|
const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
|
|
289
319
|
const { dirname, basename, join, resolve } = await import('node:path');
|
|
290
320
|
const { homedir } = await import('node:os');
|
|
291
|
-
const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL } = await import('./engine-source.mjs');
|
|
321
|
+
const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL } = await import('./engine-source.mjs');
|
|
292
322
|
|
|
293
323
|
// `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
|
|
294
|
-
//
|
|
324
|
+
// ALL THREE slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
|
|
295
325
|
const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
|
|
296
326
|
const rest = mode === 'reconcile' ? argv.slice(1) : argv;
|
|
297
327
|
const agentsPath = rest[0];
|
|
@@ -323,36 +353,32 @@ const main = async (argv) => {
|
|
|
323
353
|
}
|
|
324
354
|
};
|
|
325
355
|
|
|
326
|
-
// The orchestration
|
|
327
|
-
// two failures keyed off the engine's own detection (not message text): an
|
|
328
|
-
// simply TOO OLD to ship
|
|
329
|
-
//
|
|
330
|
-
// absent/invalid engine (it cannot supply
|
|
331
|
-
//
|
|
332
|
-
|
|
356
|
+
// The orchestration + autonomy fragments are the CHAINED (less-critical) pointers. Source each
|
|
357
|
+
// lazily, but DISTINGUISH two failures keyed off the engine's own detection (not message text): an
|
|
358
|
+
// engine that is VALID but simply TOO OLD to ship the requested fragment is a SOFT skip — the prior
|
|
359
|
+
// fills are kept and the pointer is reported as withheld (parallel to the cap-skip); only a FULLY
|
|
360
|
+
// absent/invalid engine (it cannot supply ANY pointer) is a hard STOP. A read error on a PRESENT
|
|
361
|
+
// fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current engine with
|
|
362
|
+
// an unreadable fragment). Returns { fragment } on success, { skip } for the soft case, or
|
|
363
|
+
// process.exit(1) for the hard STOP.
|
|
364
|
+
const sourceChainedFragment = async (rel) => {
|
|
333
365
|
const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
|
|
334
366
|
const stop = (err) => {
|
|
335
367
|
console.error(`[inject-methodology] reconcile STOP — ${err.message}`);
|
|
336
368
|
process.exit(1);
|
|
337
369
|
};
|
|
338
|
-
|
|
339
|
-
// PRESENT fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current
|
|
340
|
-
// engine with an unreadable fragment).
|
|
341
|
-
if (detectEngine(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }).ok) {
|
|
370
|
+
if (detectEngine(dir, { source, rel }).ok) {
|
|
342
371
|
try {
|
|
343
|
-
return { fragment: readEngineFragment(dir, { source, rel
|
|
372
|
+
return { fragment: readEngineFragment(dir, { source, rel }) };
|
|
344
373
|
} catch (err) {
|
|
345
374
|
stop(err);
|
|
346
375
|
}
|
|
347
376
|
}
|
|
348
|
-
// The orchestration fragment is NOT a usable file. SOFT-skip ONLY when the engine is otherwise
|
|
349
|
-
// valid (it can still supply the methodology fragment) — a genuine too-old (<1.2.0) engine. A
|
|
350
|
-
// fully absent/invalid engine cannot supply EITHER fragment → hard STOP with the install command.
|
|
351
377
|
if (detectEngine(dir, { source }).ok) {
|
|
352
378
|
return { skip: 'the installed engine is too old to supply it — refresh with `npx @sabaiway/agent-workflow-engine@latest init`' };
|
|
353
379
|
}
|
|
354
380
|
try {
|
|
355
|
-
readEngineFragment(dir, { source, rel
|
|
381
|
+
readEngineFragment(dir, { source, rel }); // throws the canonical install-me error
|
|
356
382
|
} catch (err) {
|
|
357
383
|
stop(err);
|
|
358
384
|
}
|
|
@@ -398,7 +424,7 @@ const main = async (argv) => {
|
|
|
398
424
|
for (const n of notes) console.log(`[inject-methodology] note: ${n}`);
|
|
399
425
|
};
|
|
400
426
|
|
|
401
|
-
// ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration
|
|
427
|
+
// ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration + autonomy reconciles ──
|
|
402
428
|
if (explicitFragmentArg) {
|
|
403
429
|
if (afterMeth === text) {
|
|
404
430
|
console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
|
|
@@ -416,7 +442,7 @@ const main = async (argv) => {
|
|
|
416
442
|
let orchSkipped = false;
|
|
417
443
|
|
|
418
444
|
const orchSource = markerSlotNeedsFill(afterMeth, ORCHESTRATION_DESCRIPTOR)
|
|
419
|
-
? await
|
|
445
|
+
? await sourceChainedFragment(ORCHESTRATION_FRAGMENT_REL)
|
|
420
446
|
: { fragment: '' };
|
|
421
447
|
if (orchSource.skip) {
|
|
422
448
|
// Engine too old to supply the recipes pointer → SOFT skip, parallel to the cap-skip: the
|
|
@@ -453,16 +479,79 @@ const main = async (argv) => {
|
|
|
453
479
|
}
|
|
454
480
|
}
|
|
455
481
|
|
|
456
|
-
// ──
|
|
482
|
+
// ── Slot 3: autonomy, chained on the orchestration-reconciled text (same combined ≤100 guard).
|
|
483
|
+
// D4 invariant: every failure lane SPECIFIC to this slot is a LOUD soft skip that preserves
|
|
484
|
+
// the prior fills and exits 0 — (i) a too-old engine (no autonomy fragment), (ii) a
|
|
485
|
+
// fill-overflow cap refusal, (iii) an ABSENT anchor (the orchestration pair is absent or was
|
|
486
|
+
// itself skipped above — a 0-anchors ensure error must not discard the methodology fill).
|
|
487
|
+
// Malformed autonomy markers, an already-over-cap custom file, a present-but-unreadable
|
|
488
|
+
// fragment, and a fully absent/invalid engine stay hard STOPs. ──
|
|
489
|
+
let describeAut;
|
|
490
|
+
let autSkipped = false;
|
|
491
|
+
const autSlot = findMarkerSlot(finalText, AUTONOMY_DESCRIPTOR);
|
|
492
|
+
if (autSlot.state === 'malformed') {
|
|
493
|
+
// Malformed markers are a hard STOP on EVERY lane — validated before the soft-skip
|
|
494
|
+
// short-circuits so a duplicate/reversed autonomy pair can never ride out as a "skip"
|
|
495
|
+
// alongside a partial (methodology) write.
|
|
496
|
+
console.error(`[inject-methodology] reconcile refused (autonomy) — ${autSlot.reason}`);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
if (orchSkipped) {
|
|
500
|
+
// The chain is CAUSAL, not merely positional: a pointer never lands in a run that withheld
|
|
501
|
+
// the pointer it chains below (cap or too-old) — otherwise a partially-shipped engine could
|
|
502
|
+
// fill autonomy under an unfilled recipes pointer, and a near-cap file could spend its last
|
|
503
|
+
// lines on the less-critical pointer. LOUD soft skip. This branch also subsumes the D4 (iii)
|
|
504
|
+
// anchor-absent lane: after a non-skipped orchestration reconcile the orchestration pair —
|
|
505
|
+
// the autonomy anchor — is present by construction, so orchSkipped is the ONLY route to a
|
|
506
|
+
// missing anchor (a separate anchor check would be dead code; coverage proved it).
|
|
507
|
+
autSkipped = true;
|
|
508
|
+
describeAut =
|
|
509
|
+
'autonomy pointer skipped — chained below the orchestration pointer, which was itself skipped this run; re-run once it lands';
|
|
510
|
+
} else {
|
|
511
|
+
const autSource = markerSlotNeedsFill(finalText, AUTONOMY_DESCRIPTOR)
|
|
512
|
+
? await sourceChainedFragment(AUTONOMY_FRAGMENT_REL)
|
|
513
|
+
: { fragment: '' };
|
|
514
|
+
if (autSource.skip) {
|
|
515
|
+
autSkipped = true;
|
|
516
|
+
describeAut = `autonomy pointer skipped — ${autSource.skip}`;
|
|
517
|
+
} else {
|
|
518
|
+
const autResult = reconcileMarkerSlot(finalText, AUTONOMY_DESCRIPTOR, autSource.fragment, { maxLines: AGENTS_MD_CAP });
|
|
519
|
+
if (autResult.status === 'error') {
|
|
520
|
+
if (isFillCapRefusal(autResult.error)) {
|
|
521
|
+
// SOFT skip — only the genuine fill-overflow withholds the pointer; an already-over-cap
|
|
522
|
+
// custom file keeps its distinct over-cap report via the hard-STOP branch below.
|
|
523
|
+
autSkipped = true;
|
|
524
|
+
describeAut = `autonomy pointer skipped — ${autResult.error}`;
|
|
525
|
+
} else {
|
|
526
|
+
console.error(`[inject-methodology] reconcile refused (autonomy) — ${autResult.error}`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
finalText = autResult.text;
|
|
531
|
+
describeAut = {
|
|
532
|
+
'reconciled-inserted': 'inserted the autonomy pointer below it and filled it',
|
|
533
|
+
'reconciled-filled': 'filled the empty autonomy pointer',
|
|
534
|
+
'reconciled-refreshed': 'refreshed the autonomy pointer to the current canon',
|
|
535
|
+
'present-filled': 'autonomy pointer already present',
|
|
536
|
+
}[autResult.status];
|
|
537
|
+
if (autResult.status === 'present-filled') {
|
|
538
|
+
const u = markerSlotUpgradeHint(finalText, AUTONOMY_DESCRIPTOR);
|
|
539
|
+
if (u) notes.push(u);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── One atomic write of the final (three-slot) text ──
|
|
457
546
|
if (finalText === text) {
|
|
458
|
-
// Byte-unchanged. Still report a
|
|
459
|
-
if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
|
|
460
|
-
else console.log('[inject-methodology] reconcile:
|
|
547
|
+
// Byte-unchanged. Still report a skip (it is not "nothing to do" — a pointer was withheld).
|
|
548
|
+
if (orchSkipped || autSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
|
|
549
|
+
else console.log('[inject-methodology] reconcile: all three pointers already present and filled — nothing to do (zero-diff).');
|
|
461
550
|
reportNotes();
|
|
462
551
|
return;
|
|
463
552
|
}
|
|
464
553
|
await writeAtomic(finalText);
|
|
465
|
-
console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
|
|
554
|
+
console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
|
|
466
555
|
reportNotes();
|
|
467
556
|
return;
|
|
468
557
|
}
|
package/tools/manifest/schema.md
CHANGED
|
@@ -18,6 +18,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
|
|
|
18
18
|
| `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
|
|
19
19
|
| `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
|
|
20
20
|
| `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
|
|
21
|
+
| `networkHosts` | string[] | no | the backend CLI's **observed egress host families** (see *Network hosts*). A malformed list **fails** `--strict`. |
|
|
21
22
|
| `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
|
|
22
23
|
| `cost` / `quota` / `provenance` | misc | no | informational |
|
|
23
24
|
| `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
|
|
@@ -85,6 +86,25 @@ Wrappers never parse JSON at run time: each carries its own shell registry/valid
|
|
|
85
86
|
drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (help section keys,
|
|
86
87
|
`aw_settings_known` registry, `AW_SETTINGS_APPLIED` subset, `aw_settings_valid` typed arms).
|
|
87
88
|
|
|
89
|
+
## Network hosts (AD-044 Plan 4, consult-locked)
|
|
90
|
+
|
|
91
|
+
`networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
|
|
92
|
+
`*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
|
|
93
|
+
`capability.json`) — the **single documentation
|
|
94
|
+
source** for a hand-applied sandbox/network allowlist (session sandbox config, or a hand-pasted
|
|
95
|
+
`sandbox.network.allowedDomains` entry). Rules:
|
|
96
|
+
|
|
97
|
+
- Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
|
|
98
|
+
Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
|
|
99
|
+
into allowlist lines by the Recommendations advisor).
|
|
100
|
+
- **The kit never seeds these into settings** (bridge council 2026-07-11, both backends concur):
|
|
101
|
+
a network pre-allow widens egress for **every** sandboxed command, so running the wrappers
|
|
102
|
+
**outside** the sandbox (`sandbox.excludedCommands`, the `--bridge-tier` wiring) stays the
|
|
103
|
+
primary lane; the hosts list exists for the **hand-apply** fallback under harness-managed
|
|
104
|
+
sandboxes where settings-level exclusions are inert.
|
|
105
|
+
- Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
|
|
106
|
+
hand-applied list by hand; the manifest list records what was actually observed.
|
|
107
|
+
|
|
88
108
|
## Path-field rules (Windows-safe, traversal-safe)
|
|
89
109
|
|
|
90
110
|
- **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
|
|
@@ -63,6 +63,10 @@ export const settingValueValid = (entry, value) => {
|
|
|
63
63
|
const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
|
|
64
64
|
const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
|
|
65
65
|
|
|
66
|
+
// A `networkHosts` entry: a bare dotted hostname, optionally a `*.` family wildcard — never a
|
|
67
|
+
// scheme, a path, a port, or whitespace (the entry is pasted verbatim into an allowlist line).
|
|
68
|
+
export const NETWORK_HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
69
|
+
|
|
66
70
|
// Cross-platform "absolute-like": POSIX root (/x), Windows drive (C:\ or C:/), UNC (\\host),
|
|
67
71
|
// or a leading backslash. node:path.isAbsolute() alone is platform-dependent, so a Windows path
|
|
68
72
|
// would slip through unchecked on a POSIX CI runner — Windows-safety requires rejecting both.
|
|
@@ -313,6 +317,28 @@ export const validateManifest = (skillDir) => {
|
|
|
313
317
|
}
|
|
314
318
|
}
|
|
315
319
|
|
|
320
|
+
// `networkHosts` (AD-044 Plan 4, consult-locked): the backend CLI's OBSERVED egress host
|
|
321
|
+
// families — a DOCUMENTATION source for hand-applied sandbox/network allowlists. The kit never
|
|
322
|
+
// seeds these into settings (exclusion stays primary; a network pre-allow widens egress for
|
|
323
|
+
// EVERY sandboxed command), but a malformed list FAILS --strict like `settings`: the
|
|
324
|
+
// Recommendations advisor renders it verbatim, so a bad entry would corrupt a pasted line.
|
|
325
|
+
const networkHosts = manifest.networkHosts;
|
|
326
|
+
if (networkHosts != null) {
|
|
327
|
+
if (!Array.isArray(networkHosts) || networkHosts.length === 0) {
|
|
328
|
+
errors.push('`networkHosts` must be a non-empty array of host strings');
|
|
329
|
+
} else {
|
|
330
|
+
const seenHosts = new Set();
|
|
331
|
+
networkHosts.forEach((host, i) => {
|
|
332
|
+
if (typeof host !== 'string' || !NETWORK_HOST_RE.test(host)) {
|
|
333
|
+
errors.push(`\`networkHosts[${i}]\` must be a bare hostname or a *.family wildcard (${JSON.stringify(host)})`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (seenHosts.has(host)) errors.push(`duplicate networkHosts entry "${host}" (\`networkHosts[${i}]\`)`);
|
|
337
|
+
else seenHosts.add(host);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
316
342
|
if (!isStub) {
|
|
317
343
|
const auth = readAuthoritativeVersion(skillDir);
|
|
318
344
|
if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
|